记账项目总结
Why Golang
推荐前端开发学习 Golang 的原因主要有以下几点:
- 高性能:Golang 是一种编译型语言,具有较快的执行速度和较低的内存占用。与 Node.js 等解释型语言相比,Go 在处理高并发和 I/O 密集型任务时表现更优,适合构建高性能的后端服务。例如 esbuild,docker
- 简洁的语法:Go 的语法相对简单,易于学习和使用。对于已经熟悉 JavaScript 的前端开发者来说,Go 的语法结构和基本概念(如函数、结构体等)容易上手,能够快速适应。没有传统面向对象的感觉,很像 TypeScript
- 强大的并发支持:Go 的并发模型基于 goroutines 和 channels,使得编写并发程序变得简单而高效。这对于需要处理多个请求的 Web 应用程序尤为重要,可以轻松实现高并发处理。GoRoutines Channels 都没学习过。
- 丰富的标准库:Go 的标准库提供了强大的工具集,尤其是在网络编程和 Web 开发方面。开发者可以利用内置的 HTTP 包快速构建 Web 服务器和处理请求,而无需依赖第三方库
- 良好的社区和生态:虽然 Go 的社区相对较小,但其生态系统正在不断发展,许多优秀的框架(如 Gin、Echo)和工具(如 Docker、Kubernetes)都是用 Go 开发的。学习 Go 可以帮助开发者更好地理解这些工具的内部工作原理
使用心得
- 虽然 Go 的语法简单,但其并发模型和一些特性(如接口、指针)可能需要时间去理解。建议通过实际项目来加深对这些概念的理解。
- 源码易读,因为内置库或第三方库都是用 Golang 写的
- go 没有繁琐的eslint和prettier 统一代码规范
- go 的学习主要靠源码 包括gin框架
- go 中首字母大写会被默认导出
- go 中变量名一般用一个字母
- go 程序员很爱用缩写 server => srv 把原音字母去掉就是简写 例如 migrate => mgrt
- go 中没有继承与实现的概念,只要这个结构体有接口要求的方法就可以作为这个接口的实现
Go 版本 go1.21.0 darwin/arm64
Go: https://go.dev/
Gin: https://gin-gonic.com/docs/quickstart/
Cobra: https://github.com/spf13/cobra
Sqlc: Getting started with PostgreSQL — sqlc 1.23.0 documentation
数据库连接字符串: PostgreSQL connection strings - ConnectionStrings.com
sql语句速查: PostgreSQL 14 / CREATE TABLE — DevDocs
数据库迁移: GitHub - golang-migrate/migrate: Database migrations. CLI and Golang library.
viper: spf13/viper: Go configuration with fangs (github.com)
crypto/rand: rand package - crypto/rand - Go Packages
gomail: go-gomail/gomail: The best way to send emails in Go. (github.com)
Mailhog: mailhog/MailHog: Web and API based SMTP testing (github.com)
数据校验: validator package - github.com/go-playground/validator/v10 - Go Packages](https://pkg.go.dev/github.com/go-playground/validator/v10#ValidationErrors)
jwt: golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go
swaggo: swag/README_zh-CN.md at master · swaggo/swag (github.com)
1 开发准备
1.1 环境配置
1.1.1 macos
官网下载安装即可
1.1.2 ubuntu 22.04 aarch64
# 下载安装包
curl -OL https://golang.org/dl/go1.21.0.linux-arm64.tar.gz
# 解压
sudo tar -C /usr/local -xvf go1.21.0.linux-arm64.tar.gz
# ~/.zshrc 添加环境变量
export PATH=$PATH:/usr/local/go/bin
# 验证安装
go version
1.1.3 配置代理
go env -w GOPROXY=https://goproxy.cn,direct
go env GOPROXY
查看当前代理
1.1.4 安装 VSCode 插件
aldijav.golangwithdidi
包含两款插件:golang.go
、premparihar.gotestexplorer
1.2 常见命令
go get -u
获取/更新依赖包
go install
安装/编译 go 程序或包,一般是命令行程序,默认安装路径是 $HOME/go/bin
go mod init account
项目初始化,将创建一个名为 "account" 的模块
go mod tidy
自动安装依赖包,删除多余依赖
go mod vendor
将依赖包复制到 vendor 目录中,以实现版本控制和更稳定的构建。
go build
编译项目为可执行文件
go build;./account
编译并执行可执行文件
go test ./...
递归执行全部测试用例
1.3 初始化项目
go mod init account
go get -u github.com/gin-gonic/gin
1.4 项目代码结构
├── README.md ---项目开发流程笔记
├── account ---编译后的可执行文件
├── api ---API 请求响应类型定义
│ ├── api.go ---API 请求响应公共类型
│ ├── item_api.go
│ ├── me_api.go
│ ├── session_api.go
│ └── tag_api.go
├── cmd ---命令行应用程序的入口
│ └── cmd.go
├── config ---配置信息
│ ├── migrations ---数据库迁移文件
│ │ ├── 000001_create_users_table.down.sql
│ │ ├── ...
│ │ └── 000006_remove_type_kind.up.sql
│ ├── queries ---原生 SQL 语句
│ │ ├── items.sql
│ │ ├── tags.sql
│ │ ├── users.sql
│ │ └── validation_codes.sql
│ ├── schema.sql ---原生数据库表结构定义
│ ├── sqlc ---sqlc 自动生成的文件,用于数据库交互
│ │ ├── db.go
│ │ ├── items.sql.go
│ │ ├── models.go
│ │ ├── tags.sql.go
│ │ ├── users.sql.go
│ │ └── validation_codes.sql.go
│ └── viper.go ---读取密钥
├── coverage ---测试覆盖率
│ ├── cover.out
│ ├── coverage.html
│ ├── coverage.out
│ └── index.html
├── docs ---接口文档
│ ├── docs.go
│ ├── swagger.json
│ └── swagger.yaml
├── go.mod ---定义项目的依赖关系和版本要求
├── go.sum ---验证下载的模块的完整性
├── internal ---内包含了各种内部功能,如控制器、数据库、中间件、路由等
│ ├── controller ---控制器文件,用于处理 HTTP 请求和响应及单元测试文件
│ │ ├── controller.go ---Controller 接口定义
│ │ ├── item_controller.go
│ │ ├── item_controller_test.go
│ │ ├── me_controller.go
│ │ ├── me_controller_test.go
│ │ ├── ping.go
│ │ ├── session_controller.go
│ │ ├── session_controller_test.go
│ │ ├── setup_helper.go ---初始化测试及测试工具函数
│ │ ├── tag_controller.go
│ │ ├── tag_controller_test.go
│ │ ├── validation_codes_controller.go
│ │ └── validation_codes_controller_test.go
│ ├── database ---数据库连接及迁移信息
│ │ └── database.go
│ ├── email ---发送电子邮件功能
│ │ └── email.go
│ ├── jwt_helper ---生成与解析 jwt
│ │ └── jwt_helper.go
│ ├── middleware ---中间件(身份验证、错误处理)
│ │ └── me.go
│ └── router ---路由配置
│ └── router.go
├── main.go ---应用程序入口
├── sqlc.yaml ---sqlc 配置文件
├── test ---测试(暂时不用)
│ ├── controller_test
│ │ └── ping_test.go
│ └── database_test
│ └── database_test.go
├── viper.config.json.example ---Viper 配置文件示例
└── web ---前端或静态资源文件
1.5 命令行程序
基于 cobra
创建命令行程序,使开发工作流自动化,实现类似 rails 的 bin/rails db:create
功能
安装依赖包:
go get -u github.com/spf13/cobra@latest
使用
cobra.Command
提供的 Use 属性注册命令字符,Run 属性注册回调函数通过回调函数的
args
可以获取命令行参数可以在回调函数中调用内置 os 库的
Create、MkdirAll、Command
等方法,创建文件或者执行脚本
创建 cmd 包,用于承担项目开发过程中的所有命令行任务,例如启动服务器、同步数据库等
- 以下是开启服务、创建同步数据库文件与生成测试覆盖率文件的代码示例
// cmd/cmd.go
func Run() {
rootCmd := &cobra.Command{
Use: "account",
}
srvCmd := &cobra.Command{
Use: "server",
Run: func(cmd *cobra.Command, args []string) {
RunServer()
},
}
dbCmd := &cobra.Command{
Use: "db",
}
mgrCreateCmd := &cobra.Command{
Use: "migrate:create",
Run: func(cmd *cobra.Command, args []string) {
database.MigrateCreate(args[0])
},
}
coverCmd := &cobra.Command{
Use: "coverage",
Run: func(cmd *cobra.Command, args []string) {
// 使用 os 预先在根目录下创建 coverage 目录
os.MkdirAll("coverage", os.ModePerm)
// 使用 os/exec 执行命令行
if err := exec.Command("MailHog").Start(); err != nil {
log.Println(err)
}
if err := exec.Command(
"go", "test", "-coverprofile=coverage/cover.out", "./...",
).Run(); err != nil {
log.Fatalln(err)
}
if err := exec.Command(
"go", "tool", "cover", "-html=coverage/cover.out", "-o", "coverage/index.html",
).Run(); err != nil {
log.Fatalln(err)
}
// 使用 gin 开启本地文件服务
var port string
if len(args) > 0 {
port = args[0]
} else {
port = "8888"
}
fmt.Println("http://localhost:" + port + "/coverage/index.html")
if err := http.ListenAndServe(":"+port, http.FileServer(http.Dir("."))); err != nil {
log.Fatalln(err)
}
},
}
database.Connect()
defer database.Close()
rootCmd.AddCommand(srvCmd, dbCmd, coverCmd)
dbCmd.AddCommand(mgrCreateCmd)
rootCmd.Execute()
}
- 使用时无需输入根命令,只需从第二级命令开始输入即可,例如
go build;./account db migrate:create
1.6 程序入口
- 因为有 cobra 命令行程序,因此 main.go 文件主要做两件事就可以了:读取本地密钥与初始化命令行程序
// main.go
func main() {
viper_config.LoadViperConfig()
cmd.Run()
}命令行程序除了注册命令外,还要进行数据库的连接
服务启动时,首先初始化 gin 引擎实例,注册中间件和路由,再调用 Run 方法启动服务即可
由于 gin.Default() 会默认加载 Logger 中间件,导致测试控制台输出信息过多,不方便 debug
因此我们照抄源码的流程,不使用 Logger 即可,同时将 gin 框架默认的 debug 模式改为 release 模式(源码有相关提示)
这样就可以大幅减少控制台输出无用的 log
// cmd/cmd.go
func RunServer() {
// 创建路由
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
// 应用中间件: 注意中间件的声明位置,要在所有路由之前Use!
r.Use(middleware.Me([]string{"/swagger", "/api/v1/session", "/api/v1/validation_codes", "/ping"}))
// 注册路由
rg := r.Group("/api")
for _, ctrl := range loadControllers() {
ctrl.RegisterRoutes(rg)
}
// 文档路由及配置
docs.SwaggerInfo.Version = "1.0"
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
r.GET("/ping", controller.Ping)
r.Run(":8080")
}
1.7 断点调试
安装 go 断点调试命令行程序:
go install -v github.com/go-delve/delve/cmd/dlv@latest
在单元测试中点 debug test 进入断点调试,(此时 VSCode 也会自动安装此程序)
配置:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Go Debug",
"type": "go",
"request": "launch",
"mode": "auto",
// "program": "${fileDirname}"
"program": "${workspaceFolder}",
"args": [
"server"
]
}
]
}
2 Database
2.1 数据库选型
2.1.1 database/sql
sql package - database/sql - Go Packages 使用示例: feat: crud via sqlc · GSemir0418/account-backend-go@ac43545 · GitHub
- 优点:
- 官方包,得到 Go 社区的支持与维护
- 轻量级,不会引入额外的依赖
- 良好的跨数据兼容性
- 缺点:
- 缺少高级功能,如查询构建器、关联、迁移等
- 手动管理 sql 语句,易出错且难以维护
2.1.2 gorm
GORM Guides | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
使用示例: feat: crud via gorm · GSemir0418/account-backend-go@ee36037 · GitHub
- 优点:
- 提供了丰富的功能,如查询构建器、关联、迁移等
- 支持自动迁移数据库
- 提供了更高级的查询接口,减少了手动编写 sql 语句的需求
- 良好的文档和社区支持
- 缺点:
- 依赖更多的外部库,可能导致项目臃肿
- 抽象层可能导致性能损失
- 可能需要更多的学习成本
2.1.3 sqlc✅
Getting started with PostgreSQL — sqlc 1.23.0 documentation
- 优点:
- 将 sql 查询转换为类型安全的 go 代码,提高代码的可读性和安全性
- 通过生成代码,可以减少手动编写 sql 语句的错误
- 支持 postgresql 和 mysql
- 自动生成代码,易与维护
- 缺点:
- 没有支持所有类型的数据库
- 可能需要对 sql 语句进行调整以生成正确的 go 代码
- 自动生成的代码可能难以理解和调试
- 功能有限,缺少一些高级功能,如关联、迁移等
2.2 连接数据库
数据库连接字符串(与本项目无关):PostgreSQL connection strings - ConnectionStrings.com
使用官方包 database/sql 连接数据库。创建 database 包,对外提供数据库的迁移与连接等方法
database/sql
包已经内置了对 PostgreSQL 数据库的支持,因此无需显式引入 "github.com/lib/pq"
包来连接 PostgreSQL 数据库
// database/database.go
// 声明全局变量 DB,存储数据库实例,保证服务只存在一个 DB 实例
var DB *sql.DB
const (
host = "localhost"
port = 5432
user = "gsemir"
password = "gsemir"
dbname = "go_account_dev"
)
func Connect() {
// 防止重复连接
if DB != nil {
return
}
// 声明连接字符串
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", host, port, user, password, dbname)
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalln(err)
}
DB = db
err = db.Ping()
if err != nil {
log.Fatal(err)
}
}
2.3 数据库迁移
选择 golang-migrate/migrate
库作为数据库迁移工具
GitHub - golang-migrate/migrate: Database migrations. CLI and Golang library.
2.3.1 安装
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
2.3.2 创建迁移文件
在 config 中创建 migrations 目录
migrate create -ext sql -dir config/migrations -seq create_users_table
指定数据库迁移文件的扩展名为 sql,目录为 config/migrations,名称为 create_users_table
该命令会在指定目录下创建迁移和回退文件,其内容需要自行编写,例如 CREATE TABLE => DROP TABLE
,ALTER TABLE users ADD COLUMN => ALTER TABLE users DROP COLUMN
等
2.3.3 注意事项
- 当迁移的内容过多或者较复杂时,要使用事务进行迁移与回退
# 000006_remove_type_kind.up.sql
BEGIN;
ALTER TABLE items
ALTER COLUMN kind TYPE VARCHAR(100),
ALTER COLUMN kind SET DEFAULT 'expenses';
ALTER TABLE tags
ALTER COLUMN kind TYPE VARCHAR(100),
ALTER COLUMN kind SET DEFAULT 'expenses';
DROP TYPE kind;
COMMIT;
# 000006_remove_type_kind.down.sql
BEGIN;
CREATE TYPE kind AS ENUM ('expenses', 'in_come', '');
ALTER TABLE items DROP COLUMN kind;
ALTER TABLE items ADD COLUMN kind kind NOT NULL DEFAULT 'expenses';
ALTER TABLE tags DROP COLUMN kind;
ALTER TABLE tags ADD COLUMN kind kind NOT NULL DEFAULT 'expenses';
COMMIT;
如果迁移(up)出错了,且不能使用回滚(down)来解决,那么手动修改数据库的
schema_migrations
表格,将数据设置为上次的版本,dirty
字段设为false
:update schema_migrations set version=3,dirty=false;
然后手动修改数据库或迁移文件错误,重新执行迁移即可
2.3.4 运行迁移文件
migrate -database "postgres://gsemir:gsemir@localhost:5432/go_account_dev?sslmode=disable" \
-source "file://$(pwd)/config/migrations" up
2.3.5 封装 cmd
使用 cobra 封装数据库同步、回退等命令。注意引入依赖的形式
// database/database.go
package database
import (
"database/sql"
"fmt"
"log"
"os/exec"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func MigrateCreate(filename string) {
cmd := exec.Command("migrate", "create", "-ext", "sql", "-dir", "config/migrations", "-seq", filename)
err := cmd.Run()
if err != nil {
log.Fatalln(err)
}
}
func MigrateUp() {
dir, err := os.Getwd()
if err != nil {
log.Fatalln(err)
}
m, err := migrate.New(
fmt.Sprintf("file://%s/config/migrations", dir),
fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
user, password, host, port, dbname,
),
)
if err != nil {
log.Fatalln(err)
}
err = m.Up() // 会直接同步所有更新
// MigrateDown 方法是调用 m.Step(-1),默认只回退一次
if err != nil {
log.Fatalln(err)
}
}
// cmd/cmd.go
mgrCreateCmd := &cobra.Command{
Use: "migrate:create",
Run: func(cmd *cobra.Command, args []string) {
database.MigrateCreate(args[0])
},
}
- 创建迁移文件命令
go build; ./account db migrate:create add_email_to_users
- 运行迁移文件命令
go build; ./account db migrate:up
- 回滚迁移命令
go build; ./account db migrate:down
2.4 sqlc 的使用
sqlc 是编译器,可以将 sql 语句编译为 Go 的 struct 和 func,以保证类型安全
- 安装命令行程序
brew install sqlc
或 go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
- 项目根目录创建配置文件 sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "config/queries" # sql 语句目录
schema: "config/schema.sql" # 表结构
gen:
go:
emit_json_tags: true #自动添加 json 标签
json_tags_case_style: snake # struct 的 json 输出格式为 snake(snake pascal camel)
package: "queries" # 生成的包名
out: "config/sqlc" # 输出目录
- 在 config/schema.sql 中复制 migration 文件中的建表语句即可
- config/queries 写 sql 查询语句
# config/queries/items.sql
-- name: CreateItem :one
INSERT INTO items (
user_id,
amount,
kind,
happened_at,
tag_ids
) VALUES (
$1,
$2,
$3,
$4,
$5
)
RETURNING *;
-- name: ListItems :many
SELECT * FROM items
ORDER BY happened_at DESC
OFFSET $1
LIMIT $2;
-- name: CountItems :one
SELECT count(*) FROM items;
-- name: DeleteAllItems :exec
DELETE FROM items;
-- name: ListItemsByHappenedAtAndKind :many
SELECT * from items
WHERE happened_at >= @happened_after
AND happened_at < @happened_before
AND kind = @kind
AND user_id = @user_id
ORDER BY happened_at DESC;
参数可以使用 $1
占位符,也可以显示指定参数 @user_id
(不能与 sql 中的关键字冲突)
- 执行
sqlc generate
生成对应的 Go 代码
3 单元测试
3.1 初始化
测试前,将公共初始化过程抽离出来,减少代码冗余
这里我们将初始化服务、注册中间件、连接数据库等操作抽离至 internal/controller/setup_helper.go 中
并提供包内全局变量 r (gin服务实例)、q(数据库查询实例)、c(默认上下文)供单元测试使用
var (
r *gin.Engine
q *queries.Queries
c context.Context
)
func setUpTestCase(t *testing.T) func(t *testing.T) {
// 读取 viper 配置
viper_config.LoadViperConfig()
// 连接数据库
database.Connect()
q = database.NewQuery()
// 初始化 gin 服务器
gin.SetMode(gin.ReleaseMode)
r = gin.New()
// 应用中间件
r.Use(gin.Recovery())
r.Use(middleware.Me([]string{"/swagger", "/api/v1/session", "/api/v1/validation_codes", "/ping"}))
// 默认上下文
c = context.Background()
// 清空 User 表
if err := q.DeleteAllUsers(c); err != nil {
t.Fatal(err)
}
// 清空 Items 表
if err := q.DeleteAllItems(c); err != nil {
t.Fatal(err)
}
// 清空 Tags 表
if err := q.DeleteAllTags(c); err != nil {
t.Fatal(err)
}
// 返回清理函数,开发者自行选择执行
return func(t *testing.T) {
database.Close()
}
}
一些测试中重复的逻辑也可以抽离到这里,例如生成 jwt 构建权限请求头的逻辑
func logIn(t *testing.T, userID int32, req *http.Request) {
jwtString, _ := jwt_helper.GenerateJWT(int(userID))
// Go 语言会自动解引用结构体指针并访问结构体对象的字段,因此可以直接访问 req.Header
req.Header = http.Header{
"Authorization": []string{"Bearer " + jwtString},
}
}
3.2 单元测试
3.2.1 测试流程
- 初始化
- 注册路由
- 初始化 `
httptest.NewRecorder()
,用于记录响应数据; - 构造请求
http.NewRequest()
;创建用户,添加请求头权限字段 r.ServeHTTP(w, req)
发起请求assert
断言响应状态码及响应体数据
3.2.2 代码示例
以 create item api 为例
func TestItemControllerWithUser(t *testing.T) {
// 初始化
cleanup := setUpTestCase(t)
defer cleanup(t)
// 注册路由
ic := ItemController{}
ic.RegisterRoutes(r.Group("/api"))
// 初始化 w
w := httptest.NewRecorder()
// 构造请求
reqBody := gin.H{
"amount": 100,
"kind": "in_come",
"happened_at": time.Now(),
"tag_ids": []int32{1, 2, 3},
}
bytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(
"POST",
"/api/v1/items",
strings.NewReader(string(bytes)),
)
// 登录
u, _ := q.CreateUser(c, "1@qq.com")
logIn(t, u.ID, req)
// 发起请求
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
// 处理响应体
var resBody api.CreateItemResponse
json.Unmarshal(w.Body.Bytes(), &resBody)
// 断言
assert.Equal(t, u.ID, resBody.Resource.UserID)
}
3.2.3 注意事项
- url 参数构造
查询字符串与 url 的拼接有两种常用方案
// 方案一 直接拼
req, _ := http.NewRequest(
"GET",
"/api/v1/items/balance?happened_after="+url.QueryEscape("2023-09-29T00:00:00+0800")+
"&happened_before="+url.QueryEscape("2023-10-01T00:00:00+0800"),
nil,
)
// 方案二 先构造,统一Encode,再拼接
qs := url.Values{
"happened_after": []string{"2023-09-01T00:00:00+08:00"},
"happened_before": []string{"2023-09-03T00:00:00+08:00"},
"kind": []string{"expenses"},
"group_by": []string{"tag_id"},
}.Encode()
req, _ := http.NewRequest(
"GET",
"/api/v1/items/summary?"+qs,
nil,
)
其中,url.QueryEscape()
将字符串中的特殊字符(空格、加号、等号、与号、问号等)转换为百分比编码形式,以便安全地包含在URL中;url.Values
是一个存储URL查询参数的类型,它可以用于构建查询字符串。Encode
方法将 url.Values
编码为查询字符串形式;
类似 JavaScripts 中的 encodeURIComponent
或者浏览器环境下的 URLSearchParams
类
- 请求体处理
在 Gin 框架中,gin.H
是一个用于构建 JSON 数据的简便方式的类型;
json.Marshal
函数用于将 Go 中的数据结构转换为 JSON 格式的字节切片;
reqBody := api.CreateTagRequest{
Name: "test",
Kind: "in_come",
Sign: "😈",
}
// reqBody := gin.H{
// "name": "test",
// "kind": "in_come",
// "sign": "😈",
// }
bytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(
"POST",
"/api/v1/tags",
strings.NewReader(string(bytes)),
)
- 响应体处理
使用 json.Unmarshal
函数将 JSON 格式的数据解析为 Go 中的数据结构。它接受一个 []byte
切片和一个指向目标类型的指针,并将 JSON 数据解析到目标类型中。
body := w.Body.String()
var j api.GetSummaryByTagIDResponse
json.Unmarshal([]byte(body), &j)
assert.Equal(t, 60000, j.Total)
- 时间格式处理
TODO
- 常用断言
assert.Equal(t, expected, actual)
断言是否相等;assert.True(t, someCondition)
和assert.False
分别用于断言表达式是否为真或为假;assert.NotNil(t, someValue)
和assert.Nil
分别用于断言值是否非空或为空;assert.Contains(t, collection, element)
用于断言一个集合(数组、切片、映射等)中是否包含某个元素assert.Empty(t, Collection)
和assert.NotEmpty
分别用于断言集合是否为空或非空。
3.3 展示测试覆盖率
- 单测文件与控制器文件须在同一个包下,这也是我们没有设置独立 test 目录的原因
展示测试覆盖率
go test -coverprofile=coverage/coverage.out ./...
生成测试覆盖率html
go tool cover -html=coverage/coverage.out -o coverage/coverage.html
- 将测试结果可视化,方便补充测试用例
3.4 优化工作流
- 目前我们的TDD流程为:
写测试 => 写代码 => 开启 MailHog => 执行测试 => 生成覆盖率 => 生成覆盖率 html => 开启http服务打开html => 根据覆盖率调整测试代码
可以将”开启MailHog“到”开启http服务打开html“的过程抽离为一个控制台命令 coverage
,具体代码详见 1.5 命令行程序。
- 具体实现
os/exec
包提供了运行系统脚本的功能;使用gin框架提供的 http.FileSever
方法开启本地文件服务
exec.Command(...).Start()
和exec.Command(...).Run()
的区别:前者会执行这条命令,但不会等待其结束,直接执行下一行代码
4 密钥管理
4.1 使用环境变量进行管理
可以使用传统的环境变量的方式进行密钥管理,即在 .zshrc 中 export EMAIL_SMTP_PWD='xxxxxx'
,在项目中通过 os.Getenv("EMAIL_SMTP_PWD")
获取到密钥即可
但当协同开发时设置环境变量会很繁琐,且数据类型仅支持字符串,需要手动转换
4.2 viper
使用 viper 进行项目的密钥管理
安装:
go get github.com/spf13/viper
配置文件:
viper.config.json
,可以放在服务器根目录下(方便测试环境读取配置信息)
项目根目录下的 viper.config.json.example
仅供参考
针对此项目,需要管理的密钥主要是 jwt 加密密钥以及邮箱授权码
其中邮箱授权码直接以字面量形式存储;jwt 加密密钥则保存在服务器中,viper 仅对其路径做管理。
{
"jwt": {
"hmac": {
"keyPath": "/Users/gsemir/.account/jwt/hmac.key"
}
},
"email": {
"smtp": {
"host": "smtp.qq.com",
"port": 465,
"user": "845217811@qq.com",
"password": "xxxx"
}
}
}
4.3 viper 的使用
- 创建加载方法(在 main.go 以及测试初始化方法中调用),读取配置文件
// config/viper.go
package viper_config
import (
"log"
"github.com/spf13/viper"
)
func LoadViperConfig() {
// 1. 设置配置文件的路径、文件名、文件格式
viper.AddConfigPath("$HOME/.account/")
viper.SetConfigName("viper.config")
viper.SetConfigType("json")
// 2. 使用 ReadInConfig 读取配置
err := viper.ReadInConfig()
if err != nil {
log.Fatalln(err)
}
}
- 使用密钥
viper提供了 GetString(keyName)
或 GetInt(keyName)
等方法读取密钥值
// 读取 jwt 密钥
func getHmacSecret() ([]byte, error) {
keyPath := viper.GetString("jwt.hmac.keyPath")
return os.ReadFile(keyPath)
}
// 发送邮件
func newDialer() *gomail.Dialer {
return gomail.NewDialer(
viper.GetString("email.smtp.host"),
viper.GetInt("email.smtp.port"),
viper.GetString("email.smtp.user"),
viper.GetString("email.smtp.password"),
)
}
5 发送邮件
5.1 gomail
使用流程:
gomail.NewMessage()
构造邮件内容gomail.NewDialer()
构造拨号器(邮件服务器配置)dialer.DialAndSend(message)
发送邮件
// internal/email/email.go
func newMessage(to, subject, body string) *gomail.Message {
m := gomail.NewMessage()
m.SetHeader("From", "845217811@qq.com")
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", body)
return m
}
func newDialer() *gomail.Dialer {
return gomail.NewDialer(
viper.GetString("email.smtp.host"),
viper.GetInt("email.smtp.port"),
viper.GetString("email.smtp.user"),
viper.GetString("email.smtp.password"),
)
}
func SendValidationCode(email, code string) error {
m := newMessage(
email,
fmt.Sprintf("[%s] 记账验证码", code),
fmt.Sprintf(`
你正在登录或注册记账网站,你的验证码是 %s 。
<br/>
如果你没有进行相关的操作,请直接忽略本邮件即可`, code),
)
d := newDialer()
return d.DialAndSend(m)
}
5.2 生成真随机验证码
- 使用
crypto/rand
库
注意数字切片转换为字符编码的逻辑
// 使用内置库 crypto/rand 生成随机四位验证码
func generateDigits() (string, error) {
len := 4
// 开辟一个 4 字节的切片
b := make([]byte, len)
// 使用 rand.Read 方法填充切片
// 此时 b 的类型为 []uint8 其中uint8的范围是 0-255
_, err := rand.Read(b)
if err != nil {
return "", err
}
// 将uint8数字转换为字符
digits := make([]byte, len)
for i := range b {
// 数字转换为字符编码
// b[i]%10 就可以得到一个 0-9 的数字
// '0' 对应的编码是 48 所以要加 48 转换为字符编码
digits[i] = b[i]%10 + 48
}
// [49, 50, 51, 52] 转为字符串就是 "1234"
return string(digits), nil
}
5.3 MailHog
使用 MailHog 简化邮件测试
安装:
go install github.com/mailhog/MailHog@latest
优势:无需在打开目标邮箱去看邮件是否正常发送了
在测试中,需要将读取到的邮件配置
email.smtp.host
和email.smtp.port
使用 Viper 覆盖viper.Set("email.smtp.port", "1025")
viper.Set("email.smtp.host", "localhost")MailHog 命令启动本地邮件服务器, 8025 前端,1025 后端
此外该服务器还提供了 api 用于读取全部收到的邮件,用于在测试代码中调用 这样连打开网页检查都不用了(本项目没用到)
6 接口开发
6.1 Controller 接口定义与生成
Controller Interface
为了方便管理 Controller 的开发与引入,统一设计 Controller 接口。
接口具有如下特征:
定义了一组方法的集合(方法签名),但不包含具体的实现。
接口描述了对象的行为,而不关心对象的具体类型。
接口是一种动态类型,可以容纳任何实现了该接口的类型。
package controller
import "github.com/gin-gonic/gin"
type Controller interface {
Get(c *gin.Context)
Create(c *gin.Context)
Update(c *gin.Context)
Find(c *gin.Context)
Destory(c *gin.Context)
GetPaged(c *gin.Context)
RegisterRoutes(rg *gin.RouterGroup)
}
go 中没有继承与实现的概念,只要这个结构体有接口要求的方法就可以作为这个接口的实现
自动生成 Controller 代码
每个模块的 ctrler 会存在大量模板代码,这种事情交给机器做就好
使用 impl 库,基于Controller 接口文件,自动编写各模块 controller 结构体方法:
josharian/impl: impl generates method stubs for implementing an interface. (github.com)
安装:
go install github.com/josharian/impl@latest
语法如下:
impl 'ctrl *SessionController' account/internal/controller.Controller
配合 vscode 插件,ctrl+shift+p
搜索 go stubs
命令
输入: ctrl *SessionController account/internal/controller.Controller
package controller
import (...)
type ValidationCodeController struct{}
// 其中 (ctrl *ValidationCodeController) 表示该方法的接收者为 ctrl
// 可以理解为该方法是 ValidationCodeController 类的实例方法
func (ctrl *ValidationCodeController) Update(c *gin.Context) {
panic("not implemented") // TODO: Implement
}
func (ctrl *ValidationCodeController) Get(c *gin.Context) {
panic("not implemented") // TODO: Implement
}
// 每个 ctrl 自己负责分配与注册路由
func (ctrl *ValidationCodeController) RegisterRoutes(rg *gin.RouterGroup) {
v1 := rg.Group("/v1")
v1.POST("validation_codes", ctrl.Create)
}
// ...
- 注意输入指令前,鼠标光标应放置在代码插入的位置
router 注册
在路由初始化逻辑中,批量注册 ctrler
- 首先批量初始化各模块的 ctrler 结构体,该方法返回 Controller 接口的切片
// 在 Go 中,接口是一种抽象类型,不能直接实例化。
// 接口本身是一组方法的集合,需要结构体或其他自定义类型来实现这些方法,然后通过这些类型来创建接口的实例。
// 也就是说 必须使用结构体或其他自定义类型的实例对象 作为接口的实例 且其类型必须为指针
func loadControllers() []controller.Controller {
return []controller.Controller{
&controller.SessionController{},
&controller.MeController{},
&controller.ItemController{},
&controller.ValidationCodeController{},
&controller.TagController{},
}
}
- 在router的初始化方法中,循环切片,调用每个模块实例的注册路由方法
func New() *gin.Engine {
// 注册路由
rg := r.Group("/api")
for _, ctrl := range loadControllers() {
// 实例通过调用自己的 RegisterRoutes 来注册路由(需要将 r.Group 返回值传递进去)
ctrl.RegisterRoutes(rg)
}
// ...
return r
}
6.2 接口出入参类型声明
根据接口文档,声明接口出入参类型,抽离为 api 包
除了声明各字段的数据类型,还可以在后面使用反引号为字段添加标签,它们告诉 Gin 如何绑定 HTTP 请求中的数据到这个结构体的字段上,并且可以包含一些校验逻辑
package api
import (...)
type CreateItemRequest struct {
Amount int32 `json:"amount" binding:"required"`
Kind string `json:"kind" binding:"required"`
HappenedAt time.Time `json:"happened_at" binding:"required"`
TagIds []int32 `json:"tag_ids" binding:"required"`
}
type CreateItemResponse struct {
Resource queries.Item
}
type GetPagedItemsRequest struct {
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
HappenedAfter time.Time `json:"happened_after"`
HappenedBefore time.Time `json:"happened_before"`
}
type GetPagedItemsResponse struct {
Resources []queries.Item
Pager Pager
}
type GetSummaryRequest struct {
HappenedAfter time.Time `form:"happened_after" binding:"required"`
HappenedBefore time.Time `form:"happened_before" binding:"required"`
Kind string `form:"kind" binding:"required,oneof=expenses in_come"`
GroupBy string `form:"group_by" binding:"required,oneof=tag_id happened_at"`
}
type GetSummaryByTagIDResponse struct {
Groups []SummaryGroupByTagID `json:"groups"`
Total int `json:"total"`
}
gin.H
在 Gin 框架中,gin.H
是一个类型,它是一个映射(map),用于表示 HTTP 响应的键值对(key-value)数据。gin.H
类型实际上是一个 map[string]interface{}
的别名,即一个键为字符串类型、值为任意类型的映射。
gin.H
主要用于构建和处理 HTTP 响应的数据,通常用于在路由处理函数中返回 JSON 数据或渲染模板时传递数据。它提供了一种方便的方式来构建包含键值对的数据结构,而不必显式地声明结构体。
6.3 Api 请求与响应
6.3.1 获取请求数据
url 查询参数
- 路径参数
例如 api/v1/user/:id
,使用 c.Params.GET("id")
来获取
- 查询参数
例如 api/v1/user?id=1
,使用 c.Request.URL.Query().Get("page")
来获取
如果查询参数过多,也可以使用 c.BindQuery()
方法将查询字符串绑定为 query 结构体
var query api.GetSummaryRequest
if err := c.BindQuery(&query); err != nil {...}
注意 api.GetSummaryRequest
结构体字段类型的标签要使用 form
而不是 json
请求体
使用 c.ShouldBindJSON()
方法将 JSON 请求体绑定为 go 结构体数据
var reqBody api.CreateTagRequest
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.String(422, "参数错误")
return
}
Bind
c.Bind()
会通过Content-Type
请求头判断参数类型,从而确定使用BindQuery
或者BindJSON
来解析参数为 go 的结构体- Bind 方法底层调用的是
MustBindWith
方法,报错后会直接返回状态码 400ShouldBindWith
报错后不会返回状态码
- 我们可以在 Bind 报错后,使用
c.Writer.WriteString("参数错误")
来追加返回错误信息,(其中 Writer 就相当于响应体)
6.3.2 构造响应体
当仅需返回一个状态码时,使用 c.Status(code)
,例如 c.Status(http.StatusUnauthorized)
当需要返回字符串信息时,使用 c.String(code, message)
,例如 c.String(422, "参数错误")
当需要返回 JSON 数据时,使用 c.JSON(code, struct)
,例如
c.JSON(http.StatusOK, api.GetPagedTagsResponse{
Resources: tags,
Pager: api.Pager{
Page: params.Page,
PageSize: params.PageSize,
Total: count,
},
})
6.3.3 错误处理
主要是利用结构体类型的标签语法,对请求数据进行校验
例如,在声明 api 请求与响应结构体类型时,后面的标签 binding:"required"
表示此项在绑定结构体时为必填项。如果该参数没传,会触发 BindQuery 方法报错
获取校验报错信息,首先要想办法拿到发生错误的字段以及错误类型
获取错误字段及类型
目前 error 的类型只是 error,无法区分其具体错误的类型
通过 debug,看到 error 的类型是一个切片,并出现了 validator.ValidationErrors
字样
于是访问这个包的官网
安装
go get github.com/go-playground/validator/v10
引入
import "github.com/go-playground/validator/v10"
ValidationErrors 类型是 FieldError 类型的切片,用于验证后的自定义错误消息
明确了这个类型,我们可以通过 switch case 断言 error 的类型,遍历 error 切片,通过 FieldError 提供的方法 Tag() 和 Field() 明确错误种类及报错的字段
func (ctrl *ItemController) GetSummary(c *gin.Context) {
var query api.GetSummaryRequest
if err := c.BindQuery(&query); err != nil {
er := api.ErrorResponse{Errors: map[string][]string{}}
switch e := err.(type) {
case validator.ValidationErrors:
for _, ve := range e {
// 错误标签
tag := ve.Tag()
// 错误字段
field := ve.Field()
if er.Errors[field] == nil {
er.Errors[field] = []string{}
}
// 给该field的数组追加一项
er.Errors[field] = append(er.Errors[field], tag)
}
c.JSON(http.StatusUnprocessableEntity, r)
default:
c.Writer.WriteString("参数错误")
}
return
}
//...
}
内置校验规则
除了 required,gin 还内置了其他校验规则,但 gin 文档中并未提及,说明此校验器并不是 gin 原生提供的
通过查看 BindQuery 的源码,可以看到在绑定结构体时,会将数据直接交给 validate(Validator.ValidateStruct)
方法,而提供这个方法的正是我们刚才安装的 validator 包
func (queryBinding) Bind(req *http.Request, obj any) error {
values := req.URL.Query()
if err := mapForm(obj, values); err != nil {
return err
}
return validate(obj)
}
gin 在某个时刻调用 validator.New() 初始化了 validator 的实例
其他校验器
validator package - github.com/go-playground/validator/v10 - Go Packages
针对 Kind 字段,只允许是某几个字符串(expenses | in_come
)的其中一种,选择 oneof
检验规则
文档没有示例写法,可以google validator oneof tag usage
在 api 类型声明文件中,定义 Kind 字段的类型及校验规则
Kind string `form:"kind" binding:"required,oneof=expenses in_come"`
后续可以将这个错误处理改为中间件
6.4 登录与中间件
6.4.1 登录 api
创建 Session Controller => 声明数据库表类型及 SQL 语句 => 声明接口数据类型 => 写单元测试 => 写登录逻辑
登录逻辑
获取与校验请求体数据 => 查询验证码是否有效 => 查询用户(无则创建) => 返回 id 及 jwt
jwt
生成示例:jwt package - github.com/golang-jwt/jwt/v5 - Go Packages
安装:
go get -u github.com/golang-jwt/jwt/v5
创建 jwt_helper package,定义 jwt 辅助方法,分别用于
生成加密用的密钥(64 位随机数)
- 将 HMAC 密钥生成后保存到本地环境变量,避免重复生成
- os.WriteFile 保存到本地,viper 环境变量中存文件路径即可
- 通过命令行工具生成 jwt 密钥并保存
func GenerateHmacSecret() ([]byte, error) {
key := make([]byte, 64)
_, err := io.ReadFull(rand.Reader, key)
if err != nil {
return nil, err
}
return key, nil
}
// cmd.go
generateHmacSecretCmd := &cobra.Command{
Use: "generateHmacSecret",
Run: func(cmd *cobra.Command, args []string) {
// 生成jwt密钥并保存到本地
bytes, _ := jwt_helper.GenerateHmacSecret()
keyPath := viper.GetString("jwt.hmac.keyPath")
// os.WriteFile(路径, 字节数据, 权限)
if err := os.WriteFile(keyPath, bytes, 0644); err != nil {
log.Fatalln(err)
}
fmt.Println("HMAC key has been saved in ", keyPath)
},
}根据 viper 存储的路径读取密钥
func getHmacSecret() ([]byte, error) {
keyPath := viper.GetString("jwt.hmac.keyPath")
return os.ReadFile(keyPath)
}使用 jwt.Token 提供的
SignedString
方法,基于用户 id 加密生成 jwt 字符串,作为登录接口返回值func GenerateJWT(user_id int) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user_id,
})
secret, err := getHmacSecret()
if err != nil {
return "", err
}
return token.SignedString(secret)
}解析 jwt 字符串,返回 jwt.Token 指针
func ParseJWT(jwtString string) (*jwt.Token, error) {
key, err := getHmacSecret()
if err != nil {
return nil, err
}
return jwt.Parse(jwtString, func(t *jwt.Token) (interface{}, error) {
return key, nil
})
}
获取登录用户
在请求头中获取权限字段(jwt),解密出 user_id,查找这个 id 是否是有效用户
func getMe(c *gin.Context) (queries.User, error) {
var user queries.User
auth := c.GetHeader("Authorization")
if len(auth) < 8 {
return user, fmt.Errorf("JWT为空")
}
// 截取 Bearer 后的字符
jwtString := auth[7:]
t, err := jwt_helper.ParseJWT(jwtString)
if err != nil {
return user, fmt.Errorf("无效的jwt")
}
// 当你通过指针调用一个方法时,Go 会自动对指针进行解引用,然后调用相应的方法
// 所以这里可以通过指针直接调用 Claims 方法,获取 jwt 声明内容
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return user, fmt.Errorf("无效的jwt")
}
userID, ok := claims["user_id"].(float64)
if !ok {
return user, fmt.Errorf("无效的jwt")
}
q := database.NewQuery()
u, err := q.FindUser(c, int32(userID))
if err != nil {
return user, fmt.Errorf("无效的jwt")
}
return u, nil
}
6.4.2 中间件
鉴权中间件
gin 框架的中间件常见结构:
func Middleware() gin.HandlerFunc {
// 返回一个函数
return func(c *gin.Context) {
// ...
// 暴露到上下文中,作为全局变量
c.Set("me", user)
// 中间件是按照注册的顺序执行的
// 移交控制权(下一个中间件或处理函数)
c.Next()
}
}
可以将获取登录用户的逻辑抽离出来,作为鉴权中间件,统一处理权限校验逻辑
// middleware.go
func Me(whiteList []string) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// 检测白名单
for _, s := range whiteList {
if has := strings.HasPrefix(path, s); has {
c.Next()
return
}
}
user, err := getMe(c)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"message": err.Error(),
})
return
}
// 将 me 放到上下文中,作为全局变量
c.Set("me", user)
c.Next()
}
}
错误处理中间件
TODO
6.5 讨论:更新接口入参讨论
主要围绕 Go 中更新字段传参类型讨论
当某字符串类型字段不能为空(NOT NULL
)
更新时,如果不想更新该字段,但由于 api 中定义了该字段类型是 string,所以即使不传该字段, Go 也会自动将其变成 ""
,
如何让空字符串表示不更新呢:在 sql 语句中使用 CASE WHEN THEN
使其忽略空字符串的情况
方案一 CASE WHEN THEN
方案一可以解决必填字符串类型的问题,注意标注入参的类型,不然 sqlc 不认识
-- name: UpdateTag :one
UPDATE tags
SET
user_id = @user_id,
name = CASE WHEN @name::varchar = '' THEN name ELSE @name END,
sign = CASE WHEN @sign::varchar = '' THEN sign ELSE @sign END,
kind = CASE WHEN @kind::varchar = '' THEN kind ELSE @kind END
WHERE id = @id
RETURNING id, user_id, name, sign, kind, deleted_at, created_at, updated_at;
此时,对于某非空字符串字段,在更新时传 ""
表示不更新该字段,保留原来的值
当某字符串类型字段可以为空
更新时,会出现三种情况:nil/""/"newStr"
,此时 nil
表示不更新该字段,""
表示将字符串置为 ""
对于以上两种情况,Go 会自动将 ""
和 nil
都变为 ""
(还是因为 api 中声明了 string 类型)
如何在传 nil 时表示此项不更新呢:
方案二 使用 NullString 类型
当声明数据库的 schema 时,如果某字段类型为 VARCHAR(100)
没有 NOT NULL
关键字
那么在 sqlc 生成的 go 代码中,该字段的类型会被指定为 sql.NullString
而不是 string
type NullString struct {
String string
Valid bool // Valid is true if String is not NULL
}
NullString
类型是一个结构体,这就会导致该字段会在响应体中变成一个对象返回给前端,而且在请求体中也要将该字段作为对象传回来
虽然 Go 可以借助这个结构体区分 nil
和 ""
,但完全不符合我们的开发使用习惯
方案二改进 重写 NullString 类型
重写 NullString 类型主要是重写结构体的序列化和反序列化方法:MarshalJSON
UnmarshalJSON
以及数据库读写方法:Scan
和 Value
// config/my_null_string.go
package config
import (
"bytes"
"database/sql/driver"
"encoding/json"
"fmt"
)
type MyNullString struct {
String string
Valid bool
}
func (s MyNullString) MarshalJSON() ([]byte, error) {
if s.Valid {
return []byte(`"` + s.String + `"`), nil
}
return []byte("null"), nil
}
func (s *MyNullString) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte("null")) {
s.Valid = false
return nil
}
if err := json.Unmarshal(data, &s.String); err != nil {
return fmt.Errorf("null: couldn't unmarshal JSON: %w", err)
}
s.Valid = true
return nil
}
// 从数据库读值
func (s *MyNullString) Scan(value interface{}) error {
if value == nil {
s.Valid = false
return nil
}
s.String, s.Valid = value.(string)
return nil
}
// 向数据库写值
func (s MyNullString) Value() (driver.Value, error) {
if !s.Valid {
return nil, nil
}
return s.String, nil
}
然后修改 sqlc 配置,指定 varchar 类型非空时的类型为我们重写的 MyNullString
即可
- db_type: "pg_catalog.varchar"
nullable: true
go_type:
import: "mangosteen/config"
type: "MyNullString"
pointer: false
type: "string"
pointer: true
通过重写 NullString
类型,使得 Go 和 sqlc 能够自动处理字符串类型为 nil
的情况,但比较麻烦
方案三 使用 *string
可以在 sqlc 的配置中将 varchar 类型非空的字段指定为字符串的指针类型(pointer: true
)
利用指针可以为空的特点,如果不传值(或传 null),那么 go 会默认变成 nil(null)
如果传空字符串,那么 Go 会默认变成空字符串
麻烦的点在于,如果要在 Go 代码中使用这个字段的值,每次使用前需进行判空处理(因为指针为空时,取值会报错)
方案四 使用 null 库
使用第三方库,使用思路与方案三一致
guregu/null: reasonable handling of nullable values (github.com)
go get gopkg.in/guregu/null.v4
- db_type: "pg_catalog.varchar"
nullable: true
go_type:
import: "gopkg.in/guregu/null.v4"
type: "String"
pointer: false
之后修改 api 中该字段出入参类型为 null.String
注意在 Go 代码中使用时要以结构体来读,而不是直接作为字符串判断
例如在测试代码中:assert.Equal(t, "xxx", j.Resource.X.String)
7 文档生成
使用 swaggo 生成API文档
使用文档: swag/README_zh-CN.md at master · swaggo/swag (github.com)
安装:
go install github.com/swaggo/swag/cmd/swag@latest
常用命令:
- 格式化:
swag fmt
- 生成:
swag init --parseDependency && go build . && ./account server
- 格式化:
在 router 中注册 swagger 路由及配置
import (
"account/internal/controller"
"account/internal/middleware"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files" // swagger embed files
ginSwagger "github.com/swaggo/gin-swagger" // gin-swagger middleware
"account/docs"
)
func New() *gin.Engine {
// ...
// 也可以在配置文件中手动修改文档信息
docs.SwaggerInfo.Version = "1.0"
// 文档路由及配置
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
7.1 通用文档注释
通用注释写在 main 中
// main.go
// @title 记账
// @description 记账应用接口文档
//
// @contact.name GSemir
// @contact.url http://gsemir0418.github.com/
// @contact.email gsemir0418@gmail.com
//
// @host localhost:8080
// @BasePath /
//
// @securityDefinitions.apiKey Bearer
// @in header
// @name Authorization
//
// @externalDocs.description OpenAPI
// @externalDocs.url https://swagger.io/resources/open-api/
func main() {}
访问 http://localhost:8080/swagger/index.html
7.2 接口文档注释
文档格式
注意要紧贴控制器方法 中间不要有空行
且注释中间也不能有空行 要使用空的//相连
// CreateTag
//
// @Summary 创建标签
// @Accept json
// @Produce json
//
// @Security Bearer
//
// @Param name body string true "金额(单位:分)" example(通勤)
// @Param kind body string true "类型" example(expenses)
// @Param sign body string true "符号" example(😈)
//
// @Success 200 {object} api.CreateTagResponse
// @Failure 401 {string} string 无效的JWT
// @Failure 422 {string} string 参数错误
// @Router /api/v1/tags [post]
func (ctrl *TagController) Create(c *gin.Context) {}
7.3 支持 JWT 测试
main.go
// @securityDefinitions.apiKey Bearer
// @in header
// @name Authorization
在需要权限的文档加上:能自动带上 Authorizition 请求头
// @Security Bearer
之后我们在测试前获取到 validationCode 然后访问 session 接口,将返回的 jwt 保存在文档上方 Authorize 的位置(别忘 Bearer )
需要权限的接口就会自动携带这个全局 jwt 来请求了
8 打包部署
TODO