Go 语言开发有很多技巧,网上也有很多文章介绍,不可能通过一篇文章就将这些技巧涵盖进去。有很多开发技巧,但有些不常用,即使一些看似常用的技巧,随着开发者、业务的不同,也可能不会被用到。所以,常用的开发技巧,没有一个固定的答案,但不妨碍我将我开发工作中认为常用的技巧分享给你。
本节课介绍代码类的开发技巧。这类开发技巧比较固定,而且技巧很多,为了方便你学习,我参考了网上的一些文章,并将一些重要的开发技巧罗列在本节课中。
编写优雅的 Go 应用
使用 Go 语言做项目开发,核心目的其实就是开发一个优雅的 Go 项目。那么如何开发一个优雅的 Go 项目呢?Go 项目包含三大内容,即 Go应用、项目管理、项目文档,因此开发一个优雅的 Go 项目,其实就是编写高质量的 Go 应用、高效管理项目和编写高质量的项目文档。针对每一项,我都给出了一些实现方式,这些方式详见下图:
在项目开发之前,要设计一个合理的 Go 目录结构,当前业界最流行的目录结构是 project-layout。在拆分功能的时候,要采用按功能拆分模块,这样可以有效避免循环引用。
在编写代码的时候,我们还要遵循代码规范。遵循这些现有的规范、技巧、最佳实践,可以让我们用最低的成本,来提高代码质量。
为了确保每次开发后的代码功能正确,在发布前,还需要执行完善的单元测试用例,来保证每一次的发布质量。
在编写代码时,也需要遵循一些更高维度的编程技巧,例如:采用设计模式、遵循 SOLID 原则。这些软件设计方法,可以从代码功能实现、代码逻辑等方面来提高我们的代码质量和稳定性。
在代码开发完成之后,还需要采用高效的手段来管理我们的项目,进而提高代码维护效率,解放双手。你有很多种途径可以提高项目管理效率,例如:
采用高效的开发流程;
使用 Makefile 来管理项目;
采用一些工具、对接 CI/CD 平台等方式,来提高项目的自动化程度。
开发完项目之后,你还要编写高质量的文档,这些文档可以降低日后的代码维护成本,也能进行知识宣发。
遵循符合 Go 编程哲学的代码
如何遵循符合 Go 编程哲学的代码呢?这里你首先需要知道 Go 语言的哲学,概括如下:
面向接口编程;
使用组合的编程;
正交性:语言设计的正交性,保证语言的稳定性和简单性;
少即是多:有且仅有一种方法把事情做好做对;
并发语言层面支持:并发更好利用多核,有更强的表现力来模拟真实世界;
开放性:开源,语言的实现对程序员不是个黑盒子,任何想了解语言实现的人都可以参与进来。
在开发 Go 代码时,你可以根据 Go 语言的哲学开发、优化你的代码。
代码相关
有很多跟代码相关的开发技巧,这里推荐你优先学习 Go 官方提供的一些编程技巧: CodeReviewComments 和 Effective Go。
我们尽量不要在复制时使用 append,例如,在合并两个或多个 slice 时。append 要小心自动分配内存,append 返回的可能是新分配的地址。
小的结构体,一般是指不超过 4 个字段的结构体。如果是一个大的结构体,复制时,会带来性能开销。所以,大的结构体,可以选择使用指针来传递。
我们可以根据字段的大小,以正确的顺序排列来对齐结构体,从而减小结构体本身的大小。
尽量使用第三个参数:make([]T, 0, len)
。如果你事先不知道确切的数量并且 slice 是临时的,你可以设置得大一些,只要 slice 在运行时不会增长。
初始化 Map 时,指定其大小,例如:varmap := make(map[string]string, 10)
。
如果你想实现一个无序的 Map,并利用这个 Map 来判断值是否存在,你可以使用 struct{}
来作为 Map 的 Value。因为 struct{}
不占用任何空间,从而可以减小内存开销,例如:
varmap := make(map[string]struct{}, 10)
if _, ok := varmap["a"]; ok {
fmt.Println("a is exist")
}
这是一个无标签语法的例子:
type T struct {
Foo string
Bar int
}
func main() {
t := T{"example", 123} // 无标签语法
fmt.Printf"t %+v\n", t)
}
那么,如果你添加一个新的字段到 T
结构体,代码会编译失败:
type T struct {
Foo string
Bar int
Qux string
}
func main() {
t := T{"example", 123} // 无法编译
fmt.Printf("t %+v\n", t)
}
最好的方法是,在初始化结构体时使用带有标签的语法:
type T struct {
Foo string
Bar int
Qux string
}
func main() {
t := T{Foo: "example", Qux: 123}
fmt.Printf("t %+v\n", t)
}
这个编译起来没问题,而且弹性也好。不论你如何添加其他字段到 T
结构体。你的代码总是能编译,并且在以后的 Go 的版本也可以保证这一点。你可以通过静态代码检查来检查这一点。
如果有两个以上的字段,那么就用多行。它会让你的代码更加容易阅读,也就是说不要:
T{Foo: "example", Bar:someLongVariable,
Qux:anotherLongVariable, B: forgetToAddThisToo}
而是:
T{
Foo: "example",
Bar: someLongVariable,
Qux: anotherLongVariable,
B: forgetToAddThisToo,
}
这有许多好处,首先它容易阅读,其次它使得允许或屏蔽字段初始化变得容易(只要注释或删除它们),最后添加其他字段也更容易(只要添加一行)。
如果你利用 iota
来使用自定义的整数枚举类型,务必要为其添加 String()
方法。例如:
type State intconst (
Running State = iota
Stopped
Rebooting
Terminated
)
如果你创建了这个类型的一个变量,然后输出,会得到一个整数:
type State intconst (
Running State = iota
Stopped
Rebooting
Terminated
)
除非你回顾常量定义,否则这里的 0
看起来毫无意义。只需要为 State
类型添加 String()
方法就可以修复这个问题:
func (s State) String() string {switch s {
case Running:
return "Running"case Stopped:
return "Stopped"case Rebooting:
return "Rebooting"case Terminated:
return "Terminated"default:
return "Unknown"
}
}
新的输出是:state: Running
。显然现在看起来可读性好了很多。在你调试程序的时候,这会带来更多的便利。同时还可以在实现 MarshalJSON()
、UnmarshalJSON()
这类方法的时候使用同样的手段。
在前面的例子中同时也产生了一个我已经遇到过许多次的 bug。假设你有一个新的结构体,有一个 State
字段:
type T struct {
Name string
Port int
State State
}
现在如果基于 T
创建一个新的变量,然后输出,你会得到奇怪的结果:
func main() {
t := T{Name: "example", Port: 6666}
// prints: "t {Name:example Port:6666 State:Running}"
fmt.Printf("t %+v\n", t)
}
上面这段代码,State
字段没有初始化,Go 默认使用对应类型的零值进行填充。由于 State
是一个整数,零值也就是 0
,但在我们的例子中它表示 Running
。
那么如何知道 State
被初始化了?还是它真的是在 Running
模式?没有办法区分它们,那么这就会产生未知的、不可预测的 bug。不过,修复这个很容易,只要让 iota
从 +1
开始:
const (
Running State = iota + 1
Stopped
Rebooting
Terminated
)
现在 t
变量将默认输出 Unknown
:
func main() {
t := T{Name: "example", Port: 6666}
// 输出: "t {Name:example Port:6666 State:Unknown}"
fmt.Printf("t %+v\n", t)
}
不过让 iota
从零值开始也是一种解决办法。例如,你可以引入一个新的状态叫做 Unknown
,将其修改为:
const (
Unknown State = iota
Running
Stopped
Rebooting
Terminated
)
我已经看过很多错误返回的代码,例如:
func bar() (string, error) {
v, err := foo()
if err != nil {
return "", err
}
return v, nil
}
然而,你只需要:
func bar() (string, error) {
return foo()
}
更简单也更容易阅读(当然,除非你要对某些内部的值做一些记录)。
Go 语言的字符串为不可变类型,使用 +
拼接字符串会创建一个新的对象,降低程序性能。fmt.Sprintf
底层大量使用了反射,性能上也会有所损耗。
所以,这里建议在大量字符串拼接的场景下,使用 strings.Builder
, 可以显著提升性能。
优化前:
func Before(str string, n int) string {
var s string
for i := 0; i < n; i++ {
s += str // 直接使用 + 来拼接
}
return s
}
优化后:
func After(str string, n int) string {
var buf strings.Builder
for i := 0; i < n; i++ {
buf.WriteString(str) // 使用 strings.Builder 优化拼接
}
return buf.String()
}
这里也有一个建议:从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),行内拼接字符串推荐使用运算符 +
,反之使用 fmt.Sprintf()
。
如果正则表达式是确定不变的,则可以将其定义为全局变量并预先编译,避免每次使用时现编译。
优化前:
func ParseIPv4Before(ip string) bool {
re := regexp.MustCompile((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))
return re.MatchString(ip)
}
优化后:
ruby
var re = regexp.MustCompile((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))
func ParseIPv4Before(ip string) bool {
return re.MatchString(ip)
}
其他技巧
- 不懂就问 Kubernetes
Kubernetes 是一个非常优秀的开源项目。其代码结构设计、功能、规范等方面都堪称模范。在开发中,如果我们遇到不知道怎么命名变量、返回错误信息是否大写开头、如何获取机器 IP 等问题,你都可以参考 Kubernetes 的现有实现。
类似的,你可以将你觉得比较优质的项目都保存在一个文件夹下。日后,如果你想知道某类功能的实现,或者想参考其他项目的实现,你可以直接通过 grep
的方式在这些优质的项目中进行搜索,例如:
$ ls
beer-shop gin-admin gin-vue-admin gin-web go-admin naas nirvana
[colin@dev learnproj]$ grep -R Login *
- 积累模板
在你的 Go 职业生涯中,你可能会开发很多个 Go 项目,这些项目在项目目录结构、代码架构、应用构建方式等方面可能都具有类似的实现。我们可以将这些功能实现抽象到一个模板 Go 项目中。这样,今后我们开发一个新项目,只需要拷贝模板项目,并稍作修改,就能很快搭建起一个新的项目。
另外,在实际开发中,也有很多功能需要重复用到,针对这部分功能,你可以整理,并存放在 Go 模板项目中,或者一个单独的 Go 包中,供日后参考使用,例如:user.go 文件中,List
函数中的并发逻辑,就可以抽象成一个功能模板,供日后使用。
小结
本节课分享了我工作中很有用的开发技巧,希望对你有所帮助。