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

- 我的评分: 8 / 10
- 购买链接
我是马特·瑞尔作品的粉丝,他的博客文章对我使用 Go 编程的方式产生了重大影响。我觉得这本书有好有坏。有些章节非常引人入胜,让我学到了宝贵的 Go 知识,而另一些章节则很枯燥,过于纠缠第三方库的细枝末节。总体而言,我仍然会向所有自认为是 Go 初学者或中级程序员的人推荐它。
我喜欢的地方
- 示例应用种类丰富,很好地在真实场景中展示了 Go 的特性。
- 书中包含非常优雅的 Go 代码,让我学到了几种新的地道语言模式。
- 以有趣的方式运用了 Go 标准库。
- 终于让我弄懂了以前一直没理解的 HTTP 上下文。
- 提供无 DRM 限制的格式。
我不喜欢的地方
- 大多数示例都聚焦于高度可扩展的应用,而不是我通常编写的单服务器 Go 应用。
- 这本书过度依赖重量级的 Google 库(例如 Google Maps、OAuth、gRPC、AppEngine)。
- 许多示例都深入某个特定库的细枝末节,而不是解决方案中与 Go 相关的部分。
- 推荐了几种极不安全的软件实践:
- 建议开发者在不知道该分配什么权限时将
0777用作默认位掩码。 - 未能防范目录遍历,导致示例应用中出现任意写入漏洞,可实现远程代码执行。
- 未能防范针对用户上传的简单拒绝服务攻击。
- 建议开发者在不知道该分配什么权限时将
- 行文编辑和代码中的错误检查都很差。
- 存在大量粗心的语法和代码错误。
- 用户已经提交了修复,但多年来一直被忽略。
- 在原生 JavaScript 同样适用甚至更好的地方,却用 jQuery 使示例复杂化。
- Bash 脚本示例显得很草率。
- 整本书的代码质量不一致。
- 有些示例优雅直观,而另一些则像是初稿。
- 存在两个独立的 GitHub 仓库:一个来自作者,一个来自出版社。
- 作者的仓库似乎是正确的那个。
- 有在 Windows 上运行示例的说明,但感觉像是未经测试的事后补充。
- 由于第三方依赖已消失,一些示例已无法编译。
核心收获
Go 语言和标准库技巧
Signal channels(信号通道)
- Signal channels 是在 Go 中实现线程安全事件的地道方式。
- Signal channels 只是类型为
struct{}的chan- Signal channels 不传递任何数据——它们只是发出某个事件已发生的信号。
- Twitter 投票应用是使用 Signal channels 的一个很好的例子,用于:
- 允许客户端中断服务器。
- 指示后台进程已完成工作。
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)”的概念,但我认为瑞尔在他的博客上把这个概念解释得更好。
如果存在深层的上下文和条件嵌套,代码就会变得难以阅读,而且当条件分支相距很远时,很难保持上下文。瑞尔主张构建代码,使逻辑保持在屏幕左侧附近。
视线不佳
当视线不佳时,逻辑被深深嵌套,条件块很大:
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,而只需修改一个文件。
瑞尔使用辅助函数 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)
}
}我如何改进瑞尔的编码辅助模式
我喜欢瑞尔的辅助方法 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)来扩展系统
- 组合水平可扩展服务的精彩示例。
- 使用 NSQ 发布消息。
- 使用 Twitter 流式 API 从 Twitter 读取实时数据。
- 使用 MongoDB 存储数据。
- 看到一个由简单部件组成却高度可扩展的系统非常酷。
- 在 HTTP 连接中使用自定义传输函数来定制底层 TCP 连接的低层行为。
- 如何在应用收到来自操作系统的
SIGINT或SIGTERM信号时重写默认信号处理器以进行自定义清理的良好示例。
随机一篇博客