Mat Ryer 著《Go Programming Blueprints》
原文由 Michael Lynch 于 发布,订阅该博客

- 我的评分:8 / 10
- 购买链接
我一直很欣赏 Mat Ryer 的作品,他的博客文章对我的 Go 编程方式产生了重要影响。这本书给我的感觉好坏参半。有些章节非常精彩,让我学到了宝贵的 Go 经验;而另一些章节则显得枯燥,过多地纠缠于第三方库的细节。总的来说,对于自认为是 Go 初学者或中级开发者的人,我依然会推荐这本书。
我喜欢的地方
- 示例应用种类丰富,很好地在真实场景中展示了 Go 的特性。
- 书中包含了非常优雅的 Go 代码,让我学到了几种新的惯用写法。
- 以有趣的方式运用了 Go 标准库。
- 终于让我理解了以前一直没搞懂的 HTTP Context。
- 提供无 DRM 限制的格式。
不喜欢的地方
- 大多数示例都聚焦于高可扩展性应用,而我平时写的更多是单服务器的 Go 应用。
- 这本书过度依赖笨重的 Google 相关库(例如 Google Maps、OAuth、gRPC、AppEngine)。
- 许多示例都深入到某个特定库的细枝末节,而不是与 Go 相关的解决方案核心。
- 推荐了几种极不安全的软件实践:
- 建议开发者在不知道该设置何种权限时,使用
0777作为默认权限掩码。 - 未对目录遍历做防护,导致示例应用中出现任意文件写入漏洞,可实现远程代码执行。
- 未对用户上传做防护,导致应用容易受到针对用户上传的简单拒绝服务攻击。
- 建议开发者在不知道该设置何种权限时,使用
- 文字编辑粗糙,代码中的错误检查也不到位。
- 存在大量粗心的语法和代码错误。
- 用户已经提交了修复,但多年来一直被忽略。
- 在一些原本用原生 JavaScript 就能同样甚至更好地实现的地方,却用 jQuery 把示例复杂化了。
- Bash 脚本示例写得比较草率。
- 全书的代码质量参差不齐。
- 有些示例优雅直观,有些则像是初稿。
- 有两个独立的 GitHub 仓库:一个来自作者,一个来自出版社。
- 作者的仓库似乎才是正确的那个。
- 书中提供了在 Windows 上运行示例的说明,但感觉像是未经测试的临时补充。
- 由于部分第三方依赖已经失效,一些示例现在已无法编译。
核心收获
Go 语言与标准库技巧
信号通道
- 信号通道是在 Go 中实现线程安全事件的惯用方式。
- 信号通道本质上就是元素类型为
struct{}的chan- 信号通道不传递任何数据——它只用来通知某个事件已经发生。
- Twitter 投票应用就是使用信号通道的一个好例子,用于:
- 允许客户端中断服务端。
- 指示后台任务已完成。
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原生支持不同的时间单位,例如55s或10m。- 也就是说,当你将
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 使用 decode 和 respond 这两个辅助函数来隐藏编码细节,使得路由处理器的代码看起来像这样:
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)
}而 decode 和 respond 则分别负责 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)来扩展系统
- 一个组合水平可扩展服务的精彩示例。
- 使用 NSQ 来发布消息。
- 使用 Twitter 流式 API 来读取 Twitter 的实时数据。
- 使用 MongoDB 来存储数据。
- 看到一个由简单组件构成却具备高度可扩展性的系统,非常酷。
- 在 HTTP 连接中使用自定义的传输函数来自定义底层 TCP 连接的低级行为。
- 一个很好的示例,展示了如何在应用收到操作系统发来的
SIGINT或SIGTERM信号时,重写默认的信号处理器以执行自定义清理操作。
随机一篇博客
评论
登录后参与讨论