GopherCon 2019 - 8 年过去了,我是如何写 Go Web Server 的?
主持人: Mat Ryer
直播博主: Kenigbolo Meya Stephen
概述
看看 Mat Ryer 在过去八年中是如何构建 web 服务的。是每个人现在都可以开始使用的非常实用且经过测试的模式。
关于 Mat Ryer
Mat 是 Go 的先行者。甚至在 Go 推出第一个主要版本(v1)之前,他就开始使用 Go 了。他目前在 Machine Box 和 Veritone 工作。他也是开源的狂热者,在 Bitbar、Gopherize.me、Gopherize.me 等开源项目中都可以找到他的身影。他在 Go 中构建 https 服务已经很长时间了,在这一过程中学到了很多东西,也改变了很多东西。你可以在推特上找到他,地址是@matryer。
编写HTTP服务时需要考虑的因素
Mat重点介绍了编写HTTP服务时需要考虑的一些非常重要的因素。他重申,这些因素很重要,有助于您编写清晰简洁的https服务。这些因素包括
- 可维护性
重要的是要考虑要编写的任何服务的可维护性。如果在创建阶段未考虑维护成本,则维护成本可能会大于创建工具的初始成本。
- 舒适性
当你阅览代码时,理解的速度有多快?在代码库中导航的速度有多快?这也可以看作是以这样一种方式编写代码,任何东西都不会很复杂地找到。这包括函数、命名空间、变量名、代码结构、项目结构等的命名。
- 代码是无趣的
这里的代码应该是无趣的,意思是关于代码库的一切都是显而易见的。这不是写花哨的东西,而是写给其他人理解的东西。重要的是,我们了解该代码可能会由经验不足或没有经验的人使用。
- 自相似代码
编写与代码库中其他代码相似的代码,有助于提高必须处理代码的人员的熟悉度。
设计模式/决策
在考虑了上面列出的不同因素之后,下一步就是根据这些考虑做出设计决策/模式。尽管存在不同的用例,重要的是人们使用最适合自己的用例,但 Mat 认为,在大多数情况下,下面列出的设计模式对于编写 https 服务实际上非常有用。
创建一个微小的抽象 main
func main () {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, “%s\n”, err)
os.Exit(1)
}
}
func run() error {
db, dbtidy, err := setupDatabase()
if err != nil {
return errors.Wrap(err, “setup database”)
}
defer dbtidy()
srv := &server{
db: db,
}
//... more stuff
Mat 认为,像上面的这样的微小抽象使得他可以返回错误,而不是对特定代码进行错误处理。这允许运行功能实际负责运行 https 服务,即只需设置服务并调用 ListenAndServe
创建一个服务器结构
type server struct {
db *someDatabase
router *someRouter
email EmailSender
}
而不是将以上代码放在包空间中,而是将其放在服务器中(代码结构)。避免全局状态是件好事,拥有服务器结构有助于避免这种情况。这使得服务器的需求显而易见。
为服务器创建构造函数
func newServer() *server {
s := &server{}
s.routes()
return s
}
重要的是不要在服务器构造函数中设置依赖关系。如果您需要设置依赖关系,则可以使用服务器结构。
让 server 成为 http.Handle
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
这里的目标是将执行传递给路由器。理想情况下,您永远不要在这里放逻辑。如果您希望在此处放置任何逻辑,请考虑将其移入中间件文件。
默认路由文件
package main
func (s *server) routes() {
s.router.Get("/api/", s.handleAPI())
s.router.Get("/about", s.handleAbout())
s.router.Get(“/", s.handleIndex())
}
一个包含所有路由的文件总是有用的,因为它很容易浏览。它的优点是可以在一处查看 http 服务的所有路由。
Handlers 挂在 server 上
func (s *server) handleSomething() http.HandlerFunc {
// put some programming here
}
每一个 HTTP 请求都对应着一个 goroutine , 其他 goroutine 也可以访问 s
,因此必须注意数据争用之类的情况。
命名 Handler 方法
handleTasksCreate
handleTasksDone
handleTasksGet
handleAuthLogin
handleAuthLogout
建议根据职责对名称进行分组。这样更容易阅读和查找。理想情况下,相关功能应始终分组在一起。
返回 handler
func (s *server) handleSomething() http.HandlerFunc {
thing := prepareThing()
return func(w http.ResponseWriter, r *http.Request) {
// 可以使用变量 thing
}
}
handler 返回一个闭包,你可以在其中设置 handler 特定的设置。
为 handler 特定的依赖项获取参数
func (s *server) handleGreeting(format string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, format, r.FormValue(“name”))
}
}
s.router.HandleFunc(“/one”, s.handleGreeting(“Hello %s”))
s.router.HandleFunc(“/two”, s.handleGreeting(“Hola %s”))
如果您具有特定的依赖关系,而又不想保留服务器类型,则可以将其作为此处理程序方法的参数。允许您执行的操作是在小处理程序函数中访问它们。这使得查看处理程序完成其工作所需的内容变得非常容易。此外,就类型安全而言,这也非常有用,因为如果不提供其依赖项,则无法获取处理程序。
Y
处理函数过度处理
func (s *server) handleSomething() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
使用处理程序功能的主要目标是帮助类型。如果要创建处理程序,则无需使用处理程序函数来创建类型,因为这样做可以选择使用匿名函数并将其强制转换为HTTP处理程序函数,如上面的代码所示。
PS-如果您发现自己经常在处理程序和处理程序功能之间切换,那么最好还是坚持使用处理程序。
中间件只是Go功能
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)
}
}
中间件是正常的go函数,它将处理程序作为参数并返回一个可以执行不同操作的新处理程序。它可以在调用原始处理程序之前或之后调用原始处理程序。在某些情况下,根本不需要调用原始处理程序。您可以在日志记录,跟踪,身份验证等中使用中间件
将您的中间件软件连接到routes.go
package main
func (s *server) routes() {
s.router.Get("/api/", s.handleAPI())
s.router.Get("/about", s.handleAbout())
s.router.Get("/", s.handleIndex())
s.router.Get(“/admin", s.adminOnly(s.handleAdminIndex()))
}
这使您可以使routes.go
文件成为http服务的高级映射。这也很有用,因为您只有一个地方,可以容纳需要解释HTTP服务API足迹的所有内容。
处理数据
作为开发人员,我们总是很想抽象功能并尽可能使代码保持DRY。但是,很多时候我们过早地进行抽象,这主要是由于我们对DRY代码的痴迷,但这是值得抗拒的。但是,处理数据需要一些抽象,我们将寻找那些经过八年尝试和测试的抽象。
响应助手
func (s *server) respond(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
w.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
// TODO: handle err
}
}
这种抽象的巨大优势在于,对于http服务响应,每当需要发生更改时,它仅在一个点发生,即使您能够以更少的重复获得更大的灵活性。响应助手通常很小且很简单
解码助手
func (s *server) decode(w http.ResponseWriter, r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}
就像响应助手一样,这使您可以抽象解码功能。这使您可以灵活地在一个位置进行更改,从而影响整个http服务。
Future proof helpers
您可以使用始终接受响应编写者和请求的简单规则,将来证明您编写的任何帮助程序。即使您在一开始就不需要它们,通常这也是您真正需要处理go的所有内容
请求和响应数据类型
func (s *server) handleGreet() http.HandlerFunc {
type request struct {
Name string
}
type response struct {
Greeting string `json:"greeting"`
}
return func(w http.ResponseWriter, r *http.Request) {
...
}
}
如果端点具有自己的请求和响应类型,通常它们仅对特定处理程序有用。如果是这样,您可以在函数中定义它们。这使您的程序包空间更加整洁,并允许您为这些类型命名相同的名称,而不必考虑处理程序特定的版本。
尽管将请求和响应类型放在包空间中是很常见的,但是将它们放在稍微封闭的环境中(如上面的代码片段所示)可以帮助您进行整理。
惰性设置同步一次
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
}
}
同步程序使您能够在首次调用给定处理程序时(而不是在程序首次启动时)运行代码。昂贵的设置会减慢服务启动时间,因此只有在首次调用时才运行它,才能大大改善服务启动时间。
测试
测试是可维护性的一个很好的工具,这是http服务中所需要的,无论它们使用哪种语言编写。您确实需要测试。
httptest
软件包应该是您最好的朋友,并且您默认使用go软件包来测试http服务,因为它非常有用。但是,您可以使用它创建HTTP请求,与可以返回错误的HTTP.NewRequest函数不同,httptest
不会,因此使编写测试变得更加容易。模拟进入的HTTP请求的功能超级有用
摘要
Matt Ryer的演讲基于他撰写的博客文章。可以在这里找到的博客文章具有病毒性。它在围棋社区中非常流行,绝对值得一读。该帖子在reddit上分享,并引发了很多问题,反馈和建议。这次演讲是他自那时以来所学到的最高成果。它专注于他偏爱的想法背后的哲学,而不是针对某些特定规则集的强硬路线。
他强调,技术主管,工程经理,CTO等应努力创建一个缓冲区,允许工程师在合理范围内尝试新事物。由于不同的团队可能有不同的需求和用例/边际案例,因此该演讲不是盲目遵循的。
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: