Skip to content

Latest commit

 

History

History
808 lines (584 loc) · 32.1 KB

File metadata and controls

808 lines (584 loc) · 32.1 KB

九、即时依赖注入

通过传统依赖注入DI方法),父类或调用对象将依赖提供给子类。但是,在许多情况下,依赖项只有一个实现。在这些情况下,一种实用的方法是问自己,为什么要注入依赖关系?在本章中,我们将研究即时JIT)依赖注入,这一策略在不向构造器或方法添加参数的情况下,为我们提供了 DI 的许多好处,如解耦和可测试性。

The following topics will be covered in this chapter:

  • 即时注射
  • JIT 注入的优点
  • 应用 JIT 注入
  • JIT 注入的缺点

技术要求

熟悉我们在第 4 章ACME 注册服务简介中介绍的我们服务的代码将是有益的。本章还假设您已经阅读了第 6 章依赖注入和构造器注入,以及第 5 章依赖注入和猴子补丁

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

有关获取代码和配置示例服务的说明,请参见此处的自述部分:https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

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

在本章中,我们将使用嘲弄(https://github.com/vektra/mockery 生成我们接口的模拟实现,并引入一个名为包覆盖率的新工具 https://github.com/corsc/go-tools/tree/master/package-coverage)

即时注射

您是否曾经编写过一个对象并注入了一个依赖项,而您知道该依赖项只有一个实现?可能您已经将数据库处理代码注入了业务逻辑层,如以下代码所示:

func NewMyLoadPersonLogic(ds DataSource) *MyLoadPersonLogic {
   return &MyLoadPersonLogic{
      dataSource: ds,
   }
}

type MyLoadPersonLogic struct {
   dataSource DataSource
}

// Load person by supplied ID
func (m *MyLoadPersonLogic) Load(ID int) (Person, error) {
   return m.dataSource.Load(ID)
}

您是否曾经在构造器中添加依赖项,只是为了在测试期间模拟它?这在以下代码中显示:

func NewLoadPersonHandler(logic LoadPersonLogic) *LoadPersonHandler {
   return &LoadPersonHandler{
      businessLogic: logic,
   }
}

type LoadPersonHandler struct {
   businessLogic LoadPersonLogic
}

func (h *LoadPersonHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   requestedID, err := h.extractInputFromRequest(request)

   output, err := h.businessLogic.Load(requestedID)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   h.writeOutput(response, output)
}

这类事情会让人感觉像是不必要的额外工作,而且它们肯定会降低代码的用户体验。JIT 注入为我们提供了一个舒适的中间地带。通过一些例子,也许可以最好地解释 JIT 注入。让我们来看一下 JIT 注入的第一个例子:

type MyLoadPersonLogicJIT struct {
   dataSource DataSourceJIT
}

// Load person by supplied ID
func (m *MyLoadPersonLogicJIT) Load(ID int) (Person, error) {
   return m.getDataSource().Load(ID)
}

func (m *MyLoadPersonLogicJIT) getDataSource() DataSourceJIT {
   if m.dataSource == nil {
      m.dataSource = NewMyDataSourceJIT()
   }

   return m.dataSource
}

如您所见,我们通过添加一个getter函数getDataSource()将直接引用从m.dataSource更改为m.getDataSource()。在getDataSource()中,我们正在执行一项简单而有效的检查,以查看依赖项是否已经存在,如果不存在,则创建依赖项。这就是我们的名字即时注射

那么,如果我们不打算注入依赖性,那么为什么我们需要注入呢?简单的答案是测试。

In our original example, we were able to swap out our dependency with a mock implementation during testing, as shown in the following code:

func TestMyLoadPersonLogic(t *testing.T) {
   // setup the mock db
   mockDB := &mockDB{
      out: Person{Name: "Fred"},
   }

   // call the object we are testing
   testObj := NewMyLoadPersonLogic(mockDB)
   result, resultErr := testObj.Load(123)

   // validate expectations
   assert.Equal(t, Person{Name: "Fred"}, result)
   assert.Nil(t, resultErr)
}

通过 JIT 注入,我们仍然可以提供一个模拟实现,但是我们不通过构造器提供它,而是直接将其注入私有成员变量,如下所示:

func TestMyLoadPersonLogicJIT(t *testing.T) {
   // setup the mock db
   mockDB := &mockDB{
      out: Person{Name: "Fred"},
   }

   // call the object we are testing
   testObj := MyLoadPersonLogicJIT{
      dataSource: mockDB,
   }
   result, resultErr := testObj.Load(123)

   // validate expectations
   assert.Equal(t, Person{Name: "Fred"}, result)
   assert.Nil(t, resultErr)
}

您可能还注意到,在这个示例中,我们放弃了构造器的使用。这不是必需的,也不会总是如此。应用 JIT 注入通过减少参数数量来提高对象的可用性。在我们的示例中,没有留下任何参数,因此删除构造器似乎也很合适。

JIT 注入允许我们改变 DI 的传统规则,让对象能够在需要时创建自己的依赖项。严格来说,这违反了单一责任原则部分,正如第 2 章Go实体设计原则中所述,可用性方面的改进是显著的。

Advantages of JIT injection

该方法旨在解决传统 DI 的一些难点。这里列出的优点是这种方法特有的,与其他形式的依赖项注入不同。此方法特有的好处包括以下几点。

由于更少的输入而带来更好的用户体验(UX)——我知道我已经提出了很多这一点,但更容易理解的代码也更容易维护和扩展。当一个函数的参数较少时,它本身就更容易理解。比较构造器:

func NewGenerator(storage Storage, renderer Renderer, template io.Reader) *Generator {
   return &Generator{
      storage:  storage,
      renderer: renderer,
      template: template,
   }
}

With this one:

func NewGenerator(template io.Reader) *Generator {
   return &Generator{
      template: template,
   }
}

在本例中,我们删除了只有一个活动实现的所有依赖项,并用 JIT 注入替换它们。现在,这个函数的用户只需要提供一个可以更改的依赖项。

它非常适合于可选依赖项-与前面关于 UX 的观点类似,可选依赖项可能会增加函数的参数列表。此外,依赖项是否是可选的还不是很明显。将依赖项移动到公共成员变量允许用户仅在需要时提供它。然后,应用 JIT 注入允许对象实例化默认依赖项的副本。这大大简化了对象内部的代码。

考虑下面的代码,它不使用 JIT 注入:

func (l *LoaderWithoutJIT) Load(ID int) (*Animal, error) {
   var output *Animal
   var err error

   // attempt to load from cache
   if l.OptionalCache != nil {
      output = l.OptionalCache.Get(ID)
      if output != nil {
         // return cached value
         return output, nil
      }
   }

   // load from data store
   output, err = l.datastore.Load(ID)
   if err != nil {
      return nil, err
   }

   // cache the loaded value
   if l.OptionalCache != nil {
      l.OptionalCache.Put(ID, output)
   }

   // output the result
   return output, nil
}

应用 JIT 注入,这将变成以下内容:

func (l *LoaderWithJIT) Load(ID int) (*Animal, error) {
   // attempt to load from cache
   output := l.cache().Get(ID)
   if output != nil {
      // return cached value
      return output, nil
   }

   // load from data store
   output, err := l.datastore.Load(ID)
   if err != nil {
      return nil, err
   }

   // cache the loaded value
   l.cache().Put(ID, output)

   // output the result
   return output, nil
}

该函数现在更加简洁易读。在下一节中,我们将更多地讨论使用 JIT 注入和可选依赖项。

更好地封装实现细节-典型 DI(即构造器或参数注入)的一个反参数是,通过暴露一个对象对另一个对象的依赖,您正在泄漏实现细节。考虑下面的构造器:

func NewLoader(ds Datastore, cache Cache) *MyLoader {
   return &MyLoader{
      ds:    ds,
      cache: cache,
   }
}

现在,将自己置于MyLoader用户的位置,而不知道其实现。MyLoader使用数据库或缓存对您重要吗?如果您没有多个实现或配置可供使用,那么让MyLoader的作者为您处理会更容易吗?

减少试验引起的损坏-另一个针对 DI 的常见投诉是,将依赖项添加到构造器中的唯一目的是在试验期间替换它们。这一立场是有根据的;这是你经常会看到的,也是测试引起的损伤的一种更常见的形式。JIT 注入通过更改与私有成员变量的关系并将其从公共 API 中删除来缓解这种情况。这仍然允许我们在测试期间替换依赖项,但不会造成公共损害。

如果您想知道,选择私有成员变量而不是公共变量是有意的,也是有意的限制。由于是私有的,我们只能在同一个包内的测试期间访问和替换依赖项。包外的测试有意不允许访问。第一个原因是封装。我们希望对其他包隐藏实现细节,以便它们不会与我们的包耦合。任何这样的耦合都会使我们更难对实现进行更改。 第二个原因是 API 污染。如果我们公开了成员变量,那么不仅测试人员可以访问它,每个人都可以访问它,从而打开了意外、无效或危险地使用内部变量的可能性。

这是 monkey patching的一个很好的替代方案——正如您在第 5 章依赖注入和 monkey patching中所记得的,monkey patching 最重要的问题之一是测试期间的并发性。通过修补单个全局变量以适应当前测试,使用该变量的任何其他测试都将受到影响,并可能被破坏。可以使用 JIT 注入来避免这个问题。考虑下面的代码:

// Global singleton of connections to our data store
var storage UserStorage

type Saver struct {
}

func (s *Saver) Do(in *User) error {
   err := s.validate(in)
   if err != nil {
      return err
   }

   return storage.Save(in)
}

按原样,在测试期间,全局变量存储将需要进行修补。但看看当我们应用 JIT 注入时会发生什么:

// Global singleton of connections to our data store
var storage UserStorage

type Saver struct {
   storage UserStorage
}

func (s *Saver) Do(in *User) error {
   err := s.validate(in)
   if err != nil {
      return err
   }

   return s.getStorage().Save(in)
}

// Just-in-time DI
func (s *Saver) getStorage() UserStorage {
   if s.storage == nil {
      s.storage = storage
   }

   return s.storage
}

With all access to the global variable now going via getStorage(), we are able to use JIT injection to swap out the storage member variable instead of monkey patching the global (and shared) variable, as seen in this example:

func TestSaver_Do(t *testing.T) {
   // input
   carol := &User{
      Name:     "Carol",
      Password: "IamKing",
   }

   // mocks/stubs
   stubStorage := &StubUserStorage{}

   // do call
   saver := &Saver{
      storage: stubStorage,
   }
   resultErr := saver.Do(carol)

   // validate
   assert.NotEqual(t, resultErr, "unexpected error")
}

在前面提到的测试中,全局变量上没有更多的数据竞争。

它非常适合分层代码-当对整个项目应用依赖项注入时,在应用程序执行的早期创建大量对象的情况并不少见。例如,我们的最小示例服务已经在main()中构建了四个对象。四个可能听起来不多,但我们还没有将 DI 应用到所有包中,到目前为止,我们只有三个端点。

对于我们的服务,我们有三层代码、REST、业务逻辑和数据。层之间的关系很简单。REST 层中的一个对象调用其业务逻辑层中的伙伴对象,业务逻辑层反过来调用数据层。除了测试,我们总是注入相同的依赖项。应用 JIT 注入将允许我们从构造器中删除这些依赖项,并使代码更易于使用。

实现成本低-正如我们在前面的 monkey 补丁示例中看到的,应用 JIT 注入非常简单。此外,这些变化仅限于一小部分。

类似地,将 JIT 注入应用于还没有任何形式 DI 的代码也很便宜。考虑下面的代码:

type Car struct {
   engine Engine
}

func (c *Car) Drive() {
   c.engine.Start()
   defer c.engine.Stop()

   c.engine.Drive()
}

如果我们决定将CarEngine解耦,那么我们只需要将抽象的交互定义为一个接口,然后将对c.engine的所有直接访问更改为使用getter函数,如下代码所示:

type Car struct {
   engine Engine
}

func (c *Car) Drive() {
   engine := c.getEngine()

   engine.Start()
   defer engine.Stop()

   engine.Drive()
}

func (c *Car) getEngine() Engine {
   if c.engine == nil {
      c.engine = newEngine()
   }

   return c.engine
}

考虑应用构造器注入的过程。在什么样的地方我们必须做出改变?

应用 JIT 注入

在前面的部分中,我提到了 JIT 注入可以用于私有依赖和公共依赖,这是两种非常不同的用例。在本节中,我们将应用这两个选项以实现非常不同的结果。

单元测试覆盖率

在 Go 中,测试覆盖率是通过添加一个-cover标志以及一个对常规调用 Go 测试的调用来计算的。由于一次只对一个包有效,我觉得这很不方便。因此,我们将使用一个工具,递归地计算目录树中所有包的测试覆盖率。此工具称为包覆盖,可从 GitHub(网站)获得 https://github.com/corsc/go-tools/tree/master/package-coverage )。

要使用package-coverage计算覆盖率,我们使用以下命令:

$ cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch08/

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

$ package-coverage -a -prefix $(go list)/ ./acme/

Note: I have intentionally used the code from Chapter 8, Dependency Injection by Config, so the coverage numbers are before any changes we might make in this chapter.

这给了我们以下信息:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  65.66 |    265 |   0.00 |      7 | acme/                             |
|  47.83 |     23 |  47.83 |     23 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  73.77 |     61 |  73.77 |     61 | acme/internal/modules/data/       |
|  61.70 |     47 |  61.70 |     47 | acme/internal/modules/exchange/   |
|  85.71 |      7 |  85.71 |      7 | acme/internal/modules/get/        |
|  46.15 |     13 |  46.15 |     13 | acme/internal/modules/list/       |
|  62.07 |     29 |  62.07 |     29 | acme/internal/modules/register/   |
|  79.73 |     74 |  79.73 |     74 | acme/internal/rest/               |
-------------------------------------------------------------------------

那么,我们能从这些数字中推断出什么呢?

  1. 代码覆盖率是合理的。这可能更好,但除了logging包装上的大脂肪 0 外,几乎所有包装都有 50%以上。
  2. 语句(stmts计数很有趣。语句大致相当于代码的行,因此数字表示哪些包有更多或更少的代码。我们可以看到,restdataexchange包是最大的。
  3. We can infer from the amount of code in a package that the more code a package has, the more responsibilities and more complexity it has. By extension, the more risk this package poses.

考虑到两个最大、风险最高的包restdata都有很好的测试覆盖率,我们仍然没有任何迹象表明它需要紧急关注。但是如果我们把测试覆盖率和依赖图考虑在一起会怎么样呢?

私有依赖关系

有很多地方我们可以通过应用 JIT 注入来改进我们的服务。那么,我们如何决定?让我们看看我们的依赖关系图是怎么说的:

There are lots of connections going into the logging package. But we have already decoupled that a reasonable amount in Chapter 8, Dependency Injection by Config.

下一个用户最多的包是data包。我们在第 5 章依赖性注入和猴子补丁中就做过这方面的工作,但也许是时候重新审视一下,看看我们是否可以进一步改进它了。

在我们做出决定之前,我将向您介绍另一种方法来了解代码的健康状况,以及我们的工作最适合花在哪里:单元测试覆盖率。与依赖关系图一样,它不能提供一个明确的指标,而只能给您一个提示。

覆盖和依赖图

依赖关系图告诉我们data包有很多用户。测试覆盖范围告诉我们,这也是我们拥有的最大的软件包之一。因此,我们可以推断,如果我们想要改进,这可能是正确的起点。

正如您在前面几章中所记得的,data包使用函数和全局单例池,这两个都给我们带来了不便。那么,让我们看看是否可以使用 JIT 注入来消除这些痛点。

赶走猴子

以下是get包当前如何使用data包:

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := loader(context.TODO(), g.cfg, ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the implementation 
         // details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// this function as a variable allows us to Monkey Patch during testing
var loader = data.Load

我们的第一个变化是定义一个接口,该接口将取代我们的loader功能:

//go:generate mockery -name=myLoader -case underscore -testonly -inpkg
type myLoader interface {
   Load(ctx context.Context, ID int) (*data.Person, error)
}

您可能已经注意到我们删除了 config 参数。当我们完成时,我们将不必在每次通话中都传递此信息。我还添加了一条go generate注释,这将创建一个我们稍后将使用的模拟。

接下来,我们将此依赖项添加为私有成员变量,并更新我们的Do()方法以使用 JIT 注入:

// 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 {
         // By converting the error we are hiding the implementation 
         // details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

但是我们的 JIT 注入getter方法会是什么样子呢?基本结构将是标准的,如以下代码所示:

func (g *Getter) getLoader() myLoader {
   if g.data == nil {
      // To be determined
   }

   return g.data
}

因为data包是作为函数实现的,所以我们目前没有实现loader接口的任何东西。我们的代码和单元测试现在已经被破坏了,所以在我们让它们重新工作的时候,我们将不得不暂时视而不见。

让代码重新工作的最短路径是定义一个数据访问对象DAO。这将用一个 struct 替换data包中的函数,并为我们提供一些实现myLoader接口的东西。为了减少更改次数,我们将让 DAO 方法调用现有函数,如下代码所示:

// NewDAO will initialize the database connection pool (if not already 
// done) and return a data access object which can be used to interact 
// with the database
func NewDAO(cfg Config) *DAO {
   // initialize the db connection pool
   _, _ = getDB(cfg)

   return &DAO{
      cfg: cfg,
   }
}

type DAO struct {
   cfg Config
}

// Load will attempt to load and return a person.
func (d *DAO) Load(ctx context.Context, ID int) (*Person, error) {
   return Load(ctx, d.cfg, ID)
}

Even after adding the DAO into our getLoader() function, our tests are still not restored. Our tests are still using monkey patching so we will need to remove that code and replace it with a mock, giving us the following:

func TestGetter_Do_happyPath(t *testing.T) {
   // inputs
   ID := 1234

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

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

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "Doug", person.FullName)
   assert.True(t, mockLoader.AssertExpectations(t))
}

最后,我们的测试又开始工作了。通过这些重构,我们还实现了一些其他改进:

  • 我们的get包测试不再使用猴子补丁;这意味着我们可以确保不存在与猴子补丁相关的并发性问题
  • Other than the data struct (data.Person), the get package tests no longer use the data package
  • 也许最重要的是,get包测试不再需要配置数据库

随着我们对get包的计划变更完成,我们可以转移到data包。

前面,我们定义了一个 DAO,其中我们的Load()方法调用了现有的Load()函数。由于Load()函数没有更多的用户,我们可以简单地复制代码并更新相应的测试。

在对data包的其余部分及其用户重复这个简单的过程之后,我们能够成功地从基于函数的包迁移到基于对象的包。

可选公共依赖项

到目前为止,我们已经将 JIT 依赖项注入应用于私有依赖项,目的是减少参数并使data包更易于使用。

还有另一种方法可以使用 JIT 注入可选的公共依赖项。这些依赖项是公共的,因为我们希望用户能够更改它们,但我们不将它们作为构造器的一部分,因为它们是可选的。这样做会削弱用户体验,特别是在很少使用可选依赖项的情况下。

假设我们的服务的加载所有注册端点出现性能问题,我们怀疑该问题与数据库的响应性有关。

面对这样的问题,我们决定需要通过添加一些工具来跟踪这些查询花费了多长时间。为了确保能够轻松地打开和关闭此跟踪器,我们可以将其作为可选的依赖项。

我们的第一步是定义我们的tracker接口:

// QueryTracker is an interface to track query timing
type QueryTracker interface {
   // Track will record/out the time a query took by calculating 
   // time.Now().Sub(start)
   Track(key string, start time.Time)
}

我们有一个决定要做。QueryTracker的使用是可选的,这意味着不能保证用户已经注入了依赖项。

为了避免在使用QueryTracker的地方使用 guard 子句,我们将引入一个 NO-OP 实现,该实现可以在用户未提供时使用。NO-OP 实现,有时称为空对象,是一个实现接口的对象,但所有方法都有意不执行任何操作。

以下是QueryTracker的 NO-OP 实现:

// NO-OP implementation of QueryTracker
type noopTracker struct{}

// Track implements QueryTracker
func (_ *noopTracker) Track(_ string, _ time.Time) {
   // intentionally does nothing
}

现在,我们可以将其作为公共成员变量引入 DAO:

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

   // Tracker is an optional query timer
   Tracker QueryTracker
}

我们可以使用 JIT 注入来访问跟踪器,默认为无操作版本:

func (d *DAO) getTracker() QueryTracker {
   if d.Tracker == nil {
      d.Tracker = &noopTracker{}
   }

   return d.Tracker
}

现在一切就绪,我们可以在要跟踪的任何方法的开头添加以下行:

// track processing time
defer d.getTracker().Track("LoadAll", time.Now())

这里需要注意的一件有趣的事情是defer的用法。基本上,defer有两个我们在这里使用的重要特性。首先,它将在函数退出时被调用,允许我们只添加一次跟踪器,而不是在每个返回语句的旁边。其次,defer的参数是在遇到行时确定的,而不是在执行时确定的。这意味着time.Now()的值将在我们跟踪的函数开始时调用,而不是在Track()函数返回时调用。

For our tracker to be of use, we need to provide an implementation other than the NO-OP. We could push these values to an external system like StatsD or Graphite, but for simplicity we will output the results to the log. The code for this is as follows:

// NewLogTracker returns a Tracker that outputs tracking data to log
func NewLogTracker(logger logging.Logger) *LogTracker {
   return &LogTracker{
      logger: logger,
   }
}

// LogTracker implements QueryTracker and outputs to the supplied logger
type LogTracker struct {
   logger logging.Logger
}

// Track implements QueryTracker
func (l *LogTracker) Track(key string, start time.Time) {
   l.logger.Info("[%s] Timing: %s\n", key, time.Now().Sub(start).String())
}

现在,我们可以通过以下方式临时更新 DAO 使用情况:

func (l *Lister) getLoader() myLoader {
   if l.data == nil {
      l.data = data.NewDAO(l.cfg)
   }

   return l.data
}

并将其更新为:

func (l *Lister) getLoader() myLoader {
   if l.data == nil {
      l.data = data.NewDAO(l.cfg)

      // temporarily add a log tracker
      l.data.(*data.DAO).Tracker = data.NewLogTracker(l.cfg.Logger())
   }

   return l.data
}

是的,这条线有点难看,但幸运的是它只是暂时的。如果我们决定将 QueryTracker 永久化,或者发现自己大部分时间都在使用它,那么我们可以很容易地切换到构造器注入。

JIT 注入的缺点

虽然 JIT 注入可以很方便,但它不能用于所有场景,并且有一些问题需要警惕。这些措施包括:

只能应用于静态依赖关系-第一个也是可能是最重要的缺点是,此方法只能应用于仅在测试期间更改的依赖关系。我们不能用它来代替参数注入或配置注入。这是因为依赖项实例化发生在私有方法内部,并且仅在第一次尝试访问变量时发生。

依赖项和用户生命周期没有分开-当使用构造器注入或参数注入时,通常可以安全地假设注入的依赖项已完全初始化并准备好使用。任何成本或延迟,如与创建资源池或预加载数据相关的成本或延迟,都将已经支付。通过 JIT 注入,依赖关系在第一次使用之前立即创建。因此,任何初始化成本都必须由第一个请求支付。下图显示了三个对象(调用者、被调用者和数据存储)之间的典型交互:

现在,将其与调用期间创建数据存储对象时的交互进行比较:

You can see the additional time (cost) that is incurred in the second diagram. These costs do not happen in most cases as creating objects in Go is fast. However, when they do exist, they can cause some unintended or inconvenient behavior during application startup.

在类似前面提到的情况下,依赖关系的状态不确定的另一个缺点存在于生成的代码中。考虑下面的代码:

func (l *Sender) Send(ctx context.Context, payload []byte) error {
   pool := l.getConnectionPool()

   // ensure pool is ready
   select {
   case <-pool.IsReady():
      // happy path

   case <-ctx.Done():
      // context timed out or was cancelled
      return errors.New("failed to get connection")
   }

   // get connection from pool and return afterwards
   conn := pool.Get()
   defer l.connectionPool.Release(conn)

   // send and return
   _, err := conn.Write(payload)

   return err
}

将前面的代码与保证依赖关系处于就绪状态的相同代码进行比较:

func (l *Sender) Send(payload []byte) error {
   pool := l.getConnectionPool()

   // get connection from pool and return afterwards
   conn := pool.Get()
   defer l.connectionPool.Release(conn)

   // send and return
   _, err := conn.Write(payload)

   return err
}

当然,这只是几行代码,但它的阅读和维护要简单得多。它也更容易实现和测试。

Potential data and initialization races—Similar to the previous point, this one also revolves around the initialization of the dependency. In this case, however, the issues are related to accessing the dependency itself. Let's return to our earlier example of a connection pool but change how the instantiation occurs:

func newConnectionPool() ConnectionPool {
   pool := &myConnectionPool{}

   // initialize the pool
   pool.init()

   // return a "ready to use pool"
   return pool
}

如您所见,连接池的此构造器在池完全初始化之前不会返回。那么,在初始化过程中,当另一个对getConnectionPool()的调用发生时会发生什么呢?

我们最终可能会创建两个连接池。此图显示了此交互:

那么,另一个连接池会发生什么情况?它将成为孤儿。所有用于创建它的 CPU 都被浪费了,甚至有可能垃圾收集器没有正确清理它;因此,内存、文件句柄或网络端口等任何资源都可能丢失。

有一种简单的方法可以确保避免这个问题,但成本很低。我们可以使用standard库中的同步包。这个软件包中有几个不错的选项,但在本例中我推荐Once()。通过在我们的getConnectionPool()方法中添加Once(),我们得到:

func (l *Sender) getConnection() ConnectionPool {
   l.initPoolOnce.Do(func() {
      l.connectionPool = newConnectionPool()
   })

   return l.connectionPool
}

这种方法有两个小成本。第一是增加了代码的复杂性;这是次要的,但它确实存在。

第二,每次对getConnectionPool()的呼叫(可能有很多次)都会检查Once()是否是第一次呼叫。这是一个难以置信的小成本,但是,根据您的性能要求,它可能会很不方便。

对象并非完全解耦-在本书中,我们使用依赖关系图来确定潜在问题,特别是关于包之间的关系,以及在某些情况下对特定包的过度依赖。虽然我们可以也应该使用第 2 章中的依赖项反转原则部分,Go的实体设计原则,并通过在代码中包含依赖项的创建,将依赖项定义为本地接口,依赖关系图仍将显示包与依赖关系之间的关系。在某种程度上,我们的对象仍然在某种程度上与我们的依赖性相耦合。

总结

在本章中,我们使用 JIT 注入(一种有点不寻常的 DI 方法)来删除前面章节中的一些猴子补丁。

我们还使用了不同形式的 JIT 注入来添加可选的依赖项,而不影响代码的 UX。

此外,我们还研究了 JIT 注入如何在不牺牲我们在测试中使用模拟和存根的能力的情况下减少测试引起的损害。

在下一章中,我们将研究书中最后一种 DI 方法,即现成的注入。我们将讨论采用 DI 框架的一般优点和缺点,对于我们的示例,我们将使用 Google 的 Wire 框架。

问题

  1. JIT 注入与构造器注入有何不同?
  2. 在处理可选依赖项时,为什么使用 NO-OP 实现很重要?
  3. JIT 注入的理想用例是什么?