用户功能与 Gin/GORM 入门

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

@

学习目标

  1. 掌握 Gin 框架的核心抽象(EngineContextRouterGroup),能写出最简 Web 服务并理解请求在 Gin 内的处理流程。
  2. 学会路由设计的三种形态(静态 / 参数 / 通配符),并理解 GET vs POST、查询参数 vs Body 的工程取舍。
  3. 理解 middleware(中间件)作为 AOP 解决方案的意义,能用它解决跨域(CORS)、登录校验等横切问题。
  4. 掌握 GORM 的基础用法(模型定义、CRUD、AutoMigrate),理解 ORM 的概念与"模型即表结构"的映射关系。
  5. 建立"Handler → Service → Repository → DAO → Domain"的分层架构意识,理解为什么要分层、每一层的职责边界。
  6. 实现完整的注册-登录闭环:密码 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
提交数据POSTBody(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 绑定,还能顺手按 binding tag 做基础校验。但它"失败即写响应"的特性你得记牢。另一个常见搭档是 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 五层架构

职责是否依赖数据库
HandlerHTTP 请求/响应处理,路由
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 查出用户
识别这是同一个人]

Cookie 是浏览器存储在本地的键值对,每次请求自动带上。特点:

  • 放在浏览器本地,不安全
  • 适合存非敏感数据(如语言偏好)

Cookie 关键配置(面试要点)

字段含义建议
DomainCookie 可用的域名最小化原则
PathCookie 可用的路径最小化原则
Max-Age / Expires过期时间只保留必要时间
HttpOnly设为 true,JS 无法读取 Cookie永远设为 true
Secure只能通过 HTTPS 传输生产环境永远 true
SameSite是否允许跨站发送尽量避免

工程视角:这几项里 HttpOnly=trueSecure=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 的两步

  1. 接入sessions.Sessions("sess_id", store) 从 Cookie 找 sess_id,再从 store 找 Session 数据。
  2. 使用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 分层架构

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

10.2 密码安全

  1. 密码必须加密存储,禁止明文。
  2. 用 BCrypt,不要自己造算法。
  3. 密码不能打日志,敏感信息不能进日志。
  4. 登录错误统一返回“用户名或密码不对”,避免枚举攻击。

10.3 HTTP 接口

  1. GET 查询、POST 提交,新手原则。
  2. Bind 自动处理 Content-Type,比手动 Unmarshal 简单。
  3. 校验两边都做:前端校验为体验,后端校验为安全。
  4. 错误返回用 HTTP 状态码 + 业务码,不要永远返回 200。

10.4 Gin 组织

  1. 按业务模块组织 Handler,每个 Handler 有 RegisterRoutes 方法。
  2. 分组路由避免路径前缀写错。
  3. middleware 解决横切关注点:CORS、日志、登录校验、限流。
  4. 目录结构internal/ 放业务代码,pkg/ 放可复用代码。

十一、自测题与动手练习

自测题(合上书能答出来,才算懂)

  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 好在哪?为什么说"相同密码每次哈希结果不同"反而是优点?

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

  1. 跑通注册-登录:把本章代码本地起起来,注册一个账号,再用邮箱密码登录,从响应或日志里确认 Session 写进了 user_id
  2. 故意触发唯一索引冲突:用同一个邮箱注册两次,观察从 DAO 的 ErrDuplicatedKey 一路如何被转换成 Handler 看到的"该邮箱已注册"。
  3. 加一个中间件:写一个统计每个请求耗时的 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 部署,把这套服务搬到分布式环境上运行。

About Me

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

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

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

目标

学AI,加油!加油!