乐趣区

用Go语言写了7年HTTP服务之后【译】

趁着元旦休假 + 春节,尝试把 2018 年期间让我受益的一些文章、问答,翻译一下。欢迎指正、讨论,希望对你也有所帮助。原文:How I write Go HTTP services after seven years
以下,开始正文我从 r59(1.0 版本之前的版本)便开始使用 Go,过去 7 年里一直用 Go 来编写 API 和 HTTP 服务。在 Machine Box(译者注:作者公司),写各式各样的 API 是我的主要工作。我们是做机器学习的,机器学习本身又很复杂,我编写的 API 就是为了让开发者更容易理解和接入机器学习。目前为止,收到的反馈还都不错。
如果你还没尝试过 Machine Box,请赶紧试一试,并给我一些反馈吧。
多年以来,我写服务端程序的方式发生了很多变化,我想把我编写服务端程序的方式分享给你,希望能对你有所帮助。
server struct
我写的组件基本都包含一个类似这样的 server 结构体:
type server struct {
db *someDatabase
router *someRouter
email EmailSender
}
routes.go
在组件里还有一个单独的文件 routes.go,用来配置路由:
package app
func (s *server) routes() {
s.router.HandleFunc(“/api/”, s.handleAPI())
s.router.HandleFunc(“/about”, s.handleAbout())
s.router.HandleFunc(“/”, s.handleIndex())
}
routes.go 很方便,因为维护代码的时候大部分都从 URL 和错误日志入手,看一眼 routers.go,能帮我们快速定位。
定义 handler 来处理不同请求
func (s *server) handleSomething() http.HandlerFunc { …}

handler 可以通过 s 访问相关数据。
返回 handler 其实 handler 中并不直接处理请求,而是返回一个函数,创造一个闭包环境,在 handler 中我们就能这样操作了:
func (s *server) handleSomething() http.HandlerFunc {
thing := prepareThing()
return func(w http.ResponseWriter, r *http.Request) {
// use thing
}
}
prepareThing 只需调用一次,也就是你可以通过在 handler 初始化时,只获取一次 thing 变量,就能在整个 handler 中使用。但要保证获取的是共享数据。如果 handler 中更改数据,需要使用 mutex 或者其他方式加锁保护。
通过传参解决 handler 的特殊情况如果某个 handler 依赖外部数据,通过传参来解决:
func (s *server) handleGreeting(format string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, format, “World”)
}
}
format 参数可以被 handler 直接使用。
用 HandlerFunc 替换 Handler 我现在在几乎所有地方都用 http.HandlerFunc 来替换 http.Handler 了。
func (s *server) handleSomething() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

}
}
这两个类型很多情况下都可以互换,对我来讲 http.HandlerFunc 更易读。
用 Go 函数实现中间件中间件函数的入参是 http.HandlerFunc,返回值是一个新 http.HandlerFunc。新 http.HandlerFunc 可以在原始 HandlerFunc 之前或者之后调用,甚至可以决定不调用原始 HandlerFunc(译者注:看例子吧).
func (s *server) adminOnly(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !currentUser(r).IsAdmin {
http.NotFound(w, r)
return
}
h(w, r)
}
}
中间件可以选择是否调用原始 handler。以上面代码为例,如果 IsAdmin 为 false,中间件直接返回 404,不再调用 h(w, r);如果 IsAdmin 为 true,h 这个 handler 就被调用(h 是传入的参数)。我通常在 routers.go 中列出中间件:
package app
func (s *server) routes() {
s.router.HandleFunc(“/api/”, s.handleAPI())
s.router.HandleFunc(“/about”, s.handleAbout())
s.router.HandleFunc(“/”, s.handleIndex())
s.router.HandleFunc(“/admin”, s.adminOnly(s.handleAdminIndex()))
}
特殊的请求类型和响应类型也可以这样处理你要处理的特殊的请求类型和响应类型,一般也都是针对个别 handler 的。如果是这样,你可以在函数中直接定义使用:
func (s *server) handleSomething() http.HandlerFunc {
type request struct {
Name string
}
type response struct {
Greeting string `json:”greeting”`
}
return func(w http.ResponseWriter, r *http.Request) {

}
}
这样做可以让代码看起来更整洁,也允许你用相同名称命名这些结构体。测试时,拷贝到测试函数中即可。或者……
创建临时测试类型让测试更简单如果 request 或者 response 类型的定义隐藏在 handler 中,你可以在测试代码中声明新类型完成测试。这也是一个阐明代码历史和设计的机会,能让维护者更容易理解代码。
举例来讲,我们有一个 Person 类型,在很多接口中都要使用。如果我们有个 /greet 接口,这个接口只关心 Person 类型的 name 字段,那我们就可以这样来写测试用例:
func TestGreet(t *testing.T) {
is := is.New(t)
p := struct {
Name string `json:”name”`
}{
Name: “Mat Ryer”,
}
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(p)
is.NoErr(err) // json.NewEncoder
req, err := http.NewRequest(http.MethodPost, “/greet”, &buf)
is.NoErr(err)
//… more test code here
这段测试代码很明显地说明了 Name 字段才是唯一需要关注的。
使用 sync.Once
如果在预处理 handler 时必须要做一些耗资源的逻辑,我会把它推迟到第一次调用时处理。这么处理能让应用启动更迅速。
func (s *server) handleTemplate(files string…) http.HandlerFunc {
var (
init sync.Once
tpl *template.Template
err error
)
return func(w http.ResponseWriter, r *http.Request) {
init.Do(func(){
tpl, err = template.ParseFiles(files…)
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// use tpl
}
}
sync.Once 确保只执行一次,其他请求在该逻辑处理完之前都会阻塞。

为了能在出错时捕获和保证日志的完整,错误检查放在了 init 之外;
如果 handler 没被调用,耗资源逻辑永远不会执行——这样做好处非常明显,当然也取决于代码部署方式。

不过我要声明,这样处理是将初始化启动时推迟到了运行时(首次访问)。因为我经常使用 Google App Engine,对我而言这样做优势明显。但你可能面临不同情况,要因地制宜地考虑如何使用 sync.Once
server 类型方便测试我们的 server 类型非常便于测试。
func TestHandleAbout(t *testing.T) {
is := is.New(t)
srv := server{
db: mockDatabase,
email: mockEmailSender,
}
srv.routes()
req, err := http.NewRequest(“GET”, “/about”, nil)
is.NoErr(err)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
is.Equal(w.StatusCode, http.StatusOK)
}

每个测试用例创建一个 server 实例——耗资源可以延迟加载,即使对大型组件总归也浪费不了多少时间;
调用 srv.ServeHTTP 时其实是在测试整个调用栈了,也包括路由、中间件等等。如果想避免全部都调用,你也可以直接调用对应的 handler;
用 httptest.NewRecorder 记录 handler 都干了啥;
这段代码用了我开发的一个小测试框架。

总结我希望文章内容对你有帮助,如果不同意本文观点或者有其他想法都欢迎在 Twitter 上和我讨论。

退出移动版