背景
通过分享 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。