go项目中比较好的实践方案
工作两年来,我并未遇到太大的挑战,也没有特别值得夸耀的项目。尽管如此,在日常的杂项工作中,我积累了不少心得,许多实践方法也在思考中逐渐得到优化。因此,我在这里记录下这些心得。
转发与封装
这个需求相当常见,包括封装上游的请求、接收下游的响应,并将它们封装后发送给上游:
这里展示的是一个简单的代理模型。乍一看,这可能是一个简单的需求,只需两个函数封装即可。但当需要扩展其他额外需求时,这里的设计就显得尤为重要。例如:server
需要支持流式协议、proxy
需要进行鉴权和计费、proxy
需要支持多个接口转发、proxy
需要支持限流等。
借助第三方代理封装
有些方案可以直接借鉴。如果仅需要支持HTTP协议,我们可以直接使用httputil.ReverseProxy
。在Director
中定义wrap request
行为,在ModifyResponse
中定义wrap response
行为。这是我们在内部的openai代理项目中采用的思路。以下是简单的代码示例:
director := func(req *http.Request) {
// 读取并重新填充请求体
body, _ := io.ReadAll(req.Body)
// 转换请求体
req.Body = io.NopCloser(bytes.NewBuffer(body))
// 转换头部
req.Header.Set("KEY", "Value")
req.Header.Del("HEADER")
// 转换URL
originURL := req.URL.String()
req.Host = remote.Host
req.URL.Scheme = remote.Scheme
req.URL.Host = remote.Host
req.URL.Path = path.Join("redirected", req.URL.Path)
req.URL.RawPath = req.URL.EscapedPath()
// 转换查询参数
query := req.URL.Query()
query.Add("ExtraHeader", "Value")
req.URL.RawQuery = query.Encode()
}
modifyResponse := func(resp *http.Response) error {
// 记录失败的请求查询和响应
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
}
// 使用一些操作包装io.reader,例如将数据转储到数据库,记录令牌和计费,ReadWrapper应实现io.Reader接口
resp.Body = &ReadWrapper{
reader: resp.Body,
}
return nil
}
p := &httputil.ReverseProxy{
Director: director,
ModifyResponse: modifyResponse,
}
这里只需专注于实现业务代码,不需关心如何发送和接收数据包,这也符合Go语言基于接口编程的思想。
自己如何实现
但如果是其他协议,例如websocket
、rpc
等,可能也存在类似好用的util
,例如websocketproxy,实现思路和上面的代码片段一致。但对于其他协议,可能没有好用的第三方库,我们就需要自己实现。
为了兼容流式和非流式,我们最初的实现是使用协程:
errCh := make(chan error)
respCh := make(chan []byte)
go RequestServer(ctx, reqBody, respCh, errCh)
loop:
for {
select {
case resp, ok := <-respCh:
if !ok {
break loop
}
// 封装响应并发送
case err, ok := <-errCh:
// 封装错误并发送
}
}
协程用于请求server
,接收并封装response
,然后通过管道发送到主逻辑,主逻辑负责与client
通信。这里乍一看没什么问题,但引入了一个协程和两个管道,导致程序的复杂度大大提高。后来我们进行了改进,将管道换成可异步读写的缓冲区:
var buf Buffer
go RequestServer(ctx, reqBody, &buf)
for {
n, err := buf.Read(chunk)
if err != nil {
if err != io.EOF {
// 封装错误并发送
}
return
}
if n > 0 {
// 封装响应并发送
}
}
这里的逻辑稍微清晰一些,只引入了一个协程,主逻辑几乎不用怎么更改。
还可以更优雅。社区建议我们放心大胆使用goroutine,但并不希望我们滥用。Practical Go:
if your goroutine cannot make progress until it gets the result from another, oftentimes it is simpler to just do the work yourself rather than to delegate it.
This often eliminates a lot of state tracking and channel manipulation required to plumb a result back from a goroutine to its initiator.
“如果主逻辑要从另一个 goroutine 获得结果才能取得进展,那么主逻辑自己完成工作通常比委托他人更简单。”。其实我们是更希望消除这个goroutine的,像之前的 httputil.ReverseProxy
一样,我们可以把逻辑封装成 io.ReadCloser
接口,然后返回到主逻辑:
type WrappedReader struct {
rawReader io.ReadCloser // response.Body
}
func (r *WrappedReader) Read(p []byte) (int, error) {
raw := make([]byte, cap(p))
n, err := r.rawReader.Read(raw)
// 封装响应
}
func (r *WrappedReader) Close() error {
return r.rawReader.Close()
}
wrappedReader, err := ConnectServer(ctx, reqBody)
if err != nil {
return err
}
defer wrappedReader.Close()
for {
n, err := wrappedReader.Read(chunk)
if err != nil {
if err != io.EOF {
return err
}
return nil
}
if n > 0 {
// 发送响应
}
}
这样,这个版本就全部改成了同步逻辑,不存在异步通信!并且在扩展类似计费、限流、鉴权等功能时,不会污染转发的主逻辑。但这里需要注意io.Reader
接口的定义,实现时需要满足接口定义的具体行为,之前的项目也踩过一次坑。
之前的一个项目接入层使用的是第二种方案,当时我还觉得自己的设计很优雅,将很多个转发协议整合到一个接口定义上,大大缩减了开发和维护人力成本。后来,我从这个项目转到另一个项目,现在再去看之前的设计,发现这个接口已经从原来的4个方法膨胀到7个方法了。之前基于接口开发的优雅设计如今一定会被后面的开发者所憎恨,因为实现一个简单的转发接口一定要求你实现7个方法。现在分析下来,还是之前的接口定义不合理,之前的接口定义wrap response
的方法为:
type Forwarder interface {
// ...
WrapInferResp(p []byte) []byte
// ...
}
所有的下游连接使用的都是基于标准HTTP的方法进行连接,所以后面需要兼容其他下游协议时就需要堆方法到接口定义中,因为这里传入的都是接口对象。如果将上面的方法改为:
type Forwarder interface {
// ...
Read(p []byte) (int, error)
// ...
}
其实也就是io.Reader
定义,我们这里就可以把连接下游的具体行为放到结构体定义去了,具体使用什么协议都可以实现。
这里给我们的提示其实就是,在定义接口时尽量多考虑更抽象更底层的行为,也就是go中已有的接口定义,通过这些接口组合得到最终的接口,这样可能往往是较好的设计。
配置文件
在go中,我们写入配置到内存,一般是有环境变量、监听远程下发、本地配置文件、主动读取数据库这几类。一般配置文件用于存放数据量不大,但变动较频繁的配置。
配置文件的读取
一般配置文件的路径会写成相对路径,方便本地调试与线上部署,读取的代码一般放在 config
模块的init()
函数中。配置文件放到 workspace
的根目录下:
package config
import (
"fmt"
"os"
"path"
)
const configPath = "./config.json"
func init() {
content, err := os.ReadFile(configPath)
if err != nil {
panic(err)
}
// 解析并设置内存全局配置变量
}
程序运行不会有什么问题,但是在做单元测试的时候就很难受了,因为我们在做单元测试的时候需要在目标模块的目录下,例如我们在下面的项目中对模块 moduleA
执行单元测试:
./
├── go.mod
├── go.sum
├── main.go
├── config.json
├── moduleA
│ ├── submodule1.go
│ ├── Test_submodule1.go
├── config
│ ├── config.go
cd moduleA
go test -v
这时候配置文件的读取就会失败,因为我们这里使用的相对路径,可能会有人提议,那不能使用绝对路径吗?如果使用绝对路径,那么这个路径就需要配置化,那么就要配置文件或者环境变量,部署的复杂度就大些了。否则就直接hardcode ,需要在发到线上生产环境前修改变量,这种情况下如果不CR就很容易出错。
因此这里应该容许读取配置文件时,可以在多个文件夹下寻找配置文件:
package config
import (
"fmt"
"path"
"github.com/spf13/viper"
)
var (
G *viper.Viper
Workspace string
)
func init() {
G = viper.New()
G.SetConfigName("config") // 配置文件的名称(不带扩展名)
G.SetConfigType("yaml")
G.AddConfigPath("../") // 查找配置文件的路径
G.AddConfigPath(".") //
err := G.ReadInConfig() // 查找并加载配置文件
if err != nil { //
panic(fmt.Errorf("fatal error config file: %w", err))
}
Workspace = path.Dir(G.ConfigFileUsed())
fmt.Println("=== Workspace:", Workspace)
}
这里的 viper
就可以支持在多个路径下查找文件,非常方便,让我们在模块的 init
函数中可以大胆使用相对路径的方式读取文件。
配置文件的格式
配置文件的格式一般会有很多种,像json、yaml、toml,一般项目中用的比较多是json和yaml,然后在代码中定义对应的结构体定义,例如:
type limitationConfig struct {
Key string `yaml:"key,omitempty"`
NParallel int32 `yaml:"nparallel,omitempty"`
}
这里会有一个问题,扩展起来很麻烦,你需要修改结构体的定义,如果涉及到结构体的嵌套,配置参数较多,就会存在一个庞大的结构体定义。其实很多时候,这些配置项本身之间没有很大关联,只是为了减少配置文件的数量,都放到同一个配置文件中,yaml格式本身就是将各个配置项解耦的。而viper
是可以允许无结构体定义直接读取配置项的,例如:
var G *viper.Viper
region := config.G.GetString("host.region")
namespace := config.G.GetString("namespace")
值得赞许的是,这里读取配置项时,不用处理error!这个真的是goher的救星好吗。因此使用viper
+yaml格式应该是对开发者来说比较舒服的方式。
JSON序列化
go官方自带的encoding/json
包对于更细微的序列化格式调整支持的不是很好,例如会将HTML字符序列化成unicode格式,默认不支持缩进,只能根据jsontag决定是否渲染缺省值(这一点在我的另一篇博客中有详细说明)等。这里安利一个第三方的sdk json-iterator/go
,例如:
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.Config{
IndentionStep: 2,
EscapeHTML: true,
SortMapKeys: true,
ValidateJsonRawMessage: true,
}.Froze()
type NotOmitemptyValEncoder struct {
encoder jsoniter.ValEncoder
}
func (codec *NotOmitemptyValEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
codec.encoder.Encode(ptr, stream)
}
func (codec *NotOmitemptyValEncoder) IsEmpty(ptr unsafe.Pointer) bool {
return false
}
type NotOmitemptyEncoderExtension struct {
jsoniter.DummyExtension
}
func (extension *NotOmitemptyEncoderExtension) DecorateEncoder(typ reflect2.Type, encoder jsoniter.ValEncoder) jsoniter.ValEncoder {
return &NotOmitemptyValEncoder{encoder: encoder}
}
func init() {
jsoniter.RegisterExtension(new(NotOmitemptyEncoderExtension))
}
通过 Config
设置缩进以及是否转义,通过注入 Extension
来避免零值在序列化时被忽略。使用的语法和标准的 encoding/json
包是一致的,可以无缝替代历史代码。