第02章 · 用户功能与 Gin / GORM 入门(正式版)

2022-12-09T14:11:02+08:00 | 68分钟阅读 | 更新于 2022-12-09T14:11:02+08:00

@

写在最前面:这一版怎么读

这是《内容社区》课程第 02 章。它假设你刚读完第 01 章——你已经会用 Go 写变量、结构体、函数、切片、map、接口,能跑通那章最后做出来的 webook-syntax 命令行小工具,但你还没写过任何一个 Web 项目。

第 01 章的 webook-syntax 是个"只能自己跟自己玩"的命令行程序:你敲命令、它打印结果,数据只在内存里活一瞬。这一章我们要做的事,就是把它变成真正的 Web 服务——别人(比如浏览器、另一个程序)能通过 HTTP 请求来调用你的代码,用户能注册、能登录、登录之后你还能认出"还是这个人"。

这一版延续第 01 章的三件事:

  1. 每一步都能跑。所有代码标了文件路径、给了运行命令、写了预期输出。
  2. 术语不跳过。凡是你没见过的词(HTTP、路由、ORM、CORS、Cookie、Session、Middleware、BCrypt……),当场就解释,并配「补课胶囊」。
  3. 结尾有面试问答。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 拒绝——这条失败路径就是本章所有受保护接口的统一守门逻辑。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 go mod init 起一个 Gin 项目,写出能 go run 起来的最小 Web 服务,访问 /hello 返回 JSON。
  2. 用 Gin 注册静态 / 参数 / 通配符三种路由,说清 GET 和 POST 分别在什么场景用、参数放 URL 还是放 Body。
  3. 用 c.Bind / ShouldBindJSON 接收并校验注册请求,手写正则或写 binding tag 校验邮箱与密码强度。
  4. 用 GORM 定义 User 模型、AutoMigrate 建表,写出 Create / First / Updates 的基础 CRUD。
  5. 写出 Handler → Service → Repository → DAO 五层,跑通"注册(BCrypt 加密)→ 登录(Session 存 user_id)“闭环,并用 curl 验证。

理解(能讲清原理和取舍,不要求背代码)

  1. 为什么需要 Web 框架(Gin 的 Engine / Context / RouterGroup 各自解决什么),以及 c.Bind 失败会自动写响应的坑。
  2. Middleware(中间件)作为 AOP 的意义,以及它如何用 c.Next() 把"公共逻辑"插进请求流水线、解决 CORS 这类横切问题。
  3. ORM 是什么、为什么用 GORM(和手写 SQL 的差别),以及 Updates 只更新非零字段的坑。
  4. 为什么必须分层、每一层职责边界在哪、为什么有了 DAO 还要有 Repository、Domain 和 DAO 的模型为什么要分两套。
  5. HTTP 无状态为什么需要 Cookie / Session;BCrypt 为什么比 MD5 适合存密码(随机盐、cost、不可逆)。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. CORS 的 preflight(预检)请求是 OPTIONS,以及 AllowOriginFunc 配错 * 是安全漏洞。
  2. 错误别名机制如何让 Handler 不依赖 DAO 包(依赖倒置的落地)。
  3. GORM 的 embedded(训练营叫 inline)标签,解决有名字段嵌入不平铺的问题。
  4. 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 五层里跑通,最后由第七节面试问答收口。全程无分支,哪一节卡住就回退那一节。

Gin / GORM 入门Web 接入持久化与分层登录与安全HTTP / 路由 / ContextBind 校验 + ValidatorMiddleware / AOP / CORSORM 与 GORM 模型H-S-R-D 五层架构错误别名传导Cookie / SessionBCrypt 加密登录态 Middleware
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。本章新名词很多,遇到蓝色术语不用慌,它们都配了「补课胶囊」。想快速过就只读每节的"抛问题"和"小收口";要真学会就把每个代码块都敲一遍、每个 curl 都打一遍。

三、正文

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 服务跑通了。

逐行讲解

行代码为什么这么写
10engine := gin.Default()Engine 是路由表 + 中间件链 + 配置的容器;Default() 自带日志和崩溃恢复两个中间件
13engine.GET("/hello", handler)登记"GET /hello 交给这个匿名函数"。GET 是 HTTP 方法
14func(c *gin.Context)gin.Context 是本次请求的"服务档案",收请求、发响应都靠它
15c.JSON(http.StatusOK, ...)把第二个参数序列化成 JSON、写回响应体,状态码 200
19engine.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取路径参数从路径段取值,不是从 ? 后取
复习提示:⚠️ 新手必踩的坑:参数路由和静态路由冲突。Gin 里 /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
提交数据POSTBody(JSON)/users/signup + JSON body
更新数据PUT/PATCH路径 + Body/users/:id + JSON body
删除数据DELETE路径/users/:id
复习提示:工程视角:这套表不是死规矩,而是"语义清晰"的约定。GET 幂等、可被缓存、能进浏览器历史;POST/PUT/DELETE 有副作用,浏览器不会缓存、刷新不会重复提交。参数放查询参数还是 Body,本质是"数据要不要出现在 URL 里"——敏感或体积大的放 Body,可分享/可书签的放查询参数。后面讲 RESTful 时会把这套约定彻底体系化。

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
	}
	// ... 业务逻辑
}
复习提示:工程视角:生产项目一般不手写 switch 翻译,而是引入 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 干干净净只管业务。

复习提示:生活类比:Middleware 就像机场的"安检流水线"。每位旅客(请求)都要依次经过值机、安检、边检,每一道只干自己那一小块事,干完交给下一道;你想加一道"测温"也只需插一个环节,不用改造整个机场。这就是 AOP(面向切面)——把"横切关注点"抽出来统一处理。
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 默认只让前端读少数几个"安全"响应头)。

复习提示:工程视角:跨域是"浏览器的限制",不是"服务器的限制"——用 curl 直接打后端端口从不跨域。所以 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. 没有业务逻辑层,密码加密放哪里?
}
复习提示:⚠️ 新手必踩的坑:在 Handler 里直接操作数据库。三大问题里最致命的是没法做单元测试——业务逻辑和 HTTP、数据库全搅在一起,你想测"注册逻辑"就不得不起一个真实 MySQL。分层之后,Service 只依赖 Repository 接口,测试时塞一个内存假实现就能跑,无需数据库。

3.6.2 五层架构

层职责是否依赖数据库
HandlerHTTP 请求/响应处理,路由否
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 后,业务层只依赖接口,切换实现不影响业务。
复习提示:工程视角:这套分层的出处是 DDD(Domain-Driven Design,领域驱动设计)。Repository 对应"领域对象的存储",Domain 就是领域对象——业务在系统中的直接反映。DDD 是门"看起来简单、实际很难"的学问,现阶段不用深究,记住这三个词的出处,以后读别的工程文章不会懵即可。

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 字符串,业务层可能是结构体;多表关联业务层可能是聚合对象。分开后两边独立演化。

复习提示:工程视角:Domain 和 DAO 分离,本质是"业务语义"和"存储细节"解耦。用户改了昵称,业务层只动 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": "注册成功"})
}
复习提示:工程视角:这个示例是整章"集大成"——Handler 只做"接参数、调 Service、回响应"三件事;Service 做"加密、校验、组合";Repository 做"错误别名转换";DAO 做"落库 + 唯一索引冲突识别"。每一层只依赖下一层的接口,替换任一层都不动其它层。读懂这一个 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自带随机盐值,相同密码每次哈希结果不同
复习提示:工程视角:这张表背后是"和攻击者的军备竞赛"。MD5 快,但快对攻击者也是好事——一秒能试十亿个密码;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"]
复习提示:工程视角:BCrypt 算法规范只处理密码的前 72 字节。注意 Go 的 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)不依赖低层细节(具体数据库驱动),都依赖中间抽象(Repository 接口)。配合"错误别名"传导机制,换存储、换中间件时业务代码一行不动。面试被问"为什么要分层",这道题(可测试 + 可替换)就是最硬的回答。

小收口

错误别名是切断跨层依赖的手段:每层把下层错误转成自己的错误,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 安全配置要守护的东西。

字段含义建议
DomainCookie 可用域名最小化原则
PathCookie 可用路径最小化原则
Max-Age / Expires过期时间只保留必要时间
HttpOnlytrue 则 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 泄露,攻击者也拿不到原始数据。

复习提示:⚠️ 新手必踩的坑:Session 认 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 数据不一样。下一章细讲。

复习提示:工程视角:这暴露了分布式经典约束——“状态要外置”。单机把 Session 放进程内存没问题;一旦水平扩展成多实例,内存状态不共享。解决只有两条:要么集中到外部存储(Redis),要么让同一用户始终落同一实例(IP 哈希,扩缩容时失效)。前者(无状态 + 外置存储)是业界主流,也是后面 K8s 能随意扩缩容的前提。

小收口

登录态 = 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 章压测的结论一脉相承:先有数据,再谈优化。

复习提示:工程提示:接口变慢时,JSON 序列化很少是第一嫌疑人——先查慢查询、网络往返和锁竞争,pprof 确认序列化确实占了 CPU 大头再换库。把 sonic 当"安慰剂"上生产,提速没等来,兼容性 bug 先来了。

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 分层架构(主线)

  1. 不要跨层调用:Handler 只调 Service,Service 只调 Repository,Repository 只调 DAO。
  2. 错误用别名机制向上传导:每层把下层错误转成自己的错误,避免上层依赖下层包。
  3. 接口优先:Repository 用接口定义,方便切换实现和单元测试(塞内存假实现)。
  4. Domain 独立于 DAO 模型:业务对象和数据库表分开演化。

5.2 密码安全(红线)

  1. 密码必须加密存储,禁止明文;用 BCrypt,别自己造算法。
  2. 密码不能打日志,敏感信息不能进日志——这是安全事故红线。
  3. 登录错误统一返回"用户名或密码不对",避免攻击者枚举哪些邮箱注册过。
  4. 密码长度卡在 72 字节内,超过 72 字节 Go 的 bcrypt 库会直接返回 ErrPasswordTooLong。

5.3 HTTP 接口

  1. GET 查询、POST 提交,这是新手原则;参数位置看场景(敏感/大体积进 Body)。
  2. Bind 自动处理 Content-Type,比手动 json.Unmarshal 简单,但失败会写响应,必须 return。
  3. 校验两边都做:前端为体验,后端为安全。
  4. 错误返回用 HTTP 状态码 + 业务码,别永远返回 200。

5.4 Gin 与存储组织

  1. 按业务模块组织 Handler,每个 Handler 有 RegisterRoutes 方法。
  2. 分组路由避免路径前缀写错;internal/ 放业务代码、pkg/ 放可复用代码。
  3. middleware 解决横切关注点:CORS、日志、登录校验、限流。
  4. AutoMigrate 只给本地/测试库用;生产 DDL 手动管理走 review。
  5. Session 多实例部署用 Redis:状态外置,才能随意扩缩容。

六、自测题与动手练习

学完不等于学会。下面先自己答,再动手做。

自测题(答案在第七节面试问答里能找到)

  1. c.Bind 绑定失败时会发生什么?为什么后面必须 return 而不能再 c.JSON 写响应?
  2. Updates(u) 把昵称改成空字符串为什么会"清空失败"?要更新零值该怎么写?
  3. 分层里 Handler / Service / Repository / DAO / Domain 各自职责是什么?为什么有了 DAO 还要 Repository?
  4. Cookie 和 Session 怎么配合记住登录态?为什么说 Session"认 ID 不认人"、带来什么风险?
  5. BCrypt 相比 MD5 好在哪?“相同密码每次哈希结果不同"为什么反而是优点?
  6. binding tag 的 min=8 放在 string 和 int 上语义一样吗?required 为什么可能误判"年龄填 0”?
  7. 注册要求"密码 8~72 位且同时含大小写字母和数字",binding:"required,min=8,max=72" 够吗?不够怎么办?
  8. DAO 模型里 Base BaseFields 这种有名字段想平铺进同一张表,不加标签会怎样?

动手练习(建议真做一遍)

  1. 跑通注册-登录闭环:本地起 MySQL,注册一个账号,用邮箱密码登录,从响应或日志确认 Session 写进了 user_id;不带 cookie 访问 /profile 确认返回 401。
  2. 故意触发唯一索引冲突:用同一个邮箱注册两次,观察从 DAO 的 ErrDuplicatedKey 如何被转成 Handler 看到的"该邮箱已注册"。
  3. 加一个耗时统计中间件:写一个 Logger middleware 注册到 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 / Service / Repository / DAO / Domain 各自职责是什么?为什么有了 DAO 还要有 Repository?
候选人

一句话结论: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"(答不出可测试性)。

面试官
【基础】 Cookie 和 Session 怎么配合记住登录态?为什么说 Session"认 ID 不认人"、带来什么风险?
候选人

一句话结论:登录成功后服务端生成 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 泄露的风险与防护。

面试官
【基础】 路由有哪三种形态?GET 和 POST 分别在什么场景用、参数放哪?
候选人

一句话结论:路由有静态(/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 不行"。

面试官
【进阶】 什么是 Middleware(中间件)?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"(因为它要拦在所有业务前)。

面试官
【进阶】 错误别名机制是什么?为什么 Handler 不应该直接 import DAO 包的错误类型?
候选人

一句话结论:错误别名是每层把下层错误转成自己的错误(如 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 里直接写数据库操作?分层带来哪两个最硬的好处?
候选人

一句话结论:在 Handler 直接写 DB 会让 HTTP、业务、数据库搅在一起,最致命的是无法单元测试;分层带来两大硬好处——可测试(Service 依赖 Repository 接口,测试塞内存假实现)和可替换(换存储/中间件业务代码不动)。

展开:分层后 Handler 只做"接参数、调 Service、回响应",Service 做加密/校验/组合,Repository 做错误转译,DAO 做落库。每层只依赖下一层接口,替换任一层不影响其它层。这是依赖倒置 + 开闭原则的直接体现。

面试官想听的关键词:可测试、可替换、开闭原则、依赖倒置、不跨层、错误转译。

常见失分点:只答"代码更清晰"这种空话;答不出最硬的两个好处(可测试 + 可替换)。

面试官
【追问】 BCrypt 相比 MD5 好在哪?“相同密码每次哈希结果不同"为什么是优点而不是 bug?
候选人

一句话结论: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 的处理。

面试官
【追问】 GORM 里 Base BaseFields(有名字段)嵌入想平铺进同一张表,不加标签会怎样?匿名嵌入和有名嵌入冲突时怎么办?
候选人

一句话结论:有名字段嵌入默认不平铺,会当普通字段/关联处理、建表抛 unsupported data type 运行时错误;必须加 gorm:"embedded"(训练营叫 inline)才平铺。多个匿名嵌入结构体有同名字段会冲突,得改用"有名 + embeddedPrefix"。

展开:匿名嵌入(直接写类型名)默认平铺,最熟悉的 gorm.Model 就是这种。有名字段嵌入要显式加 embedded 标签,多个嵌入结构体字段重名时用 embeddedPrefix:author_ 加前缀隔离列名。忘打标签不会编译报错,而是运行时报错,新手难定位。

面试官想听的关键词:embedded/inline、匿名嵌入默认平铺、unsupported data type、embeddedPrefix、运行时错误。

常见失分点:以为"嵌入就一定会平铺"(漏掉有名字段要打标签);或不知道匿名嵌入同名字段冲突的解决方式。

面试官
【追问】 多实例部署时为什么 Session 必须用 Redis?这反映了分布式什么经典约束?
候选人

一句话结论:多实例部署时请求经负载均衡可能落到不同实例,内存 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?
候选人

一句话结论: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。
  • 校验两条路:手写正则灵活但啰嗦;binding tag 声明式,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 把它跑成可随意扩缩容的服务。

复习提示:给下一章留个伏笔:本章登录态用服务端 Session,多实例必须靠 Redis 共享。下一章会引入 JWT——把"我是谁"直接编码进 token 交给客户端,服务端不存 Session,天然契合无状态扩缩容。带着"Session 的 sess_id 为什么会丢"这个问题进入第 03 章,会学得更扎实。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!