Go Programming Blueprints by Mat Ryer

Michael Lynch

Go Programming Blueprints(《Go编程蓝图》)by Mat Ryer(马特·瑞尔)

我是马特·瑞尔作品的粉丝,他的博客文章对我使用 Go 编程的方式产生了重大影响。我觉得这本书有好有坏。有些章节非常引人入胜,让我学到了宝贵的 Go 知识,而另一些章节则很枯燥,过于纠缠第三方库的细枝末节。总体而言,我仍然会向所有自认为是 Go 初学者或中级程序员的人推荐它。


我喜欢的地方

  • 示例应用种类丰富,很好地在真实场景中展示了 Go 的特性。
  • 书中包含非常优雅的 Go 代码,让我学到了几种新的地道语言模式。
  • 以有趣的方式运用了 Go 标准库。
  • 终于让我弄懂了以前一直没理解的 HTTP 上下文。
  • 提供无 DRM 限制的格式。

我不喜欢的地方

  • 大多数示例都聚焦于高度可扩展的应用,而不是我通常编写的单服务器 Go 应用。
  • 这本书过度依赖重量级的 Google 库(例如 Google Maps、OAuth、gRPC、AppEngine)。
    • 许多示例都深入某个特定库的细枝末节,而不是解决方案中与 Go 相关的部分。
  • 推荐了几种极不安全的软件实践:
  • 行文编辑和代码中的错误检查都很差。
    • 存在大量粗心的语法和代码错误。
    • 用户已经提交了修复,但多年来一直被忽略。
  • 在原生 JavaScript 同样适用甚至更好的地方,却用 jQuery 使示例复杂化。
  • Bash 脚本示例显得很草率。
  • 整本书的代码质量不一致。
    • 有些示例优雅直观,而另一些则像是初稿。
  • 存在两个独立的 GitHub 仓库:一个来自作者,一个来自出版社
  • 有在 Windows 上运行示例的说明,但感觉像是未经测试的事后补充。
  • 由于第三方依赖已消失,一些示例已无法编译。

核心收获

Go 语言和标准库技巧

Signal channels(信号通道)

  • Signal channels 是在 Go 中实现线程安全事件的地道方式。
  • Signal channels 只是类型为 struct{}chan
    • Signal channels 不传递任何数据——它们只是发出某个事件已发生的信号。
    • Twitter 投票应用是使用 Signal channels 的一个很好的例子,用于:
      1. 允许客户端中断服务器。
      2. 指示后台进程已完成工作。

time.Ticker

我以前从未见过 time.Ticker 类型,还曾自己重新实现了一个版本。它是一种按固定时间间隔执行代码的简单方式:

for range time.NewTicker(5 * time.Minute).C {
  // Execute this code every five minutes.
}

我在 PicoShare 中使用 time.Ticker 来调度定期的数据库维护

flags.Duration 的灵活性令人印象深刻

  • flags.Duration 原生支持不同的时间单位,如 55s10m
    • 也就是说,当你将 flags.Duration 用作命令行标志时,你的命令行界面可以接受像 --interval 10m 这样的标志,而 flags 包会将其原生解析为 time.Duration

将测试包与生产代码分离

  • 将测试写在与生产代码不同的包中,可以得到更好的测试。
    • 例如,为 foo 包编写的测试放在同一目录下名为 foo_test 的包中。
    • 通常,Go 工具禁止在同一文件夹中包含多个包,但对测试做了例外。
  • 独立的 _test 包确保测试只能访问生产包的公开成员。
    • 这会促使测试去验证面向客户端的行为,而不是内部实现细节。

将函数参数放在参数列表的末尾

如果你的函数接受函数参数,请将它们放在参数列表的末尾。否则,读者很难分清哪个参数属于内部函数,哪个属于外部函数。

不佳的参数顺序

假设你有一个函数 updateValue,它会轮询值的变化并定期更新本地副本,因此它需要接受一个 SetValFn

type SetValFn func(key, value string) bool

如果 SetValFn 参数是第一个参数,在函数定义中看起来一切正常:

func updateValue(setFn SetValFn, interval time.Duration) {
  for range time.NewTicker(interval).C {
    value := fetchValue()
    setFn("somekey", value)
  }
}

但当需要调用 updateValue 时,调用点会变得难以阅读:

updateValue(func(key, value string) bool {
  if err := DB.SetKey(key, value); err != nil {
    return false
  }
  return true
}, 5*time.Minute) // Which function call is this for?

微妙之处在于,5*time.MinuteupdateValue 的一个参数,但它出现在整个 SetValFn 内联函数定义之后,因此很难注意到它与 updateValue 的关联。

更好的参数顺序

对上面示例更好的重写方式是确保函数参数在列表的最后:

// Reorder arguments so that SetValFn is last
func updateValue(interval time.Duration, setFn SetValFn) {

这样,在调用点就能更明显地看出两个参数都是传给 updateValue 的:

updateValue(5*time.Minute, func(key, value string) bool {
  if err := DB.SetKey(key, value); err != nil {
    return false
  }
  return true
})

在代码中优先保证视线清晰

书中提到了“视线(line of sight)”的概念,但我认为瑞尔在他的博客上把这个概念解释得更好。

如果存在深层的上下文和条件嵌套,代码就会变得难以阅读,而且当条件分支相距很远时,很难保持上下文。瑞尔主张构建代码,使逻辑保持在屏幕左侧附近。

视线不佳

当视线不佳时,逻辑被深深嵌套,条件块很大:

if something.OK() {
  something.Lock()
  defer something.Unlock()
  err := something.Do()
  if err == nil {
    stop := StartTimer()
    defer stop()
    log.Println("working...")
    doWork(something)
    <-something.Done()
    log.Println("finished")
    return nil
  } else {
    return err
  }
} else {
  return errors.New("something not ok")
}

视线良好

为了改善视线,你可以反转条件逻辑,在出错时提前退出,然后将其余逻辑保留在条件之外:

if !something.OK() {  // flipped
  return errors.New("something not ok")
}
something.Lock()
defer something.Unlock()
err := something.Do()
if err != nil {       // flipped
  return err
}
stop := StartTimer()
defer stop()

log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nil

在 HTTP 处理器中使用 context

我做了五年业余的 Go Web 开发,直到读了这本书才明白 context.Context 在 HTTP 处理器中的意义。第 6 章提供了很好的解释,但我会在此尝试总结一下。

假设你的 Web 应用要求用户在每个 HTTP 请求中都提供 API 密钥。它可以是请求头、URL 查询参数或 Cookie,但为简单起见,我们假设它是查询参数。你期望用户使用像 /foo?key=abc123 这样的密钥来调用你的 API。并且你想通过确保请求具有正确的 API 密钥来保护所有端点。

要实现这一点,你可以创建一个 HTTP 中间件函数。中间件函数会形成一个链,因此多个中间件函数可以串行处理同一个 HTTP 请求。中间件函数通过使用 context.Context 将数据传递给后续的 HTTP 处理器。

要强制验证 API 密钥,我们首先需要为在 Context 对象中存储 API 密钥创建一个键:

type contextKey struct {
  name string
}

var contextKeyAPIKey = &contextKey{"api-key"}

出于我仍然不太理解的原因,该键需要是包含字符串的结构体,而不是简单的字符串。

更新(2023-01-02):起初我很困惑为什么 contextKey 是包含字符串的结构体而不是简单的字符串。在书中,瑞尔解释说这一决定可以防止与具有相同值的其他键发生冲突,但我不理解为什么开发者不直接避免为不同目的重用相同的键。Matthew Riley 为我澄清了这一行为,并让我意识到本地类型可以防止跨包的冲突,而简单的字符串则无法做到。

如果你使用像 const contextKeyToken := "token" 这样的上下文键,而另一个处理同一请求的包也使用了键 "token",那么你们就会互相覆盖对方的上下文值。通过在你的包内定义自定义的本地类型,你可以保证 Context 不会将来自任何其他包的令牌视为与你的相等,因为它们具有不同的类型。

既然你已经定义了上下文键,就可以像这样创建一个中间件函数:

func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    key := r.URL.Query().Get("key")
    if key != "abc123" {
      http.Error(w, "Invalid API key", http.StatusUnauthorized)
      return
    }
    // Add the API key to the request context.
    ctx := context.WithValue(r.Context(), contextKeyAPIKey, key)
    fn(w, r.WithContext(ctx))
  }
}

在定义路由时,用 withAPIKey 中间件包装请求处理器:

mux := http.NewServeMux()
mux.HandleFunc("/foo", withAPIKey(s.handleFoo))

withAPIKey 中间件保证请求中的 API 密钥是有效且存在的。如果位于 withAPIKey 下游的任何请求处理器需要访问 API 密钥,它们可以调用这个辅助函数:

func APIKey(ctx context.Context) string {
  k := ctx.Value(contextKeyAPIKey)
  if k == nil {
    panic("no API key in request")
  }
  key, ok := k.(string)
  if !ok {
    panic("API key in request is not a string")
  }
  return key
}

handleFoo 处理器位于 withAPIKey 中间件的下游,因此它可以从请求上下文中访问 API 密钥:

func (s *Server) handleFoo(w http.ResponseWriter, r *http.Request) {
  log.Printf("handling /foo, API key=%v", APIKey(r.Context()))
}

HTTP 辅助函数

瑞尔的 HTTP 编码辅助模式

瑞尔主张将编码格式抽象出来,使 HTTP 处理器与交换格式无关。这样,如果你的接口使用 JSON,你可以将其改为 protobuf,而只需修改一个文件。

瑞尔使用辅助函数 decoderespond 来隐藏编码细节,使你的路由处理器看起来像这样:

func handleFooPost(w http.ResponseWriter, r *http.Request) {
  var payload struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }
  if err := decode(r, &payload); err != nil {
    respondErr(ctx, w, r, err, http.StatusBadRequest)
    return
  }

  // Do something with the request.

  response := struct {
      ID string `json:"id"`
    }{
      ID: "1234",
    }
  respond(ctx, w, r, response, http.StatusOK)
}

然后 decoderespond 分别处理 JSON 的反序列化和序列化:

// decode parses JSON from an HTTP request body.
func decode(r *http.Request, v interface{}) error {
  err := json.NewDecoder(r.Body).Decode(v)
  if err != nil {
    return err
  }
  if valid, ok := v.(interface {
    OK() error
  }); ok {
    err = valid.OK()
    if err != nil {
      return err
    }
  }
  return nil
}

// respond serializes response data to JSON in the body of an HTTP request.
func respond(ctx context.Context, w http.ResponseWriter, r *http.Request, v interface{}, code int) {
  var buf bytes.Buffer
  err := json.NewEncoder(&buf).Encode(v)
  if err != nil {
    respondErr(ctx, w, r, err, http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json; charset=utf-8")
  w.WriteHeader(code)
  _, err = buf.WriteTo(w)
  if err != nil {
    log.Errorf(ctx, "respond: %s", err)
  }
}

我如何改进瑞尔的编码辅助模式

我喜欢瑞尔的辅助方法 idea,但我认为它为太小的好处付出了过高的抽象代价。你多久会将 Web 应用重写为使用不同的编码方案呢?

而且,你无论如何都会泄露抽象,因为路由处理器必须在结构体中指定 JSON 标签,尽管它们本不应该知道任何关于格式的信息。

我也不喜欢用 JSON 编写错误消息,因为 Go HTTP 栈中的大多数组件都以纯文本错误失败,所以 JSON 格式的错误意味着客户端必须同时将错误当作格式正确的 JSON 和纯文本来查找。直接始终以纯文本发送错误消息更简单。

对于成功的 JSON 响应,我使用一个名为 respondJSON 的函数,像这样:

func respondJSON(w http.ResponseWriter, data interface{}) {
  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")
  if err := json.NewEncoder(w).Encode(data); err != nil {
    log.Fatalf("failed to encode JSON response: %v", err)
  }
}

而我只是内联进行 JSON 解码,所以我的 handleFooPost 看起来会像这样:

func handleFooPost(w http.ResponseWriter, r *http.Request) {
  var payload struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }
  if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
    http.Error(w, "JSON is invalid", http.StatusBadRequest)
    return
  }

  // Do something with the request.

  respondJSON(w, struct {
      ID string `json:"id"`
    }{
      ID: "1234",
    })
}

我最终会重复那个 json.NewDecoder(r.Body).Decode(&payload) 代码片段,但它只有一行,所以没什么大不了的。

对客户端隐藏内部结构体细节

一个影响所有语言的 Web 开发陷阱是意外的数据泄露。假设你有一个用于表示用户数据的内部结构体:

type User struct {
  Username string `json:"username"`
  DisplayName string `json:"displayName"`
}

你想暴露一个像 /user?id=1234 这样的 JSON API,于是你写了类似这样的代码:

func handleUserGet(w http.ResponseWriter, r *http.Request) {
  user, err := loadUser(r.URL.Query().Get("id"))
  if err != nil {
    http.Error(w, "Failed to load user", http.StatusInternalServerError)
    return
  }

  respondJSON(w, user)
}

当用户查询 /user 路由时,他们会得到关于用户的公开信息:

curl https://example.com/user?id=1234
{
  "username": "alice123",
  "displayName": "Alice"
}

到目前为止,一切正常。但一个月后,你意识到想调整内部结构体以传递更多数据,比如用户的电子邮件地址和密码哈希:

type User struct {
  Username string `json:"username"`
  DisplayName string `json:"displayName"`
  Email string `json:"email"`               // Add these for
  PasswordHash string `json:"passwordHash"` // internal operations.
}

即使你没有动过 handleUserGet,现在当用户调用 /user 路由时,他们会得到大量新信息:

curl https://example.com/users?id=1234
{
  "username": "alice123",
  "displayName": "Alice",
  "email": "[email protected]",
  "passwordHash": "$2a$10$J5zqqeQgH80ScyOSeCNCD.1V3ApJ1ULYMwMEhOjG6j4SM1mqL84YO"
}

哎呀!你刚刚泄露了所有人的电子邮件地址和密码哈希。

当我以前做渗透测试时,我在现实世界中发现好几家公司都犯了这个错误。这是一个微妙的错误,因为从开发者的角度来看,他们实现 handlerUserGet 时一切都按预期工作。当他们向 User 结构体添加字段时,他们并没有动 handleUsersGet,所以除非他们定期检查应用的原始 HTTP 流量,否则不会注意到泄露。

我很担心在自己的应用中犯这类错误,所以我总是好奇其他人是如何处理这个问题的。

瑞尔的 Public 方法模式

瑞尔提议通过为具有内部和外部两种表示的结构体添加 Public 方法来解决上述问题:

type obj struct {
  value1 string
  value2 string
  value3 string
}

func (o *obj) Public() interface{} {
  return map[string]interface{}{"one": o.value1, "three": o.value3}
}

func TestPublic(t *testing.T) {
  is := is.New(t)

  o := &obj{
    value1: "value1",
    value2: "value2",
    value3: "value3",
  }

  v, ok := meander.Public(o).(map[string]interface{})
  is.Equal(true, ok)
  is.Equal(v["one"], "value1")
  is.Nil(v["two"])
  is.Equal(v["three"], "value3")
}

我喜欢瑞尔的这种技巧,我认为如果你在代码库中确立了这种约定,它会很有效,但它不是我最喜欢的在 Go 中解决这个问题的方案。

我对瑞尔技巧的主要不满是它违反了封装。我更喜欢让我的内部类型尽可能简单,并尽量减少对客户端如何使用数据的假设。添加 Public 方法意味着该类型在预判客户端将如何使用数据,并迫使所有包含该类型的端点都暴露相同的字段。

我更喜欢的细节隐藏方法

在我的 Go 代码中,我更喜欢为面向外部的数据使用不同的结构体。当我需要向外部客户端发布数据时,我会将数据从内部结构体复制到我的外部结构体中。

通常,我会使用在行内声明的匿名结构体,这样我甚至不需要另一个命名类型:

// my internal data
type User struct {
  Username string
  DisplayName string
  Email string
  PasswordHash string
}

func handleUserGet(w http.ResponseWriter, r *http.Request) {
  user, err := loadUser(r.URL.Query().Get("id"))
  if err != nil {
    http.Error(w, "Failed to load user", http.StatusInternalServerError)
    return
  }

  // Copy the fields from User that I want to publish into a new anonymous
  // struct.
  respondJSON(w, struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }{
    Username: user.Username,
    DisplayName: user.DisplayName,
  })
}

我更喜欢这种方法有几个原因:

  • 它为防止意外泄露提供了额外的保护。
    • 即使有人不小心在外部类型中包含了内部结构体,也不会打印出任何内容,因为内部结构体字段没有 JSON 标签。
  • 它使你要返回的数据更加明确。
  • 它让你对数据有更细粒度的控制。
    • 使用 Public 模式时,所有包含该类型的端点都必须以相同格式返回数据,而使用上述方法时,每个端点都可以决定暴露哪些字段以及以何种格式暴露。

值得一提的有趣章节

使用 WebSocket 的聊天应用

  • 使用 goroutine 和 WebSocket 的精彩演示。

添加用户账户

  • 如何链式调用 HTTP 处理器的良好示例。

构建分布式系统与处理灵活数据

  • 仅这一章就值回了书价。
  • 水平扩展:通过增加节点来提高可靠性或性能的系统扩展方式
  • 垂直扩展:通过增加单个节点的资源(例如,增加内存或 CPU)来扩展系统
  • 组合水平可扩展服务的精彩示例。
  • 看到一个由简单部件组成却高度可扩展的系统非常酷。
  • 在 HTTP 连接中使用自定义传输函数来定制底层 TCP 连接的低层行为。
  • 如何在应用收到来自操作系统的 SIGINTSIGTERM 信号时重写默认信号处理器以进行自定义清理的良好示例。

原文由 Michael Lynch 发布

本文章由 muse-spark-1.2-contributor 进行翻译