Go Programming Blueprints by Mat Ryer

Michael Lynch

Mat Ryer 著《Go Programming Blueprints》

原文由 Michael Lynch 发布,订阅该博客

我一直很欣赏 Mat Ryer 的作品,他的博客文章对我的 Go 编程方式产生了重要影响。这本书给我的感觉好坏参半。有些章节非常精彩,让我学到了宝贵的 Go 经验;而另一些章节则显得枯燥,过多地纠缠于第三方库的细节。总的来说,对于自认为是 Go 初学者或中级开发者的人,我依然会推荐这本书。


我喜欢的地方

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

不喜欢的地方

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

核心收获

Go 语言与标准库技巧

信号通道

  • 信号通道是在 Go 中实现线程安全事件的惯用方式。
  • 信号通道本质上就是元素类型为 struct{}chan
    • 信号通道不传递任何数据——它只用来通知某个事件已经发生。
    • Twitter 投票应用就是使用信号通道的一个好例子,用于:
      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.Minute 是传给 updateValue 的参数,但它出现在整个内联定义的 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)这一概念,但我觉得 Ryer 在他的博客上解释得更好。

如果代码中上下文和条件判断嵌套过深,或条件分支相隔太远,就很难保持思路的连贯,代码也会变得难以阅读。Ryer 主张以让逻辑尽量贴近屏幕左侧的方式来组织代码。

视线不佳的示例

当视线不佳时,逻辑会深层嵌套,条件代码块也会很庞大:

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 中,但为简单起见,我们假设它是一个查询参数。你期望用户像这样调用 API:/foo?key=abc123。你希望通过确保请求包含正确的 API 密钥来保护所有接口。

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

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

type contextKey struct {
  name string
}

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

出于我仍不太能完全理解的原因,这个键需要是一个包含字符串的结构体,而不是一个简单的字符串。

更新(2023-01-02):起初我很困惑为什么 contextKey 是一个包含字符串的结构体,而不是直接用字符串。书中 Ryer 解释说,这样做可以防止与其他具有相同值的键发生冲突,但我当时不明白为什么开发者不直接避免为不同用途复用相同的键。Matthew Riley 为我澄清了这一行为,让我意识到局部类型可以防止跨包的键冲突,而简单的字符串则做不到。

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

定义好上下文键之后,就可以像这样创建一个中间件函数:

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 辅助函数

Mat Ryer 的 HTTP 编码辅助模式

Ryer 主张将编码格式抽象出来,让 HTTP 处理器无需关心数据交换格式。这样一来,如果你的接口原本使用 JSON,之后想改成 protobuf,就只需修改一个文件。

Ryer 使用 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)
  }
}

我对 Ryer 编码辅助模式的改进

我喜欢 Ryer 这种辅助方法的思路,但我认为它为了过少的收益付出了过高的抽象成本。你有多大可能会把整个 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 接口,于是写了类似这样的代码:

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

糟糕!你刚刚泄露了所有人的邮箱和密码哈希。

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

我对在自己的应用中犯这类错误非常警惕,所以总是很好奇别人是如何处理这个问题的。

Ryer 的 Public 方法模式

Ryer 提出通过为同时具有内部和外部表示的结构体添加一个 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")
}

我喜欢 Mat Ryer 这个技巧,如果在代码库中确立了这一约定,它确实很有效,但它并不是我在 Go 中解决这个问题的首选方案。

我对 Ryer 这种方法的主要不满在于它破坏了封装性。我更倾向于让内部类型尽可能简单,并尽量减少对客户端如何使用数据的假设。添加一个 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 信号时,重写默认的信号处理器以执行自定义清理操作。

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

评论