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语言基于接口编程的思想。

自己如何实现

但如果是其他协议,例如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 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 包是一致的,可以无缝替代历史代码。

未完待续

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值