net/http
每种编程语言都自带标准库,但随着时间的推移对标准库包含内容的预期在发生变化。作为一个21世纪10年代发布的语言,Go标准库中包含了一些其它语言认为应由第三方库负责的部分:生产级的HTTP/2客户端和服务端。
客户端
net/http
包定义了一个Client
类型,发送HTTP请求及接收HTTP响应。net/http
包中有一个默认客户端实例(恰到好处地命名为DefaultClient
),但应当避免在生产应用中使用它,因为它默认不带超时。请实例化自己的客户端。在整个应用中只需要创建一个http.Client
,因为它处理好了跨协程的多并发请求:
client := &http.Client{
Timeout: 30 * time.Second,
}
在希望发送请求时,通过http.NewRequestWithContext
函数实例化一个新的*http.Request
实例,将上下文、方法和希望连接的URL发送给它。如果为PUT
、POST
或PATCH
请求,最后一个参数使用io.Reader
类型指定请求体。如果没有请求体,使用nil
:
req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, "https://jsonplaceholder.typicode.com/todos/1", nil)
if err != nil {
panic(err)
}
注:我们会在上下文一章中讨论上下文。
有了*http.Request
实例,就可通过实例的Headers
字段设置请求头。用http.Request
对http.Client
调用Do
方法,结果在http.Response
中返回。
req.Header.Add("X-My-Client", "Learning Go")
res, err := client.Do(req)
if err != nil {
panic(err)
}
响应中有多个包含请求相应信息的字段。响应状态的数字码位于StatusCode
字段,响应码的文本位于Status
字段,响应头位于Header
字段,返回的内容都位于io.ReadCloser
类型的Body
字段中。这样我们就可以使用json.Decoder
来处理REST API响应了:
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
panic(fmt.Sprintf("unexpected status: got %v", res.Status))
}
fmt.Println(res.Header.Get("Content-Type"))
var data struct {
UserID int `json:"userId"`
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", data)
可在GitHub仓库的sample_code/client目录中查看代码。
警告:
net/http
包中有方法处理GET
、HEAD
和POST
调用。避免使用这些函数,因为它们使用默认客户端,因此没有设置请求超时。
服务端
HTTP服务端是以http.Server
的概念和http.Handler
接口进行构建的。就像http.Client
是发送HTTP请求的,http.Server
负责监听HTTP的请求。它是一个支持TLS的高性能HTTP/2服务端。
对服务端的请求由赋值给Handler
字段的http.Handler
接口实现来处理。接口中定义了一个方法:
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
*http.Request
很眼熟,它和向HTTP服务端发送请求使用的同一种类型。http.ResponseWriter
是一个带有三个方法的接口:
type ResponseWriter interface {
Header() http.Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
这些方法必须按指定顺序调用。首先,调用Header
来获取一个http.Header
实例并设置所需响应头。如果无需设置头部,就不用调用它。接着, 使用响应的HTTP状态码调用WriteHeader
。(所有的状态码在net/http
包中以常量进行定义。这会是定义自定义类型的好地方,但并不完全,所有的状态码常量都是无类型整数。)如果想要发送状态码为200的响应,可以跳过WriteHeader
。最后,调用Write
方法来设置响应体。以下是小型handler的示例:
type HelloHandler struct{}
func (hh HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!\n"))
}
可以像其它结构体那样实例化一个新的http.Server
:
s := http.Server{
Addr: ":8080",
ReadTimeout: 30 * time.Second,
WriteTimeout: 90 * time.Second,
IdleTimeout: 120 * time.Second,
Handler: HelloHandler{},
}
err := s.ListenAndServe()
if err != nil {
if err != http.ErrServerClosed {
panic(err)
}
}
Addr
字段指定了服务端监听的主机和端口。如不指定,服务端默认监听所有主机以及标准HTTP端口80。然后使用time.Duration
值指定服务端读取、写入及空闲的超时时间。确保设置这些值以规避恶意或崩溃的HTTP客户端,因为默认是一律不超时。最后,使用Handler
字段为服务端指定http.Handler
。
代码参见GitHub的sample_code/server目录。
处理单个请求的服务端并没有多大用处,因此Go标准库集成了请求路由*http.ServeMux
。我们通过http.NewServeMux
函数创建一个实例。它符合http.Handler
接口,因此可赋值给http.Server
的Handler
字段。它还包含两个方法可派发请求。第一个方法是Handle
,接收两个参数:路径和http.Handler
。若路径匹配,则调用http.Handler
。
虽然可以创建http.Handler
的实现,但更常见的模式是使用*http.ServeMux
的HandleFunc
方法:
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!\n"))
})
方法接收一个函数或闭包,将其转化为http.HandlerFunc
。我们已经在函数类型是接口的桥梁中探讨过http.HandlerFunc
类型。对于简单的handler,用闭包就够了。更复杂的依赖于其它业务逻辑的handler,使用方法或结构体,参见隐式接口让依赖注入更简单。
Go 1.22将路径的语法扩展为允许使用HTTP动词和路径通配符变量。通配符变量值使用http.Request
的PathValue
方法进行读取:
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Write([]byte(fmt.Sprintf("Hello, %s!\n", name)))
})
警告:有一些包级函数:
http.Handle
、http.HandleFunc
、http.ListenAndServe
和http.ListenAndServeTLS
配合*http.ServeMux
的包级实例http.DefaultServeMux
进行使用。不要在小测试程序之外使用它们。http.Server
实例在http.ListenAndServe
和http.ListenAndServeTLS
函数中创建,因而无法配置超时这样的服务端属性。此外,第三方库可以能通过http.DefaultServeMux
注册了自己的 handler,不扫描所有依赖(直接和间接依赖)就无法知晓。通过避免共享状态来让应用处于掌控之中。
因*http.ServeMux
将请求分发给http.Handler
,而*http.ServeMux
实现了http.Handler
,可以用多个关联请求创建一个*http.ServeMux
实例,并通过一个父*http.ServeMux
来注册它。
person := http.NewServeMux()
person.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("greetings!\n"))
})
dog := http.NewServeMux()
dog.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("good puppy!\n"))
})
mux := http.NewServeMux()
mux.Handle("/person/", http.StripPrefix("/person", person))
mux.Handle("/dog/", http.StripPrefix("/dog", dog))
本例中,请求/person/greet
由附属于person
的handler处理,而/dog/greet
由附属于dog
的handler处理。在将person
和dog
注册到mux
上时,使用http.StripPrefix
帮助函数来删除已由mux
处理的路径部分。代码参见GitHub的sample_code/server_mux目录。
中间件
HTTP服务端最常见的一个要求是执行一组跨多个handler的操作,比如检查用户是否登录、对请求计时或检测请求头。Go通过中间件模式处理这类横向请求。中间并没有使用特定的类型,而是接收一个http.Handler
实例,再返回http.Handler
。通常返回的http.Handler
是一个转化为http.HandlerFunc
的闭包。这里有两个中间件生成器,一个提供请求用时,另一个使用最差情况访问控制:
func RequestTimer(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
h.ServeHTTP(w, r)
dur := time.Since(start)
slog.Info("request time",
"path", r.URL.Path,
"duration", dur)
})
}
var securityMsg = []byte("You didn't give the secret password\n")
func TerribleSecurityProvider(password string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Secret-Password") != password {
w.WriteHeader(http.StatusUnauthorized)
w.Write(securityMsg)
return
}
h.ServeHTTP(w, r)
})
}
}
这两个中间件实现演示了中间件的功能。首先是做配置操作或检测。如果检测不通过,在中间件中编写输出(通常带错误码)并返回。如果一切正常,则调用handler的ServeHTTP
方法。在返回时执行清理操作。
TerribleSecurityProvider
显示了如何创建可配置的中间件。传入配置信息(本例中为密码),函数使用该配置信息返回中间件。它有点不直观,因为返回了一个返回闭包的闭包。
注:读者可能会想如何透过一层层中间件传递值,这个可参见上下文。
我们通过链式调用对请求添加中间件:
terribleSecurity := TerribleSecurityProvider("GOPHER")
mux.Handle("/hello", terribleSecurity(RequestTimer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!\n"))
}))))
我们通过TerribleSecurityProvider
获取中间件,然后将handler封装到一系列的函数调用中。这会首先调用terribleSecurity
闭包,然后调用RequestTimer
,接着又调用实际的请求handler。
因为*http.ServeMux
实现了http.Handler
接口,可以单个请求路由注册的所有handler应用一组中间件:
terribleSecurity := TerribleSecurityProvider("GOPHER")
wrappedMux := terribleSecurity(RequestTimer(mux))
s := http.Server{
Addr: ":8080",
Handler: wrappedMux,
}
代码参见GitHub的sample_code/middleware目录。
使用第三方模型增强服务端
服务端达到生产品质并不意味着不应使用第三方模块来改善其功能。如果不喜欢中间件的链式函数调用,可以使用第三方模块alice
,通过它可以使用如下语法:
helloHandler := func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!\n"))
}
chain := alice.New(terribleSecurity, RequestTimer).ThenFunc(helloHandler)
mux.Handle("/hello", chain)
虽然*http.ServeMux
在Go 1.22中获得了一些新特性,但其对路由和变量的支持还很基础。嵌套*http.ServeMux
实例也有些笨重。如果你需要更高级的特性,比如基于头部值的路由、使用正则表达式指定路径变量或更好的handler嵌套,可以用一些第三方请求路由。最知名的两个是gorilla mux和chi。两者都很地道,因为可配合http.Handler
和http.HandlerFunc
实例使用,并使用适配标准库的可组合库展示了Go的设计看哲学。它们也可以与原生的中间件相配合,并且这两个项目都提供了普遍关注的可选中间件实现。
还有一些知名的web框架,实现了自有的handler和中间件模式。最知名的两个是 Echo and Gin。它们通过集成了自动将请求或响应数据与JSON绑定等特性简化了web开发。它们还提供了适配器函数,让我们可以使用http.Handler
实现,给了我们另一种途径。
ResponseController
在接收接口,返回结构体一节中,我们学习到变更结果会打破向后兼容。我们还学习到解决方法是通过定义新接口渐进演变接口,使用类型开关和类型断言来查看是否实现了新接口。创建这些额外接口的缺点是知道它们的存在很困难,使用类型开关来检查非常的繁琐。这种示例可在http
包中看到。在设计这个包时,选择是将http.ResponseWriter
创建为接口。也就意味着在未来的版本中无法加入其它方法,否则Go语言的兼容承诺就会被打破。为使用http.ResponseWriter
实现加入新的功能,http
包带了一些可由http.ResponseWriter
实现、http.Flusher
和http.Hijacker
实现的接口。这些接口中的方法用于控制响应的输出。
在Go 1.20中,对http
包增加了新的实体类型http.ResponseController
。它展示了对已有API暴露新方法的另一种方式:
func handler(rw http.ResponseWriter, req *http.Request) {
rc := http.NewResponseController(rw)
for i := 0; i < 10; i++ {
result := doStuff(i)
_, err := rw.Write([]byte(result))
if err != nil {
slog.Error("error writing", "msg", err)
return
}
err = rc.Flush()
if err != nil && !errors.Is(err, http.ErrNotSupported) {
slog.Error("error flushing", "msg", err)
return
}
}
}
本例中,若http.ResponseWriter
支持Flush
,则将计算的数据返回给客户端。若不支持,在所有部分完成计算后返回所有数据。工厂函数http.NewResponseController
接收http.ResponseWriter
并将指针返回给http.ResponseController
。这个实体类型具备http.ResponseWriter
可选功能的方法。我们使用errors.Is
将返回错误与http.ErrNotSupported
进行以比较检测底层http.ResponseWriter
是否实现了可选方法。代码参见GitHub仓库的sample_code/response_controller目录。
因为http.ResponseController
是一个封装了http.ResponseWriter
访问实现的实体类型,往后可对其添加新方法而不破坏已有的实现。这让新功能可被发现,提供一种方法使用标准错误检查检测是否存在可选方法。这种模式对于处理需演进的接口是一种有趣的方式。事实上,http.ResponseController
包含两个没有相应接口的方法,SetReadDeadline
和SetWriteDeadline
。很可能未来会使用这种技术对http.ResponseWriter
添加其它可选方法。
结构化日志
自发布起,Go标准库就自带了简单的日志包log
。虽足以应付小项目,但不太容易生成结构化的日志。现代web服务可能同时有几百万个用户,这种量级要求软件处理日志输出以便了解发生了什么。结构化日志对每个日志条目使用档案格式,让其更晚饭后地写入程序,以处理日志输出及发现模式和异常。JSON常用于结构化日志,但即使是空格分隔的键值对都比没将值分隔成字段的非结构化日志要易于处理。虽然可以使用log
包写入JSON,但log
包并没有提供简化结构化日志创建的支持。log/slog
包解决了这一问题。
在标准库中添加log/slog
展示了Go库设计的一些良好实践。第一个正确决策是在标准库中内置结构化日志。标准的结构化日志使得更易于编写共同协作的模块。有多个第三方结构化日志用于解决log
的问题,包括zap、logrus、go-kit log等等。碎片化日志生态的问题在于我们希望控制日志输出到哪里以及对何种级别的消息记录日志。如果代码依赖于使用不同日志工具的第三方模块,这就不太可能实现。通常避免日志碎片化的建议是不在规划为库的模块中打日志,这就使用得实施上不可能做到,并且监控第三方库所执行任务也变得困难。log/slog
包在Go 1.21中引入,但鉴于它可以解决这些不一致性,很可能在未来几年出现在大部分Go程序中。
第二个正确决策是让结构化日志独立成包,而不隶属于log
包。虽然两者目的相似,但设计哲学截然不同。将结构化日志添加到非结构化日志包中会让API变得有歧义。通过分离这两个包,我们马上就能知道slog.Info
是一个结构化日志,而log.Print
是非结构化的,你也不需要去记忆Info
是结构化还是非结构化日志。
另一个正确决策是让log/slog
API可扩展。很容易起步,通过函数提供默认日志工具:
func main() {
slog.Debug("debug log message")
slog.Info("info log message")
slog.Warn("warning log message")
slog.Error("error log message")
}
这些函数让我们可以对不同的日志级别打出简单的消息。输出类似下面这样:
2023/04/20 23:13:31 INFO info log message
2023/04/20 23:13:31 WARN warning log message
2023/04/20 23:13:31 ERROR error log message
有两件需要注意的事情。第一是默认不输出调试级别的消息。稍后讨论如何创建自己的logger时会讲到如何控制日志级别。
第二个更加隐晦。虽然这是普通文本输出,它使用的是空格来对日志结构化。第一列是年/月/日格式的日期。第二列是24小时制的时间。第三列是日志级别。最后一列是消息。
结构化日志的强大来自于其可添加自定义值的能力。我们来使用自定义字段更新日志:
userID := "fred"
loginCount := 20
slog.Info("user login",
"id", userID,
"login_count", loginCount)
使用的函数与之前相同,但这里添加了可选参数。可选参数成对出现。第一部分是键,应为字符串。第二部分是值。输出的日志为:
2023/04/20 23:36:38 INFO user login id=fred login_count=20
消息之后为空格分隔的键值对。
虽然文本格式要远比非结构化日志更易于解析,但可能需要的是像JSON这样的格式。可能还会希望自定义日志的位置或是日志级别。这时,我们创建一个结构化日志实例:
options := &slog.HandlerOptions{Level: slog.LevelDebug}
handler := slog.NewJSONHandler(os.Stderr, options)
mySlog := slog.New(handler)
lastLogin := time.Date(2023, 01, 01, 11, 50, 00, 00, time.UTC)
mySlog.Debug("debug message",
"id", userID,
"last_login", lastLogin)
这里使用了slog.HandlerOptions
结构体来对新logger定义了最低日志级别。然后使用slog.HandlerOptions
中的NewJSONHandler
方法创建一个使用JSON写入指定io.Writer
的slog.Handler
。本例中,我们使用的是标准错误输出。最后,我们使用slog.New
函数创建包装slog.Handler
的*slog.Logger
。然后我们创建需要与用户id一同打日志的lastLogin
值。输出如下:
{"time":"2023-04-22T23:30:01.170243-04:00","level":"DEBUG", "msg":"debug message","id":"fred","last_login":"2023-01-01T11:50:00Z"}
如果JSON和文本都无法满足你的输出需求,可以自己实现slog.Handler
接口并将其传递给slog.New
。
最后,log/slog
考虑到了性能问题。如果你不小心,可能会导致写日志的时间多过所设计执行的任务。可以有多种方式将数据写入log/slog
。我们已经学习最简单(也最慢)的方法,即对Debug
、Info
、Warn
和Error
方法使用可选键值。要通过减少内存分配提升性能,可使用LogAttrs
方法:
mySlog.LogAttrs(ctx, slog.LevelInfo, "faster logging",
slog.String("id", userID),
slog.Time("last_login", lastLogin))
第一个参数是context.Context
,下一个是日志级别,接下来是零到多个slog.Attr
实例。有很多用于最常用类型的工厂函数,对没有现成函数的可以使用slog.Any
。
由于Go的兼容性承诺,log
依然存在。当前使用它的程序依然运行正常,使用第三方结构化日志的程序也是如此。如果代码中使用了log.Logger
,slog.NewLogLogger
函数为原来的log
包提供了一个桥梁。它创建了一个使用slog.Handler
写到输出的log.Logger
实例:
myLog := slog.NewLogLogger(mySlog.Handler(), slog.LevelDebug)
myLog.Println("using the mySlog Handler")
输出如下:
{"time":"2023-04-22T23:30:01.170269-04:00","level":"DEBUG", "msg":"using the mySlog Handler"}
有关log/slog
的代码参见GitHub仓库的sample_code/structured_logging目录。
log/slog
API还包含其它功能,包含动态日志级别的支持、上下文支持(参见上下文一章),值分组以及创建值的通用头。可以查阅API文档学习更多知识。最重要的是学习log/slog
的结合方式用于未来自己API的构建。
小结
本文中我们学习了标准库中最常用的一些包并演示了如何在代码中临摹最佳实践。我们也学习到了其它不错的软件工程原则:根据经验如何做出不同的决策以及如何遵循向后兼容性以构建具有坚实基础的应用。
本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。