跳到主要内容

记账项目总结

Why Golang

推荐前端开发学习 Golang 的原因主要有以下几点:

  1. 高性能:Golang 是一种编译型语言,具有较快的执行速度和较低的内存占用。与 Node.js 等解释型语言相比,Go 在处理高并发和 I/O 密集型任务时表现更优,适合构建高性能的后端服务。例如 esbuild,docker
  2. 简洁的语法:Go 的语法相对简单,易于学习和使用。对于已经熟悉 JavaScript 的前端开发者来说,Go 的语法结构和基本概念(如函数、结构体等)容易上手,能够快速适应。没有传统面向对象的感觉,很像 TypeScript
  3. 强大的并发支持:Go 的并发模型基于 goroutines 和 channels,使得编写并发程序变得简单而高效。这对于需要处理多个请求的 Web 应用程序尤为重要,可以轻松实现高并发处理。GoRoutines Channels 都没学习过。
  4. 丰富的标准库:Go 的标准库提供了强大的工具集,尤其是在网络编程和 Web 开发方面。开发者可以利用内置的 HTTP 包快速构建 Web 服务器和处理请求,而无需依赖第三方库
  5. 良好的社区和生态:虽然 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

选型: https://star-history.com/

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

官网下载安装即可

https://go.dev/dl/

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.gopremparihar.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

  • 优点:
  1. 官方包,得到 Go 社区的支持与维护
  2. 轻量级,不会引入额外的依赖
  3. 良好的跨数据兼容性
  • 缺点:
  1. 缺少高级功能,如查询构建器、关联、迁移等
  2. 手动管理 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

  • 优点:
  1. 提供了丰富的功能,如查询构建器、关联、迁移等
  2. 支持自动迁移数据库
  3. 提供了更高级的查询接口,减少了手动编写 sql 语句的需求
  4. 良好的文档和社区支持
  • 缺点:
  1. 依赖更多的外部库,可能导致项目臃肿
  2. 抽象层可能导致性能损失
  3. 可能需要更多的学习成本

2.1.3 sqlc✅

Getting started with PostgreSQL — sqlc 1.23.0 documentation

  • 优点:
  1. 将 sql 查询转换为类型安全的 go 代码,提高代码的可读性和安全性
  2. 通过生成代码,可以减少手动编写 sql 语句的错误
  3. 支持 postgresql 和 mysql
  4. 自动生成代码,易与维护
  • 缺点:
  1. 没有支持所有类型的数据库
  2. 可能需要对 sql 语句进行调整以生成正确的 go 代码
  3. 自动生成的代码可能难以理解和调试
  4. 功能有限,缺少一些高级功能,如关联、迁移等

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 TABLEALTER 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,以保证类型安全

  1. 安装命令行程序

brew install sqlcgo install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

  1. 项目根目录创建配置文件 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" # 输出目录
  1. 在 config/schema.sql 中复制 migration 文件中的建表语句即可
  2. 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 中的关键字冲突)

  1. 执行 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 测试流程

  1. 初始化
  2. 注册路由
  3. 初始化 `httptest.NewRecorder(),用于记录响应数据;
  4. 构造请求 http.NewRequest();创建用户,添加请求头权限字段
  5. r.ServeHTTP(w, req) 发起请求
  6. 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 注意事项

  1. 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

  1. 请求体处理

在 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)),
)
  1. 响应体处理

使用 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)
  1. 时间格式处理

TODO

  1. 常用断言
    1. assert.Equal(t, expected, actual) 断言是否相等;
    2. assert.True(t, someCondition)assert.False 分别用于断言表达式是否为真或为假;
    3. assert.NotNil(t, someValue)assert.Nil 分别用于断言值是否非空或为空;
    4. assert.Contains(t, collection, element) 用于断言一个集合(数组、切片、映射等)中是否包含某个元素
    5. 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 的使用

  1. 创建加载方法(在 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)
}
}
  1. 使用密钥

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

使用流程:

  1. gomail.NewMessage() 构造邮件内容
  2. gomail.NewDialer() 构造拨号器(邮件服务器配置)
  3. 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.hostemail.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 查询参数
  1. 路径参数

例如 api/v1/user/:id,使用 c.Params.GET("id") 来获取

  1. 查询参数

例如 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 方法,报错后会直接返回状态码 400
    • ShouldBindWith 报错后不会返回状态码
  • 我们可以在 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 以及数据库读写方法:ScanValue

// 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