设计方法:怎么写出优雅的 Go 项目?

在这里插入图片描述

前言

Go 语言简单易学,对于大部分开发者来说,编写可运行的代码并不是一件难事,但如果想真正成为 Go 编程高手,你需要花很多精力去研究 Go 的编程哲学。

在我的开发生涯中,我见过各种各样的代码问题,例如:代码不规范,难以阅读;函数共享性差,代码重复率高;不是面向接口编程,代码扩展性差,代码不可测;代码质量低下。究其原因,是因为这些代码的开发者很少花时间去认真研究如何开发一个优雅的 Go项目,更多时间是埋头在需求开发中。

如果你也遇到过以上问题,那么是时候花点时间来研究下如何开发一个优雅的 Go 项目了。只有这样,你才能区别于绝大部分的 Go 开发者,从而在职场上建立自己的核心竞争力,并最终脱颖而出。

其实,我们之前所学的各种规范设计,也都是为了写出一个优雅的 Go 项目。在这一讲,我又补充了一些内容,从而形成了一套“写出优雅 Go 项目”的方法论。这一讲内容比较多,但很重要,希望你能花点精力认真掌握,掌握之后,能够确保你开发出一个优秀的 Go项目。

如何写出优雅的 Go 项目?

那么,如何写出一个优雅的 Go 项目呢?在回答这个问题之前,我们先来看另外两个问题:

  1. 为什么是 Go 项目,而不是 Go 应用?
  2. 一个优雅的 Go 项目具有哪些特点?

先来看第一个问题。Go 项目是一个偏工程化的概念,不仅包含了 Go 应用,还包含了项目管理和项目文档:
在这里插入图片描述

这就来到了第二个问题,一个优雅的 Go 项目,不仅要求我们的 Go 应用是优雅的,还要确保我们的项目管理和文档也是优雅的。这样,我们根据前面几讲学到的 Go 设计规范,很容易就能总结出一个优雅的 Go 应该具备的特点:

  • 符合 Go 编码规范和最佳实践;
  • 易阅读、易理解,易维护;
  • 易测试、易扩展;
  • 代码质量高。

解决了这两个问题,让我们回到这一讲的核心问题:如何写出优雅的 Go 项目?

写出一个优雅的 Go 项目,在我看来,就是用“最佳实践”的方式去实现 Go 项目中的 Go应用、项目管理和项目文档。具体来说,就是编写高质量的 Go 应用、高效管理项目、编写高质量的项目文档。

为了协助你理解,我将这些逻辑绘制成了下面一张图。
在这里插入图片描述

接下来,我们就看看如何根据前面几讲学习的 Go 项目设计规范,实现一个优雅的 Go 项目。我们先从编写高质量的 Go 应用看起。

编写高质量的 Go 应用

基于我的研发经验,要编写一个高质量的 Go 应用,其实可以归纳为 5 个方面:代码结构、代码规范、代码质量、编程哲学和软件设计方法,见下图。
在这里插入图片描述

接下来,我们详细说说这些内容。

代码结构

为什么先说代码结构呢?因为组织合理的代码结构是一个项目的门面。我们可以通过两个手段来组织代码结构。

第一个手段是,组织一个好的目录结构。

第二个手段是,选择一个好的模块拆分方法。做好模块拆分,可以使项目内模块职责分明,做到低耦合高内聚。

那么 Go 项目开发中,如何拆分模块呢?目前业界有两种拆分方法,分别是按层拆分和按功能拆分。

首先,我们看下按层拆分,最典型的是 MVC 架构中的模块拆分方式。在 MVC 架构中,我们将服务中的不同组件按访问顺序,拆分成了 Model、View 和 Controller 三层。

在这里插入图片描述
每层完成不同的功能:

  • View(视图)是提供给用户的操作界面,用来处理数据的显示。
  • Controller(控制器),负责根据用户从 View 层输入的指令,选取 Model 层中的数据,然后对其进行相应的操作,产生最终结果。
  • Model(模型),是应用程序中用于处理数据逻辑的部分。

我们看一个典型的按层拆分的目录结构:

$ tree --noreport -L 2 layers
layers
├── controllers
│ ├── billing
│ ├── order
│ └── user
├── models
│ ├── billing.go
│ ├── order.go
│ └── user.go
└── views
└── layouts

在 Go 项目中,按层拆分会带来很多问题。最大的问题是循环引用:相同功能可能在不同层被使用到,而这些功能又分散在不同的层中,很容易造成循环引用。

所以,你只要大概知道按层拆分是什么意思就够了,在 Go 项目中我建议你使用的是按功能拆分的方法,这也是 Go 项目中最常见的拆分方法。

那什么是按功能拆分呢?我给你看一个例子你就明白了。比如,一个订单系统,我们可以根据不同功能将其拆分成用户(user)、订单(order)和计费(billing)3 个模块,每一个模块提供独立的功能,功能更单一:

在这里插入图片描述
下面是该订单系统的代码目录结构:

$ tree pkg
$ tree --noreport -L 2 pkg
pkg
├── billing
├── order
│ └── order.go
└── user

相较于按层拆分,按功能拆分模块带来的好处也很好理解:

  • 不同模块,功能单一,可以实现高内聚低耦合的设计哲学。
  • 因为所有的功能只需要实现一次,引用逻辑清晰,会大大减少出现循环引用的概率。

所以,有很多优秀的 Go 项目采用的都是按功能拆分的模块拆分方式,例如 Kubernetes、Docker、Helm、Prometheus 等。

除了组织合理的代码结构这种方式外,编写高质量 Go 应用的另外一个行之有效的方法,是遵循 Go 语言代码规范来编写代码。在我看来,这也是最容易出效果的方式。

代码规范

那我们要遵循哪些代码规范来编写 Go 应用呢?在我看来,其实就两类:编码规范和最佳实践。

首先,我们的代码要符合 Go 编码规范,这是最容易实现的途径。Go 社区有很多这类规范可供参考,其中,比较受欢迎的是Uber Go 语言编码规范

阅读这些规范确实有用,也确实花时间、花精力。所以,我在参考了已有的很多规范后,结合自己写 Go 代码的经验,特地为你整理了一篇 Go 编码规范作为加餐,也就是“特别放送 | 给你一份清晰、可直接套用的 Go 编码规范”。

有了可以参考的编码规范之后,我们需要扩展到团队、部门甚至公司层面。只有大家一起参与、遵守,规范才会变得有意义。其实,我们都清楚,要开发者靠自觉来遵守所有的编码规范,不是一件容易的事儿。这时候,我们可以使用静态代码检查工具,来约束开发者的行为。

有了静态代码检查工具后,不仅可以确保开发者写出的每一行代码都是符合 Go 编码规范的,还可以将静态代码检查集成到 CI/CD 流程中。这样,在代码提交后自动地检查代码,就保证了只有符合编码规范的代码,才会被合入主干。

Go 语言的静态代码检查工具有很多,目前用的最多的是golangci-lint,这也是我极力推荐你使用的一个工具。关于这个工具的使用,我会在第 15 讲和你详细介绍。

除了遵循编码规范,要想成为 Go 编程高手,你还得学习并遵循一些最佳实践。“最佳实践”是社区经过多年探索沉淀下来的、符合 Go 语言特色的经验和共识,它可以帮助你开发出一个高质量的代码。

代码质量

有了组织合理的代码结构、符合 Go 语言代码规范的 Go 应用代码之后,我们还需要通过一些手段来确保我们开发出的是一个高质量的代码,这可以通过单元测试和 Code Review来实现。

单元测试非常重要。我们开发完一段代码后,第一个执行的测试就是单元测试。它可以保证我们的代码是符合预期的,一些异常变动能够被及时感知到。进行单元测试,不仅需要编写单元测试用例,还需要我们确保代码是可测试的,以及具有一个高的单元测试覆盖率。

接下来,我就来介绍下如何编写一个可测试的代码。

如果我们要对函数 A 进行测试,并且 A 中的所有代码均能够在单元测试环境下按预期被执行,那么函数 A 的代码块就是可测试的。我们来看下一般的单元测试环境有什么特点:

  • 可能无法连接数据库。
  • 可能无法访问第三方服务。

如果函数 A 依赖数据库连接、第三方服务,那么在单元测试环境下执行单元测试就会失败,函数就没法测试,函数是不可测的。

解决方法也很简单:将依赖的数据库、第三方服务等抽象成接口,在被测代码中调用接口的方法,在测试时传入 mock 类型,从而将数据库、第三方服务等依赖从具体的被测函数中解耦出去。如下图所示:

在这里插入图片描述

为了提高代码的可测性,降低单元测试的复杂度,对 function 和 mock 的要求是:

  • 要尽可能减少 function 中的依赖,让 function 只依赖必要的模块。编写一个功能单一、职责分明的函数,会有利于减少依赖。
  • 依赖模块应该是易 Mock 的。

为了协助你理解,我们先来看一段不可测试的代码:

package post
import "google.golang.org/grpc"
type Post struct {
Name string
Address string
}
func ListPosts(client *grpc.ClientConn) ([]*Post, error) {
return client.ListPosts()
}

这段代码中的 ListPosts 函数是不可测试的。因为 ListPosts 函数中调用了client.ListPosts()方法,该方法依赖于一个 gRPC 连接。而我们在做单元测试时,可能因为没有配置 gRPC 服务的地址、网络隔离等原因,导致没法建立 gRPC 连接,从而导致 ListPosts 函数执行失败。

下面,我们把这段代码改成可测试的,如下:

package main
type Post struct {
Name string
Address string
}
type Service interface {
ListPosts() ([]*Post, error)
}
func ListPosts(svc Service) ([]*Post, error) {
return svc.ListPosts()
}

上面代码中,ListPosts 函数入参为 Service 接口类型,只要我们传入一个实现了 Service接口类型的实例,ListPosts 函数即可成功运行。因此,我们可以在单元测试中可以实现一个不依赖任何第三方服务的 fake 实例,并传给 ListPosts。上述可测代码的单元测试代码如下:

package main
import "testing"
type fakeService struct {
}
func NewFakeService() Service {
return &fakeService{}
}
func (s *fakeService) ListPosts() ([]*Post, error) {
posts := make([]*Post, 0)
posts = append(posts, &Post{
Name: "colin",
Address: "Shenzhen",
})
posts = append(posts, &Post{
Name: "alex",
Address: "Beijing",
})
return posts, nil
}
func TestListPosts(t *testing.T) {
fake := NewFakeService()
if _, err := ListPosts(fake); err != nil {
t.Fatal("list posts failed")
}
}

当我们的代码可测之后,就可以借助一些工具来 Mock 需要的接口了。常用的 Mock 工具,有这么几个:

  • golang/mock,是官方提供的 Mock 框架。它实现了基于 interface 的 Mock 功能,能够与 Golang 内置的 testing 包做很好的集成,是最常用的 Mock 工具。golang/mock 提供了 mockgen 工具用来生成 interface 对应的 Mock 源文件。
  • sqlmock,可以用来模拟数据库连接。数据库是项目中比较常见的依赖,在遇到数据库依赖时都可以用它。
  • httpmock,可以用来 Mock HTTP 请求。
  • bouk/monkey,猴子补丁,能够通过替换函数指针的方式来修改任意函数的实现。如果 golang/mock、sqlmock 和 httpmock 这几种方法都不能满足我们的需求,我们可以尝试通过猴子补丁的方式来 Mock 依赖。可以这么说,猴子补丁提供了单元测试Mock 依赖的最终解决方案。

接下来,我们再一起看看如何提高我们的单元测试覆盖率。
当我们编写了可测试的代码之后,接下来就需要编写足够的测试用例,用来提高项目的单元测试覆盖率。这里我有以下两个建议供你参考:

  • 使用 gotests 工具自动生成单元测试代码,减少编写单元测试用例的工作量,将你从重复的劳动中解放出来。
  • 定期检查单元测试覆盖率。你可以通过以下方法来检查:
$ go test -race -cover -coverprofile=./coverage.out -timeout=10m -short -v ./
$ go tool cover -func ./coverage.out

执行结果如下:
在这里插入图片描述
在提高项目的单元测试覆盖率时,我们可以先提高单元测试覆盖率低的函数,之后再检查项目的单元测试覆盖率;如果项目的单元测试覆盖率仍然低于期望的值,可以再次提高单元测试覆盖率低的函数的覆盖率,然后再检查。以此循环,最终将项目的单元测试覆盖率优化到预期的值为止。

这里要注意,对于一些可能经常会变动的函数单元测试,覆盖率要达到 100%。

说完了单元测试,我们再看看如何通过 Code Review 来保证代码质量。

Code Review 可以提高代码质量、交叉排查缺陷,并且促进团队内知识共享,是保障代码质量非常有效的手段。在我们的项目开发中,一定要建立一套持久可行的 Code Review 机制。

但在我的研发生涯中,发现很多团队没有建立有效的 Code Review 机制。这些团队都认可

Code Review 机制带来的好处,但是因为流程难以遵守,慢慢地 Code Review 就变成了形式主义,最终不了了之。其实,建立 Code Review 机制很简单,主要有 3 点:

  • 首先,确保我们使用的代码托管平台有 Code Review 的功能。比如,GitHub、GitLab这类代码托管平台都具备这种能力。
  • 接着,建立一套 Code Review 规范,规定如何进行 Code Review。
  • 最后,也是最重要的,每次代码变更,相关开发人员都要去落实 Code Review 机制,并形成习惯,直到最后形成团队文化。

到这里我们可以小结一下:组织一个合理的代码结构、编写符合 Go 代码规范的代码、保证代码质量,在我看来都是编写高质量 Go 代码的外功。那内功是什么呢?就是编程哲学和软件设计方法。

编程哲学

那编程哲学是什么意思呢?在我看来,编程哲学,其实就是要编写符合 Go 语言设计哲学的代码。Go 语言有很多设计哲学,对代码质量影响比较大的,我认为有两个:面向接口编程和面向“对象”编程。

我们先来看下面向接口编程。

Go 接口是一组方法的集合。任何类型,只要实现了该接口中的方法集,那么就属于这个类型,也称为实现了该接口。

接口的作用,其实就是为不同层级的模块提供一个定义好的中间层。这样,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦。很多流行的 Go 设计模式,就是通过面向接口编程的思想来实现的。

我们看一个面向接口编程的例子。下面这段代码定义了一个Bird接口,Canary 和 Crow类型均实现了Bird接口。

package main
import "fmt"
// 定义了一个鸟类
type Bird interface {
Fly()
Type() string
}
// 鸟类:金丝雀
type Canary struct {
Name string
}
func (c *Canary) Fly() {
fmt.Printf("我是%s,用黄色的翅膀飞\n", c.Name)
}
func (c *Canary) Type() string {
return c.Name
}
// 鸟类:乌鸦
type Crow struct {
Name string
}
func (c *Crow) Fly() {
fmt.Printf("我是%s,我用黑色的翅膀飞\n", c.Name)
}
func (c *Crow) Type() string {
return c.Name
}
// 让鸟类飞一下
func LetItFly(bird Bird) {
fmt.Printf("Let %s Fly!\n", bird.Type())
bird.Fly()
}
func main() {
LetItFly(&Canary{"金丝雀"})
LetItFly(&Crow{"乌鸦"})
}

这段代码中,因为 Crow 和 Canary 都实现了 Bird 接口声明的 Fly、Type 方法,所以可以说 Crow、Canary 实现了 Bird 接口,属于 Bird 类型。在函数调用时,可以传入 Bird 类型,并在函数内部调用 Bird 接口提供的方法,以此来解耦 Bird 的具体实现。

好了,我们总结下使用接口的好处吧:

  • 代码扩展性更强了。例如,同样的 Bird,可以有不同的实现。在开发中用的更多的是,将数据库的 CURD 操作抽象成接口,从而可以实现同一份代码对接不同数据库的目的。
  • 可以解耦上下游的实现。例如,LetItFly 不用关注 Bird 是如何 Fly 的,只需要调用 Bird提供的方法即可。
  • 提高了代码的可测性。因为接口可以解耦上下游实现,我们在单元测试需要依赖第三方系统 / 数据库的代码时,可以利用接口将具体实现解耦,实现 fake 类型。
  • 代码更健壮、更稳定了。例如,如果要更改 Fly 的方式,只需要更改相关类型的 Fly 方法即可,完全影响不到 LetItFly 函数。

所以,我建议你,在 Go 项目开发中,一定要多思考,那些可能有多种实现的地方,要考虑使用接口。

接下来,我们再来看下面向“对象”编程。

面向对象编程(OOP)有很多优点,例如可以使我们的代码变得易维护、易扩展,并能提高开发效率等,所以一个高质量的 Go 应用在需要时,也应该采用面向对象的方法去编程。那什么叫“在需要时”呢?就是我们在开发代码时,如果一个功能可以通过接近于日常生活和自然的思考方式来实现,这时候就应该考虑使用面向对象的编程方法。

Go 语言不支持面向对象编程,但是却可以通过一些语言级的特性来实现类似的效果。

面向对象编程中,有几个核心特性:类、实例、抽象,封装、继承、多态、构造函数、析构函数、方法重载、this 指针。在 Go 中可以通过以下几个方式来实现类似的效果:

  • 类、抽象、封装通过结构体来实现。
  • 实例通过结构体变量来实现。
  • 继承通过组合来实现。这里解释下什么叫组合:一个结构体嵌到另一个结构体,称作组合。例如一个结构体包含了一个匿名结构体,就说这个结构体组合了该匿名结构体。
  • 多态通过接口来实现。

至于构造函数、析构函数、方法重载和 this 指针等,Go 为了保持语言的简洁性去掉了这些特性。

Go 中面向对象编程方法,见下图:
在这里插入图片描述
我们通过一个示例,来具体看下 Go 是如何实现面向对象编程中的类、抽象、封装、继承和多态的。代码如下:

package main
import "fmt"
// 基类:Bird
type Bird struct {
Type string
}
// 鸟的类别
func (bird *Bird) Class() string {
return bird.Type
}
// 定义了一个鸟类
type Birds interface {
Name() string
Class() string
}
// 鸟类:金丝雀
type Canary struct {
Bird
name string
}
func (c *Canary) Name() string {
return c.name
}
// 鸟类:乌鸦
type Crow struct {
Bird
name string
}
func (c *Crow) Name() string {
return c.name
}
func NewCrow(name string) *Crow {
return &Crow{
Bird: Bird{
Type: "Crow",
},
name: name,
}
}
func NewCanary(name string) *Canary {
return &Canary{
Bird: Bird{
Type: "Canary",
},
name: name,
}
}
func BirdInfo(birds Birds) {
fmt.Printf("I'm %s, I belong to %s bird class!\n", birds.Name(), birds.Class
}
func main() {
canary := NewCanary("CanaryA")
crow := NewCrow("CrowA")
BirdInfo(canary)
BirdInfo(crow)
}

将上述代码保存在 oop.go 文件中,执行以下代码输出如下:

$ go run oop.go
I'm CanaryA, I belong to Canary bird class!
I'm CrowA, I belong to Crow bird class!

在上面的例子中,分别通过 Canary 和 Crow 结构体定义了金丝雀和乌鸦两种类别的鸟,其中分别封装了 name 属性和 Name 方法。也就是说通过结构体实现了类,该类抽象了鸟类,并封装了该鸟类的属性和方法。

在 Canary 和 Crow 结构体中,都有一个 Bird 匿名字段,Bird 字段为 Canary 和 Crow 类的父类,Canary 和 Crow 继承了 Bird 类的 Class 属性和方法。也就是说通过匿名字段实现了继承。

在 main 函数中,通过 NewCanary 创建了 Canary 鸟类实例,并将其传给 BirdInfo 函数。也就是说通过结构体变量实现实例。

在 BirdInfo 函数中,将 Birds 接口类型作为参数传入,并在函数中调用了 birds.Name,birds.Class 方法,这两个方法会根据 birds 类别的不同而返回不同的名字和类别,也就是说通过接口实现了多态。

高效管理项目

一个优雅的 Go 项目,还需要具备高效的项目管理特性。那么如何高效管理我们的项目呢?

不同团队、不同项目会采用不同的方法来管理项目,在我看来比较重要的有 3 点,分别是制定一个高效的开发流程、使用 Makefile 管理项目和将项目管理自动化。我们可以通过自动生成代码、借助工具、对接 CI/CD 系统等方法来将项目管理自动化。具体见下图:
在这里插入图片描述

使用 Makefile 管理项目

为了更好地管理项目,除了一个高效的开发流程之外,使用 Makefile 也很重要。Makefile可以将项目管理的工作通过 Makefile 依赖的方式实现自动化,除了可以提高管理效率之外,还能够减少人为操作带来的失误,并统一操作方式,使项目更加规范。

IAM 项目的所有操作均是通过 Makefile 来完成的,具体 Makefile 完成了如下操作:

build Build source code for host platform.
build.multiarch Build source code for multiple platforms. See option PLAT
image Build docker images for host arch.
image.multiarch Build docker images for multiple platforms. See option PL
push Build docker images for host arch and push images to regi
push.multiarch Build docker images for multiple platforms and push image
deploy Deploy updated components to development env.
clean Remove all files that are created by building.
lint Check syntax and styling of go sources.
test Run unit test.
cover Run unit test and get test coverage.
release Release iam
format Gofmt (reformat) package sources (exclude vendor dir if e
verify-copyright Verify the boilerplate headers for all files.
add-copyright Ensures source code files have copyright license headers.
gen Generate all necessary files, such as error code files.
ca Generate CA files for all iam components.
install Install iam system with all its components.
swagger Generate swagger document.
serve-swagger Serve swagger spec and docs.
dependencies Install necessary dependencies.
tools install dependent tools.
check-updates Check outdated dependencies of the go projects.
help Show this help info.
  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

kuokay

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值