Go实践分享

78 阅读6分钟

背景

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

  1. 下载 GoLand
  2. 启动 GoLand,填写 License后,点击 Activate 即可。

VSCode

  1. 安装 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

image.png

锁定依赖版本

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 存在的不同之处:

  1. Go 中所有系统调用 API 都是阻塞式的,而 Node.js 所有系统调用都提供了非阻塞式 API。
  2. 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。以下代码需要关注两个点:

  1. 第三行预分配了相应任务数量的 retList,目的是让每个任务都只给对应的 retList[idx] 赋值,避免每个任务都去操作 retList 引起数据竞争。

  2. 第4行、13-18行、23行是 Go 中并发多个 Go routine 执行任务的模板代码,语义是

    1. 第 13 行,在异步任务启动之前给任务数加 1
    2. 第 17 行,在异步任务结束之后给任务数减 1
    3. 第 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。