当前位置: 首页 > article >正文

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 包是一致的,可以无缝替代历史代码。

未完待续


http://www.kler.cn/a/406390.html

相关文章:

  • JDBC 批量调用数据库 SQL, 函数与存储过程
  • Spring Boot 3.4 正式发布,结构化日志!
  • 渗透测试笔记—shodan(7完结)
  • 虚表 —— 数据中的特殊成员
  • 软件团队的共担责任
  • Spring源码(十三):Spring全系列总结
  • 【qt版本概述】
  • js前端加密方案库Crypto-js之aes的使用
  • 速通前端篇 —— CSS
  • c++中操作数据库的常用函数
  • 前端vue调试样式方法
  • 前端 px、rpx、em、rem、vh、vw计量单位的区别
  • 【D3.js in Action 3 精译_040】4.4 D3 弧形图的绘制方法
  • 准备阶段 Statistics界面性能分析
  • uniapp H5上传图片前压缩
  • vue的class绑定,后边的类会覆盖前边类样式吗
  • 3-22 ElementPlus:表单
  • vue3 在哪些方便做了性能提升?
  • 【不墨迹系列】快速入门 XML 语言
  • SpringCloud OpenFeign用户转发在请求头中添加用户信息 微服务内部调用
  • STL-stack栈:P1981 [NOIP2013 普及组] 表达式求值
  • Cannal实现MySQL主从同步环境搭建
  • 量子神经网络
  • Java 创建不可变集合
  • 浅谈丨功能安全测试,汽车的守护者
  • 40分钟学 Go 语言高并发:sync包详解(下)