一个好的目录结构至少要满足以下几个要求。
根据功能,我们可以将目录结构分为结构化目录结构和平铺式目录结构两种。
Go
应用中,相对来说比较复杂;Go
包中,相对来说比较简单;一个 Go
项目可以是一个应用,也可以是一个代码框架 / 库,当项目是代码框架 / 库时,比较适合采用平铺式目录结构。
平铺方式就是在项目的根目录下存放项目的代码,整个目录结构看起来更像是一层的,例如 log
包 github.com/golang/glog
就是平铺式的,目录如下:
$ ls glog/
glog_file.go glog.go glog_test.go LICENSE README
当前 Go
社区比较推荐的结构化目录结构是 https://github.com/golang-standards/project-layout。虽然它并不是官方和社区的规范,但因为组织方式比较合理,被很多 Go
开发人员接受。
├── api
├── assets
├── build
├── cmd
├── configs
├── deployments
├── docs
├── examples
├── githooks
├── go.mod
├── init
├── internal
├── LICENSE.md
├── Makefile
├── pkg
├── README_zh-CN.md
├── scripts
├── test
├── third_party
├── tools
├── vendor
├── web
└── website
一个 Go
项目包含 3 大部分:Go
应用 、项目管理和文档。所以,我们的项目目录也可以分为这 3 大类。同时,Go
应用又贯穿开发阶段、测试阶段和部署阶段,相应的应用类的目录,又可以按开发流程分为更小的子类。所以整体来看,我们的目录结构可以按下图所示的方式来分类:
开发的代码包含前端代码和后端代码,可以分别存放在前端目录和后端目录中。
前端代码存放目录,主要用来存放 Web
静态资源,服务端模板和单页应用(SPAs
)。
一个项目有很多组件,可以把组件 main
函数所在的文件夹统一放在 /cmd
目录下,例如:
$ ls cmd/
gendocs geniamdocs genman genyaml apiserver iamctl iam-pump
$ ls cmd/apiserver/
apiserver.go
这里要保证 /cmd/<组件名>
目录下不要存放太多的代码,如果你认为代码可以导入并在其他项目中使用,那么它应该位于 /pkg
目录中。如果代码不是可重用的,或者你不希望其他人重用它,请将该代码放到 /internal
目录中。
存放私有应用和库代码。如果一些代码,你不希望在其他应用和库中被导入,可以将这部分代码放在/internal
目录下。
在引入其它项目 internal
下的包时,Go
语言会在编译时报错:
An import of a path containing the element “internal” is disallowed
if the importing code is outside the tree rooted at the parent of the
"internal" directory.
如果 internal
目录下直接存放每个组件的源码目录(一个项目可以由一个或多个组件组成),当项目变大、组件增多时,可以将新增加的组件代码存放到 internal
目录,这时 internal
目录就是可扩展的。例如:
$ ls internal/
apiserver authzserver iamctl pkg pump watcher
/internal
目录建议包含如下目录:
/internal/apiserver
:该目录中存放真实的应用代码。这些应用的共享代码存放在 /internal/pkg
目录下。/internal/pkg
:存放项目内可共享,项目外不共享的包。这些包提供了比较基础、通用的功能,例如工具、错误码、用户验证等功能。建议是,一开始将所有的共享代码存放在 /internal/pkg
目录下,当该共享代码做好了对外开发的准备后,再转存到 /pkg
目录下。
放入 internal
的包从 go
机制上就无法被外部引用。
该目录中存放可以被外部应用使用的代码库,其他项目可以直接通过 import
导入这里的代码。所以,我们在将代码库放入该目录时一定要慎重。
项目依赖,可通过 go mod vendor
创建。需要注意的是,如果是一个 Go
库,不要提交 vendor
依赖包。vendor
就是把依赖的代码 clone
一份,放在 vendor
中,这样构建时,go
编译器会使用 vendor
中的依赖,而不是到网上去下载,也不会使用本地 module cache
中的依赖。
外部帮助工具,分支代码或其他第三方应用(例如 Swagger UI)。比如我们 fork
了一个第三方 go
包,并做了一些小的改动,我们可以放在目录 /third_party/forked
下。一方面可以很清楚的知道该包是 fork
第三方的,另一方面又能够方便地和 upstream
同步。
用于存放其他外部测试应用和测试数据。/test
目录的构建方式比较灵活:对于大的项目,有一个数据子目录是有意义的。例如,如果需要 Go
忽略该目录中的内容,可以使用 /test/data
或 /test/testdata
目录。
需要注意的是,Go
也会忽略以 .
或 _
开头的目录或文件。这样在命名测试数据目录方面,可以具有更大的灵活性。
这个目录用来配置文件模板或默认配置。例如,可以在这里存放 confd
或 consul-template
模板文件。这里有一点要注意,配置中不能携带敏感信息,这些敏感信息,我们可以用占位符来替代,例如:
apiVersion: v1
user:
username: ${CONFIG_USER_USERNAME} # iam 用户名
password: ${CONFIG_USER_PASSWORD} # iam 密码
用来存放 Iaas
、PaaS
系统和容器编排部署配置和模板(Docker-Compose
,Kubernetes/Helm
,Mesos
,Terraform
,Bosh
)。在一些项目,特别是用 Kubernetes
部署的项目中,这个目录可能命名为 deploy
。
存放初始化系统(systemd
,upstart
,sysv
)和进程管理配置文件(runit
,supervisord
)。比如 sysemd
的 unit
文件。这类文件,在非容器化部署的项目中会用到。
一个 Go
项目在其根目录下应该有一个 Makefile
工具,用来对项目进行管理,Makefile
通常用来执行静态代码检查、单元测试、编译等功能。其他常见功能:
lint
):推荐用 golangci-lint
。test
):运行 go test ./...
。build
):编译源码,支持不同的平台,不同的 CPU
架构。image/image.push
):现在的系统比较推荐用 Docker/Kubernetes
进行部署,所以一般也要有镜像构建功能。clean
):清理临时文件或者编译后的产物。gen
):比如要编译生成 protobuf pb.go
文件。deploy
,可选):一键部署功能,方便测试。release
):发布功能,比如:发布到 Docker Hub
、github
等。help
):告诉 Makefile
有哪些功能,如何执行这些功能。add-copyright
):如果是开源项目,可能需要在每个文件中添加版权头,这可以通过 Makefile
来添加。swagger
):如果使用 swagger
来生成 API
文档,这可以通过 Makefile
来生成。建议:直接执行 make
时,执行如下各项 format -> lint -> test -> build
,如果是有代码生成的操作,还可能需要首先生成代码 gen -> format -> lint -> test -> build
。
该目录主要用来存放脚本文件,实现构建、安装、分析等不同功能。不同项目,里面可能存放不同的文件,但通常可以考虑包含以下 3 个目录:
/scripts/make-rules
:用来存放 makefile
文件,实现 /Makefile
文件中的各个功能。Makefile
有很多功能,为了保持它的简洁,我建议你将各个功能的具体实现放在 /scripts/make-rules
文件夹下/scripts/lib
:shell
库,用来存放 shell
脚本。一个大型项目中有很多自动化任务,比如发布、更新文档、生成代码等,所以要写很多 shell
脚本,这些 shell
脚本会有一些通用功能,可以抽象成库,存放在 /scripts/lib
目录下,比如 logging.sh
,util.sh
等。/scripts/install
:如果项目支持自动化部署,可以将自动化部署脚本放在此目录下。如果部署脚本简单,也可以直接放在 /scripts
目录下。另外,shell
脚本中的函数名,建议采用语义化的命名方式,例如 iam::log::info
这种语义化的命名方式,可以使调用者轻松的辨别出函数的功能类别,便于函数的管理和引用。
这里存放安装包和持续集成相关的文件。这个目录下有 3 个大概率会使用到的目录,在设计目录结构时可以考虑进去。
/build/package
:存放容器(Docker
)、系统(deb
, rpm
, pkg
)的包配置和脚本。/build/ci
:存放 CI
的配置文件和脚本。/build/docker
:存放子项目各个组件的 Dockerfile
文件。存放这个项目的支持工具。这些工具可导入来自 /pkg
和 /internal
目录的代码。
Git
钩子。比如,我们可以将 commit-msg
存放在该目录。
项目使用的其他资源 (图片、CSS
、JavaScript
等)。
如果你不使用 Github
页面,则在这里放置项目的网站数据。
项目的 README
文件一般包含了项目的介绍、功能、快速安装和使用指引、详细的文档链接以及开发指引等。
存放设计文档、开发文档和用户文档等(除了 godoc
生成的文档)。推荐存放以下几个子目录:
/docs/devel/{en-US,zh-CN}
:存放开发文档、hack
文档等。/docs/guide/{en-US,zh-CN}
: 存放用户手册,安装、quickstart
、产品文档等,分为中文文档和英文文档。/docs/images
:存放图片文件。开源就绪的项目,用来说明如何贡献代码,如何开源协同等等。CONTRIBUTING.md
不仅能够规范协同流程,还能降低第三方开发者贡献代码的难度。
/api
目录中存放的是当前项目对外提供的各种不同类型的 API
接口定义文件,其中可能包含类似 /api/protobuf-spec
、/api/thrift-spec
、/api/http-spec
、openapi
、swagger
的目录,这些目录包含了当前项目对外提供和依赖的所有 API
文件。
版权文件可以是私有的,也可以是开源的。常用的开源协议有:Apache 2.0
、MIT
、BSD
、GPL
、Mozilla
、LGPL
。
当项目有更新时,为了方便了解当前版本的更新内容或者历史更新内容,需要将更新记录存放到 CHANGELOG
目录。
存放应用程序或者公共包的示例代码
其中一个重要的原因是:在默认情况下,Go
语言的项目都会被放置到 $GOPATH/src
目录下。这个目录中存放着所有代码,如果我们在自己的项目中使用 /src
目录,这个包的导入路径中就会出现两个 src
,例如:
$GOPATH/src/github.com/marmotedu/project/src/main.go
这样的目录结构看起来非常怪。
对于小型项目,可以考虑先包含 cmd
、pkg
、internal
3 个目录,其他目录后面按需创建,例如:
$ tree --noreport -L 2 tms
tms
├── cmd
├── internal
├── pkg
└── README.md
另外,在设计目录结构时,一些空目录无法提交到 Git
仓库中,但我们又想将这个空目录上传到 Git
仓库中,以保留目录结构。这时候,可以在空目录下加一个 .keep
文件,例如:
$ ls -A build/ci/
.keep
├── admin.sh # 进程的start|stop|status|restart控制文件
├── conf # 配置文件统一存放目录
│ ├── config.yaml # 配置文件
│ ├── server.crt # TLS配置文件
│ └── server.key
├── config # 专门用来处理配置和配置文件的Go package
│ └── config.go
├── db.sql # 在部署新环境时,可以登录MySQL客户端,执行source db.sql创建数据库和表
├── docs # swagger文档,执行 swag init 生成的
│ ├── docs.go
│ └── swagger
│ ├── swagger.json
│ └── swagger.yaml
├── handler # 类似MVC架构中的C,用来读取输入,并将处理流程转发给实际的处理函数,最后返回结果
│ ├── handler.go
│ ├── sd # 健康检查handler
│ │ └── check.go
│ └── user # 核心:用户业务逻辑handler
│ ├── create.go # 新增用户
│ ├── delete.go # 删除用户
│ ├── get.go # 获取指定的用户信息
│ ├── list.go # 查询用户列表
│ ├── login.go # 用户登录
│ ├── update.go # 更新用户
│ └── user.go # 存放用户handler公用的函数、结构体等
├── main.go # Go程序唯一入口
├── Makefile # Makefile文件,一般大型软件系统都是采用make来作为编译工具
├── model # 数据库相关的操作统一放在这里,包括数据库初始化和对表的增删改查
│ ├── init.go # 初始化和连接数据库
│ ├── model.go # 存放一些公用的go struct
│ └── user.go # 用户相关的数据库CURD操作
├── pkg # 引用的包
│ ├── auth # 认证包
│ │ └── auth.go
│ ├── constvar # 常量统一存放位置
│ │ └── constvar.go
│ ├── errno # 错误码存放位置
│ │ ├── code.go
│ │ └── errno.go
│ ├── token
│ │ └── token.go
│ └── version # 版本包
│ ├── base.go
│ ├── doc.go
│ └── version.go
├── README.md # API目录README
├── router # 路由相关处理
│ ├── middleware # API服务器用的是Gin Web框架,Gin中间件存放位置
│ │ ├── auth.go
│ │ ├── header.go
│ │ ├── logging.go
│ │ └── requestid.go
│ └── router.go
├── service # 实际业务处理函数存放位置
│ └── service.go
├── util # 工具类函数存放目录
│ ├── util.go
│ └── util_test.go
└── vendor # vendor目录用来管理依赖包
├── github.com
├── golang.org
├── gopkg.in
└── vendor.json
Go API
项目中,一般都会包括这些功能项:Makefile
文件、配置文件目录、RESTful API
服务器的 handler
目录、model
目录、工具类目录、vendor
目录,以及实际处理业务逻辑函数所存放的 service
目录。这些都在上述的代码结构中有列出,新加功能时将代码放入对应功能的目录/文件中,可以使整个项目代码结构更加清晰,非常有利于后期的查找和维护。
参考:
https://juejin.cn/book/6844733730678898702/section/6844733730720841735
Makefile
基本格式如下:
target ... : prerequisites ...
command
...
其中:
target
:编译文件要生成的目标prerequisites
:编译文件需要的依赖command
:依赖生成目标所需要执行的命令(任意的 shell
命令),Makefile
中的命令必须以 [tab]
开头比如我们平时使用的 gcc a.c b.c -o test
这里的 test 就是我们要生成的目标, a.c
、b.c
就是我们生成目标需要的依赖,而 gcc a.c b.c -o test
则是命令。将这行命令用 Makefile
的方式来写就是:
test: a.c b.c
gcc a.c b.c -o test
all: gotool
@go build -v .
clean:
rm -f apiserver
find . -name "[._]*.s[a-w][a-z]" | xargs -i rm -f {}
gotool:
gofmt -w .
go tool vet . |& grep -v vendor;true
ca:
openssl req -new -nodes -x509 -out conf/server.crt -keyout conf/server.key -days 3650 -subj "/C=DE/ST=NRW/L=Earth/O=Random Company/OU=IT/CN=127.0.0.1/[email protected]"
help:
@echo "make - compile the source code"
@echo "make clean - remove binary file and vim swp files"
@echo "make gotool - run go tool 'fmt' and 'vet'"
@echo "make ca - generate ca files"
.PHONY: clean gotool ca help
上面的 Makefile
文件中,.PHONY
是个伪目标,形式上是一个目标,但是不需要依赖,伪目标一般只是为了执行目标下面的命令(比如 clean
就是伪目标)。@
放在行首,表示不打印此行。默认在编译的过程中,会把此行的展开效果字符串打印出来。
上面的 Makefile
实现了如下功能:
make
:执行 go build -v .
生成 Go
二进制文件make gotool
:执行 gofmt -w .
和 go tool vet .
(格式化代码和源码静态检查)make clean
:做一些清理工作:删除二进制文件、删除 vim swp
文件make ca
:生成证书make help
:打印 help 信息package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
"github.com/spf13/pflag"
)
var (
version = pflag.BoolP("version", "v", false, "show version info.")
)
var (
gitTag string = ""
gitCommit string = "$Format:%H$" // sha1 from git, output of $(git rev-parse HEAD)
gitTreeState string = "not a git tree" // state of git tree, either "clean" or "dirty"
buildDate string = "1970-01-01T00:00:00Z" // build date in ISO8601 format, output of $(date -u +'%Y-%m-%dT%H:%M:%SZ')
)
// Info contains versioning information.
type Info struct {
GitTag string `json:"gitTag"`
GitCommit string `json:"gitCommit"`
GitTreeState string `json:"gitTreeState"`
BuildDate string `json:"buildDate"`
GoVersion string `json:"goVersion"`
Compiler string `json:"compiler"`
Platform string `json:"platform"`
}
// String returns info as a human-friendly version string.
func (info Info) String() string {
return info.GitTag
}
func Get() Info {
return Info{
GitTag: gitTag,
GitCommit: gitCommit,
GitTreeState: gitTreeState,
BuildDate: buildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
}
}
func main() {
pflag.Parse()
if *version {
v := Get()
marshalled, err := json.MarshalIndent(&v, "", " ")
if err != nil {
fmt.Printf("%v\n", err)
os.Exit(1)
}
fmt.Println(string(marshalled))
return
}
}
SHELL := /bin/bash
BASEDIR = $(shell pwd)
# build with verison infos
versionDir = "apiserver/pkg/version"
gitTag = $(shell if [ "`git describe --tags --abbrev=0 2>/dev/null`" != "" ];then git describe --tags --abbrev=0; else git log --pretty=format:'%h' -n 1; fi)
buildDate = $(shell TZ=Asia/Shanghai date +%FT%T%z)
gitCommit = $(shell git log --pretty=format:'%H' -n 1)
gitTreeState = $(shell if git status|grep -q 'clean';then echo clean; else echo dirty; fi)
ldflags="-w -X ${versionDir}.gitTag=${gitTag} -X ${versionDir}.buildDate=${buildDate} -X ${versionDir}.gitCommit=${gitCommit} -X ${versionDir}.gitTreeState=${gitTreeState}"
all: gotool
@go build -v -ldflags ${ldflags} .
clean:
rm -f apiserver
find . -name "[._]*.s[a-w][a-z]" | xargs -i rm -f {}
gotool:
gofmt -w .
go tool vet . |& grep -v vendor;true
ca:
openssl req -new -nodes -x509 -out conf/server.crt -keyout conf/server.key -days 3650 -subj "/C=DE/ST=NRW/L=Earth/O=Random Company/OU=IT/CN=127.0.0.1/[email protected]"
help:
@echo "make - compile the source code"
@echo "make clean - remove binary file and vim swp files"
@echo "make gotool - run go tool 'fmt' and 'vet'"
@echo "make ca - generate ca files"
.PHONY: clean gotool ca help
其中 gitTag
、gitCommit
、gitTreeState
等变量的值是通过 -ldflags -X importpath.name=value
在编译时传到程序中的。为此我们需要在编译时传入这些信息,并在 go build
中添加这些 flag
。
go build -v -ldflags ${ldflags} .
-w
为去掉调试信息(无法使用 gdb
调试),这样可以使编译后的二进制文件更小。
$ ./apiserver -v
{
"gitTag": "7322949",
"gitCommit": "732294928b3c4dff5b898fde0bb5313752e1173e",
"gitTreeState": "dirty",
"buildDate": "2018-06-05T07:43:26+0800",
"goVersion": "go1.10.2",
"compiler": "gc",
"platform": "linux/amd64"
}
我们可以将这些信息写在配置文件中,程序运行时从配置文件中取得这些信息进行显示。但是在部署程序时,除了二进制文件还需要额外的配置文件,不是很方便。或者将这些信息写入代码中,这样不需要额外的配置,但要在每次编译时修改代码文件,也比较麻烦。Go 官方提供了一种更好的方式:通过 -ldflags -X importpath.name=value
(详见 -ldflags -X importpath.name=value
)来给程序自动添加版本信息。https://golang.org/cmd/link/
package main
import "fmt"
var (
VERSION string
BUILD_TIME string
GO_VERSION string
)
func main() {
fmt.Printf("%s\n%s\n%s\n", VERSION, BUILD_TIME, GO_VERSION)
}
编译命令
go build -ldflags "-w -s -X main.VERSION=1.0.0 -X 'main.BUILD_TIME=`date`' -X 'main.GO_VERSION=`go version`'"
因为
date
和go version
的输出有空格,所以main.BUILD_TIME
和main.GO_VERSION
必须使用引号括起来
-w
去掉 DWARF
调试信息,得到的程序就不能用 gdb
调试了。
-s
去掉符号表,panic
时候的 stack trace
就没有任何文件名/行号信息了,这个等价于普通C/C++
程序被 strip
的效果, -w -s
如果使用这两个将会看不见文件名、行号, 对于调试不利 gdb
看不到源码
-X
设置包中的变量值
gcflags
-N
参数代表禁止优化;-l
参数代表禁止内联;go
在编译目标程序的时候会嵌入运行时( runtime
)的二进制,禁止优化和内联可以让运行时(runtime
)中的函数变得更容易调试。
go build -gcflags='all=-N -l' main.go
VERSION := v1.0.0
LDFLAGS = -X "main.BuildTS=$(shell date -u '+%Y-%m-%d %I:%M:%S')"
LDFLAGS += -X "main.GitHash=$(shell git rev-parse HEAD)"
LDFLAGS += -X "main.GitBranch=$(shell git rev-parse --abbrev-ref HEAD)"
LDFLAGS += -X "main.Version=${VERSION}"
ifeq ($(gorace), 1)
BUILD_FLAGS=-race
endif
build:
go build -ldflags '$(LDFLAGS)' $(BUILD_FLAGS) main.go
lint:
golangci-lint run ./...
其中,build
下的命令就是构建程序的命令。在这段命令中,LDFLAGS
为编译时的一些选项,我们在编译时注入了程序的版本号、分支、构建时间、git commit
号等信息。这些信息会注入到 main.go
中的全局变量中。在 main.go
中,我们还要进行一些配套的处理,用来打印一些程序的版本信息。
// Version information.
var (
BuildTS = "None"
GitHash = "None"
GitBranch = "None"
Version = "None"
)
func GetVersion() string {
if GitHash != "" {
h := GitHash
if len(h) > 7 {
h = h[:7]
}
return fmt.Sprintf("%s-%s", Version, h)
}
return Version
}
// Printer print build version
func Printer() {
fmt.Println("Version: ", GetVersion())
fmt.Println("Git Branch: ", GitBranch)
fmt.Println("Git Commit: ", GitHash)
fmt.Println("Build Time (UTC): ", BuildTS)
}
var (
PrintVersion = flag.Bool("version", false, "print the version of this build")
)
func main(){
flag.Parse()
if *PrintVersion {
Printer()
os.Exit(0)
}
}
如下所示。当我们执行 make build
构建可运行程序,并传递 -version
运行参数时,就会打印出程序的版本信息了:
> make build
> ./main -version
Version: v1.0.0-ed89d91
Git Branch: master
Git Commit: ed89d91d03834fe85b1ca7f74f0cca305b8e516a
Build Time (UTC): 2022-11-30 04:52:45
同时在 Makefile
中,BUILD_FLAGS
表示构建可执行文件的参数。当我们设置环境变量 gorace=1
时,go build
会将 race
工具编译到程序中。最后我们会看到完整的构建命令:
» export gorace=1
» make build
go build -ldflags '-X "main.BuildTS=2022-12-03 05:48:59" -X "main.GitHash=e73f1126031f56178ca86deda7fceb0a71b5314e" -X "main.GitBranch=master" -X "main.Version=v1.0.0"' main.go