背景
通过分享 Go 语言在 ConfigHub 服务中的使用场景,将 Go 与 JS 中的术语、编程技巧、服务框架进行对比,帮助前端同学快速入门 Go。
环境搭建
安装 Go
与 Node 的 nvm 相似,Go 社区也提供了 gvm 用于快速安装和切换 Go 版本。但开场就要踩坑,要想运行 gvm,就需要提前安装 Go,因为需要用 Go 去编译源码才能安装。
ruby
# 先通过 brew 安装 Go
brew install go
# 安装 gvm
bash < <(curl -s -S -L https://raw.githubusercontent.com/moovweb/gvm/master/binscripts/gvm-installer)
# gvm 安装指定版本的 Go
gvm install go1.20.7
gvm use go1.20.7 --default
# 删掉 brew 安装的 Go
brew uninstall go
GOROOT 与 GOPATH
安装完 Go 后经常会提到 GOROOT 与 GOPATH 两个环境变量,这两个环境变量都对依赖加载起作用。GOROOT 可以理解为 Go 源码和可执行文件存在的目录,对于 Go 内置的依赖都将从 GOROOT 的目录中寻找 。GOPATH 可以理解为三方依赖的包会被下载到这里,对于三方依赖将从该目录中寻找。
IDE
GoLand
-
下载 GoLand。
-
启动 GoLand,填写 License后,点击 Activate 即可。
VSCode
- 安装 VSCode GoLang 插件。
依赖管理
Go 提供了 Go Module 进行依赖管理,与 Node 社区存在 npm/yarn/pnpm 工具不同,Go 的依赖管理工具是 Go 官方支持的。但使用起来仍然经常踩坑。
什么是 Go Module
在 Node 中依赖的三方包称为 package,package.json 中的 "name"
字段值就是包名。而在 Go 中三方依赖时通过 git 管理的,三方依赖称为 module,一个 module 是指目录下有 go.mod
文件,go.mod
文件功能与 package.json 类似。
Go 中 module 的名称是 go.mod
文件中的 module
指令声明的。例如 github.com/cloudwego/hertz
是一个 Go module,因为仓库目录下存在 go.mod 文件,且该 module 名称是 github.com/cloudwego/hertz
。
锁定依赖版本
Node 中通过 package-lock.json 或 yarn.lock 锁定依赖版本,而 Go 中通过 go.sum 锁定依赖版本。
如何安装依赖
全量安装依赖: go mod ``tidy
新增依赖: go get {module}@{version}
。新手同学可能会在 go.mod
中新增依赖,然后执行 go mod tidy
来安装新依赖。由于项目中没有使用该依赖,执行 tidy 后,依赖会从 go.mod
中移除,新手同学直接懵了。
删除依赖: 删除项目中所有导入依赖的代码后,执行 go mod tidy
Go 基础知识与 Node.js 对比
Go routine 与协程调度
在并发与调度上,Go 与 Node.js 存在的不同之处:
- Go 中所有系统调用 API 都是阻塞式的,而 Node.js 所有系统调用都提供了非阻塞式 API。
- Go 用户代码是多线程的,而 Node.js 用户代码是单线程的。
阻塞式 API 与非阻塞式 API
以读取文件内容作为例子,上面是为 Go 代码,下面是 JS 代码。
scss
// Go 代码,「阻塞式 API」
func demo() {
os.ReadFile("file.txt")
// 以下代码将在读取文件结束后才执行
execFunc()
}
scss
// JS 代码,「非阻塞式 API」
function demo() {
fs.readFile("file.txt", (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
// 以下代码立刻执行
execFunc()
}
// JS 代码,「阻塞式 API」
async function demo() {
fs.readFileSync("file.txt");
// 以下代码将在读取文件结束后才执行
execFunc()
}
在 Node.js 中所有系统调用都推荐使用非阻塞式 API,并通过这种方式实现用户 线程 发起系统调用( IO 阻塞)时可继续执行 JS 代码。 Node.js 底层读取文件的系统调用是在另一个线程中执行的,仍然是阻塞式的。而 Go 中所有系统调用都是阻塞式的,虽然也可以将 API 封装成类似 JS 的回调形式,但这不符合 Go 的编程习惯。
Go routine 与异步编程
在 Go 中通过 go
关键字,再加上一个函数调用,就创建了一个 Go routine。go
关键字之后的函数调用将在该 Go routine 中执行。在调用方看来,该函数就是异步执行的。那么我们来看看如何通过 go routine 实现类似 JS 的 readFile API?
实现 Node.js 的 fs.readFile:
go
func readFile(filePath string, cb func(content []byte, err error)) {
go func() {
content, err := os.ReadFile(filePath)
cb(content, err)
}()
}
以上代码中,第二行到第五行的 func(){}
声明了一个匿名函数,之后的 ()
是调用该匿名函数。由于匿名函数的调用是在 go
关键字后面,所以就创建了一个新的 Go routine,并在该 Go routine 中执行该匿名函数。
实现 Promise.all:
我们再来看看,在 Go 中如何实现 Promise.all。以下代码需要关注两个点:
-
第三行预分配了相应任务数量的
retList
,目的是让每个任务都只给对应的retList[idx]
赋值,避免每个任务都去操作retList
引起数据竞争。 -
第4行、13-18行、23行是 Go 中并发多个 Go routine 执行任务的模板代码,语义是
- 第 13 行,在异步任务启动之前给任务数加 1
- 第 17 行,在异步任务结束之后给任务数减 1
- 第 23 行,等待所有异步任务结束,当
wg
中的任务数为 0 时继续执行。
go
func PromiseDotAll() {
ids := []int{1, 2, 3}
retList = make([]interface{}, len(ids))
var wg sync.WaitGroup
fetch := func (idx int) {
id := ids[idx]
// 查询 id 对应的数据
retList[idx] = "xxx"
}
for idx := range ids {
wg.Add(1)
// idx 作为参数,避免闭包问题,同 JS
go func(idx int) {
defer func() {
wg.Done()
}()
fetch(idx)
}(idx)
}
wg.Wait()
// ids 中所有 id 的都执行完了 fetch 了
}
多线程存在的数据竞争问题
在 Go 中读写同一个变量时,需要非常小心,因为一不小心就会引起数据竞争导致程序异常。而在 Node.js 中,我们根本不需要关心数据竞争问题。以计数为例子,代码1是存在数据竞争的 Go 代码,代码2是加锁后解决数据竞争后的 Go 代码。
- 代码1
go
func TestCnt1(t *testing.T) {
cnt := 0
total := 100000
var wg sync.WaitGroup
wg.Add(1)
wg.Add(1)
write := func() {
for i := 0; i < total; i++ {
cnt += 1
}
wg.Done()
}
go write()
go write()
wg.Wait()
// 本次打印值不是 200000
fmt.Println("cnt:", cnt)
}
- 代码2
scss
func TestCnt2(t *testing.T) {
cnt := 0
total := 100000
m := sync.Mutex{}
var wg sync.WaitGroup
wg.Add(1)
wg.Add(1)
write := func() {
for i := 0; i < total; i++ {
m.Lock()
cnt += 1
m.Unlock()
}
wg.Done()
}
go write()
go write()
wg.Wait()
// 本次打印值一定是 200000
fmt.Println("cnt:", cnt)
}
而类似的功能通过 JS 代码实现时,变量 cnt 的值一定是 200000。
javascript
async function demo() {
let cnt = 0
let total = 100000
async function write(label) {
for (let i = 0; i < total; i++) {
cnt++
}
}
// 这样写,看起来更异步
await Promise.all([write(), write()])
console.log('cnt:', cnt)
}
之所以 Node.js 无需加锁也不会出现数据竞争,是因为 JS 的用户代码是执行在单线程中的, 而 Go 的程序是多线程运行的。在 Go 代码中 cnt += 1
可以分为两步,第一步是读出 cnt 内存中的值,第二步为计算 cnt + 1 并赋值。假设 cnt 初始值为 0,两个线程按照如下顺序执行,那么尽管执行了两次 cnt += 1
,但最终 cnt 值却为 1。
协程调度
协程就是指操作系统中的用户级别****线程 ,在 JS 中我们说的 EventLoop 就是 JS 协程的调度算法。JS 的协程(异步任务)分为宏任务和微任务,在 Node.js 中微任务包括 nextTick 和 Promise,其他的都是宏任务。流传很广的 Event Loop 宏任务流程如下(参考自 Node.js Docs)。在每个宏任务阶段生成的微任务都会在当前宏任务阶段执行完后,才会进入下一个宏任务。请大家不要深陷宏任务执行顺序的细节中,我们通常只需要知道异步任务的执行时间是未知的就行了,而不需要深究具体执行顺序。
在 Go 中协程就是一个 Go routine,Go routine 的调度算法是 GMP,如下图(参考自深入理解 Go 语言)。
在 GMP 算法中,G 是指 Go routine,M 是指线程(物理处理器),P 是指 Processor(逻辑处理器)。一个 Go routine 将放在逻辑处理器 P 中,逻辑处理器将分配给一个物理处理器,并执行其中的 Go routine。与 Node.js 相比较,如果我们把逻辑处理器设置为 1,那么所有 Go routine 都将串行执行,也就不会出现数据竞争问题了。例如我们修改之前存在数据竞争的计数例子,将逻辑处理器设置为 1。我们看到尽管没有加锁,但它的运行结果与 Node.js 相同,始终都是 200000。
scss
func TestCnt3(t *testing.T) {
runtime.GOMAXPROCS(1)
cnt := 0
total := 100000
var wg sync.WaitGroup
wg.Add(1)
wg.Add(1)
write := func() {
for i := 0; i < total; i++ {
cnt += 1
}
wg.Done()
}
go write()
go write()
wg.Wait()
// 本次打印值一定是 200000
fmt.Println("cnt:", cnt)
}
Go 数据类型
语言内置类型
语言提供的内置类型是以小写字母开头的类型(如 int/float64/bool/map)。这些类型通常是没有任何可调用方法的,即不能通过 .xxx() 调用方法(除了 error 外)。比如 map:
go
m := map[string]int{}
// m 下面是没有任何方法的
m.xxx()
自定义类型
自定义类型分三种:
- 类型别名。如:
type BitBool = bool
。 BitBool 就是 bool,这种用法通常在 SDK wrapper 中使用,也用于在实现"继承"时为 struct embedding 命名别名。不可以为 BitBool 定义新的方法。 - 类型新名。如:
type BitBool bool
。BitBool 和 bool 只是内存结构一致,但它们是不同的类型,可以为 BitBool 定义新的方法。下面两张图分别是类型别名和类型新名的差异。
- Struct 类型。如:
type C3Mgr struct {}
。Struct 类型只有属性字段,不包括方法,方法通过 func 单独声明。 - Interface 类型。如:
type Client interface {}
。Interface 类型只有方法,没有属性字段。
Any type
interface{} 可以代表任意类型,任何值都可以赋值给类型为 interface{} 的变量。Go 支持泛型之后 any 等价于 interface{}。
ini
var (
i interface{}
i2 any
a int
b string
c *int
)
i = a
i = b
i = c
i2 = a
i2 = b
i2 = c
go
var i interface{}
i = 2
i2 := i.(int) // 将其转为 int 类型
i = true
i3 := i.(bool) // 将其转为 bool 类型
if _, ok := i.(int); ok { // 通过 ok 判断类型
// 是 int 类型
} else {
// 不是 int 类型
}
类型强转与 Type Assertion
在 Go 中当类型 A 和类型 B 的内存结构一致时,可以用于强转,Go 会在编译时将检查两个类型是否支持强转。常用于以下情况:
[]byte 和 string
互转- 任意类型都能被强转为 interface{}
- 类型新名互转
go
// case 1: []byte 和 string 互转
var (
bs []byte
s string
)
s = string(bs)
bs = []byte(s)
// case 2: 任意类型都能被强转为 interface{}
var ia = (interface{})(123)
// case 3: 类型新名互转
type A int
func (a A) p() {
fmt.Printf("a = %v", int(a))
}
func TestTypeConvert(t *testing.T) {
var a = A(123)
a.p()
}
而 Type Assertion 是指将某个 interface 类型的变量转换成另一个 interface 或者具体类型,Go 是在运行时进行检查的。类似于我们要在 JS 中检查参数的类型,并进行不同的处理。
go
func TestTypeAssertion(t *testing.T) {
var p = func(a interface{}) {
if v, isInt := a.(int); isInt {
fmt.Printf("我是 int. %d\n", v)
} else if v, isString := a.(string); isString {
fmt.Printf("我是 string. %s\n", v)
} else {
fmt.Printf("函数不支持该参数类型,%#v\n", a)
}
}
p(123)
p("cg")
p([]byte("cg"))
}
// 输出如下:
// 我是 int. 123
// 我是 string. cg
// 函数不支持该参数类型,[]byte{0x63, 0x67}
泛型
Go 中的泛型比 TS 要简单很多,对前端同学来说非常好上手,下面代码是泛型实现的 interface/struct/func。
go
type a[T any, S any] interface {
GetT() T
GetS() S
}
type b[T comparable] struct {
v T
}
type Equatable interface {
Equals(other interface{}) bool
}
func f[T Equatable](a, b T) bool {
return a.Equals(b)
}
在支持泛型的 Go 上进行开发时,推荐使用 Go 中的 lodash 工具库。如果你发现有些工具函数在 lo 工具库中没有,那有可能该函数已经被 Go 内置了。例如往数组(切片)中插入元素的方法 slices.Insert
就不在 lo 工具库中,而在官方维护的试验性仓库(golang.org/x/exp/slices
)中。
面向接口编程 VS OOP
Go 通过 interface 强调面向接口编程的设计理念,对于熟悉 OOP 的同学来说,在 Go 中实现封装性和多态性都很好理解。但用 Go 中来实现继承性的代码就比较奇怪了,Go 通过 struct embedding **语法实现类似继承的能力。
css
type A struct {
a string
}
func (a A) printA() {
fmt.Printf("printA from A\n")
}
// B 将继承 A 的方法及属性
type B struct {
// 可以通过类型别名指定为其他名称
A
}
func (b B) printB() {
fmt.Printf("printB from B\n")
}
func TestInherit(t *testing.T) {
var b = B{
A{
a: "cg",
},
}
// 从类型为 B 的变量访问类型 A 中的属性
fmt.Printf("b.A.a: %v\n", b.A.a)
b.printA()
b.printB()
}
Go Package 等价于 Node.js 中的 Module
在 Go 中,每个文件顶部都有个 package xxx
。在 Node.js 中一个文件就是一个模块,代表导入依赖的粒度。但在 Go 中一个目录下的所有的 go 文件是一个 package(子目录下的不算),一个 package 是 Go 中导入依赖的粒度。Go 基于目录划分 package 使得 Go 的 package 有两点比较特别:
-
文件 b 不需要 import 就能直接使用同目录下的 a 文件中的 package 级别变量和方法。
-
同目录下的所有文件的第一行 package xxx 必须同名。Go 不允许在同目录下声明两个 package。
Go 如何导出变量、属性或方法
Node.js 的模块通过 export 关键字或者 module.exports 来导出模块属性,而 Go 是通过变量名的首字母是否是大写来判断一个变量是否对 package 外可见。
在 Go 中,package 变量是否大写开头,结构体的属性或方法是否大写开头决定了这些变量、属性或方法是否对 package 外的代码是否可见。无论首字母是否大写,这些变量、属性或方法在 package 内都是可以访问的。
Go Channel 与 Node.js EventEmitter 对比
Do not communicate by sharing memory; instead, share memory by communicating.
Channel 可以理解为 Node.js 中的 EventEmitter,一端写入后,另一端会收到通知。但它们又存在以下差异点:
-
Go 中 Channel 发送端发送的一个消息,只能被一个 Channel 接收端处理。而 Node.js 会遍历所有 Listener 并执行,即所有的接收端都会收到该消息。
-
Go 中 Channel 存在消息缓存,当 Channel 发送端发送的消息,没有 Channel 接收端处理时,该消息会被存入缓存区 。如果缓存区也满了,那么发送端会被阻塞直到该消息被存入缓存区或者被接收端处理。而 Node.js 中 EventEmitter 发送事件时,会同步遍历所有 Listener 并执行,没有 Listener 时不会发生阻塞。
下面代码是通过将数组求和分成两个并行部分,分别在两个 go routine 中计算两部分的各自的求和结果,并通过 channel 将每部分的求和结果通知到 mian goroutine。
go
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // send sum to c
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(s[:len(s)/2], c)
go sum(s[len(s)/2:], c)
x, y := <-c, <-c // receive from c
fmt.Println(x, y, x+y)
}
Go 错误处理
error 作为函数的最后一个返回值
在 Go 中推荐将程序异常状态作为函数的最后一个返回值 ,返回给调用方。例如打开文件的 os.Open
方法,它的函数签名如下,当返回一个非空的 error 时,表示打开文件失败了。
go
func Open(name string) (file *File, err error)
error 类型值可通过 errors.New()
或者 fmt.Errorf()
生成。对于 error 的类型判断,则通过 errors.Is()
进行。关于 error 的最佳实践参考 Go 官方的 Error handling and Go 和 Working with Errors in Go 1.13。
Panic error
除了将 error 作为函数的最后一个返回值外,Go 还提供了另一种错误,panic 错误。在 API 设计时尽量中不要主动 panic,因为任意 Go routine 中未被处理的 panic 都将引起整个程序退出。接口调用方通常不会处理 panic 错误,panic 错误在 Go 程序中通常意味着非预期的错误。
在 Go 语言中没有 try/catch 语法,只能通过 defer/recover 语法拦截 panic。如下代码,该方法将注册 Apollo 的路由。如果注册过程中出现 panic 错误,那也只会打印日志,不会引起整个程序退出。
go
func safeRegisterApolloPortal() {
defer func() {
// 处理 registerApolloPortal() 出现的 panic 错误
if r := recover(); r != nil {
log.Error("panic when register apollo portal to platform. err: %v", r)
}
}()
// 处理 registerApolloPortal() 返回的 error 类型
if err := registerApolloPortal(); err != nil {
log.Error("error when register apollo portal to platform. err: %v", err)
}
}
Go 测试用例
Go 的测试用例的文件名以 _test.go
结尾,方法以 Test 开始,参数固定为 (t *testing.T)
。如下所示:
go
// 文件名 config_center_test.go
func Test01(t *testing.T) {
}
func Test02(t *testing.T) {
}
Go 实践分享
技术选型为什么选择 Go?
在我们做配置中心服务时,选择 Go 的最大原因是考虑到 ToB 服务所需的技术生态在 Go 语言中是比较完备的。当时 ToB 团队后端的编程语言基本都是 Go,且服务依赖的技术生态(如:服务接入 MGR、log SDK、服务监控等)都比较完善,只需要对齐就可以。而 Node.js API 服务在 ToB 侧的技术生态是相对缺失的,需要和 Go 对齐维护很多轮子,对于我们来说 ROI 太低了。
Node.js or Go?
在公司内如果服务只考虑 ToD 的话,Node.js 的基建与 Go 差不多,前端同学使用 Node.js 作为服务开发语言时 ROI 更高。从开源社区和生态来看,JS 的社区和生态肯定是比 Go 大的,如果能用 JS 实现,当然首选还是 JS 而不是 Go。但在某些领域(如 K8s),Go 的生态是比 JS 完备的,这些领域就需要首选 Go 了。另外前端掌握 Go 之后也可以使用 Go 做一些高效工具(如 esbuild)或将 Go 代码转译为 WASM 代码后在 JS 环境中运行等。
Go 日志 Accesslog
由于 byted/hertz 中的 accesslog 中间件打印的内容偏少、采样率低,且无法扩展,所以我们实现了自己的 accesslog 中间件。在 accesslog 中我们打印了请求和响应的所有内容,并把 cookie、secret 等敏感信息做了脱敏。
链路日志
链路日志是公司级别的基建,其设计方案是所有日志都打印 __logid 字段,最初的 __logid 由 TLB 或请求的发起方生成,并在发起 HTTP 请求时通过 x-tt-logid 请求头传给下游。
在 Go 中,当前请求的 logid 值是保存在 ctx 中的,所以每条日志在打印时都需要传入当前请求对应的 ctx。Node.js 的服务框架实现更简单些,直接通过调用 ctx.log 打印日志就可以关联上链路日志。
监控
ToB 公有云上服务的监控打点和 ToD 类似,使用的 metrics SDK 打点,监控大盘在 Grafana 中,监控告警是使用的 Argos。
为了提高告警信噪比,我们做了如下优化:
- 针对 4xx 和 5xx 设置了不同的阈值,甚至对于 401/404 类型的错误设置的阈值会更高些。
- 增加了服务端自定义的业务错误码,用于对指定的错误设置不同的阈值。例如在 BOE 由于数据库的不稳定性,经常收到 invalid connection 的错误,我们就对这种类型的错误在 BOE 设置更高的阈值。
流控
流控是通过开源 QPS 限流仓库 ratecounter 实现的,实现了用户维度限流和服务端维度限流,限流流控具体值支持动态配置、实时生效。
容灾
服务容灾
- ToB 多 AZ
ToB 服务部署在 K8S 中,通过配置 deployment 就能实现 Pod 与 AZ 的亲和性。以下配置是优先将 Pod 分配在不同的 AZ 中,如果只有一个 AZ 那么所有 Pod 都会分配在该 AZ 中。
yaml
apiVersion: v1
kind: Pod
metadata:
name: pod-a
spec:
containers:
- name: pod-a
image: nginx:latest
ports:
- containerPort: 80
------
apiVersion: v1
kind: Pod
metadata:
name: pod-b
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: security
operator: In
values:
- S2
topologyKey: topology.kubernetes.io/zone
containers:
- name: nginx-specify-zone
image: nginx:latest
ports:
- containerPort: 80
- 数据库读写分离
使用 Gorm 访问数据库时,可以设置读写分离,之后的数据库如果只有查询的话将走读库。只有事务和更新操作会走写库。
- 重试
对于进查询配置,幂等的操作等支持在特定的报错时(如超时)会重试,目前重试次数为 3 次。第一次立即重试,第二次将休息 1s 后重试,第三次将休息 2s 后重试。
go
func Attempt3TimesWithIncrDelay(ctx context.Context, f func(index int) (err error, retry bool)) (int, error) {
attemptTimes := 3
return lo.Attempt(attemptTimes, func(i int) error {
err, retry := f(i)
if !retry {
if err != nil {
log.CtxInfo(ctx, "Attempt3TimesWithIncrDelay 函数返回 err,但是不重试。err: %v", err)
}
return nil
}
log.CtxWarn(ctx, "Attempt3TimesWithIncrDelay will retry. 当前尝试次数为: %v, err: %v, ", i+1, err)
if i != attemptTimes-1 {
sleepTimeSecond := i * 2
log.CtxInfo(ctx, "Attempt3TimesWithIncrDelay retry after sleep %vs", sleepTimeSecond)
time.Sleep(time.Duration(sleepTimeSecond) * time.Second)
}
return err
})
}
SDK 容灾
- 内存缓存
SDK 查询配置后,将存储在内存缓存中,后续读取配置都是从内存缓存中读取,缓存过期时间为 1 天。每次轮询查询配置后,都会更新缓存的过期时间,基本等价于缓存永不过期(只有服务挂掉超过 1 天,缓存才会失效)。
- 配置降级从配置文件读取
如果服务启动时依赖的配置无法查询到,那么业务服务将无法启动。此时业务方可通过修改环境变量,设置配置降级所在的目录,之后配置查询接口失败后,配置将从目录中进行读取,用于将服务启动起来。后续配置查询服务没问题后,将配置降级的环境变量下掉即可。
自动化测试
在 ToB 场景下,我们实现了接口侧的自动化测试,通过配置流水线每隔 10 分钟运行一次,对配置服务的重要流程进行自动化测试验证。目前已经覆盖的重要场景包括:
- 修改配置后可以查询到最新配置,保证修改链路和查询链路正确,查询链路模拟真实页面请求,而不是直接访问服务接口。
- 确保服务对于配置管理分 Region 隔离的。因为 ConfigHub 的服务和数据库在 tob 场景下都没有分 Region 部署,所以服务可能出现写错 Region 或读错 Region 的场景。这类场景下请求都是成功的,只是值不正确,服务监控和人工 Check 都很难覆盖到。