欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 财经 > 创投人物 > go项目中比较好的实践方案

go项目中比较好的实践方案

2025/2/24 23:26:05 来源:https://blog.csdn.net/QSTARTmachine/article/details/143976488  浏览:    关键词: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")// 转换URLoriginURL := req.URL.String()req.Host = remote.Hostreq.URL.Scheme = remote.Schemereq.URL.Host = remote.Hostreq.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语言基于接口编程的思想。

自己如何实现

但如果是其他协议,例如websocketrpc等,可能也存在类似好用的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 configimport ("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.gocd moduleA
go test -v

这时候配置文件的读取就会失败,因为我们这里使用的相对路径,可能会有人提议,那不能使用绝对路径吗?如果使用绝对路径,那么这个路径就需要配置化,那么就要配置文件或者环境变量,部署的复杂度就大些了。否则就直接hardcode ,需要在发到线上生产环境前修改变量,这种情况下如果不CR就很容易出错。

因此这里应该容许读取配置文件时,可以在多个文件夹下寻找配置文件:

package configimport ("fmt""path""github.com/spf13/viper"
)var (G         *viper.ViperWorkspace 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 包是一致的,可以无缝替代历史代码。

未完待续

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词