目录

构建可持续迭代的 Golang 应用

本文是 Golang 程序编码指南,主要介绍 Golang 程序的编码风格、项目结构、开发工具链等,从而快速了解 Golang 程序的开发规范,提高代码质量。希望通过这篇文章,能够帮助大家快速地上手 Golang 程序的开发,减少后期维护成本。

概述

Golang 是一门非常优秀的编程语言,它的设计目标是提高程序员的生产力,因此 Golang 语言的设计非常注重简洁、高效、易用。经过数十年的发展,Golang 社区已经积累了大量的最佳实践,也形成了一些比较成熟的编码风格和项目结构规范。在构建多人协作的大型应用时,遵循这些规范可以提高代码的可读性、可维护性,减少代码的重构成本。

本文将从项目结构、代码质量、自动化工具与 CI/CD、项目拆分与重构、编程模式等多个方面介绍 Golang 编程规范与实用技巧,这些内容是我在实际项目中总结的经验,希望能够帮助大家构建可持续维护的 Golang 应用。

项目结构

Golang 项目的结构对于项目的可维护性和可读性非常重要,一个好的项目结构可以让团队成员快速地定位到模块的位置,了解模块的功能和用法。Golang 社区中的优秀的项目结构大致可以分为两种:一种是以构建 SDK、开发框架为主的平铺式项目结构,另一种是以构建一个或多个二进制文件为主的标准 Go 项目结构。这两种项目结构都有各自的适用场景,需要根据实际的项目需求来选择。

平铺式项目结构

平铺式项目结构适用于构建 SDK、开发框架等需要提供给其他开发者使用的项目,这种项目结构的特点是将所有的代码放在一个目录下,方便其他开发者快速地了解框架的功能和用法。由于在 Go 语言中一个目录就是一个 Namespace,采用平铺式结构的项目,可以将框架的所有功能都放在同一个命名空间下,其他开发者只需要通过 import 一个包路径就可以使用框架的所有功能。

1
2
3
4
5
6
7
8
.
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── gin.go
├── context.go
├── xxx.go

平铺式项目结构的优点是简单、易用,适合于小型项目,但是当项目规模变大时,这种项目结构会导致代码文件过多,不利于代码的维护和管理,所以一些复杂的开发框架会将不同的子模块放在不同的目录下,以便于更好地组织代码。例如 go-pretty 将不同的子模块具有独立的依赖路径,并且可能存在依赖关系:

1
2
3
4
5
.
├── list/
├── progress/
├── table/
├── text

常见的平铺式项目结构案例有:

标准 Go 项目结构

Standard Go Project Layout 是 Golang 社区常见的项目布局模式,虽然它不是 Go 核心开发团队定义的官方标准,但已经被 Golang 社区广泛采用。

标准项目结构的优点是清晰、通用,每个目录都有特定的作用,适用于大型项目。如果开发者都遵循这种项目结构,可以帮助团队成员快速地定位到模块的位置,了解模块的功能和用法。

标准项目结构详细介绍了其组织结构和每个目录的作用,感兴趣的读者可以点击链接查看,本小节仅阐述一些常用的目录:

1
2
3
4
5
6
7
8
9
.
├── api
├── build
├── cmd
├── docs
├── internal
├── pkg
├── third_party
├── tools
api

api目录用于存放 OpenAPI 或者 gRPC Proto 的定义文件,这些文件可以用于生成客户端代码、服务端代码、Swagger 文档等。通过 api 目录,能够清晰地了解项目的接口定义,方便其他开发者快速地了解项目的 API 功能和用法。

某些项目会将 api 目录作为对外的 SDK 或者 Client 的封装,例如 Consul API SDK,这也符合 api 目录的作用。

build

build目录用于存放构建 shell 脚本、Dockerfile、CI 等配置文件,这些文件可以用于自动化构建、测试、部署。将构建相关的文件放在 build 目录下,可以避免将构建脚本和业务代码混在一起,使得项目的结构更加简洁清晰。

cmd

cmd目录是 Golang 项目的入口目录,用于存放项目的main.go文件。通常一个 Golang 项目需要构建出多个二进制可执行文件,由于每一个二进制文件的入口函数都需要一个独立的 main Package,我们可以将每个入口文件放在子目录下,例如cmd/server/main.gocmd/cli/main.go等。

cmd 目录不应该包含业务逻辑,只包含 main 函数的入口文件,代码行数也应该尽量少,只包含一些简单的初始化逻辑。业务逻辑应该放在 internal 或 pkg 目录下,由 main 函数调用。

docs

docs目录用于存放项目的文档,包括设计文档、API 文档、使用手册等。通过 docs 目录,能够清晰地了解项目的设计思路、接口定义、使用方法等。

internal

internal是一个特殊的目录,它是 Golang 1.4 Release Notes 版本引入的一个新特性,用于限制包的可见性。

Go 语言鼓励模块化和封装,将代码组织成 Package 的形式,提高代码的可读性和复用性。但在某些场景下,我们希望某些 Package 只能在当前模块中使用,而不希望被其他模块引用。这种情况下,就可以使用 internal 目录来限制包的可见性:internal 目录下的代码只能被当前项目的其他 Package 引用,当其他项目引用当前项目时,internal 目录下的代码是不可见的。

我们可以将项目的私有代码,例如项目的内部核心逻辑、不希望被其他项目引用的客户端代码等,放在 internal 目录下,但外部模块引用该项目时,也无法访问内部的逻辑,并且减少了不必要的依赖。

pkg

pkg目录用于存放项目的通用 Package,pkg 目录下的模块应该具有良好的封装和抽象,以便于业务代码复用。pkg 目录下的模块可以被其他项目引用,但是某些模块可能只是当前项目的内部实现细节,不希望被其他项目引用,这种情况下,可以将模块放在 internal 目录。

third_party

third_party目录用于存放第三方 repo 的依赖,例如 swagger、依赖的 submodules、fork 的 repo 等。当项目需要独自维护第三方代码时,可以将第三方代码放在 third_party 目录下,以便于管理和维护。

tools

tools目录是 golang 项目的独有目录,用于约定项目的内/外部工具链,可以是/pkg/internal目录下的内部工具,也可以 import 外部工具。

如下代码所示,我们利用tools/tools.go文件来约定项目的外部工具链,通过匿名导入的方式,将工具链的版本锁定在go.mod文件中。每次初始化开发环境时,只需要执行go generate -tags tools tools/tools.go命令,就可以自动安装工具链。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//go:build tools

//go:generate go install github.com/swaggo/swag/cmd/swag
//go:generate go install github.com/fatih/gomodifytags
//go:generate go install go.uber.org/mock/mockgen
import (
	_ "github.com/swaggo/swag/cmd/swag"

	_ "github.com/fatih/gomodifytags"

	_ "go.uber.org/mock/mockgen"
)

代码质量

Golang 语言的设计目标是提高程序员的生产力,因此 Golang 语言的设计非常注重简洁、高效、易用。Golang 社区已经形成了一些比较成熟的编码风格,在构建多人协作的大型应用时,遵循这些规范可以提高代码的可读性、可维护性,减少代码的编写与维护成本。

编码规范与代码格式化

做为有技术追求的软件工程师,我们应该在条件允许的情况下,遵循 Golang 社区的编码规范,保持代码的一致性,Uber Go Style Guide 是 Golang 社区比较成熟的编码格式,它提供了一些比较成熟的编码技巧,例如代码规范、性能优化技巧、编程范式等。感兴趣的读者可以阅读上述链接内容,以助于我们编写出更加优雅的 Golang 代码。

Go 语言天然注重代码的格式化,它内置了gofmtgoimports等工具,可以帮助我们自动格式化代码,保持代码的一致性。在笔者看来,代码格式化是 Golang 语言的一大特色,它可以让开发者专注于业务的逻辑,而不用花费时间在争论代码的格式上。

笔者倾向于使用 golangci-lint 进行代码格式化,它是一个集成了多种代码检查工具的运行器,可以帮助开发者自动格式化代码、检查命名规范、控制圈复杂度、并发安全检查等。使用 golangci-lint 可以帮助我们保持代码风格的一致性,减少 easy issue 的发生概率。通常情况下,笔者会开启所有的默认检查规则,并补充额外的检查规则,下面是我个人常用的 golangci-lint 配置文件,可以在此基础上根据自己的需求进行修改:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# 检测基本配置
run:
  # The default concurrency value is the number of available CPU.
  concurrency: 8
  # Timeout for analysis, e.g. 30s, 5m.
  # Default: 1m
  timeout: 5m
  tests: false
  # Which dirs to skip: issues from them won't be reported.
  skip-dirs:
    - test
    - third_party
  # Which files to skip: they will be analyzed, but issues from them won't be reported.
  skip-files:
    - _test.go
    - _mock.go
    - ".*\\.pb\\.go"
    - ".*\\.gen\\.go"
linters:
  disable-all: true
  enable: # please keep this alphabetized
    # Don't use soon to deprecated[1] linters that lead to false
    # https://github.com/golangci/golangci-lint/issues/1841
    - bodyclose
    - dogsled
    - dupl
    - errcheck
    - exportloopref
    - exhaustive
    - goconst
    - gocritic
    - gofmt
    # - gomnd
    - gocyclo
    # - gosec
    - gosimple
    - govet
    - ineffassign
    - misspell
    - nolintlint
    - nakedret
    - prealloc
    - predeclared
    - revive
    - staticcheck
    - stylecheck
    - thelper
    - tparallel
    - unconvert
    - unparam
    - whitespace
    - wsl
    # - unused

linters-settings: # please keep this alphabetized
  revive:
    ignore-generated-header: true
    severity: error
    confidence: 0.8
    errorCode: 2
    warningCode: 0
    rules:
      - name: atomic
        severity: warning
      - name: package-comments
      - name: unhandled-error
        arguments : ["fmt.Printf"]
      - name: blank-imports
      - name: context-as-argument
      - name: context-keys-type
      - name: dot-imports
      - name: error-return
      - name: error-strings
      - name: error-naming
      - name: exported
        severity: warning
        arguments:
          - disableStutteringCheck
      - name: if-return
      - name: increment-decrement
      - name: var-naming
      - name: var-declaration
      - name: package-comments
      - name: range
      - name: receiver-naming
      - name: time-naming
      - name: unexported-return
      - name: indent-error-flow
      - name: errorf
      - name: argument-limit
        arguments:
          - 6
      - name: function-result-limit
        arguments:
          - 3
      - name: empty-block
      - name: confusing-naming
      - name: superfluous-else
      #      - name: unused-parameter
      - name: unreachable-code
      - name: unnecessary-stmt
      - name: struct-tag
      - name: atomic
      - name: empty-lines
      - name: duplicated-imports
      - name: import-shadowing
      - name: confusing-results
      - name: modifies-parameter
      - name: redefines-builtin-id

  staticcheck:
    checks:
      - "all"
      - "-SA1019" # TODO(fix) Using a deprecated function, variable, constant or field
  stylecheck:
    checks:
      - "ST1019"  # Importing the same package multiple times.

  lll:
    line-length: 120
  gocyclo:
    # Minimal code complexity to report.
    # Default: 30 (but we recommend 10)
    min-complexity: 8

issues:
  # List of regexps of issue texts to exclude.
  include:
    - EXC0012  # EXC0012 revive: exported (.+) should have comment( \(or a comment on this block\))? or be unexported
    - EXC0013  # EXC0013 revive: package comment should be of the form "(.+)...
    - EXC0014  # EXC0014 revive: comment on exported (.+) should be of the form "(.+)..."
    - EXC0015  # EXC0015 revive: should have a package comment
  # Excluding configuration per-path, per-linter, per-text and per-source
  exclude-rules:
    # Exclude some `typecheck` messages.
    - linters:
        - typecheck
      text: "undeclared name:"
    - linters:
        - revive
      text: "var-naming: don't use an underscore in package name"
    # Exclude `lll` issues for long lines with `go:generate`.
    - linters:
        - lll
      source: "^//go:generate "
  # Fix found issues (if it's supported by the linter).
  fix: true
# output configuration options
output:
  # Format: colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions
  #
  # Multiple can be specified by separating them by comma, output can be provided
  # for each of them by separating format name and path by colon symbol.
  # Output path can be either `stdout`, `stderr` or path to the file to write to.
  # Example: "checkstyle:report.json,colored-line-number"
  #
  # Default: colored-line-number
  format: colored-line-number,github-actions
  # Print lines of code with issue.
  # Default: true
  print-issued-lines: false
  # Print linter name in the end of issue text.
  # Default: true
  print-linter-name: true
  # Make issues output unique by line.
  # Default: true
  uniq-by-line: true
  # Add a prefix to the output file references.
  # Default is no prefix.
  path-prefix: ""
  # Sort results by: filepath, line and column.
  sort-results: true

severity:
  # Set the default severity for issues.
  #
  # If severity rules are defined and the issues do not match or no severity is provided to the rule
  # this will be the default severity applied.
  # Severities should match the supported severity names of the selected out format.
  # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
  # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#SeverityLevel
  # - GitHub: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
  #
  # Default value is an empty string.
  default-severity: error
  # If set to true `severity-rules` regular expressions become case-sensitive.
  # Default: false
  case-sensitive: true
  # When a list of severity rules are provided, severity information will be added to lint issues.
  # Severity rules have the same filtering capability as exclude rules
  # except you are allowed to specify one matcher per severity rule.
  # Only affects out formats that support setting severity information.
  #
  # Default: []
  rules:
    - linters:
        - revive
      severity: error

显式声明

Golang 团队对显式声明非常看重,他们认为显式声明可以提高代码的可读性,减少代码的歧义。在 Golang 语言中,显式声明主要体现在以下几个方面:

  • 变量声明时需要显式指定类型;
  • 函数声明时需要显式指定参数和返回值的类型;
  • 结构体声明时需要显式指定字段的类型。
  • 接口声明时需要显式指定接口的方法。

可以看到,Golang 语言中的显式声明非常严格,一些 Java 工程师可能会觉得 Golang 的显式声明过于繁琐,倾向于使用依赖注入、反射等技术来减少代码的重复。依赖注入通常在运行时解析依赖关系,因此可能会导致运行时错误,例如,如果一个依赖项没有被正确地注入,那么在运行时可能会出现空指针错误。过度使用依赖注入还会导致过度抽象,在 debug 代码分析时需要 trace 大量的依赖关系,才能找到真正的被注入对象,使得代码更难理解和维护。

笔者不会在 Golang 项目中使用依赖注入,而是遵循 Golang 团队的显式声明原则,尽量减少代码的歧义。在编写代码时,我们应该尽量避免使用隐式声明:

1
2
3
4
5
6
7
8
func NewUserServer() *UserServer {
    return &UserServer{}
}

func main()  {
    server := NewUserServer()
    ...
}

Go 语言中另一个常见的隐式声明是init函数,它会在包被引用时自动执行,用于初始化包的状态。下面的示例代码是经典的init函数错误使用案例,它在 package 被引用时自动执行,用于初始化包的状态,创建数据库连接对象,并将连接对象分配给了一个全局变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var DB *sql.DB

func init() {
    db, err := sql.Open("mysql",os.Getenv("dataSource"))
    if err != nil {
        panic(err)
    }

    DB = db
}

init函数会增加程序的不确定性,由于init函数是没有参数和返回值的,因此在示例中,需要通过环境变量来获取数据库连接的配置信息,这样就使得init函数的行为不可预料,如果环境变量设置错误,那么init函数无法处理连接建立失败的情况,只能粗暴地调用panic函数来终止程序执行。

init函数还会降低代码的文档性,初次使用包时,开发者可能并不知道这里执行了一个init函数,也不知道init函数有哪些隐形的环境变量依赖或配置,也无从得知这个init函数的执行结果。因此它的执行结果是不可预料的,可能会导致一些难以预料的问题。在编写init函数时需要特别小心,避免在init函数中执行一些复杂的逻辑,尽量保持init函数的简单和干净。

上述代码中的init函数带来的另一个不确定性是将数据库连接对象分配给了全局变量DB,我们可能会在程序中的任何地方更改DB变量的值,这就使得DB变量的生命周期不可控,也不利于单元测试的编写。

这并不意味着init函数是一个坏东西,它在某些场景下是非常有用的,例如在database/sql包中,init函数用于自动注册数据库驱动,这样在程序中就可以通过sql.Open函数来创建数据库连接对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package db

import (
	"database/sql"

	_ "github.com/go-sql-driver/mysql"
)

func New(dsn string) (*sql.DB, error) {
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}

	if err := db.Ping(); err != nil {
		return nil, err
	}

	return db, nil
}

在日常编码中,笔者也会注意避免使用隐式声明与隐式依赖,提高代码的可读性与 debug 的可追踪性。

错误处理

Go 的错误处理也遵循了显式声明的原则,不同于 Python 或 Java 等语言的 Try-Catch 机制,Go 语言中的错误处理是通过显式声明错误来实现的,我们可以在任何地方返回错误,并在错误发生时及时处理。

Go error 设计的也具有一定的缺陷,例如err != nil条件成立时不再意味着一定发生了错误,在标准库中io.Reader返回io.EOF Error来告知调用者数据已经读取完毕,虽然这并不意味着发生了错误,但是我们仍然需要显式地处理这个错误。

显式错误处理的也具有一些缺点,我们需要在每个函数调用处都进行错误处理,业务逻辑中编写大量的if err != nil会使得代码段看起来较为冗余。 为了简化错误处理的逻辑,Go 1.13 版本借鉴了github.com/pkg/error的设计思想,引入了errors.Iserrors.Asfmt.Errorf("%w", err)等函数,使得错误处理更加简洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

func main() {
    err := do()
    if errors.Is(err, ErrNotFound) {
        fmt.Println("not found")
    }
}

显式错误处理使得代码的行为更加可预测。当函数遇到错误时,它会显式地返回一个错误值,调用者可以根据这个错误值来决定如何处理错误。这种方式使得错误处理的逻辑更加清晰,也利于编写单元测试,我们可以模拟函数返回错误,然后检查调用者是否正确地处理了这个错误,提高代码的质量。

单元测试

单元测试是保证代码质量的重要手段,Golang 语言内置了testing单元测试框架,可以帮助开发者编写高质量的单元测试用例,提高代码的正确性。在面向测试的开发中,要有意识地编写可测试的代码,笔者也积累了一些小技巧:

  • 编写可测试的代码,避免使用全局变量、单例模式等,模块之间的依赖关系应该通过 interface 来定义;
  • 使用测试框架 gomock 对 interface 进行 Mock,以便于验证上层模块的逻辑;
  • 使用猴子补丁*gomonkey* 对不便于测试的函数或方法进行 monkey patch;
  • 使用断言库 testify 对测试结果进行断言,以保证测试用例的正确性;
  • 在执行单元测试时执行go test -race静态竞争检查,以保证代码的并发安全性;
  • 测试用例要尽可能地覆盖所有的分支,包括正常分支、异常分支、边界分支等,避免将单元测试当做 Happy Path。

单元测试可以自动化地执行大量的测试用例,从而节省了手动测试的时间,提高了开发效率。除此之外,我们在标准库的源码中还可以看到example_test.go文件,这些文件中包含了函数的使用示例,通过示例代码来展示函数的使用方法,作为函数的 API 文档。

当我们需要对代码进行重构时,单元测试可以作为一个安全网,确保重构不会引入新的错误,也不会更改现有的逻辑,提高代码的可维护性。

总体来说,单元测试是保证代码质量的必要手段,它可以帮助我们提早发现代码中的错误,即使项目经历了多名工程师的迭代开发,也能够通过单元测试了解代码的用途和行为。

自动化工具与 CI/CD

Golang 语言的工具链非常丰富,在前面的内容中我们已经介绍了代码格式化工具golangci-lint、测试框架gomocktestify等,Go 社区十分喜欢使用自动化工具来自动生成代码,减少重复劳动,提高开发效率。本章节我们将介绍一些常用的 Golang 工具链:

  • buf.build:buf 是一个用于管理 Protocol Buffers 文件的工具,它可以帮助开发者管理 Protocol Buffers 文件的版本、依赖、生成代码等,简化项目开发配置;
  • gomodifytags:gomodifytags 是一个用于修改 Golang 结构体标签的工具,它可以帮助开发者自动添加、删除、修改结构体标签,简化代码的维护;
  • go-gorm gen:go-gorm gen 是一个用于生成 GORM 模型的工具,它可以帮助开发者自动生成数据库 CRUD 代码,简化数据库操作;
  • swaggo:swaggo 是一个基于注释自动生成 Swagger API 文档的工具,简化 API 文档的维护;

上述工具可以帮助我们自动生成代码、文档,简化项目的开发配置,当配置发生变化时,也能够自动更新代码,替代人工的重复劳动,提高人效比。

除了自动生成代码、文档外,我们还需要使用 CI/CD 工具来自动化构建、测试、部署,常见的 CI/CD 工具有 GitHub Actions、Jenkins 等,通常情况下,我们会在项目中维护一个Makefile文件,用于定义项目的构建、测试、部署等命令,然后在 CI/CD 工具中调用make命令来执行这些命令。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.PHONY: lint
lint:
    golangci-lint run

.PHONY: test
test:
    go test -race -coverprofile=coverage.out ./...

.PHONY: build
    go build -o build/bin/app1 cmd/app1/main.go

自动化在软件开发中有许多优点,一个稳定、完善的 CI/CD 系统可以帮助我们实现以下目标:

  1. 提高效率:通过自动执行耗时的任务(如构建和测试),可以节省大量时间,使工程师可以将更多的时间和精力投入到解决问题和添加新功能上,专注于更复杂的业务逻辑,减少不必要的心智负担,从而提高生产力;
  2. 减少错误:人为操作更容易出错,例如我们在更新结构体标签时可能会有 typo,而自动化工具可以确保每次执行任务时都遵循相同的步骤和标准,从而提高结果的一致性,避免这种低级错误;
  3. 快速反馈:CI/CD 可以在代码提交后立即运行,提供快速反馈,帮助工程师及时发现并修复问题;
  4. 文档化过程:CI/CD 不仅能够自动执行任务,还记录了任务的执行过程,当任务失败时,可以通过 CI/CD 工具的日志来查看任务的执行过程,帮助工程师定位问题;

编程范式与设计模式

编程范式与设计模式是软件工程师在编写代码时需要考虑的问题,它们可以帮助我们编写出更加优雅、扩展性更高的代码。在 Golang 语言中,我们可以使用一些编程范式和设计模式来提高代码的质量。

Go 语言是一门多范式编程语言,它支持面向对象编程、函数式编程、面向接口编程等多种编程范式,我们可以根据项目的需求选择合适的编程范式。而设计模式可以弥补编程语言表达能力的不足,它可以帮助我们解决一些常见的问题,提高代码的可维护性。本小节将会介绍一些常见的编程范式和设计模式。

面向接口编程

面向接口编程是 Golang 语言的一大特色,它可以帮助我们提高代码的可扩展性。在 Golang 语言中,接口是一种抽象类型,它定义了一组方法,任何实现了这组方法的类型都可以被赋值给这个接口类型的变量。这种特性使得 Golang 语言可以很方便地实现依赖注入、多态等特性。

在 Golang 语言中,我们可以使用接口来定义模块的依赖关系,例如:

1
2
3
4
type Repository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

在上面的代码中,我们定义了一个 Repository 接口,它定义了两个方法:FindByIDSave。任何实现了这两个方法的类型都可以被赋值给Repository类型的变量。这种特性使得我们可以很方便地实现依赖注入,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type UserService struct {
    Repository Repository
}

func (s *UserService) FindByID(id int) (*User, error) {
    return s.Repository.FindByID(id)
}

func (s *UserService) Save(user *User) error {
    return s.Repository.Save(user)
}

面向接口编程也可以帮助我们实现多态,在下面的代码中,我们定义了一个MockRepository类型与MySQLRepository类型,它实现了 Repository 接口的两个方法。在业务逻辑中我们可以根据上下文来选择不同的 Repository 实现::

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type MockRepository struct {
}

func (r *MockRepository) FindByID(id int) (*User, error) {
    return &User{}, nil
}

func (r *MockRepository) Save(user *User) error {
    return nil
}

type MySQLRepository struct {
}

func (r *MySQLRepository) FindByID(id int) (*User, error) {
    return &User{}, nil
}

func (r *MySQLRepository) Save(user *User) error {
    return nil
}

func main() {
    var repository Repository
    if os.Getenv("ENV") == "test" {
        repository = &MockRepository{}
    } else {
        repository = &MySQLRepository{}
    }
    
    userService := &UserService{Repository: repository}
    userService.FindByID(1)
}

interface 接口可以将具体的实现和使用者解耦,使得代码更加模块化。使用者只需要知道接口的定义,而不需要关心具体的实现,这使得我们可以在不改变使用者代码的情况下更换实现,或者为同一个接口提供多种实现。interface 还可以让我们更容易地为代码编写测试,前文介绍过我们可以使用 gomock 创建 mock 对象来实现接口,在测试中使用这些 mock 对象,在不依赖外部系统的情况下测试代码。

面向对象编程

面向对象编程是一种常见的编程范式,具有 封装、继承、多态三大特性,它将数据和操作数据的方法封装在一起,使得对象的功能更加聚合。相比于其他编程语言 Go 的面向对象模型更为简洁。在 Go 语言中,没有类(class)和继承(inheritance),而是通过结构体(struct)和接口(interface)来实现面向对象编程。这种简洁的设计使得 Go 语言的面向对象编程更易于理解和使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type User struct {
    ID   int
    Name string
}

func (u *User) id() int {
	return u.ID
}

func (u *User) Save() error {
    fmt.Printf("save user %s", u.Name)
    return nil
}

func main() {
    user := &User{ID: 1, Name: "Tom"}
    user.Save()
}

在上面的代码中,我们定义了一个 User 结构体,它有两个字段IDName,以及一个公开的Save方法。这种方式使得我们可以将数据和操作数据的方法封装在一起。

在继承方面,Go 语言采用了组合优于继承的方式,我们可以通过嵌入(embedding)其他的结构体或接口来复用代码,而不需要通过继承来实现代码的复用。这种设计使得我们可以更灵活地组合代码,而不需要考虑复杂的继承关系。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type NewUser struct {
    User
    Age int
}

func (u *User) Save() error {
    fmt.Printf("save user %s, age %d", u.Name, u.Age)
    return nil
}

func main() {
    user := &NewUser{User: User{ID: 1, Name: "Tom"}, Age: 18}
    user.Save()
}

在上面的代码中,我们定义了一个NewUser结构体,它嵌入了User结构体,这使得NewUser结构体拥有了User结构体的所有字段和方法。我们也可以重写NewUserSave方法,来实现多态特性。

我们已经介绍了 Go 语言的面向接口与面向对象编程,面向对象编程能够封装数据和操作数据的方法,隐藏对象的内部状态,只暴露必要的方法给外部,增强内聚度。面向接口编程能够将具体的实现和使用者解耦,使得代码更加模块化。它们可以帮助我们编写出更加模块化、可扩展的代码,降低模块的耦合度。在实际的项目中,我们可以根据实际需要选择合适的编程范式。

选项模式

选项模式(Functional Options)可以说是 Go 语言中最常见的设计模式之一,它可以帮助我们简化代码的配置。选项模式的核心思想是将配置选项封装为一个函数,这个函数会返回一个可执行函数来更新构建时的配置项,这样我们就可以通过链式调用的方式来配置对象。

使用选项模式的原因是,不同于 Python 的构造函数在初始化时支持选择性传入多个参数,并为每个参数提供默认值,即使我们不传入任何参数,也可以创建一个新对象:

1
2
3
4
5
6
7
8
class User:
    def __init__(self, name="Tom", age=18):
        self.name = name
        self.age = age

user = User(name="Jerry")
print(user.name)  # Jerry
print(user.age)   # 18

要解决这个问题,最常见的方式是使用一个配置对象,如下所示,我们将非必要的选项都移到一个结构体里,:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Config struct {
    Timeout  time.Duration
    TLS      *tls.Config
}

type Request struct {
    host string
    port int
    config *Config
}

func NewRequest(host string, port int, config *Config) *Request {
    return &Request{host: host, port: port, config: config}
}

在上面的代码中,我们定义了一个Config结构体,它包含了一些可选的配置选项,然后我们在NewRequest函数中传入了一个Config结构体,与必要的参数,这样我们就可以通过NewRequest函数来创建一个Request对象。这种解决方式已经能够满足需求了,但还不够优雅,因为即使在我们并不需要配置这些选项的情况下,我们仍需要传入一个Config结构体,并要对其进行是否为 nil 校验。

选项模式可以进一步优化这个问题,我们定义一个Option 函数类型,它接收一个Request指针类型的参数,然后返回一个函数,这个函数的参数是一个Request指针类型的参数,这样我们就可以通过链式调用的方式来配置Request对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Option func(*Request)

type Request struct {
    host string
    port int
    tls *tls.Config
    timeout time.Duration
}

func WithTimeout(timeout time.Duration) Option {
    return func(r *Request) {
        r.timeout = timeout
    }
}

func WithTLS(tls *tls.Config) Option {
    return func(r *Request) {
        r.tls = tls
    }
}

func NewRequest(host string, port int, options ...Option) *Request {
  r := &Request{host: host, port: port, timeout: 10*time.Second}
    for _, option := range options {
        option(r)
    }
    return r
}

func main() {
    request := NewRequest("localhost", 8080, WithTimeout(5*time.Second), WithTLS(&tls.Config{}))
    request := NewRequest("localhost", 8080)
}

使用选项模式可以选择性地配置对象,使得代码更加灵活,当我们需要添加新的配置选项时,只需要添加一个新的函数和配置字段即可。

修饰器模式

修饰器模式(Decorator Pattern)是一种常见的设计模式,它可以帮助我们动态地为对象添加新的功能,降低代码的重复编写。在 Python 语言中,我们可以使用语法糖@来实现修饰器模式,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def log(func):
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
    
@log
def add(a, b):
    return a + b

add(1, 2)

在上面的代码中,我们定义了一个log修饰器,它接收一个函数作为参数,然后返回一个函数,这个函数的参数是一个函数,这样我们就可以通过@log语法糖来为add函数添加日志功能。

在 Golang 语言中,我们可以使用函数封装来实现修饰器模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func log(f func(int, int) int) func(int, int) int {
    return func(a, b int) int {
        fmt.Printf("call %s\n", runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name())
        return f(a, b)
    }
}

func add(a, b int) int {
    return a + b
}

func main() {
    add = log(add)
    add(1, 2)
}

相比之下,Golang 语言中的修饰器模式没有 Python 语言中的语法糖那么优雅,但它同样可以帮助我们动态地为对象添加新的功能。Go 语言中装饰器最常见的应用场景是自定义中间件,例如 Gin 框架中的中间件就大量使用了修饰器模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        t := time.Now()
        c.Next()
        latency := time.Since(t)
        log.Print(latency)
    }
}

func main() {
    r := gin.Default()
    r.Use(Logger())
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "pong",
        })
    })
    r.Run()
}

小结

本小节简单介绍了 Golang 语言中的一些编程范式和设计模式,它们可以帮助我们编写出更加优雅、高可扩展的代码。如果读者想要深入了解这些编程范式和设计模式,可以参考已故程序员《左耳朵耗子》的 GO编程模式 系列文章。

总的来说,Go 的语法本身就已经非常简洁,而且 Go 语言的接口和函数式编程特性使得我们可以更加灵活地组合代码,而不需要过多地依赖设计模式。但是设计模式仍然是一种很好的编程范式,它可以帮助我们解决一些 Go 语言的设计缺陷,让我们的代码更加优雅。

项目拆分与重构

在大型的 Golang 项目中,随着业务的发展,代码的规模会变得越来越大,这时候就需要考虑对项目进行拆分与重构。项目拆分可以提高代码的可读性和可维护性,也可以提高开发效率。以下是一些常见的 Golang 项目拆分策略。

水平拆分

水平拆分或按照功能模块拆分是最常见的拆分策略,将不同的功能模块放在不同的包(Package)中。例如,用户管理、订单管理、商品管理等功能可以分别放在 user、order、product 等包中。这样做的好处是可以清晰地看到每个包的职责。

1
2
3
4
5
6
7
8
├── user
   ├── entity.go
   ├── user.go
   └── user_test.go
├── order
   ├── entity.go
   ├── order.go
   └── order_test.go

由于 Golang 语言中的包是一个独立的 Namespace,因此不同的包可以有相同的函数名,我们可以通过包名来区分不同的函数:user.FindByIDorder.FindByID

随着业务的发展,单体应用可能会变得越来越复杂,这时候可能hui考虑将项目拆分为多个微服务。每个微服务都是一个独立的应用,微服务之间通过 API 进行通信,可以独立部署和扩展。如果我们在项目中使用了水平拆分的策略,那么将项目拆分为多个微服务会变得更加容易,因为每个子模块都是一个独立的包,天然具有独立性,降低微服务拆分时的改动成本。

垂直拆分

垂直拆分或按照层次结构拆是另一种常见的拆分策略,将不同的层次放在不同的包中。例如,将数据访问层(Repository)、业务逻辑层(Service)、控制器层(Controller)放在独立的子目录中。每个层次都有其特定的职责,例如数据访问层负责与数据库交互,业务逻辑层负责处理业务逻辑,控制器层负责处理 HTTP 请求。

垂直拆分通常与水平拆分结合使用,以确保模块间是完全解耦的,每个功能模块都有自己的数据访问层、业务逻辑层、控制器层,例如将用户管理模块拆分为 order/repository、order/service、order/controller 等包。不同功能模块的内部代码对其它模块是不可见的,只能通过暴露的 Interface 进行通信。

1
2
3
4
5
6
7
8
├── order
   ├── repository
      ├── history.go
      ├── order.go
   ├── service
      ├── order.go
   └── controller
       ├── order.go

重构

重构是一种常见的优化代码的手段,可以帮助我们提高性能、甩掉历史包袱、提高可扩展性等。重构可以帮助我们优化代码的设计,当新的需求出现时,我们可以更容易地在现有的代码基础上进行扩展。在前面的内容中我们介绍过了一些 Golang 的自动化工具、代码格式化、单元测试等,在重构的过程中,我们可以使用这些工具来确保重构前后功能一致性,避免引入新的错误。

重构往往发生在项目的后期,当项目的代码规模变得越来越大,代码的质量变得越来越差,或是项目的业务需求发生了变化,现有的代码无法满足新的需求时,我们就需要考虑对项目进行重构。但在笔者看来,重构不仅仅是在项目的后期才需要考虑的问题,它应该是项目的一部分,随着项目的发展时时刻刻都在进行。

我们在开发新需求或是维护项目的过程中,可能会遇到一些代码质量不高的代码,例如:

  • 重复的代码段:在不同的地方出现了相同的代码段,这样的代码段不利于维护,因为每次修改都需要修改多个地方,或是发生了遗漏,我们可以将这些代码段抽取出来,维护在一个函数或方法中,在需要的地方调用这个函数;
  • 不合理的结构体或接口:结构体或接口的定义不合理,我们可以通过重构来简化结构体或接口的定义;
  • 冗余的参数:函数或方法的参数过多,或是参数的类型不合理,我们可以通过重构来简化参数。

在项目早期,往往由于排期紧张、需求频繁变化等原因,我们的项目可能不会那么规范,只关注功能的实现,所以在功能迭代的过程中,我们可能每天都会对小段的代码进行重构,提高代码的整洁。

总结

本篇文章主要介绍了 Golang 语言的一些最佳实践,包括代码格式化、错误处理、单元测试、自动化、编程模式、拆分与重构等。归根结底,我们的终极目标是写出优雅、干净、整洁的代码,提高项目的可维护性、可扩展性,降低维护的成本

在实际的工程中,我们需要在项目早期就开始执行严格的代码审查和 Code Review,遵循「先紧后松」的原则,保证代码的质量,如果我们在项目的早期没有注重代码规范,那么随着项目的发展,往往会变得越来越难以维护,即使想要添加更为严格的代码规范也会遭到许多质疑和反对的声音。

在项目的中后期,我们需要不断进行重构,对子模块进行重新设计与拆分,保持代码的整洁。在重构的过程中,我们可以使用自动化工具来确保重构前后功能一致性,避免引入新的错误。

希望本篇文章能够帮助各位工程师实现更加稳定可靠的 Go 服务。