转自: 如何写出优雅的 Go 语言代码  https://draveness.me/golang-101/  (Go 语言设计与实现  作者)

Go 语言是一门简单、易学的编程语言,对于有编程背景的工程师来说,学习 Go 语言并写出能够运行的代码并不是一件困难的事情,对于之前有过其他语言经验的开发者来说,写什么语言都像自己学过的语言其实是有问题的,想要真正融入生态写出优雅的代码就一定要花一些时间和精力了解语言背后的设计哲学和最佳实践。

bottle-of-wate

如果你之前没有 Go 语言的开发经历,正在学习和使用 Go 语言,相信这篇文章能够帮助你更快地写出优雅的 Go 语言代码;在这篇文章中,我们并不会给一个长长地列表介绍变量、方法和结构体应该怎么命名,这些 Go 语言的代码规范可以在 Go Code Review Comments 中找到,它们非常重要但并不是这篇文章想要介绍的重点,我们将从代码结构、最佳实践以及单元测试几个不同的方面介绍如何写出优雅的 Go 语言代码。

0. 写在前面

想要写出好的代码并不是一件容易的事情,它需要我们不断地对现有的代码进行反思 — 如何改写这段代码才能让它变得更加优雅。优雅听起来是一个非常感性、难以量化的结果,然而这却是好的代码能够带来的最直观感受,它可能隐式地包含了以下特性:

  • 容易阅读和理解;
  • 容易测试、维护和扩展;
  • 命名清晰、无歧义、注释完善清楚;

相信读完了这篇文章,我们也不能立刻写出优雅的 Go 语言代码,但是如果我们遵循这里介绍几个的容易操作并且切实可行的方法,就帮助我们走出第一步,作者写这篇文章有以下的几个目的:

  • 帮助 Go 语言的开发者了解生态中的规范与工具,写出更优雅的代码;
  • 为代码和项目的管理提供被社区广泛认同的规则、共识以及最佳实践;

1. 代码规范

代码规范其实是一个老生常谈的问题,我们也不能免俗还是要简单介绍一下相关的内容,Go 语言比较常见并且使用广泛的代码规范就是官方提供的 Go Code Review Comments,无论你是短期还是长期使用 Go 语言编程,都应该至少完整地阅读一遍这个官方的代码规范指南,它既是我们在写代码时应该遵守的规则,也是在代码审查时需要注意的规范。

学习 Go 语言相关的代码规范是一件非常重要的事情,也是让我们的项目遵循统一规范的第一步,虽然阅读代码规范相关的文档非常重要,但是在实际操作时我们并不能靠工程师自觉地遵守以及经常被当做形式的代码审查,而是需要借助工具来辅助执行。

1.1 辅助工具

使用自动化的工具保证项目遵守一些最基本的代码规范是非常容易操作和有效的事情,相比之下人肉审查代码的方式更加容易出错,也会出现一些违反规则和约定的特例,维护代码规范的最好方式就是**『尽量自动化一切能够自动化的步骤,让工程师审查真正重要的逻辑和设计』**。

我们在这一节中就会介绍两种非常切实有效的办法帮助我们在项目中自动化地进行一些代码规范检查和静态检查保证项目的质量。

goimports

goimportsgofmt

golang-goimports

goimportsgoimportsgoimportsgofmt
gofmtgoimports

golint

golintgolint
golintgolintgolint
lintgolintmin_confidence
golint

golang-lint

golintgolintgolangci-lint
golintgolangci-lint

1.2 自动化

goimportsglintgolangci-lint

在自建的或者其他的代码托管平台上也应该想尽办法寻找合适的工具,现代的代码托管工具应该都会对 CI/CD 有着非常不错的支持;我们需要通过这些 CI 工具将代码的自动化检查变成 PR 合并和发版的一个前置条件,减少工程师 Review 代码时可能发生的疏漏。

2. 最佳实践

我们在上一节中介绍了一些能通过自动化工具发现的问题,这一节提到的最佳实践可能就没有办法通过自动化工具进行保证,这些最佳实践更像是 Go 语言社区内部发展过程中积累的一些工程经验和共识,遵循这些最佳实践能够帮助我们写出符合 Go 语言『味道』的代码,我们将在这一小节覆盖以下的几部分内容:

  • 目录结构;
  • 模块拆分;
  • 显式调用;
  • 面向接口;

这四部分内容是在社区中相对来说比较常见的约定,如果我们学习并遵循了这些约定,同时在 Go 语言的项目中实践这几部分内容,相信一定会对我们设计 Go 语言项目有所帮助。

2.1 目录结构

目录结构基本上就是一个项目的门面,很多时候我们从目录结构中就能够看出开发者对这门语言是否有足够的经验,所以在这里首先要介绍的最佳实践就是如何在 Go 语言的项目或者服务中组织代码。

官方并没有给出一个推荐的目录划分方式,很多项目对于目录结构的划分也非常随意,这其实也是没有什么问题的,但是社区中还是有一些比较常见的约定,例如:golang-standards/project-layout 项目中就定义了一个比较标准的目录结构。

我们在这里就先简单介绍其中几个比较常见并且重要的目录和文件,帮助我们快速理解如何使用如上所示的目录结构,如果各位读者想要了解使用其他目录的原因,可以从 golang-standards/project-layout 项目中的 README 了解更详细的内容。

/pkg

/pkg
importpkg/pkg
私有代码
/internal/internal/app/internal/pkg/pkg/internal

golang-internal-app-and-pkg

internal
internalinternal
/src
/src/src/src
$GOPATH/src/srcPATHsrc
/src
/src
平铺
pkggithub.com/draveness/project/pkg/somepkggithub.com/draveness/project

所以对于一个 Go 语言的框架或者库,将代码平铺在根目录下也很正常,但是在一个 Go 语言的服务中使用这种代码组织方法可能就没有那么合适了。

/cmd

/cmdgrpc/cmd/server/main.goserver
/cmd/pkg/internal/cmdmain

/api

/api/api/protobuf-spec/api/thrift-spec/api/http-spec

二级目录的主要作用就是在一个项目同时提供了多种不同的访问方式时,用这种办法避免可能存在的潜在冲突问题,也可以让项目结构的组织更加清晰。

Makefile

Makefile/scriptsMakefile

小结

总的来说,每一个项目都应该按照固定的组织方式进行实现,这种约定虽然并不是强制的,但是无论是组内、公司内还是整个 Go 语言社区中,只要达成了一致,对于其他工程师快速梳理和理解项目都是很有帮助的。

这一节介绍的 Go 语言项目的组织方式也并不是强制要求的,这只是 Go 语言社区中经常出现的项目组织方式,一个大型项目在使用这种目录结构时也会对其进行微调,不过这种组织方式确实更为常见并且合理。

2.2 模块拆分

我们既然已经介绍过了如何从顶层对项目的结构进行组织,接下来就会深入到项目的内部介绍 Go 语言对模块的一些拆分方法。

Go 语言的一些顶层设计最终导致了它在划分模块上与其他的编程语言有着非常明显的不同,很多其他语言的 Web 框架都采用 MVC 的架构模式,例如 Rails 和 Spring MVC,Go 语言对模块划分的方法就与 Ruby 和 Java 完全不同。

按层拆分

无论是 Java 还是 Ruby,它们最著名的框架都深受 MVC 架构模式 的影响,我们从 Spring MVC 的名字中就能体会到 MVC 对它的影响,而 Ruby 社区的 Rails 框架也与 MVC 的关系非常紧密,这是一种 Web 框架的最常见架构方式,将服务中的不同组件分成了 Model、View 和 Controller 三层。

divide-by-laye

modelsviewscontrollersrails new example
modeldaoview
  1. MVC 架构模式 — MVC 本身就强调了按层划分职责的设计,所以遵循该模式设计的框架自然有着一脉相承的思路;
  2. 扁平的命名空间 — 无论是 Spring MVC 还是 Rails,同一个项目中命名空间非常扁平,跨文件夹使用其他文件夹中定义的类或者方法不需要引入新的包,使用其他文件定义的类时也不需要增加额外的前缀,多个文件定义的类被『合并』到了同一个命名空间中;
  3. 单体服务的场景 — Spring MVC 和 Rails 刚出现时,SOA 和微服务架构还不像今天这么普遍,绝大多数的场景也不需要通过拆分服务;
modelsviewscontrollers

按职责拆分

Go 语言在拆分模块时就使用了完全不同的思路,虽然 MVC 架构模式是在我们写 Web 服务时无法避开的,但是相比于横向地切分不同的层级,Go 语言的项目往往都按照职责对模块进行拆分:

divide-by-responsibility

postusercommentpost
importpkg.xxxmodelviewcontrollermodel.Postmodel.Commentview.PostView

这种划分层级的方法在 Go 语言中会显得非常冗余,并且如果对项目依赖包的管理不够谨慎时,很容易发生引用循环,出现这些问题的最根本原因其实也非常简单:

package

小结

项目是按照层级还是按照职责对模块进行拆分其实并没有绝对的好与不好,语言和框架层面的设计最终决定了我们应该采用哪种方式对项目和代码进行组织。

packagepackage

2.3 显式与隐式

从开始学习、使用 Go 语言到参与社区上一些开源的 Go 语言项目,作者发现 Go 语言社区对于显式的初始化、方法调用和错误处理非常推崇,类似 Spring Boot 和 Rails 的框架其实都广泛地采纳了『约定优于配置』的中心思想,简化了开发者和工程师的工作量。

然而 Go 语言社区虽然达成了很多的共识与约定,但是从语言的设计以及工具上的使用我们就能发现显式地调用方法和错误处理是被鼓励的。

init

initpackage
initpackageinit
ClientNewClientPostGetPostclient.GetPost
mainmainmainmain

各个模块之间会构成一种树形的结构和依赖关系,上层的模块会持有下层模块中的接口或者结构体,不会存在孤立的、不被引用的对象。

golang-project-and-tree-structure

Databasemain
gochecknoinitsgochecknoglobalsinit
init
initinit
initinitinit

error

if err != nil { return nil, err }

上述代码只是简单展示 Go 语言常见的错误处理逻辑,我们不应该在这种方法中初始化数据库的连接。

try/catchpanicrecoverinitpanicrecover

当我们在 Go 语言中处理错误相关的逻辑时,最重要的其实就是以下几点:

errorerrorerrors.Wrap

小结

作者在使用 Go 语言的这段时间,能够深刻地体会到它对于显式方法调用与错误处理的鼓励,这不仅能够帮助项目的其他开发者快速地理解上下文,也能够帮助我们构建更加健壮、容错性与可维护性更好的工程。

2.4 面向接口

面向接口编程是一个老生常谈的话题,接口 的作用其实就是为不同层级的模块提供了一个定义好的中间层,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦。

golang-interface

这种编程方式不仅是在 Go 语言中是被推荐的,在几乎所有的编程语言中,我们都会推荐这种编程的方式,它为我们的程序提供了非常强的灵活性,想要构建一个稳定、健壮的 Go 语言项目,不使用接口是完全无法做到的。

type ... interface

单元测试是一个项目保证工程质量最有效并且投资回报率最高的方法之一,作为静态语言的 Go,想要写出覆盖率足够(最少覆盖核心逻辑)的单元测试本身就比较困难,因为我们不能像动态语言一样随意修改函数和方法的行为,而接口就成了我们的救命稻草,写出抽象良好的接口并通过接口隔离依赖能够帮助我们有效地提升项目的质量和可测试性,我们会在下一节中详细介绍如何写单元测试。

initListPostsListPosts

我们可以使用下面的代码改写原有的逻辑,使得同样地逻辑变得更容易测试和维护:

ServiceListPostsNewServiceServiceserviceListPostsservice
mainServiceListPosts

这种使用接口组织代码的方式在 Go 语言中非常常见,我们应该在代码中尽可能地使用这种思想和模式对外提供功能:

ServiceserviceNewServiceService

当我们使用上述方法组织代码之后,其实就对不同模块的依赖进行了解耦,也正遵循了软件设计中经常被提到的一句话 — 『依赖接口,不要依赖实现』,也就是面向接口编程

小结

initerror

3. 单元测试

一个代码质量和工程质量有保证的项目一定有比较合理的单元测试覆盖率,没有单元测试的项目一定是不合格的或者不重要的,单元测试应该是所有项目都必须有的代码,每一个单元测试都表示一个可能发生的情况,单元测试就是业务逻辑

casewiki

简单总结一下,单元测试的缺失不仅会意味着较低的工程质量,而且意味着重构的难以进行,一个有单元测试的项目尚且不能够保证重构前后的逻辑完全相同,一个没有单元测试的项目很可能本身的项目质量就堪忧,更不用说如何在不丢失业务逻辑的情况下进行重构了

3.1 可测试

写代码并不是一件多困难的事情,不过想要在项目中写出可以测试的代码并不容易,而优雅的代码一定是可以测试的,我们在这一节中需要讨论的就是什么样的代码是可以测试的。

如果想要想清楚什么样的才是可测试的,我们首先要知道测试是什么?作者对于测试的理解就是控制变量,在我们隔离了待测试方法中一些依赖之后,当函数的入参确定时,就应该得到期望的返回值。

golang-unit-test

Mock
Mock

单元测试的执行不应该依赖于任何的外部模块,无论是调用外部的 HTTP 请求还是数据库中的数据,我们都应该想尽办法模拟可能出现的情况,因为单元测试不是集成测试的,它的运行不应该依赖除项目代码外的其他任何系统。

接口

Mock

我们再来回顾一下上一节对接口进行介绍时展示的常见模式:

上述代码在 Go 语言中是非常常见的,如果你不知道应不应该使用接口对外提供服务,这时就应该无脑地使用上述模式对外暴露方法了,这种模式可以在绝大多数的场景下工作,至少作者到目前还没有见到过不适用的。

函数简单

另一个建议就是保证每一个函数尽可能简单,这里的简单不止是指功能上的简单、单一,还意味着函数容易理解并且命名能够自解释。

lintif/elseswitch/case
107

3.2 组织方式

packageserver.goserver_test.go
_test.gogo test ./pkg

Test

_test.goTesttesting.T
AddTestAddAdd
Testfor
Addif/else

作者通常会在测试代码比较简单时使用第一种组织方式,而在依赖较多、函数功能较为复杂时使用第二种方式,不过这也不是定论,我们需要根据实际情况决定如何对测试进行设计。

Suite

suite
suitesuiteSetupTestSetupSuiteTearDownTestTearDownSuite

BDD

最后一种组织代码的方式就是使用 BDD 的风格对单元测试进行组织,ginkgo 就是 Go 语言社区最常见的 BDD 框架了,这里提到的行为驱动开发(BDD)和测试驱动开发(TDD)都是一种保证工程质量的方法论。想要在项目中实践这种思想还是需要一些思维上的转变和适应,也就是先通过写单元测试或者行为测试约定方法的 Spec,再实现方法让我们的测试通过,这是一种比较科学的方法,它能为我们带来比较强的信心。

我们虽然不一定要使用 BDD/TDD 的思想对项目进行开发,但是却可以使用 BDD 的风格方式组织非常易读的测试代码:

DescribeContextItDescribeContextIt

3.3 Mock 方法

项目中的单元测试应该是稳定的并且不依赖任何的外部项目,它只是对项目中函数和方法的测试,所以我们需要在单元测试中对所有的第三方的不稳定依赖进行 Mock,也就是模拟这些第三方服务的接口;除此之外,为了简化一次单元测试的上下文,在同一个项目中我们也会对其他模块进行 Mock,模拟这些依赖模块的返回值。

单元测试的核心就是隔离依赖并验证输入和输出的正确性,Go 语言作为一个静态语言提供了比较少的运行时特性,这也让我们在 Go 语言中 Mock 依赖变得非常困难。

Mock 的主要作用就是保证待测试方法依赖的上下文固定,在这时无论我们对当前方法运行多少次单元测试,如果业务逻辑不改变,它都应该返回完全相同的结果,在具体介绍 Mock 的不同方法之前,我们首先要清楚一些常见的依赖,一个函数或者方法的常见依赖可以有以下几种:

  1. 接口
  2. 数据库
  3. HTTP 请求
  4. Redis、缓存以及其他依赖

这些不同的场景基本涵盖了写单元测试时会遇到的情况,我们会在接下来的内容中分别介绍如何处理以上几种不同的依赖。

接口

首先要介绍的其实就是 Go 语言中最常见也是最通用的 Mock 方法,也就是能够对接口进行 Mock 的 golang/mock 框架,它能够根据接口生成 Mock 实现,假设我们有以下代码:

jekyllwordpressListsPostsPostBlogListPosts

golang-interface-blog-example

BlogServiceBlog
ServicemockgenMockBlog
mockgen
mock:
    rm -rf test/mocks
       
    mkdir -p test/mocks/blog
    mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go
ServiceNewMockBlogBlogEXPECTListPostsPost
ServiceBlog[]Post{}ListPosts
package

SQL

另一个项目中比较常见的依赖其实就是数据库,在遇到数据库的依赖时,我们一般都会使用 sqlmock 来模拟数据库的连接,当我们使用 sqlmock 时会写出如下所示的单元测试:

ExpectQueryExpectExec

HTTP

HTTP 请求也是我们在项目中经常会遇到的依赖,httpmock 就是一个用于 Mock 所有 HTTP 依赖的包,它使用模式匹配的方式匹配 HTTP 请求的 URL,在匹配到特定的请求时就会返回预先设置好的响应。

如果遇到 HTTP 请求的依赖时,就可以使用上述 httpmock 包模拟依赖的 HTTP 请求。

猴子补丁

最后要介绍的猴子补丁其实就是一个大杀器了,bouk/monkey 能够通过替换函数指针的方式修改任意函数的实现,所以如果上述的几种方法都不能满足我们的需求,我们就只能够通过猴子补丁这种比较 hack 的方法 Mock 依赖了:

rand.Int63ntime.Now-gcflags=-l
interfacetime.Nowrand.Int63n

从理论上来说,通过猴子补丁这种方式我们能够在运行时 Mock Go 语言中的一切函数,这也为我们提供了单元测试 Mock 依赖的最终解决方案。

3.4 断言

在最后,我们简单介绍一下辅助单元测试的 assert 包,它提供了非常多的断言方法帮助我们快速对期望的返回值进行测试,减少我们的工作量:

assert

小结

如果之前完全没有写过单元测试或者没有写过 Go 语言的单元测试,相信这篇文章已经给了足够多的上下文帮助我们开始做这件事情,我们要知道的是单元测试其实并不会阻碍我们的开发进度,它能够为我们的上线提供信心,也是质量保证上投资回报率最高的方法。

学习写好单元测试一定会有一些学习曲线和不适应,甚至会在短期内影响我们的开发效率,但是熟悉了这一套流程和接口之后,单元测试对我们的帮助会非常大,每一个单元测试都表示一个业务逻辑,每次提交时执行单元测试就能够帮助我们确定新的代码大概率上不会影响已有的业务逻辑,能够明显地降低重构的风险以及线上事故的数量

4. 总结

在这篇文章中我们从三个方面分别介绍了如何写优雅的 Go 语言代码,作者尽可能地给出了最容易操作和最有效的方法:

modelcontrollerinitServiceservicefunc NewService(...) (Service, error)Servicesuite

想要写出优雅的代码本身就不是一件容易的事情,它需要我们不断地对自己的知识体系进行更新和优化,推倒之前的经验并对项目持续进行完善和重构,而只有真正经过思考和设计的代码才能够经过时间的检验(代码是需要不断重构的),随意堆砌代码的行为是不能鼓励也不应该发生的,每一行代码都应该按照最高的标准去设计和开发,这是我们保证工程质量的唯一方法。

作者也一直在努力学习如何写出更加优雅的代码,写出好的代码真的不是一件容易的事情,作者也希望能通过这篇文章帮助使用工程师写出更有 Go 语言风格的项目。

Reference