工程目录结构
如果熟悉Unix下的C语言编程,我们知道编译一个C工程(通常使用Makefile构建)需要为编译器和链接器指定源码路径、库文件、头文件、输出可执行文件等路径信息,因此如何组织工程的目录结构是一门学问。Go语言也是类似的,不过GoSDK为我们做了很多简化操作的约定机制和命令行工具,这篇笔记我们介绍Go语言工程的包机制、模块化和推荐的工程目录结构。
开始之前的一些说明
Go语言的GOPATH模块机制最初极为简陋,但在1.13和1.18有两次较大的更新,分别引入了Go Modules模块管理机制和Go Workspace工作区模式,对整个工程目录的组织逻辑有较大的改动,这也造成了网上大多数教程介绍的内容极为割裂与令人费解。不过无论如何,底层原理都是一样的,这篇笔记我们直接站在最新版本的Go Modules基础上进行介绍。
创建模块工程例子
我们这里直接以一个例子工程说明Go模块和源码的组织目录结构。
初始化工程
我们首先创建一个名为demogo
的文件夹,作为我们新开发的一个工程目录,并在其中执行如下命令:
go mod init demogo
执行成功后,工程目录中会自动生成一个go.mod
文件,它用于记录该工程的名字、GoSDK版本和依赖库(作用类似于npm的package.json
或是maven的pom.xml
)。我们可以查看其中的内容:
module demogo
go 1.19
有关go.mod
将在后文详细介绍。
添加源码和源码包
这里我们创建了1个main.go
以及1个package
文件夹起名mylib
,bin
目录目前还是一个空文件夹,我们打算在其中存放编译输出的可执行文件。
demogo
├── bin
└── mylib
└── func.go
├── main.go
├── go.mod
main.go
package main
import "demogo/mylib"
func main() {
mylib.Foo()
}
func.go
package mylib
import "fmt"
func Foo() {
fmt.Printf("func Foo\n")
}
观察代码我们可以发现,main.go
中声明的是main
包,这是Go中一个特殊的包名,main
包下是可执行文件的入口函数;而mylib
文件夹下的包名就是mylib
,非main
包文件夹名和包名相同,这是一种约定。
编译运行
在demogo
文件夹下执行如下命令进行编译:
go build -o /root/demogo/bin/main .
其中-o
参数指定了编译的输出目录,我们这里将其输出到我们工程的bin
目录中;最后一个参数.
是编译的可执行程序入口文件所在的目录(其实可以不加该参数,因为其默认值就是当前目录)。
注意:这里如果不指定-o
参数,编译结果默认会输出到当前目录下。
执行程序:
./bin/main
此时我们已经搭建、编译运行了一个最简单的Go工程模块。
package包机制
上面例子工程中的mylib
,我们其实就用到了源码包的概念,包package
是Go语言中组织源代码的一个基本单元,一个源代码文件必须属于某个package
,并在源码文件中声明。同一个文件夹下的所有.go
文件都需要属于同一个包。
main
是一个特殊的包名,可执行程序的入口main
函数必须位于main
包;其余自定义的包名约定与所处文件夹名相同(这个约定可以不遵守,但纯属没事找事),同一目录下的所有.go
源码文件只能存在一个包名的声明。
导入包使用import
关键字,注意import
后面的参数是包的文件夹路径,而具体调用import
的内容时使用的才是包名,尽管这俩名通常约定是相同的。
Go编译器在编译代码时,会自动寻找所需的package
,包括当前工程中的包和依赖的第三方包。
包init函数
Go语言有一个有趣的特性,它支持为一个包(package)指定初始化函数,我们可以在包初始化函数中编写一些统一的初始化逻辑,下面是一个例子。
package mylib
import "fmt"
func init() {
fmt.Println("package initialized")
}
func Foo() {
fmt.Printf("func Foo\n")
}
代码中,我们为mylib
包添加了初始化函数init()
,这个函数的调用时机是当使用了import
语句引入包时,编译器就会安排它在程序的main()
函数之前执行。
实际上一个包可以有任意个init()
函数,不过它们的执行顺序是不确定的,通常来说我们都是建一个init()
函数即可。此外init()
函数只会执行一次,所有的初始化函数都会在程序入口main()
函数之前执行。
Go Modules模块机制
Go1.13版本推出了模块化机制,现在新建工程都需要采用Go Modules模式。上面创建的工程中,我们使用了一个go.mod
文件,它和npm的package.json
类似,其实就是一个模块工程的描述文件,其中会声明当前工程的模块名,以及该模块依赖的其它模块信息。
引入开源第三方依赖
如果我们的工程依赖某些开源库或是框架,那么自然就需要在go.mod
中进行依赖的声明。Go官方维护了一个模块的索引网站,一些开源的模块可以在https://pkg.go.dev/中搜索。
Go语言的模块管理机制一个比较奇葩的特性是其依赖于Git仓库,模块分发采用源码方式而非二进制库,安装第三方模块时会直接从源码仓库中拉取。下面例子中我们引入一个依赖:
go get github.com/labstack/echo
拉取成功后会在项目路径下的go.mod
中写入相关依赖信息,此外还会生成一个go.sum
文件,它用于锁定依赖版本,作用类似于npm的package-lock.json
,我们需要将其提交到版本控制系统中。拉取的源码实际上不在我们的工程中,而是在$GOPATH/pkg/mod
目录下。
注:
- 如果要指定版本,可以通过Git仓库的Tag或分支来指定,使用形如
go get github.com/demoproject/demo@v1.0
的形式。 echo
是一个简易的HTTP服务框架,引入它没有什么特别的意义,这里我们仅用于演示Go的模块管理机制。
此时我们可以尝试在自己的工程源码中引入第三方依赖:
main.go
package main
import (
"net/http"
"github.com/labstack/echo"
)
func main() {
e := echo.New()
e.GET("/demogo", func(ctx echo.Context) error {
return ctx.String(http.StatusOK, "Hello, world!")
})
e.Start(":8080")
}
注意这里import
语句的写法,对于我们自己的工程内的代码包,路径是以当前模块名开头的,例如之前我们编写的import demogo/mylib
;而使用go get
安装的第三方模块,则是以$GOPATH/pkg/mod
开始搜索,import github.com/labstack/echo
的完整路径是$GOPATH/pkg/mod/github.com/labstack/echo
。
编译运行:
go build -o bin/main
上面提到过,Go模块都是以源码模式分发的,因此这里编译时编译器会自动查找当前工程和$GOPATH/pkg/mod
内相关的目录,并将所有源码静态编译成一个可执行文件。
使用GOPROXY代理
这里需要提一下的是Github在国内处于封禁状态,通常直接执行上述命令是无法成功拉取模块源码的,我们还需要设置国内的GOPROXY
:
go env -w GOPROXY=https://proxy.golang.com.cn,direct
注:如果GOPROXY
不稳定也可能导致拉取失败,此时就没有什么太好的办法了,如果解决不了建议直接放弃学习。
添加私有仓库依赖
首先要说明的一点是Go语言最初在工程实践中推荐Mono Repo,也就是单代码仓库多模块工程,那么理论上此时也不需要什么私有仓库,当然这肯定不符合实际情况,后来也做出了妥协,加入了更多对Multi Repo的支持。
如果是企业内部项目开发,较大型的项目很可能位于多个代码仓库中,此时使用go get
命令拉取依赖代码时会报错,例如下图。
此时我们可以将私有Git仓库的地址加入GOPRIVATE
环境变量,就能够正常拉取了。
go env -w GOPRIVATE=gitee.com
编写私有模块工程
如果我们需要编写一个工程,供其它工程作为模块引入,在模块命名上最好按照约定的规则编写。
go.mod
module gitee.com/gacfox/myfunc
go 1.19
上面代码中,我们指定了模块名为gitee.com/gacfox/myfunc
,其格式<域名>/<用户名>/<模块名>
其实就是一个Git仓库的地址。按照这种约定进行命名,在另外一个工程内使用go get
拉取代码后,声明的模块名和引入的模块名就能够直接对上,不需要进行额外的配置。