今天读到一篇不错的文章,讲如何用 Go 写 HTTP 服务的,很有同感,翻译如下。
原文链接
一个 Server struct 是一个代表服务的对象,持有所有依赖。
每个组件都有一个唯一的 server struct,最后看起来通常类似这个样子:
1
2
3
4
5
| type server struct {
db *someDatabase
router *someRouter
email EmailSender
}
|
每个组件都有一个 routers.go
文件,包含所有的路由:
1
2
3
4
5
6
7
| package app
func (s *server) routes() {
s.router.HandleFunc("/api/", s.handleAPI())
s.router.HandleFunc("/about", s.handleAbout())
s.router.HandleFunc("/", s.handleIndex())
}
|
由于大部分代码维护工作都是从一个 URL 和一个错误报告开始的,所以只需要看一眼 routes.go
文件,即可知道应该去那里查找问题。
HTTP handlers 挂着 server 对象:
1
| func (s *server) handleSomething() http.HandlerFunc { ... }
|
Handlers 可以通过 server 对象访问依赖。
Handler 函数不直接处理请求,而是返回一个函数处理之。
这样我们就有一个闭包环境,在这里我们的 handler 可以这样操作:
1
2
3
4
5
6
| func (s *server) handleSomething() http.HandlerFunc {
thing := prepareThing()
return func(w http.ResponseWriter, r *http.Request) {
// use the thing
}
}
|
prepareThing()
方法只会被调用一次,因此你可以用来执行一次性的 handler 初始化动作,然后在 handler 中使用初始化的结果( thing
)。
在访问共享数据时,确保只执行读操作,否则需要加锁或者类似的保护措施。
如果一个 handler 需要一个特殊依赖,可以通过参数来传递。
1
2
3
4
5
| func (s *server) handleGreeting(format string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, format, "World")
}
}
|
我几乎在所有情况下都使用 http.HandlerFunc
,而非 http.Handler
。
1
2
3
4
5
| func (s *server) handleSomething() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// ...
}
}
|
两者基本上是可互换的,只需要选一个可读性更强的就好。对我而言, http.HandlerFunc
会好点。
中间件函数接受一个 http.HandlerFunc
参数,并返回一个新的 http.HandlerFunc
,新的这个 handler 可以在调用传入的 handler 之前或之后,执行任意代码,也可以选择完全不执行传入的 handler。
1
2
3
4
5
6
7
8
9
| 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)
}
}
|
上述例子中,如果 IsAdmin
为 false,则返回 404 并且终止处理。注意这种情况下,传入的 h handler 并未被调用。
如果 IsAdmin
为 true,则正常走传入的 h handler 逻辑。
中间件也可以列在 routes.go
中:
1
2
3
4
5
6
7
| 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()))
}
|
如果一个端点(endpoint)有自己的请求、响应类型,通常这些类型只对改 handler 有用。
如果确实如此,则可以直接在函数内部定义这些类型:
1
2
3
4
5
6
7
8
9
10
11
| 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) {
// ...
}
}
|
这样不会污染你的包命名空间,允许你在不同的 handler 中使用相同的名字,而非为每个 handler 想一个不同的名字。
在测试代码中,也可以直接拷贝这些类型定义到测试函数中。
如果你的请求、响应类型隐藏在 handler 内部,你可以在测试代码中直接定义新类型。
这是一个表达你的意图,方便后人理解你的代码的机会。
例如,假设有一个 Person
类型,在很多端点(endpoint)中被复用。其中有一个 /greet
端点,我们大概率只关心 Person.name
这个字段,因此我们可以在测试代码中表达这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 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
}
|
仅从功能测试角度来讲,这么做是 OK 的,被测代码的用法也表达的很清楚。但是从鲁棒性测试的角度,也许需要考虑到传递整个数据结构进去,会不会产生什么问题?
在准备 handler 的时候,如果需要执行一些成本比较高的初始化操作,可以考虑将该操作延迟到该 handler 第一次被调用的时候。
这可以改善应用的启动时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func (s *server) handleTemplate(files string...) http.HandlerFunc {
var (
init sync.Once
tpl *template.Template
tplerr error
)
return func(w http.ResponseWriter, r *http.Request) {
init.Do(func() {
tpl, tplerr = template.ParseFiles(files...)
})
if tplerr != nil {
http.Error(w, tplerr.Error(), http.StatusInternalServerError)
return
}
// use tpl
}
}
|
sync.Once
确保该代码只会被执行一次,而且其他调用(其他人发起同一个请求时)会一直阻塞直到执行结束。
- 错误检查放在 init 函数外面,因此如果有错误发生,我们可以暴露出该错误,同时保留错误日志
- 如果该 handler 未被调用,则该高成本操作永远不会被执行。有些情况下这样做有很大收益,取决于你的代码是如何部署的
这种方式实际上是将初始化时间从启动阶段转移到了运行时。如果使用 Google App Engine 则很有用,其他场景则需要单独考虑。
上述的 server 类型是充分可测的。
1
2
3
4
5
6
7
8
9
10
11
12
| func TestHandleAbout(t *testing.T) {
is := is.New(t)
srv := server {
db: mockDatabase,
email: mockEmailSender,
}
srv.routes()
req := httptest.NewRequest("GET", "/about", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
is.Equal(w.StatusCode, http.StatusOK)
}
|
- 在每个测试中创建一个 server 实例 —— 如果耗时操作是懒加载的,那么这么做不会耗费太多时间,即使对于大组件来说也适用
- 通过调用 server 的
ServeHTTP
方法,包括路由、中间件等整个栈都可以被测到。当然,如果你不希望测试整个栈,也可以直接调用 handler 方法 - 使用
httptest.NewRequest
和 httptest.NewRecorder
来记录 handlers 都做了什么 - 代码中使用了
is
测试框架,Testify 的一个迷你替代版本:is