Go 学习笔记

本文最后更新于 2025年1月6日 中午

Day1

开发环境

  • 编辑器

    使用 Golang IDE

  • Redis

    推荐使用 AnotherRedisDesktopManager,跨平台,支持 Mac、Windows 和 Linux

  • 数据库管理工具

    推荐使用 TablePlus ,跨平台,支持 Mac、Windows 和 Linux。

项目简介

功能模块

  1. 登录、注册、找回密码、话题模块、分类模块、友情链接

创建项目

这里直接使用golang IDE 创建,默认使用 $GOPATH

.gitignore 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
tmp
.env
gohub
.DS_Store
.history

# Golang #
######################
# `go test -c` 生成的二进制文件
*.test
# go coverage 工具
*.out
*.prof
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*

# 编译文件 #
###################
*.com
*.class
*.dll
*.exe
*.o
*.so

# 压缩包 #
############
# Git 自带压缩,如果这些压缩包里有代码,建议解压后 commit
*.7z
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip

# 日志文件和数据库 #
######################
*.log
*.sqlite
*.db

# 临时文件 #
######################
tmp/
.tmp/

# 系统生成文件 #
######################
.DS_Store
.DS_Store?
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.TemporaryItems
.fseventsd
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# IDE 和编辑器 #
######################
.idea/
/go_build_*
out/
.vscode/
.vscode/settings.json
*.sublime*
__debug_bin
.project

# 前端工具链 #
######################
.sass-cache/*
node_modules/

Air 自动重载

安装

使用以下命令来安装 air :

1
GO111MODULE=on  go install github.com/cosmtrek/air@latest

Windows 下也可以手动安装,进入 github.com/cosmtrek/air/releases 下载后放入 Go 安装目录下的 bin 目录,重命名为 air.exe。

安装成功

1
2
air -v

添加 .air.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# https://github.com/cosmtrek/air/blob/master/air_example.toml TOML 格式的配置文件

# 工作目录
# 使用 . 或绝对路径,请注意 `tmp_dir` 目录必须在 `root` 目录下
root = "."
tmp_dir = "tmp"

[build]
# 由`cmd`命令得到的二进制文件名
# Windows平台示例:bin = "./tmp/main.exe"
bin = "./tmp/main"
# 只需要写你平常编译使用的shell命令。你也可以使用 `make`
# Windows平台示例: cmd = "go build -o ./tmp/main.exe ."
cmd = "go build -o ./tmp/main ."
# 如果文件更改过于频繁,则没有必要在每次更改时都触发构建。可以设置触发构建的延迟时间
delay = 1000
# 忽略这些文件扩展名或目录
exclude_dir = ["assets", "tmp", "vendor","public/uploads"]
# 忽略以下文件
exclude_file = []
# 使用正则表达式进行忽略文件设置
exclude_regex = []
# 忽略未变更的文件
exclude_unchanged = false
# 监控系统链接的目录
follow_symlink = false
# 自定义参数,可以添加额外的编译标识,例如添加 GIN_MODE=release
full_bin = ""
# 监听以下指定目录的文件
include_dir = []
# 监听以下文件扩展名的文件.
include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "env"]
# kill 命令延迟
kill_delay = "0s"
# air的日志文件名,该日志文件放置在你的`tmp_dir`中
log = "build-errors.log"
# 在 kill 之前发送系统中断信号,windows 不支持此功能
send_interrupt = false
# error 发生时结束运行
stop_on_error = true

[color]
# 自定义每个部分显示的颜色。如果找不到颜色,使用原始的应用程序日志。
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"

[log]
# 显示日志时间
time = false

[misc]
# 退出时删除tmp目录
clean_on_exit = false

集成 gin

安装 gin

1
go get github.com/gin-gonic/gin

修改 mian.go 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// main.go  
package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

func main() {
// 初始化 Gin 实例
r := gin.Default()

// 注册一个路由
r.GET("/", func(c *gin.Context) {

// 以 JSON 格式响应
c.JSON(http.StatusOK, gin.H{
"Hello": "World!",
})
})

// 运行服务
r.Run()
}

上面的 gin 初始化的地方,以下两行:

1
2
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())

等于我们第一个 Hello World 的一行:

1
r := gin.Default()

查看 Default 方法的源码:

1
2
3
4
5
6
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery())
return engine
}

Default 返回的是一个 Engine 对象。且默认帮我们注册了两个中间件,Logger 和 Recovery 中间件。这里暂时不深究这两个中间件,后面的课程中我们会根据需要定制自己的中间件。这里将他们写出来,方便后面的修改。

air 自动重载程序以后,也可以看到我们的端口已经绑定在 8000 端口了:

自定义 404 Handle

下面我们来加入 404 处理,利用 Engine 对象的 NoRoute 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

func main() {
.
.
.

// 处理 404 请求
r.NoRoute(func(c *gin.Context) {
// 获取标头信息的 Accept 信息
acceptString := c.Request.Header.Get("Accept")
if strings.Contains(acceptString, "text/html") {
// 如果是 HTML 的话
c.String(http.StatusNotFound, "页面返回 404")
} else {
// 默认返回 JSON
c.JSON(http.StatusNotFound, gin.H{
"error_code": 404,
"error_message": "路由未定义,请确认 url 和请求方法是否正确。",
})
}
})

// 运行服务
r.Run(":8000")
}

c.Requestgin 封装的请求对象,所有用户的请求信息,都可以从这个对象中获取。打开 Postman,访问 http://localhost:8000/no-found ,注意我们已经修改为 8000 端口了,可以看到提示信息了。

关注项目的错误提示

初始化路由

按照目标项目结构来调整代码。

  • 创建 bootstrap 包
  • 初始化路由
  • 注册 api 路由
创建 bootstrap 包

bootstrap/route.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Package bootstrap 处理程序初始化逻辑
package bootstrap

import (
"gohub/routes"
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

// SetupRoute 路由初始化
func SetupRoute(router *gin.Engine) {

// 注册全局中间件
registerGlobalMiddleWare(router)

// 注册 API 路由
routes.RegisterAPIRoutes(router)

// 配置 404 路由
setup404Handler(router)
}

func registerGlobalMiddleWare(router *gin.Engine) {
router.Use(
gin.Logger(),
gin.Recovery(),
)
}

func setup404Handler(router *gin.Engine) {
// 处理 404 请求
router.NoRoute(func(c *gin.Context) {
// 获取标头信息的 Accept 信息
acceptString := c.Request.Header.Get("Accept")
if strings.Contains(acceptString, "text/html") {
// 如果是 HTML 的话
c.String(http.StatusNotFound, "页面返回 404")
} else {
// 默认返回 JSON
c.JSON(http.StatusNotFound, gin.H{
"error_code": 404,
"error_message": "路由未定义,请确认 url 和请求方法是否正确。",
})
}
})
}

路由文件 api.go

我们所有项目 API 路由,都会统一放在 routes/api.go 文件中。创建文件:
routes/api.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Package routes 注册路由
package routes

import (
"net/http"

"github.com/gin-gonic/gin"
)

// RegisterAPIRoutes 注册网页相关路由
func RegisterAPIRoutes(r *gin.Engine) {

// 测试一个 v1 的路由组,我们所有的 v1 版本的路由都将存放到这里
v1 := r.Group("/v1")
{
// 注册一个路由
v1.GET("/", func(c *gin.Context) {
// 以 JSON 格式响应
c.JSON(http.StatusOK, gin.H{
"Hello": "World!",
})
})
}
}

注意这里使用 gin 提供的 r.Group 方法,注册了 v1 的路由组,作为我们的 API 版本区分。
随着业务的发展,需求的不断变化,API 的迭代是必然的,很可能当前版本正在使用,而我们就得开发甚至上线一个不兼容的新版本,为了让旧用户可以正常使用,为了保证开发的顺利进行,我们需要控制好 API 的版本区分。
这里我们实现的是将版本号直接加入 URL 中:

API 版本区分,大部分的商业 API 项目中都会有此要求。在现实生产环境中,多版本共存是很正常的情况。

1
2
https://api.gohub.com/v1
https://api.gohub.com/v2

main.go

main.go 中调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"gohub/bootstrap"

"github.com/gin-gonic/gin"
)

func main() {

// new 一个 Gin Engine 实例
router := gin.New()

// 初始化路由绑定
bootstrap.SetupRoute(router)

// 运行服务
err := router.Run(":3000")
if err != nil {
// 错误处理,端口被占用了或者其他错误
fmt.Println(err.Error())
}
}

在我们的 Gohub 项目中,我们只会放一些初始化的代码,其他逻辑的代码我们会使用合理的结构,将他们封装到各自所属的文件和包。

bootstrap 目录和 routes 目录

我们刚刚创建了两个目录,bootstrap 和 routes。

bootstrap 目录将存放程序初始化的代码,现在是只有 route ,后面我们还会加上 database, redis, config … 。

routes 目录存放我们所有项目的路由文件,后面如果我们有 Web 前端,或者 Admin 的路由,可以在此目录下添加 web.go 和 admin.go 。

配置信息的设计

说明

目前的 main.go 里:

err := router.Run(“:3000”)
端口是写死在代码中的,我们需要来优化下。像这种程序配置相关的需求,后续还有有很多,例如

数据库连接信息
Redis 连接信息
验证码复杂度
邮件服务的配置
第三方短信的 KEY 和 秘钥

方案

我们的配置信息,将分为两个层级:

env
config
接下来我们分别讲解。

.env
一般来讲,项目会运行在多个环境下,例如:

local —— 本地开发环境(我的机器上、其他开发同事的机器上)
testing —— 自动化测试环境
stage —— 接近线上环境的测试环境,方便其他成员访问和测试(编辑人员、产品经理、项目经理)
production —— 线上生产环境
不同的环境下,我们将使用不同的配置。例如 local 环境里,发送短信使用的是测试账号,production 环境下,我们将使用验证了公司信息的发信账号。

.env 文件里,一般会存放敏感信息,所以不会将其添加到代码版本库中。

那怎么知道 .env 里有哪些配置项呢?

我们会添加一个 .env.example 文件,配置项放到这里面做占位符,敏感的信息留空,且将此文件提交到版本库中。部署到新项目中时,参考此文件创建一个 .env 文件,对其进行配置即可。

config
config 是将配置信息存放于 config 目录下,按照单独的逻辑区分单独的配置文件,例如数据库连接信息存放于 config/database.go 文件下。

config 里加载 .env 里的配置项,且可设置缺省值。

既然有 .env 文件,为何还要 config 呢?

config 可以提高配置方案灵活度。在 config 里,我们可以为每个配置项设置默认值。也可以做一些简单的数学运算,或者调用 Go 函数进行默认值的处理。我们甚至可以为配置项设置一个回调函数。

config 文件是要加入代码版本控制器中的,这些代码是固定的。如果要修改一个 config 配置项,就修改其对应的 .env 文件中的配置项即可。

多个 .env 文件
单独的 .env 的设计,是满足一台机器一套环境变量的需求。多个 .env 文件是满足一台机器上运行多套环境变量的需求。

开发时,除了 local 环境变量,很多时候还需要 testing 测试相关的环境变量,testing 的配置有别于 local 。例如测试时,一般需要使用不同的数据库,这样才能不污染我们的开发数据库。

我们可以利用程序参数,在命令行运行主程序时,传参 –env=testing 的参数,程序接收到这个参数后会读取 .env.testing 文件,而不是 .env 文件。

–env 的参数不需要限制值,取到以后直接读取对应的文件即可。以下是几个例子:

–env=testing 读取 .env.testing 文件,用以在测试环境使用不同的数据库
–env=production 读取 .env.production 文件,用以在本地环境中调试线上的第三方服务配置信息(短信、邮件)

结语

配置是项目的最基础模块,所以在一开始设计好灵活性,非常重要。

当然,.env + config 方案不是我们自己发明轮子,很多优秀的开源项目和框架,都有类似的方案。

本课程将借鉴 PHP 的 Laravel 框架配置信息方案,笔者使用此框架做过几十个商业项目,这个方案在实践中能满足多人开发、以及各种复杂的业务需求。

配置方案的实现

config 包是我们自定的包,对 Viper 第三方库的封装。封装以下逻辑:

初始化
读取配置文件
设置配置项
读取配置项

config 包以外的其他项目代码,将对内部使用依赖包 Viper 无感知。

这样做的好处是后续以为某些特殊需求,Viper 无法满足需求,或者 Viper 不再维护有更加优秀的第三方包需要替换。除了我们的 config 包,项目中的其他代码我们都不需要动。

安装依赖

1
go get github.com/spf13/cast
1
go get github.com/spf13/viper
config 包

pkg/config/config.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// Package config 负责配置信息
package config

import (
"gohub/pkg/helpers"
"os"

"github.com/spf13/cast"
viperlib "github.com/spf13/viper" // 自定义包名,避免与内置 viper 实例冲突
)

// viper 库实例
var viper *viperlib.Viper

// ConfigFunc 动态加载配置信息
type ConfigFunc func() map[string]interface{}

// ConfigFuncs 先加载到此数组,loadConfig 再动态生成配置信息
var ConfigFuncs map[string]ConfigFunc

func init() {

// 1. 初始化 Viper 库
viper = viperlib.New()
// 2. 配置类型,支持 "json", "toml", "yaml", "yml", "properties",
// "props", "prop", "env", "dotenv"
viper.SetConfigType("env")
// 3. 环境变量配置文件查找的路径,相对于 main.go
viper.AddConfigPath(".")
// 4. 设置环境变量前缀,用以区分 Go 的系统环境变量
viper.SetEnvPrefix("appenv")
// 5. 读取环境变量(支持 flags)
viper.AutomaticEnv()

ConfigFuncs = make(map[string]ConfigFunc)
}

// InitConfig 初始化配置信息,完成对环境变量以及 config 信息的加载
func InitConfig(env string) {
// 1. 加载环境变量
loadEnv(env)
// 2. 注册配置信息
loadConfig()
}

func loadConfig() {
for name, fn := range ConfigFuncs {
viper.Set(name, fn())
}
}

func loadEnv(envSuffix string) {

// 默认加载 .env 文件,如果有传参 --env=name 的话,加载 .env.name 文件
envPath := ".env"
if len(envSuffix) > 0 {
filepath := ".env." + envSuffix
if _, err := os.Stat(filepath); err == nil {
// 如 .env.testing 或 .env.stage
envPath = filepath
}
}

// 加载 env
viper.SetConfigName(envPath)
if err := viper.ReadInConfig(); err != nil {
panic(err)
}

// 监控 .env 文件,变更时重新加载
viper.WatchConfig()
}

// Env 读取环境变量,支持默认值
func Env(envName string, defaultValue ...interface{}) interface{} {
if len(defaultValue) > 0 {
return internalGet(envName, defaultValue[0])
}
return internalGet(envName)
}

// Add 新增配置项
func Add(name string, configFn ConfigFunc) {
ConfigFuncs[name] = configFn
}

// Get 获取配置项
// 第一个参数 path 允许使用点式获取,如:app.name
// 第二个参数允许传参默认值
func Get(path string, defaultValue ...interface{}) string {
return GetString(path, defaultValue...)
}

func internalGet(path string, defaultValue ...interface{}) interface{} {
// config 或者环境变量不存在的情况
if !viper.IsSet(path) || helpers.Empty(viper.Get(path)) {
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
return viper.Get(path)
}

// GetString 获取 String 类型的配置信息
func GetString(path string, defaultValue ...interface{}) string {
return cast.ToString(internalGet(path, defaultValue...))
}

// GetInt 获取 Int 类型的配置信息
func GetInt(path string, defaultValue ...interface{}) int {
return cast.ToInt(internalGet(path, defaultValue...))
}

// GetFloat64 获取 float64 类型的配置信息
func GetFloat64(path string, defaultValue ...interface{}) float64 {
return cast.ToFloat64(internalGet(path, defaultValue...))
}

// GetInt64 获取 Int64 类型的配置信息
func GetInt64(path string, defaultValue ...interface{}) int64 {
return cast.ToInt64(internalGet(path, defaultValue...))
}

// GetUint 获取 Uint 类型的配置信息
func GetUint(path string, defaultValue ...interface{}) uint {
return cast.ToUint(internalGet(path, defaultValue...))
}

// GetBool 获取 Bool 类型的配置信息
func GetBool(path string, defaultValue ...interface{}) bool {
return cast.ToBool(internalGet(path, defaultValue...))
}

// GetStringMapString 获取结构数据
func GetStringMapString(path string) map[string]string {
return viper.GetStringMapString(path)
}

上面有一个 helpers.Empty() 方法未定义,现在定义此方法:

pkg/helpers/helpers.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Package helpers 存放辅助方法
package helpers

import "reflect"

// Empty 类似于 PHP 的 empty() 函数
func Empty(val interface{}) bool {
if val == nil {
return true
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.String, reflect.Array:
return v.Len() == 0
case reflect.Map, reflect.Slice:
return v.Len() == 0 || v.IsNil()
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Ptr:
return v.IsNil()
}
return reflect.DeepEqual(val, reflect.Zero(v.Type()).Interface())
}
创建配置信息

config/app.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Package config 站点配置信息
package config

import "gohub/pkg/config"

func init() {
config.Add("app", func() map[string]interface{} {
return map[string]interface{}{

// 应用名称
"name": config.Env("APP_NAME", "Gohub"),

// 当前环境,用以区分多环境,一般为 local, stage, production, test
"env": config.Env("APP_ENV", "production"),

// 是否进入调试模式
"debug": config.Env("APP_DEBUG", false),

// 应用服务端口
"port": config.Env("APP_PORT", "3000"),

// 加密会话、JWT 加密
"key": config.Env("APP_KEY", "33446a9dcf9ea060a0a6532b166da32f304af0de"),

// 用以生成链接
"url": config.Env("APP_URL", "http://localhost:3000"),

// 设置时区,JWT 里会使用,日志记录里也会使用到
"timezone": config.Env("TIMEZONE", "Asia/Shanghai"),
}
})
}

config/config.go

1
2
3
4
5
6
// Package config 存放程序所有的配置信息
package config

// Initialize 触发加载 config 包的所有 init 函数
func Initialize() {
}
.env 文件

.env

1
2
3
4
5
6
APP_ENV=local
APP_KEY=zBqYyQrPNaIUsnRhsGtHLivjqiMjBVLS
APP_DEBUG=true
APP_URL=http://localhost:3000
APP_LOG_LEVEL=debug
APP_PORT=3000
配置初始化

修改 main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"flag"
"fmt"
"gohub/bootstrap"
btsConfig "gohub/config"
"gohub/pkg/config"

"github.com/gin-gonic/gin"
)

func init() {
// 加载 config 目录下的配置信息
btsConfig.Initialize()
}

func main() {

// 配置初始化,依赖命令行 --env 参数
var env string
flag.StringVar(&env, "env", "", "加载 .env 文件,如 --env=testing 加载的是 .env.testing 文件")
flag.Parse()
config.InitConfig(env)

// new 一个 Gin Engine 实例
router := gin.New()

// 初始化路由绑定
bootstrap.SetupRoute(router)

// 运行服务
err := router.Run(":" + config.Get("app.port"))
if err != nil {
// 错误处理,端口被占用了或者其他错误
fmt.Println(err.Error())
}
}

init 方法

init 函数有以下逻辑:

如果一个包定义了 init 函数,Go 运行时会负责在该包初始化时调用它的 init 函数;
init 不能被显式调用 ,否则会在编译期间报错;
多个包的情况,在初始化该包时,Go 运行时会按照一定的次序逐一顺序地调用该包的 init 函数;
每个 init 函数在整个 Go 程序生命周期内仅会被执行一次;
一般来说,先被传递给 Go 编译器的源文件中的 init 函数先被执行(main.go 作为起点);
同一个源文件中的多个 init 函数按声明顺序依次执行。
关于 init 的加载顺序,这张图给了一个很好的说明:

image

Day2

手机或邮箱是否已注册

Day3

Day4


Go 学习笔记
https://dev.dgdream.online/go学习笔记/
作者
执念
发布于
2024年4月28日
更新于
2025年1月6日
许可协议