用无上下文的Go语言实现HTTP服务


许多Go开发者,尤其是新开发者,发现一个不明显的问题是,我到底该如何把所有我需要的东西都传到我的处理程序中?
我们没有像Java或C那样花哨的控制反转系统。 http.处理程序是静态签名,所以我不能只传递我真正想要的东西。看来我们只有3个选择:使用globals、将处理程序包裹在一个函数中,或者在context.Context中传递东西。

这里举例是电子商务:
任务是写一个端点,给定某个类别的ID,返回该类别的物品列表。
这个端点需要访问我们的

  1. items.Serviceto 来做实际的查询,
  2. logging.Service 是用来以防出错,
  3. 还有 metrics.Service 来获得甜蜜的营销指标。

让我们来看看这三种选择:
1、全局变量
我们在处理程序中的第一次尝试是使用全局变量globals。这是一种相当自然的方式,许多初学者都倾向于这样做。

func GetItemsInCategory(w http.ResponseWriter, r *http.Request) {
  categoryID := // get the id in your favorite way possible (net/http, mux, chi, etc)
  
  metrics.Service.Increment(
"category_lookup", categoryID)
  
  items, err := items.Service.LookupByCategory(categoryID)
  if err != nil {
    logging.Service.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  err := json.NewEncoder(w).Encode(items)
  if err != nil {
    logging.Service.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
}

我们都曾经写过这样的代码。然后我们很快就知道,全局变量并不好的,因为它们会使代码变得不灵活,无法测试。
这自然而然地导致了一个由书籍、文章、视频等组成的兔子洞,告诉你 "注入你的依赖关系!"。
这段代码是不可能的,让我们尝试一下注入。

2. 依赖性注入|获得你的助推器
好吧,我们不想使用globals全局变量,但http.Handler有一个固定的签名。那么我们该怎么做呢?当然是包裹它

func GetItemsInCategory(itemsService items.Service, metricsService metrics.Service, loggingService logging.Service) http.Handler {
  return http.HandlerFunc(func GetItemsInCategory(w http.ResponseWriter, r *http.Request) {
    categoryID := // get the id in your favorite way possible (net/http, mux, chi, etc)

    metricsService.Increment(
"category_lookup", categoryID)

    items, err := itemsService.LookupByCategory(categoryID)
    if err != nil {
      logging.Service.Logf(
"failed to get items for category, %d: %w", categoryID, err)
      http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
      return
    }

    err := json.NewEncoder(w).Encode(items)
    if err != nil {
      loggingService.Logf(
"failed to get items for category, %d: %w", categoryID, err)
      http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
      return
    }
  })
}

这样做比较好,因为我们不再依赖全局状态,但它仍然有很多地方需要改进。主要是,它使每一个处理程序都变成了一个大的、长的、令人讨厌的混乱的编写。很容易想象,如果我们再增加几个服务,这个签名就会延长2-3倍。
所以我们阅读了一下,发现我们的*http.Request里面有一个context.Context! 我们可以创建一个中间件,注入我们所有的依赖项,然后处理程序可以直接提取它需要的东西!这肯定会解决我们所有的问题。

2. 引入Context上下文
首先,让我们制作我们所谈论的中间件。我们只是要在设置好所有的依赖关系后再进行内联。

// initialize things

contextMiddleware := func(h http.Handler) http.Handler {
  return http.HandlerFunc(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    ctx = context.WithValue(ctx, CtxKeyLogger, loggingService)
    ctx = context.WithValue(ctx, CtxKeyItems itemsService)
    ctx = context.WithValue(ctx, CtxKeyMetrics, metricsService)
    ...
// add ALL the services we would ever need!
    h.ServeHTTP(w, r.WithContext(ctx))
  })
}

// register the middleware

现在,我们已经添加了所有这些内容,我们可以再次重做我们的处理程序。

func GetItemsInCategory(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  
  loggingService := ctx.Value(CtxKeyLogging).(logging.Service)
  metricsService := ctx.Value(CtxKeyMetrics).(metrics.Service)
  itemsService  := ctx.Value(CtxKeyItems).(items.Service)

  categoryID := // get the id in your favorite way possible (net/http, mux, chi, etc)
  
  metricsService.Increment(
"category_lookup", categoryID)
  
  items, err := itemsService.LookupByCategory(categoryID)
  if err != nil {
    loggingService.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  err := json.NewEncoder(w).Encode(items)
  if err != nil {
    logging.Service.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
}

不错,很容易! 这里不会出什么问题。好吧,除了在3个月后,有人在应用程序的其他地方移动了一行代码,这些服务中的一个不再存在于context中。
你可能会说:"好吧,聪明的先生,我只需要检查一下确保即可!"。

func GetItemsInCategory(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  
  loggingService, ok := ctx.Value(CtxKeyLogging).(logging.Service)
  if ok != nil {
    // We dont have a logging service to log to!!!
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  metricsService, ok := ctx.Value(CtxKeyMetrics).(metrics.Service)
  if ok != nil {
    loggingService.Logf(
"metrics service not in context!", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  itemsService, ok := ctx.Value(CtxKeyItems).(items.Service)
  if ok != nil {
    loggingService.Logf(
"items service not in context!", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  categoryID :=
// get the id in your favorite way possible (net/http, mux, chi, etc)
  
  metricsService.Increment(
"category_lookup", categoryID)
  
  items, err := itemsService.LookupByCategory(categoryID)
  if err != nil {
    loggingService.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  
  err := json.NewEncoder(w).Encode(items)
  if err != nil {
    logging.Service.Logf(
"failed to get items for category, %d: %w", categoryID, err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
}


正如你所看到的,即使在小的、简单的、设计好的例子中,以安全的方式来做这件事也变得有点混乱。

我们使用Go的原因之一就是为了避免所有这些类型检查的混乱! 通过使用上下文作为我们的依赖关系的某种抓包,我们有效地放弃了类型安全。此外,我们在运行时才知道事情是否存在。为了解决这个问题,我们最终会发现到处都是长串的错误检查。

这可不好。我们的三个选项都以各自的方式糟糕。如果我们想注入我们的依赖关系,似乎我们无法逃离冗长的地狱。当然,你可以绕过这些问题,创建花哨的函数来包装很多东西,但它实际上并没有解决问题。

banq注:依赖注入后还需要检测?在java中从来不检测,运行时会报错,没有注入,认为需要手工检测是一种过度思考,或者不熟悉IOC/DI概念以后的焦虑表现吧?

4. Structs 
你们中的一些人可能已经对我大喊大叫,让我说到这个问题,但是在教过这个课题好几次之后,先看看其他的解决方案确实有帮助。
我们没有考虑的 "第四种方法 "其实很简单。
我们创建一个结构来保存我们需要的依赖关系。
我们将我们的处理程序作为方法添加到该结构中。
让我们来看看一个例子。

type CategoryHandler struct {
  metrics metrics.Service
  logger logging.Service
  
  items items.Service
}

func (h *CategoryHandler) GetItemsInCategory(w http.ResponseWriter, r *http.Request) {
    categoryID := // get the id in your favorite way possible (net/http, mux, chi, etc)

    h.metrics.Increment(
"category_lookup", categoryID)

    items, err := h.items.LookupByCategory(categoryID)
    if err != nil {
      h.logger.Logf(
"failed to get items for category, %d: %w", categoryID, err)
      http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
      return
    }

    err := json.NewEncoder(w).Encode(items)
    if err != nil {
      h.logger.Logf(
"failed to get items for category, %d: %w", categoryID, err)
      http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
      return
    }
}

这个解决方案有几个很大的好处。

  • 我们所依赖的一切在构建时都是已知的,并且是类型安全的(与上下文不同)。
  • 我们只有最少的额外模板(不像封装函数)。
  • 保持可测试性(与globals不同)
  • 允许我们将相关的处理程序 "分组group "为单元

第4条是我们还没有触及的一个问题,但是现在我们有了这个结构,我们就可以对有共同依赖关系的处理程序进行分组,或者从逻辑上将它们放在一起。
例如,我们可以在这里添加一个处理程序来添加一个新的类别。我们可以创建一个MetricsHandler,将所有与度量相关的端点组合在一起。你可以根据自己的意愿,对其进行细化或扩展(在大多数情况下,细化可能更好)。

banq注:这个方法也是一种依赖注入,不过是构造函数形式的依赖注入,静态上下文是一种类似JavaBeans的setterX方法注入依赖。静态上下文的注入见下面链接: