云原生系列Go语言篇标准库 Part 2

2024年 1月 4日 65.7k 0

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发送给它。如果为PUTPOSTPATCH请求,最后一个参数使用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.Requesthttp.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包中有方法处理GETHEADPOST调用。避免使用这些函数,因为它们使用默认客户端,因此没有设置请求超时。

服务端

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.ServerHandler字段。它还包含两个方法可派发请求。第一个方法是Handle,接收两个参数:路径和http.Handler。若路径匹配,则调用http.Handler

虽然可以创建http.Handler的实现,但更常见的模式是使用*http.ServeMuxHandleFunc方法:

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.RequestPathValue方法进行读取:

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.Handlehttp.HandleFunchttp.ListenAndServehttp.ListenAndServeTLS配合*http.ServeMux的包级实例http.DefaultServeMux进行使用。不要在小测试程序之外使用它们。http.Server实例在http.ListenAndServehttp.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处理。在将persondog注册到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.Handlerhttp.HandlerFunc实例使用,并使用适配标准库的可组合库展示了Go的设计看哲学。它们也可以与原生的中间件相配合,并且这两个项目都提供了普遍关注的可选中间件实现。

还有一些知名的web框架,实现了自有的handler和中间件模式。最知名的两个是 Echo and Gin。它们通过集成了自动将请求或响应数据与JSON绑定等特性简化了web开发。它们还提供了适配器函数,让我们可以使用http.Handler实现,给了我们另一种途径。

ResponseController

在接收接口,返回结构体一节中,我们学习到变更结果会打破向后兼容。我们还学习到解决方法是通过定义新接口渐进演变接口,使用类型开关和类型断言来查看是否实现了新接口。创建这些额外接口的缺点是知道它们的存在很困难,使用类型开关来检查非常的繁琐。这种示例可在http包中看到。在设计这个包时,选择是将http.ResponseWriter创建为接口。也就意味着在未来的版本中无法加入其它方法,否则Go语言的兼容承诺就会被打破。为使用http.ResponseWriter实现加入新的功能,http包带了一些可由http.ResponseWriter实现、http.Flusherhttp.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包含两个没有相应接口的方法,SetReadDeadlineSetWriteDeadline。很可能未来会使用这种技术对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/slogAPI可扩展。很容易起步,通过函数提供默认日志工具:

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.Writerslog.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。我们已经学习最简单(也最慢)的方法,即对DebugInfoWarnError方法使用可选键值。要通过减少内存分配提升性能,可使用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.Loggerslog.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语言&云原生自我提升系列,欢迎关注后续文章。

相关文章

KubeSphere 部署向量数据库 Milvus 实战指南
探索 Kubernetes 持久化存储之 Longhorn 初窥门径
征服 Docker 镜像访问限制!KubeSphere v3.4.1 成功部署全攻略
那些年在 Terraform 上吃到的糖和踩过的坑
无需 Kubernetes 测试 Kubernetes 网络实现
Kubernetes v1.31 中的移除和主要变更

发布评论