Go 语言程序结构

项目

使用 Go Modules 管理的项目,其结构可以相对简单且灵活,一切根据项目需求进行调整。

源码分类

Go 语言源代码根据用途可分为几类:

  • 命令源文件:包含 main 函数,属于 package main 的代码文件,是程序入口点。通常存放在项目根目录下或 cmd 目录下子目录中,可用 go buildgo run 命令编译为可执行程序。
  • 库源文件:库文件指被设计为被外部导入重用的代码文件。库不包含 main 函数,而是提供函数、类型、变量等供其他程序使用。通常位于 pkg 目录下。
  • 测试源文件:测试代码与被测试代码位于同一个目录,文件名以 _test.go 结尾。测试函数以 Test 开头,使用内置测试框架来编写测试用例。
  • 内部源文件:内部源文件指放置在 internal 目录下的代码文件。作用和库文件一样,只不过作用域限于项目内部。
  • 辅助工具:包括用于支持开发、构建、部署和维护的辅助脚本和工具。可能存到 tools 目录中。

源文件默认使用 UTF-8 编码。

基本文件

项目根目录下 go.modmain.go 文件,是一个可执行项目的最基础配置:

  • go.mod: 项目核心文件,记录项目模块路径以及依赖关系。由 go mod init 命令自动生成,之后下载依赖包时会自动更新内容。

  • main.go:程序入口文件,包含 main 包和 main 函数。

main.go 文件也可叫其他名字,但不推荐改名。

常用结构

关于项目必备目录,Go 语言没有强制要求和指导说明,下面是较常见的目录结构:

  • cmd:用于存放项目可执行文件入口点。如果项目生成多个二进制文件,可以用子目录划分不同应用程序。例如 cmd/api/main.gocmd/cli/main.go,编译后会生成 api.execli.exe
  • pkg:用于存放可导出的库代码。
  • internal:存放不可导出的应用代码和库代码,只能项目内使用。
  • config:存放配置文件模板或默认配置。
  • assets:存放项目所需的媒体文件。
  • scripts:存放外部脚本和工具。
  • test:存放外部测试文件。
  • api:存放开放 API 的定义,例如 OpenAPI/Swagger 规格。
  • docs:文档目录,存放用户手册、开发者指南和 API 文档。

规范参考来源于:https://github.com/golang-standards/project-layout

项目示范

下面通过新建一个简单项目,来演示模块和包。

首先新建目录 awkgo,代表项目名称。在 awkgo 路径下中初始化项目:

PS D:\Software\Programming\Go\awkgo> go mod init awkgo
go: creating new go.mod: module awkgo
go: to add module requirements and sums:
        go mod tidy

这里仅作演示,所以没有初始化成一个代码仓库路径。继续在 awkgo 目录下新建 echo 目录,代表有一个本地包。在 echo 目录内新建文件 text.go,声明属于 echo 包,内容只有一个函数 ToTitle,用来转换字符串:

/*
Package echo 包最顶部说明
调用扩展库中实验功能
*/
package echo

import (
	"golang.org/x/text/cases"
	"golang.org/x/text/language"
)

// ToTitle 用于转换英语单词为首字母大小
func ToTitle(s string) string {
	c := cases.Title(language.English)
	return c.String(s)
}

代码中导入了外部包 golang.org/x/text,需要单独下载:

PS D:\Software\Programming\Go\awkgo> go get golang.org/x/text
go: downloading golang.org/x/text v0.16.0
go: added golang.org/x/text v0.16.0

下载完毕后,go.mod 文件内容会自动更新,不需要手动修改:

module awkgo

go 1.22.0

require golang.org/x/text v0.16.0 // indirect

然后在 awkgo 目录新建文件 main.go 作为程序入口,内容如下:

package main

import (
	"awkgo/echo" // 导入本地包
	"fmt"
)

func main() {
	fmt.Println(echo.ToTitle("hello world = 世界你好"))
}

整个项目文件结构应该像下面这样,包内文件 text.go 命名没有规定,只要保证包名和目录名一致即可:

awkgo/
├── echo/
│   └── text.go
├── go.mod
├── main.go

最后在项目路径下,使用 go rungo build 命令来编译运行项目:

PS D:\Software\Programming\Go\awkgo> go run .
Hello World = 世界你好
PS D:\Software\Programming\Go\awkgo> go build
PS D:\Software\Programming\Go\awkgo> .\awkgo.exe
Hello World = 世界你好

模块

Go 模块(Module)在 Go 1.11 版本中引入,是 Go 语言包管理和依赖管理的基础。模块可以视为一组包的集合,通过 go.mod 文件来记录管理依赖:

  • 模块:由一个根目录、一个 go.mod 文件以及多个 Go 包组成。
  • go.mod 文件:在模块根目录中,描述了模块属性、依赖项及其版本。

简单来看,任何包含 go.mod 文件的代码目录都可以被称为模块。

初始化模块

在创建完项目目录后,第一件事就是使用 go mod init 命令来初始化新模块:

go mod init <module-name>

其中 module-name 为模块名字,一般用项目代码仓库路径,例如 :github.com/hxz393/projectname,这样能保证模块名独一无二。

初始化完成后,会在项目根目录自动创建 go.mod 文件。

go.mod

go.mod 文件是 Go 模块的核心,一个示例文件内容如下:

module github.com/hxz393/myproject

go 1.22.0

require (
    github.com/gin-gonic/gin v1.6.3
    github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
)

replace (
    github.com/gin-gonic/gin v1.6.3 => github.com/gin-gonic/gin v1.8.0
    github.com/mohae/deepcopy => ../myproject/deepcopy
)

exclude (
    github.com/gin-gonic/gin v1.7.0
)

retract (
    v0.0.1 // 不再支持
    v0.2.3 // 有安全问题
)

其中必备字段会自动生成和更新:

  • module:声明模块名,也就是使用 go mod init 命令时输入的模块路径。
  • go:指定模块使用的 Go 语言版本,编译时不得低于指定版本。
  • require:列出依赖及其版本号。如果没有版本号,由仓库提交号代替。

可选字段需要手动添加,用于特殊目的:

  • replace:替换依赖版本或替换为其他依赖。
  • exclude:指定排除特定依赖版本,避免代码缺陷或兼容问题。
  • retract:标记本模块的特定版本为弃用。

一般 go.sum 文件和go.mod 文件成对出现,用于记录依赖版本哈希值,确保依赖未被非法篡改或损坏。

添加依赖

从 Go 1.17 版本开始,go get 命令用于添加和更新外部依赖,不再用来安装工具:

go get <module-name>[@version]
  • module-name:和初始化模块时一样,module-name 为依赖仓库路径,除了 Git 也支持 Mercurial 和 Subversion 等版本控制系统。
  • version:版本号类似于 vX.Y.Z 格式。如果忽略则下载最新版本,否则会安装指定版本。也用于降级依赖版本。

依赖在本地存放路径由 GOMODCACHE 环境变量决定,如果本地已存在依赖缓存,则不会重新下载安装。

go get 命令支持一些选项和参数:

  • -d:只下载不安装。
  • -insecure:允许使用 HTTP 协议下载,用于在内部仓库中下载依赖。
  • -u:更新依赖包。
  • -v:打印详细信息。

比较常用的是 -u 更新选项,新增和更新依赖都会自动更新 go.modgo.sum 文件。但升级模块时需要注意,一般不同主版本号会使用不同路径,表示新版本包含不向后兼容的更改。例如 cloud.google.com/go 大版本升级后地址变为 cloud.google.com/go/v2,在更新依赖和导入包时都需要调整地址。

管理模块

大多数时候,模块不需要手动管理,只需用 go get 命令来指定依赖就够了。少数情况下,可以使用 go mod 子命令来管理模块:

  • 清理依赖:运行 go mod tidy 命令可以清理未使用的依赖。只会优化 go.mod 文件内容,不会删除实际依赖文件。
  • 下载依赖:运行 go mod download 来一键下载 go.mod 中列出的所有依赖。
  • 验证依赖:运行 go mod verify 验证当前依赖文件是否都符合哈希值。

如果要查看当前模块所有依赖,包括依赖的依赖,可以使用 go list 命令列出:

PS D:\Software\Programming\Go\new> go list -m all
new
cloud.google.com/go v0.26.0
filippo.io/edwards25519 v1.1.0
github.com/BurntSushi/toml v1.4.0

Go 语言以包(Package)来封装、组织和重用代码,一个包就是一个目录,里面包含属于这个包的 .go 源文件。一个包结构通常包括三部分:包声明、包引入和包内容。

Go 语言与大部分编译语言类似,当改动源文件时,必须重新编译该源文件及依赖包。但和其他语言比较,编译速度快得多,是以下特性在起作用:

  • 每个源文件在开头显式地列出所有依赖包,编译器可以快速读取依赖包列表。
  • 包之间禁止循环依赖,所以包可以被单独编译,也支持并行编译。
  • 每个包在编译后会缓存结果,当代码没变化时,编译器能重用之前缓存。

包声明

所有源文件必须在开头显式声明所属包,包声明格式如下:

package <pkgName>

包名 pkgName 使用小写形式单词命名,每个 Go 文件都属于且仅属于一个包。一个包可以由多个 Go 文件组成,一个应用程序可以由多个包组成。

属于同个包的源文件必须被一起编译,所以每个目录只能代表一个包。也不能把同个包的文件拆分到多个目录中,这样做编译器会强制拆分包。例如在 confighello 目录中都有 package hello 声明,那么在其他包中导入时,要作为不同包对待:

package main

import (
	"fmt"
    
    // 只当是同名包,导入需要用别名
	hello1 "new/internal/config"
	hello2 "new/internal/hello"
)

func main() {
	hello2.Sum([]int{1, 3, 4})
	fmt.Print(hello1.MaxConnections)
}

包导入

无论需要调用标准库包、本地包还是外部包,都要使用导入来使用。Go 语言中,使用关键字 import 来导入包中公开变量、常量和函数。导入包有三种模式:

  • 正常模式import <pkgName> 。导入 pkgName 之后,使用 <pkgName>.<funcName> 形式对包中函数或类型进行调用。
  • 别名模式import aliasName <pkgName>。导入包时,可能遇到包名相同或相近的情况,此时可以拟定包别名来进行区别。注意,一旦定义包别名,就不能再使用包原名来调用。
  • 简便模式import .<pkgName>。在简便模式中,可直接使用包内函数名进行调用,而不用带上包名。通常不会使用此导入方式。

所有外部包导入路径以托管域名为前缀,例如:github.com/go-sql-driver/mysql,包名匹配包导入路径的最后一段。本地包则通过 path/<pkgName> 来导入,计算相对路径要包括工作目录,路径分隔符统一使用斜杠 /

有多个导入包时,可以使用导入块来包装,并通过空行分隔进行分组。导入分组用来区分包来源是标准库、内部库还是第三方库:

package main

// 导入块形式
import (
	// 标准库
	"fmt"
	"runtime"

	// 项目内部模块
	"myproject/models"
	tools "myproject/utils"

	// 第三方库
	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

func main() {
}

Go 语言中不允许导入包而不用,但可以将下划线 _ 作为包别名来绕过匿名导入检查。这样做的唯一目的是触发包内初始化函数,初始化函数可能用于注册驱动或环境检查:

package main

import (
	"database/sql"
	"fmt"
	_ "github.com/lib/pq" // 注册 PostgreSQL 驱动
)

func main() {
	db, _ := sql.Open("postgres", "192.168.2.1")
	db.Close()
}

不同包之间允许存在同名函数,调用时通过带上不同包名来消除歧义:

package main

import (
	"new/internal/config"
	"new/internal/hello"
)

func main() {
    // 同名函数,接受不同参数。调用带上包名
	fmt.Print(hello.Sum([]int{1, 3, 4}))
	fmt.Print(config.Sum(1,2))
}

包内容

在包导入之后就是包内容,包含实际功能和数据结构,通常是些变量、函数、类型和方法:

package cli

import (
	"fmt"
	"runtime"
)

// 全局变量最先计算
var MaxUser = runtime.NumCPU() * Multiple
var Multiple = 100

// 初始化函数依次执行
func init() { fmt.Println("可支持最大用户:\t", MaxUser) }
func init() { fmt.Println("操作系统:\t", runtime.GOOS) }

// Client 为可导出函数
func Client(max int) {
	fmt.Println("注册用户数:\t", max)
	if !check(max) {
		fmt.Println("无法注册新用户")
		return
	}
	fmt.Println("继续正常注册流程")
}

// 不可导出函数,注释不必以函数名开头
func check(max int) bool {
	if max <= 0 || max > MaxUser {
		fmt.Println("超出可支持最大用户数")
		return false
	}
	return true
}