Help us learn about your current experience with the documentation. Take the survey.

Go 标准与风格指南

本文档描述了 GitLab 使用 Go 语言 的各种指南和最佳实践。

GitLab 基于 Ruby on Rails 构建,但在合适的项目中我们也使用 Go。Go 是一门非常强大的语言,具有许多优势,最适合处理大量 IO(磁盘/网络访问)、HTTP 请求、并行处理等的项目。由于我们在 GitLab 中同时使用 Ruby on Rails 和 Go,应该仔细评估哪个更适合当前任务。

本页旨在根据我们的各种经验定义和组织我们的 Go 指南。一些项目采用了不同的标准启动,它们可能仍有特定要求。这些要求在其各自的 README.mdPROCESS.md 文件中有描述。

项目结构

根据 Go 应用项目的基本布局,Go 没有官方的项目布局。然而,Ben Johnson 的 标准包布局 中提供了一些很好的建议。

以下是 GitLab 基于 Go 的项目列表,供参考:

Go 语言版本

Go 升级文档 提供了概述,说明 GitLab 如何管理和发布 Go 二进制文件支持。

如果 GitLab 组件需要更新版本的 Go,请遵循 升级流程,确保不会对客户、团队或组件产生不利影响。

有时,单个项目也必须 使用多个 Go 版本管理构建

依赖管理

Go 使用基于源码的策略进行依赖管理。依赖项从其源码仓库下载为源码。这与更常见的基于制品的策略不同,后者依赖项作为制品从与依赖项源码仓库分离的包仓库下载。

在 1.11 版本之前,Go 没有对版本管理的一流支持。该版本引入了 Go 模块和语义版本的使用。Go 1.12 引入了模块代理,可以作为客户端和源码版本控制系统之间的中间层,以及校验和数据库,可用于验证依赖项下载的完整性。

有关更多详细信息,请参阅 Go 中的依赖管理

代码审查

我们遵循 Go 代码审查评论 的通用原则。

审查者和维护者应注意:

  • defer 函数:确保在需要时使用,并在 err 检查之后。
  • 将依赖项作为参数注入。
  • 序列化为 JSON 时使用空结构体(生成 null 而不是 [])。

安全性

安全性是 GitLab 的首要任务。在代码审查中,我们必须注意代码中可能存在的安全漏洞:

  • 使用 text/template 时的 XSS
  • 使用 Gorilla 进行 CSRF 保护
  • 使用没有已知漏洞的 Go 版本
  • 不要泄露密钥令牌
  • SQL 注入

请务必在您的项目上运行 SAST依赖扫描(至少运行 gosec 分析器),并遵循我们的 安全要求

Web 服务器可以利用 Secure 等中间件。

寻找审查者

我们许多项目太小,没有全职维护者。这就是为什么我们在 GitLab 有一个共享的 Go 审查者池。要寻找审查者,请使用手册中工程项目的 “GitLab” 项目的 “Go” 部分

要将自己添加到此列表中,请在 team.yml 文件中添加以下内容,并请求您的经理审查和合并。

projects:
  gitlab: reviewer go

代码风格和格式

  • 避免全局变量,即使在包内也是如此。这样做会在包被多次包含时引入副作用。

  • 提交前使用 goimportsgoimports 是一个工具,它使用 Gofmt 自动格式化 Go 源码,此外还格式化导入行,添加缺失的导入并移除未引用的导入。

    大多数编辑器/IDE 允许在保存文件之前/之后运行命令,您可以设置它来运行 goimports,以便在保存时应用于每个文件。

  • 将私有方法放在源文件中第一个调用方法的下方。

自动代码检查

使用 registry.gitlab.com/gitlab-org/gitlab-build-images:golangci-lint-alpine 已在 16.10 版本中被弃用

使用 golangci-lint 的上游版本。查看默认 启用/禁用的检查器列表

Go 项目应包含以下 GitLab CI/CD 作业:

variables:
  GOLANGCI_LINT_VERSION: 'v1.56.2'
lint:
  image: golangci/golangci-lint:$GOLANGCI_LINT_VERSION
  stage: test
  script:
    # 将代码覆盖率报告写入 gl-code-quality-report.json
    # 并以以下格式将检查问题打印到标准输出:path/to/file:line description
    # 移除 `--issues-exit-code 0` 或设置为非零值,以便在检测到检查问题时使作业失败
    - golangci-lint run --issues-exit-code 0 --print-issued-lines=false --out-format code-climate:gl-code-quality-report.json,line-number
  artifacts:
    reports:
      codequality: gl-code-quality-report.json
    paths:
      - gl-code-quality-report.json

在项目根目录包含 .golangci.yml 可以配置 golangci-lintgolangci-lint 的所有选项都列在这个 示例 中。

一旦 递归包含 可用,您可以共享像这个 分析器 这样的作业模板。

Go GitLab 检查器插件在 gitlab-org/language-tools/go/linters 命名空间中维护。

帮助文本风格指南

如果您的 Go 项目为用户提供帮助文本,可以考虑遵循 gitaly 项目中 帮助文本风格指南 的建议。

依赖项

依赖项应保持在最低限度。引入新依赖项应在合并请求中进行论证,遵循我们的 批准指南。应在所有项目上激活 依赖扫描,以确保新依赖项的安全状态和许可证兼容性。

模块

在 Go 1.11 及更高版本中,名为 Go Modules 的标准依赖系统可用。它提供了一种定义和锁定依赖项以实现可重现构建的方法。应尽可能使用。

当使用 Go Modules 时,不应有 vendor/ 目录。相反,Go 会在需要构建项目时自动下载依赖项。这与 Ruby 项目中使用 Bundler 处理依赖项的方式一致,使合并请求更容易审查。

在某些情况下,例如构建 Go 项目作为另一个项目 CI 运行的依赖项,移除 vendor/ 目录意味着代码必须重复下载,这可能导致由于速率限制或网络故障导致的间歇性问题。在这些情况下,您应该 在之间缓存下载的代码

在早于 v1.11.4 的 Go 版本中存在 模块校验和的 bug,因此请确保至少使用此版本以避免 校验和不匹配 错误。

ORM

我们在 GitLab 不使用对象关系映射库(ORM)(Ruby on Rails 中的 ActiveRecord 除外)。项目可以通过服务结构来避免使用它们。pgx 应足以与 PostgreSQL 数据库交互。

迁移

在管理托管数据库的罕见情况下,需要使用像 ActiveRecord 提供的迁移系统。像 Journey 这样的简单库,设计用于在 postgres 容器中使用,可以作为长期运行的 pod 部署。新版本部署新的 pod,自动迁移数据。

测试

测试框架

我们不应使用任何特定的库或框架进行测试,因为 标准库 已经提供了入门所需的一切。如果需要更复杂的测试工具,以下外部依赖项可能在决定使用特定库或框架时值得考虑:

子测试

尽可能使用 子测试 来提高代码可读性和测试输出。

更好的测试输出

在测试中比较预期值和实际值时,使用 testify/require.Equal, testify/require.EqualError, testify/require.EqualValues, 等,以提高在比较结构体、错误、大量文本或 JSON 文档时的可读性:

type TestData struct {
    // ...
}

func FuncUnderTest() TestData {
    // ...
}

func Test(t *testing.T) {
    t.Run("FuncUnderTest", func(t *testing.T) {
        want := TestData{}
        got := FuncUnderTest()

        require.Equal(t, want, got) // 预期值在前,实际值在后("diff" 语义)
    })
}

表格驱动测试

当您对同一函数有多个输入/输出条目时,使用 表格驱动测试 通常是好的做法。以下是编写表格驱动测试时可以遵循的一些指南。这些指南大多提取自 Go 标准库源代码。请注意,在合理的情况下不遵循这些指南是可以的。

定义测试用例

每个表格条目都是一个完整的测试用例,包含输入和预期结果,有时还有附加信息,如测试名称,以便使测试输出易于阅读。

测试用例内容

  • 理想情况下,每个测试用例应有一个具有唯一标识符的字段,用于命名子测试。在 Go 标准库中,这通常是 name string 字段。
  • 当您在测试用例中指定用于断言的内容时,使用 want/expect/actual

变量名

  • 每个表格驱动的测试结构体映射/切片可以命名为 tests
  • 当遍历 tests 时,匿名结构体可以称为 tttc
  • 测试的描述可以称为 name/testName/tn

基准测试

处理大量 IO 或复杂操作的程序应始终包含 基准测试,以确保性能随时间保持一致。

错误处理

添加上下文

在返回错误之前添加上下文可能很有帮助,而不是直接返回错误。这使开发人员能够理解程序在进入错误状态时试图做什么,从而更容易调试。

例如:

// 包装错误
return nil, fmt.Errorf("get cache %s: %w", f.Name, err)

// 仅添加上下文
return nil, fmt.Errorf("saving cache %s: %v", f.Name, err)

添加上下文时需要注意的几点:

  • 决定是否要向调用者暴露底层错误。如果是,使用 %w,如果不是,可以使用 %v
  • 不要使用 failederrordidn't 等词。因为这是一个错误,用户已经知道某些事情失败了,这可能导致出现像 failed xx failed xx failed xx 这样的字符串。解释 什么 失败了。
  • 错误字符串不应大写或以标点符号或换行符结尾。您可以使用 golint 来检查这一点。

命名

  • 使用哨兵错误时,应始终命名为 ErrXxx
  • 创建新错误类型时,应始终命名为 XxxError

检查错误类型

  • 不要使用 == 检查错误相等性,而是使用 errors.Is(适用于 Go 版本 >= 1.13)。
  • 不要使用类型断言检查错误是否为特定类型,而是使用 errors.As(适用于 Go 版本 >= 1.13)。

处理错误的参考资料

CLIs

每个 Go 程序都从命令行启动。 cli 是创建命令行应用的便捷包。无论项目是守护进程还是简单的 CLI 工具,都应使用它。标志可以直接映射到 环境变量,这同时记录和集中了所有可能的命令行交互。不要使用 os.GetEnv,它将变量隐藏在代码深处。

LabKit

LabKit 是保存 Go 服务通用库的地方。有关使用 LabKit 的示例,请参见 workhorse 和 [gitaly。LabKit 导出三个相关功能:

这为我们提供了对底层实现的薄层抽象,在 Workhorse、Gitaly 和其他可能的 Go 服务器中保持一致。例如,在 gitlab.com/gitlab-org/labkit/tracing 的情况下,我们可以直接从使用 Opentracing 切换到使用 Zipkin 或 Go kit 自己的跟踪包装器,而无需更改应用程序代码,同时保持相同的配置机制(即 GITLAB_TRACING 环境变量)。

结构化(JSON)日志记录

理想情况下,每个二进制文件都必须有结构化(JSON)日志记录,因为它有助于搜索和过滤日志。LabKit 在 Logrus 上提供了一个抽象层。我们使用 JSON 格式的结构化日志记录,因为我们的所有基础设施都假设如此。使用 Logrus 时,您可以通过使用内置的 JSON 格式化程序 来启用结构化日志记录。这遵循了我们在 Ruby 应用程序 中使用的相同日志类型。

如何使用 Logrus

使用 Logrus 包时应遵循一些指南:

  • 打印错误时使用 WithError。例如, logrus.WithError(err).Error("Failed to do something")
  • 由于我们使用 结构化日志记录,我们可以在该代码路径的上下文中记录字段,例如使用 WithFieldWithFields 记录请求的 URI。例如, logrus.WithField("file", "/app/go").Info("Opening dir")。如果您必须记录多个键,始终使用 WithFields 而不是多次调用 WithField

Context

由于守护进程是长期运行的应用程序,它们应该有管理取消的机制,并避免不必要的资源消耗(这可能导致 DDoS 漏洞)。Go Context 应该用于可能阻塞的函数,并作为第一个参数传递。

Dockerfiles

每个项目在其仓库根目录都应该有一个 Dockerfile,用于构建和运行项目。由于 Go 程序是静态二进制文件,它们不应需要任何外部依赖,最终镜像中的 shell 是无用的。我们鼓励 多阶段构建

  • 它们允许用户使用正确的 Go 版本和依赖项构建项目。
  • 它们生成一个小的、自包含的镜像,派生自 Scratch

生成的 Docker 镜像应将程序放在其 Entrypoint 中,以创建可移植的命令。这样,任何人都可以运行镜像,如果没有参数,它会显示其帮助消息(如果使用了 cli)。

安全团队标准和风格指南

以下是一些特定于安全团队的风格指南。

代码风格和格式

提交前使用 goimports -local gitlab.com/gitlab-orggoimports 是一个工具,它使用 Gofmt 自动格式化 Go 源码,此外还格式化导入行,添加缺失的导入并移除未引用的导入。 通过使用 -local gitlab.com/gitlab-org 选项,goimports 将本地引用的包与外部包分开分组。有关更多详细信息,请参阅 Go wiki 上代码审查评论页面的 导入部分。 大多数编辑器/IDE 允许在保存文件之前/之后运行命令,您可以设置它来运行 goimports -local gitlab.com/gitlab-org,以便在保存时应用于每个文件。

命名分支

除了 GitLab 分支名称规则 外,在分支名称中仅使用字符 a-z0-9-。此限制是因为当分支名称包含某些字符(如斜杠 /)时,go get 无法按预期工作:

$ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature

go get: gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature: invalid version: version "some-user/some-feature" invalid: disallowed version string

如果分支名称包含斜杠,它将迫使我们引用提交 SHA,这不够灵活。例如:

$ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@5c9a4279fa1263755718cf069d54ba8051287954

go: downloading gitlab.com/gitlab-org/security-products/analyzers/report/v3 v3.15.3-0.20221012172609-5c9a4279fa12
...

初始化切片

如果初始化切片,尽可能提供容量以避免额外的分配。

不要

var s2 []string
for _, val := range s1 {
    s2 = append(s2, val)
}

s2 := make([]string, 0, len(s1))
for _, val := range s1 {
    s2 = append(s2, val)
}

如果在创建新切片时没有向 make 传递容量,append 将在无法容纳值时持续调整切片的底层数组大小。提供容量确保分配保持最小。建议 prealloc golanci-lint 规则自动检查这一点。

分析器测试

传统的安全 分析器 有一个 convert 函数, 用于将 SAST/DAST 扫描器报告转换为 GitLab 安全报告。 在为 convert 函数编写测试时,我们应该使用 测试夹具,在分析器仓库的根目录使用一个 testdata 目录。 testdata 目录应包含两个子目录:expectreportsreports 目录应包含示例 SAST/DAST 扫描器报告,在测试设置期间传递给 convert 函数。expect 目录应包含 convert 返回的预期 GitLab 安全报告。请参阅秘密检测的 示例

如果扫描器报告很小,少于 35 行,则可以 内联报告 而不是使用 testdata 目录。

测试差异

在测试中比较大型结构体时应使用 go-cmp 包。 它能够输出两个结构体差异的具体差异,而不是在测试日志中看到两个完整结构体的打印输出。这是一个小示例:

package main

import (
  "reflect"
  "testing"

  "github.com/google/go-cmp/cmp"
)

type Foo struct {
  Desc  Bar
  Point Baz
}

type Bar struct {
  A string
  B string
}

type Baz struct {
  X int
  Y int
}

func TestHelloWorld(t *testing.T) {
  want := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 1, Y: 2},
  }

  got := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 2, Y: 2},
  }

  t.Log("reflect comparison:")
  if !reflect.DeepEqual(got, want) {
    t.Errorf("Wrong result. want:\n%v\nGot:\n%v", want, got)
  }

  t.Log("cmp comparison:")
  if diff := cmp.Diff(wot, got); diff != "" {
    t.Errorf("Wrong result. (-want +got):\n%s", diff)
  }
}

输出展示了为什么在比较大型结构体时 go-cmp 远远优越。即使您可以通过这个小差异发现区别,但随着数据增长,它会很快变得难以处理。

  main_test.go:36: reflect comparison:
  main_test.go:38: Wrong result. want:
      {{a b} {1 2}}
      Got:
      {{a b} {2 2}}
  main_test.go:41: cmp comparison:
  main_test.go:43: Wrong result. (-want +got):
        main.Foo{
              Desc: {A: "a", B: "b"},
              Point: main.Baz{
      -               X: 1,
      +               X: 2,
                      Y: 2,
              },
        }

返回开发文档