You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

04初窥门径一个Go程序的结构是怎样的

你好我是Tony Bai。

经过上一讲的学习我想现在你已经成功安装好至少一个Go开发环境了是时候撸起袖子开始写Go代码了

程序员这个历史并不算悠久的行当却有着一个历史悠久的传统那就是每种编程语言都将一个名为“hello, world”的示例作为这门语言学习的第一个例子这个传统始于20世纪70年代那本大名鼎鼎的由布莱恩·科尼根Brian W. Kernighan与C语言之父丹尼斯·里奇Dennis M. Ritchie合著的《C程序设计语言》。

图片

在这一讲中我们也将遵从传统从编写一个可以打印出“hello, world”的Go示例程序开始我们正式的Go编码之旅。我希望通过这个示例程序你能够对Go程序结构有一个直观且清晰的认识。

在正式开始之前我要说明一下我们这节课对你开发Go程序时所使用的编辑器工具没有任何具体的要求。

如果你喜欢使用某个集成开发环境Integrated Development EnvironmentIDE那么就用你喜欢的IDE好了。如果你希望我给你推荐一些好用的IDE我建议你试试GoLandVisual Studio Code简称VS Code。GoLand是知名IDE出品公司JetBrains针对Go语言推出的IDE产品也是目前市面上最好用的Go IDEVS Code则是微软开源的跨语言源码编辑器通过集成语言插件Go开发者可以使用Go官方维护的vscode-go插件可以让它变成类IDE的工具。

如果你有黑客情怀喜欢像黑客一样优雅高效地使用命令行那么像Vim、Emacs这样的基于终端的编辑器同样可以用于编写Go源码。以Vim为例结合vim-gococ.nvim代码补全以及Go官方维护的gopls语言服务器你在编写Go代码时同样可以体会到“飞一般”的感觉。但在我们这门课中我们将尽量使用与编辑器或IDE无关的说明。

好,我们正式开始吧。

创建“helloworld”示例程序

在Go语言中编写一个可以打印出“helloworld”的示例程序我们只需要简单两步一是创建文件夹二是开始编写和运行。首先我们来创建一个文件夹存储编写的Go代码。

创建“helloworld”文件夹

通常来说Go不会限制我们存储代码的位置Go 1.11之前的版本另当别论)。但是针对我们这门课里的各种练习和项目,我还是建议你创建一个可以集合所有项目的根文件夹(比如:~/goprojects然后将我们这门课中所有的项目都放在里面。

现在你可以打开终端并输入相应命令来创建我们用于储存“helloworld”示例的文件夹helloworld了。对于Linux系统、macOS系统以及Windows系统的PowerShell终端来说用下面这个命令就可以建立helloworld文件夹了

$mkdir ~/goprojects // 创建一个可以集合所有专栏项目的根文件夹
$cd ~/goprojects
$mkdir helloworld // 创建存储helloworld示例的文件夹
$cd helloworld

建好文件夹后我们就要开始编写我们第一个Go程序了。

编写并运行第一个Go程序

首先我们需要创建一个名为main.go的源文件。

这里我需要跟你啰嗦一下Go的命名规则。Go源文件总是用全小写字母形式的短小单词命名并且以.go扩展名结尾。

如果要在源文件的名字中使用多个单词我们通常直接是将多个单词连接起来作为源文件名而不是使用其他分隔符比如下划线。也就是说我们通常使用helloworld.go作为文件名而不是hello_world.go。

这是因为下划线这种分隔符在Go源文件命名中有特殊作用这个我们会在以后的讲解中详细说明。总的来说我们尽量不要用两个以上的单词组合作为文件名否则就很难分辨了。

现在你可以打开刚刚创建的main.go文件键入下面这些代码

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}

写完后我们保存文件并回到终端窗口然后在Linux或macOS系统中你就可以通过输入下面这个命令来编译和运行这个文件了

$go build main.go
$./main
hello, world

如果是在Windows系统中呢你需要把上面命令中的./main替换为.\main.exe。

>go build main.go
>.\main.exe
hello, world

不过无论你使用哪种操作系统到这里你都应该能看到终端输出的“hello, world”字符串了。如果你没有看到这个输出结果要么是Go安装过程的问题要么是源文件编辑出现了问题需要你再次认真地确认。如果一切顺利那么恭喜你你已经完成了第一个Go程序并正式成为了Go开发者欢迎来到Go语言的世界

“helloworld”示例程序的结构

现在让我们回过头来仔细看看“helloworld”示例程序中到底发生了什么。第一个值得注意的部分是这个

package main

这一行代码定义了Go中的一个包package。包是Go语言的基本组成单元通常使用单个的小写单词命名一个Go程序本质上就是一组包的集合。所有Go代码都有自己隶属的包在这里我们的“helloworld”示例的所有代码都在一个名为main的包中。main包在Go中是一个特殊的包整个Go程序中仅允许存在一个名为main的包

main包中的主要代码是一个名为main的函数

func main() {
    fmt.Println("hello, world")
}

**这里的main函数会比较特殊当你运行一个可执行的Go程序的时候所有的代码都会从这个入口函数开始运行。**这段代码的第一行声明了一个名为main的、没有任何参数和返回值的函数。如果某天你需要给函数声明参数的话那么就必须把它们放置在圆括号()中。

另外,那对花括号{}被用来标记函数体Go要求所有的函数体都要被花括号包裹起来。按照惯例我们推荐把左花括号与函数声明置于同一行并以空格分隔。Go语言内置了一套Go社区约定俗称的代码风格并随安装包提供了一个名为Gofmt的工具这个工具可以帮助你将代码自动格式化为约定的风格。

Gofmt是Go语言在解决规模化scale问题上的一个最佳实践并成为了Go语言吸引其他语言开发者的一大卖点。很多其他主流语言也在效仿Go语言推出自己的format工具比如Java formatter、Clang formatter、Dartfmt等。因此作为Go开发人员请在提交你的代码前使用Gofmt格式化你的Go源码。

回到正题我们再来看一看main函数体中的代码

fmt.Println("hello, world")

这一行代码已经完成了整个示例程序的所有工作了将字符串输出到终端的标准输出stdout上。不过这里还有几个需要你注意的细节。

注意点1标准Go代码风格使用Tab而不是空格来实现缩进的当然这个代码风格的格式化工作也可以交由gofmt完成。

**注意点2**我们调用了一个名为Println的函数这个函数位于Go标准库的fmt包中。为了在我们的示例程序中使用fmt包定义的Println函数我们其实做了两步操作。

第一步是在源文件的开始处通过import声明导入fmt包的包路径

import "fmt"

第二步则是在main函数体中通过fmt这个限定标识符Qualified Identifier调用Println函数。虽然两处都使用了“fmt”这个字面值但在这两处“fmt”字面值所代表的含义却是不一样的

  • import “fmt” 一行中“fmt”代表的是包的导入路径Import它表示的是标准库下的fmt目录整个import声明语句的含义是导入标准库fmt目录下的包
  • fmt.Println函数调用一行中的“fmt”代表的则是包名。

通常导入路径的最后一个分段名与包名是相同的这也很容易让人误解import声明语句中的“fmt”指的是包名其实并不是这样的。

main函数体中之所以可以调用fmt包的Println函数还有最后一个原因那就是Println函数名的首字母是大写的。在Go语言中只有首字母为大写的标识符才是导出的Exported才能对包外的代码可见如果首字母是小写的那么就说明这个标识符仅限于在声明它的包内可见。

另外在Go语言中main包是不可以像标准库fmt包那样被导入Import如果导入main包在代码编译阶段你会收到一个Go编译器错误import “xx/main” is a program, not an importable package。

**注意点3**我们还是回到main函数体实现上把关注点放在传入到Println函数的字符串“hello, world”上面。你会发现我们传入的字符串也就是我们执行程序后在终端的标准输出上看到的字符串

这种“所见即所得”得益于Go源码文件本身采用的是Unicode字符集而且用的是UTF-8标准的字符编码方式这与编译后的程序所运行的环境所使用的字符集和字符编码方式是一致的。

这里,即便我们将代码中的"hello, world"换成中文字符串“你好,世界”,像下面这样:

package main

import "fmt"

func main() {
    fmt.Println("你好,世界")
}

我们依旧可以在终端的标准输出上看到正确的输出。

最后不知道你有没有发现我们整个示例程序源码中都没有使用过分号来标识语句的结束这与C、C++、Java那些传统编译型语言好像不太一样呀

不过其实Go语言的正式语法规范是使用分号“;”来做结尾标识符的。那为什么我们很少在Go代码中使用和看到分号呢这是因为大多数分号都是可选的常常被省略不过在源码编译时Go编译器会自动插入这些被省略的分号。

我们给上面的“helloworld”示例程序加上分号也是完全合法的是可以直接通过Go编译器编译并正常运行的。不过gofmt在按约定格式化代码时会自动删除这些被我们手工加入的分号的。

在分析完这段代码结构后我们来讲一下Go语言的编译。虽然刚刚你应该已经运行过“hello, world”这个示例程序了在这过程中有一个重要的步骤——编译现在我就带你来看看Go语言中程序是怎么进行编译的。

Go语言中程序是怎么编译的

你应该也注意到了,刚刚我在运行"hello, world"程序之前输入了go build命令还有它附带的源文件名参数来编译它

$go build main.go

假如你曾经有过C/C++语言的开发背景那么你就会发现这个步骤与gcc或clang编译十分相似。一旦编译成功我们就会获得一个二进制的可执行文件。在Linux系统、macOS系统以及Windows系统的PowerShell中我们可以通过输入下面这个ls命令看到刚刚生成的可执行文件

$ls
main*		main.go

上面显示的文件里面有我们刚刚创建的、以.go为后缀的源代码文件还有刚生成的可执行文件Windows系统下为main.exe其余系统下为main

如果你之前更熟悉某种类似于Ruby、Python或JavaScript之类的动态语言你可能还不太习惯在运行之前需要先进行编译的情况。Go是一种编译型语言这意味着只有你编译完Go程序之后才可以将生成的可执行文件交付于其他人并运行在没有安装Go的环境中。

而如果你交付给其他人的是一份.rb、.py或.js的动态语言的源文件那么他们的目标环境中就必须要拥有对应的Ruby、Python或JavaScript实现才能解释执行这些源文件。

当然Go也借鉴了动态语言的一些对开发者体验较好的特性比如基于源码文件的直接执行Go提供了run命令可以直接运行Go源码文件比如我们也可以使用下面命令直接基于main.go运行

$go run main.go
hello, world

当然像go run这类命令更多用于开发调试阶段真正的交付成果还是需要使用go build命令构建的。

但是在我们的生产环境里Go程序的编译往往不会像我们前面基于单个Go源文件构建类似“helloworld”这样的示例程序那么简单。越贴近真实的生产环境也就意味着项目规模越大、协同人员越多项目的依赖和依赖的版本都会变得复杂。

**那在我们更复杂的生产环境中go build命令也能圆满完成我们的编译任务吗**我们现在就来探讨一下。

复杂项目下Go程序的编译是怎样的

我们还是直接上项目吧给go build 一个机会,看看它的复杂依赖管理到底怎么样。

现在我们创建一个新项目“hellomodule”在新项目中我们将使用两个第三方库zap和fasthttp给go build的构建过程增加一些难度。和“helloworld”示例一样我们通过下面命令创建“hellomodule”项目

$cd ~/goprojects
$mkdir hellomodule
$cd hellomodule

接着我们在“hellomodule“下创建并编辑我们的示例源码文件

package main

import (
	"github.com/valyala/fasthttp"
	"go.uber.org/zap"
)

var logger *zap.Logger

func init() {
	logger, _ = zap.NewProduction()
}

func fastHTTPHandler(ctx *fasthttp.RequestCtx) {
	logger.Info("hello, go module", zap.ByteString("uri", ctx.RequestURI()))
}

func main() {
	fasthttp.ListenAndServe(":8081", fastHTTPHandler)
}

这个示例创建了一个在8081端口监听的http服务当我们向它发起请求后这个服务会在终端标准输出上输出一段访问日志。

你会看到和“helloworld“相比这个示例显然要复杂许多。但不用担心你现在大可不必知道每行代码的功用你只需要我们在这个稍微有点复杂的示例中引入了两个第三方依赖库zap和fasthttp就可以了。

我们尝试一下使用编译“helloworld”的方法来编译“hellomodule”中的main.go源文件go编译器的输出结果是这样的

$go build main.go
main.go:4:2: no required module provides package github.com/valyala/fasthttp: go.mod file not found in current directory or any parent directory; see 'go help modules'
main.go:5:2: no required module provides package go.uber.org/zap: go.mod file not found in current directory or any parent directory; see 'go help modules'

看这结果这回我们运气似乎不佳main.go的编译失败了

从编译器的输出来看go build似乎在找一个名为go.mod的文件来解决程序对第三方包的依赖决策问题。

好了我们也不打哑谜了是时候让Go module登场了

Go module构建模式是在Go 1.11版本正式引入的为的是彻底解决Go项目复杂版本依赖的问题在Go 1.16版本中Go module已经成为了Go默认的包依赖管理机制和Go源码构建机制。

Go Module的核心是一个名为go.mod的文件在这个文件中存储了这个module对第三方依赖的全部信息。接下来我们就通过下面命令为“hellomodule”这个示例程序添加go.mod文件

$go mod init github.com/bigwhite/hellomodule
go: creating new go.mod: module github.com/bigwhite/hellomodule
go: to add module requirements and sums:
	go mod tidy

你会看到go mod init命令的执行结果是在当前目录下生成了一个go.mod文件

$cat go.mod
module github.com/bigwhite/hellomodule

go 1.16

其实一个module就是一个包的集合这些包和module一起打版本、发布和分发。go.mod所在的目录被我们称为它声明的module的根目录。

不过呢这个时候的go.mod文件内容还比较简单第一行内容是用于声明module路径module path的。而且module隐含了一个命名空间的概念module下每个包的导入路径都是由module path和包所在子目录的名字结合在一起构成。

比如如果hellomodule下有子目录pkg/pkg1那么pkg1下面的包的导入路径就是由module pathgithub.com/bigwhite/hellomodule和包所在子目录的名字pkg/pkg1结合而成也就是github.com/bigwhite/hellomodule/pkg/pkg1。

另外go.mod的最后一行是一个Go版本指示符用于表示这个module是在某个特定的Go版本的module语义的基础上编写的。

有了go.mod后是不是我们就可以构建hellomodule示例了呢

来试试看我们执行一下构建Go编译器输出结果是这样的

$go build main.go
main.go:4:2: no required module provides package github.com/valyala/fasthttp; to add it:
	go get github.com/valyala/fasthttp
main.go:5:2: no required module provides package go.uber.org/zap; to add it:
	go get go.uber.org/zap

你会看到Go编译器提示源码依赖fasthttp和zap两个第三方包但是go.mod中没有这两个包的版本信息我们需要按提示手工添加信息到go.mod中。

这个时候除了按提示手动添加外我们也可以使用go mod tidy命令让Go工具自动添加

$go mod tidy       
go: downloading go.uber.org/zap v1.18.1
go: downloading github.com/valyala/fasthttp v1.28.0
go: downloading github.com/andybalholm/brotli v1.0.2
... ...

从输出结果中我们看到Go工具不仅下载并添加了hellomodule直接依赖的zap和fasthttp包的信息还下载了这两个包的相关依赖包。go mod tidy执行后我们go.mod的最新内容变成了这个样子

module github.com/bigwhite/hellomodule

go 1.16

require (
	github.com/valyala/fasthttp v1.28.0
	go.uber.org/zap v1.18.1
)

这个时候go.mod已经记录了hellomodule直接依赖的包的信息。不仅如此hellomodule目录下还多了一个名为go.sum的文件这个文件记录了hellomodule的直接依赖和间接依赖包的相关版本的hash值用来校验本地包的真实性。在构建的时候如果本地依赖包的hash值与go.sum文件中记录的不一致就会被拒绝构建。

有了go.mod以及hellomodule依赖的包版本信息后我们再来执行构建

$go build main.go
$ls
go.mod		go.sum		main*		main.go

这次我们成功构建出了可执行文件main运行这个文件新开一个终端窗口在新窗口中使用curl命令访问该http服务curl localhost:8081/foo/bar我们就会看到服务端输出如下日志

$./main
{"level":"info","ts":1626614126.9899719,"caller":"hellomodule/main.go:15","msg":"hello, go module","uri":"/foo/bar"}

这下,我们的“ hellomodule”程序可算创建成功了。我们也看到使用Go Module的构建模式go build完全可以承担其构建规模较大、依赖复杂的Go项目的重任。还有更多关于Go Module的内容我会在第7节课再详细跟你讲解。

小结

到这里我们终于亲手编写完成了Go语言的第一个程序“hello, world”我们终于知道一个Go程序长成啥样子了这让我们在自己的Go旅程上迈出了坚实的一步

在这一节课里我们通过helloworld示例程序了解了一个Go程序的源码结构与代码风格自动格式化的约定。

我希望你记住这几个要点:

  • Go包是Go语言的基本组成单元。一个Go程序就是一组包的集合所有Go代码都位于包中
  • Go源码可以导入其他Go包并使用其中的导出语法元素包括类型、变量、函数、方法等而且main函数是整个Go应用的入口函数
  • Go源码需要先编译再分发和运行。如果是单Go源文件的情况我们可以直接使用go build命令+Go源文件名的方式编译。不过对于复杂的Go项目我们需要在Go Module的帮助下完成项目的构建。

最后我们结合hellomodule示例初步学习了一个基于Go Module构建模式编写和构建更大规模Go程序的步骤并介绍了Go Module涉及到的各种概念。而且Go Module机制日渐成熟我希望你学会基于Go Module构建Go应用。关于Go Module构建模式我们还会在后面的讲解中详细介绍。

思考题

今天我给你留了一道思考题经过今天这节课你喜欢Go统一的代码风格吗你觉得Go这么做的利弊都有哪些呢欢迎在留言区和我探讨。

欢迎你把这节课分享给更多对Go语言学习感兴趣的朋友。我是Tony Bai我们下节课见。