在本章中,我们将介绍一个称为ACME 注册服务的小而假的服务。此服务的代码将作为本书其余部分中大多数示例的基础。我们将检查此服务所处的业务环境,讨论服务和代码的目标,最后,我们将看一些我们可以通过应用依赖项注入(DI修复的问题示例。
在本章结束时,您应该有足够的知识加入我们的团队,我们将在接下来的章节中进行改进。
本章将介绍以下主题:
- 我们系统的目标
- 我们的制度简介
- 已知问题
在我们学习本书其余部分将要使用的系统时,我强烈建议下载源代码并在您喜欢的 IDE 中运行它。
本章中的所有代码可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch04 。
有关如何获取代码和配置示例服务的说明,请参见自述文件https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ 。
您可以在ch04/acme
文件中找到服务的代码
你有没有试过用种子种植自己的蔬菜?这是一段漫长、缓慢但令人满意的经历。构建伟大的代码也不例外。在园艺中,跳过第一步,从苗圃购买幼苗可能更常见,编程也差不多。大多数时候,当我们加入一个项目时,代码已经存在;有时它是快乐和健康的,但往往是生病和死亡。
在这种情况下,我们正在采用一种制度。它是有效的,但是有一些刺好吧,也许不止几个。通过一些温柔的关爱,我们将把这个系统变成健康和繁荣的东西。
那么,我们如何定义一个健康的系统呢?我们现有的制度有效;它做企业需要它做的事情。够了,对吗?
绝对不是!我们可能会为提供一定数量的功能而获得明确的报酬,但我们会为提供可以维护和扩展的代码而获得隐含的报酬。除了考虑为什么我们得到报酬,让我们采取一个更自私的观点:你希望你的工作比今天更容易还是更难?
健康的代码库具有以下关键特性:
- 高可读性
- 高测试性
- 低耦合
我们在第 1 部分中已经讨论或暗示了所有这些问题,但它们的重要性意味着我们将再次讨论它们。
简单地说,高可读性意味着能够阅读并理解代码。不可读的代码会使您的速度变慢,并可能导致错误,您认为它做了一件事,但实际上它做了另一件事。
让我们看一个示例,如下代码所示:
type House struct {
a string
b int
t int
p float64
}
在本例中,代码的命名有问题。短变量名似乎是一种胜利;少打字意味着少工作,对吗?从短期来看,是的,但从长远来看,它们很难理解。您必须读取代码以确定变量的含义,然后在该上下文中重新读取代码,而一个好的名称可以让我们从第一步开始。这也并不意味着超长名称是正确的;他们还增加了精神负担,浪费了不动产。一个好的变量通常是一个词,具有普遍理解的含义或目的。
有两种情况不应遵循上述原则。首先是方法。也许是因为我使用 C++和 java 的时间,以及 GO 中缺少一个 TyrT0p 操作符,但是我发现短方法接收器是有用的,可能是因为它们在整个结构中是一致的,并且只有短变量才区别于其他所有方法。
第二种情况是当我们使用测试名称时。测试本质上是小故事;在这种情况下,长名称通常是完全合适的。注释也会起作用,但效果较差,因为测试运行者在测试失败时输出测试名称,而不是注释。
让我们根据这些想法更新前面的示例,看看是否更好,如以下代码所示:
type House struct {
address string
bedrooms int
toilets int
price float64
}
有关可读性的更多信息,请返回到第 3 章用户体验编码中的人性化优化部分。
编写自动化测试可能感觉像是额外的工作,这会占用我们编写特性的真正目的的时间。事实上,自动化测试的主要目标是确保代码按预期执行,并且即使我们可能对整个代码库进行任何更改或添加,也会继续这样做。然而,自动化测试确实有成本:您必须编写和维护它们。因此,如果我们的代码易于测试,我们就不太倾向于略过测试而匆忙使用令人兴奋的下一个特性。
让我们看一个示例,如以下代码所示:
func longMethod(resp http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
resp.WriteHeader(http.StatusPreconditionFailed)
return
}
userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
if err != nil {
resp.WriteHeader(http.StatusPreconditionFailed)
return
}
row := DB.QueryRow("SELECT * FROM Users WHERE userID = ?", userID)
person := &Person{}
err = row.Scan(person.ID, person.Name, person.Phone)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(resp)
err = encoder.Encode(person)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
}
那么这个例子有什么问题?最简单的答案是它知道的太多,或者如果我更自私,它会让我知道太多。
它包含边界层(HTTP 和数据库)逻辑,还包含业务逻辑。它相当长,这意味着我必须在头脑中保持更多的上下文。这基本上是对单一责任原则(SRP的一次大规模违反。它可能会改变的原因有很多。输入格式可能会更改。数据库格式可能会更改。商业规则可能会改变。任何此类更改都意味着此代码的每个测试可能也需要更改。让我们看看前面代码的测试可能是什么样子,如以下代码所示:
func TestLongMethod_happyPath(t *testing.T) {
// build request
request := &http.Request{}
request.PostForm = url.Values{}
request.PostForm.Add("UserID", "123")
// mock the database
var mockDB sqlmock.Sqlmock
var err error
DB, mockDB, err = sqlmock.New()
require.NoError(t, err)
mockDB.ExpectQuery("SELECT .* FROM people WHERE ID = ?").
WithArgs(123).
WillReturnRows(
sqlmock.NewRows(
[]string{"ID", "Name", "Phone"}).
AddRow(123, "May", "0123456789"))
// build response
response := httptest.NewRecorder()
// call method
longMethod(response, request)
// validate response
require.Equal(t, http.StatusOK, response.Code)
// validate the JSON
responseBytes, err := ioutil.ReadAll(response.Body)
require.NoError(t, err)
expectedJSON := `{"ID":123,"Name":"May","Phone":"0123456789"}` + "\n"
assert.Equal(t, expectedJSON, string(responseBytes))
}
正如您所看到的,这个测试冗长而笨拙。也许最糟糕的是,此方法的任何其他测试都将涉及复制此测试并进行微小更改。这听起来很有效,但有两个问题。在所有这些样板代码中很难发现细微的差异,我们正在测试的特性的任何更改也需要对所有这些测试进行更改。
虽然有许多方法可以修复示例的可测试性,但最简单的方法可能是分离不同的关注点,然后一次测试一个方法,如以下代码所示:
func shortMethods(resp http.ResponseWriter, req *http.Request) {
userID, err := extractUserID(req)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
person, err := loadPerson(userID)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
outputPerson(resp, person)
}
func extractUserID(req *http.Request) (int64, error) {
err := req.ParseForm()
if err != nil {
return 0, err
}
return strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
}
func loadPerson(userID int64) (*Person, error) {
row := DB.QueryRow("SELECT * FROM people WHERE ID = ?", userID)
person := &Person{}
err := row.Scan(&person.ID, &person.Name, &person.Phone)
if err != nil {
return nil, err
}
return person, nil
}
func outputPerson(resp http.ResponseWriter, person *Person) {
encoder := json.NewEncoder(resp)
err := encoder.Encode(person)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
}
有关单元测试可以为您做什么的更多信息,请返回到第 3 章用户体验编码中的一个名为单元测试的安全毯部分。
耦合是一种度量对象或包与其他对象或包之间的关系的方法。如果对象的更改可能会导致其他对象的更改,或者反之亦然,则认为该对象具有高度耦合。相反,当一个对象具有低耦合时,它独立于其他对象或包。在 Go 中,低耦合最好通过隐式接口和稳定且最小的导出 API 来实现。
低耦合是可取的,因为它会导致代码发生局部更改。在以下示例中,通过使用隐式接口定义我们的需求,我们能够将自己与依赖关系的更改隔离开来:
从前面的示例中可以看到,我们不再依赖 FileManager 包,这在其他方面对我们有所帮助。这种缺乏依赖性也意味着我们在阅读代码时要记住的上下文更少,在编写测试时要记住的依赖性更少。
有关如何实现低耦合的更多信息,请返回到第 2 章、Go实体设计原则中涵盖的实体原则。
到现在为止,你可能看到了一种模式。所有这些目标都将导致代码易于阅读、理解、测试和扩展,也就是说,代码是可维护的。虽然这些似乎是自私或完美主义的目标,但我认为从长远来看,这对企业来说是必要的。在短期内,向用户提供价值(通常以功能的形式)至关重要。但如果这项工作做得不好,那么添加功能的速度、添加功能所需的程序员数量以及由于更改而引入的 bug 数量都将增加,并且业务成本将超过开发好代码的成本。
既然我们已经定义了我们服务的目标,让我们看看它的当前状态。
欢迎来到这个项目!那么你需要知道什么才能加入这个团队呢?与任何项目一样,您想知道的第一件事是它做什么、它的用户以及它部署的业务环境。
我们正在使用的系统是一个基于 HTTP 的事件注册服务。它是为我们的 web 应用程序或本机移动应用程序而设计的。下图显示了它如何融入我们的网络:
目前,有三个端点,如下所示:
- 注册:创建新的注册记录
- 获取:返回现有注册记录的全部详细信息
- 列表:返回所有注册的列表
所有请求和响应有效负载都是 JSON 格式的。数据存储在 MySQL 数据库中。
我们还有一个上游货币转换服务,我们在注册期间调用该服务,将 100 欧元的注册价格转换为用户要求的货币。
如果您希望在本地运行服务或测试,请参考ch04/README.md
文件了解说明。
从概念上讲,我们的代码有三层,如下图所示:
这些层如下所示:
- REST:此包接受 HTTP 请求并将其转换为业务逻辑中的函数调用。然后,它将业务逻辑响应转换回 HTTP。
- 商业逻辑:这就是奇迹发生的地方。该层使用外部服务和数据层来执行业务功能。
- 外部服务和数据:该层由访问数据库的代码和提供货币汇率的上游服务组成。
我在本节开头概念上使用了一词,因为我们的导入图显示了一个稍微不同的故事:
正如您所看到的,我们有一个带有配置和日志包的准第四层,更糟糕的是,一切似乎都依赖于它们。这可能会在今后的某个时候给我们带来问题。
这里显示了一个不太明显的问题。查看 rest 和数据包之间的链接?这表明我们的 HTTP 层依赖于数据层。这是有风险的,因为它们有不同的生命周期和不同的改变原因。在下一节中,我们将看到这一点和其他一些令人不快的惊喜。
每一个系统都有它的骨架,这是我们不自豪的部分代码。有时,它们是代码的一部分,如果我们有更多的时间,我们会做得更好。这个项目也不例外。让我们检查一下我们目前了解的问题。
尽管是一个小型的、有效的服务,但我们有很多问题,其中最令人震惊的可能是它的测试难度。现在,我们不想开始引入测试引起的损害,但我们确实希望有一个我们有信心的系统。为了实现这一点,我们需要降低测试的复杂性和冗长性。请看以下测试:
func TestGetHandler_ServeHTTP(t *testing.T) {
// ensure the test always fails by giving it a timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Create and start a server
// With out current implementation, we cannot test this handler
// without a full server as we need the mux.
address, err := startServer(ctx)
require.NoError(t, err)
// build inputs
response, err := http.Get("http://" + address + "/person/1/")
// validate outputs
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
expectedPayload := []byte(`{"id":1,"name":"John","phone":"0123456780","currency":"USD","price":100}` + "\n")
payload, _ := ioutil.ReadAll(response.Body)
defer response.Body.Close()
assert.Equal(t, expectedPayload, payload)
}
这个测试是针对我们最直接的终点Get
。扪心自问,这个测试怎么可能打破?技术或业务相关的哪些更改会导致此测试需要更新?系统的哪些部分必须正常工作才能通过此测试?
这些问题的一些可能答案包括:
- 如果 URL 路径更改,此测试将中断
- 如果输出格式更改,此测试将中断
- 如果
config
文件配置不正确,此测试将中断 - 如果数据库不工作,此测试将中断
- 如果数据库中缺少记录 ID 1,则此测试将中断
- 如果业务逻辑层有一个 bug,这个测试就会中断
- 如果数据库层有 bug,这个测试就会中断
对于一个简单的端点来说,这个列表相当糟糕。事实上,这个测试可以在很多方面破坏,这意味着它是一个脆弱的测试。脆性测试维护起来很费劲,写起来也很费劲。
让我们检查一下业务层中Get
端点的测试,如下代码所示:
func TestGetter_Do(t *testing.T) {
// inputs
ID := 1
name := "John"
// call method
getter := &Getter{}
person, err := getter.Do(ID)
// validate expectations
require.NoError(t, err)
assert.Equal(t, ID, person.ID)
assert.Equal(t, name, person.FullName)
}
此测试与上一节中的测试几乎相同。也许这是合乎逻辑的,因为它是同一个端点。但是,让我们用一个自私的观点来看,除了更好的单元测试覆盖率之外,这个测试给我们带来了什么?
没有什么因为之前的测试实际上是一个集成测试,所以它测试了整个堆栈。这个测试也是一个集成测试,但只需要一层。因为它测试了前面示例测试过的代码,所以我们完成了双倍的工作,需要维护的测试量增加了一倍,但什么都没有得到。
前面代码中显示的缺乏隔离是层之间高度耦合的一个症状。在下一节中,我们将应用 DI 和依赖项反转原则(DIP来解决这个问题。
我们的REST
包正在使用data
包中定义的Person
结构。从表面上看,这是有道理的。更少的代码意味着编写和维护代码的工作量更少;但是,这意味着输出格式和数据格式相互关联。考虑一下,如果我们开始存储与客户相关的私人信息,比如密码或 IP 地址会发生什么。某些函数可能需要此信息,但不太可能需要通过Get
或List
端点发布此信息。
我们还应考虑另一个问题。随着存储的数据量或使用量的增加,可能需要更改数据的格式。对该结构的任何此类更改都将破坏 API 契约,因此也将破坏我们的用户。
也许这里最重要的风险仅仅是人为错误;如果您正在处理data
包,您可能不记得REST
包使用了该结构,或者是如何使用的。假设我们增加了用户登录系统的功能。最直接的实现是向数据库中添加密码字段。如果我们的Get
端点正在构建其输出,如下面的代码所示,会发生什么?
// output the supplied person as JSON
func (h *GetHandler) writeJSON(writer io.Writer, person *data.Person) error {
return json.NewEncoder(writer).Encode(person)
}
我们的Get
端点负载现在将包括密码。哎呀!
这个问题是一个 SRP 冲突,解决这个问题的方法是确保这两个用例是解耦的,并且允许单独发展。
正如我们在依赖关系图中看到的,几乎所有东西都依赖于config
包。这主要是因为代码直接引用公共全局变量来配置自身。第一个问题是它如何影响测试。现在,所有的测试几乎都确保配置全局在运行之前已经正确初始化。因为所有的测试都使用相同的全局变量,我们不得不在不更改配置(这会妨碍我们的测试能力)和串行运行测试(这会浪费我们的时间)之间做出选择。
让我们看一个示例,如以下代码所示:
// bind stop channel to context
ctx := context.Background()
// start REST server
server := rest.New(config.App.Address)
server.Listen(ctx.Done())
在这段代码中,我们启动 REST 服务器并将要绑定的地址(主机和端口)传递给它。如果我们决定要启动多台服务器来单独测试不同的东西,那么我们必须更改config.App.Address
中存储的值。然而,在一个测试中这样做,我们可能会意外地影响另一个测试。
第二个问题并不经常出现,但这种耦合也意味着该代码不能被其他项目、包或超出其原始意图的用例轻松使用。
最后一个问题可能是最烦人的:由于循环依赖性问题,您无法在配置中使用Config
包之外定义的自定义数据类型。
考虑下面的代码:
// Currency is a custom type; used for convenience and code readability
type Currency string
// UnmarshalJSON implements json.Unmarshaler
func (c *Currency) UnmarshalJSON(in []byte) error {
var s string
err := json.Unmarshal(in, &s)
if err != nil {
return err
}
currency, valid := validCurrencies[s]
if !valid {
return fmt.Errorf("'%s' is not a valid currency", s)
}
*c = currency
return nil
}
假设您的配置包含以下内容:
type Config struct {
DefaultCurrency currency.Currency `json:"default_currency"`
}
在这种情况下,任何试图在与Currency
类型相同的包中使用配置包的行为都将被阻止。
exchange 包对汇率的外部服务进行 HTTP 调用。当前,当测试运行时,它将调用该服务。这意味着我们的测试具有以下特点:
- 他们需要互联网连接
- 它们依赖于下游服务的可访问性和正常工作
- 它们需要下游服务提供适当的凭据和配额
所有这些因素要么不受我们的控制,要么与我们的服务完全无关。如果我们认为测试的可靠性是我们工作质量的衡量标准,那么我们的质量现在取决于我们无法控制的事情。这远非理想。
我们可以创建一个伪货币服务,并将配置更改为指向该服务,在测试 exchange 包时,我可能会这样做。但在其他地方这样做很烦人,而且容易出错。
在本章中,我们介绍了一个非常粗糙的小服务。在探索许多 DI 技术的同时,我们将通过一系列重构来改进这项服务。在下面的章节中,我们将通过应用 Go 中可用的不同 DI 技术来解决本章中概述的问题。
对于每一种不同的技术,请记住代码气味、坚实的原则、代码 UX 以及我们在第 1 部分中讨论的所有其他想法。另外,记得带上你内心的怀疑者。
总是问自己,这项技术能实现什么?这种技术如何使代码更好/更差?您如何应用此技术来改进属于您的其他代码?
- 为我们的服务定义的目标中,哪一个对您个人最重要?
- 概述的问题中,哪一个似乎是最紧迫或最重要的?