百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程字典 > 正文

Golang 在即刻后端的实践

toyiye 2024-06-24 19:22 10 浏览 0 评论

背景

随着业务变迁,即刻后端服务内积累了大量的陈旧代码,维护成本较高,代码重构甚至重写被提上了日程。相比起 Node.js ,Golang 有着一定的优点。由于即刻后端已经较好地服务化了,其他业务在 Go 上也有了一定的实践,直接使用 Go 重写部分即刻服务是一个可行的选择。在此过程中我们可以验证在同一个业务上两种语言的差异,并且可以完善 Go 相关的配套设施。

改造成果

截至目前,即刻部分非核心服务已经通过 Go 重写并上线。相比原始服务,新版服务的开销显著降低:

接口响应时长降低 50%

旧服务响应时间


新服务响应时间


内存占用降低 95%

服务替换前后内存消耗趋势

CPU 占用降低 90%

服务替换前后 CPU 消耗趋势

注:以上性能数据以用户筛选服务为例,这是一个读远大于写、任务单一的服务。由于在重写的过程中,对原有的实现也进行了一定的优化,所以以上数据仅供参考,不完全代表 Go 和 Node 真实性能比较。

改造方案

第一步:重写服务

在保证对外接口不变的情况下,需要重写一遍整个业务核心逻辑。不过在重写的过程当中,还是碰到一些问题:

  1. 以往的 Node 服务大多没有显式声明接口的输入输出类型,重写的时候需要找到所有相关字段。
  2. 以往代码绝大多数不包含单元测试,重写之后需要理解业务需求并设计单元测试。
  3. 老代码里面大量使用了 any 类型,需要费一番功夫才能明确所有可能的类型。很多类型在 Node 里面不需要非常严格,但是放到 Go 里面就不容偏差。

总之,重写不是翻译,需要对业务深入理解,重新实现一套代码。

第二步:正确性验证

由于很多服务没有完整的回归测试,单纯地依赖单元测试是远远不够保证正确性的。

一般来说,只读的接口可以通过数据对拍来验证接口正确性,即对比相同输入的新旧服务的输出。对于小规模的数据集,可以通过在本地启动两个服务进行测试。但是一旦数据规模足够大,就没办法完全在本地测试,一个办法就是流量复制测试。

由于服务之间跨环境调用比较麻烦且影响性能,所以使用消息队列复制请求异步对拍。

  1. 原始服务在每一次响应的时候,将输入和输出打包成消息发送至消息队列。
  2. 在测试环境下的消费服务会接受消息,并将输入重新发送至新版服务。
  3. 等到新版服务响应之后,消费服务会对比前后两次响应体,如果结果不同则输出日志。
  4. 最后,只需要下载日志到本地,根据测试数据逐一修正代码即可。

第三步:灰度并逐步替换旧服务

等到对业务正确性胸有成竹,就可以逐步上线新版服务了。得益于服务拆分,我们可以在上下游无感的情况下替换服务,只需要将对应服务的逐步替换为新的容器即可。

仓库结构

项目结构是基于 Standard Go Project Layout 的 monorepo:

.
├── build: 构建相关文件,可 symbolic link 至外部
├── tools: 项目自定义工具
├── pkg: 共享代码
│   ├── util
│   └── ...
├── app: 微服务目录
│   ├── hello: 示例服务
│   │   ├── cmd
│   │   │   ├── api
│   │   │   │   └── main.go
│   │   │   ├── cronjob
│   │   │   │   └── main.go
│   │   │   └── consumer
│   │   │       └── main.go
│   │   ├── internal: 具体业务代码一律放在 internal 内,防止被其他服务引用
│   │   │   ├── config
│   │   │   ├── controller
│   │   │   ├── service
│   │   │   └── dao
│   │   └── Dockerfile
│   ├── user: 大业务拆分多个子服务示例
│   │   ├── internal: 子业务间共享代码
│   │   ├── account:账户服务
│   │   │   ├── main.go
│   │   │   └── Dockerfile
│   │   └── profile: 用户主页服务
│   │       ├── main.go
│   │       └── Dockerfile
│   └── ...
├── .drone.yml
├── .golangci.yaml
├── go.mod
└── go.sum

  • app 目录包含了所有服务代码,可以自由划分层级。
  • 所有服务共享的代码放置于根目录的 pkg 内。
  • 所有外部依赖在根目录的 go.mod 内声明。
  • 每一个服务或者一组服务,通过 internal 目录,独占下面的所有的代码,避免被其他服务引用。

这种模式带来的好处:

  • 开发时只需要关心单一代码仓库,提高开发效率。
  • 所有服务的代码都可以放在一起,大到一整个大功能的服务集,小到一个运营活动服务,通过合理的层级组织,都可以在 app 目录下清晰维护。
  • 在修改公共代码的时候,对所有依赖其的服务保证兼容。即便是不兼容,通过 IDE 提供的重构功能,能够轻松地进行替换。

持续集成与构建

静态检查

项目使用 golangci-lint 静态检查。每一次代码 push,Github Action 会自动运行 golangci-lint,非常快且方便,如果发生了错误会将警告直接 comment 的 PR 上。

golangci-lint 本身不包含 lint 策略,但是可以集成各式 linter 以实现非常细致的静态检查,把潜在错误扼杀在摇篮。

测试+构建镜像

为了更快的构建速度,我们尝试过在 GitHub Action 上构建镜像,通过 matrix 特性可以良好地支持 monorepo。但是构建镜像毕竟相对耗时,放在 GitHub Action 上构建会耗费大量的 GitHub Action 额度,一旦额度用完会影响正常开发工作。

最终选择了自建的 Drone 来构建,通过 Drone Configuration Extension 也可以自定义复杂的构建策略。

通常来讲,我们希望 CI 系统构建策略足够智能,能够自动分辨哪些代码是需要构建,哪些代码是需要测试的。在开发初期,我也深以为然,通过编写脚本分析整个项目的依赖拓扑,结合文件变动,找到所有受到影响的 package,进而执行测试和构建。看上去非常美好,但是现实是,一旦改动公共代码,几乎所有服务都会被重新构建,简直就是噩梦。这种方式可能更加适合单元测试,而不是打包。

于是,我现在选择了一种更加简单粗暴的策略,以 Dockerfile 作为构建的标志:如果一个目录包含 Dockerfile,那么表示此目录为“可构建“的;一旦此目录子文件发生变动(新增或者修改),则表示此 Dockerfile 是“待构建“的。Drone 会为每一个待构建的 Dockerfile 启动一个 pipeline 进行构建。

有几点是值得注意的:

  • 由于构建的时候不但需要拷贝当前服务的代码,同时需要拷贝共享代码,构建的时候就需要将上下文目录设置在根目录,并将服务目录作为参数传入方便构建:
    docker build --file app/hello/Dockerfile --build-arg TARGET="./app/hello" .
  • 镜像名会被默认命名为从内向外的文件夹名的拼接,如./app/live/chat/Dockerfile 在构建之后会生成 {registry}/chat-live-app:{branch}-{commitId} 形式的镜像。
  • 所有构建(包括下载依赖、编译)由 Dockerfile 定义,避免在 CI 主流程上引入过多逻辑降低灵活度。通过 Docker 本身的缓存机制也能使构建速度飞快。
  • 一个问题,一旦服务目录之外的共享代码发生变化,Drone 无法感知并构建受到影响的服务。解决方案是在 git commit message 内加上特定的字段,告知 Drone 执行相应的构建。

配置管理

在 Node 项目里面,我们通常使用 node-config 来为不同环境配置不同的配置。Go 生态内并没有现成的工具可以直接完成相同的工作,不过可以尝试抛弃这种做法。

正如 Twelve-Factor 原则所推崇的,我们要尽可能通过环境变量来配置服务,而不是多个不同的配置文件。事实上,在 Node 项目当中,除开本地开发环境,我们往往也是通过环境变量动态配置,多数的 test.json/beta.json 直接引用了 production.json。

我们将配置分为两部分:

  • 单一配置文件
    我们在服务内通过文件的方式,定义一份完整的配置,作为基础配置,并且可以在本地开发的时候使用。
  • 动态环境变量
  • 当服务部署到线上之后,在基础配置的基础上,我们将环境变量注入到配置当中。

我们可以在服务目录中编写一份 config.toml(选择任何喜欢的配置格式),并编写基础的配置,作为本地开发的时候使用。

# config.toml
port=3000
sentryDsn="https://project@sentry.io"


[mongodb]
url="mongodb://localhost:27017"
database="db"

当在线上运行的时候,我们还需要在配置当中注入环境变量。可以使用 Netflix/go-env 将环境变量注入配置数据结构中:

type MongoDBConfig struct {
    URL      string `toml:"url" env:"MONGO_URL,MONGO_URL_ACCOUNT"`
    Database string `toml:"database"`
}


type Config struct {
    Port      int            `toml:"port" env:"PORT,default=3000"`
    SentryDSN string         `toml:"sentryDsn"`
    MongoDB   *MongoDBConfig `toml:"mongodb"`
}


//go:embed config.toml
var configToml string


func ParseConfig() (*Config, error) {
  var cfg Config
    if _, err := toml.Decode(configToml, &cfg); err != nil {
        return nil, err
    }
    if _, err := env.UnmarshalFromEnviron(&cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

上面代码还使用了最新的 Go1.16 embed 功能,只需要一行 Compiler Directive 就可以将任意文件一并打包进入最终构建出来二进制文件内,构建镜像只需要拷贝单个可执行文件即可,降低构建发布的复杂度。

服务调用

代码管理

即刻后端有多种语言的服务(Node/Java/Go),各个服务重复定义类型会造成人力浪费和不统一,故通过 ProtoBuf 定义类型,再用 protoc 生成对应的代码,并在一个仓库内维护各个语言的 client。

.
├── go
│   ├── internal: 内部实现,如 http client 封装
│   ├── service
│   │   ├── user
│   │   │   ├── api.go: 接口定义与实现
│   │   │   ├── api_mock.go: 通过 gomock 生成的接口 mock
│   │   │   └── user.pb.go: 通过 protoc 生成的类型文件
│   │   ├── hello
│   │   └── ...
│   ├── go.mod
│   ├── go.sum
│   └── Makefile
├── java
├── proto
│   ├── user
│   │   └── user.proto
│   ├── hello
│   │   └──  hello.proto
│   └── ...
└── Makefile

每一个服务通过一个独立的 package 对外暴露接口,每一个服务都由四部分组成:

  • 接口定义
  • 基于接口定义实现的具体调用代码
  • 基于接口定义由 gomock 生成 mock 实现
  • 基于 proto 生成类型代码

ProtoBuf

正如上面所说,为了降低内部接口对接和维护成本,我们选择使用 ProtoBuf 定义类型,并生成了 Go 类型。虽然使用 ProtoBuf 定义,但服务之间依然通过 JSON 传递数据,数据序列化和反序列化成了问题。

为了简化 ProtoBuf 和 JSON 互相转换,Google 提供了一个叫做 jsonpb 的包,这个包在原生 json 的基础上实现了 Enum Name(string) 和 Value(int32) 互相转换,以兼容传统的 string enum;还支持了 oneof 类型。上面的能力都是 Go 原生的 json 所无法实现的。如果使用原生 json 序列化 proto 类型,将会导致 enum 无法输出字符串和 oneof 完全无法输出。

这么说起来,是不是我们在代码全部都使用 jsonpb 替换掉原生 json 就好了?并不是,jsonpb 只支持对 proto 类型序列化:

func Marshal(w io.Writer, m proto.Message) error

除非所有对外读写接口的类型都用 ProtoBuf 定义,否则就不能一路使用 jsonpb

不过天无绝人之路,Go 的原生 json 定义了两个接口:

// Marshaler is the interface implemented by types that
// can marshal themselves into valid JSON.
type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

// Unmarshaler is the interface implemented by types
// that can unmarshal a JSON description of themselves.
// The input can be assumed to be a valid encoding of
// a JSON value. UnmarshalJSON must copy the JSON data
// if it wishes to retain the data after returning.
//
// By convention, to approximate the behavior of Unmarshal itself,
// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.
type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

任何类型只要实现了这两个接口,在被(反)序列化的时候就能调用自己的逻辑进行操作,类似 Hook 函数。那样,只需要为所有的 proto 类型实现这两个接口:当 json 尝试(反)序列化自己,就转而使用 jsonpb 进行。

func (msg *Person) MarshalJSON() ([]byte, error) {
    var buf bytes.Buffer
    err := (&jsonpb.Marshaler{
        EnumsAsInts:  false,
        EmitDefaults: false,
        OrigName:     false,
    }).Marshal(&buf, msg)
    return buf.Bytes(), err
}

func (msg *Person) UnmarshalJSON(b []byte) error {
    return (&jsonpb.Unmarshaler{
        AllowUnknownFields: true,
    }).Unmarshal(bytes.NewReader(b), msg)
}

经过一番寻找,最后找到了一个 protoc 插件 protoc-gen-go-json :它可以在生成 proto 类型的同时,为所有类型实现 json.Marshalerjson.Unmarshaler。这样一来就不需要为了序列化兼容而妥协,并且对代码也没有任何侵入性。

发布

由于是独立维护的仓库,需要以 Go module 的形式引入项目内使用。得益于 Go module 的设计,版本发布可以和 GitHub 无缝结合在一起,效率非常高。

  • 测试版本
    go mod 支持直接拉取对应的分支的代码作为依赖,不需要手动发布 alpha 版本,只需要在调用方的代码执目录执行
    go get -u github.com/iftechio/rpc/go@{branch} 就可以直接下载对应开发分支的最新版本了。
  • 正式版本
    当改动合并进入主分支,只需通过 Github Release 就可以发布一个稳定版本(也可以在本地打 git tag),即可通过具体版本号拉到对应的仓库快照:
    go get github.com/iftechio/rpc/go@{version}

由于 go get 本质上就是下载代码,我们的代码托管在 GitHub 上,所以在国内阿里云上构建代码时可能因为网络原因出现拉取依赖失败的情况(private mod 无法通过 goproxy 拉取)。于是我们改造了 goproxy,在集群内部署了一个 goproxy:

  • 针对公共仓库会通过 goproxy.cn 拉取。
  • 针对私有仓库,则可以通过代理直接从 GitHub 上拉取,并且 goproxy 也会代为处理好 GitHub 私有仓库鉴权工作。

我们只需要执行如下代码即可通过内部 goproxy 下载依赖:

GOPROXY="http://goproxy.infra:8081" \
GONOSUMDB="github.com/iftechio" \
go mod download

Context

Context provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processes.

Context 是 Go 当中一个非常特别的存在,可以像一座桥一样将整个业务串起来,使得数据和信号可以在业务链路上下游之间传递。在我们的项目当中,context 也有不少的应用:

取消信号

每一个 http 请求都会携带一个 context,一旦请求超时或者 client 端主动关闭连接,最外层会将一个 cancel 信号通过 context 传递到整个链路当中,所有下游调用立即结束运行。如果整个链路都遵循这个规范,一旦上游关闭请求,所有服务都会取消当前的操作,可以减少大量无谓的消耗。

在开发的时候就需要注意:

  • 大多数任务被取消的同时,会抛出一个 context.ErrCancelled 错误,以使调用者能够感知异常并退出。但是 RPC 断路器也会捕获这个错误并记录为失败。极端场景下,客户端不断发起请求并立刻取消,就能够使服务的断路器纷纷打开,造成服务的不稳定。解决方案就是改造断路器,对于特定的错误依然抛出,但不记录为失败。
  • 分布式场景下绝大多数数据写入无法使用事务,需要考虑一个操作如果被中途取消,最终一致性还能否得到保证?对于一致性要求高的操作,需要在执行前主动屏蔽掉 cancel 信号:
// 返回一个仅仅实现了 Value 接口的 context
// 只保留 context 内的数据,但忽略 cancel 信号

func DetachedContext(ctx context.Context) context.Context {
	return &detachedContext{Context: context.Background(), orig: ctx}
}

type detachedContext struct {
	context.Context
	orig context.Context
}

func (c *detachedContext) Value(key interface{}) interface{} {
	return c.orig.Value(key)
}

func storeUserInfo(ctx context.Context, info interface{}) {
  ctx = DetachedContext(ctx)
  saveToDB(ctx, info)
  updateCahce(ctx, info)
} 

上下文透传

每一个请求进入的时候,http request context 都被携带上各种当前 request 的信息,比如 traceId、用户信息,这些数据就能够随着 context 被一路透传至业务整条链路,期间收集到的监控数据都会与这些数据进行关联,便于监控数据聚合。

Context.Value should inform, not control.

使用 context 传递数据最需要注意的就是:context 的数据仅仅用于监控,切勿用于业务逻辑。所谓“显式优于隐式”,由于 context 不直接对外暴露任何内部数据,使用 context 传递业务数据会使程序非常不优雅,而且难以测试。换句话说,任何一个函数哪怕传入了的是 emptyCtx 也不应该影响正确性。

错误收集

Errors are just values.

Go 的错误是一个普通的值(从外部看来就是一个字符串),这给收集错误带来了一定的麻烦:我们收集错误不单需要知道那一行错误的内容,还需要知道错误的上下文信息。

Go1.13 引入了 error wrap 的概念,通过 Wrap/Unwrap 的设计, 就可以将一个 error 变成单向链表的结构,每一个节点上都能够存储自定义的上下文信息,并且可以使用一个 error 作为链表头读取后方所有错误节点。

对于单个错误来说,错误的 stacktrace 是最重要的信息之一。Go 通过 runtime.Callers 实现 stacktrace 收集:

Callers fills the slice pc with the return program counters of function invocations on the calling goroutine's stack.

可以看到, Callers 只能收集单个 goroutine 内的调用栈,如果希望收集到完整的 error trace,则需要在跨 goroutine 传递错误的时候,将 stacktrace 包含在 error 内部。这个时候就可以使用第三方库 pkg/errors 的 errors.WithStack 或者 errors.Wrap 来实现,它们会创建一个新的 error 节点,并存入当时的调用栈:

// WithStack annotates err with a stack trace at the point WithStack was called.
// If err is nil, WithStack returns nil.
func WithStack(err error) error {
    if err == nil {
        return nil
    }
    return &withStack{
        err,
        callers(),
    }
}

func main() {
  ch := make(chan error)
  go func() {
    err := doSomething()
      ch <- errors.withStack(err)    
  }()
  err := <-ch
  fmt.Printf("%w", err)
}

最终的错误收集(往往在根部的 web 中间件上),可以直接使用 Sentry:

sentry.CaptureException(errors.WithStack(err)) // 最终上传的时候也不忘收集 stacktrace

Sentry 会基于 errors.Unwrap 接口,取出每一层的 error。Sentry 针对每一层 error 能够自动导出错误栈。由于 stacktrace 并非正式标准,Sentry 主动适配了几个主流的 Stacktrace 方案,其中就包括 pkg/errors 的。

这样就可以通过 Sentry 后台查看完整的报错信息。如下图,每一个大的 section 都是一层 error,每一个 section 内都包含这个 error 内的上下文信息。

参考链接

  • TJ 谈 Go 相比 Node 的生产力优势
  • https://qr.ae/pNdNhU
  • Standard Go Project Layout
  • https://github.com/golang-standards/project-layout
  • The Tweleve-Factor App
  • https://12factor.net/
  • Go Wiki - Module: Releaseing Modules (V2 or Higher)
  • https://github.com/golang/go/wiki/Modules#releasing-modules-v2-or-higher
  • How to correctly use context.Context in Go 1.7
  • https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
  • Don’t just check errors, handle them gracefully
  • https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully


作者:sorcererxw

来源-微信公众号:即刻技术团队

出处:https://mp.weixin.qq.com/s/cepoYJR5Xeloan31-D1iQg

相关推荐

为何越来越多的编程语言使用JSON(为什么编程)

JSON是JavascriptObjectNotation的缩写,意思是Javascript对象表示法,是一种易于人类阅读和对编程友好的文本数据传递方法,是JavaScript语言规范定义的一个子...

何时在数据库中使用 JSON(数据库用json格式存储)

在本文中,您将了解何时应考虑将JSON数据类型添加到表中以及何时应避免使用它们。每天?分享?最新?软件?开发?,Devops,敏捷?,测试?以及?项目?管理?最新?,最热门?的?文章?,每天?花?...

MySQL 从零开始:05 数据类型(mysql数据类型有哪些,并举例)

前面的讲解中已经接触到了表的创建,表的创建是对字段的声明,比如:上述语句声明了字段的名称、类型、所占空间、默认值和是否可以为空等信息。其中的int、varchar、char和decimal都...

JSON对象花样进阶(json格式对象)

一、引言在现代Web开发中,JSON(JavaScriptObjectNotation)已经成为数据交换的标准格式。无论是从前端向后端发送数据,还是从后端接收数据,JSON都是不可或缺的一部分。...

深入理解 JSON 和 Form-data(json和formdata提交区别)

在讨论现代网络开发与API设计的语境下,理解客户端和服务器间如何有效且可靠地交换数据变得尤为关键。这里,特别值得关注的是两种主流数据格式:...

JSON 语法(json 语法 priority)

JSON语法是JavaScript语法的子集。JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔花括号保存对象方括号保存数组JS...

JSON语法详解(json的语法规则)

JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔大括号保存对象中括号保存数组注意:json的key是字符串,且必须是双引号,不能是单引号...

MySQL JSON数据类型操作(mysql的json)

概述mysql自5.7.8版本开始,就支持了json结构的数据存储和查询,这表明了mysql也在不断的学习和增加nosql数据库的有点。但mysql毕竟是关系型数据库,在处理json这种非结构化的数据...

JSON的数据模式(json数据格式示例)

像XML模式一样,JSON数据格式也有Schema,这是一个基于JSON格式的规范。JSON模式也以JSON格式编写。它用于验证JSON数据。JSON模式示例以下代码显示了基本的JSON模式。{"...

前端学习——JSON格式详解(后端json格式)

JSON(JavaScriptObjectNotation)是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。它基于JavaScriptProgrammingLa...

什么是 JSON:详解 JSON 及其优势(什么叫json)

现在程序员还有谁不知道JSON吗?无论对于前端还是后端,JSON都是一种常见的数据格式。那么JSON到底是什么呢?JSON的定义...

PostgreSQL JSON 类型:处理结构化数据

PostgreSQL提供JSON类型,以存储结构化数据。JSON是一种开放的数据格式,可用于存储各种类型的值。什么是JSON类型?JSON类型表示JSON(JavaScriptO...

JavaScript:JSON、三种包装类(javascript 包)

JOSN:我们希望可以将一个对象在不同的语言中进行传递,以达到通信的目的,最佳方式就是将一个对象转换为字符串的形式JSON(JavaScriptObjectNotation)-JS的对象表示法...

Python数据分析 只要1分钟 教你玩转JSON 全程干货

Json简介:Json,全名JavaScriptObjectNotation,JSON(JavaScriptObjectNotation(记号、标记))是一种轻量级的数据交换格式。它基于J...

比较一下JSON与XML两种数据格式?(json和xml哪个好)

JSON(JavaScriptObjectNotation)和XML(eXtensibleMarkupLanguage)是在日常开发中比较常用的两种数据格式,它们主要的作用就是用来进行数据的传...

取消回复欢迎 发表评论:

请填写验证码