后端从 ThinkPHP 改为 Gin:BFF 架构下的无缝替换
作者:陈宁(万象部落) 关键词:Gin、Go、ThinkPHP、BFF、GORM、Docker 分类:架构
背景
我的个人博客最早是 2014 年的 ThinkPHP5.1,后来引入了 Next.js 16 作为前端,并搭了一层 BFF(Backend For Frontend)。架构如下:
关键一点:浏览器只跟 Next.js 说话,后端只通过 Docker 内网被 Node.js 容器访问。这给了我们极大的自由度——换后端等于改一个环境变量,前端零改动。
本文记录把后端从 ThinkPHP 换成 Gin 的完整过程:项目搭建、API 契约对齐、数据清理(base64 → 明文、utf8mb3 → utf8mb4)、搜索从 LIKE 升级到 ngram 全文索引,以及 Docker 部署切换。
一、为什么能「无缝」替换
BFF 层早已埋好伏笔
上篇《从 TP5.1 到 Next.js BFF》里,BFF 的 BFF_UPSTREAM 指向 http://web1(ThinkPHP 容器)。切换只需:
// pm2.app.json
{
"env": {
"BFF_UPSTREAM": "http://gin:8080" // 之前是 http://web1
}
}
前端依赖的是「契约」不是「实现」
前端通过 BFF 调用后端,只关心响应格式:
{ "status": 200, "data": ... }
{ "status": 400, "error": "..." }
Gin 只要复刻同样的 JSON 契约,前端完全无感。
二、Gin 项目搭建
独立仓库 gin-server/,技术栈:Gin + GORM + MySQL 8。
gin-server/
├── main.go # 入口 + 路由
├── db.go # GORM 连接 + 模型 + base64 解码
├── handlers.go # 各端点 handler
└── Dockerfile # 多阶段构建,alpine 运行
复刻的 API 端点
| 端点 | 说明 |
|---|---|
GET /api/article/list | 文章列表(分页) |
GET /api/article/:id | 文章详情(含 labels/prev/next/recommend) |
GET /api/article/search | 搜索(ngram 全文索引) |
POST /api/article/click | 点击量 +1 |
GET /api/category/list | 分类菜单树 |
GET /api/category/:id | 分类文章 |
GET /api/tag/list | 标签列表 |
GET /api/tag/:id | 标签文章 |
GET /api/rank | 点击排行 Top10 |
GET /api/recommend | 推荐文章 |
GET /api/config | 网站配置 |
三、关键实现细节
1. 响应契约统一
func ok(c *gin.Context, data interface{}) {
c.JSON(200, gin.H{"status": 200, "data": data})
}
func fail(c *gin.Context, msg string) {
c.JSON(400, gin.H{"status": 400, "error": msg})
}
2. base64 解码兼容
老的 document 表 title/content 是 base64 存储。Gin 复刻 TP 的 is_base64 判断:
func isBase64(s string) bool {
dec, err := base64.StdEncoding.DecodeString(s)
if err != nil { return false }
return base64.StdEncoding.EncodeToString(dec) == s
}
func b64Decode(s string) string {
if isBase64(s) {
if dec, err := base64.StdEncoding.DecodeString(s); err == nil {
return string(dec)
}
}
return s
}
「是 base64 就解码,否则原样返回」——兼容两种数据形态,迁移前迁移后都能读。
3. 分类菜单树的 path 拆分
TP 的 getMenu() 靠 path 字段(如 "0"、"0-5")构建父子层级。Gin 复刻:
parts := strings.Split(r.Path, "-")
switch len(parts) {
case 1:
menu[r.ID] = node // 顶层
case 2:
pid, _ := strconv.ParseUint(parts[1], 10, 32)
parent.Child[r.ID] = node // 子分类
}
4. 标签关联用 FIND_IN_SET
document.label_id 存的是逗号分隔的标签 id(如 ,270,19,271),Gin 用 FIND_IN_SET 查询:
db.Where("FIND_IN_SET(?, label_id)", strconv.Itoa(id))
5. 详情页的 prev / next / recommend
prev/next:同分类下比当前 id 小/大的最近一篇recommend:按第一个标签找同标签最新 5 篇
均按 TP 原逻辑复刻。
四、数据清理(顺手做的两件事)
1. base64 → 明文
之前发现文章 title/content 用 base64 存储,这是个历史遗留怪癖:没有安全性(不是加密)、无法搜索、存储膨胀 33%。迁移只影响 25 行:
UPDATE document SET title=... , content=... WHERE id=...;
两个后端(TP 的 is_base64 判断 + Gin 的 b64Decode)都是「是 base64 才解码」的双向兼容逻辑,所以迁移前后读取都正常,零风险纯收益。
2. utf8mb3 → utf8mb4
document 等表原本是 utf8_general_ci(utf8mb3),无法存储 emoji 等 4 字节字符。全库 12 张表转:
ALTER TABLE document CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
转换前先 mysqldump 全库备份。转换后 emoji 写入测试通过。
五、搜索:从 LIKE 到 ngram 全文索引
老的 TP 搜索
->whereLike('title|content', '%' . $keyword . '%')
LIKE %kw% 无法走索引,数据量大时全表扫描;且 base64 内容下中文搜索完全无效。
Gin 的内存搜索(过渡)
最初因为 base64 无法用全文索引,Gin 用内存 strings.Contains:
if strings.Contains(title, kl) || strings.Contains(content, kl) { ... }
对 71 篇数据量无所谓,但明显是「数据没就绪时的过渡」。
ngram 全文索引(最终)
base64 → 明文 + utf8mb4 转换后,MySQL 8 原生的 ngram 解析器可以正常工作了:
ALTER TABLE document
ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;
Gin 查询改为:
matchClause := "MATCH(title, content) AGAINST (? IN BOOLEAN MODE)"
phrase := `"` + safeKw + `"`
用 BOOLEAN MODE + 短语 是为了消除 ngram 2-gram 的噪音召回——NATURAL LANGUAGE MODE 下搜 "php" 会拆成 ph/hp 误命中一堆无关内容,而短语匹配更精确。
对比:
| 方案 | 召回 | 性能 |
|---|---|---|
| LIKE %kw% | 子串匹配 | 全表扫描 |
| 内存 Contains | 子串匹配 | 全量载入 |
| ngram 全文索引 | 中文分词 | SQL 层过滤 |
六、Docker 部署
Dockerfile(多阶段构建)
FROM golang:1.27-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /wxbuluo-gin .
FROM alpine:3.20
RUN adduser -D -u 1001 app
USER app
COPY --from=builder /wxbuluo-gin /app/wxbuluo-gin
EXPOSE 8080
CMD ["/app/wxbuluo-gin"]
注意
GOPROXY=https://goproxy.cn——国内服务器拉 Go 依赖不走 goproxy.cn 会超时。
容器加入内网
docker run -d --name gin --restart unless-stopped \
--network docker_default \
-e DB_DSN='root:xxx@tcp(mysql:3306)/wxbuluo?charset=utf8mb4&parseTime=True&loc=Local' \
-e PORT=8080 \
wxbuluo-gin:latest
关键点:
--network docker_default:与 node、mysql 同网络- 不映射公网端口:后端只被内网访问,符合「公网只暴露 Next.js」原则
- DSN 用容器名
mysql而不是 IP
切换验证
# node 容器内测 gin
docker exec node curl -sf http://gin:8080/api/article/list
# 切 BFF_UPSTREAM
sed -i "s|http://web1|http://gin:8080|g" pm2.app.json
pm2 delete wxbuluo-web && pm2 start pm2.app.json
切换后 gin 日志能直观看到请求来源:
[GIN] 200 | 4.32ms | 172.25.0.4 | GET "/api/article/list?page=1&page_size=8"
172.25.0.4 正是 node 容器 IP——数据链路已切到 gin。
七、踩坑记录
- 构建超时:服务器拉
golang基础镜像和 Go 依赖超时。解决:GOPROXY=goproxy.cn+ 先docker pull基础镜像。 - go.mod 版本:本地 go 1.27,Dockerfile 用 golang:1.24 报
requires go >= 1.27。解决:统一用 golang:1.27。 - SSH 间歇断连:docker build 网络请求多时,服务器 SSH 偶发超时(疑似 fail2ban 误判)。解决:等待 + 重试,构建放后台。
- ngram 噪音:
NATURAL LANGUAGE MODE对短词(php、isr)按 2-gram 拆词过度召回。解决:BOOLEAN MODE + 短语。 - base64 无法搜索:这是数据层问题,靠迁移 base64→明文根治,而不是继续在搜索上加补丁。
八、为什么 ThinkPHP 仍保留
web1 容器还挂着其他应用,不能停。切换只影响 BFF 的上游指向,web1 继续运行,作为回滚选项随时可切回:
sed -i "s|http://gin:8080|http://web1|g" pm2.app.json && pm2 delete wxbuluo-web && pm2 start pm2.app.json
总结
这套替换能「无缝」成功,核心是架构分层做对了:
- BFF 让后端可替换:前端只认 JSON 契约,后端语言/框架随便换,改一个环境变量即可。
- 数据兼容设计:base64 解码逻辑双向兼容,迁移前后都能读,才有胆子动数据。
- 顺手治理数据:base64→明文、utf8mb3→utf8mb4,解锁了中文全文搜索能力。
- 内网部署:后端永远只在内网,公网攻击面只有 Next.js。
如果你也在用 BFF 架构,把后端从 PHP 换 Go 其实没有想象中可怕——契约定好,数据清干净,剩下的就是复刻接口 + 切换上游。