Skip to content

Latest commit

 

History

History
861 lines (638 loc) · 35.6 KB

File metadata and controls

861 lines (638 loc) · 35.6 KB

八、配置依赖注入

In this chapter, we will be looking at dependency injection (DI) by config. Config injection is not a completely different method but an extension of both constructor injection and method injection.

它打算解决这些方法的潜在问题,例如过度或重复注入依赖项,而不牺牲代码的用户体验。

本章将介绍以下主题:

  • 配置注入
  • 配置注入的优点
  • 应用配置注入
  • 配置注入的缺点

技术要求

熟悉我们在第 4 章ACME 注册服务简介中介绍的服务代码会有所帮助。本章还假设您已经阅读了第 6 章依赖项注入和构造器注入以及第 7 章依赖项注入和方法注入

You might also find it useful to read and run the full versions of the code for this chapter, which is available at https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch08.

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

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

配置注入

配置注入是方法和参数注入的具体实现。通过配置注入,我们将多个依赖项和系统级配置结合起来,并将它们合并到一个config接口中。

考虑下面的构造器:

// NewLongConstructor is the constructor for MyStruct
func NewLongConstructor(logger Logger, stats Instrumentation, limiter RateLimiter, cache Cache, timeout time.Duration, workers int) *MyStruct {
 return &MyStruct{
 // code removed
 }
}

如您所见,我们正在注入多个依赖项,包括记录器、检测、速率限制器、缓存和一些配置。

可以安全地假设,在同一个项目中,我们可能至少将记录器和仪器注入到我们的大多数对象中。这导致每个构造器至少有两个参数。在整个系统中,这会增加很多额外的输入。它还降低了构造器的用户体验,使其更难阅读,这可能会隐藏常见参数中的重要参数。

考虑一下超时值和可能被定义的工人的数量在哪里?它们可能是从某个中心源定义的,例如config文件。

通过应用配置注入,我们的示例如下所示:

// NewByConfigConstructor is the constructor for MyStruct
func NewByConfigConstructor(cfg MyConfig, limiter RateLimiter, cache Cache) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

我们已将常见问题和配置合并到配置定义中,但保留了重要参数不变。以这种方式,功能参数仍然是信息性的,无需读取config接口定义。在某种程度上,我们隐藏或封装了共同关注的问题。

还有另一个可用性方面考虑配置现在是一个接口。我们应该考虑什么样的对象将实现这样的接口。这样的物体是否已经存在?它的职责是什么?

配置通常来自单个源,其职责是加载配置并提供对它的访问。尽管我们引入配置接口是为了与实际的配置管理脱钩,但利用它是一个单一源的事实仍然很方便。

考虑下面的代码:

myFetcher := NewFetcher(cfg, cfg.URL(), cfg.Timeout())

此代码表示所有参数都来自同一位置。这很好地表明它们可以合并。

如果您来自面向对象的背景,您可能熟悉服务定位器的概念。配置注入有意地非常相似。然而,与典型的服务定位器用法不同,我们只提取配置和一些共享依赖项。

配置注入采用这种方法来避免服务定位器的God 对象以及使用和 God 对象之间的固有耦合。

配置注入的优点

鉴于配置注入是构造器和方法注入的扩展形式,其他方法的优点也适用于此。在本节中,我们将仅讨论此方法特有的其他好处。

它非常适合与配置包分离——当我们有一个config包从一个地方加载时,比如一个文件,那么这个包往往会成为系统中许多其他包的依赖项。考虑到第 2 章中的单一责任原则部分,Go 的实体设计原则,我们认识到,一个包或对象的用户越多,更改的阻力和/或难度就越大。

通过配置注入,我们还在本地接口中定义我们的需求,并利用 Go 的隐式接口和依赖倒置原则DIP)来保持包的解耦。

这些步骤也使测试我们的结构变得非常容易。考虑下面的代码:

func TestInjectedConfig(t *testing.T) {
   // load test config
   cfg, err := config.LoadFromFile(testConfigLocation)
   require.NoError(t, err)

   // build and use object
   obj := NewMyObject(cfg)
   result, resultErr := obj.Do()

   // validate
   assert.NotNil(t, result)
   assert.NoError(t, resultErr)
}

现在,请参阅配置注入的相同代码:

func TestConfigInjection(t *testing.T) {
   // build test config
   cfg := &TestConfig{}

   // build and use object
   obj := NewMyObject(cfg)
   result, resultErr := obj.Do()

   // validate
   assert.NotNil(t, result)
   assert.NoError(t, resultErr)
}

// Simple implementation of the Config interface
type TestConfig struct {
   logger *logging.Logger
   stats  *stats.Collector
}

func (t *TestConfig) Logger() *logging.Logger {
   return t.logger
}

func (t *TestConfig) Stats() *stats.Collector {
   return t.stats
}

是的,代码量更大。然而,我们不再需要管理测试配置文件,这通常是一件痛苦的事情。我们的测试是完全自包含的,并且应该没有并发问题,就像使用全局配置对象时一样。

**它减轻了注入公共关注点的负担-**在前面的示例中,我们使用配置注入来注入日志记录和检测对象。像这样的常见问题是配置注入的一个很好的用途,因为它们经常被需要,但对于函数本身的用途没有什么信息。它们可以被视为环境依赖性。由于他们的共同性质,另一种方法是将他们转变为全球单身人士,而不是注入他们。就我个人而言,我更喜欢注入它们,因为这让我有机会验证它们的使用。这本身可能会让人觉得奇怪,但在许多情况下,我们会根据检测数据的存在或缺乏来构建系统监控和警报,从而使检测成为我们代码的功能或契约的一部分,以及我们可能希望通过测试防止回归的东西。

它通过减少参数来提高可用性——与之前的优势类似,使用配置注入可以提高方法的可用性,尤其是构造器,但减少了参数的数量。考虑下面的构造器:

func NewLongConstructor(logger Logger, stats Instrumentation, limiter RateLimiter, cache Cache, url string, credentials string) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

Now. take a look at the same constructor with config injection:

func NewByConfigConstructor(cfg MyConfig, url string, credentials string) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

由于从构造器定义中删除了环境依赖项,我们剩下的参数明显减少。更重要的是,剩下的唯一参数是那些特定于目的的参数,因此使方法更易于理解和使用。

依赖项的创建可以推迟到使用-您是否曾经尝试注入依赖项,但却发现它不存在或尚未准备好?您是否曾经有过一个依赖项,它的启动或运行成本太高,以至于您只想在绝对必要时创建它?

With config injection, dependency creation, and access only need to be resolved at the point of usage and not during injection.

应用配置注入

Previously, I mentioned there were a couple of issues that I really wanted us to fix with our ACME registration service. In this section, we are going to use config injection to deal with two of them.

第一个事实是,我们的许多包依赖于configlogging包,除了严重违反单一责任原则之外,这种耦合可能会导致循环依赖问题。

第二个问题是我们无法在没有实际调用上游服务的情况下测试对汇率的调用。到目前为止,我们已经避免在这个包中添加任何测试,因为我们担心我们的测试会受到该服务的影响(在速度和稳定性方面)。

首先,让我们看看我们在哪里。我们的依赖关系图目前如下图所示:

如您所见,我们有四个包(dataregisterexchangemain)取决于config包,还有五个包(dataregisterexchangerestconfig)依赖于logging包。更糟糕的是,这些包如何依赖于configlogging包。目前,他们直接访问公共单身人士。这意味着,当我们想在测试过程中测试记录器的使用情况或交换一些配置时,我们必须进行 monkey-patch,这将导致测试中的数据竞争不稳定。

为了解决这个问题,我们将为每个对象定义一个配置。每个配置将包括记录器及其所需的任何其他配置。然后,我们用对注入配置的引用替换到全局变量的任何直接链接。

这将导致一点鸟枪手术(许多小的变化),但代码将更好。

在这里,我们将只经历一组变化;如果您希望看到所有这些,请查看本章的源代码。

将配置注入应用于模型层

回顾我们的register包,我们发现它同时引用了配置和日志:

// Registerer validates the supplied person, calculates the price in 
// the requested currency and saves the result.
// It will return an error when:
// -the person object does not include all the fields
// -the currency is invalid
// -the exchange rate cannot be loaded
// -the data layer throws an error.
type Registerer struct {
}

// get price in the requested currency
func (r *Registerer) getPrice(ctx context.Context, currency string) (float64, error) {
  converter := &exchange.Converter{}
  price, err := converter.Do(ctx, config.App.BasePrice, currency)
  if err != nil {
    logging.L.Warn("failed to convert the price. err: %s", err)
    return defaultPersonID, err
  }

  return price, nil
}

我们的第一步是定义一个接口,该接口将提供我们需要的依赖项:

// Config is the configuration for the Registerer
type Config interface {
   Logger() *logging.LoggerStdOut
   BasePrice() float64
}

你觉得这有什么不对吗?跳出的第一件事是我们的Logger()方法返回一个指向记录器实现的指针。这将是可行的,但它不是未来的证明或可测试的。我们可以在本地定义一个logging接口,并将自己与logging包完全分离。然而,这意味着我们必须在大多数包中定义一个logging接口。从理论上讲,这是最好的选择,但不太实际。相反,我们可以定义一个logging接口,让所有包都依赖于此。虽然这意味着我们仍然与logging包保持耦合,但我们将依赖一个很少改变的接口,而不是一个更可能改变的实现。

第二个潜在问题是另一个方法的命名,BasePrice(),因为它有点通用,并且可能会在以后引起混淆。它也是Config结构中字段的名称,但 Go 不允许我们有同名的成员变量和方法,因此我们需要更改它。

在更新我们的config接口后,我们有以下内容:

// Config is the configuration for the Registerer
type Config interface {
  Logger() logging.Logger
  RegistrationBasePrice() float64
}

We can now apply config injection to our Registerer, giving us the following:

// NewRegisterer creates and initializes a Registerer
func NewRegisterer(cfg Config) *Registerer {
   return &Registerer{
      cfg: cfg,
   }
}

// Config is the configuration for the Registerer
type Config interface {
   Logger() logging.Logger
   RegistrationBasePrice() float64
}

// Registerer validates the supplied person, calculates the price in 
// the requested currency and saves the result.
// It will return an error when:
// -the person object does not include all the fields
// -the currency is invalid
// -the exchange rate cannot be loaded
// -the data layer throws an error.
type Registerer struct {
   cfg Config
}

// get price in the requested currency
func (r *Registerer) getPrice(ctx context.Context, currency string) (float64, error) {
   converter := &exchange.Converter{}
   price, err := converter.Do(ctx, r.cfg.RegistrationBasePrice(), currency)
   if err != nil {
      r.logger().Warn("failed to convert the price. err: %s", err)
      return defaultPersonID, err
   }

   return price, nil
}

func (r *Registerer) logger() logging.Logger {
   return r.cfg.Logger()
}

我还添加了一个方便的方法logger(),将代码从r.cfg.Logger()减少到r.logger()。我们的服务和测试目前已中断,因此我们需要进行更多更改。

为了让测试再次进行,我们需要定义测试配置并更新测试。对于我们的测试配置,我们可以使用 mockry 并创建一个 mock 实现,但是我们对验证配置使用情况或向这个包中的所有测试添加额外代码来配置 mock 不感兴趣。相反,我们将使用返回可预测值的存根实现。这是我们的存根测试配置:

// Stub implementation of Config
type testConfig struct{}

// Logger implement Config
func (t *testConfig) Logger() logging.Logger {
   return &logging.LoggerStdOut{}
}

// RegistrationBasePrice implement Config
func (t *testConfig) RegistrationBasePrice() float64 {
   return 12.34
}

并将此测试配置添加到我们的所有Registerer测试中,如下代码所示:

registerer := &Registerer{
   cfg: &testConfig{},
}

我们的测试正在再次运行,但奇怪的是,当我们的服务编译时,如果我们运行它,它将崩溃并出现nil指针异常。我们需要从以下内容更新Registerer的创建:

registerModel := &register.Registerer{}

我们将其改为:

registerModel := register.NewRegisterer(config.App)

This leads us to the next problem. The config.App struct does not implement the methods we need. Adding these methods to config, we get the following:

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

通过这些更改,我们切断了registration包和config包之间的依赖关系链接。在前面介绍的Logger()方法中,您可以看到我们仍然将记录器作为一个单例使用,但它不是一个全局公共变量(容易发生数据争用),而是在config对象中。表面上看,这似乎没有什么不同;然而,我们主要关心的数据竞争是在测试期间。我们的对象现在依赖于记录器的注入版本,不需要使用全局公共变量。

在这里,我们检查更新的依赖关系图,以了解下一步的方向:

我们在config套餐中加入了三通;即来自maindataexchange包的。无法删除main包中的链接,因此,我们可以忽略这一点。那么,让我们看看data套餐。

将配置注入应用于数据包

我们的data包目前是基于函数的,因此,与之前的更改相比,这些更改将略有不同。以下是data包中的一个典型功能:

// 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 Load(ctx context.Context, ID int) (*Person, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return nil, err
   }

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

   // perform DB select
   row := db.QueryRowContext(subCtx, sqlLoadByID, ID)

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

      logging.L.Error("failed to convert query result. err: %s", err)
      return nil, err
   }
   return out, nil
}

在这个函数中,我们有对要删除的记录器的引用,以及一个真正需要提取的配置。前面代码中函数的第一行需要配置。以下是getDB()功能:

var getDB = func() (*sql.DB, error) {
   if db == nil {
      if config.App == nil {
         return nil, errors.New("config is not initialized")
      }

      var err error
      db, err = sql.Open("mysql", config.App.DSN)
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

我们有一个对DSN的引用来创建数据库池。那么,你认为我们的第一步应该是什么?

与前面的更改一样,让我们首先定义一个接口,该接口包含我们要注入的所有依赖项和配置:

// Config is the configuration for the data package
type Config interface {
   // Logger returns a reference to the logger
   Logger() logging.Logger

   // DataDSN returns the data source name
   DataDSN() string
}

现在,让我们更新我们的函数来注入config接口:

// 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 Load(ctx context.Context, cfg Config, ID int) (*Person, error) {
   db, err := getDB(cfg)
   if err != nil {
      cfg.Logger().Error("failed to get DB connection. err: %s", err)
      return nil, err
   }

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

   // perform DB select
   row := db.QueryRowContext(subCtx, sqlLoadByID, ID)

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

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

var getDB = func(cfg Config) (*sql.DB, error) {
   if db == nil {
      var err error
      db, err = sql.Open("mysql", cfg.DataDSN())
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

不幸的是,这个更改将破坏很多东西,因为data包中的所有公共函数都调用了getDB(),而模型层包又调用了这些函数。谢天谢地,我们有足够的单元测试来帮助防止在进行更改时出现回归。

我想请你停一下,考虑一下:我们正试图做出一个无关紧要的改变,但这会导致大量的小变化。此外,我们被迫向这个包中的每个公共函数添加一个参数。对于基于函数构建此包的决策,您有何感想?从函数中重构不是一项小任务,但您认为值得吗?

对模型层的更改很小,但很有趣,因为我们已经用配置注入更新了模型层。

只需做两个小改动:

  • 我们将把DataDSN()方法添加到配置中
  • 我们需要通过loader()调用将配置传递给数据包

以下是应用了更改的代码:

// Config is the configuration for Getter
type Config interface {
   Logger() logging.Logger
   DataDSN() string
}

// Getter will attempt to load a person.
// It can return an error caused by the data layer or when the 
// requested person is not found
type Getter struct {
   cfg Config
}

// 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

遗憾的是,我们需要在所有模型层包中进行这些小的更改。完成后,我们的依赖关系图现在看起来如下图所示:

好极了到config包只剩下一个不必要的连接,它来自exchange包。

将配置注入应用于 exchange 包

我们可以使用以下步骤将配置注入应用到exchange包,就像我们对其他包所做的一样:

  1. 定义一个包含我们要注入的依赖项和配置的接口

  2. 定义/更新构造器以接受config接口

  3. 将注入的配置另存为成员变量

  4. 更改引用(例如,configlogger以指向成员变量

  5. Update the other layer config interfaces to include anything new

在我们将配置注入应用于exchange包之后,出现了一种不寻常的情况。我们的依赖关系图显示我们已经删除了从exchangeconfig包的链接,如下图所示:

但是,为了使测试保持工作状态,我们仍然需要引用配置,如下代码所示:

type testConfig struct{}

// ExchangeBaseURL implements Config
func (t *testConfig) ExchangeBaseURL() string {
   return config.App.ExchangeRateBaseURL
}

// ExchangeAPIKey implements Config
func (t *testConfig) ExchangeAPIKey() string {
   return config.App.ExchangeRateAPIKey
}

退一步,我们注意到我们所指的测试不是对exchange包的测试,而是对其用户而言的register包的测试。这是相当危险的信号。通过将构造器注入应用于这两个包之间的关系,我们可以快速修复这个问题的第一部分。然后,我们可以模拟或存根对交换机的调用。

我们还可以撤销我们之前对寄存器Config接口的一些更改,删除exchange包相关方法,让我们回到这个问题:

// Config is the configuration for the Registerer
type Config interface {
   Logger() logging.Logger
   RegistrationBasePrice() float64
   DataDSN() string
}

这最终允许我们删除register测试与config包之间的链接,也许更重要的是,允许我们将测试与外部汇率服务分离。

当我们开始本节时,我们定义了两个目标。首先,与config包和logging包解耦,其次,能够在不调用外部服务的情况下进行测试。到目前为止,我们已经成功地与config包完全解耦。我们已经从config包之外的所有包中删除了对全球公共记录器的使用,并且我们还删除了对外部汇率服务的依赖。

然而,我们的服务仍然依赖于该外部服务,并且我们绝对没有测试来验证我们是否正确调用了它,或者证明服务按照我们预期的方式响应。这些测试称为边界测试

边界测试

Boundary tests come in two forms, each with their own goal—internal-facing and external-facing.

内表面边界测试旨在验证两件事:

  • 我们的代码以我们期望的方式调用外部服务
  • 我们的代码以我们期望的方式对来自外部服务的所有响应、愉快路径和错误做出反应

因此,面向内部的边界测试不会与外部服务交互,而是与外部服务的模拟或存根实现交互。

外表面边界试验则相反。它们与外部服务交互,并验证外部服务是否按照我们的需要执行。请注意,它们不会验证外部服务的 API 合同,服务也不会按照其所有者的期望行事。然而,他们只关注我们的需求。从本质上讲,外部边界测试将比单元测试更慢、更不可靠。因此,我们可能不希望一直运行它们。我们可以使用 Go 的构建标志来实现这一点。

让我们从向我们的服务添加面向外部的边界测试开始。我们可以编写一个测试,以服务文档建议的格式包含对外部服务的 HTTP 调用,然后验证响应。如果我们不熟悉该服务,并且还没有构建调用该服务的代码,那么这也是了解外部服务的一个很好的方法。

然而,在我们的例子中,我们已经编写了代码,因此更快的选择是使用live配置调用该代码。这样做将返回一个 JSON 负载,该负载看起来类似于以下内容:

{
   "success":true,
   "historical":true,
   "date":"2010-11-09",
   "timestamp":1289347199,
   "source":"USD",
   "quotes":{
      "USDAUD":0.989981
   }
}

虽然响应的格式是可预测的,timestampquotes值将发生变化。那么,我们可以测试什么呢?也许,更重要的是,我们所依赖的反应是什么?在仔细检查我们的代码之后,我们意识到在响应中的所有字段中,我们唯一使用的是quotes映射。此外,我们从外部服务需要的唯一一件事是,我们请求的货币存在于该映射中,并且该值为float64类型。因此,通过只测试这些特定属性,我们的测试将尽可能地适应变化。

This gives us a test that looks like the following code:

func TestExternalBoundaryTest(t *testing.T) {
   // define the config
   cfg := &testConfig{
      baseURL: config.App.ExchangeRateBaseURL,
      apiKey:  config.App.ExchangeRateAPIKey,
   }

   // create a converter to test
   converter := NewConverter(cfg)

   // fetch from the server
   response, err := converter.loadRateFromServer(context.Background(), "AUD")
   require.NotNil(t, response)
   require.NoError(t, err)

   // parse the response
   resultRate, err := converter.extractRate(response, "AUD")
   require.NoError(t, err)

   // validate the result
   assert.True(t, resultRate > 0)
}

为了确保此测试仅在需要时运行,我们在文件顶部放置了以下 build 标记:

// +build external

现在,让我们看一下面向内部的边界测试。第一步是使我们自己成为外部服务的模拟实现。如前所述,我们得到了结果有效载荷。为此,我们将使用httptest包创建一个返回测试负载的 HTTP 服务器,如下所示:

type happyExchangeRateService struct{}

// ServeHTTP implements http.Handler
func (*happyExchangeRateService) ServeHTTP(response http.ResponseWriter, request *http.Request) {
  payload := []byte(`
{
   "success":true,
   "historical":true,
   "date":"2010-11-09",
   "timestamp":1289347199,
   "source":"USD",
   "quotes":{
      "USDAUD":0.989981
   }
}`)
  response.Write(payload)
}

目前,它返回一个固定的响应,并且不验证请求。现在,我们可以构建面向内部的边界测试。与外部边界测试不同,结果现在完全由我们控制,因此是可预测的。因此,我们可以测试准确的结果,如以下代码所示:

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, 101.01, resultRate)
   assert.NoError(t, resultErr)
}

我们现在有了一个基本的内部边界测试。我们能够在不依赖外部服务的情况下验证外部服务是否返回我们期望的有效负载,并且我们能够正确提取和使用结果。我们可以进一步扩展测试以涵盖更多场景,包括以下内容:

  • 当外部服务关闭或运行缓慢时,验证代码并返回可感知错误的测试
  • 当外部服务返回一个空的或无效的响应时,证明我们的代码返回一个合理错误的测试
  • 验证代码执行的 HTTP 请求的测试

有了面向内部的边界测试,我们终于可以对汇率代码进行测试了。我们已经设法确保我们的代码按预期工作,测试是可靠的,完全由我们控制。此外,我们还可以偶尔运行外部边界测试,以通知我们对外部服务的任何更改,这些更改将破坏我们的服务。

配置注入的缺点

正如我们所看到的,配置注入可以与构造器和函数一起使用,因此,只使用配置注入构建系统是可能的。不幸的是,配置注入确实有一些缺点。

通过 TrFig 而不是抽象依赖项泄漏实现细节 AUT1,考虑下面的代码:

type PeopleFilterConfig interface {
   DSN() string
}

func PeopleFilter(cfg PeopleFilterConfig, filter string) ([]Person, error) {
   // load people
   loader := &PersonLoader{}
   people, err := loader.LoadAll(cfg)
   if err != nil {
      return nil, err
   }

   // filter people
   out := []Person{}
   for _, person := range people {
      if strings.Contains(person.Name, filter) {
         out = append(out, person)
      }
   }

   return out, nil
}

type PersonLoaderConfig interface {
   DSN() string
}

type PersonLoader struct{}

func (p *PersonLoader) LoadAll(cfg PersonLoaderConfig) ([]Person, error) {
   return nil, errors.New("not implemented")
}

In this example, the PeopleFilter function is aware of the fact that PersonLoader is a database. This might not seem like a big deal, and if the implementation strategy never changes, it will have no adverse impact. Should we shift from a database to an external service or anything else, however, we would then have to change our PersonLoader database as well. A more future-proof implementation would be as follows:

type Loader interface {
   LoadAll() ([]Person, error)
}

func PeopleFilter(loader Loader, filter string) ([]Person, error) {
   // load people
   people, err := loader.LoadAll()
   if err != nil {
      return nil, err
   }

   // filter people
   out := []Person{}
   for _, person := range people {
      if strings.Contains(person.Name, filter) {
         out = append(out, person)
      }
   }

   return out, nil
}

如果我们更改数据的加载位置,此实现不太可能需要更改。

Dependency life cycles are less predictable—In the advantages, we stated that dependency creation can be deferred until use. Your inner critic may have rebelled against that assertion, and for a good reason. It is an advantage, but it also makes the life cycle of the dependency less predictable. When using constructor injection or method injection, the dependency must exist before it is injected. Due to this, any issues with the creation or initialization of the dependency surfaces at this earlier time. When the dependency is initialized at some unknown later point, a couple of issues can arise.

首先,如果问题无法恢复或导致系统恐慌,这将意味着系统最初看起来是健康的,然后变得不健康或意外崩溃。这种不可预测性会导致极难调试的问题。

第二,如果依赖项的初始化包含延迟的可能性,我们必须了解并解释任何此类延迟。考虑下面的代码:

func DoJob(pool WorkerPool, job Job) error {
   // wait for pool
   ready := pool.IsReady()

   select {
   case <-ready:
      // happy path

   case <-time.After(1 * time.Second):
      return errors.New("timeout waiting for worker pool")
   }

   worker := pool.GetWorker()
   return worker.Do(job)
}

现在将其与假设池在注入之前已准备就绪的实现进行比较:

func DoJobUpdated(pool WorkerPool, job Job) error {
   worker := pool.GetWorker()
   return worker.Do(job)
}

如果此函数是具有延迟预算的端点的一部分,会发生什么情况?如果启动延迟大于延迟预算,则第一个请求将始终失败。

过度使用会降低 UX-虽然我强烈建议您仅将此模式用于配置和环境依赖性(如仪器),但也可以在许多其他地方应用此模式。然而,通过将依赖关系推到config接口中,它们变得不那么明显,我们需要实现一个更大的接口。让我们重新检查前面的一个示例:

// NewByConfigConstructor is the constructor for MyStruct
func NewByConfigConstructor(cfg MyConfig, limiter RateLimiter, cache Cache) *MyStruct {
   return &MyStruct{
   // code removed
   }
}

考虑速率限制器依赖性。如果我们将其合并到Config接口中会发生什么?不太明显的是,该对象使用并依赖于速率限制器。如果每个类似的函数都有速率限制,那么随着其使用变得更加环保,问题就不会那么严重了。

另一个不太明显的方面是配置。速率限制器的配置可能在所有用途中都不一致。当所有其他依赖项和配置都来自共享对象时,这是一个问题。我们可以组合 config 对象并自定义返回的速率限制,但这感觉像是过度工程化了。

更改可能会波及软件层-此问题仅在配置通过各层时适用。考虑下面的例子:

**```go func NewLayer1Object(config Layer1Config) *Layer1Object { return &Layer1Object{ MyConfig: config, MyDependency: NewLayer2Object(config), } }

// Configuration for the Layer 1 Object type Layer1Config interface { Logger() Logger }

// Layer 1 Object type Layer1Object struct { MyConfig Layer1Config MyDependency *Layer2Object }

// Configuration for the Layer 2 Object type Layer2Config interface { Logger() Logger }

// Layer 2 Object type Layer2Object struct { MyConfig Layer2Config }

func NewLayer2Object(config Layer2Config) *Layer2Object { return &Layer2Object{ MyConfig: config, } }


使用这种结构,当我们需要向`Layer2Config`接口添加新的配置或依赖项时,我们也会被迫将其添加到`Layer1Config`接口。`Layer1Config`将违反[第 2 章](02.html)、*Go*实体设计原则中讨论的界面分离原则,这表明我们可能存在问题。此外,根据代码的分层和重用级别,更改的数量可能会很大。在这种情况下,更好的选择是应用构造器注入将`Layer2Object`注入`Layer1Object`。这将完全解耦对象并消除分层更改的需要。

# 总结

在本章中,我们利用配置注入(构造器和方法注入的扩展版本)来改进代码的用户体验,主要是通过将环境依赖项和配置与上下文重要依赖项分开处理。

在将配置注入应用于我们的示例服务时,我们将所有可能的包从`config`包中分离出来,使其更自由地随时间演化。我们还通过消除与记录器实例相关的任何数据竞争的可能性,并使我们能够测试记录器的使用情况,而无需任何混乱的修补程序,从而将大多数记录器使用情况从全局公共变量切换到注入的抽象依赖项。

在下一章中,我们将研究另一种不寻常的依赖注入形式,称为**即时**(**JIT**)**依赖注入**。使用这种技术,我们将减少与层间依赖关系创建和注入相关的负担,而不会牺牲使用模拟和存根进行测试的能力。

# 问题

1.  配置注入与方法或构造器注入有何不同?
2.  我们如何决定将哪些参数移动到配置注入?
3.  为什么我们不通过配置注入注入所有依赖项?
4.  为什么我们要注入环境依赖项(如记录器),而不是使用全局公共变量?
5.  为什么边界测试很重要?
6.  配置注入的理想用例是什么?**