学习目标
- 掌握 Gin 框架的核心抽象(
Engine、Context、RouterGroup),能写出最简 Web 服务并理解请求在 Gin 内的处理流程。 - 学会路由设计的三种形态(静态 / 参数 / 通配符),并理解 GET vs POST、查询参数 vs Body 的工程取舍。
- 理解 middleware(中间件)作为 AOP 解决方案的意义,能用它解决跨域(CORS)、登录校验等横切问题。
- 掌握 GORM 的基础用法(模型定义、CRUD、AutoMigrate),理解 ORM 的概念与"模型即表结构"的映射关系。
- 建立"Handler → Service → Repository → DAO → Domain"的分层架构意识,理解为什么要分层、每一层的职责边界。
- 实现完整的注册-登录闭环:密码 BCrypt 加密、唯一索引冲突错误传导、基于 Gin Session 的登录态校验。
前置知识(先看这部分,避免中途卡壳):
- 已学完第01章 Go 基础语法(变量、函数、结构体、接口、错误处理)。
- 装好 Go 工具链,能
go run一个程序。 - 知道什么是 HTTP 请求(URL、方法、请求体),不用会 Gin。
- 本地能跑一个 MySQL(后面用 Docker Compose 起)。
本章你会动手做的事:
- 从零起一个 Gin 服务,访问
/hello能看到返回的 JSON。 - 自己写正则校验注册邮箱和密码强度,亲眼看到不合法时返回的错误。
- 把注册-登录链路跑通:注册后拿邮箱密码登录,从 Session 里读出
user_id。
一、Gin 入门
生活类比:Web 框架就像餐厅的"前厅标准流程"。没有框架时,每个服务员(handler)自己决定怎么迎客、怎么记录、怎么送客,乱七八糟;Gin 这套框架把"迎客(路由匹配)→ 登记(绑定请求)→ 后厨处理(业务)→ 送客(返回响应)“标准化了,你只管写"后厨逻辑”。
Engine是餐厅经理,RouterGroup是按楼层/菜系分的接待台,gin.Context是这一次招待顾客用的"服务档案"。
一个请求从进来到返回,在 Gin 里走的是下面这条流水线:
flowchart LR
Req[HTTP 请求] --> MW[Middleware 链
日志/CORS/登录校验]
MW --> R[RouterGroup 路由匹配]
R --> C[gin.Context
绑定请求+组织响应]
C --> H[你的 Handler 业务逻辑]
H --> Res[JSON 响应返回]1.1 什么是 Web 框架,为什么需要 Gin
如果用 Go 标准库 net/http 写 Web 服务,你很快会发现:
- 路由匹配能力弱(不支持
/users/:id这种参数路由) - 没有统一的请求绑定(要把 JSON 反序列化成 struct 全靠手写
json.Unmarshal) - 没有中间件机制(每个跨切面关注点都得在每个 handler 里复制粘贴)
- 错误处理、日志、渲染都需要自己造轮子
Web 框架就是把这些通用能力封装好的工具箱。Gin 是 Go 生态里最流行的轻量级 Web 框架,特点是基于 httprouter 的快速路由、简洁的 API、强大的中间件机制。
1.2 最简 Gin 应用
# 安装依赖
go get github.com/gin-gonic/gin@latest
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// Engine 是 Gin 中的核心抽象,代表一个逻辑上的 Web 服务器
// 一个 Go 进程可以创建多个 Engine,监听不同端口
engine := gin.Default()
// 注册路由:GET 方法 + 路径 + 处理函数
engine.GET("/hello", func(c *gin.Context) {
// gin.Context 是 Gin 的核心类型,承担"处理请求 + 返回响应"的职责
// c.Request 是 *http.Request,c.Writer 是 http.ResponseWriter
c.JSON(http.StatusOK, gin.H{"msg": "hello, world"})
})
// 监听端口并启动服务,默认 :8080
// 启动失败最常见原因是端口被占用
if err := engine.Run(":8080"); err != nil {
panic(err)
}
}
Engine 内部组合了 RouterGroup,真正实现路由功能的是 RouterGroup。这种组合设计让 Gin 支持"分组路由",后面会看到。
工程视角:
Engine不是"一个服务器进程"的本体,它更像"路由表 + 中间件链 + 配置"的容器;真正监听端口的是http.Server。一个进程可以 new 多个Engine(比如一个对外、一个对内暴露 metrics),各自有不同的路由和中间件,这就是组合设计带来的灵活度。
1.3 路由的三种形态
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
engine := gin.Default()
// 1. 静态路由:完全匹配
engine.GET("/users/signup", signup)
// 2. 参数路由:用 :name 捕获路径段
// 请求 /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. 通配符路由:用 *name 捕获剩余路径(不能单独出现 /users/*)
// 请求 /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") // 不存在返回默认值
c.JSON(http.StatusOK, gin.H{"q": q, "page": page})
})
engine.Run(":8080")
}
func signup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "signup"})
}
1.4 路由设计原则(新手必读)
| 场景 | HTTP 方法 | 参数位置 | 示例 |
|---|---|---|---|
| 查询数据 | GET | 查询参数 / 路径 | /users/:id 或 /users?age=20 |
| 提交数据 | POST | Body(JSON) | /users/signup + JSON body |
| 更新数据 | PUT/PATCH | 路径 + Body | /users/:id + JSON body |
| 删除数据 | DELETE | 路径 | /users/:id |
工程视角:这套表不是死规矩,而是"语义清晰"的约定。GET 幂等、可被缓存、能进浏览器历史;POST/PUT/DELETE 有副作用,浏览器不会缓存、刷新不会重复提交。参数放查询参数还是 Body,本质是"数据要不要出现在 URL 里"——敏感或体积大的放 Body,可分享/可书签的放查询参数。后面讲 RESTful 时会把这套约定彻底体系化。
新手原则:用户查询数据用 GET,参数放查询参数里;用户提交数据用 POST,参数全部放 Body 里。课程后面会讲 RESTful 风格。
二、小微书起步:用户接口与项目结构
2.1 接口设计
从用户模块起步,先定义接口:
POST /users/signup注册POST /users/login登录POST /users/profile编辑用户信息(需登录)GET /users/profile查看用户信息(需登录)
工程习惯:从前端往后端设计。先确定前端需要什么字段,再设计数据库。这能避免数据库设计完后前端频繁要加字段的问题。
工程视角:这条习惯的本质是"接口契约先行"。前端要的是
{email, password, nickname},你据此定义请求结构体和表字段,而不是先拍脑袋建一张表、再想前端怎么凑。需求变更时,从接口层往下改,比从数据库往上改成本低得多——因为改表结构往往涉及数据迁移,而改接口只是改结构体。
2.2 用 Handler 组织路由
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// UserHandler 集中管理所有用户相关路由
// 这种"按业务模块组织 Handler"的方式在中小项目里很实用
type UserHandler struct {
// 后续会注入 svc *userService.UserService
}
// NewUserHandler 构造函数(Go 没有构造函数,约定用 NewXXX)
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
// RegisterRoutes 注册路由
// 接收 *gin.Engine 或 *gin.RouterGroup,便于分组路由
func (h *UserHandler) RegisterRoutes(server *gin.Engine) {
// 分组路由:所有路由都有 /users 前缀
// 避免每个路由都手写 /users/xxx,防止手抖写错
g := server.Group("/users")
g.POST("/signup", h.SignUp)
g.POST("/login", h.Login)
g.POST("/profile", h.Profile)
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"})
}
集中注册 vs 分散注册:
- 分散注册(每个 Handler 自己
RegisterRoutes):模块边界清晰,但找路由要跨文件。 - 集中注册(在 main 里把所有路由写一遍):一打开能看到全部路由,但路由多时找起来费劲。
中小项目推荐分散注册,main 函数只调用各个 Handler 的 RegisterRoutes。
工程视角:分散注册的本质是"每个模块管自己的路由"。新增一个
OrderHandler,只要它自己实现RegisterRoutes并在main里加一行orderHandler.RegisterRoutes(...),其它模块完全不动——这又是对"开闭原则"的落地。集中注册则适合路由极少的微型服务,图一眼看全。选哪种看项目规模,别为了"看起来整齐"牺牲可维护性。
2.3 推荐目录结构
webook/
├── main.go # 程序入口
├── internal/ # 项目私有代码,Go 工具链禁止别的项目 import
│ └── web/ # Web 层(Handler)
│ └── user.go
├── pkg/ # 可被其它项目复用的代码
│ └── ...
├── go.mod
└── go.sum
internal 是 Go 编译器强制的访问控制:a/b/internal/c 只能被 a/b/... 下的代码 import。把业务代码放在 internal 下,防止被外部项目意外依赖。
工程视角:
internal是 Go 给你的"私有化护栏"——你不用靠约定提醒同事"这个包别外引",编译器直接在编译期拦死。业务核心(用户、订单、互动逻辑)放internal,只有真正可复用的通用工具(日志、加密)才放pkg。这样你的代码库对外暴露的面最小,重构时牵扯最少。
三、注册接口:请求绑定与校验
3.1 接收 JSON:Bind 方法
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绑定,还能顺手按bindingtag 做基础校验。但它"失败即写响应"的特性你得记牢。另一个常见搭档是ShouldBind/ShouldBindJSON,区别在于失败时不自动写响应,适合你想自己完全控制错误返回格式的场景。
3.2 请求校验:正则表达式
校验规则一般由产品经理确定。注册场景的典型校验:
- 邮箱格式合法
- 密码与确认密码一致
- 密码强度:≥ 8 位,包含数字、特殊字符
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)。
工程视角:这不是"重复劳动",而是职责不同。前端校验失败立刻提示、不浪费一次网络往返;后端校验是"最后一道门",防的是有人绕过页面直接发恶意请求(SQL 注入、超长字段打爆数据库)。任何"只在前端校验"的接口,等于把家门钥匙挂在门外。
四、跨域问题与 Middleware
4.1 什么是跨域
浏览器有同源策略:从前端 localhost:3000 发请求到后端 localhost:8080,域名或端口任一不同就是跨域。浏览器会先发一个 OPTIONS 预检请求(preflight),询问后端是否接受。
工程视角:跨域是"浏览器的限制",不是"服务器的限制"——用 curl 直接打后端端口从不跨域。所以 CORS 配置是写给浏览器看的"准入名单":你允许哪些源、哪些头、哪些方法。配错
AllowOriginFunc(比如开发期图省事写*)上线后就是安全漏洞。preflight 机制就是让浏览器在"真发请求前"先探一波,确认后端放行才发正式请求。
preflight 请求特征:
- HTTP 方法:OPTIONS
- 没有请求体
- 带有 Access-Control-Request-* 系列头
4.2 Middleware 概念
Middleware(中间件) 是一种 AOP(面向切面编程)机制:让所有请求经过一段公共逻辑。Java 里叫 interceptor / filter,Go 这里叫 middleware。
适合用 middleware 解决的问题:
- 跨域 CORS
- 日志、metrics、tracing(可观测性)
- 身份认证与鉴权
- 熔断限流降级
工程视角:Middleware 解决的所有问题有个共同点——“横切”。它们不属于某个具体业务逻辑(不是"注册"或"登录"专属),而是"所有请求都要过一遍"。如果把日志、CORS 写进每个 Handler,代码会又臭又长还容易漏。Middleware 把它们抽成"管道上的一个环节",Handler 干干净净只管业务。
生活类比:Middleware 就像机场的"安检流水线"。每位旅客(请求)都要依次经过值机、安检、边检,每一道只干自己那一小块事,干完交给下一道;你想加一道"测温"也只需插一个环节,不用改造整个机场。这就是 AOP(面向切面)——把"横切关注点"抽出来统一处理。
flowchart LR
Req[请求] --> M1[Logger 中间件]
M1 --> M2[CORS 中间件]
M2 --> M3[登录校验中间件]
M3 --> H[最终 Handler]
H --> Res[响应]4.3 用 Gin 中间件解决 CORS
go get github.com/gin-gonic/contrib
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{
// AllowOrigins: []string{"http://localhost:3000"},
// 也可以用函数动态判断来源
AllowOriginFunc: func(origin string) bool {
return origin == "http://localhost:3000"
},
// 允许携带认证信息(cookie)
AllowCredentials: true,
// 允许的请求头
AllowHeaders: []string{"Content-Type", "Authorization"},
// 允许前端访问的响应头(JWT 场景需要暴露 x-jwt-token)
ExposeHeaders: []string{"x-jwt-token"},
// 允许的方法
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
// preflight 响应的缓存时间
MaxAge: 12 * time.Hour,
}))
return engine
}
AllowHeaders 必须包含 Authorization,否则前端发请求时带不上 JWT。ExposeHeaders 必须包含 x-jwt-token,否则前端读不到这个响应头(CORS 默认只让前端读少数几个"安全"响应头)。
4.4 自定义 Middleware
package web
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
// HandlerFunc 是 func(*Context) 的衍生类型
// 所以任何接收 *gin.Context 的函数都可以作为 middleware
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// c.Next() 把控制权交给下一个 middleware / 最终的 handler
// 在 c.Next() 之前的代码在请求处理前执行
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)放在后——放反了就量不出耗时。
五、GORM 入门
5.1 什么是 ORM
ORM(Object-Relational Mapping,对象关系映射):把数据库表映射成结构体,把 SQL 操作映射成方法调用。让你用 Go 代码操作数据库,而不是手写 SQL。
| 数据库概念 | Go 概念 |
|---|---|
| 表 | 结构体 |
| 行 | 结构体实例 |
| 列 | 结构体字段 |
| 主键 | primaryKey 标签 |
| 索引 | index / uniqueIndex 标签 |
生活类比:ORM 就像"翻译官"。你用 Go 结构体写
user.Create(),它翻译成INSERT INTO user ...;你写user.FindByEmail(),它翻译成SELECT ... WHERE email=?。没有它,你得手写一堆 SQL 字符串拼接,既容易拼错又容易被 SQL 注入。代价是"翻译"有一层开销,且复杂查询还是得会写原生 SQL。
GORM 是 Go 生态最流行的 ORM 框架,特点:支持多种数据库、CRUD 简单、支持事务、支持钩子、支持关联。
5.2 安装与初始化
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
package db
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitMySQL() (*gorm.DB, error) {
// DSN 格式:用户名:密码@tcp(host:port)/dbname?参数
dsn := "root:root@tcp(localhost:3306)/webook?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("connect mysql: %w", err)
}
return db, nil
}
// 简单的连接池配置(生产环境必须配置)
func mustClose(db *gorm.DB) {
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(100) // 最大连接数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间
}
5.3 Docker Compose 启动 MySQL
# 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"
docker compose up -d # 启动
docker compose down # 停止并删除容器
看到 ready for connections 即启动成功。
5.4 模型定义
package dao
import (
"time"
"gorm.io/gorm"
)
// gorm.Model 内置了 4 个公共字段:
// ID uint `gorm:"primarykey"`
// CreatedAt time.Time
// UpdatedAt time.Time
// DeletedAt gorm.DeletedAt `gorm:"index"` —— 软删除
// 实践中每家公司可能有不同规范,可以不用它,自定义 BaseModel
type User struct {
ID int64 `gorm:"primaryKey,autoIncrement"` // 自增主键
Email string `gorm:"uniqueIndex"` // 唯一索引
Password string // 默认就映射为 password 列
Nickname string
Birthday string
AboutMe string
Ctime int64 // 创建时间(毫秒),用 int 而非 time.Time 避免时区问题
Utime int64 // 更新时间
}
工程要点:
- 用自增主键:数据库帮我们生成 ID,避免业务 ID 冲突。
Email加唯一索引:保证邮箱不重复;注册时数据库会替我们做这个校验。- 时间用
int64存毫秒戳:避免时区、序列化格式问题,跨语言友好。 - 软删除(
DeletedAt):删数据时不真删,标记一下。便于审计和恢复,但有索引膨胀问题。
工程视角:用
int64毫秒戳存ctime/utime而不是time.Time,是踩过时区坑后的共识——time.Time带时区,序列化/跨语言传时容易被转错;毫秒戳是无歧义的纯数字,前端自己格式化。软删除同理:加了DeletedAt后,GORM 的所有查询自动带WHERE deleted_at IS NULL,你查不到已删数据,但要真删得用Unscoped()——这是"防误删"的双刃剑,记得告诉 DBA。
5.5 基础 CRUD
package dao
import "gorm.io/gorm"
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
// Insert 创建用户
func (d *UserDAO) Insert(u User) error {
// Create 接收指针,会把生成的 ID 回填到 u.ID
err := d.db.Create(&u).Error
if err != nil {
// 唯一索引冲突会返回特定错误,需要转换为业务错误
return err
}
return nil
}
> **工程视角**:`Create(&u)` 传指针,GORM 在插入后会把数据库生成的自增 `id` 写回 `u.ID`——这是 GORM 的"主键回填"。后面写"登录成功后返回用户信息"时,你不用再查一次库拿 ID,直接用回填的 `u.ID` 即可。
// FindByEmail 按邮箱查找
func (d *UserDAO) FindByEmail(email string) (User, error) {
var u User
// First 取第一条,没找到返回 ErrRecordNotFound
err := d.db.Where("email = ?", email).First(&u).Error
return u, err
}
// FindByID 按主键查找
func (d *UserDAO) FindByID(id int64) (User, error) {
var u User
err := d.db.First(&u, id).Error
return u, err
}
// UpdateByID 更新非零字段
// 注意 Updates 只更新非零字段,零值字段会被忽略
// 如果要更新零值,需要用 map 或 Select
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显式指定列。
六、分层架构:Handler / Service / Repository / DAO / Domain
6.1 为什么不能在 Handler 里直接操作数据库
⚠️ 新手必踩的坑:在 Handler 里直接操作数据库。下面"反面教材"三大问题里,最致命的是没法做单元测试——业务逻辑和 HTTP、数据库全搅在一起,你想测"注册逻辑"就不得不起一个真实 MySQL。分层之后,Service 只依赖 Repository 接口,测试时塞一个内存假实现就能跑,无需数据库。
// 反面教材:Handler 直接操作数据库
func (h *UserHandler) SignUp(c *gin.Context) {
var req SignUpReq
c.Bind(&req)
// 直接 db.Create(...) —— 问题:
// 1. HTTP 逻辑和数据库逻辑耦合,无法复用
// 2. 无法单元测试(要起真实数据库)
// 3. 没有业务逻辑层,密码加密放哪里?
}
6.2 五层架构
| 层 | 职责 | 是否依赖数据库 |
|---|---|---|
| Handler | HTTP 请求/响应处理,路由 | 否 |
| Service | 业务逻辑(加密、校验、组合多个 Repository) | 否 |
| Repository | 数据存储抽象(不绑定具体数据库) | 否 |
| DAO | 数据库操作(GORM 调用) | 是 |
| Domain | 业务对象(领域模型) | 否 |
一张图看清楚请求如何"自顶向下流动、依赖自底向上注入":
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、MongoDB。
- DAO 是具体实现:直接映射到数据库表。
- 如果只用 DAO,未来切换存储就要改 DAO,影响业务。引入 Repository 后,业务层只依赖 Repository 接口,切换实现不影响业务。
6.3 Domain vs DAO 模型
// domain/user.go —— 业务概念
package domain
type User struct {
ID int64
Email string
Password string
Nickname string
}
// 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 完全无感。这是"开闭原则"在存储层的体现。
6.4 完整代码示例
// internal/domain/user.go
package domain
type User struct {
ID int64
Email string
Password string
Nickname string
Birthday string
AboutMe string
}
// 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
}
// 用接口 + 实现的方式,方便后续切换实现(如 ES、Mongo)
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 层的业务错误
// 这样上层不需要依赖 dao 包,避免跨层依赖
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
}
// FindByID、Update 类似...
// 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+)识别唯一索引冲突
// 旧版本需要用 strings.Contains 判断错误消息
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
}
// 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 {
// BCrypt 加密:每次加密结果不同(随机盐值),无法解密,只能比对
// cost 控制加密性能,默认 10
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
}
// BCrypt 比对:把明文和密文比较
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
return domain.User{}, ErrInvalidCredentials
}
return u, nil
}
// internal/web/user.go
package web
import (
"errors"
"net/http"
"regexp"
"github.com/dlclark/regexp2"
"github.com/gin-gonic/gin"
"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(req.Email, 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": "注册成功"})
}
工程视角:这个 6.4 的完整示例是整章的"集大成"——Handler 只做"接参数、调 Service、回响应"三件事;Service 做"加密、校验、组合";Repository 做"错误别名转换";DAO 做"落库 + 唯一索引冲突识别"。每一层只依赖下一层的接口,替换任一层都不动其它层。读懂这一个
Signup全流程,分层架构就真正入门了。
6.5 main 函数组装
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()
// DAO 层
userDAO := dao.NewUserDAO(db)
// Repository 层
userRepo := repository.NewUserRepository(userDAO)
// Service 层
userService := service.NewUserService(userRepo)
// Handler 层
userHandler := web.NewUserHandler(userService)
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 生成这些胶水代码,省得手写一长串NewA(NewB(NewC(...)))。
七、密码加密:为什么是 BCrypt
生活类比:明文存密码就像把家门钥匙挂在门上,谁都能进。简单哈希(MD5)像把钥匙熔成一块固定形状的金属——同一把钥匙永远熔出同一块,坏人提前备好"常见钥匙→熔块"对照表(彩虹表)就能反推。BCrypt 的聪明之处在于:每次熔都随机撒一把盐,所以同一把钥匙每次熔出来的形状都不一样,坏人没法预建对照表。
7.1 加密算法的演进
| 算法 | 缺陷 |
|---|---|
| MD5 / SHA-1 | 相同密码哈希结果相同,易被彩虹表破解 |
| 加盐 MD5 | 盐值要存储,开发者容易用错 |
| PBKDF2 / BCrypt | 自带随机盐值,相同密码每次哈希结果不同 |
工程视角:这张表背后的主线是"和攻击者的军备竞赛"。MD5 快,但快对攻击者也是好事——一秒能试十亿个密码;BCrypt 故意"慢"(靠
cost调),且每次加盐随机,彩虹表直接失效。所以选哈希算法别只看"能不能用",要看"抗暴力破解"——这也是为什么明文、MD5、双 MD5 在生产环境都是事故。
7.2 BCrypt 的优点
import "golang.org/x/crypto/bcrypt"
// 加密:每次结果不同(因为盐值随机)
hashed1, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
hashed2, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
// hashed1 != hashed2,但都能通过 CompareHashAndPassword 校验
// 校验:把明文和哈希比较
err := bcrypt.CompareHashAndPassword(hashed1, []byte("123456")) // nil = 匹配
BCrypt 特点:
flowchart LR
P[明文密码 123456] --> B[BCrypt 加密
随机盐 + cost]
B --> H1[哈希1 随机]
B --> H2[哈希2 也随机]
P --> C[CompareHashAndPassword
比对明文与哈希]
C --> OK["匹配? 返回 nil"]- 自带随机盐值,无需单独存储盐
- 通过
cost控制加密耗时(cost+1,耗时翻倍),对抗暴力破解 - 不可逆,无法解密,只能比对
- 限制密码长度 ≤ 72 字节,校验时要在正则里限制
工程视角:"≤ 72 字节"这个限制是 BCrypt 算法本身定的——它只取密码前 72 字节做哈希,更长的部分直接忽略。如果你不限制,用户设个超长密码,BCrypt 偷偷只算前 72 字节,安全性反而因为"长密码"的错觉而放松。所以在注册校验时就要用正则把密码长度卡在 72 以内,别等出事才发现。
7.3 加密放在哪一层
| 层 | 论点 |
|---|---|
| Service | 加密是业务概念(推荐) |
| Repository | 加密是"加密存储"概念 |
| DAO | 加密是数据库概念(用数据库加密功能) |
| Domain | 加密是 User 自己的事 |
没有标准答案,课程选择 Service。注意:选择不同层会影响其它接口实现,比如登录时如果 DAO 加密,那 Service 拿到的密码就是密文,比对逻辑要相应调整。
工程视角:把加密放在 Service 还有个好处——业务逻辑对"密码是经过哈希的"这件事负责,Repository / DAO 只管存储,不关心里面是不是密码。如果哪天你想换成 Argon2,只改 Service 一处;反过来若放在 DAO,所有经过 DAO 的写路径都得考虑"这里到底存的是明文还是密文",容易漏。
八、错误传导:别名机制
目标:让顶层 Handler 只依赖 Service,不依赖 DAO/Repository 的错误类型。
// DAO 层
var ErrUserDuplicateEmail = errors.New("邮箱冲突")
// 把 gorm.ErrDuplicatedKey 转换为 ErrUserDuplicateEmail
// Repository 层
var ErrUserDuplicateEmail = errors.New("邮箱冲突") // 同名但不同包
// 把 dao.ErrUserDuplicateEmail 转换为 repository.ErrUserDuplicateEmail
// Service 层
var ErrDuplicateEmail = repository.ErrUserDuplicateEmail
// 直接转发
// Handler 层
if errors.Is(err, service.ErrDuplicateEmail) {
c.JSON(http.StatusOK, gin.H{"msg": "该邮箱已注册"})
}
好处:Handler 不需要 import dao 包,跨层依赖被切断。未来 DAO 从 GORM 换成 MongoDB,Handler 和 Service 完全不需要改。
工程视角:这叫"依赖倒置"——高层模块(Handler)不依赖低层细节(具体数据库驱动),而是都依赖中间的抽象(Repository 接口)。配合"错误别名"这套传导机制,换存储、换中间件时,业务代码一行不动。面试被问"为什么要分层"时,这道题(可测试 + 可替换)就是最硬的回答。
九、登录与登录态:Cookie、Session、Middleware
9.1 HTTP 是无状态的
HTTP 协议本身是无状态的:连续两次请求,服务器无法知道是不是同一个用户发的。所以需要一种机制记录"登录态"。
工程视角:“无状态"不是缺点,是 Web 能水平扩展的基石——任何一个请求都能被任意实例处理,不用绑定某台机器。代价是"每次都要自带身份信息”。Session / Cookie / Token 都是为了解决这个问题:把"我是谁"这件事,要么存在客户端(Token),要么存在服务端用个 ID 关联(Session)。
一张图看明白"无状态"为什么需要 Session:
flowchart TD
L[用户登录] --> S[服务端生成 sess_id
并把数据存后端]
S --> C[把 sess_id 写回浏览器 Cookie]
C --> R1[后续请求自动带 sess_id]
R1 --> V[服务端凭 sess_id 查出用户
识别这是同一个人]9.2 Cookie
Cookie 是浏览器存储在本地的键值对,每次请求自动带上。特点:
- 放在浏览器本地,不安全
- 适合存非敏感数据(如语言偏好)
Cookie 关键配置(面试要点):
| 字段 | 含义 | 建议 |
|---|---|---|
| Domain | Cookie 可用的域名 | 最小化原则 |
| Path | Cookie 可用的路径 | 最小化原则 |
| Max-Age / Expires | 过期时间 | 只保留必要时间 |
| HttpOnly | 设为 true,JS 无法读取 Cookie | 永远设为 true |
| Secure | 只能通过 HTTPS 传输 | 生产环境永远 true |
| SameSite | 是否允许跨站发送 | 尽量避免 |
工程视角:这几项里
HttpOnly=true和Secure=true是红线——HttpOnly挡住 XSS 偷 Cookie 的 JS 读取,Secure保证 Cookie 只走 HTTPS 不被中间人截。至于SameSite,现代浏览器默认值已经偏严格(Lax),能有效防 CSRF 的一部分场景,但涉及钱的操作仍要配 CSRF Token,别迷信这一个开关。
9.3 Session
Session 是把关键数据存在后端,浏览器只持有一个 sess_id。这样即使 sess_id 泄露,攻击者也拿不到原始数据。
⚠️ 新手必踩的坑:Session 认 ID 不认人。如果攻击者拿到了你的
sess_id,服务器会把攻击者当成你——因为服务端只凭这个 ID 查数据,不验证"是不是你本人在用"。所以sess_id必须走 HTTPS 传输、Cookie 设HttpOnly+Secure,并定期刷新,降低被盗用的窗口。
Session 认 ID 不认人:如果攻击者拿到了你的 sess_id,服务器会把攻击者当成你。所以 sess_id 必须保护:
- HTTPS 传输
- Cookie 设 HttpOnly + Secure
- 定期刷新
9.4 用 Gin Session 插件实现登录
go get github.com/gin-contrib/sessions
go get github.com/gin-contrib/sessions/cookie
package web
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
svc *service.UserService
}
func (h *UserHandler) RegisterRoutes(g *gin.RouterGroup) {
// 在分组上注册 Session middleware
// 1. 初始化 store:基于 cookie 的实现(开发期用,生产用 Redis)
// 两个 key:Authentication(身份认证)、Encryption(数据加密)
store := cookie.NewStore([]byte("secret-authentication-key"),
[]byte("secret-encryption-key"))
// MaxAge 控制 Cookie 过期 + 部分 store 控制 Session 数据过期
store.Options(sessions.Options{
MaxAge: 60 * 60 * 24, // 1 天
HttpOnly: true,
Secure: false, // 开发环境 false,生产必须 true
SameSite: http.SameSiteLaxMode,
})
g.Use(sessions.Sessions("sess_id", store))
g.POST("/login", h.Login)
// 需要登录的路由放在下面,加登录校验 middleware
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
}
// 步骤 1:登录成功,取出本次请求的 Session 对象
sess := sessions.Default(c)
// 步骤 2:把 user_id 写进 Session(数据存后端,浏览器只拿 sess_id)
sess.Set("user_id", user.ID)
// 步骤 3:设置过期时间并持久化到 store
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
}
// 把 user_id 放到上下文,后续 handler 可以直接用
c.Set("user_id", userID)
c.Next()
}
> **工程视角**:`c.Set("user_id", ...)` + `c.MustGet("user_id")` 是 Gin 里"在 middleware 和 handler 之间传值"的标准做法。注意 `sess.Get("user_id").(int64)` 这个类型断言——Session 里存的是 `interface{}`,取出来必须断言成 `int64`,断言错类型会 panic。所以 Login 时 `sess.Set("user_id", user.ID)` 要保证 `user.ID` 本就是 `int64`,别混进 `int`。
func (h *UserHandler) Profile(c *gin.Context) {
userID := c.MustGet("user_id").(int64)
// 查询用户信息...
c.JSON(http.StatusOK, gin.H{"user_id": userID})
}
Session middleware 的两步:
- 接入:
sessions.Sessions("sess_id", store)从 Cookie 找sess_id,再从 store 找 Session 数据。 - 使用:
sessions.Default(c)拿到 Session 对象,可以Set/Get/Delete/Save。
9.5 Session 存储的选择
Gin Session 提供多种 store 实现:
| Store | 适用场景 |
|---|---|
| cookie | 单机开发 |
| memstore | 单实例部署 |
| redis | 多实例部署,无脑选 |
| gorm/mysql | 已有 MySQL 不想引 Redis |
| memcached | 已有 Memcached |
| mongo | 已有 MongoDB |
| postgres | 已有 PostgreSQL |
多实例部署必须用 Redis:因为用户的请求经过负载均衡后可能落到不同实例,如果用内存存储,每个实例的 Session 数据都不一样。下一章会详细讲。
工程视角:这里暴露的是一个分布式系统的经典约束——“状态要外置”。单机时把 Session 放进程内存没问题;一旦水平扩展成多实例,内存状态就不共享了。解决思路只有两条:要么把状态集中到外部分布式存储(Redis),要么让同一个用户始终落到同一实例(IP 哈希,但实例扩缩容时会失效)。前者(无状态 + 外置存储)是业界主流,也是后面 Kubernetes 部署能随意扩缩容的前提。
十、工程实践要点
10.1 分层架构
- 不要跨层调用:Handler 只调 Service,Service 只调 Repository,Repository 只调 DAO。
- 错误用别名机制向上传导:每层把下层错误转换为自己的错误,避免上层依赖下层包。
- 接口优先:Repository 用接口定义,方便切换实现和单元测试。
- Domain 独立于 DAO 模型:业务对象和数据库表分开演化。
10.2 密码安全
- 密码必须加密存储,禁止明文。
- 用 BCrypt,不要自己造算法。
- 密码不能打日志,敏感信息不能进日志。
- 登录错误统一返回“用户名或密码不对”,避免枚举攻击。
10.3 HTTP 接口
- GET 查询、POST 提交,新手原则。
- Bind 自动处理 Content-Type,比手动 Unmarshal 简单。
- 校验两边都做:前端校验为体验,后端校验为安全。
- 错误返回用 HTTP 状态码 + 业务码,不要永远返回 200。
10.4 Gin 组织
- 按业务模块组织 Handler,每个 Handler 有
RegisterRoutes方法。 - 分组路由避免路径前缀写错。
- middleware 解决横切关注点:CORS、日志、登录校验、限流。
- 目录结构:
internal/放业务代码,pkg/放可复用代码。
十一、自测题与动手练习
自测题(合上书能答出来,才算懂):
c.Bind绑定失败时会发生什么?为什么后面必须return而不能再c.JSON写响应?- 为什么
Updates(u)把昵称改成空字符串会"清空失败"?要更新零值该怎么写? - 分层架构里 Handler / Service / Repository / DAO / Domain 各自的职责是什么?为什么有了 DAO 还要有 Repository?
- Cookie 和 Session 是怎么配合记住登录态的?为什么说 Session"认 ID 不认人",这会带来什么风险?
- BCrypt 相比 MD5 好在哪?为什么说"相同密码每次哈希结果不同"反而是优点?
动手练习(建议真做一遍):
- 跑通注册-登录:把本章代码本地起起来,注册一个账号,再用邮箱密码登录,从响应或日志里确认 Session 写进了
user_id。 - 故意触发唯一索引冲突:用同一个邮箱注册两次,观察从 DAO 的
ErrDuplicatedKey一路如何被转换成 Handler 看到的"该邮箱已注册"。 - 加一个中间件:写一个统计每个请求耗时的 middleware,注册到 Engine 上,访问任意接口看控制台是否打印耗时。
十二、本章小结
- Gin = 路由 + Context + Middleware。
Engine是服务器抽象,Context处理请求/响应,RouterGroup支持分组路由。 - 路由设计:静态 / 参数 / 通配符三种;GET 查询、POST 提交;查询参数 vs Body 看场景。
- Middleware 是 AOP 解决方案,用
Use注册;CORS 必须用 middleware 解决,preflight 是 OPTIONS 请求。 - GORM 是 Go 的 ORM 框架,模型定义用 struct + tag,
Create/First/Where/Updates是基础 CRUD。Updates用 map 才能更新零值。 - 分层架构:Handler(HTTP)→ Service(业务)→ Repository(存储抽象)→ DAO(数据库)→ Domain(业务对象)。错误用别名机制向上传导。
- 密码加密 用 BCrypt,放 Service 层。BCrypt 自带随机盐、可调 cost、不可逆。
- 登录态 用 Cookie + Session:Cookie 放
sess_id,Session 数据放后端(开发用 cookie store,生产用 Redis)。 - 登录校验 用 middleware:从 Session 取
user_id,没有就 401。
下一章我们将进入 JWT 和 Kubernetes 部署,把这套服务搬到分布式环境上运行。