在项目开发实现功能需求的过程中不可避免的要与外部第三方系统进行交互,这些交互大部分是通过请求API接口来完成的。
前几节提到但一直没带大家用代码过一遍的Lib层就是负责写第三方对接逻辑的,通过把跟第三方对接的逻辑限制在Lib层里,让项目的其他部分不需要关注第三方的逻辑,从而达到每部分都职责分明,这样项目的代码多起来后才不会变得臃肿和杂乱。
不过在演示Lib层的使用前我们需要先一起给项目封装一个好用的HTTP请求工具。
图片
用Go 实现一个好用的 HTTP 请求工具
Go自带了的http库就能发起API调用,为啥我们还要做这个封装呢?其实主要有以下几个目的:
- 简化 HTTP 请求的发起
- 利用Option模式用命名参数的方式进行请求的多选项设置
- header 头中自动携带trace信息,方便内部的二方服务一起做好链路追踪
- 慢请求的日志记录
- 非 200 响应错误统一处理
我们一个个来说,首先在项目中发起HTTP请求调用API的时候不同的情况会有不同的设置:
- Method GET 或者 是POST
- POST 请求要设置请求Body
- 超时时间是否要单独设置
- Header 头是否要携带的信息
- 特殊情况下还可能有其他更多的请求设置
如果项目中每次调用API都是像下面这段代码一样用原生 http 库中的方法, 先 new 出一个Request对象,再按照需要一个个设置上面的配置项,最后再发起请求,当然是没有问题,完全能实现功能。
req, err := http.NewRequest(method, url, bytes.NewReader(reqOpts.data))
req.WithContext(ctx)
req.Header.Add("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
但就是每次都得写这一堆代码,在多人开发的项目中一定会把这些代码粘来粘去,除此之外像请求日志记录、请求头设置追踪信息等通用操作的代码每次也都得写一遍,增加很多冗余不说,一旦忘记了这些后面出问题想排查原因也不好排查。
所以我们必须要封装一个统一的 HTTP 请求工具方法,把一些通用的基础工作在工具中都做好避免每次都要记得去手写那些代码,从而减少编码中不必要的精力浪费。
那么要封装HTTP请求工具就遇到一个问题,我们并不是每次发请求都需要设置这么多参数,那你的工具方法应该怎么设置参数呢?设置少了遇到不满足的情况还得重新再写一个多参数版本的工具方法,那谁能保证类似需要加参数的情况会不会再有呢?
而且参数设置的多了,每次使用时用不到的参数也得给传一个零值才能调用,一旦调用时参数顺序传错了还会有问题,属于自己给自己写BUG的一种常见情况。
用Option模式让Go支持命名参数
考虑到这些情况后,根据这些痛点,我们利用Golang func 的可变参数特性,结合 Option 模式的设计,让我们的工具方法支持可变且具名的参数,即拥有下面的两个能力
- 用到哪些设置了,调用时再传那些参数,不需要让用不到的设置占用参数位置。
- 利用Option模式让参数变成具有名称的参数,不再限定参数的顺序。
首先我们在 common/util 下创建 httptool 目录,其中新增httptool.go 文件。
我们用Option模式是为了设置请求的选项,所以我们在 httptool.go 中先定义一个用于保存请求选项的结构体。
type requestOption struct {
ctx context.Context
timeout time.Duration
data []byte
headers map[string]string
}
func defaultRequestOptions() *requestOption {
return &requestOption{
ctx: context.Background(),
timeout: 5 * time.Second,
data: nil,
headers: map[string]string{},
}
}
这个里面的字段可以根据自己的需要再增加。然后我们定义出Option的通用行为:
type Option interface {
apply(option *requestOption) error
}
type optionFunc func(option *requestOption) error
func (f optionFunc) apply(opts *requestOption) error {
return f(opts)
}
我们看下面这几个请求配置选项对应的Option 函数,这里我不写注释光看每个函数的名字你们也能看出来他们都是用来设置什么的。
func WithContext(ctx context.Context) Option {
return optionFunc(func(opts *requestOption) (err error) {
opts.ctx = ctx
return
})
}
func WithTimeout(timeout time.Duration) Option {
return optionFunc(func(opts *requestOption) (err error) {
opts.timeout, err = timeout, nil
return
})
}
func WithHeaders(headers map[string]string) Option {
return optionFunc(func(opts *requestOption) (err error) {
for k, v := range headers {
opts.headers[k] = v
}
return
})
}
func WithData(data []byte) Option {
return optionFunc(func(opts *requestOption) (err error) {
opts.data, err = data, nil
return
})
}
optionFunc 把这些 func(opts *requestOption) (err error) 类型函数都转换成了自己的类型,让他们成为了Option接口的实现,拥有了apply方法, apply方法的逻辑就是直接调用这些被转换的函数。
这样在我们的请求工具方法中,就可以迭代可变参数的实际参数,然后一个个地去调用他们的 apply 方法来构造最终的请求选项, 像下面这样。
func Request(method string, url string, options ...Option) (httpStatusCode int, respBody []byte, err error) {
start := time.Now()
reqOpts := defaultRequestOptions() // 默认的请求选项
for _, opt := range options { // 在reqOpts上应用通过options设置的选项
err = opt.apply(reqOpts)
if err != nil {
return
}
}
...
}
上面这个Request方法就是我们的工具提供的函数,method、url 因为是必填的就不必再整成Option参数了,其他关于请求的设置都可以通过在调用是使用WithXXX()一系列的函数传参进来。
Request("POST", url, WithTimeout(timeout), WithHeaders(headers), WithData(data))
日志和追踪头信息
我们在发起请求的第一个参数都是 context.Context 类型的上下文参数, 这个意图是为了让你调用时把请求上下文 gin.Context 传递进来,我们好从其中取到一开始种进去的追踪信息,然后设置到要发起的请求的Header中去。
func Request(method string, url string, options ...Option) (httpStatusCode int, respBody []byte, err error) {
......
// 在Header中添加追踪信息 把内部服务串起来
traceId, spanId, _ := util.GetTraceInfoFromCtx(reqOpts.ctx)
reqOpts.headers["traceid"] = traceId
reqOpts.headers["spanid"] = spanId
if len(reqOpts.headers) != 0 { // 设置请求头
for key, value := range reqOpts.headers {
req.Header.Add(key, value)
}
}
......
}
同时因为有了ctx 信息,我们使用项目自己的Logger门面进行日志记录的时候也会把请求的追踪信息一并写到日志信息中去,通过trace、span 信息也能查到项目的一个接口在执行过程中内部发起了哪些API调用?以及得到了什么结果?
func Request(method string, url string, options ...Option) (httpStatusCode int, respBody []byte, err error) {
......
// 发起请求
client := &http.Client{Timeout: reqOpts.timeout}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
// 记录请求日志
dur := time.Since(start).Seconds()
if dur >= 3 { // 超过 3s 返回, 记一条 Warn 日志
log.Warn("HTTP_REQUEST_SLOW_LOG", "method", method, "url", url, "body", reqOpts.data, "reply", respBody, "err", err, "dur/ms", dur)
} else {
log.Debug("HTTP_REQUEST_DEBUG_LOG", "method", method, "url", url, "body", reqOpts.data, "reply", respBody, "err", err, "dur/ms", dur)
}
}
连接池的设置
服务间接口调用,维持稳定数量的长连接,对性能非常有帮助,这就需要我们在Go 的 http Client的连接池特性,该特性需要在创建Client时用 http.Transport 进行设置。