到本讲为止,我们已经一同走过系列课程的三分之二了。除了基础语法部分之外,还一同攻克了函数、结构体以及接口三个专题技术点。从本讲开始,我们将一起攻克第四个专题技术点——包。

在实际开发中,我们通常会直面比课程中的示例复杂很多的业务场景。我们以往实现的各种示例几乎都是在单个Go源码文件中完成的,即使用到了结构体、接口等语法也是如此。

在这些逻辑结构较为简单的时候的确可以这么做,但如果一个结构体或者接口过于复杂,或者我们想要封装一些通用的工具库时,就要把它们分开管理。

这种“分而治之”的编码方式有益于代码的重复利用并使整个工程更具结构化,更易于日后的维护。而要实现这种结构化的编码方式,就要用到“包”的概念。

💡 提示:提到“结构化”,我推荐大家阅读《金字塔原理》。金字塔原理是一项层次性、结构化的思考、沟通技术,可以用于结构化的说话与写作过程。这一技巧在软件开发领域也很有实用价值。当开发者面对庞大的系统时,想要时刻把控全局是很难的。通常的做法是将复杂的系统拆分成为若干子系统,必要的时候还会对子系统再做拆分,在特定的时间段内只搞定单个子系统。这种结构化的方法不仅有助于增强代码的条理性,便于进行多人协同开发,还会增强整个开发团队的信心。

在一开始配置好开发环境后,我们一起编写了一个能输出“Hello World”的程序:

go
复制代码package main
import "fmt"
func main(){
    fmt.Println("Hello World!")
}

尽管那个程序非常简单,只有5行,但有些细节还是值得深入挖掘的。比如,第一行的package时什么意思,第二行的import到底做了什么……

这些问题看似互相独立,但都和一个话题有关,它就是——Go程序源码的组织结构。本讲就来带大家彻底搞清楚一个Go程序的源码是如何组织起来的。

❗️ 注意:本讲的概念比较多,请大家打起十二分的精神,争取一次就弄懂。

# 包的声明

在Go源码中,package的意思就是包,后面跟着的就是包名。Go语言通过包来组织源码,拥有相同包名的Go源码属于同一个包。反过来,一个包内通常会包含一个或多个Go源码文件。“封装”和“复用”等就可以用包来实现。

在Hello World的源码中,第一行的内容是:

go
复制代码package main

这句话就表示这个源码属于main包。Go语言有一个强制性要求,就是源码文件的第一行有效代码必须声明自己所在的包

需要特别指出的是:main包是一个比较特殊的包。一个Go程序必须有main包,且只能有一个main包。

# 包的导入

和声明相对的,是导入。用通俗的话讲,包的声明就是要告诉大家:“我属于哪个包”;包的导入就是要提出要求:“我想要使用哪个包”

在Hello World示例中,第2行代码就是在做包的导入,具体如下:

go
复制代码import "fmt"

这句代码的意思就是说要导入名为“fmt”的包。细心的朋友可能会问:“这个fmt的包在哪儿呢?”fmt是Go SDK的众多内置包之一,当我们在装Go SDK的时候,fmt包就一并安装进来了。如果使用GoLand,可以在Project视图中找到fmt包的源码。这些源码文件位于Go SDK的安装路径下。

image.png

我们使用过无数次的fmt.Println()函数就位于fmt包里的print.go源码文件中。

image.png

正因为是内置包,在导入时只需指定包名就可以了。如果要导入自定义的包,则需要按照一定的规则来进行。具体的内容我们在下一讲详述。

❗️ 注意:Go语言在导入包方面要求较为严格,在使用GoLand时,如果某个包被导入却没有使用,将会出现错误提示。

# main()函数

从示例的第3行开始到最后,都是main()函数了:

go
复制代码func main(){
    fmt.Println("Hello World!")
}

在Go语言中,main()函数是程序的入口函数,它位于main包中。如果想要编译生成可执行文件,main()函数则是必须的。如果将示例代码中的main()函数去掉直接编译,可以看到控制台会输出如下错误:

runtime.main_main·f: function main is undeclared in the main package

大意就是说main()函数没有在main包中声明。

我们还可以看到在main()函数中可以调用fmt包中的函数,这正是由于我们导入了fmt包才能做到的。

# Go源码的启动流程

我们都知道,main()函数是Go程序的入口函数。实际上,Go程序还有一个init()函数,被称为“初始化”函数。我们来看下面这段代码:

go
复制代码package main
import "fmt"
func init() {
   fmt.Println("Hello")
}
func main() {
   fmt.Println("World")
}

上述代码运行后,控制台将输出:

Hello

World

发现规律了吗?没错,init()函数在main()函数之前执行,经常做一些程序初始化的工作,因此它被称为初始化函数。

对于一个较为复杂的软件代码而言,通常会按照前面介绍过的“分而治之”的编码方式进行开发。特别是在多人协同开发场景中,由于每个人负责的功能模块不同,通常会将一个完整的软件产品代码分为多个包。一旦Go程序开始运行,main包中的代码便会首先得到执行,所有导入的包会执行其中的init()初始化函数。

下图较为清晰地描述了Go源码的启动过程:

image.png

我们从左上角的开始处分析这张图,可以发现Go源码的启动流程是这样的:

  1. 程序开始运行后,首先来到main包,检索所有导入的包。发现代码中导入了A包,于是来到A包;
  2. 发现A包代码中导入了B包,于是又来到B包;
  3. B包代码没有导入任何其它的包,于是开始声明B包内的常量和变量,并执行B包中的init()函数;
  4. 回到A包,进行A包内的常量和变量的声明,并执行A包中的init()函数;
  5. 回到main包,执行main包内的常量和变量的声明,并执行main包中的init()函数;
  6. 执行main包中的main()函数。
💡 提示:了解Go源码的启动加载过程,有助于编写更高效率的代码,排查程序启动缓慢等性能问题。

# 总结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  • Go程序源码的组织结构

本讲是包系列的第一篇,介绍了包存在的意义。

包提供了一种更好的组织源码文件的方式,在进行团队开发或区分功能模块时特别有用。

接着,我们回到最初的原点,从包的视角再次剖析Hello World源码,理解了包的声明和导入。当然,我们会在下一讲中更详细地讲解如何声明和导入自定义包,包括命名规范等等。

本讲的最后,用流程图向大家演示了一个Go源码的运行加载过程。明确这一过程有助于排查启动时的性能问题、编写启动更快的代码。

好了,本讲就到这里。

➡️ 在下次课程中,我们会介绍Go语言中包的更多知识,具体内容是:

  • 自定义包的声明与导入