Skip to content

Files

Latest commit

00d6687 · Oct 25, 2021

History

History
880 lines (642 loc) · 34.1 KB

File metadata and controls

880 lines (642 loc) · 34.1 KB

六、依赖注入与构造器注入

在研究了依赖注入DI)的一种最独特的形式,即猴子补丁之后,在本章中,我们将其推向另一个极端,看看最正常的或传统的构造器注入。

虽然构造器注入是如此普遍,以至于您甚至可能在没有意识到的情况下使用它,但它有许多微妙之处,特别是关于优缺点,值得检验。

与前一章类似,我们将把这项技术应用到我们的示例服务中,在那里我们将获得显著的改进。

本章将介绍以下主题:

  • Constructor injection
  • 构造器注入的优点
  • 应用构造器注入
  • 构造器注入的缺点

技术要求

It would be beneficial to be familiar with the code for our service that we introduced in Chapter 4, Introduction to the ACME Registration Service . 

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

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

您可以在ch06/acme中找到我们服务的代码,其中已经应用了本章的更改。

构造器注入

当对象需要依赖项才能工作时,确保依赖项始终可用的最简单方法是要求所有用户将其作为参数提供给对象的构造器。这就是所谓的构造器注入

让我们看一个例子,在这个例子中,我们将提取一个依赖项,对其进行泛化,并实现构造器注入。假设我们正在为一个在线社区建立一个网站。对于本网站,我们希望在新用户注册时向他们发送电子邮件。此操作的代码可能如下所示:

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.mailer.Send(to, body)
}

我们将*Mailer设置为私有,以确保类内部的正确封装。我们可以通过将*Mailer依赖项定义为构造器的参数来注入它,如下代码所示:

func NewWelcomeSender(in *Mailer) (*WelcomeSender, error) {
   // guard clause
   if in == nil {
      return nil, errors.New("programmer error: mailer must not provided")
   }

   return &WelcomeSender{
      mailer: in,
   }, nil
}

在上一个示例中,我们包含了一个 guard 子句。这样做的目的是确保提供的依赖项不是nil。这是没有必要的,是否包括主要取决于个人风格;这样做完全可以接受:

func NewWelcomeSenderNoGuard(in *Mailer) *WelcomeSender {
   return &WelcomeSender{
      mailer: in,
   }
}

你可能会觉得我们已经完蛋了。毕竟,我们正在将依赖性Mailer注入WelcomeSender

遗憾的是,我们还没有完全做到这一点。事实上,我们正在失去 DI 的真正目的。不,这不是测试,尽管我们会做的。DI 的真正目的是解耦。

此时,如果没有Mailer实例,我们的WelcomeSender将无法工作。它们是紧密耦合的。因此,让我们通过应用第 2 章Go实体设计原则中的依赖项反转原则部分来将它们解耦

首先,让我们看看Mailer结构:

// Mailer sends and receives emails
type Mailer struct{
   Host string
   Port string
   Username string
   Password string
}

func (m *Mailer) Send(to string, body string) error {
   // send email
   return nil
}

func (m *Mailer) Receive(address string) (string, error) {
   // receive email
   return "", nil
}

我们可以通过将其转换为基于方法签名的接口来引入抽象:

// Mailer sends and receives emails
type MailerInterface interface {
   Send(to string, body string) error
   Receive(address string) (string, error)
}

等等,我们只需要发电子邮件。让我们应用接口分离原则,将接口简化为我们使用的方法,并更新构造器。现在,我们有:

type Sender interface {
   Send(to string, body string) error
}

func NewWelcomeSenderV2(in Sender) *WelcomeSenderV2 {
   return &WelcomeSenderV2{
      sender: in,
   }
}

有了这个小小的变化,一些方便的事情发生了。首先,我们的代码现在是完全独立的。这意味着任何 bug、扩展、测试或其他更改都只涉及此包。其次,我们可以使用 mock 或 stub 来测试我们的代码,阻止我们向自己发送垃圾邮件,并要求有一个工作的电子邮件服务器才能通过测试。最后,我们不再被Mailer类束缚。如果我们想从欢迎电子邮件更改为短信或推特,我们可以将输入参数更改为不同的Sender并完成

通过将依赖项定义为抽象(作为本地接口)并将该依赖项传递给构造器,我们明确定义了需求,并在测试和扩展中给予了我们更大的自由。

向房间里的鸭子致辞

在深入研究构造器注入之前,我们应该花点时间讨论 duck 类型。

我们之前提到过 Go 对隐式接口的支持,以及如何利用它来执行依赖项反转和解耦对象。对于那些熟悉 Python 或 Ruby 的人来说,这可能感觉像鸭子打字。对于其他人来说,什么是 duck 类型?具体描述如下:

如果它看起来像一只鸭子,而且它像鸭子一样嘎嘎叫,那么它就是一只鸭子

或者,更严格地说:

在运行时,仅基于对象中被访问的部分动态确定对象的适用性

Let's look at a Go example to see if it supports duck typing:

type Talker interface {
   Speak() string
   Shout() string
}

type Dog struct{}

func (d Dog) Speak() string {
   return "Woof!"
}

func (d Dog) Shout() string {
   return "WOOF!"
}

func SpeakExample() {
   var talker Talker
   talker = Dog{}

   fmt.Print(talker.Speak())
}

正如您所看到的,我们的Dog类型没有声明它实现了Talker接口,正如我们从 Java 或 C#中所期望的那样,但我们可以将其用作Talker

从我们的示例来看,Go 可能支持 duck 类型,但存在两个问题:

  • 在 duck 类型中,兼容性是在运行时确定的;Go 将在编译时检查我们的Dog类型实现Talker
  • 在 duck 类型中,适用性仅基于所访问对象的部分。在前面的示例中,实际上只使用了Speak()方法。但是,如果我们的Dog类型没有实现Shout()方法,那么它将无法编译。

如果不是鸭子打字,那是什么?类似的东西被称为结构类型。结构类型是一种静态类型系统,它根据类型的结构确定编译时的适用性。不要让不那么花哨的名字欺骗你;结构类型是非常强大和非常有用的。Go 提供了编译时检查的安全性,而无需显式地声明所实现的接口。

构造器注入的优点

对于许多程序员和编程语言来说,构造器注入是 DI 的默认方法。因此,它有许多优点,这也许并不奇怪。

与依赖项生命周期的分离-构造器注入与大多数 DI 方法一样,将依赖项的生命周期管理与注入的对象分离。通过这样做,对象变得更直观、更容易理解。

易于实现-正如我们在前面的示例中所看到的,很容易做到这一点:

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   Mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.Mailer.Send(to, body)
}

并将其更改为:

func NewWelcomeSender(mailer *Mailer) *WelcomeSender {
   return &WelcomeSender{
      mailer: mailer,
   }
}

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.mailer.Send(to, body)
}

可预测且简洁-通过将依赖项的分配转移到构造器,我们不仅明确了我们的需求,而且还确保了我们的方法可以设置并使用依赖项。如果我们在构造器中包含一个 guard 子句,这一点尤其正确。如果没有构造器,每个方法可能都必须包含一个 guard 子句(如下面的示例所示),或者有引发 nil 指针异常的风险:

type Car struct {
   Engine Engine
}

func (c *Car) Drive() error {
   if c.Engine == nil {
      return errors.New("engine ie missing")
   }

   // use the engine
   c.Engine.Start()
   c.Engine.IncreasePower()

   return nil
}

func (c *Car) Stop() error {
   if c.Engine == nil {

      return errors.New("engine ie missing")
   }

   // use the engine
   c.Engine.DecreasePower()
   c.Engine.Stop()

   return nil
}

而不是更简洁的以下内容:

func NewCar(engine Engine) (*Car, error) {
  if engine == nil {
    return nil, errors.New("invalid engine supplied")
  }

  return &Car{
    engine: engine,
  }, nil
}

type Car struct {
   engine Engine
}

func (c *Car) Drive() error {
   // use the engine
   c.engine.Start()
   c.engine.IncreasePower()

   return nil
}

func (c *Car) Stop() error {
   // use the engine
   c.engine.DecreasePower()
   c.engine.Stop()

   return nil
}

通过扩展,方法还可以假设我们的依赖项在访问依赖项时处于良好的就绪状态,从而消除了在构造器之外处理初始化延迟或配置问题的需要。此外,没有与访问依赖项相关的数据争用。在施工过程中设置,从未更改。

封装-构造器注入提供了关于对象如何使用依赖关系的高度封装。考虑一下,如果我们通过添加一个 AUT1 T1 方法来扩展前面的例子,会发生什么,如下面的代码所示:

func (c *Car) FillPetrolTank() error {
   // use the engine
   if c.engine.IsRunning() {
      return errors.New("cannot fill the tank while the engine is running")
   }

   // fill the tank!
   return c.fill()
}

如果我们假设给油箱加油Engine无关,并且在调用此方法之前没有填充,那么前面的代码会发生什么情况?

如果没有构造器注入来确保我们提供了一个Engine,这个方法将崩溃,并导致一个 nil 指针异常。或者,该方法可以在没有构造器注入的情况下编写,如以下代码所示:

func (c *Car) FillPetrolTank(engine Engine) error {
   // use the engine
   if engine.IsRunning() {
      return errors.New("cannot fill the tank while the engine is running")
   }

   // fill the tank!
   return c.fill()
}

然而,这个版本现在泄露了该方法需要Engine才能工作的实现细节。

有助于发现代码气味-在现有结构或接口中添加只是一个简单的陷阱。正如我们在前面讨论单一责任原则时所看到的,我们应该抵制这种冲动,并将我们的对象和接口保持尽可能小。当一个对象有太多的责任时,一个简单的方法是计算它的依赖项。通常,一个对象的职责越多,它积累的依赖性就越多。因此,当所有依赖项都清楚地列在一个地方,即构造器中时,很容易发现某些东西可能不太正确。

提高测试场景覆盖率

我们要做的第一件事是在测试中打破对上游货币服务的依赖。然后,我们将继续添加测试,以覆盖以前无法覆盖的其他场景。这就是我们目前的测试结果:

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

我们目前正在启动整个 HTTP 服务器;这似乎太过分了,所以让我们将测试范围缩小到RegisterHandler

测试范围的减少还将通过消除其他外围问题(如 HTTP 路由器)来改进测试。

正如我们所知,我们将要测试多个类似的场景,让我们首先为表驱动测试添加一个框架:

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

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // test goes here
      })
   }
}

从最初的测试中,我们可以看到我们的输入是一个*http.Request*MockRegisterModel。两者的创建和配置都有点复杂,因此我们选择使用函数来构建它们。另外,从最初的测试中,我们可以看到测试的输出是 HTTP 响应代码和Location头。

这四个对象,*http.Request*MockRegistrationModel、HTTP 状态代码和Location头将构成我们测试场景的配置,如前面的代码所示。

为了完成表驱动测试,我们将原始测试的内容复制到测试循环中,并替换输入和输出,如下代码所示:

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

      // build handler
      handler := &RegisterHandler{
         registerer: 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))
   })
}

现在我们已经准备好了所有的部分,我们编写测试场景,从快乐之路开始:

{
   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).Return(resultID, resultErr).Once()

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

接下来,我们需要测试代码是否能够很好地处理错误。那么,我们可以期待什么样的错误呢?我们可以检查代码并查找类似于if err != nil的代码。这可能是一条有用的捷径,但请考虑一下。如果我们的测试反映了当前的实现,那么当实现发生变化时会发生什么?

一个更好的角度是考虑不是实现而是特征本身以及它的情况或使用。有两个答案几乎总是适用的。用户错误,例如输入错误,以及依赖项返回的错误。

我们的用户错误场景如下代码所示:

{
   desc: "Bad Input / User Error",
   inRequest: func() *http.Request {
      invalidRequest := bytes.NewBufferString(`this is not valid JSON`)
      request, err := http.NewRequest("POST", "/person/register", invalidRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // Dependency should not be called
      mockRegisterModel := &MockRegisterModel{}
      return mockRegisterModel
   },
   expectedStatus: http.StatusBadRequest,
   expectedHeader: "",
},

依赖项返回的错误如下代码所示:

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

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // call to the dependency failed
      resultErr := errors.New("something failed")

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

      return mockRegisterModel
   },
   expectedStatus: http.StatusInternalServerError,
   expectedHeader: "",
},

有了这三个测试,我们就有了合理的测试场景覆盖率,但我们遇到了一个问题。我们从依赖项场景返回的错误导致 HTTP 状态代码为400(错误请求),而不是预期的 HTTP500(内部服务器错误)。在研究了模型层的实现之后,很明显,400错误是故意的,应该表明请求不完整,因此验证失败。

我们的第一反应可能是将验证转移到 HTTP 层。但是考虑一下:如果我们添加另一种服务器类型,比如 GRPC,会发生什么?仍然需要执行此验证。那么,我们如何区分用户错误和系统错误呢

另一个选项是从模型中返回一个命名错误用于验证错误,并为其他错误返回一个不同的命名错误。单独检测和处理响应将很容易。然而,这将导致我们的代码与model包保持紧密耦合。

另一种选择是将我们对模型包的调用分为两个调用,可能是Validate()Do(),但这会降低我们model包的用户体验。我会让你决定这些或其他选择是否适合你。

在对RegisterHandler和该包中的其他处理程序进行这些更改后,我们可以使用 Go 的测试覆盖率工具查看是否遗漏了任何明显的场景。

对于 Unix/Linux 用户,我已经在本章的源代码中包含了脚本,用于生成 HTML 中的覆盖范围。这些步骤应该与其他平台类似。该脚本可在找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/blob/master/ch06/pcov-html

Please note, the test coverage percentage is not significant here. The critical thing to look at is what code has not been executed by any tests and decide whether that indicates an error that could reasonably occur and therefore a scenario that we need to add.

现在我们的RegisterHandler状态好了很多,我们可以用同样的方式将构造器注入应用到REST包中的其他处理程序。

这些更改的结果可以在本章的源代码中看到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch06/acme/internal/rest

应用构造器注入

让我们将构造器注入应用于 ACME 注册服务。这次我们将重构 REST 包,从Register端点开始。您可能还记得,Register是我们服务的三个端点之一,其他端点是GetList。 该Register端点有三项职责:

  • 验证注册是否完整有效
  • 调用货币转换服务将注册价格转换为注册中请求的货币
  • 将注册和转换后的注册价格保存到数据库中

我们的Register端点的代码当前看起来如以下代码所示:

// RegisterHandler is the HTTP handler for the "Register" endpoint
// In this simplified example we are assuming all possible errors 
// are user errors and returning "bad request" HTTP 400.
// There are some programmer errors possible but hopefully these 
// will be caught in testing.
type RegisterHandler struct {
}

// ServeHTTP implements http.Handler
func (h *RegisterHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract payload from request
   requestPayload, err := h.extractPayload(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

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

   // happy path
   response.Header().Add("Location", fmt.Sprintf("/person/%d/", id))
   response.WriteHeader(http.StatusCreated)
}

// extract payload from request
func (h *RegisterHandler) extractPayload(request *http.Request) (*registerRequest, error) {
   requestPayload := &registerRequest{}

   decoder := json.NewDecoder(request.Body)
   err := decoder.Decode(requestPayload)
   if err != nil {
      return nil, err
   }

   return requestPayload, nil
}

// call the logic layer
func (h *RegisterHandler) register(requestPayload *registerRequest) (int, error) {
   person := &data.Person{
      FullName: requestPayload.FullName,
      Phone:    requestPayload.Phone,
      Currency: requestPayload.Currency,
   }

   registerer := &register.Registerer{}
   return registerer.Do(person)
}

令人失望的是,我们目前只对这个函数进行了一次测试,而且很容易出错。它要求数据库和我们的下游汇率服务都可以访问和配置。

虽然我们可以确保本地数据库正常工作,并且对其进行的任何更改都不会影响除我们以外的任何人,但下游汇率服务是在互联网上的,并且是有汇率限制的。我们无法控制它或它何时起作用。

这意味着,尽管我们只有一个测试,但该测试运行和维护起来可能会很麻烦,因为它随时可能因我们无法控制的原因而中断。

幸运的是,我们不仅可以删除这些依赖项,而且还可以使用 mock 来创建其他情况下无法创建的情况。例如,使用 mock,我们可以测试错误处理代码,以确定汇率服务何时关闭或超出配额。

与依赖性脱钩

第一步是确定我们希望注入的依赖关系。对于我们的处理程序,这不是数据库或汇率调用。我们希望注入下一个软件层,在本例中是模型层。

具体来说,我们想从我们的register方法中注入这一行:

registerer := &register.Registerer{}

按照我们更容易使用的相同过程,我们首先将对象升级为成员变量,如下代码所示:

// RegisterHandler is the HTTP handler for the "Register" endpoint
type RegisterHandler struct {
   registerer *register.Registerer
}

由于这没有将代码与依赖项解耦,因此我们将需求定义为本地接口并更新成员变量,如以下代码所示:

// RegisterModel will validate and save a registration
type RegisterModel interface {
   Do(in *data.Person) (int, error)
}

// RegisterHandler is the HTTP handler for the "Register" endpoint
type RegisterHandler struct {
   registerer RegisterModel
}

构建构造器

既然RegisterHandler需要一个抽象依赖项,我们需要确保通过应用构造器注入来设置依赖项,如下代码所示:

// NewRegisterHandler is the constructor for RegisterHandler
func NewRegisterHandler(model RegisterModel) *RegisterHandler {
   return &RegisterHandler{
      registerer: model,
   }
}

通过应用构造器注入,我们的RegisterHandler与模型层和外部资源(数据库和上游服务)的耦合更少。我们可以利用这种更松散的耦合来改进和扩展我们的RegisterHandler测试。

使用依赖关系图验证我们的改进

在我们结束REST包的工作之前,让我们先评估一下我们从哪里开始,以及我们现在在哪里。当我们开始时,我们的处理程序与它们匹配的model包紧密耦合,并且测试很差。这两个问题都已得到解决。

让我们看看依赖关系图是否显示出任何改善的迹象:

可悲的是,它看起来仍然和以前一样。在深入研究代码后,我们找到了罪魁祸首:

// New will create and initialize the server
func New(address string) *Server {
   return &Server{
      address:         address,
      handlerGet:      NewGetHandler(&get.Getter{}),
      handlerList:     NewListHandler(&list.Lister{}),
      handlerNotFound: notFoundHandler,
      handlerRegister: NewRegisterHandler(&register.Registerer{}),
   }
}

We are instantiating our model layer objects inside the constructor for our Server (part of the REST package). The fix is easy and hopefully obvious. We push the dependencies up one level, as shown in the following code:

// New will create and initialize the server
func New(address string,
   getModel GetModel,
   listModel ListModel,
   registerModel RegisterModel) *Server {

   return &Server{
      address:         address,
      handlerGet:      NewGetHandler(getModel),
      handlerList:     NewListHandler(listModel),
      handlerNotFound: notFoundHandler,
      handlerRegister: NewRegisterHandler(registerModel),
   }
}

再次检查我们的依赖关系图,它现在终于显示了一些改进:

正如你所看到的,这是一种恭维;REST包不依赖于模块层(即listgetregister包)。

仍然有太多的依赖于dataconfig包,但我们将在后面的章节中讨论。

构造器注入的缺点

当谈到 DI 时,遗憾的是没有银弹。尽管构造器注入很有用,但它不能用于所有情况。本节介绍构造器注入的缺点和限制。

可能导致大量更改-当对现有代码应用构造器注入时,可能导致大量更改。如果代码最初是作为函数编写的,则尤其如此。

考虑下面的代码:

// Dealer will shuffle a deck of cards and deal them to the players
func DealCards() (player1 []Card, player2 []Card) {
   // create a new deck of cards
   cards := newDeck()

   // shuffle the cards
   shuffler := &myShuffler{}
   shuffler.Shuffle(cards)

   // deal
   player1 = append(player1, cards[0])
   player2 = append(player2, cards[1])

   player1 = append(player1, cards[2])
   player2 = append(player2, cards[3])
   return
}

As we saw in the previous section, to convert this to use constructor injection, we will have to do the following:

  • 从函数转换为结构
  • 通过定义接口,将对*myShuffler的依赖关系转换为抽象的东西
  • Create a constructor
  • 更新函数的所有当前用法以使用构造器并注入依赖项

在所有的变化中,最令人关注的是最后一个。在本地(即在同一个包中)发生的更改更容易进行,因此风险更低,但是对外部包的更改,特别是属于另一个团队的代码的更改,要危险得多。

除了非常小心之外,降低风险的最佳方法是测试。如果代码在重构之前很少或没有测试,那么在开始任何重构之前先创建一些测试是有益的。

使用 monkey 补丁的 DI 可能是替换这些测试中的任何依赖项的一个有吸引力的候选者。是的,在更改为构造器注入后,这些测试需要重构或删除,但这并没有错。进行测试将确保代码在重构之前工作,并且这些测试将在重构期间继续提供信息。或者换一种说法,这些测试将有助于使重构更加安全。

可能导致初始化问题-在讨论构造器注入的优点时,我们提到将对象与其依赖项的生命周期分离。这种代码和复杂性仍然存在,它们只是被推到调用图的更高位置。虽然能够单独处理这些问题无疑是一个优势,但它确实会产生第二个问题:对象初始化顺序。考虑我们的 ACME 注册服务。它有三个层:表示层、模型层和数据层。

在表示层工作之前,我们需要一个工作模型层。 在模型层工作之前,我们需要有一个工作的数据层。 在数据层正常工作之前,我们必须创建一个数据库连接池

对于一个简单的服务,这已经变得有些复杂了。这种复杂性导致了许多 DI 框架的产生,我们将在第 10 章现成的注入中研究一个这样的框架,即 Google 的 Wire。

这里的另一个潜在问题是在应用程序启动时创建的对象数量过大。虽然这确实会导致应用程序启动稍慢,但一旦支付了初始成本,应用程序将不再因依赖项创建而延迟。

The last initialization issue to consider here is debugging.  When the creation of a dependency and its users are in the same part of the code, it is easier to understand and debug their life cycles and relationships.

过度使用的危险-鉴于这项技术非常容易理解和使用,因此也很容易过度使用。过度使用的最明显迹象是构造器参数过多。过多的构造器参数可能表明对象有太多的责任,但也可能是提取和抽象太多依赖项的症状。

在提取依赖项之前,请考虑封装。此对象的用户需要了解哪些信息?我们可以隐藏的与实现相关的信息越多,重构的灵活性就越大。

另一个需要考虑的方面是:是否需要提取依赖关系,或者可以将其留给配置吗?考虑下面的代码:

// FetchRates rates from downstream service
type FetchRates struct{}

func (f *FetchRates) Fetch() ([]Rate, error) {
   // build the URL from which to fetch the rates
   url := downstreamServer + "/rates"

   // build request
   request, err := http.NewRequest("GET", url, nil)
   if err != nil {
      return nil, err
   }

   // fetch rates
   response, err := http.DefaultClient.Do(request)
   if err != nil {
      return nil, err
   }
   defer response.Body.Close()

   // read the content of the response
   data, err := ioutil.ReadAll(response.Body)
   if err != nil {
      return nil, err
   }

   // convert JSON bytes to Go structs
   out := &downstreamResponse{}
   err = json.Unmarshal(data, out)
   if err != nil {
      return nil, err
   }

   return out.Rates, nil
}

可以提取并注入*http.Client,但这真的有必要吗?事实上,唯一真正需要更改的方面是基本 URI。我们将在第 8 章通过配置进行依赖注入中进一步探讨这种方法。

非明显要求-在 Go 中使用构造器不是必需的模式。在一些团队中,这甚至不是一个标准模式。因此,用户甚至可能没有意识到构造器存在,并且他们必须使用它。如果没有注入依赖项,代码很可能会崩溃,这不太可能导致生产问题,但可能有点烦人。

一些团队试图通过将对象设为私有对象并仅导出构造器和接口来解决此问题,如以下代码所示:

// NewClient creates and initialises the client
func NewClient(service DepService) Client {
   return &clientImpl{
      service: service,
   }
}

// Client is the exported API
type Client interface {
   DoSomethingUseful() (bool, error)
}

// implement Client
type clientImpl struct {
   service DepService
}

func (c *clientImpl) DoSomethingUseful() (bool, error) {
   // this function does something useful
   return false, errors.New("not implemented")
}

这种方法确实确保了构造器的使用,但它确实有一些成本。 首先,我们现在必须保持接口和结构的同步。不难,但这是额外的工作,可能会变得烦人。

其次,有些用户倾向于使用该界面,而不是在本地定义自己的界面。这将导致用户和导出的界面之间的紧密耦合。这种耦合会使添加导出的 API 变得更加困难。

Consider using the previous example in another package, as shown in the following code:

package other

// StubClient is a stub implementation of sdk.Client interface
type StubClient struct{}

// DoSomethingUseful implements sdk.Client
func (s *StubClient) DoSomethingUseful() (bool, error) {
   return true, nil
}

现在,如果我们在Client接口中添加另一个方法,前面提到的代码将被破坏。

构造器不是继承的-与方法和方法注入不同,我们将在下一章中讨论,构造器在执行合成时不包括在内;相反,我们需要记住构造器的存在并使用它们。

执行构图时要考虑的另一个因素是,必须将内部结构的构造器的任何参数添加到外部结构的构造器中,如下面的代码所示:

type InnerService struct {
   innerDep Dependency
}

func NewInnerService(innerDep Dependency) *InnerService {
   return &InnerService{
      innerDep: innerDep,
   }
}

type OuterService struct {
   // composition
   innerService *InnerService

   outerDep Dependency
}

func NewOuterService(outerDep Dependency, innerDep Dependency) *OuterService {
   return &OuterService{
      innerService: NewInnerService(innerDep),
      outerDep:     outerDep,
   }
}

A relationship like the preceding one would severely discourage us from changing InnerService because we would be forced to make matching changes to OuterService.

总结

在本章中,我们研究了具有构造器注入的 DI。我们已经看到它是多么容易理解和应用。这就是为什么在许多情况下,它是许多程序员的默认选择。

我们已经了解了构造器注入如何为对象及其依赖项之间的关系带来一定程度的可预测性,特别是当我们使用 guard 子句时。

通过将构造器注入应用到我们的REST包中,我们得到了一组松散耦合且易于跟踪的对象。因此,我们能够轻松地扩展测试场景的覆盖范围。我们还可以预期,现在对模型层的任何后续更改都不太可能过度影响我们的REST包。

在下一章中,我们将介绍使用方法注入的 DI,这是处理可选依赖项的一种非常方便的方法。

问题

  1. 我们采用构造器注入的步骤是什么?
  2. 什么是保护条款?什么时候使用?
  3. 构造器注入如何影响依赖项的生命周期?
  4. 构造器注入的理想用例是什么?