编写可维护的Go代码

编写可维护的代码是最基本的要求。清晰度、可读性和简单性都是保持代码可维护性的各个方面。它应该使某人加入您的项目或在有人离开后维护代码的过程变得容易。可维护性的衡量指标是代码更改的容易程度以及与这些更改引起的风险性。为了有效地编写Go程序,了解Go语言的属性和地道写法,并使用与命名、程序构建、格式等相关既定约定是至关重要。

以下是一些有助于编写可维护的Go代码的良好实践。

原文: Writing maintainable Go code by Jogendra.

保持小巧的main函数

"Go之旅"中讲到:

每个 Go 程序都由包组成。程序在包 main 中开会运行。

main是唯一的,那些导出名称(exported name)既不会导出,编译器也不会将其视为常规包; 相反,它将其编译为可执行程序。在包main中,main函数必须存在,它是 Go 程序的入口点。对软件包main和函数main的期望是它们尽可能少。

main.main是单例的,仅被调用一次。你为它内部的代码编写测试也很困难,因此,强烈建议使用main.main仅仅启动程序,但不要在此包中编写业务逻辑。将启动程序和业务逻辑分别写在单独的包中可改进程序的结构和可维护性。

使用有意义的名称

在 Go 中命名主要强调一致、简短和准确的名称,因为它们往往会提高代码的可读性。

Russ Cox的命名理念

一个名称的长度不应超过它的信息内容。对于一个局部变量,名称iindex或者idx携带同样的信息,而且更方便快速阅读。类似地,ij这一对命名比索引变量i1i2更好(更差的是index1index2),在阅读代码的时候它们更容易区分。全局名称必须传达相对更多的信息,因为它们出现在更广泛的上下文中。即便如此,一个简短、准确的名字比冗长的名字更能说明问题:比较acquiretake_ownership。让每个命名都能区分

Ken Thompson,Rob Pike,Robert Griesemer,Russ Cox,Ian Lance Taylor等人多年的编程经验和命名理念很可能激发了Go中的命名约定。这是安德鲁·格兰德(Andrew Gerrand)的一张幻灯片,其更深入地讨论了Go中的命名。

代码分组

在函数(或方法)中,某些语句可能有关联。因此,建议将这些语句保留在单独的代码块中,用换行符分隔。分组使程序构造更好,并通过分隔相关部分来提高可读性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Creating new HTTP request with request body
req, err := http.NewRequest("POST", "https://api.example.com/endpoint", body)
if err != nil {
// handle err
}
// Setting headers to HTTP request
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer b7d03a6947b217efb6f3ec3bd3504582")
// Executing the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
// handle err
}
defer resp.Body.Close()

撰写有意义的注释

注释是理解现有代码的绝佳方式,它可以回答这段代码的作用,为什么会存在某些代码段以及为什么它是这样编写的。最好在编写代码时就编写注释,但更重要的是在更新代码时更新注释。代码更新可能会改变特定代码的实现目的,因此更新注释也至关重要的; 否则,它将造成困惑而不是在以后有所帮助。与其编写与代码相矛盾的注释还不如不写代码注释。您可以在 Go 中编写块注释或内联注释,你可以选择任何更适合您的代码的内容。

您使用该工具做检查在 Go 中你是否正确编写注释代码。godoc将从您的代码中提取注释,并为您的 Go 程序生成文档。Go 中的注释有几个不错的功能,请参阅Go代码注释的魔法以详细了解。

知道不必写注释与知道应该写注释一样重要。最好避免过度注释代码,并将其留给其他程序员来理解Go。您应该避显而易见的注释,如果代码的可读性足够高,则不需要注释。例如:

1
2
3
4
5
6
7
8
9
// Get country code from customer address.
countryCode := getCountryCode(address)
// If country code is "IN", assign "India" as
// country.
if countryCode == "IN" {
country = "India"
}
}

这里写的注释没啥意义,不会增加任何价值。

不要重复

DRY(不要重复自己)是软件开发的一个原则,旨在减少软件模式的重复,用抽象代替它以避免冗余。

那么,代码重复又有什么问题呢?在有变更之前,没有太多问题。想象一下,在10个地方重复相同的代码,每当有小的变化时都要在所有10个地方做改变。如果代码仅存在于一个位置,则更容易维护代码,从而确保一致性。如果代码重复,您很有可能忘记更新其中一个副本,这意味着您在一个副本中修复的错误仍将存在于另一个副本中。

如果必须再次编写相同的代码,可以其移动到大多数helper函数所在的共享package中。通过删除重复的代码,您将拥有更少的代码,这些代码将更清晰,更易于维护。

缺少泛型(Go 1.17以及先前版本)可能会使 Go 代码在某些地方看起来重复。但是,随着Go 1.18正式支持泛型,将使编写通用代码变得更加简单,冗余更少,并且更易于维护。

但是,有时重复代码比试图强制抽象以免冗余会更简单,更容易理解。因此,更重要的是要知道在哪里应用 DRY,在哪里不应用 DRY,因为代码的可读性和易懂性胜过其他问题。

Linting和样式指南

遵守编码标准使代码库保持一致,易于代码审查和维护。它使编码风格一致。通常,风格指南是有争议的,为了让人们遵守相同指南的最佳方法是为Go代码创建一个标准的风格指南。拥有风格指南不是最重要的,最重要的是让你的团队真正地使用它。市面上有许多开源的 linting 工具和样式指南,你可以从以此为基础并对其进行修改,使其适合你。

Go有一个在社区中普遍使用和接受的代码格式标准,尽管它不需要特殊的规则。Go 提供了这个fmt工具,鼓励和保护 Go 代码使用既定约定进行格式化。

许多编辑器支持在保存文件时调用文件格式化工具。或者,像 gofumpt工具,提供更严格的格式化版本。该工具是 gofmt的修改分支,可用作gofmt原地替换。此外,这些工具还支持自定义源代码转换和添加自定义的规则。

如果您想遵循Go的社区风格指南,可以使用golint。该工具提供了有关代码样式的有用提示,还可以帮助查看Go的公认约定。这将极大地帮助加入项目的每个新开发人员。

避免深度嵌套

过度的嵌套困扰着每个人。深度嵌套使得代码很难遵循设计逻辑。如果您正在执行代码审查或重新访问旧代码,具有大量嵌套的超大函数(或方法)会造成逻辑的混乱。此外,嵌套代码难以阅读; 嵌套越小,读者的认知负荷就越小。

1
2
3
4
5
6
7
8
9
10
11
12
13
if condition1 {
if condition2 {
if condition3 {
// ...
} else {
// ...
}
} else {
if condition5 {
// ...
}
}
}

开发人员可以通过多种方式避免这种情况。这里是一个很好的阅读材料。

编写更好的函数

避免编写较长的函数;函数越小越好。较长的函数可能难以读取、测试和维护。较长的函数通常具有较大的职责,因此建议将它们分解为较小的函数。通过分解较长的函数创建的较短的函数,可以服务更多的调用方。因为它们可以提供可管理的独立任务。

Unix管道的发明者、Unix的创始人之一道格·麦克罗伊(Doug McIlroy)说(Unix Philosophy):

让每个程序都做好一件事。要完成一项新工作,请重新构建,而不是通过添加新功能使旧程序复杂化。

因此,分解函数以做好一件事确实与Unix哲学英雄相惜。

如前所述,命名对于可读性至关重要。好的函数名称比注释更好,它们与编写良好的注释或 API 文档一样有助于理解代码。尽量保留较少的功能参数。

避免包(package)级别状态

在 Go 中,对于任何给定的导入路径,包的实例都是唯一一个(单例)。这意味着在包级别上,任何变量只有一个实例。包级别的变量在全局级别共享,这意味着所有访问者将共享同一个实例。函数X可以修改变量,函数Y可以读取修改后的值。

使用包级别的变量可能会产生许多影响:

  1. 很难跟踪变量的修改位置以及跟踪访问变量的位置以做出任何决定。
  2. 包级变量导致紧密耦合; 一个角落的代码修改可能需要修改代码的另一个角落,这使得阅读、修改和单元测试代码变得更加困难。
  3. 它可能会导致争用条件等问题。

但是,包级别常量的使用非常有用。因此,始终建议尽可能避免使用包级别状态(译者注: 可修改的变量)。若要减少耦合,请将相关变量移到所需的结构体的字段上。在结构体中定义依赖项和配置使其变得容易。接口的使用也非常有帮助。

尽早返回并明智地使用条件

条件语句是我们必须经常写的东西。它在代码是干净还是混乱方面发挥着重要作用。例如:

1
2
3
4
5
6
7
func do(n int) bool {
if n > 12 {
return false
} else {
return true
}
}

此代码的问题在于else语句在此处没有帮助;相反,它使代码变得混乱且可读性降低。相反,把它写成:

1
2
3
4
5
6
func do(n int) bool {
if n > 12 {
return false
}
return true
}

译者注, 把它写成下面的方式更简洁:

1
2
3
func do(n int) bool {
return n <= 12
}

你也经常会看到整个函数体都包在if语句里面,这也是不好的

1
2
3
4
5
6
7
func do(n int) {
if n > 12 {
sum()
subtract()
multiply()
}
}

可以翻转判断条件使代码更简洁可读:

1
2
3
4
5
6
7
8
func do(n int) {
if n <= 12 {
return
}
sum()
subtract()
multiply()
}

更经常的使用switch

switch语句是缩短 if-else 语句序列的最佳方式,有益于编写干净的程序。程序经常需要做比较判断,如果我们的程序使用了太多的if-else,会使代码凌乱且可读性较差。所以使用switch有很大帮助。

1
2
3
4
5
6
7
8
9
10
11
12
13
func transact(bank string) {
if bank == "Citi" {
fmt.Printf("Tx #1: %s\n", bank)
} else if bank == "StandardChartered" {
fmt.Printf("Tx #2: %s\n", bank)
} else if bank == "HSBC" || bank == "Deutsche" || bank == "JPMorgan" {
fmt.Printf("Tx #3: %s\n", bank)
} else if bank == "NatWest" {
fmt.Printf("Tx #4: %s\n", bank)
} else {
fmt.Printf("Tx #E: %s\n", bank)
}
}

这看起来很乱,对吧?现在让我们改用一个switch。以下代码如何以惯用方式重写相同的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func transact(bank string) {
switch bank {
case "Citi":
fmt.Printf("Tx #1: %s\n", bank)
case "StandardChartered":
fmt.Printf("Tx #2: %s\n", bank)
case "HSBC", "Deutsche", "JPMorgan":
fmt.Printf("Tx #3: %s\n", bank)
case "NatWest":
fmt.Printf("Tx #4: %s\n", bank)
default:
fmt.Printf("Tx #E: %s\n", bank)
}
}

将来,如果添加新的银行,使用switch-case将更容易,更干净。

持续的代码重构

在大型代码库中,重构代码库结构至关重要。代码重构不时地提高代码的可读性和质量。这不是一次性的过程;团队应持续支付技术债务,以保持代码库正常。我曾学过“尽早重构,经常重构”(refactor early, refactor often),这对于编写可维护的Go代码非常有意义。随着时间的推移,包的代码数量和责任会变得越来越重,因此最好将一些包分解成更小的包,因为它们易于维护。重构包的另一个好理由是改进命名。包只包含与其功能相关的代码也是至关重要的。例如,Go把os.SEEK_SETos.SEEK_CURos.SEEK_END移动到io.SeekStartio.SeekCurrentio.SeekEnd。包io更适合于组织涉及文件I/O的代码。将包分解成小的包也会使依赖关系变得轻量级。

结论

随着时间和其他程序员在代码库上工作,我们更好地理解可维护性意味着什么。 编写可维护的代码并不复杂; 它需要每个贡献代码的人的知识、经验和仔细思考。 我们讨论的一组良好实践应该可以帮助您和团队更好地维护您的 Go 代码。

原文 Writing maintainable Go code - DeepSource Learn by Jogendra