1、基本介绍

GoFrame 是一个面向 Go 项目开发的框架,除了 HTTP 服务,还提供配置、日志、校验、数据库访问等组件。学习时可以先围绕一个小接口,把请求接收、业务处理、数据库操作和响应返回串起来,再逐步理解其他模块。

本文以 GoFrame v2 为基础,整理开发中常用的写法和容易忽略的细节。查阅时官方文档处于 2.10.x 分支,实际项目仍应以自己的 go.mod 和 gf 工具版本为准,不直接混用 v1 的导入路径。

下面的留言示例用于讲解 GoFrame;当前博客的评论、留言后端使用 FastAPI,文章中的示例不代表本站已经迁移了后端框架。

2、创建项目与常用命令

安装 Go 和官方 gf 命令行工具后,先确认本机版本。CLI 的安装方式可以参考官方工具安装文档,项目依赖和 CLI 尽量使用相互匹配的版本。

1
2
3
4
5
6
7
go version
gf version

gf init gf-notes
cd gf-notes
go mod tidy
gf run main.go

gf init 创建脚手架项目;gf run main.go 会监视代码变化,重新编译并重启程序,适合开发阶段。它不是在原进程里替换代码,重启期间内存中的临时状态也会丢失。项目创建说明、自动编译说明。

其他常用命令:

命令 用途 使用时注意
gf gen ctrl 根据 API 定义生成控制器相关代码 生成后检查路由注册和待实现方法
gf gen dao 根据数据库表生成 DAO、DO、Entity 数据库要先有表,不会代替建表迁移
gf gen service 从符合规范的 logic 实现生成 service 接口 手工维护接口时注意工具覆盖规则
go test ./... 执行 Go 测试 依赖数据库的测试与纯函数测试分开组织
go build -o bin/gf-notes . 编译当前项目 Windows 可把输出名写成 bin/gf-notes.exe

生成代码前先阅读对应目录的文件头。标明由工具维护的文件,不适合直接塞入业务逻辑,否则下次生成可能被覆盖。DAO 生成文档、Service 生成文档。

3、项目目录应该怎么理解

官方脚手架中经常见到这些目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
api/                    对外接口的请求、响应定义
internal/
cmd/ 启动命令与路由组装
controller/ 将请求转换为业务调用和响应
service/ 业务接口定义
logic/ 业务接口的具体实现
dao/ 数据访问对象
model/
do/ 数据操作模型
entity/ 数据表实体模型
manifest/config/ 应用配置
resource/ 静态资源
main.go 程序入口

例如一个“发表留言”请求,可以按下面的顺序理解:

1
2
3
4
5
6
HTTP 请求
→ api:定义字段与基础校验
→ controller:组织输入、调用业务服务
→ service 接口 / logic 实现:执行业务规则
→ dao:访问数据库
→ controller:组装对外响应

service 在这套规范里主要是接口层,具体业务实现通常放在 logic。小演示可以直接在控制器里写逻辑;当多个入口需要复用同一套规则时,再抽到业务层更合适。官方工程目录设计。

我会按以下边界组织自己的代码:

  • API 对象只暴露接口确实需要的字段,不把数据库实体直接作为公开响应。
  • 昵称整理、回复关系校验、状态控制等规则放在业务层,方便 HTTP、管理端和任务程序复用。
  • DAO 层不承包所有业务判断,避免出现“Service 只转发、DAO 装下所有逻辑”的情况。
  • 使用生成式 Service 注册时,检查启动入口是否已经引入 logic 的注册包;生成了接口,不等于具体实现已经注册。

4、规范路由、参数校验与接口文档

规范路由把路径、请求方法和字段规则写进结构体,并用固定的处理函数签名接收请求。基础校验由框架完成,控制器处理校验后的请求。规范路由文档。

下面是一个独立的 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
39
40
41
42
43
44
45
package main

import (
"context"
"strings"

"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)

type MessagePreviewReq struct {
g.Meta `path:"/messages/preview" method:"post" tags:"留言" summary:"校验留言内容"`
Nickname string `json:"nickname" v:"required|length:1,50" dc:"昵称"`
Content string `json:"content" v:"required|length:1,2000" dc:"留言内容"`
}

type MessagePreviewRes struct {
Nickname string `json:"nickname"`
Content string `json:"content"`
}

type MessageController struct{}

func (c *MessageController) Preview(ctx context.Context, req *MessagePreviewReq) (*MessagePreviewRes, error) {
nickname := strings.TrimSpace(req.Nickname)
content := strings.TrimSpace(req.Content)
if nickname == "" || content == "" {
return nil, gerror.NewCode(gcode.CodeValidationFailed, "昵称和内容不能只包含空白字符")
}
return &MessagePreviewRes{Nickname: nickname, Content: content}, nil
}

func main() {
s := g.Server()
s.SetAddr(":8199")
s.SetOpenApiPath("/api.json")
s.SetSwaggerPath("/swagger")
s.Group("/api", func(group *ghttp.RouterGroup) {
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Bind(new(MessageController))
})
s.Run()
}

这个例子的接口地址为 POST /api/messages/preview;OpenAPI JSON 位于 /api.json,Swagger 页面位于 /swagger。请求体示例:

1
2
3
4
{
"nickname": "Zany",
"content": "记录一下今天学到的内容。"
}

几个常用标签可以这样记:

标签 作用 示例
g.Meta 接口路径、方法、分组和说明 path:"/messages" method:"get"
json JSON 字段名称 json:"nickname"
v 输入校验规则 `v:"required
d 参数默认值 d:"1"
dc 文档字段说明 dc:"页码"

required 建议放在组合校验规则的前面。字段必填、长度合法,不等于业务合法;例如回复目标是否存在、用户是否有权限,还需要业务层继续判断。修改接口涉及 0、false 或“未传字段”的区别时,可以使用指针字段表达可选输入。校验规则。

注册 MiddlewareHandlerResponse 后,处理函数通常返回 Res 和 error,由中间件组织结果。不要在同一个普通 JSON 接口里既手动写响应,又返回另一份结果。业务码也不等于 HTTP 状态码;正式项目需要明确两者的对应规则,并对数据库内部错误做脱敏。规范路由使用说明。

5、配置与数据库驱动

脚手架通常使用 manifest/config/config.yaml。以下为开发环境的配置形状,数据库账号是示例值:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
address: ":8199"
openapiPath: "/api.json"
swaggerPath: "/swagger"

logger:
path: "./logs"
level: "all"
stdout: true

database:
default:
link: "pgsql:app:replace_me@tcp(127.0.0.1:5432)/gf_notes"

第 4 节为了便于单文件演示,在代码里设置了 Server 参数。回到工程项目时,可以统一使用配置文件,避免配置值与硬编码各维护一份。

GoFrame v2 的数据库驱动需要单独引入。例如 PostgreSQL 使用以下驱动包,并在程序启动时完成导入注册;驱动版本应与项目采用的框架版本匹配。ORM 介绍、数据库配置格式。

1
import _ "github.com/gogf/gf/contrib/drivers/pgsql/v2"

配置组件可以通过 g.Cfg() 读取;Get 返回配置值和错误,MustGet 在读取失败时会触发 panic,因此关键业务路径应按需要选择错误处理方式。想用环境变量提供秘密信息时,要明确使用相应读取方法或配置适配逻辑,不要默认 YAML 中的任意 ${变量} 都会自动展开。配置读取方法。

生产数据库密码放在部署环境或受保护的配置中,文章、仓库和日志里只保留示例。切换数据库类型时,驱动、连接格式、SQL 方言和主键返回方式都需要一起检查。

6、数据库操作:先把查询边界写清楚

这一节的代码是业务函数片段,需要放入已配置 GoFrame 和 PostgreSQL 驱动的 Go 包中,并补齐对应 import。它们不属于上面的无数据库演示。

为了便于理解,约定演示表为 demo_messages,不是当前博客的生产表:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE demo_messages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nickname VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);

CREATE INDEX idx_demo_messages_public
ON demo_messages (created_at DESC, id DESC)
WHERE status = 1 AND deleted_at IS NULL;

建表 SQL 和后面的代码都只是文章示例,运行前应使用独立的演示数据库。gf gen dao 是“从已有表生成代码”,数据库表结构变更仍需维护迁移脚本。

6.1 创建:白名单组织写入字段

1
2
3
4
5
6
7
8
func CreateMessage(ctx context.Context, nickname, content string) error {
_, err := g.DB().Model("demo_messages").Ctx(ctx).Data(g.Map{
"nickname": nickname,
"content": content,
"status": 1,
}).Insert()
return err
}

调用这个函数前要完成输入整理和业务校验。status 由服务端决定,不让访客通过请求体修改。客户端提交什么就整包写入数据库,很容易把内部字段也暴露成可写字段。

创建与更新最好明确区分;Save 涉及冲突时的插入或更新语义,不能一律当成“新增”使用。涉及返回自增 ID 时,应核对具体数据库驱动的支持方式,尤其不要把所有数据库都当成 MySQL 的 LastInsertId。写入保存文档。

6.2 查询:列表和总数使用同一组过滤条件

下面片段需导入 context、gerror、g 和 gtime:

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
type MessageItem struct {
ID int64 `json:"id" orm:"id"`
Nickname string `json:"nickname" orm:"nickname"`
Content string `json:"content" orm:"content"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}

func ListMessages(ctx context.Context, page, size int) ([]MessageItem, int, error) {
if page < 1 || page > 10000 || size < 1 || size > 50 {
return nil, 0, gerror.New("分页参数不合法")
}
items := make([]MessageItem, 0)
var total int
err := g.DB().Model("demo_messages").Ctx(ctx).
Fields("id", "nickname", "content", "created_at").
Where("status", 1).
WhereNull("deleted_at").
OrderDesc("created_at").OrderDesc("id").
Page(page, size).
ScanAndCount(&items, &total, false)
if err != nil {
return nil, 0, err
}
return items, total, nil
}

ScanAndCount 用来组织列表和总数查询,但不代表它们被合并成一条 SQL,也不自动保证并发写入时两次查询看到相同快照。这里第三个参数传 false,表示计数不沿用 Fields 作为 COUNT 参数。ScanAndCount 文档。

公开列表需要始终带上状态和软删除条件。排序增加 id 作为相同创建时间下的确定顺序;深分页或持续新增的动态列表,可以考虑使用游标分页,而不是无限增大页码。

6.3 更新与软删除:带上明确条件

以下是软删除函数示例,需额外导入 gtime。调用前还要完成身份和权限校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func SoftDeleteMessage(ctx context.Context, id int64) error {
if id <= 0 {
return gerror.New("留言 ID 不合法")
}
result, err := g.DB().Model("demo_messages").Ctx(ctx).
Where("id", id).WhereNull("deleted_at").
Data(g.Map{"deleted_at": gtime.Now()}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return gerror.New("留言不存在或已经删除")
}
return nil
}

软删除仅改变记录状态,不会自动保证你写的每条查询都排除已删除数据。需要区分框架已配置的软删除规则与自己手动维护的字段,避免只加了 deleted_at,查询时却忘记过滤。

条件值交给 ORM 参数化处理;排序字段、表名等不能简单视为普通参数,动态排序应使用服务端白名单映射,不能直接拼接用户输入。ORM 使用说明。

7、事务与 Context

当多个数据库操作必须一起成功或失败时,使用事务。下面演示“创建一条留言并读取当前事务内可见的公开留言数”,只操作上面的演示表;计数仅用于展示事务读取,不应作为高并发计数器方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func CreateAndCount(ctx context.Context, nickname, content string) (int, error) {
var total int
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("demo_messages").Data(g.Map{
"nickname": nickname,
"content": content,
"status": 1,
}).Insert()
if err != nil {
return err
}
total, err = tx.Model("demo_messages").
Where("status", 1).WhereNull("deleted_at").Count()
return err
})
if err != nil {
return 0, err
}
return total, nil
}

该片段还需导入 github.com/gogf/gf/v2/database/gdb。事务闭包返回 nil 时提交,返回错误时回滚;示例显式使用 tx,让事务边界更容易看清。事务文档。

开发时要特别注意:

  • 请求的 ctx 应沿控制器、业务层、DAO 继续传递,不要随手替换成 context.Background()。
  • 事务内遇到错误需要返回;只打印日志再返回 nil,可能导致本应回滚的操作提交。
  • 不在事务里长时间请求外部 HTTP 服务,避免连接和锁一直被占用。
  • 数据库提交与 Redis 操作不属于同一个本地事务,不能假设会一起回滚。
  • 独立后台任务需要自己的生命周期与超时,不能永久依附已结束的请求 Context。

8、错误处理与日志

业务失败与系统失败应区分处理。比如“回复目标不存在”可以返回明确提示;数据库连接串、SQL 和调用栈则留在服务端,不能直接返回给访客。

gerror.Wrap 可以在保留错误链的同时补充操作语境,适合定位“哪一步失败”。不要每一层都重复打印同一个错误,最终处理边界统一记录通常更清晰。错误处理文档。

1
2
3
4
5
6
7
8
func CountPublicMessages(ctx context.Context) (int, error) {
total, err := g.DB().Model("demo_messages").Ctx(ctx).
Where("status", 1).WhereNull("deleted_at").Count()
if err != nil {
return 0, gerror.Wrap(err, "查询公开留言总数失败")
}
return total, nil
}

日志建议记录请求标识、操作名、耗时和必要的业务 ID,方便把一次请求串起来。密码、Token、完整请求头和访客的私密信息不作为默认日志内容。

使用 g.Log() 时可通过 logger 配置业务日志的路径、级别和轮转。HTTP Server、ORM 等组件有各自的日志配置,修改 logger 并不意味着所有组件都会采用同样设置。日志组件、日志配置。

9、结合留言和评论场景的设计思路

以下是接口设计建议,与具体框架无关。使用 GoFrame 时,可以把这些规则放在业务层和中间件中。

9.1 分页与楼中楼

留言板使用普通分页即可,例如每页 30 条。文章评论则拆成两个查询入口:

  • 根评论列表:每页最多 30 条,每个根评论先带 3 条回复预览。
  • 某楼层的回复列表:用户点击“查看更多”后,再按游标取下一批。

不要先查所有评论,再在 Go 内存里截取第一页;也不要针对每条根评论单独执行一次回复查询。对于 30 个根评论,可以使用批量查询或窗口函数限制每个根评论的预览数量,避免 N+1 和无限展开。

回复要验证目标仍然存在、属于同一篇文章,并由服务端确定根评论 ID。文章关联标识使用稳定的文章 Key,不把完整域名作为唯一标识,后续换域名时更容易保留讨论记录。

9.2 创建限流

对于公开留言,可以先采用“同一 IP,10 秒内最多创建一次”。多进程部署时应在 Redis 中执行原子操作,例如 SET key value NX EX 10;不要用“先 GET、再 SET”模拟互斥。

限流 Key 是否跨评论、留言共享,需要在产品规则中明确。代理后的真实 IP 只能从可信代理链获取,不能直接相信任意客户端提交的 X-Forwarded-For。IP 限流也不能代替登录权限、内容长度限制和必要的内容审核。

9.3 查询缓存与创建后的可见性

可以从短时间缓存公开列表开始,例如缓存第一页 30 秒。缓存 Key 至少包含文章标识、页码、每页数量和排序规则,不缓存访客邮箱等非公开信息。

创建成功后的流程可以设计为:

1
2
3
4
5
数据库提交成功
→ 失效相关列表缓存,或递增文章/留言板的缓存版本
→ 返回已创建的数据
→ 前端立即显示自己的新内容
→ 再刷新列表进行校准

缓存失效必须在数据库提交成功后进行。若先删缓存、再提交,其他请求可能在这段时间读到旧数据库内容,并把旧结果重新写入缓存。即使提交后删除,仍需考虑已经在途的旧查询,因此并发较高时可结合版本号、短 TTL 和前端临时合并策略处理。

单机内存缓存可以作为轻量优化,但多个服务实例不会天然共享。限流和缓存需要跨实例一致时,应明确采用共享存储,并定义 Redis 暂不可用时是降级、拒绝,还是受控直查数据库。

10、开发时容易踩的坑

现象 优先检查
修改接口后路由没出现 API 元数据、控制器生成结果、路由组 Bind 是否完整
接口前缀重复 组路径和 g.Meta path 是否各写了一次 /api
参数校验没按预期执行 是否使用规范路由、v 标签是否正确、必填规则和默认值是否冲突
找不到数据库驱动 对应 contrib 驱动是否安装并在启动路径导入
生成的字段与数据库不同 是否更新了表结构并重新执行 DAO 生成
事务没有回滚 闭包是否吞掉错误、操作是否真的使用了事务对象
列表空时返回 null 切片是否初始化为空切片,接口契约是否约定为数组
查不到某行却没有报错 不要一律按 sql.ErrNoRows 判断;检查所用查询方法及返回对象是否为空
更新“成功”但数据没变 查询条件、更新字段和受影响行数是否符合预期
修改日志配置没生效 修改的是业务 logger,还是 Server / ORM 各自的日志配置
生成后手写代码丢失 是否修改了标明由工具维护的文件
新留言刚发表却看不到 缓存失效时机、在途旧查询与前端列表合并是否正确

当前这篇先覆盖常见的 HTTP 与数据库开发过程。后续可以继续补充登录鉴权、文件上传、定时任务、自动化测试,以及更完整的业务项目示例。