写在最前面:这一版怎么读
这是《内容社区》课程第 02 章。它假设你刚读完第 01 章——你已经会用 Go 写变量、结构体、函数、切片、map、接口,能跑通那章最后做出来的
webook-syntax命令行小工具,但你还没写过任何一个 Web 项目。第 01 章的
webook-syntax是个"只能自己跟自己玩"的命令行程序:你敲命令、它打印结果,数据只在内存里活一瞬。这一章我们要做的事,就是把它变成真正的 Web 服务——别人(比如浏览器、另一个程序)能通过 HTTP 请求来调用你的代码,用户能注册、能登录、登录之后你还能认出"还是这个人"。这一版延续第 01 章的三件事:
- 每一步都能跑。所有代码标了文件路径、给了运行命令、写了预期输出。
- 术语不跳过。凡是你没见过的词(HTTP、路由、ORM、CORS、Cookie、Session、Middleware、BCrypt……),当场就解释,并配「补课胶囊」。
- 结尾有面试问答。15 道题按"基础 → 进阶 → 追问"三档排列,每题告诉你结论、展开、关键词、失分点。
如果你想要"高密度速查版",可以看原版第 02 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章我们打牢了 Go 语言的地基——类型系统、切片与 map 的底层结构、接口与泛型,最后还做了一个能在内存里存用户的小工具 webook-syntax。但那个工具"敲命令进、打印结果出",没人能从外面访问它。
从本章起,地基之上开始砌墙:我们要动手实现《内容社区》的第一个真实模块——用户注册与登录,并在此过程中学会两件往后每一章都要用的本事:用 Gin 接收和处理 HTTP 请求(让外部能调你),用 GORM 把数据存进 MySQL(让数据不再随程序退出就消失)。本章还会首次引入分层架构(Handler → Service → Repository → DAO → Domain),它是这个项目从单体一路演进到微服务的过程中始终不变的组织原则。
学完本章,你将拥有一个能注册、能登录、能维持登录态的完整用户服务,也会第一次体会到"一个正经的后端项目"是如何组织代码的——不再是把所有逻辑堆在一个 main 函数里,而是像餐厅后厨一样,洗菜、切菜、炒菜、装盘各有人管。
开读前先看一眼全局:下面这张图是本章要建成的两条链路加一道闸门——注册链路把用户写进 MySQL,登录链路发 Cookie,之后每个请求都过同一道 Session 校验闸门。正文所有小节都在为这三块服务。
flowchart TB
subgraph R["注册链路"]
A["提交注册表单"] --> B["Gin 绑定 + 校验"] --> C["Service 业务校验
密码加密"] --> D["Repository / DAO"] --> E[("MySQL user 表")]
end
subgraph L["登录链路"]
F["提交登录表单"] --> G["查用户 + 比对密码"] --> H["写入 Session"] --> I["Set-Cookie sess_id"]
end
J["后续每个请求"] --> K{"middleware
校验 Session"}
K -->|"有效"| L["取出 user_id 放入上下文
继续业务"]
K -->|"无效"| M["401 拒绝"]这张图怎么读:先看上半部分的两条写链路——注册链路里"提交注册表单"先经 Gin 绑定+校验,再交给 Service 做业务校验和密码加密,往下经 Repository/DAO 落进 MySQL 的 user 表,数据自上而下单向流动;登录链路则是"提交登录表单→查用户+比对密码→写入 Session",最后用 Set-Cookie 把 sess_id 发回浏览器。下半部分是读链路:后续每个请求都要先过 middleware 的 Session 校验这道闸门,校验有效则取出 user_id 放入上下文继续业务,无效则走失败分支直接 401 拒绝——这条失败路径就是本章所有受保护接口的统一守门逻辑。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用
go mod init起一个 Gin 项目,写出能go run起来的最小 Web 服务,访问/hello返回 JSON。 - 用 Gin 注册静态 / 参数 / 通配符三种路由,说清 GET 和 POST 分别在什么场景用、参数放 URL 还是放 Body。
- 用
c.Bind/ShouldBindJSON接收并校验注册请求,手写正则或写bindingtag 校验邮箱与密码强度。 - 用 GORM 定义 User 模型、
AutoMigrate建表,写出Create/First/Updates的基础 CRUD。 - 写出 Handler → Service → Repository → DAO 五层,跑通"注册(BCrypt 加密)→ 登录(Session 存 user_id)“闭环,并用 curl 验证。
理解(能讲清原理和取舍,不要求背代码)
- 为什么需要 Web 框架(Gin 的
Engine/Context/RouterGroup各自解决什么),以及c.Bind失败会自动写响应的坑。 - Middleware(中间件)作为 AOP 的意义,以及它如何用
c.Next()把"公共逻辑"插进请求流水线、解决 CORS 这类横切问题。 - ORM 是什么、为什么用 GORM(和手写 SQL 的差别),以及
Updates只更新非零字段的坑。 - 为什么必须分层、每一层职责边界在哪、为什么有了 DAO 还要有 Repository、Domain 和 DAO 的模型为什么要分两套。
- HTTP 无状态为什么需要 Cookie / Session;BCrypt 为什么比 MD5 适合存密码(随机盐、cost、不可逆)。
了解(知道有这回事,面试能说出名字和一句话定位)
- CORS 的 preflight(预检)请求是 OPTIONS,以及
AllowOriginFunc配错*是安全漏洞。 - 错误别名机制如何让 Handler 不依赖 DAO 包(依赖倒置的落地)。
- GORM 的
embedded(训练营叫 inline)标签,解决有名字段嵌入不平铺的问题。 - Session 多实例部署必须用 Redis(状态外置),这是后面 Kubernetes 能扩缩容的前提。
前置依赖与补课清单
本章用到的、且前文没讲过的技术,都标了"本章补”。下面这张表请你先扫一眼,知道哪些词会就地展开:
| 需要你已掌握 | 来自 | 本章会不会补 |
|---|---|---|
| Go 语法、结构体、方法、interface、error | 第 01 章 | ❌ 不补 |
| 什么是 HTTP 请求 / 响应、URL、方法 | 无(首次出现) | ✅ 会补,3.1 节补课胶囊 |
| 什么是 Web 框架、为什么需要 Gin | 无(首次出现) | ✅ 会补,3.1 节补课胶囊 |
| 什么是 ORM、为什么用 GORM | 无(首次出现) | ✅ 会补,3.5 节补课胶囊 |
| 什么是跨域 CORS、为什么浏览器要拦 | 无(首次出现) | ✅ 会补,3.4 节补课胶囊 |
| 什么是 Cookie / Session、登录态怎么保持 | 无(首次出现) | ✅ 会补,3.9 节补课胶囊 |
| 什么是分层架构、为什么 Handler 不能写 SQL | 无(首次出现) | ✅ 会补,3.6 节补课胶囊(本章重点) |
| BCrypt 为什么比 MD5 适合存密码 | 无(首次出现) | ✅ 会补,3.7 节补课胶囊 |
| MySQL / Docker 基础概念 | 无(首次出现) | ✅ 会补原理,3.5 节启动步骤 |
不需要你提前懂的:Redis、Kafka、K8s、gRPC、ES——这些都在后面章节,本章用不到。
预计时长与学习成果
- 预计时长:4 小时(精读 + 跟着敲全部代码);只求主干约 2 小时。
- 学完你能跑出来的东西(验收标准,做到才算过关):
一个叫
webook的 Web 服务:起 MySQL(Docker),访问POST /users/signup能注册、返回"注册成功";同一邮箱再注册返回"该邮箱已注册";POST /users/login用邮箱密码登录成功,之后GET /users/profile能凭 Session 读出user_id。每一步都可用 curl 验证。
flowchart LR
A["3.1 Gin 入门
+HTTP 补课胶囊"] --> B["3.2 路由三形态"]
B --> C["3.3 注册 Bind 与校验
+Validator 加餐"]
C --> D["3.4 跨域 + Middleware
+CORS 补课胶囊"]
D --> E["3.5 GORM 入门
+ORM 补课胶囊"]
E --> F["3.6 分层架构
+分层类比胶囊"]
F --> G["3.7 BCrypt 加密
+MD5 对比胶囊"]
G --> H["3.8 错误别名传导"]
H --> I["3.9 登录态
+Cookie/Session 胶囊"]
I --> J["第四节 跟着写
注册登录闭环"]
J --> K["第七节 面试问答"]这张图怎么读:这是一条单向串行路线——从 3.1 Gin 入门起步,按路由三形态、Bind 与校验、跨域+Middleware、GORM、分层架构、BCrypt 加密、错误别名传导的顺序推进到 3.9 登录态,每一节解决"注册登录闭环"的一块拼图。其中 3.6 分层架构是枢纽:第四节"跟着写注册登录闭环"会把前面所有技术点装进 Handler→Service→Repository→DAO 五层里跑通,最后由第七节面试问答收口。全程无分支,哪一节卡住就回退那一节。
三、正文
3.1 把第一个 Web 服务跑起来:Gin 入门
先抛问题
第 01 章的 webook-syntax 只能"自己跟自己玩"——你在终端敲命令,它打印结果。但一个真正的社区网站,得让浏览器、让别人的程序来调你:
- 浏览器地址栏输入
http://localhost:8080/hello,怎么让 Go 程序"接住"这个请求并返回一个 JSON? - 为什么不能直接用
fmt.Println把结果打印出来就算完事? - 听说有个叫"Gin"的东西,为什么不直接用 Go 标准库
net/http写?
不先弄清"请求怎么进来、响应怎么出去",你后面每一步都会像在黑屋子里拼图。
给个直觉:把 Web 服务想成一家餐厅前台
一个 Web 服务就像餐厅的前台接待:
- HTTP 请求 = 一位客人走过来提需求(“我要一杯美式”)。
- 路由(router) = 前台看需求决定"这事儿该交给谁办"(咖啡交给咖啡师,结账交给收银)。
- Web 框架(比如 Gin) = 一整套标准化的接待流程:怎么迎客、怎么记录、怎么回话,全帮你定好了,你只管写"后厨逻辑"。
- 没有框架时,每个服务员自己发明一套迎客方式,乱七八糟;有了 Gin,所有人按同一套流程来。
补课胶囊 · HTTP 请求与响应 一句话:HTTP 是浏览器和服务器之间"一问一答"的规矩,问叫请求、答叫响应。 类比:像去快餐店点单——你(浏览器)说"来个汉堡"(请求:方法 + 地址 + 内容),店员(服务器)递给你汉堡和收银小票(响应:状态码 + 内容)。 最小示例:
请求:GET /hello HTTP/1.1 ← 方法 GET,地址 /hello 响应:HTTP/1.1 200 OK ← 状态码 200 表示成功 {"msg":"hello"} ← 响应体(JSON)回到主线:Gin 干的事,就是替你解析这个"请求"、并帮你拼出这个"响应"。
补课胶囊 · 什么是路由、什么是 Web 框架 一句话:路由是"哪个网址交给哪个函数处理"的对照表;框架是把路由、解析、响应这些脏活打包好的工具箱。 类比:路由像公司前台的分机表(拨 8001 找技术部、8002 找人事);框架像整栋楼的物业系统(分机表 + 门禁 + 监控一套齐活),你不用自己装水管。 最小示例:
engine.GET("/hello", func(c *gin.Context) { // 路由:GET /hello → 这个函数 c.JSON(200, gin.H{"msg": "hello"}) })回到主线:下面这段最小 Gin 程序,就用
engine.GET登记了一条路由。
最小可运行示例
先装依赖,再写一个不到 20 行、但已用上 Gin 三个核心抽象的程序。
# 文件路径: ~/code/webook/ (在第四节课会正式创建,这里先体验)
# 安装 Gin 依赖(确保已 go mod init)
go get github.com/gin-gonic/gin@latest
// 文件路径: ~/code/webook/main.go(最小可运行版,体验用)
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// Engine 是 Gin 的核心抽象,代表一个逻辑上的 Web 服务器
engine := gin.Default()
// 注册一条路由:GET 方法 + 路径 /hello + 处理函数
engine.GET("/hello", func(c *gin.Context) {
// gin.Context 是本次请求的核心类型,负责"收请求 + 发响应"
c.JSON(http.StatusOK, gin.H{"msg": "hello, world"})
})
// 监听 8080 端口并启动;端口被占用是启动失败最常见原因
if err := engine.Run(":8080"); err != nil {
panic(err)
}
}
跑起来(第四节课会正式建目录,这里你也可在任意临时目录试):
go run main.go
预期输出(服务会一直挂起等待请求,不会自己退出):
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] Listening and serving HTTP on :8080
另开一个终端,用 curl 发请求验证:
curl http://localhost:8080/hello
预期输出:
{"msg":"hello, world"}
看到这行 JSON,恭喜——你的第一个 Web 服务跑通了。
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 10 | engine := gin.Default() | Engine 是路由表 + 中间件链 + 配置的容器;Default() 自带日志和崩溃恢复两个中间件 |
| 13 | engine.GET("/hello", handler) | 登记"GET /hello 交给这个匿名函数"。GET 是 HTTP 方法 |
| 14 | func(c *gin.Context) | gin.Context 是本次请求的"服务档案",收请求、发响应都靠它 |
| 15 | c.JSON(http.StatusOK, ...) | 把第二个参数序列化成 JSON、写回响应体,状态码 200 |
| 19 | engine.Run(":8080") | 真正监听端口、开始接客;:8080 表示本机 8080 端口 |
Engine 不是"一个服务器进程"的本体,它更像"路由表 + 中间件链 + 配置"的容器;真正监听端口的是底层的 http.Server。一个 Go 进程可以 new 多个 Engine(比如一个对外、一个对内暴露监控指标),各自有不同路由和中间件——这就是组合设计带来的灵活度。另外 Engine 内部组合了 RouterGroup,所以天然支持"分组路由",3.2 节会看到。常见坑:端口被占用
坑 · 启动报 address already in use
[GIN-debug] Listening and serving HTTP on :8080
[ERROR] ... listen tcp :8080: bind: address already in use
原因:上一个 go run 没退出,还占着 8080。解决:找到并退出旧进程,或换个端口 engine.Run(":9090")。开发期养成"改完保存、重启服务"的习惯,别让多个服务叠在同一端口。
小收口
这一小节你拿到了"接住 HTTP 请求并返回 JSON"的能力——这是后面所有用户接口的地基。记住三个核心抽象:Engine(服务器容器)、gin.Context(本次请求档案)、RouterGroup(分组路由)。下一节我们把路由玩出三种形态。
3.2 路由的三种形态与设计原则
先抛问题
一个 /hello 够演示,但真实项目有几十上百个接口:查用户、查文章、查评论……
- 怎么让
/users/123这种"带具体 ID"的网址自动把123抽出来给我用? - 怎么让
/files/a/b/c.txt这种"后面还有好几层"的网址不被写死? - 什么时候用 GET、什么时候用 POST?参数放网址里还是放请求体里?
这一节把"路由怎么设计"一次说清,这是新手最容易随心所欲、后面又返工的地方。
给个直觉:路由是前台的分机表
静态路由像"拨 8001 永远找技术部"——完全写死。参数路由像"拨 8+工号,前台自动把工号转给对应同事"。通配符路由像"拨 9 开头任何号码都转总机"。三种形态应付不同复杂度的需求。
最小可运行示例
下面这段代码把三种路由 + 查询参数的取法一次演示完,建议跑一遍、逐个访问体会差异:
// 文件路径: ~/code/webook/main_route.go(演示用,建议在独立临时目录里运行)
// 注意:不要和 ~/code/webook/main.go 放在同一目录,两个 main 函数会编译冲突
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
engine := gin.Default()
// 1. 静态路由:完全匹配路径
engine.GET("/users/signup", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "signup"})
})
// 2. 参数路由:用 :id 捕获路径中的这一段
// 访问 /users/123 时,c.Param("id") 返回 "123"
engine.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"id": id})
})
// 3. 通配符路由:用 *all 捕获剩余所有路径
// 访问 /files/a/b/c.txt 时,c.Param("all") 返回 "/a/b/c.txt"
engine.GET("/files/*all", func(c *gin.Context) {
all := c.Param("all")
c.JSON(http.StatusOK, gin.H{"path": all})
})
// 查询参数:URL 里 ?key=value 这种,用 Query 取
// 访问 /search?q=go&page=2
engine.GET("/search", func(c *gin.Context) {
q := c.Query("q") // 不存在返回 ""
page := c.DefaultQuery("page", "1") // 不存在返回默认值 "1"
c.JSON(http.StatusOK, gin.H{"q": q, "page": page})
})
engine.Run(":8080")
}
跑起来后逐个验证:
curl http://localhost:8080/users/123
# 预期输出:{"id":"123"}
curl http://localhost:8080/files/a/b/c.txt
# 预期输出:{"path":"/a/b/c.txt"}
curl "http://localhost:8080/search?q=go&page=2"
# 预期输出:{"page":"2","q":"go"}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
:id | 参数路由 | 冒号开头 = 捕获这一段路径,名字随便起,用 c.Param("id") 取 |
*all | 通配符路由 | 星号开头 = 捕获其后所有剩余路径,常用于静态文件服务 |
c.Query | 取查询参数 | 从 URL ? 后取值;DefaultQuery 给默认值更稳 |
c.Param | 取路径参数 | 从路径段取值,不是从 ? 后取 |
/users/:id 和 /users/signup 能共存——Gin 会优先匹配静态的 /users/signup,signup 不会被认为是 :id 的值。但 /users/*all 和 /users/:id 同时注册会冲突报错,因为通配符太"贪心"。一般项目里只用参数路由就够,通配符留给文件服务。小收口:路由设计原则(新手必记)
单个接口怎么写都行,接口一多团队就必须有约定。一句话结论:读数据用 GET、参数进 URL;写数据用 POST/PUT/DELETE、参数进 Body。
| 场景 | HTTP 方法 | 参数位置 | 示例 |
|---|---|---|---|
| 查询数据 | GET | 查询参数 / 路径 | /users/:id 或 /users?age=20 |
| 提交数据 | POST | Body(JSON) | /users/signup + JSON body |
| 更新数据 | PUT/PATCH | 路径 + Body | /users/:id + JSON body |
| 删除数据 | DELETE | 路径 | /users/:id |
3.3 注册接口:请求绑定与校验
先抛问题
路由接住了请求,可请求体里是一坨 JSON 字符串。怎么把它变成我代码里能用的结构体?怎么确保用户填的邮箱是真的邮箱、密码够强?如果不校验,有人传个空密码、传个假邮箱,数据库就脏了。
这一节解决两件事:怎么接参数(Bind)、怎么校验参数(正则 / binding tag)。原文的「加餐:Gin Validator 参数校验」我整合进这一节主线,不再悬空。
给个直觉:Bind 像快递拆包 + 验货
c.Bind像快递员:你给他一个空箱子(结构体指针),他照着面单(JSON)把东西一件件摆进去(反序列化)。面单填错了(JSON 格式不对),他当场拒收并给你回个"拒收通知"(400 响应)。- 校验像验货:拆包后你还得检查"这瓶是不是真酒、生产日期过没过"——邮箱格式、密码强度都得自己查。
3.3.1 接收 JSON:Bind 方法
// 文件路径: ~/code/webook/internal/web/user_signup_bind.go(演示片段)
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// SignUpReq 用于接收注册请求
// 建议定义为方法内部类型,限制其只在本方法使用
type SignUpReq struct {
Email string `json:"email"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
}
func (h *UserHandler) SignUp(c *gin.Context) {
var req SignUpReq
// Bind 会根据 Content-Type 决定如何反序列化
// application/json → 用 JSON 反序列化
// 如果绑定失败,Bind 会直接写一个 400 响应到前端,无需我们处理
if err := c.Bind(&req); err != nil {
return // Bind 已经写了响应,直接 return
}
// 校验逻辑放下面...
c.JSON(http.StatusOK, gin.H{"msg": "注册成功"})
}
Bind 失败会自己写响应。上面 c.Bind 在绑定失败时(比如 JSON 格式不对)已经向客户端写了 400 响应,所以你必须 return,绝不能在后面再 c.JSON(...) 写第二次响应。新手常忘记 return,导致 superfluous WriteHeader call 报错,或客户端收到两个响应。c.Bind 是 Gin 帮你干的"脏活"——它看 Content-Type,JSON 就走 json.Unmarshal、form 就走 PostForm 绑定,还能顺手按 binding tag 做基础校验。但它"失败即写响应"的特性你得记牢。另一个常见搭档是 ShouldBind / ShouldBindJSON,区别在于失败时不自动写响应,适合你想自己完全控制错误返回格式的场景。3.3.2 手写正则校验
校验规则一般由产品经理定。注册场景的典型校验:邮箱格式合法、密码与确认密码一致、密码强度(≥8 位且含数字与特殊字符)。
// 文件路径: ~/code/webook/internal/web/user_signup_regex.go(演示片段)
package web
import (
"net/http"
"regexp"
"github.com/gin-gonic/gin"
"github.com/dlclark/regexp2" // Go 官方正则不支持 (?=.) 前瞻语法
)
// 预编译正则表达式,避免每次请求都重新编译
var (
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// 使用 regexp2 支持 (?=) 前瞻语法
passwordRegex = regexp2.MustCompile(
`^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{8,}$`,
regexp2.None,
)
)
func (h *UserHandler) SignUp(c *gin.Context) {
var req SignUpReq
if err := c.Bind(&req); err != nil {
return
}
// 校验邮箱格式
if !emailRegex.MatchString(req.Email) {
c.JSON(http.StatusOK, gin.H{"msg": "邮箱格式不正确"})
return
}
// 校验密码强度(regexp2 的 MatchString 返回 (bool, error))
ok, err := passwordRegex.MatchString(req.Password)
if err != nil || !ok {
c.JSON(http.StatusOK, gin.H{"msg": "密码必须不少于8位,且包含数字和特殊字符"})
return
}
// 校验两次密码一致
if req.Password != req.ConfirmPassword {
c.JSON(http.StatusOK, gin.H{"msg": "两次输入的密码不一致"})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "注册成功"})
}
前端校验 vs 后端校验:必须两边都校验。前端校验为了用户体验(立刻提示、不浪费一次网络往返);后端校验为了安全——前端能被绕过(比如直接用 curl),“只在前端校验"等于把家门钥匙挂门外。
regexp.MustCompile 写在 handler 函数里,等于每个请求都重新编译一遍正则,白白浪费 CPU。正确做法是像上面代码那样,把编译结果提为包级变量——进程启动时编译一次,所有请求复用。3.3.3 整合加餐:用 binding tag 声明式校验(Validator)
3.3.2 手写正则能跑,但 Gin 还内置了一套声明式校验方案——binding tag。你在结构体 tag 里声明规则,绑定请求时自动校验,一行 if 都不用写。它的底层是社区标准库 go-playground/validator(Gin 已集成,无需额外安装)。
// 文件路径: ~/code/webook/internal/web/user_signup_validator.go(演示片段)
// 常用 binding 规则速查(不用背,用到查表):
// required 必填(不能是零值)
// email 邮箱格式
// min / max 字符串=长度边界;数字=数值边界
// len 精确长度(短信验证码)
// oneof 枚举取值(空格分隔),如 oneof=male female
// gte / lte ≥ / ≤
// eqfield 必须等于另一字段(两次密码一致)
type SignUpReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=72"`
ConfirmPassword string `json:"confirmPassword" binding:"required,eqfield=Password"`
}
min/max 的语义随类型变化。同一个 min=8,放在 string 上是"至少 8 个字符",放在 int 上是"数值 ≥ 8",从别的语言转过来的同学常栽跟头。另外 required 判断的是非零值——0、""、false 都是零值,所以"年龄填 0 是合法业务值"的字段不能用 required(否则用户填 0 会被判为"没填")。ShouldBindJSON 与 Validator 的关系(面试常追问):
c.ShouldBindJSON(&req):只负责按 JSON 反序列化 + 执行 binding tag 校验,失败时返回 error,不写响应(c.Bind则失败时自动写 400)。binding.Validator:Gin 包里的全局校验器单例,本质是 validator 库*validator.Validate的封装;ShouldBindJSON反序列化完成后就调用它校验。- validator 库:真正干活的规则引擎,自定义规则
RegisterValidation注册到它身上。
一句话:ShouldBindJSON 是入口,Validator 是引擎。不加 binding tag 就只绑定不校验;两者搭配才是完整的"接收 + 校验”。
校验错误怎么提取返回给前端:校验失败时返回的 error 是一长串英文,直接丢给前端不友好。正确姿势是断言成 validator.ValidationErrors,逐字段翻译成中文:
// 文件路径: ~/code/webook/internal/web/user_signup_err.go(演示片段)
package web
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" // Gin 依赖树里已带,直接 import
)
func (h *UserHandler) SignUp(c *gin.Context) {
var req SignUpReq
// ShouldBindJSON 失败不写响应,错误处理权完全在自己手里
if err := c.ShouldBindJSON(&req); err != nil {
// 断言成校验错误集合,逐条翻译成中文
if ves, ok := err.(validator.ValidationErrors); ok {
msgs := make([]string, 0, len(ves))
for _, e := range ves {
switch e.Field() {
case "Email":
msgs = append(msgs, "邮箱格式不正确")
case "Password":
msgs = append(msgs, "密码长度必须在 8~72 位之间")
case "ConfirmPassword":
msgs = append(msgs, "两次输入的密码不一致")
default:
msgs = append(msgs, e.Field()+" 参数不合法")
}
}
c.JSON(http.StatusOK, gin.H{"msg": strings.Join(msgs, "; ")})
return
}
// 断言失败说明不是校验错误,而是 JSON 本身解析不了
c.JSON(http.StatusOK, gin.H{"msg": "请求体格式错误"})
return
}
// ... 业务逻辑
}
go-playground/locales + universal-translator + 中文翻译包,一次性注册所有规则的中文文案。但你要先懂"断言 ValidationErrors → 取 Field/Tag"这条手动链路——它是所有翻译方案的地基,线上校验出问题时也只有你能排查。自定义验证器:规则表覆盖不了"密码必须同时含数字和特殊字符"?注册一个自定义验证器:
// 文件路径: ~/code/webook/internal/web/strong_pwd_validator.go(演示片段)
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// 定义校验函数——返回 true 表示通过
var strongPassword validator.Func = func(fl validator.FieldLevel) bool {
pwd, ok := fl.Field().Interface().(string)
if !ok {
return false
}
hasDigit, hasSpecial := false, false
for _, r := range pwd {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r == '!' || r == '@' || r == '#' || r == '$':
hasSpecial = true
}
}
return hasDigit && hasSpecial
}
func init() {
// 把函数注册成名为 strongpwd 的规则(进程启动时执行一次)
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("strongpwd", strongPassword)
}
}
type SignUpReq struct {
// binding tag 里直接用自定义规则名,可和内置规则混用
Password string `json:"password" binding:"required,min=8,max=72,strongpwd"`
}
func main() {
engine := gin.Default()
engine.POST("/users/signup", func(c *gin.Context) {
var req SignUpReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "ok"})
})
engine.Run(":8080")
}
RegisterValidation 必须在任何请求到来之前执行。放在 init() 或 main 最前面都行;如果写在某个 handler 里"第一次访问时再注册",之前进来的请求会直接报 undefined validation function。另外自定义函数内部不要 panic——validator 不帮你兜底,panic 会打崩整个请求。小收口
这一节你拿到了"接参数 + 校验参数"的本事。记住两条主路:手写正则灵活但啰嗦;binding tag 声明式更清爽,校验失败时 ShouldBindJSON 不写响应、要自己处理。下一节我们撞上第一个"环境问题"——跨域,并顺势引入中间件。
3.4 跨域问题与 Middleware(中间件)
先抛问题
注册接口写完了,本地联调时马上会撞上第一个"环境问题":前端页面跑在 localhost:3000、后端跑在 localhost:8080,浏览器会拦下前端发往后端的请求。为什么会拦?又怎么让它不拦?解决它恰好需要引入本章另一个重要概念——Middleware(中间件)。
给个直觉:跨域像"跨校区送外卖被门卫拦"
你(前端)在 A 校区(3000 端口),要给 B 校区(8080 端口)的后厨(后端)点单。两个校区门禁系统不一样(端口不同 = 不同源),B 校区的门卫(浏览器同源策略)默认不放你进来。除非 B 校区提前在前台贴一张"准入名单"(CORS 响应头),说"A 校区的人我收"。
补课胶囊 · 什么是跨域 CORS 一句话:浏览器出于安全,禁止页面向"不同源"(域名或端口不同)的服务器发请求,除非对方在响应头里明确放行。 类比:像小区门禁——你只能凭业主卡进自己小区;要去隔壁小区串门,得隔壁小区物业在门口贴"欢迎 XX 小区访客"。 最小示例:
前端 localhost:3000 → 请求 → 后端 localhost:8080 端口不同 = 跨域 → 浏览器先发 OPTIONS 预检问"让不让我进" 后端回 Access-Control-Allow-Origin: http://localhost:3000 → 放行回到主线:CORS 配置是写给浏览器看的"准入名单",下面用 middleware 来加这张名单。
3.4.1 什么是 Middleware(中间件)
Middleware(中间件) 是一种 AOP(面向切面编程)机制:让所有请求经过一段公共逻辑。Java 里叫 interceptor / filter,Go 这里叫 middleware。
适合用 middleware 解决的问题都带一个共同点——横切:它们不属于某个具体业务逻辑(不是"注册"或"登录"专属),而是"所有请求都要过一遍"。如果把日志、CORS 写进每个 Handler,代码又臭又长还容易漏。Middleware 把它们抽成"管道上的一个环节",Handler 干干净净只管业务。
flowchart LR
Req[请求] --> M1[Logger 中间件]
M1 --> M2[CORS 中间件]
M2 --> M3[登录校验中间件]
M3 --> H[最终 Handler]
H --> Res[响应]这张图怎么读:请求进来后不直接到 Handler,而是按注册顺序依次穿过 Logger→CORS→登录校验三个中间件,每个中间件干完自己的横切事(记日志、放行跨域、验登录态)就调用 c.Next() 把控制权交给下一环,最后一棒才是最终 Handler;Handler 处理完生成响应,沿原路返回给调用方。想再加一类公共逻辑,就是在链条中间插一个新环节,Handler 本身不用动。
3.4.2 用 Gin 中间件解决 CORS
go get github.com/gin-contrib/cors
// 文件路径: ~/code/webook/internal/web/engine.go(演示片段)
package web
import (
"net/http"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupEngine() *gin.Engine {
engine := gin.Default()
// Use 注册的 middleware 会作用于所有路由
// cors.New 返回一个 gin.HandlerFunc
engine.Use(cors.New(cors.Config{
// 也可以用函数动态判断来源(更灵活也更安全)
AllowOriginFunc: func(origin string) bool {
return origin == "http://localhost:3000"
},
AllowCredentials: true, // 允许携带认证信息(cookie)
AllowHeaders: []string{"Content-Type", "Authorization"},
ExposeHeaders: []string{"x-jwt-token"}, // JWT 场景需要暴露这个头
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
MaxAge: 12 * time.Hour, // preflight 响应缓存时间
}))
return engine
}
AllowHeaders 必须包含 Authorization,否则前端带不上 JWT;ExposeHeaders 必须包含 x-jwt-token,否则前端读不到这个响应头(CORS 默认只让前端读少数几个"安全"响应头)。
AllowOriginFunc(比如开发期图省事写 *)上线后就是安全漏洞。preflight 机制就是让浏览器在"真发请求前"先探一波,确认后端放行才发正式请求。3.4.3 自定义 Middleware
// 文件路径: ~/code/webook/internal/web/logger_middleware.go(演示片段)
package web
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
// Logger 返回一个 gin.HandlerFunc,即一个中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// c.Next() 把控制权交给下一个 middleware / 最终 handler
c.Next()
// c.Next() 之后的代码在请求处理完后执行
fmt.Printf("[%s] %s %d %v\n",
start.Format(time.RFC3339),
c.Request.URL.Path,
c.Writer.Status(),
time.Since(start),
)
}
}
func main() {
engine := gin.New()
engine.Use(Logger())
// ...
}
c.Next() 是 middleware 的"分水岭"。c.Next() 之前的代码在请求处理前跑(鉴权、计时起点),之后的代码在请求处理完后跑(记耗时、记日志)。一个日志 middleware 必须把 start := time.Now() 放在 c.Next() 前、time.Since(start) 放在后——放反了就量不出耗时。小收口
跨域是浏览器的安全门禁,CORS 是后端贴的"准入名单";中间件是把日志、CORS、登录校验这类"横切逻辑"抽出来的管道环节,用 Use 注册、c.Next() 分隔前后。下一节我们让数据真正落库——请出 GORM。
3.5 GORM 入门:把数据存进 MySQL
先抛问题
请求能进来了、参数也校验过了,下一步是把数据存下来。可数据库里是表、是 SQL,Go 里是结构体、是方法,两边怎么对上?能不能不写 SQL 字符串就操作数据库?
这一节从"什么是 ORM"讲起,一路讲到建表和基础 CRUD。
补课胶囊 · 什么是 ORM 一句话:ORM 是把"数据库表"映射成"Go 结构体"、“SQL 操作"映射成"方法调用"的翻译层,让你用 Go 代码操作数据库。 类比:ORM 像"翻译官”。你写
user.Create(),它翻译成INSERT INTO user ...;你写user.FindByEmail(),它翻译成SELECT ... WHERE email=?。没有它,你得手写一堆 SQL 拼接,既易错又易被 SQL 注入。 最小示例:var u User db.Where("email = ?", email).First(&u) // 像写 Go,背后是 SELECT回到主线:GORM 是 Go 生态最流行的 ORM 框架,下面从安装到建表一步步来。
3.5.1 安装与初始化
GORM 使用永远是三步:初始化 DB 实例 →(可选)初始化表结构 → 发起查询。
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
// 文件路径: ~/code/webook/internal/repository/dao/mysql.go(演示片段)
package dao
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitMySQL() (*gorm.DB, error) {
// DSN 格式:用户名:密码@tcp(host:port)/dbname?参数
// TranslateError: true 让 GORM 把驱动错误翻译成 gorm.ErrDuplicatedKey 等哨兵错误
dsn := "root:root@tcp(localhost:3306)/webook?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{TranslateError: true})
if err != nil {
return nil, fmt.Errorf("connect mysql: %w", err)
}
return db, nil
}
// 连接池配置(生产环境必须配置)
func setupConnPool(db *gorm.DB) {
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(100) // 最大连接数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间
}
3.5.2 Docker Compose 启动 MySQL
本地开发推荐用 Docker Compose 起 MySQL:一条命令拉起版本确定、随时可销毁重建的数据库。
# 文件路径: ~/code/webook/docker-compose.yaml
services:
mysql8:
image: mysql:8.0.29
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: root
volumes:
- ./init:/docker-entrypoint-initdb.d # 启动时执行初始化 SQL
ports:
- "3306:3306"
| 配置项 | 含义 |
|---|---|
services | 顶级节点,要启动的所有服务放这里 |
mysql8 | 服务名,自己起,docker compose 靠它定位 |
image | 跑什么镜像(锁死 mysql 8.0.29 版本) |
command | 容器启动追加参数(改用老版密码插件,避免 Go 驱动连不上 MySQL 8) |
environment | 环境变量(这里 root 密码) |
volumes | 挂载目录(./init 挂到容器初始化目录) |
ports | 端口映射 宿主机:容器(本机 3306 → 容器 3306) |
docker compose up -d # 启动
docker compose down # 停止并删容器
看到 ready for connections 即启动成功。
3.5.3 模型定义
在 ORM 世界"模型即表结构"——这个结构体连同它的 gorm 标签,决定了 users 表长什么样:
// 文件路径: ~/code/webook/internal/repository/dao/user.go(演示片段)
package dao
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID int64 `gorm:"primaryKey,autoIncrement"` // 自增主键
Email string `gorm:"uniqueIndex"` // 唯一索引
Password string // 默认映射为 password 列
Nickname string
Birthday string
AboutMe string
Ctime int64 // 创建时间(毫秒),用 int 避免时区问题
Utime int64 // 更新时间
}
工程要点:
- 用自增主键:数据库帮我们生成 ID,避免业务 ID 冲突。
Email加唯一索引:保证邮箱不重复;注册时数据库替我们做这个校验。- 时间用
int64毫秒戳:避免时区、序列化格式问题,跨语言友好。
int64 毫秒戳存 ctime/utime 而不是 time.Time,是踩过时区坑后的共识——time.Time 带时区,序列化/跨语言传时容易被转错;毫秒戳是无歧义的纯数字,前端自己格式化。软删除(DeletedAt)同理:加了 DeletedAt 后 GORM 所有查询自动带 WHERE deleted_at IS NULL,要真删得用 Unscoped()——防误删的双刃剑,记得告诉 DBA。3.5.4 基础 CRUD
GORM 把 SQL 封装成方法:Create 插入、First 查单条、Updates 更新。
// 文件路径: ~/code/webook/internal/repository/dao/user_crud.go(演示片段)
package dao
import "gorm.io/gorm"
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
// Insert 创建用户。参数是值传递:Create(&u) 会把自增 ID 回填到局部变量 u,
// 但函数返回后该副本即被丢弃,调用方拿不到这个 ID。
// 本课程注册场景不需要返回自增 ID,故用值传递即可;若要回传 ID,应把签名改成 Insert(u *User)。
func (d *UserDAO) Insert(u User) error {
return d.db.Create(&u).Error
}
// FindByEmail 按邮箱查找;没找到返回 ErrRecordNotFound
func (d *UserDAO) FindByEmail(email string) (User, error) {
var u User
err := d.db.Where("email = ?", email).First(&u).Error
return u, err
}
// UpdateByID 更新非零字段
// 注意 Updates 只更新非零字段,零值字段会被忽略
func (d *UserDAO) UpdateByID(u User) error {
return d.db.Model(&User{}).Where("id = ?", u.ID).
Updates(map[string]any{
"nickname": u.Nickname,
"birthday": u.Birthday,
"about_me": u.AboutMe,
}).Error
}
Updates 忽略零值。Updates(u) 只更新非零字段。比如用户把 Nickname 改成空字符串想清空资料,Updates(u) 会因为空串是"零值"而跳过这个字段,清空失败。要更新零值(空串、0、false),必须传 map[string]any{"nickname": ""} 或用 Select 显式指定列。3.5.5 建表:AutoMigrate
// 文件路径: ~/code/webook/internal/repository/dao/migrate.go(演示片段)
package dao
import "gorm.io/gorm"
func InitTables(db *gorm.DB) error {
// AutoMigrate:表不存在就建表;表存在但缺列、缺索引就补上
return db.AutoMigrate(
&User{},
// 后续新增表(Article、Comment...)都回到这里补一行
)
}
调用时机:在 main 里连完数据库后立刻调用一次。后续每新增一张表都要回到这里初始化,否则第一笔写入会报 Table 'webook.users' doesn't exist。
AutoMigrate 只会加列、加索引,不会删列、不会改列类型(怕你误删数据)——所以线上表结构和代码模型"飘了"是常态,靠它对不齐。规范严格的公司,表结构变更(DDL)要走审批:你写 SQL → leader 评审 → DBA 评审(重点看索引设计)→ DBA 执行。中小企业至少建议 DDL 手动管理(建表 SQL 进仓库、走 MR review),AutoMigrate 只留给本地和测试库。3.5.6 整合加餐:GORM 的 embedded(inline)标签
场景:每张表都有 ID / Ctime / Utime 公共字段,每个模型都抄一遍太蠢。抽一个"基础字段结构体"嵌入——但嵌入的字段,GORM 会平铺进同一张表,还是当成独立东西?结论:
- 匿名嵌入(直接写类型名):默认平铺——内层字段直接变成外层表的列(最熟悉的
gorm.Model就是这种)。 - 有名字段(写
Base BaseFields):默认不平铺!会报运行时错误。 - 给有名字段打
gorm:"embedded"(训练营口头叫 inline),明确告诉 GORM “平铺进当前表”。
// 文件路径: ~/code/webook/internal/repository/dao/base_fields.go(演示片段)
package dao
// 抽出公共字段
type BaseFields struct {
Id int64 `gorm:"primaryKey,autoIncrement"`
Ctime int64
Utime int64
}
type User struct {
BaseFields // 匿名嵌入——默认平铺
Email string `gorm:"uniqueIndex"`
Password string
Nickname string
}
// users 表的列:id / ctime / utime / email / password / nickname
type Article struct {
Base BaseFields `gorm:"embedded"` // 有名字段——必须加 embedded 才平铺
Title string
}
// articles 表的列:id / ctime / utime / title
type Post struct {
Base BaseFields `gorm:"embedded"`
Author BaseFields `gorm:"embedded;embeddedPrefix:author_"` // 重名加前缀
Title string
}
// posts 表的列:id / ctime / utime / author_id / author_ctime / author_utime / title
| 写法 | 是否平铺到同一张表 | 说明 |
|---|---|---|
BaseFields(匿名,无标签) | 默认平铺 | 最常用,gorm.Model 同款 |
Base BaseFields(有名,无标签) | 不平铺 | 被当普通字段/关联,建表报错 |
Base BaseFields + gorm:"embedded" | 平铺 | 有名字段的正确打开方式 |
再加 embeddedPrefix:xxx_ | 平铺且加前缀 | 多个嵌入结构体字段重名时解法 |
Base BaseFields 不写 embedded,GORM 不会报"语法错误",而是建表/查询时抛 unsupported data type 这类让人摸不着头脑的运行时错误。另一个坑:匿名嵌入的多个结构体不能有同名字段,一旦冲突(比如两组都有 Id),必须改成"有名 + embeddedPrefix"方案。小收口
这一节你拿到了"用结构体操作 MySQL"的能力。记住 ORM 是翻译官、Updates 只更新非零字段、AutoMigrate 只加不改、嵌入式字段要 embedded 才平铺。下一节是全章重点——分层架构。
3.6 分层架构:Handler / Service / Repository / DAO / Domain
先抛问题
注册接口的"零件"齐了:Gin 接参数、正则校验、GORM 落库。但如果把它们全堆在一个函数里,项目一大就成一团乱麻。新人最典型的写法就是在 Handler 里直接 db.Create(...)——为什么这是大忌?
这一节是全章最重要的一节。我会用一个生活类比把"为什么要分层"讲透。
补课胶囊 · 什么是分层架构 一句话:分层就是把"接请求 / 做业务 / 存取数据"拆成各管一摊的层,下层给上层提供服务,谁也不越界。 类比:开餐厅——前台只管点单(Handler),厨师只管做菜(Service),采购只管买食材(Repository/DAO),菜谱是业务对象(Domain)。厨师不自己去买菜、不自己当前台,每层只干自己的活。 最小示例:
// Handler 调 Service,Service 调 Repository,Repository 调 DAO h.svc.Signup(...) // 永远不直接碰 db回到主线:下面看"Handler 里直接写 SQL"的反面教材,以及正确的五层怎么拆。
3.6.1 为什么不能在 Handler 里直接操作数据库
先看一段"能跑但千万别学"的反面教材:
// 反面教材:Handler 直接操作数据库
func (h *UserHandler) SignUp(c *gin.Context) {
var req SignUpReq
c.Bind(&req)
// 直接 db.Create(...) —— 问题:
// 1. HTTP 逻辑和数据库逻辑耦合,无法复用
// 2. 无法单元测试(要起真实数据库)
// 3. 没有业务逻辑层,密码加密放哪里?
}
3.6.2 五层架构
| 层 | 职责 | 是否依赖数据库 |
|---|---|---|
| Handler | HTTP 请求/响应处理,路由 | 否 |
| Service | 业务逻辑(加密、校验、组合多个 Repository) | 否 |
| Repository | 数据存储抽象(不绑定具体数据库) | 否 |
| DAO | 数据库操作(GORM 调用) | 是 |
| Domain | 业务对象(领域模型) | 否 |
大方向:越靠近 HTTP 的层越"薄",只做转译;越靠近数据的层越"专一",只管存取。
flowchart TD
H["Handler
HTTP 请求/响应"] --> S["Service
业务逻辑"]
S --> R["Repository
存储抽象"]
R --> D["DAO
数据库操作"]
D --> DB["(MySQL)"]
DM["Domain
业务对象"] -. 贯穿各层 .-> H
DM -.-> S为什么有 Repository 还要有 DAO:
- Repository 是整体抽象:代表"存储",背后可以是 MySQL、ES、Mongo。
- DAO 是具体实现:直接映射到数据库表。
- 只用 DAO,未来切换存储就要改 DAO,影响业务;引入 Repository 后,业务层只依赖接口,切换实现不影响业务。
3.6.3 Domain vs DAO 模型
最让新手困惑的一件事:为什么 User 要定义两遍?
// 文件路径: ~/code/webook/internal/domain/user.go
package domain
type User struct {
ID int64
Email string
Password string
Nickname string
}
// 文件路径: ~/code/webook/internal/repository/dao/user.go
package dao
type User struct {
ID int64 `gorm:"primaryKey,autoIncrement"`
Email string `gorm:"uniqueIndex"`
Password string
Nickname string
Ctime int64
Utime int64
}
为什么不复用一个:业务概念不一定和表结构一一对应。数据库里某字段是 JSON 字符串,业务层可能是结构体;多表关联业务层可能是聚合对象。分开后两边独立演化。
User.Nickname;至于这字段在 MySQL 是 varchar、在 ES 是 text、在 Redis 是 hash,业务代码一个字都不用管。反过来数据库从 MySQL 迁到 MongoDB,只要 DAO 重写、Domain 不动,上层 Service 完全无感——这是"开闭原则"在存储层的体现。3.6.4 完整代码示例(五层串起来)
重点留意两处:Repository 如何把 DAO 的数据库错误"翻译"成业务错误;Service 里密码加密的位置。
// 文件路径: ~/code/webook/internal/domain/user.go
package domain
type User struct {
ID int64
Email string
Password string
Nickname string
Birthday string
AboutMe string
}
// 文件路径: ~/code/webook/internal/repository/user.go
package repository
import (
"errors"
"webook/internal/domain"
"webook/internal/repository/dao"
)
var (
ErrUserDuplicateEmail = errors.New("邮箱冲突")
ErrUserNotFound = errors.New("用户不存在")
)
type UserRepository interface {
Create(user domain.User) error
FindByEmail(email string) (domain.User, error)
FindByID(id int64) (domain.User, error)
Update(user domain.User) error
}
type userRepository struct {
dao *dao.UserDAO
}
func NewUserRepository(dao *dao.UserDAO) UserRepository {
return &userRepository{dao: dao}
}
func (r *userRepository) Create(user domain.User) error {
err := r.dao.Insert(dao.User{
Email: user.Email,
Password: user.Password,
Nickname: user.Nickname,
})
if errors.Is(err, dao.ErrUserDuplicateEmail) {
// 错误别名:DAO 层数据库错误 → Repository 层业务错误
return ErrUserDuplicateEmail
}
return err
}
func (r *userRepository) FindByEmail(email string) (domain.User, error) {
u, err := r.dao.FindByEmail(email)
if err != nil {
return domain.User{}, err
}
return domain.User{
ID: u.ID,
Email: u.Email,
Password: u.Password,
Nickname: u.Nickname,
}, nil
}
// 文件路径: ~/code/webook/internal/repository/dao/user.go
package dao
import (
"errors"
"gorm.io/gorm"
)
var ErrUserDuplicateEmail = errors.New("邮箱冲突")
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func (d *UserDAO) Insert(u User) error {
err := d.db.Create(&u).Error
if err != nil {
// 用 gorm.ErrDuplicatedKey(GORM v1.25+)识别唯一索引冲突
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrUserDuplicateEmail
}
return err
}
return nil
}
func (d *UserDAO) FindByEmail(email string) (User, error) {
var u User
err := d.db.Where("email = ?", email).First(&u).Error
return u, err
}
// 文件路径: ~/code/webook/internal/service/user.go
package service
import (
"errors"
"golang.org/x/crypto/bcrypt"
"webook/internal/domain"
"webook/internal/repository"
)
var (
ErrInvalidCredentials = errors.New("用户名或密码不对")
ErrDuplicateEmail = repository.ErrUserDuplicateEmail
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
// Signup 注册;密码加密放在 service 层(加密是业务概念,不是存储概念)
func (s *UserService) Signup(user domain.User) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(hashed)
err = s.repo.Create(user)
if errors.Is(err, repository.ErrUserDuplicateEmail) {
return ErrDuplicateEmail
}
return err
}
// Login 登录
func (s *UserService) Login(email, password string) (domain.User, error) {
u, err := s.repo.FindByEmail(email)
if err != nil {
// 不管用户不存在还是密码错,都返回同一个错误,防枚举攻击
return domain.User{}, ErrInvalidCredentials
}
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
return domain.User{}, ErrInvalidCredentials
}
return u, nil
}
// 文件路径: ~/code/webook/internal/web/user.go
package web
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"webook/internal/domain"
"webook/internal/service"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) RegisterRoutes(g *gin.RouterGroup) {
g.POST("/signup", h.SignUp)
g.POST("/login", h.Login)
}
func (h *UserHandler) SignUp(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
}
if err := c.Bind(&req); err != nil {
return
}
// 校验省略...
err := h.svc.Signup(domain.User{Email: req.Email, Password: req.Password})
if errors.Is(err, service.ErrDuplicateEmail) {
c.JSON(http.StatusOK, gin.H{"msg": "该邮箱已注册"})
return
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "系统错误"})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "注册成功"})
}
Signup 全流程,分层架构就真正入门了。3.6.5 main 函数组装(依赖注入雏形)
五层写好后,在 main 里自下而上组装:先建 DAO,再包进 Repository,逐层向上,最后把 Service 交给 Handler。
// 文件路径: ~/code/webook/main.go(组装片段)
package main
import (
"github.com/gin-gonic/gin"
"webook/internal/repository"
"webook/internal/repository/dao"
"webook/internal/service"
"webook/internal/web"
)
func main() {
db := initDB()
userDAO := dao.NewUserDAO(db) // DAO 层
userRepo := repository.NewUserRepository(userDAO) // Repository 层
userService := service.NewUserService(userRepo) // Service 层
userHandler := web.NewUserHandler(userService) // Handler 层
engine := gin.Default()
userHandler.RegisterRoutes(engine.Group("/users"))
engine.Run(":8080")
}
flowchart TD
DB[(MySQL)] --> DAO[DAO 层]
DAO --> REPO[Repository 层]
REPO --> SVC[Service 层]
SVC --> H[Handler 层]
H -->|处理请求时反向调用| SVC
SVC -->|反向调用| REPO这种"自下而上构造,自上而下调用"就是依赖注入(DI)的雏形。后面会引入 Wire 自动生成这些胶水代码。
NewUserService(repo) 把仓库塞给服务,服务不自己 new 具体数据库——测试时你能传内存假仓库,生产时传真 MySQL 仓库。手写 NewXXX 是"手动 DI",项目变大后用 Wire 生成这些胶水代码。小收口
分层是全书主线:调用不跨层、错误要转译、依赖走接口、模型分两套。下一节补上最后一块安全拼图——密码怎么存。
3.7 密码加密:为什么是 BCrypt
先抛问题
注册链路还差最后一块安全拼图:密码到底怎么存?直接存明文?还是 MD5 哈希一下?为什么老手都选 BCrypt?
补课胶囊 · 为什么 BCrypt 比 MD5 适合存密码 一句话:MD5 是"快哈希",相同密码永远得到相同结果,可被彩虹表反推;BCrypt 每次随机加盐、结果都不同、还故意慢,抗暴力破解。 类比:明文存密码像把家门钥匙挂门上;MD5 像把钥匙熔成固定形状的金属块,坏人备好"钥匙→熔块"对照表就能反推;BCrypt 每次熔都随机撒盐,同一把钥匙每次熔出形状都不同,对照表失效。 最小示例:
h1, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) h2, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) // h1 != h2,但都能 CompareHashAndPassword 通过回到主线:下面看加密算法如何一路演进到 BCrypt。
3.7.1 加密算法的演进
密码加密在防两类人——研发(包括你自己和同事)和外部攻击者。所以"数据库里的密码谁都看不懂"只是及格线,“明文密码连日志都不能打"才是红线。
| 算法 | 缺陷 |
|---|---|
| MD5 / SHA-1 | 相同密码哈希结果相同,易被彩虹表破解 |
| 加盐 MD5 | 盐值要存储,开发者容易用错 |
| PBKDF2 / BCrypt | 自带随机盐值,相同密码每次哈希结果不同 |
cost 调),且每次加盐随机,彩虹表直接失效。选哈希算法别只看"能不能用",要看"抗暴力破解"。3.7.2 BCrypt 的优点
// 文件路径: ~/code/webook/internal/service/bcrypt_demo.go(演示片段)
package service
import "golang.org/x/crypto/bcrypt"
// 加密:每次结果不同(盐值随机)
func demo() {
hashed1, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
hashed2, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
// hashed1 != hashed2,但都能通过 CompareHashAndPassword 校验
// 校验:把明文和哈希比较
err := bcrypt.CompareHashAndPassword(hashed1, []byte("123456")) // nil = 匹配
_ = err
}
BCrypt 四条特点:
- 自带随机盐值,无需单独存储盐
- 通过
cost控制加密耗时(cost+1,耗时翻倍),对抗暴力破解 - 不可逆,无法解密,只能比对
- 限制密码长度 ≤ 72 字节,校验时要在正则里限制
flowchart LR
P[明文密码 123456] --> B[BCrypt 加密
随机盐 + cost]
B --> H1[哈希1 随机]
B --> H2[哈希2 也随机]
P --> C[CompareHashAndPassword
比对明文与哈希]
C --> OK["匹配? 返回 nil"]golang.org/x/crypto/bcrypt 遇到超过 72 字节的密码不会静默截断,而是直接返回 ErrPasswordTooLong 错误(对应本节 Signup 里 GenerateFromPassword 后的 if err != nil 分支)。所以如果你不在校验层限制长度,用户设个超长密码,注册就会直接报错失败。因此注册校验时要用正则把密码长度卡在 72 以内。3.7.3 加密放在哪一层
| 层 | 论点 |
|---|---|
| Service | 加密是业务概念(推荐) |
| Repository | 加密是"加密存储"概念 |
| DAO | 加密是数据库概念 |
| Domain | 加密是 User 自己的事 |
课程选 Service。好处:业务逻辑对"密码经过哈希"负责,Repository / DAO 只管存储。哪天换 Argon2,只改 Service 一处。
小收口
密码必须 BCrypt 加密、放 Service 层、长度卡 72 内、明文不得进日志。下一节解决"错误怎么从 DAO 一路传到 Handler”。
3.8 错误传导:别名机制
先抛问题
分层之后有个现实问题:DAO 层报出的数据库错误,如何一路传到最上层 Handler?如果 Handler 直接 import dao 包的错误类型来判断,分层就被"击穿"了。
目标:让顶层 Handler 只依赖 Service,不依赖 DAO/Repository 的错误类型。
// DAO 层:把 gorm.ErrDuplicatedKey 转换为 ErrUserDuplicateEmail
var ErrUserDuplicateEmail = errors.New("邮箱冲突")
// Repository 层:再把 dao.ErrUserDuplicateEmail 转换为 repository.ErrUserDuplicateEmail
var ErrUserDuplicateEmail = errors.New("邮箱冲突") // 同名但不同包
// Service 层:直接转发
var ErrDuplicateEmail = repository.ErrUserDuplicateEmail
// Handler 层:只依赖 Service
if errors.Is(err, service.ErrDuplicateEmail) {
c.JSON(http.StatusOK, gin.H{"msg": "该邮箱已注册"})
}
好处:Handler 不需要 import dao 包,跨层依赖被切断。未来 DAO 从 GORM 换 Mongo,Handler 和 Service 完全不用改。
gorm.ErrDuplicatedKey 怎么来的? GORM v1.25 之前没有这个哨兵错误,得用 MySQL 驱动的错误类型自己判断:拿到 error 后断言成 *mysql.MySQLError,看错误码 e.Number == 1062(唯一索引冲突码);更粗糙的老写法是 strings.Contains(err.Error(), "Duplicate entry")——靠错误文案判断,驱动升级改文案就失效。还有个坑:gorm.ErrDuplicatedKey 需要初始化时开 gorm.Config{TranslateError: true} 才生效,不开则返回的还是驱动原始错误,errors.Is 永远匹配不上。小收口
错误别名是切断跨层依赖的手段:每层把下层错误转成自己的错误,Handler 永远只认 Service 的错误。下一节解决"登录之后怎么认出还是这个人"。
3.9 登录态:Cookie、Session 与 Middleware
先抛问题
注册和登录这两个"一次性行为"完成后,还剩"持续状态"问题:用户登录之后发下一个请求,服务器怎么知道"还是这个人"?HTTP 本身记不住事,得靠 Cookie 和 Session。
补课胶囊 · 什么是 Cookie / Session 一句话:Cookie 是浏览器本地存的小纸条(每次请求自动带上);Session 是服务端凭纸条上的 ID 查出的"用户档案",真正数据存在服务端。 类比:Cookie 像门禁卡(你随身带);Session 像前台抽屉里按卡号存的访客登记本(卡只写卡号,本子在物业手里)。卡丢了别人能冒充你,所以卡要加密、要走 HTTPS。 最小示例:
登录成功 → 服务端生成 sess_id,把 user_id 存后端 把 sess_id 写回浏览器 Cookie 下次请求自动带 sess_id → 服务端查出 user_id → 认出是同一个人回到主线:下面用 Gin 的 Session 插件实现完整登录态。
3.9.1 HTTP 是无状态的
HTTP 协议本身无状态:连续两次请求,服务器无法知道是不是同一用户。代价是"每次都要自带身份信息";好处是能水平扩展——任何一个请求都能被任意实例处理。
flowchart TD
L[用户登录] --> S[服务端生成 sess_id
并把数据存后端]
S --> C[把 sess_id 写回浏览器 Cookie]
C --> R1[后续请求自动带 sess_id]
R1 --> V[服务端凭 sess_id 查出用户
识别这是同一个人]这张图怎么读:这是登录态建立与使用的数据流——用户登录后,服务端先生成 sess_id 并把用户数据存在后端,再把 sess_id 写回浏览器 Cookie;此后每次请求浏览器自动携带 sess_id,服务端凭这个 ID 反查出用户、识别"还是同一个人"。注意数据分两半:浏览器手里只有 ID,真正的用户数据始终留在服务端,这正是下一小节 Cookie 安全配置要守护的东西。
3.9.2 Cookie 关键配置
| 字段 | 含义 | 建议 |
|---|---|---|
| Domain | Cookie 可用域名 | 最小化原则 |
| Path | Cookie 可用路径 | 最小化原则 |
| Max-Age / Expires | 过期时间 | 只保留必要时间 |
| HttpOnly | true 则 JS 无法读 Cookie | 永远 true |
| Secure | 只走 HTTPS | 生产永远 true |
| SameSite | 是否允许跨站发送 | 尽量避免 |
HttpOnly=true 挡住 XSS 偷 Cookie 的 JS 读取,Secure=true 保证只走 HTTPS 不被中间人截。现代浏览器 SameSite 默认偏严格(Lax),能防一部分 CSRF,但涉及钱的操作仍要配 CSRF Token,别迷信这一个开关。3.9.3 Session
Session 把关键数据存在后端,浏览器只持 sess_id。这样即使 sess_id 泄露,攻击者也拿不到原始数据。
sess_id,服务器就把他当成你——因为服务端只凭这个 ID 查数据,不验证"是不是你本人在用"。所以 sess_id 必须走 HTTPS、Cookie 设 HttpOnly+Secure、并定期刷新。sess_id 放哪传给服务端?三种方式:
| 载体 | 写法 | 说明 |
|---|---|---|
| Cookie(最佳) | 服务端 Set-Cookie 下发 | 浏览器每次自动带,用户无感 |
| Header | 前端每个请求手动带 sess_id | 需前端记得带,易漏 |
| 查询参数 | URL 拼 ?sess_id=xxx | 进访问日志,最不推荐 |
3.9.4 用 Gin Session 插件实现登录
go get github.com/gin-contrib/sessions
go get github.com/gin-contrib/sessions/cookie
// 文件路径: ~/code/webook/internal/web/user_session.go(演示片段)
package web
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func (h *UserHandler) RegisterRoutes(g *gin.RouterGroup) {
// 1. 初始化 store:基于 cookie 的实现(开发期用,生产用 Redis)
// 第二个 key(加密 key)长度必须是 16/24/32 字节,否则首次写 Session 会报错
store := cookie.NewStore([]byte("a-key-for-auth-must-be-32-bytes!"),
[]byte("a-key-for-encryption-32-bytes-ok"))
store.Options(sessions.Options{
MaxAge: 60 * 60 * 24,
HttpOnly: true,
Secure: false, // 开发环境 false,生产必须 true
SameSite: http.SameSiteLaxMode,
})
g.Use(sessions.Sessions("sess_id", store))
g.POST("/login", h.Login)
g.GET("/profile", h.LoginRequired, h.Profile) // 需登录的路由加校验中间件
}
func (h *UserHandler) Login(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.Bind(&req); err != nil {
return
}
user, err := h.svc.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "用户名或密码不对"})
return
}
// 登录成功,把 user_id 写进 Session(数据存后端,浏览器只拿 sess_id)
sess := sessions.Default(c)
sess.Set("user_id", user.ID)
sess.Options(sessions.Options{MaxAge: 60 * 60 * 24})
if err := sess.Save(); err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "系统错误"})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
}
// LoginRequired 登录校验 middleware
func (h *UserHandler) LoginRequired(c *gin.Context) {
sess := sessions.Default(c)
userID, ok := sess.Get("user_id").(int64)
if !ok || userID == 0 {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Set("user_id", userID) // 传给后续 handler
c.Next()
}
func (h *UserHandler) Profile(c *gin.Context) {
userID := c.MustGet("user_id").(int64)
c.JSON(http.StatusOK, gin.H{"user_id": userID})
}
工程视角:
c.Set+c.MustGet是 Gin 里"middleware 和 handler 之间传值"的标准做法。sess.Get("user_id").(int64)是类型断言——Session 里存的是interface{},取出来必须断言成int64,断言错类型会 panic。所以sess.Set("user_id", user.ID)要保证user.ID本就是int64,别混进int。
Session middleware 两步:① 接入 sessions.Sessions("sess_id", store) 从 Cookie 找 sess_id、从 store 找数据;② 使用 sessions.Default(c) 拿到 Session 对象,可 Set/Get/Delete/Save。
3.9.5 Session 存储的选择
| Store | 适用场景 |
|---|---|
| cookie | 单机开发 |
| memstore | 单实例部署 |
| redis | 多实例部署,无脑选 |
| gorm/mysql | 已有 MySQL 不想引 Redis |
| mongo / postgres / memcached | 已有对应存储 |
多实例部署必须用 Redis:用户请求经负载均衡可能落到不同实例,内存存储每个实例 Session 数据不一样。下一章细讲。
小收口
登录态 = Cookie 带 sess_id + Session 后端存数据;登录校验用 middleware 从 Session 取 user_id,没有就 401。至此注册-登录闭环的所有零件齐了,下一节我们一步步把它们组装成能跑的项目。
补充知识 · 进阶延伸【补充:小红书亿级流量项目】
本章所有接口的入参出参都在用 JSON 绑定,但标准库 encoding/json 并不是唯一选项,也不是最快的。亿级流量课程在"Go 技术栈映射"里专门列了序列化这一格,这里把它补上。
1. 三代选手:
| 库 | 定位 | 一句话点评 |
|---|---|---|
encoding/json | 标准库 | 稳定、反射实现,性能够用;绝大多数业务无脑选它 |
jsoniter(json-iterator) | 第三方 | API 兼容标准库,“换一行 import 就提速”,老项目无痛优化 |
sonic(字节开源) | JIT 编译 | 运行时生成编解码代码,号称最快;大流量接口的杀器 |
// 用法上三者几乎一致——都是 Marshal / Unmarshal 一对函数
type SignupReq struct {
Email string `json:"email" binding:"required,email"` // tag 同时服务 JSON 映射与参数校验
Password string `json:"password" binding:"required,min=8"`
}
req := SignupReq{Email: "a@b.com", Password: "abc123"}
data, _ := json.Marshal(req) // 想换 jsoniter / sonic,主要就是改 import 和别名
2. 结构体 tag 是序列化的"说明书":json:"email" 定义 JSON 字段名;不加 tag 时导出字段按原名输出(Email),和前端约定立刻对不上。还有一个高频坑:非导出字段(小写开头)永远不会被序列化——调试半天"数据没出来",十有八九是字段没大写或没加 tag。
3. 性能取舍的判断标准:序列化优化的收益 = QPS × 单次耗时差。日活几千的内部系统,标准库和 sonic 的差距根本测不出来;只有网关聚合、列表批量输出这种单请求序列化几十上百个对象的场景才值得换。这和第 04 章压测的结论一脉相承:先有数据,再谈优化。
4. 更进一步:内部服务之间的大流量通信可以彻底绕开 JSON,改用 Protobuf(二进制、体积小、编解码快)——第 10 章拆微服务时 gRPC 会正式登场,到时你会看到 JSON 与 Protobuf 的正面交锋。
四、跟着写:分步实现
这一节是动手主线,从零做一个能注册登录的 Web 服务。每一步结束项目都能编译、能跑、能用 curl 验证。建议新建目录 ~/code/webook 跟着敲。
第 1 步 · 初始化模块 + 最小 Gin 服务
目标:起一个 Gin 服务,GET /hello 返回 JSON。
mkdir -p ~/code/webook && cd ~/code/webook
go mod init github.com/yourname/webook
go get github.com/gin-gonic/gin@latest
// 文件路径: ~/code/webook/main.go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
engine := gin.Default()
engine.GET("/hello", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "hello, world"})
})
engine.Run(":8080")
}
验证:
go run main.go &
curl http://localhost:8080/hello
# 预期输出:{"msg":"hello, world"}
第 2 步 · 用 Handler 组织用户路由
目标:按分层骨架建 internal/web/user.go,用 UserHandler 集中注册用户路由。
mkdir -p ~/code/webook/internal/web
// 文件路径: ~/code/webook/internal/web/user.go
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
svc UserService // 先用占位类型,第 4 步补真 Service
}
func NewUserHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) RegisterRoutes(g *gin.RouterGroup) {
g.POST("/signup", h.SignUp)
g.POST("/login", h.Login)
g.GET("/profile", h.Profile)
}
func (h *UserHandler) SignUp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "signup"})
}
func (h *UserHandler) Login(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "login"})
}
func (h *UserHandler) Profile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "profile"})
}
注意:上面
UserService只是占位编译用。为让第 2 步先能编译,可临时把svc字段类型改为interface{}或定义一个空type UserService struct{}。第 4 步换成真类型即可。
第 3 步 · 起 MySQL + 定义 GORM 模型与 DAO
目标:用 Docker 起 MySQL,InitMySQL + User 模型 + UserDAO 能连库、能建表。
# 先准备 docker-compose.yaml(见 3.5.2 节),然后:
docker compose up -d
// 文件路径: ~/code/webook/internal/repository/dao/user.go
package dao
import (
"errors"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var ErrUserDuplicateEmail = errors.New("邮箱冲突")
type User struct {
ID int64 `gorm:"primaryKey,autoIncrement"`
Email string `gorm:"uniqueIndex"`
Password string
Nickname string
Ctime int64
Utime int64
}
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func InitMySQL() (*gorm.DB, error) {
// TranslateError: true 让 errors.Is(err, gorm.ErrDuplicatedKey) 能生效
dsn := "root:root@tcp(localhost:3306)/webook?charset=utf8mb4&parseTime=True&loc=Local"
return gorm.Open(mysql.Open(dsn), &gorm.Config{TranslateError: true})
}
func (d *UserDAO) Insert(u User) error {
err := d.db.Create(&u).Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrUserDuplicateEmail
}
return err
}
return nil
}
func (d *UserDAO) FindByEmail(email string) (User, error) {
var u User
err := d.db.Where("email = ?", email).First(&u).Error
return u, err
}
再建一个负责建表的文件——第 5 步的 main 组装会调用它:
// 文件路径: ~/code/webook/internal/repository/dao/migrate.go
package dao
import "gorm.io/gorm"
func InitTables(db *gorm.DB) error {
return db.AutoMigrate(&User{})
}
验证(建表):
// 在 main 里临时加:dao.InitTables(db) —— 见第 5 步 main 组装
预期:MySQL 里多出 users 表(DESCRIBE users; 可见 id/email/password… 列)。
第 4 步 · 写 Service 层(含 BCrypt 加密)
目标:UserService.Signup 加密密码并落库;Login 比对密码。
// 文件路径: ~/code/webook/internal/service/user.go
package service
import (
"errors"
"golang.org/x/crypto/bcrypt"
"webook/internal/domain"
"webook/internal/repository"
)
var (
ErrInvalidCredentials = errors.New("用户名或密码不对")
ErrDuplicateEmail = repository.ErrUserDuplicateEmail
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Signup(user domain.User) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(hashed)
if err := s.repo.Create(user); err != nil {
if errors.Is(err, repository.ErrUserDuplicateEmail) {
return ErrDuplicateEmail
}
return err
}
return nil
}
func (s *UserService) Login(email, password string) (domain.User, error) {
u, err := s.repo.FindByEmail(email)
if err != nil {
return domain.User{}, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
return domain.User{}, ErrInvalidCredentials
}
return u, nil
}
需要配套 domain.User(见 3.6.3)和 repository.UserRepository 接口(见 3.6.4)。
第 5 步 · 接好 Repository,组装 main
目标:自下而上组装 DAO → Repository → Service → Handler,并 AutoMigrate。
// 文件路径: ~/code/webook/main.go(最终组装版,含前面各层)
package main
import (
"log"
"github.com/gin-gonic/gin"
"webook/internal/domain"
"webook/internal/repository"
"webook/internal/repository/dao"
"webook/internal/service"
"webook/internal/web"
)
func main() {
db, err := dao.InitMySQL()
if err != nil {
log.Fatalf("init mysql: %v", err)
}
if err := dao.InitTables(db); err != nil {
log.Fatalf("init tables: %v", err)
}
userDAO := dao.NewUserDAO(db)
userRepo := repository.NewUserRepository(userDAO)
userService := service.NewUserService(userRepo)
userHandler := web.NewUserHandler(userService)
engine := gin.Default()
userHandler.RegisterRoutes(engine.Group("/users"))
engine.Run(":8080")
}
验证(注册):
curl -X POST http://localhost:8080/users/signup \
-H 'Content-Type: application/json' \
-d '{"email":"alice@qq.com","password":"abc123!@","confirmPassword":"abc123!@"}'
# 预期输出:{"msg":"注册成功"}
验证(重复注册触发唯一索引冲突):
curl -X POST http://localhost:8080/users/signup \
-H 'Content-Type: application/json' \
-d '{"email":"alice@qq.com","password":"abc123!@","confirmPassword":"abc123!@"}'
# 预期输出:{"msg":"该邮箱已注册"}
第 6 步 · 加 Session 登录态
目标:Login 成功后把 user_id 写进 Session;/profile 加 LoginRequired 中间件。
按 3.9.4 节代码给 UserHandler 加 sessions.Sessions(...) 中间件、改 Login 写 Session、加 LoginRequired 与 Profile 读 user_id。
验证(登录 + 带 Session 访问 profile):
curl -c /tmp/cookie.txt -X POST http://localhost:8080/users/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@qq.com","password":"abc123!@"}'
# 预期输出:{"msg":"登录成功"},且 -c 把 sess_id 存进 /tmp/cookie.txt
curl -b /tmp/cookie.txt http://localhost:8080/users/profile
# 预期输出:{"user_id":1}
curl http://localhost:8080/users/profile
# 预期:401 Unauthorized(没带 Session)
第 7 步 · 加 CORS 中间件
目标:让 3000 端口的前端能调 8080 的后端。
按 3.4.2 节,在 SetupEngine() 里 engine.Use(cors.New(...)),然后 main 用 SetupEngine() 代替 gin.Default()。
验证:用浏览器或 curl -H 'Origin: http://localhost:3000' 打接口,响应头应含 Access-Control-Allow-Origin: http://localhost:3000。
本章完成后的项目结构
webook/
├── main.go # 程序入口,自下而上组装
├── docker-compose.yaml # MySQL 环境
├── go.mod / go.sum
└── internal/
├── domain/user.go # 业务对象
├── repository/
│ ├── user.go # Repository 接口 + 实现
│ └── dao/
│ ├── mysql.go # InitMySQL
│ ├── user.go # UserDAO + User 模型
│ └── migrate.go # InitTables
├── service/user.go # 业务逻辑 + BCrypt
└── web/user.go # Handler + 路由 + Session
验证清单(可勾选)
-
go run main.go后curl /hello返回 JSON -
POST /users/signup合法邮箱密码返回"注册成功" - 同一邮箱再注册返回"该邮箱已注册"
-
POST /users/login正确密码返回"登录成功" - 登录后带 cookie 访问
GET /users/profile返回user_id - 不带 cookie 访问
/profile返回 401 - 带
Origin: localhost:3000的请求响应头含 CORS 允许
五、工程实践要点
把全章散落的工程经验集中复盘一遍。每一条都出自前文,想不起"为什么"就回对应小节再看。
5.1 分层架构(主线)
- 不要跨层调用:Handler 只调 Service,Service 只调 Repository,Repository 只调 DAO。
- 错误用别名机制向上传导:每层把下层错误转成自己的错误,避免上层依赖下层包。
- 接口优先:Repository 用接口定义,方便切换实现和单元测试(塞内存假实现)。
- Domain 独立于 DAO 模型:业务对象和数据库表分开演化。
5.2 密码安全(红线)
- 密码必须加密存储,禁止明文;用 BCrypt,别自己造算法。
- 密码不能打日志,敏感信息不能进日志——这是安全事故红线。
- 登录错误统一返回"用户名或密码不对",避免攻击者枚举哪些邮箱注册过。
- 密码长度卡在 72 字节内,超过 72 字节 Go 的 bcrypt 库会直接返回
ErrPasswordTooLong。
5.3 HTTP 接口
- GET 查询、POST 提交,这是新手原则;参数位置看场景(敏感/大体积进 Body)。
- Bind 自动处理 Content-Type,比手动
json.Unmarshal简单,但失败会写响应,必须return。 - 校验两边都做:前端为体验,后端为安全。
- 错误返回用 HTTP 状态码 + 业务码,别永远返回 200。
5.4 Gin 与存储组织
- 按业务模块组织 Handler,每个 Handler 有
RegisterRoutes方法。 - 分组路由避免路径前缀写错;
internal/放业务代码、pkg/放可复用代码。 - middleware 解决横切关注点:CORS、日志、登录校验、限流。
- AutoMigrate 只给本地/测试库用;生产 DDL 手动管理走 review。
- Session 多实例部署用 Redis:状态外置,才能随意扩缩容。
六、自测题与动手练习
学完不等于学会。下面先自己答,再动手做。
自测题(答案在第七节面试问答里能找到)
c.Bind绑定失败时会发生什么?为什么后面必须return而不能再c.JSON写响应?Updates(u)把昵称改成空字符串为什么会"清空失败"?要更新零值该怎么写?- 分层里 Handler / Service / Repository / DAO / Domain 各自职责是什么?为什么有了 DAO 还要 Repository?
- Cookie 和 Session 怎么配合记住登录态?为什么说 Session"认 ID 不认人"、带来什么风险?
- BCrypt 相比 MD5 好在哪?“相同密码每次哈希结果不同"为什么反而是优点?
bindingtag 的min=8放在string和int上语义一样吗?required为什么可能误判"年龄填 0”?- 注册要求"密码 8~72 位且同时含大小写字母和数字",
binding:"required,min=8,max=72"够吗?不够怎么办? - DAO 模型里
Base BaseFields这种有名字段想平铺进同一张表,不加标签会怎样?
动手练习(建议真做一遍)
- 跑通注册-登录闭环:本地起 MySQL,注册一个账号,用邮箱密码登录,从响应或日志确认 Session 写进了
user_id;不带 cookie 访问/profile确认返回 401。 - 故意触发唯一索引冲突:用同一个邮箱注册两次,观察从 DAO 的
ErrDuplicatedKey如何被转成 Handler 看到的"该邮箱已注册"。 - 加一个耗时统计中间件:写一个
Loggermiddleware 注册到 Engine,访问任意接口看控制台是否打印耗时(注意c.Next()前后放对位置)。
七、模拟面试问答
这一节模拟真实面试。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点组织。
c.Bind 绑定失败时会发生什么?为什么后面必须 return 而不能再写一次 c.JSON?一句话结论:c.Bind 在绑定失败(如 JSON 格式不对)时会自己向客户端写 400 响应;一旦返回 err 必须立刻 return,否则再写响应会"重复写响应"报 superfluous WriteHeader call。
展开:c.Bind 看 Content-Type 决定反序列化方式,并顺手按 binding tag 校验;失败它已经调用了 c.Writer.WriteHeader(400) 并写了 body。HTTP 响应头只能写一次,你再调 c.JSON 等于第二次写头,Go 的 http 会报错或直接被后来的写覆盖,客户端也可能收到两段响应。
面试官想听的关键词:失败即写响应、400、superfluous WriteHeader、return 中止、ShouldBind 不写响应。
常见失分点:只知道要 return 却说不清"为什么不能写第二次"(答不出"响应头只能写一次");或者分不清 Bind 和 ShouldBindJSON(后者失败不写响应,要自己处理)。
一句话结论:Handler 管 HTTP 收发、Service 管业务逻辑、Repository 是"存储抽象"、DAO 是"数据库操作"、Domain 是业务对象;Repository 把"存储"抽象成接口,让业务层不绑定具体数据库,未来换 MySQL→Mongo 只改 DAO。
展开:越靠近 HTTP 的层越薄(只做转译),越靠近数据的层越专一(只管存取)。Repository 是整体抽象(背后可以是 MySQL/ES/Mongo),DAO 是具体实现(映射到表)。只用 DAO,切换存储就要改 DAO、影响业务;引入 Repository 后业务只依赖接口,符合开闭原则。Domain 与 DAO 模型分两套,业务语义和存储细节解耦。
面试官想听的关键词:不跨层调用、接口优先、开闭原则、依赖倒置、Domain/DAO 分离、可测试。
常见失分点:说不清 Repository 和 DAO 的区别(只答"都是存取");或答不出"为什么不能让 Handler 直接调 DAO"(答不出可测试性)。
一句话结论:登录成功后服务端生成 sess_id、把用户数据存后端,只把 sess_id 写回浏览器 Cookie;后续请求自动带 sess_id,服务端凭它查出用户。Session “认 ID 不认人”,sess_id 泄露攻击者就能冒充你。
展开:Cookie 是浏览器本地小纸条,每次请求自动带;Session 是服务端按 sess_id 查出的"访客登记本",真正数据在服务端。因为服务端只凭 sess_id 查数据,不验证"是不是你本人在用",所以 sess_id 必须走 HTTPS、Cookie 设 HttpOnly+Secure、定期刷新,缩小被盗用窗口。
面试官想听的关键词:无状态、sess_id、HttpOnly、Secure、HTTPS、状态外置、多实例用 Redis。
常见失分点:把"数据存在 Cookie 还是 Session"搞反(说"密码存 Session"没错,但说"sess_id 存服务端"就反了);漏掉 sess_id 泄露的风险与防护。
一句话结论:路由有静态(/users/signup)、参数(/users/:id)、通配符(/files/*all)三种;读数据用 GET、参数进 URL,写数据用 POST、参数进 Body。
展开:静态路由完全匹配;参数路由用 :name 捕获路径段,用 c.Param 取;通配符用 *name 捕获剩余路径。GET 幂等、可缓存、能进历史;POST/PUT/DELETE 有副作用,不缓存、刷新不重复提交。参数放查询参数还是 Body,本质是"数据要不要出现在 URL 里"——敏感或大体积进 Body。
面试官想听的关键词:静态/参数/通配符、c.Param、c.Query、幂等、Body vs 查询参数、RESTful。
常见失分点:把"参数路由"和"查询参数"混为一谈(一个在路径 /users/123,一个在 ?id=123);或不知道通配符不能单独用在 /users/*。
Updates(u) 把昵称改成空字符串为什么会"清空失败"?要更新零值该怎么写?一句话结论:Updates(u) 只更新非零字段,空串 ""、0、false 都是零值会被跳过;要更新零值必须传 map[string]any{"nickname": ""} 或 Select 显式指定列。
展开:GORM 的 Updates 用反射遍历结构体字段,零值字段直接忽略——这是为避免"误把零值当更新"设计的行为,但副作用是"想清空成零值"也做不到。用 map 时所有 key 都会生成 SET 子句,不受零值影响;Select("nickname") 则强制只更新指定列,即使为零值也更新。
面试官想听的关键词:只更新非零字段、零值跳过、map 更新、Select 显式列、Create 主键回填。
常见失分点:以为 Updates(u) 会全字段更新(实际跳零值);或知道用 map 但说不清"为什么 struct 不行"。
c.Next() 在中间件里的作用是什么?CORS 为什么必须用中间件解决?一句话结论:Middleware 是 AOP 机制,让所有请求经过一段公共逻辑;c.Next() 是"分水岭",之前的代码请求前跑、之后的代码请求后跑;CORS 是横切关注点,适合用中间件统一挡在请求最前面。
展开:中间件解决的是"横切"问题——日志、CORS、登录校验都不属于某个具体业务,却要所有请求都过。Gin 用 engine.Use 注册,c.Next() 把控制权交给下一个中间件/最终 Handler,像一个管道上的环节。CORS 是浏览器跨域的准入名单,配在中间件里对所有路由统一生效,不必在每个 Handler 复制。
面试官想听的关键词:AOP、横切关注点、c.Next 分水岭、Use 注册、preflight OPTIONS、依赖注入雏形。
常见失分点:把 c.Next() 前后代码顺序放反(日志 middleware 计时放反就量不出耗时);或答不出"为什么 CORS 不能用普通 Handler"(因为它要拦在所有业务前)。
一句话结论:错误别名是每层把下层错误转成自己的错误(如 DAO 的 ErrDuplicatedKey → Repository 的 ErrUserDuplicateEmail → Service 的 ErrDuplicateEmail),让上层只依赖本层错误;Handler 直接 import DAO 会"击穿分层"。
展开:GORM v1.25+ 用哨兵错误 gorm.ErrDuplicatedKey 识别唯一索引冲突(需开 TranslateError: true),旧版要断言 *mysql.MySQLError 看错误码 1062。每层向上转译后,Handler 只认 Service 错误;未来 DAO 从 GORM 换 Mongo,Handler/Service 一行不动。这是依赖倒置的落地。
面试官想听的关键词:依赖倒置、哨兵错误、errors.Is、TranslateError、1062、可替换。
常见失分点:不知道 gorm.ErrDuplicatedKey 需要 TranslateError: true 才生效;或答不出"换存储时上层为何不用改"。
一句话结论:在 Handler 直接写 DB 会让 HTTP、业务、数据库搅在一起,最致命的是无法单元测试;分层带来两大硬好处——可测试(Service 依赖 Repository 接口,测试塞内存假实现)和可替换(换存储/中间件业务代码不动)。
展开:分层后 Handler 只做"接参数、调 Service、回响应",Service 做加密/校验/组合,Repository 做错误转译,DAO 做落库。每层只依赖下一层接口,替换任一层不影响其它层。这是依赖倒置 + 开闭原则的直接体现。
面试官想听的关键词:可测试、可替换、开闭原则、依赖倒置、不跨层、错误转译。
常见失分点:只答"代码更清晰"这种空话;答不出最硬的两个好处(可测试 + 可替换)。
一句话结论:MD5 等快哈希相同密码结果永远相同、可被彩虹表反推;BCrypt 每次随机加盐、结果都不同、还能用 cost 调慢抗暴力破解。“每次结果不同"正是安全设计,校验用 CompareHashAndPassword 比对明文与密文。
展开:BCrypt 把随机盐值直接编码进哈希结果里(无需单独存盐),同一明文每次盐不同故哈希不同,攻击者无法预建"明文→哈希"对照表。cost 调大则加密更慢,专门对抗暴力破解。校验时 CompareHashAndPassword 从哈希里取出盐重新算并比对,故无需知道盐。
面试官想听的关键词:随机盐、彩虹表失效、cost 可调慢、不可逆、≤72 字节、CompareHashAndPassword。
常见失分点:把"每次结果不同"当成缺陷;或不知道 BCrypt 有 72 字节长度限制(Go 的 bcrypt 库对超长密码直接返回 ErrPasswordTooLong,而不是静默截断)。
binding tag 的 min=8 放在 string 和 int 上语义一样吗?required 有什么坑?一句话结论:不一样——min=8 在 string 上是"至少 8 个字符",在 int 上是"数值 ≥ 8";required 判断非零值,所以"年龄填 0 是合法业务值"的字段用 required 会被误判为"没填"。
展开:binding tag 的 min/max 语义随字段类型变化,这是从别的语言转 Go 的同学常栽的坑。required 只拒绝零值(0、""、false、nil),业务上"0 是合法值"的字段不能用 required,否则用户填 0 会被判为缺失。正确做法是用指针 *int 或自定义校验区分"没填"和"填了 0"。
面试官想听的关键词:min/max 语义随类型、零值、required 陷阱、指针区分、validator 库。
常见失分点:以为 min=8 永远表示"长度";或不知道 required 对 0 的处理。
Base BaseFields(有名字段)嵌入想平铺进同一张表,不加标签会怎样?匿名嵌入和有名嵌入冲突时怎么办?一句话结论:有名字段嵌入默认不平铺,会当普通字段/关联处理、建表抛 unsupported data type 运行时错误;必须加 gorm:"embedded"(训练营叫 inline)才平铺。多个匿名嵌入结构体有同名字段会冲突,得改用"有名 + embeddedPrefix"。
展开:匿名嵌入(直接写类型名)默认平铺,最熟悉的 gorm.Model 就是这种。有名字段嵌入要显式加 embedded 标签,多个嵌入结构体字段重名时用 embeddedPrefix:author_ 加前缀隔离列名。忘打标签不会编译报错,而是运行时报错,新手难定位。
面试官想听的关键词:embedded/inline、匿名嵌入默认平铺、unsupported data type、embeddedPrefix、运行时错误。
常见失分点:以为"嵌入就一定会平铺"(漏掉有名字段要打标签);或不知道匿名嵌入同名字段冲突的解决方式。
一句话结论:多实例部署时请求经负载均衡可能落到不同实例,内存 Session 不共享,所以用 Redis 把状态外置;这反映分布式"状态要外置"的约束——无状态 + 外置存储才能随意扩缩容。
展开:单机把 Session 放进程内存没问题;水平扩展成多实例后,内存状态不共享,同一用户第二次请求落到另一实例就"丢了登录态"。解决两条路:要么集中到外部存储(Redis,业界主流),要么让同一用户始终落同一实例(IP 哈希,但扩缩容时失效)。前者让 K8s 能随意扩缩容,是后面微服务部署的前提。
面试官想听的关键词:状态外置、负载均衡、无状态、Redis、Session 共享、K8s 扩缩容。
常见失分点:答"用 cookie store 就行"(单机开发可以,多实例不行);或说不出"状态外置"这个本质约束。
encoding/json、jsoniter、sonic 三个 JSON 库怎么选?一句话结论:默认用标准库 encoding/json(稳定、够用);老项目要无痛提速换 jsoniter(API 兼容,改个 import 就行);只有大流量、序列化密集的接口才上 sonic(JIT 编译,最快)。
展开:选择的标尺是收益 = QPS × 单次耗时差——内部小服务两者的差距根本测不出来,网关聚合、列表批量输出这类单请求序列化几十上百个对象的场景才值得换。另外三者用法几乎一致(Marshal/Unmarshal),迁移成本主要在行为差异的回归测试上。
面试官想听的关键词:标准库默认、jsoniter API 兼容、sonic JIT、收益量化、按场景不追新。
常见失分点:无脑说"全换 sonic"却讲不出收益怎么量化;或者不知道 jsoniter 的核心卖点是 API 兼容而非极致性能。
json:"..." tag 会发生什么?小写字段为什么永远序列化不出来?一句话结论:不加 tag 时导出字段按原名输出(Email → "Email",和前端约定的小写驼峰对不上);小写开头是非导出字段,反射拿不到它,永远不会被序列化。
展开:标准库基于反射工作,Go 的反射只能访问包外可见(大写导出)的字段——这是语言层面的可见性规则,不是库的 bug。调试半天"字段没出来",十有八九是字段没大写或没加 tag。json:"email" 和 binding:"required" 这两类 tag 相互独立:前者管序列化映射,后者管参数校验,可以共存。
面试官想听的关键词:导出字段、反射可见性、tag 缺省行为、json tag 与 binding tag 独立。
常见失分点:只说"大小写问题"却点不透"非导出 = 反射不可见"这个根因;或者以为加不加 tag 输出一样。
一句话结论:Protobuf 是二进制编码——体积约为 JSON 的 1/3 到 1/10、编解码快一个量级、.proto 文件还提供强类型契约;内网 QPS 高、报文大的服务间通信收益显著,代价是可读性差、要跑代码生成。
展开:两者不是替代关系而是分工关系:对外 API 用 JSON(通用、可读、调试友好、浏览器直连),内部服务间用 gRPC + Protobuf(第 10 章拆微服务时正式登场)。取舍的判断依据还是那把尺子:通信量和 QPS 上不去,Protobuf 的优势就体现不出来,反而平添工具链成本。
面试官想听的关键词:二进制、体积压缩、编解码性能、强类型契约、gRPC、内外分工。
常见失分点:说"Protobuf 全面碾压 JSON"忽略了可读性和生态成本;或说不出各自适合在哪一端。
八、本章小结
学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。
可勾选清单(面试前 5 分钟扫一遍)
- Gin 三抽象:
Engine(服务器容器)、gin.Context(本次请求档案)、RouterGroup(分组路由);c.Bind失败会自己写 400 响应,必须return。 - 路由三形态:静态 / 参数
:id(c.Param)/ 通配符*all;GET 查询、POST 提交;敏感/大体积参数放 Body。 - 校验两条路:手写正则灵活但啰嗦;
bindingtag 声明式,ShouldBindJSON失败不写响应要自己处理;min/max语义随类型、required坑零值。 - Middleware:AOP 机制,
Use注册,c.Next()前跑请求前、后跑请求后;CORS / 日志 / 登录校验都用它。 - CORS:浏览器跨域门禁,preflight 是 OPTIONS,后端用
AllowOriginFunc贴准入名单,开发期别乱写*。 - GORM 三步:InitMySQL → AutoMigrate → CRUD;模型即表结构;
Updates只更新非零字段(零值用 map);embedded让有名字段平铺;AutoMigrate 只加不改、生产手动管 DDL。 - 分层五层:Handler(HTTP)→ Service(业务)→ Repository(存储抽象)→ DAO(数据库)→ Domain(业务对象);不跨层、错误要转译、依赖走接口、模型分两套。
- 依赖注入雏形:自下而上构造(
NewA(NewB(...)))、自上而下调用;测试塞内存假 Repository。 - BCrypt:随机盐、cost 可调慢、不可逆、≤72 字节;放 Service 层;明文密码不得进日志;登录错误统一返回"用户名或密码不对"。
- 错误别名:每层把下层错误转成自己的错误,Handler 只认 Service 错误;
gorm.ErrDuplicatedKey需TranslateError: true。 - 登录态:Cookie 带
sess_id+ Session 后端存user_id;LoginRequired中间件取user_id,没有就 401;HttpOnly+Secure+HTTPS 防泄露。 - Session 存储:开发用 cookie store,多实例用 Redis(状态外置)。
下一章我们将进入 JWT 与 Kubernetes 部署,把这套用户服务搬到分布式环境上运行——你会看到 Session 在多实例下的局限,以及如何用 JWT 把登录态"自带在客户端",再用 K8s 把它跑成可随意扩缩容的服务。