Skip to content

Latest commit

 

History

History
1060 lines (779 loc) · 47.6 KB

File metadata and controls

1060 lines (779 loc) · 47.6 KB

十二、回顾我们的进展

在这最后一章中,我们将回顾并比较应用依赖注入DI之后,我们的样本服务现在的状态和质量,以及我们开始时的状态和质量。

我们将看一看我们所做的改进,最后看一看我们的依赖关系图,并将讨论我们在测试覆盖率和服务的可测试性方面的改进。

最后,我们将在本章结束时简要讨论如果我们使用 DI 启动一个新服务,而不是将其应用于现有代码,我们可以做些什么。

本章将介绍以下主题:

  • 改进概述
  • 依赖图综述
  • A review of test coverage and testability
  • 使用 DI 启动新服务

技术要求

第 4 章ACME 注册服务简介中介绍的,熟悉我们服务的代码将是有益的。本章还假设您已经阅读了第 5 章依赖注入和猴子补丁、到第 10 章、*现成注入、*关于各种 DI 方法以及我们在此过程中所做的其他各种改进。

您可能还发现阅读并运行本章代码的完整版本非常有用,可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch12

自述文件中提供了获取代码和配置示例服务的说明,可在中找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在找到我们服务的代码,本章中的更改已经应用 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch12/acme

改进概述

唷,我们成功了。你觉得我们做得怎么样?你认为这些改进值得付出努力吗?让我看看。

要想知道我们已经走了多远,我们应该首先回顾一下我们的起点。

In Chapter 4, Introduction to the ACME Registration Service, we had a small, simple, working service. It got the job done for our users, but it created many inconveniences for those of us that had to maintain and extend it.

全球单身人士

最大的痛苦之一无疑是全球公共单身人士的使用。乍一看,它们似乎使代码更加简洁,但实际上它们使我们更难进行测试。

使用init()函数来创建变量意味着我们要么使用实时版本(即在数据库上),要么必须对全局进行修补,这导致了潜在的数据竞争。

我们从两个公共全局(configlogger和一个私有全局(数据库连接池)开始。在第 5 章依赖注入与 Monkey Patching中,我们使用 Monkey Patching 来测试依赖于数据库连接池单例的代码。

第 10 章现货注入中,我们在第 8 章通过配置进行依赖注入时,首先移除了对config全局的大部分直接访问,最后成功地移除了config全局。

通过删除直接访问和定义本地配置接口,我们能够将模型和数据层与配置完全解耦。这意味着我们的代码是可移植的,如果我们想在另一个应用程序中使用它的话。

也许最重要的是,这意味着在这段代码上编写测试的工作量大大减少,我们的测试都可以独立并发运行。如果没有到全局实例的链接,我们就不必使用 monkey-patch。没有依赖链接,我们只剩下一个更小、更专注的config接口,它更容易模拟、存根和一般理解。

全局logger实例成功地在多次重构中幸存下来,但它唯一被使用的地方是在config加载代码期间。现在,让我们把它移除。我们的config加载函数目前看起来像下面的代码所示:

// Load returns the config loaded from environment
func Load() (*Config, error) {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      err := fmt.Errorf("failed to locate file specified by %s", DefaultEnvVar)
      logging.L.Error(err.Error())
      return nil, err
   }

   cfg, err := load(filename)
   if err != nil {
      logging.L.Error("failed to load config with err %s", err)
      return nil, err
   }

   return cfg, nil
}

可以肯定地说,如果我们无法加载配置,我们的服务将无法工作。因此,我们可以将错误更改为直接写入标准错误。我们更新的函数如下所示:

// Load returns the config loaded from environment
func Load() (*Config, error) {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      err := fmt.Errorf("failed to locate file specified by %s", DefaultEnvVar)
      fmt.Fprintf(os.Stderr, err.Error())
      return nil, err
   }

   cfg, err := load(filename)
   if err != nil {
      fmt.Fprintf(os.Stderr, "failed to load config with err %s", err)
      return nil, err
   }

   return cfg, nil
}

使用配置注入将记录器以其他方式传入。通过使用配置注入,我们能够在不影响构造器的用户体验的情况下忘记常见的关注点(如logger)。我们现在还可以轻松编写测试,验证日志记录,而不存在任何数据争用问题。虽然这样的测试可能会觉得奇怪,但考虑到这些日志是我们系统的输出,当出错时,我们经常依赖它们,我们需要调试。

因此,在某些情况下,尽管将来会进行重构,但确保我们按照预期的方式创建日志并继续这样做是很有用的。这不是我们想要经常测试的东西,但当我们这样做时,测试本身就很简单,如下所示:

func TestLogging(t *testing.T) {
   // build log recorder
   recorder := &LogRecorder{}

   // Call struct that uses a logger
   calculator := &Calculator{
      logger: recorder,
   }
   result := calculator.divide(10, 0)

   // validate expectations, including that the logger was called
   assert.Equal(t, 0, result)
   require.Equal(t, 1, len(recorder.Logs))
   assert.Equal(t, "cannot divide by 0", recorder.Logs[0])
}

type Calculator struct {
   logger Logger
}

func (c *Calculator) divide(dividend int, divisor int) int {
   if divisor == 0 {
      c.logger.Error("cannot divide by 0")
      return 0
   }

   return dividend / divisor
}

// Logger is our standard interface
type Logger interface {
   Error(message string, args ...interface{})
}

// LogRecorder implements Logger interface
type LogRecorder struct {
   Logs []string
}

func (l *LogRecorder) Error(message string, args ...interface{}) {
   // build log message
   logMessage := fmt.Sprintf(message, args...)

   // record log message
   l.Logs = append(l.Logs, logMessage)
}

最后,数据库连接池的全局实例也保持不变;然而,与ConfigLogger不同,它是私有的,因此与之相关的任何风险都有一个有限的范围。事实上,通过在-时间JIT)DI 中使用只是-,我们能够将我们的模型层测试与数据包完全解耦,而不会影响模型层包的 UX。

与配置包的高度耦合

当我们在第 4 章介绍 ACME 注册服务时,我们根本没有使用任何接口,因此,我们所有的包都彼此紧密耦合。因此,我们的包装具有很高的耐变化性;没有比config包更重要的了。这是我们最初的Config结构和全局单例:

// App is the application config
var App *Config

// Config defines the JSON format for the config file
type Config struct {
   // DSN is the data source name (format: https://github.com/go-sql-driver/mysql/#dsn-data-source-name)
   DSN string

   // Address is the IP address and port to bind this rest to
   Address string

   // BasePrice is the price of registration
   BasePrice float64

   // ExchangeRateBaseURL is the server and protocol part of the 
   // URL from which to load the exchange rate
   ExchangeRateBaseURL string

   // ExchangeRateAPIKey is the API for the exchange rate API
   ExchangeRateAPIKey string
}

由于全局单例、缺少接口以及几乎每个包都引用了这个包,我们对Config结构所做的任何更改都有可能破坏一切。类似地,如果我们决定将配置格式从一个平面 JSON 文件更改为一个更复杂的结构,那么我们可能会面临一些非常棘手的问题。

让我们将原来的Config结构与现在的结构进行比较:

// Config defines the JSON format for the config file
type Config struct {
   // DSN is the data source name (format: https://github.com/go-sql-driver/mysql/#dsn-data-source-name)
   DSN string

   // Address is the IP address and port to bind this rest to
   Address string

   // BasePrice is the price of registration
   BasePrice float64

   // ExchangeRateBaseURL is the server and protocol part of the 
   // URL from which to load the exchange rate
   ExchangeRateBaseURL string

   // ExchangeRateAPIKey is the API for the exchange rate API
   ExchangeRateAPIKey string

   // environmental dependencies
   logger logging.Logger
}

// Logger returns a reference to the singleton logger
func (c *Config) Logger() logging.Logger {
   if c.logger == nil {
      c.logger = &logging.LoggerStdOut{}
   }

   return c.logger
}

// RegistrationBasePrice returns the base price for registrations
func (c *Config) RegistrationBasePrice() float64 {
   return c.BasePrice
}

// DataDSN returns the DSN
func (c *Config) DataDSN() string {
   return c.DSN
}

// ExchangeBaseURL returns the Base URL from which we can load 
// exchange rates
func (c *Config) ExchangeBaseURL() string {
   return c.ExchangeRateBaseURL
}

// ExchangeAPIKey returns the DSN
func (c *Config) ExchangeAPIKey() string {
   return c.ExchangeRateAPIKey
}

// BindAddress returns the host and port this service should bind to
func (c *Config) BindAddress() string {
   return c.Address
}

可以看出,我们现在有了更多的代码。然而,额外的代码主要包括实现包的各种配置接口的getter函数。这些getter函数为我们提供了一个间接层,允许我们更改配置的加载和存储方式,而不必影响其他包。

通过在许多包中引入本地Config接口,我们能够将这些包与config包解耦。虽然其他软件包仍然间接使用config软件包,但我们获得了两个好处。首先,它们可以分别进化。第二,所有的包都在本地记录了包的要求,这使得我们在处理包时可以处理的范围更小。当我们使用 mock 和 stub 时,这在测试期间尤其有用。

审查测试覆盖率和可测试性

When we introduced our sample service, we identified several issues related to testing. The first of these issues was the lack of isolation, where tests for one layer were also indirectly testing all the layers below it, as shown in the following code:

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)
}

这是 REST 层中的测试,但因为它调用实际模型,因此调用实际数据层,所以它有效地测试了所有内容。这使得它成为一个合理的集成测试,因为它可以确保各层适当地协同工作。但这是一个糟糕的单元测试,因为层不是孤立的。

我们的单元测试现在如下所示:

func TestGetHandler_ServeHTTP(t *testing.T) {
   scenarios := []struct {
      desc            string
      inRequest       func() *http.Request
      inModelMock     func() *MockGetModel
      expectedStatus  int
      expectedPayload string
   }{
      // scenarios removed
   }

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // define model layer mock
         mockGetModel := scenario.inModelMock()

         // build handler
         handler := NewGetHandler(&testConfig{}, mockGetModel)

         // perform request
         response := httptest.NewRecorder()
         handler.ServeHTTP(response, scenario.inRequest())

         // validate outputs
         require.Equal(t, scenario.expectedStatus, response.Code, scenario.desc)

         payload, _ := ioutil.ReadAll(response.Body)
         assert.Equal(t, scenario.expectedPayload, string(payload), scenario.desc)
      })
   }
}

这个测试被认为是孤立的,因为在我们的例子中,我们不是依赖于其他层,而是依赖于一个抽象,一个名为*MockGetModel的模拟实现。让我们来看看一个典型的模拟实现:

type MockGetModel struct {
   mock.Mock
}

func (_m *MockGetModel) Do(ID int) (*Person, error) {
   outputs := _m.Called(ID)

   if outputs.Get(0) != nil {
      return outputs.Get(0).(*Person), outputs.Error(1)
   }

   return nil, outputs.Error(1)
}

如您所见,模拟实现非常简单;绝对比这个依赖项的实际实现简单。由于这种简单性,我们可以相信它的性能与我们预期的一样,因此,测试中出现的任何问题都将由实际代码而不是模拟代码引起。这种信任可以通过使用代码生成器来进一步加强,例如 Mockery(如第 3 章用户体验编码中介绍的),它生成可靠且一致的代码。

模拟还使我们能够轻松测试其他场景。我们现在对以下各项进行了测试:

  • 快乐之路
  • 请求中缺少 ID
  • 请求中的 ID 无效
  • 依赖项(模型层或以下)失败
  • 请求的记录不存在

如果没有我们所做的更改,许多情况都很难可靠地测试。

既然我们的测试与其他层隔离,那么测试本身的范围就小得多了。这意味着我们需要知道的更少;我们需要知道的只是我们正在测试的层的 API 合同。

在我们的示例中,这意味着我们只需要担心 HTTP 问题,例如从请求中提取数据、输出正确的状态代码以及呈现响应负载。此外,我们正在测试的代码可能失败的方式也减少了。因此,我们最终得到了更少的测试设置、更短的测试和更多的场景覆盖。

与测试相关的第二个问题是重复工作。由于缺乏隔离,我们最初的测试往往有些多余。例如,Get 端点的模型层测试如下所示:

func TestGetter_Do(t *testing.T) {
   // inputs
   ID := 1

   // call method
   getter := &Getter{}
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "John", person.FullName)
}

这看起来不错,但是当我们考虑到这个测试场景已经被我们的 To0t0 包测试覆盖时,我们实际上从这个测试中一无所获。另一方面,让我们看看我们现在进行的几个测试之一:

func TestGetter_Do_noSuchPerson(t *testing.T) {
   // inputs
   ID := 5678

   // configure the mock loader
   mockLoader := &mockMyLoader{}
   mockLoader.On("Load", mock.Anything, ID).Return(nil, data.ErrNotFound).Once()

   // call method
   getter := &Getter{
      data: mockLoader,
   }
   person, err := getter.Do(ID)

   // validate expectations
   require.Equal(t, errPersonNotFound, err)
   assert.Nil(t, person)
   assert.True(t, mockLoader.AssertExpectations(t))
}

这个测试现在是 100%可预测的,因为它不依赖于数据库的当前状态。它不测试数据库,也不测试我们如何与之交互,而是测试我们如何与数据加载器抽象交互。这意味着数据层实现可以自由发展或更改,而无需重新访问和更新测试。此测试还验证,如果我们从数据层收到错误,我们会按照 API 合同的预期适当地转换此错误。

我们仍然在这两个层上进行测试,就像以前一样,但测试并没有给我们带来任何价值,而是带来了显著的价值。

第三,我们在测试时遇到的另一个问题是测试详细性。我们所做的许多更改之一是采用表驱动测试。注册端点的原始服务测试如下所示:

func TestRegisterHandler_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
   validRequest := buildValidRequest()
   response, err := http.Post("http://"+address+"/person/register", "application/json", validRequest)

   // validate outputs
   require.NoError(t, err)
   require.Equal(t, http.StatusCreated, response.StatusCode)
   defer response.Body.Close()

   // call should output the location to the new person
   headerLocation := response.Header.Get("Location")
   assert.Contains(t, headerLocation, "/person/")
}

现在,考虑一下它在下面的代码块中的样子:

func TestRegisterHandler_ServeHTTP(t *testing.T) {
   scenarios := []struct {
      desc           string
      inRequest      func() *http.Request
      inModelMock    func() *MockRegisterModel
      expectedStatus int
      expectedHeader string
   }{
      // scenarios removed
   }

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // define model layer mock
         mockRegisterModel := scenario.inModelMock()

         // build handler
         handler := NewRegisterHandler(mockRegisterModel)

         // perform request
         response := httptest.NewRecorder()
         handler.ServeHTTP(response, scenario.inRequest())

         // validate outputs
         require.Equal(t, scenario.expectedStatus, response.Code)

         // call should output the location to the new person
         resultHeader := response.Header().Get("Location")
         assert.Equal(t, scenario.expectedHeader, resultHeader)

         // validate the mock was used as we expected
         assert.True(t, mockRegisterModel.AssertExpectations(t))
      })
   }
}

我知道你在想什么,考试变得更冗长,而不是更少。是的,这个单独的测试确实如此。然而,在最初的测试中,如果我们要测试另一个场景,第一步应该是复制并粘贴几乎整个测试,留下大约 10 行重复代码,只有该测试场景特有的几行代码。

在我们的表驱动测试风格中,我们有八行共享代码,这些代码在每个场景中都可以执行,并且清晰可见。每个场景都整齐地指定为切片中的一个对象,如下所示:

{
   desc: "Happy Path",
   inRequest: func() *http.Request {
      validRequest := buildValidRegisterRequest()
      request, err := http.NewRequest("POST", "/person/register", validRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // valid downstream configuration
      resultID := 1234
      var resultErr error

      mockRegisterModel := &MockRegisterModel{}
      mockRegisterModel.On("Do", mock.Anything, mock.Anything).Return(resultID, resultErr).Once()

      return mockRegisterModel
   },
   expectedStatus: http.StatusCreated,
   expectedHeader: "/person/1234/",
},

For us to add another scenario, all we have to do is add another item to the slice. This is both very simple, and quite neat and tidy.

最后,如果我们需要对测试进行更改,可能是因为 API 契约发生了更改,那么我们现在只有一个测试需要修复,而不是很多。

我们遇到的第四个问题是对上游服务的依赖。这是我最讨厌的事之一。测试应该是可靠和可预测的,测试失败应该是存在需要修复的问题的绝对指示器。当测试依赖于第三方和互联网连接时,任何事情都可能出错,并且测试可能因任何原因而中断。谢天谢地,在第 8 章通过配置进行依赖注入之后,我们所有的测试,除了面向外部的边界测试,现在都依赖于上游服务的抽象和模拟实现。不仅我们的测试是可靠的,而且我们现在可以很容易地测试我们的错误处理条件,就像我们前面讨论的那样

在以下测试中,我们删除并模拟了对converter包的调用,以测试当我们无法加载货币转换时,我们的注册会发生什么情况:

func TestRegisterer_Do_exchangeError(t *testing.T) {
   // configure the mocks
   mockSaver := &mockMySaver{}
   mockExchanger := &MockExchanger{}
   mockExchanger.
      On("Exchange", mock.Anything, mock.Anything, mock.Anything).
      Return(0.0, errors.New("failed to load conversion")).
      Once()

   // define context and therefore test timeout
   ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
   defer cancel()

   // inputs
   in := &Person{
      FullName: "Chang",
      Phone:    "11122233355",
      Currency: "CNY",
   }

   // call method
   registerer := &Registerer{
      cfg:       &testConfig{},
      exchanger: mockExchanger,
      data:      mockSaver,
   }
   ID, err := registerer.Do(ctx, in)

   // validate expectations
   require.Error(t, err)
   assert.Equal(t, 0, ID)
   assert.True(t, mockSaver.AssertExpectations(t))
   assert.True(t, mockExchanger.AssertExpectations(t))
}

您可能还记得,我们的 exchange 包中仍然有测试。事实上,我们有两种类型。我们有内部面向边界测试调用我们创建的假的 HTTP 服务器。这些测试确保当服务器给出特定响应时,我们的代码会按照预期进行响应,如以下代码段所示:

func TestInternalBoundaryTest(t *testing.T) {
   // start our test server
   server := httptest.NewServer(&happyExchangeRateService{})
   defer server.Close()

   // define the config
   cfg := &testConfig{
      baseURL: server.URL,
      apiKey:  "",
   }

   // create a converter to test
   converter := NewConverter(cfg)
   resultRate, resultErr := converter.Exchange(context.Background(), 100.00, "AUD")

   // validate the result
   assert.Equal(t, 158.79, resultRate)
   assert.NoError(t, resultErr)
}

type happyExchangeRateService struct{}

// ServeHTTP implements http.Handler
func (*happyExchangeRateService) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   payload := []byte(`
{
  "success":true,
  "timestamp":1535250248,
  "base":"EUR",
  "date":"2018-08-26",
  "rates": {
   "AUD":1.587884
  }
}
`)
   response.Write(payload)
}

但我们也有外部面向边界测试,仍然调用上游服务。这些测试帮助我们验证上游服务是否按照我们的需要执行,并与我们的代码保持一致。但是,为了确保我们的测试是可预测的,我们不经常运行外部测试。我们通过在这个文件中添加一个 build 标记来实现这一点,这使我们能够轻松地决定何时包含测试。通常,我只会在出现问题时运行这些测试,或者是为了在构建管道中设置一个只运行这些测试的特殊步骤。然后,我们可以决定在这些测试中出现任何故障后如何继续。

测试覆盖率

先谈一下原始数据,当我们开始时,我们的服务的测试覆盖率如下所示:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  52.94 |    238 |   0.00 |      3 | acme/                             |
|  73.33 |     15 |  73.33 |     15 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  63.33 |     60 |  63.33 |     60 | acme/internal/modules/data/       |
|   0.00 |     38 |   0.00 |     38 | acme/internal/modules/exchange/   |
|  50.00 |      6 |  50.00 |      6 | acme/internal/modules/get/        |
|  25.00 |     12 |  25.00 |     12 | acme/internal/modules/list/       |
|  64.29 |     28 |  64.29 |     28 | acme/internal/modules/register/   |
|  73.61 |     72 |  73.61 |     72 | acme/internal/rest/               |
-------------------------------------------------------------------------

如您所见,测试覆盖率有些低。由于编写测试的困难以及我们无法模拟或存根依赖关系,这并不奇怪。

更改后,我们的测试覆盖率正在提高:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  63.11 |    309 |  30.00 |     20 | acme/                             |
|  28.57 |     28 |  28.57 |     28 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  74.65 |     71 |  74.65 |     71 | acme/internal/modules/data/       |
|  61.70 |     47 |  61.70 |     47 | acme/internal/modules/exchange/   |
|  81.82 |     11 |  81.82 |     11 | acme/internal/modules/get/        |
|  38.10 |     21 |  38.10 |     21 | acme/internal/modules/list/       |
|  75.76 |     33 |  75.76 |     33 | acme/internal/modules/register/   |
|  77.03 |     74 |  77.03 |     74 | acme/internal/rest/               |
-------------------------------------------------------------------------

虽然我们对服务所做的许多更改使其更易于测试,但我们并没有花太多时间添加额外的测试。我们所取得的大部分改进来自于场景覆盖率的提高,主要包括能够测试非愉快路径代码。

如果我们想提高测试覆盖率,找出哪里需要更多测试的最简单方法是使用标准的 go 工具来计算覆盖率并将其显示为 HTML。为此,我们在终端中运行以下命令:

# Change directory to the code for this chapter
$ cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/

# Set the config location
$ export ACME_CONFIG=cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/config.json

# Calculate coverage
$ go test ./acme/ -coverprofile=coverage.out

# Render as HTML
$ go tool cover -html=coverage.out

运行这些命令后,覆盖范围将在默认浏览器中打开。为了找到可以改进的地方,我们扫描了文件,寻找红色代码块。以红色突出显示的代码表示测试期间未执行的行。

删除所有未测试的行是不实际的,特别是当一些错误几乎不可能触发时,关键是检查代码并确定它是否代表了应该测试的场景。

考虑下面的例子(未覆盖的行是粗体的)-我们将更详细地检查它:

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      c.cfg.ExchangeBaseURL(),
      c.cfg.ExchangeAPIKey(),
      currency)

   // perform request
   req, err := http.NewRequest("GET", url, nil)
   if err != nil {
      c.logger().Warn("[exchange] failed to create request. err: %s", err) return nil, err
   }

   // set latency budget for the upstream call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // replace the default context with our custom one
   req = req.WithContext(subCtx)

   // perform the HTTP request
   response, err := http.DefaultClient.Do(req)
   if err != nil {
      c.logger().Warn("[exchange] failed to load. err: %s", err)
 return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
 c.logger().Warn("[exchange] %s", err)
 return nil, err
   }

   return response, nil
}

首先,让我们谈谈以下几行:

if response.StatusCode != http.StatusOK {
   err = fmt.Errorf("request failed with code %d", response.StatusCode)
   c.logger().Warn("[exchange] %s", err)
   return nil, err
}

这些行处理上游服务无法返回 HTTP200(OK)的场景。鉴于互联网和 HTTP 服务的性质,这种情况很有可能发生。因此,我们应该构造一个测试来确保我们的代码能够处理这种情况。

现在,请看以下几行:

req, err := http.NewRequest("GET", url, nil)
if err != nil {
   c.logger().Warn("[exchange] failed to create request. err: %s", err)
   return nil, err
}

你知道http.NewRequest()怎么会失败吗?在对标准库进行深入研究之后,如果我们指定一个有效的 HTTP 方法或者 URL 解析失败,那么它可能会失败。这些都是程序员的错误,也是我们不太可能犯的错误。即使我们真的做了,结果也会很明显,并被现有的测试所捕获。

此外,为这些条件添加测试将是困难的,并且几乎肯定会损害代码的清洁度。

最后,到目前为止,我们的测试缺乏端到端测试。在第 10 章现货注射的末尾,我们添加了少量端到端测试。我们最初使用这些测试来验证 GoogleWire 的性能是否符合我们的预期。从长远来看,它们将有助于保护我们的 API 不受意外回归的影响。对我们服务的公共 API 的更改,无论是 URL、输入还是输出有效负载,都很有可能导致用户代码中断。有时需要进行更改,在这些情况下,这些测试还将提醒我们需要采取其他措施,例如通知用户或对 API 进行版本控制。

Removing the dependence on upstream service

第 6 章依赖项注入和构造器注入中,我们使用构造器注入将我们的模型层与exchange包解耦。您可能还记得,exchange包是对我们的上游货币转换器服务的精简抽象。这不仅确保了我们的模型层测试不再需要上游服务工作才能通过,而且还使我们能够确保充分处理服务失败的情况。

第 8 章中,通过配置添加依赖注入,我们增加了边界测试,通过让我们能够独立于上游服务测试exchange包,进一步消除了我们对上游服务的依赖。在从频繁运行的单元测试中删除对上游服务的所有依赖之后,我们添加了一个面向外部的边界来测试外部服务。然而,我们用一个 build 标签来保护这个测试,使我们能够有选择地偶尔运行它,从而为我们提供保护,使我们免受互联网和上游服务问题的影响。

停止短时间和延迟预算

第 7 章依赖项注入和方法注入中,我们使用方法注入来介绍context包和请求作用域依赖项。通过使用context作为请求范围的依赖项,我们可以实现延迟预算和短停。有了这些,我们就能够在异常系统行为期间减少资源使用。例如,如果(从上游货币转换服务或数据库)检索数据花费的时间太长,客户端不再等待响应,我们可以取消请求并停止任何进一步的处理。

简化的依赖项创建

When we started in Chapter 4Introduction to the ACME Registration Service, our main() function looks rather simple, as shown in the following code:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server := rest.New(config.App.Address)
   server.Listen(ctx.Done())
}

After applying several DI methods to our code, by Chapter 9, Just-in-Time Dependency Injection, our main() function had become the following:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // build the exchanger
   exchanger := exchange.NewConverter(config.App)

   // build model layer
   getModel := get.NewGetter(config.App)
   listModel := list.NewLister(config.App)
   registerModel := register.NewRegisterer(config.App, exchanger)

   // start REST server
   server := rest.New(config.App, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

正如你所看到的,它变得越来越长,越来越复杂。这是关于 DI 的常见投诉。因此,在第 10 章现货注射中,我们通过让 Wire 为我们做这件事来降低成本。这让我们回到了一个非常简洁的main()函数,如下所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server, err := initializeServer()
   if err != nil {
      os.Exit(-1)
   }

   server.Listen(ctx.Done())
}

Similarly, in Chapter 9, Just-in-Time Dependency Injection, we recognized the fact that there would only ever be one live implementation of the data layer, and the only time we would inject anything different was during testing. We, therefore, decided not to make the data layer a constructor parameter, but instead to use JIT injection, as shown in the following code:

// Getter will attempt to load a person.
type Getter struct {
   cfg  Config
   data myLoader
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := g.getLoader().Load(context.TODO(), ID)
   if err != nil {
      if err == data.ErrNotFound {
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// Use JIT DI to lessen the constructor parameters
func (g *Getter) getLoader() myLoader {
   if g.data == nil {
      g.data = data.NewDAO(g.cfg)
   }

   return g.data
}

从这里可以看出,这为我们提供了简化的本地依赖关系创建,而不会影响构造器的用户体验,也不会失去在测试期间模拟数据层的能力。

耦合性和可扩展性

在经历了所有的变化之后,也许我们最重要的胜利是我们的一揽子计划的脱钩。只要有可能,我们的包定义并仅依赖于本地接口。因此,我们的单元测试与其他软件包完全隔离,并验证我们对依赖关系的使用——我们的软件包之间的契约,而不依赖于它们。这意味着,在处理我们的软件包时,需要最少的知识范围。

也许更重要的是,我们可能希望进行的任何更改或扩展都可能包含在单个或少量的包中。例如,如果我们想在我们的上游货币转换服务前面添加一个缓存,那么所有的更改将只对exchange包进行。类似地,如果我们想在另一个服务中重用这个包,我们可以复制或提取它,并在不做任何更改的情况下使用它。

依赖图综述

在本书中,我们使用依赖关系图作为发现潜在问题的方法。以下是我们开始时的样子:

对于只有三个端点的小型服务来说,这有点复杂。从这个图中,我们还注意到有很多箭头指向dataconfiglogging包。

在假设更多的箭头进入或离开包意味着更多的风险、复杂性和耦合的情况下,我们开始尝试减少这些关系。

影响最大的变化是我们采用了配置注入,其中包括本地config接口的定义(如前一节所述)。这删除了进入配置包的所有箭头,除了main()中的箭头,我们无法删除该箭头。

此外,在配置注入工作期间,我们还删除了对全局日志实例的所有引用,并注入了记录器。然而,这并没有改变图表。这是因为我们决定重用该包中定义的Logger接口。

我们本可以在每个包中定义这个接口的一个副本,并删除这个耦合,但考虑到记录器的定义可能不会改变,我们决定不这么做。在任何地方复制接口都会添加代码,除了从图形中删除箭头之外没有任何好处。

在完成所有重构和解耦工作后,我们的依赖关系图如下图所示:

好一些了,但遗憾的是,这里仍然很乱。为了解决这个问题以及我们前面提到的关于日志接口的问题,我还要向您展示一个技巧。

到目前为止,我们一直在使用如下命令生成图形:

$ BASE_PKG=github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/acme
godepgraph -s -o $BASE_PKG $BASE_PKG | dot -Tpng -o depgraph.png

我们可以使用 Godepgraph 的排除功能从图表中删除logging包,将命令更改为以下形式:

$ BASE_PKG=github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/acme
godepgraph -s -o $BASE_PKG -p $BASE_PKG/internal/logging $BASE_PKG | dot -Tpng -o depgraph.png

这最终为我们提供了一个清晰的金字塔形图形,这是我们的目标:

您可能想知道我们是否可以通过删除RESTmodel包(getlistregister之间的链接来进一步展平图表。

我们目前正在将模型代码注入到REST包中;然而,两者之间剩下的一个链接是model包的输出格式。现在让我们来看看这个。

我们的列表模型 API 如下所示:

// Lister will attempt to load all people in the database.
// It can return an error caused by the data layer
type Lister struct {
   cfg  Config
   data myLoader
}

// Exchange will load the people from the data layer
func (l *Lister) Do() ([]*data.Person, error) {
   // code removed
}

我们将返回一个*data.Person类型的切片,这将强制我们在REST包中的本地接口定义如下:

type ListModel interface {
   Do() ([]*data.Person, error)
}

考虑到data.Person数据传输对象DTO),我倾向于实事求是地离开它。当然,我们可以把它移走。要做到这一点,我们需要修改我们的ListModel定义,以期望得到interface{}的一部分,然后定义一个接口,当我们需要使用它时,我们可以将*data.Person投射到该接口中。

这有两个主要问题。首先,只从依赖关系图中删除一行,但会使代码更加混乱,这是大量额外的工作。其次,如果我们的模型层的返回类型与REST包的期望不同,我们将有效地绕过类型系统,并创建一种代码在运行时失败的方法。

使用 DI 启动新服务

在本书中,我们将 DI 应用于现有服务。虽然这是目前为止我们所处的最常见的情况,但有时我们会有幸从头开始一个新项目。

那么,我们可以做些什么不同的事情呢?

用户体验

我们应该做的第一件事就是停下来思考我们试图解决的问题。回到用户体验发现调查(第 3 章用户体验编码)。问问自己以下几点:

  • 谁是我们的用户?
  • 我们的用户想要实现什么?
  • 我们的用户能做什么?
  • 我们的用户希望如何使用我们将要创建的系统?

假设您正在启动 ACME 注册服务,您将如何回答这些问题?

答案可能如下:

  • 谁是我们的用户?-此服务的用户将是负责注册前端的移动应用程序和 web 开发人员。
  • 我们的用户想要实现什么?-他们希望能够创建、查看和管理注册。
  • 我们的用户能做什么?-他们熟悉调用基于 HTTP 的 REST 服务。他们熟悉传入和使用 JSON 编码的数据。
  • 我们的用户希望如何使用我们将要创建的系统?-鉴于他们对 JSON 和 REST 的熟悉,他们希望通过 HTTP 请求完成所有事情。有了第一组最明显的用户,我们可以转移到第二个最重要的组:开发团队。
  • 谁是我们代码的用户?-我和其他开发团队成员。
  • 我们的用户想要实现什么?-我们希望建立一个快速、可靠、易于管理和扩展的系统。
  • 我们的用户能做什么?-我们还熟悉 HTTP、REST 和 JSON。我们也熟悉 MySQL 和 Go。我们也对多种形式的 DI 感到满意。
  • 我们的用户希望如何使用我们将要创建的代码?-我们希望使用 DI 来确保我们的代码松散耦合,并且易于测试和维护。

您可以看到,通过考虑我们的用户,我们已经开始概述我们的服务。我们已经确定,如果两个用户都熟悉 HTTP、JSON 和 REST,那么这是通信的最佳选择。考虑到开发人员对 Go 和 MySQL 的熟悉程度,这些将是实现技术的最佳选择。

代码结构

Armed with the framework provided by getting to know our users, we are ready to think about implementation and code structure.

鉴于我们正在进行独立服务,我们将需要一个main()功能。在那之后,我经常添加的下一个东西是一个直接位于main()下的internal文件夹。这在该服务的代码和同一存储库中的任何代码之间添加了一个干净的边界

当您发布一个包或 SDK 供其他人使用时,这是一种确保内部实现包不会泄漏到公共 API 中的简单方法。如果您的团队碰巧在一个存储库中使用了 mono repo 或多个服务,那么这是确保您不会与其他团队发生包名冲突的好方法。

The layers we had in our original service were relatively normal, so can reuse them here. These layers are shown in the following diagram:

使用这组特定的层的主要优点是,每个层代表处理请求时所需的不同方面。REST层只处理与 HTTP 相关的关注点;具体来说,从请求中提取数据并呈现响应。业务逻辑层是业务逻辑所在的层。它还倾向于包含与调用外部服务和数据层相关的协调逻辑。外部服务和数据将处理与数据库等外部服务和系统的交互。

正如您所看到的,每个层都有一个完全独立的职责和透视图。任何系统级别的更改,例如更改数据库或从 JSON 更改为其他内容,都可以在一个层中完全处理,并且不会对其他层造成任何更改。层之间的依赖关系契约将被定义为接口,这就是我们不仅利用 DI,而且利用模拟和存根进行测试的方式。

随着服务的增长,我们的层可能由许多小包组成,而不是每层一个大包。这些小软件包将导出它们自己的公共 API,以便层中的其他软件包可以使用它们。然而,这确实会恶化层的封装。让我们看一个例子。

假设我们的数据库存在性能问题,希望添加缓存,以便减少对它的调用次数。它可能类似于以下代码所示:

// DAO is a data access object that provides an abstraction over our 
// database interactions.
type DAO struct {
   cfg Config

   db    *sql.DB
   cache *cache.Cache
}

// Load will attempt to load and return a person.
// It will return ErrNotFound when the requested person does not exist.
// Any other errors returned are caused by the underlying database or 
// our connection to it.
func (d *DAO) Load(ctx context.Context, ID int) (*Person, error) {
   // load from cache
   out := d.loadFromCache(ID)
   if out != nil {
      return out, nil
   }

   // load from database
   row := d.db.QueryRowContext(ctx, sqlLoadByID, ID)

   // retrieve columns and populate the person object
   out, err := populatePerson(row.Scan)
   if err != nil {
      if err == sql.ErrNoRows {
         d.cfg.Logger().Warn("failed to load requested person '%d'. err: %s", ID, err)
         return nil, ErrNotFound
      }

      d.cfg.Logger().Error("failed to convert query result. err: %s", err)
      return nil, err
   }

   // save person into the cache
   d.saveToCache(ID, out)

   return out, nil
}

但是,业务逻辑层不需要看到该缓存的存在。我们可以通过在data文件夹下添加另一个internal文件夹来确保数据层的封装不会泄漏cache包。

这种改变似乎没有必要,对于小项目来说,这是一个很好的论据。但随着项目的发展,增加一个额外的internal文件夹所需的少量成本将得到回报,并确保我们的封装永远不会泄漏。

Cross-cutting concerns

我们已经看到,可以用许多不同的方式处理横切关注点,例如日志记录和配置。明智的做法是事先决定一项战略,并让您的团队对此达成一致。猴子补丁、构造器注入、配置注入和 JIT 注入都是传递或访问配置和日志单例的可能方式。选择完全取决于您和您的喜好。

由外而内的设计

从项目一开始就应用 DI 的一个好处是,它使我们能够推迟决策,直到我们得到更好的信息才能做出决策。

例如,在决定实现 HTTP REST 服务之后,我们可以继续设计端点。在设计 Get 端点时,我们可以这样描述它:

get 端点返回 JSON 格式的 person 对象,格式为{“id”:1,“name”:“John”,“phone”:“0123456789”,“currency”:“USD”,“price”:100}

您可能会注意到,这只描述了用户需要什么,而没有指定数据来自何处。然后,我们可以对端点进行编码,以实现这个确切的目标。甚至可能看起来像这样,从第 10 章现货注射

type GetHandler struct {
   getter GetModel
}

// ServeHTTP implements http.Handler
func (h *GetHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract person id from request
   id, err := h.extractID(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // attempt get
   person, err := h.getter.Do(id)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusNotFound)
      return
   }

   // happy path
   err = h.writeJSON(response, person)
   if err != nil {
      // this error should not happen but if it does there is nothing we
      // can do to recover
      response.WriteHeader(http.StatusInternalServerError)
   }
}

由于GetModel是一个本地定义的抽象,它也没有描述数据存储的位置或方式。

同样的过程也可以应用于我们在业务逻辑层中对GetModel的实现。它不需要知道如何调用它,也不需要知道数据存储在哪里,只需要知道它需要协调流程,并将数据层的任何响应转换为 REST 层所期望的格式。

At each step of the way, the scope of the problem is small. The interactions with layers below depend on abstractions and the implementations of each layer is straightforward.

当一个函数的所有层都实现时,我们可以使用 DI 将其连接在一起。

总结

在本章中,我们检查了应用 DI 后样本服务的状态和质量,并将其与原始状态进行了对比,从而提醒自己为什么要进行更改,以及我们从中获得了什么。

我们最后看了一下依赖关系图,从视觉上了解了我们如何将包解耦。

我们还看到,我们的示例服务非常容易测试,而且在进行更改后,我们的测试更加集中。

At the end of the chapter, we also discussed how to approach starting a new service and how DI can help with that endeavor too.

至此,我们已经完成了围棋 DI 的考试。感谢您抽出时间阅读本书,我希望您已经发现它既实用又有用。

快乐编码!

问题

  1. 对我们的样品服务最重要的改进是什么?
  2. 在我们的依赖关系图中,为什么数据包不在main下?
  3. 如果你开始一项新的服务,你会有什么不同的做法?