可汗学院使用Go静态上下文理顺全局变量和依赖 - khanacademy

22-04-08 banq

可汗学院正在完成一个巨大的项目,将我们的后端从Python迁移到Go。虽然这个项目的主要目标是迁移到一个过时的平台上,但我们看到了改进我们代码的机会,而不仅仅是直接移植。

我们想改进的一件大事是我们的Python代码库中的隐性依赖关系。访问当前请求或当前用户是通过调用全局函数完成的。同样地,我们通过全局函数或全局装饰器连接到其他内部服务和外部功能,如数据库、存储和缓存层。

像这样使用全局函数使得我们很难知道一个代码块是否触及了某个数据或调用了某个服务。这也使代码测试变得复杂,因为所有隐含的依赖关系都需要被模拟出来。

我们考虑了许多可能的解决方案,包括将所有东西作为参数传入,或者使用上下文来保存所有的依赖关系,但每一种方法都有缺陷。

在这篇文章中,我将描述我们如何以及为什么通过创建Go语言静态类型的上下文来解决这些问题。
我们用函数来扩展上下文对象,以访问这些共享资源,而函数则声明接口,显示它们需要哪些功能。结果是我们在编译时明确列出了依赖关系并进行了验证,但调用和测试一个函数仍然很容易。

func DoTheThing(
    ctx interface {
        context.Context
        RequestContext
        DatabaseContext
        HttpClientContext
        SecretsContext
        LoggerContext
    },
    thing string,
) {...}

我将介绍一下我们考虑过的各种想法,并说明我们为什么会选择这个解决方案。本文中所有的代码实例都可以在这里。你可以在该资源库中查看工作实例以及静态类型上下文的实现细节。
 

尝试1:全局变量
让我们从一个代表性例子开始:

func DoTheThing(thing string) error {
    // Find User Key from request
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := database.Read(userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = httpClient.Post("www.dothething.example", user.GetName())
    }
    return err
}


这段代码相当直接,能处理错误,甚至还有注释,但有几个大问题。这里的request是什么?一个全局变量!?database和httpClient是从哪里来的?这些函数的任何依赖关系又是什么?

以下是我们不喜欢全局变量的一些原因。
  • 很难追踪依赖关系的使用情况。
  • 由于每个测试都使用相同的全局变量,所以很难模拟出测试中的依赖关系。
  • 我们不能针对不同的数据同时运行。

将所有这些依赖关系隐藏在globals全局变量中,使得代码很难被跟踪。
在Go中,我们喜欢显式的!
与其隐含地依赖所有这些globals全局变量,不如让我们试着把它们作为参数传入。

尝试2:使用参数

func DoTheThing(
    thing string,
    request *Request,
    database *Database,
    httpClient *HttpClient,
    secrets *Secrets,
    logger *Logger,
    timeout *Timeout,
) error {
    // Find User Key from request
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := database.Read(userKey, secrets, logger, timeout)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        token, err := request.GetToken()
        if err != nil { return err }
 
        err = httpClient.Post("www.dothething.example", user.GetName(), token, logger)
        return err
    }
    return nil
}


现在DoTheThing所需要的所有功能都非常明显了,而且很清楚哪个请求正在被处理,哪个数据库正在被访问,以及数据库正在使用哪个秘密。如果我们想测试这个函数,很容易看到如何传递模拟对象。

不幸的是,现在的代码非常冗长。有些参数几乎在每个函数中都是通用的,需要到处传递:例如,request、logger和secrets。DoTheThing有一堆参数,它们的存在只是为了让我们能把它们传递给其他函数。有些函数可能需要取几十个参数来包含它们需要的所有功能。

当每个函数都需要几十个参数时,就很难把参数的顺序搞清楚。当我们想传入mock时,我们需要生成大量的mock并确保它们相互兼容。

我们也许应该检查每个参数以确保它不是nil,但在实践中,如果调用者错误地传递了nils,很多开发者就会冒着恐慌的风险。

当我们为一个函数添加一个新的参数时,我们必须更新所有的调用站点,但调用函数也需要检查它们是否已经有这个参数。如果没有,他们需要把它作为自己的参数添加进去。这就导致了大量的非自动化代码的流失。

这个想法的一个潜在转折是创建一个服务器对象,将这些依赖关系捆绑在一起。这种方法可以减少参数的数量,但现在它隐藏了一个函数究竟需要哪些依赖。在大量的小对象和少数的大对象之间有一个权衡,这些对象将一堆依赖关系捆绑在一起,而这些依赖关系有可能并没有全部被使用。这些对象可以成为全能的实用类,这就否定了明确列出依赖关系的价值。整个对象都必须被模拟,即使我们只依赖其中的一小部分。

对于其中的一些功能,比如超时和请求,有一个标准的Go解决方案。上下文库提供了一个持有当前请求信息的对象,并提供了围绕处理超时和取消的功能。

它可以进一步扩展到持有任何其他开发者想要到处传递的对象。在实践中,很多代码库将上下文作为一个容纳所有常用对象的容器。这是否让代码变得更漂亮?

尝试3:上下文

func DoTheThing(
    ctx context.Context,
    thing string,
) error {
    // Find User Key from request
    userKey, err := ctx.Value("request").(*Request).GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := ctx.Value("database").(*Database).Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = ctx.Value("httpClient").(*HttpClient).
            Post(ctx, "www.dothething.example", user.GetName())
        return err
    }
    return nil
}


这比列出所有内容要小得多,但如果ctx.Value(...)的任何调用返回nil或错误类型的值,代码就很容易在运行时出现恐慌。我们很难知道在调用这个之前哪些字段需要在ctx上填充,以及预期的类型是什么。我们也许应该检查这些参数。

尝试3:安全地上下文

func DoTheThing(
    ctx context.Context,
    thing string,
) error {
    // Find User Key from request
    request, ok := ctx.Value("request").(*Request)
    if !ok || request == nil { return errors.New("Missing Request") }
 
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    database, ok := ctx.Value("database").(*Database)
    if !ok || database == nil { return errors.New("Missing Database") }
 
    user, err := database.Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        httpClient, ok := ctx.Value("httpClient").(*HttpClient)
        if !ok || httpClient == nil {
            return errors.New("Missing HttpClient")
        }
 
        err = httpClient.Post(ctx, "www.dothething.example", user.GetName())
        return err
    }
    return nil
}


所以现在我们要正确地检查上下文是否包含我们需要的一切,并适当地处理错误。单一的ctx参数承载了所有常用的功能。这个上下文可以在少数集中的地方创建,用于不同的情况(例如,GetProdContext(), GetTestContext())。

不幸的是,现在的代码甚至比我们把所有的东西都作为参数传入的时候还要长。增加的大部分代码都是无聊的模板,让人很难看出代码到底在做什么。

这个解决方案确实可以让我们独立地处理并发请求(每个请求都有自己的上下文),但它仍然受到globals解决方案的很多其他问题的影响。特别是,没有简单的方法来告诉一个函数需要什么功能。例如,当你调用datastore.Get时,并不清楚ctx需要包含一个 "秘密",因此当你调用DoTheThing时也需要。

如果上下文缺少必要的功能,这段代码就会出现运行时故障。这可能导致生产中的错误。例如,如果我们CanDoTheThing很少返回true,我们可能不会意识到这个函数需要httpClient,直到它开始失败。在编译时没有简单的方法来保证上下文总是包含它所需要的一切。

我们的解决方案:静态类型的上下文
我们想要的是明确列出我们函数的依赖关系,但不要求我们在每个调用点都列出它们。我们希望在编译时验证所有的依赖关系,但我们也希望能够添加新的依赖关系,而不需要大规模地手动修改代码。

我们在可汗学院设计的解决方案是,用代表共享功能的接口来扩展上下文对象。每个函数都声明一个接口,描述它从静态类型的上下文中需要的所有功能。该函数可以通过访问上下文来使用所声明的功能。

上下文在函数签名后被正常处理,被传递给其他函数。但是现在编译器确保上下文为我们调用的每个函数实现了接口。

func DoTheThing(
    ctx interface {
        context.Context
        RequestContext
        DatabaseContext
        HttpClientContext
        SecretsContext
        LoggerContext
    },
    thing string,
) error {
    // Find User Key from request
    userKey, err := ctx.Request().GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := ctx.Database().Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = ctx.HttpClient().Post(ctx, "www.dothething.example", user.GetName())
    }
    return err
}


这个函数的主体几乎和使用globals全局的原始函数一样简单。函数签名列出了这个代码块的所有必要功能以及它所调用的函数。注意,调用ctx.Datastore().Read(ctx, ...)这样的函数并不要求我们改变ctx,尽管Read只需要一个子集的功能。

当我们需要调用一个以前不属于静态类型上下文的新接口时,我们需要在我们的函数签名中添加一个接口,只需一行。这就记录了新的依赖关系,使我们能够在上下文中调用新的函数。

如果我们的调用者的上下文中没有新的接口,他们会得到一个错误信息,描述他们缺少什么接口,他们可以在签名中添加同样的上下文。开发者在进行改变的同时有机会确保新的依赖关系是合适的。像这样的改变有时会在堆栈中产生涟漪,但这只是在每个受影响的函数中的一行改变,直到我们到达一个仍然拥有该接口的层次。对于深层调用栈来说,这可能有点烦人,但对于大的变化来说,这也是可以自动化的。

这些接口是由每个库声明的,通常由一个单一的调用组成,返回一个数据或该功能的客户对象。例如,这里是示例代码中的请求和数据库上下文接口。

type RequestContext interface {
    Request() *Request
    context.Context
}
 
type DatabaseInterface interface {
    Read(
        ctx interface{
            context.Context
            SecretsContext
            LoggerContext
        },
        key DatabaseKey,
    ) (*User, error)
}
 
type DatabaseContext interface {
    Database() DatabaseInterface
    context.Context
}

我们有一个库,为不同的情况提供上下文。在某些情况下,例如在我们的请求处理程序的开始,我们有一个基本的context.Context,需要将其升级为静态类型的context。

func GetProdContext() ProdContext {...}
func GetTestContext() TestContext {...}
 
func Upgrade(ctx *context.Context) ProdContext {...}


这些预先构建的上下文通常满足我们代码库中所有的上下文接口,因此可以被传递给任何函数。ProdContext连接到我们生产中的所有服务,而我们的TestContext使用了一堆被设计成可以正常工作的模拟。

我们还有一些特殊的上下文,是为我们的开发者环境和在cron jobs中使用的。每个上下文的实现方式不同,但都可以传递给我们代码中的任何函数。

我们也有只实现接口子集的上下文,比如只实现只读接口的ReadOnlyContext。你可以把它传递给任何不需要在其上下文接口中写入的函数。这就保证了在编译时,不小心的写入是不可能的。

我们有一个linter来确保每个函数都声明了必要的最小接口。这保证了函数不会只是声明它们需要 "一切"。你可以在示例代码中找到我们的linter的版本。

总结
我们在可汗学院使用静态类型的上下文已经有两年时间了。我们有十多个函数可以依赖的接口。它们使我们很容易跟踪代码中的依赖关系,而且对于注入测试用的模拟也很有用。我们可以在编译时保证所有的函数在被使用前都是可用的。

静态类型的上下文并不总是惊人的。它们比不声明你的依赖关系更啰嗦,而且当你 "只是想记录一些东西 "时,它们可能需要摆弄你的上下文接口,但它们也能节省工作。当一个函数需要使用新的功能时,可以简单地在上下文接口中声明它,然后使用它。

静态类型的上下文已经消除了整类的错误。我们永远不会有未初始化的全局或丢失的上下文值。我们再也不会有什么东西突变了一个全局而破坏了后来的请求。我们不会有一个函数意外地调用一个服务。Mocks总是能很好地配合,因为我们有一个全公司的惯例,在测试代码中注入依赖关系。

Go是一种鼓励明确和使用静态类型的语言,以提高可维护性。使用静态类型的上下文可以让我们在访问全局资源时实现这些目标。

banq:类似面向对象的依赖注入反转,将依赖通过接口上下文注入进来。

1
猜你喜欢