GO学习笔记 | 第五章节 用户基本功能与 Gin|GORM 入门| 跨域、中间件与 GORM 入门

核心内容:跨域问题(CORS)、中间件(Middleware)、GORM 增删改查、Docker Compose 启动数据库、项目分层设计
前置知识:Gin 路由注册、请求绑定、参数校验


一、跨域问题(CORS)

image-20260621101452455

1.1 什么是跨域?

image-20260621101512547

当你用浏览器打开前端页面,前端向后端发请求时,如果前端和后端的「家」不一样,浏览器就会阻止这次请求——这就是跨域。

怎么判断是不是「同一家」? 浏览器看三个东西:

判定维度 举例:同一家 举例:不是同一家
协议 都是 http:// http:// vs https://
域名 都是 localhost qq.com vs wechat.com
端口 都是 :3000 :3000 vs :8080

三个只要有一个不同,浏览器就认为是跨域,直接拦住。

为什么浏览器要多管闲事?防止黑客在你的网页里植入恶意脚本,偷偷往别的服务器发请求。

怎么识别跨域错误? 打开浏览器 F12 → Console,看到 CORSAccess-Control-Allow-Origin 关键词,就是跨域问题。

1.2 跨域是怎么被拦住的?

前端发请求前,浏览器会先发一个**「试探请求」**(Preflight Request),问后端:

「喂,你是 localhost:8080 对吧?有个 localhost:3000 的页面想请求你,你接不接受?」

后端必须明确回答「我接受」,浏览器才会把真正的请求发过去。

Preflight 请求的特征:

  • 请求方法是 OPTIONS(不是 GET/POST)
  • 你在 Network 面板会看到同一个路径有两条请求:第一条是 OPTIONS(预检),第二条才是真正的 GET/POST

一句话总结:浏览器先派个侦察兵(OPTIONS)去问后端同不同意,同意了主力部队(真正的请求)才出发。

image-20260621101818562

1.3 后端怎么告诉浏览器「我接受」?

image-20260621101924448

后端在 OPTIONS 请求的响应里加上特殊的响应头:

1
2
3
4
5
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 43200

逐个解释:

响应头 含义
Allow-Origin 允许哪个前端来源访问。写 * 表示谁都行(有坑,见 1.4
Allow-Methods 允许哪些 HTTP 方法
Allow-Headers 允许请求带哪些自定义头
Allow-Credentials 是否允许携带 Cookie / Authorization
Max-Age 这次预检的有效期(秒),有效期内部再重复预检

1.4 ⚠️ Allow-Origin: * 的坑

1
2
3
4
// 有风险:当 Allow-Credentials = true 时,不能用 *
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// 浏览器会拒绝执行!must not be the wildcard when credentials is "include"

正确做法:要么用具体的 origin(如 http://localhost:3000),要么动态获取请求的 origin 并写到响应头里。

用函数动态返回 origin,一行代码搞定,灵活可控。

image-20260621104012599

图中就是用的函数动态返回origin

规范上,只有“当 Allow-Credentialstrue 时,才不能使用 \*”。如果 Allow-Credentialsfalse(或者不加这个头),使用 \* 是合法的。

不过在实际业务场景中,这个担忧是合理的,因为只要涉及跨域携带 Cookie 或 Token,Credentials 就必须为 true。一旦这个开关开了,通配符 * 就会直接被浏览器拦截。

1. 正确且严谨的 CORS 规则

Allow-Credentials Allow-Origin 浏览器行为 说明
false (或不传) \* 正常通过 允许所有来源,但不带凭证。
true \* 🚫 拒绝执行 这是安全禁令,浏览器会报你图片里的错误。
true http://localhost:3000 正常通过 必须指定具体的、精确的源。

2. 为什么 true 绝对不能配合 *

这是非常硬核的安全限制

  • 如果 Access-Control-Allow-Origin: *,意味着任何网站(哪怕是一个恶意钓鱼网站 http://evil.com)都可以向你的服务器发请求。
  • 如果同时 Access-Control-Allow-Credentials: true,意味着任何网站发来的请求,浏览器都会自动带上用户登录在当前浏览器里的 Cookie。
  • 结果:一个恶意网站只要贴一张 <img> 标签或者写一段 JS 脚本,就可以利用用户之前登录过的身份,向你的服务器发起攻击(比如 CSRF 跨站请求伪造,或者盗取数据)。这是绝对不允许的。

1.5 Gin 中解决跨域:官方 CORS 中间件

image-20260621102142892

image-20260621104012599

Gin 提供了现成的 CORS 插件,不需自己造轮子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import (
"github.com/gin-gonic/gin"
"github.com/gin-contrib/cors"
)

func main() {
server := gin.Default()

// 使用 CORS 中间件
server.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"POST", "GET", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
// AllowOriginFunc 也支持函数动态判断
}))

// ...注册路由
server.Run(":8080")
}

关键点

  • AllowOrigin 要和前端请求头里的 Origin 一一对应
  • AllowHeaders 要和前端请求头里的自定义 Header 一一对应
  • 配完后在 Network 面板看 OPTIONS 请求是否返回 204 No Content,是就说明跨域搞定了

image-20260621104903048

image-20260621104929169


二、中间件(Middleware)

image-20260621102640073

2.1 中间件是干什么的?

把中间件想象成安检通道

1
浏览器请求 → [安检1] → [安检2] → 你的业务逻辑 → 响应

所有请求都要先经过安检,安检通过了才能到达你的业务代码。所有业务都关心的公共问题(如跨域、登录校验、日志、限流),用中间件统一解决,不需要每个接口都写一遍。

这种编程思想叫 AOP(面向切面编程)——把横切关注点集中处理。

2.2 中间件 vs 路由处理函数

image-20260621102945287

1
2
3
4
5
6
7
8
9
10
11
12
// 路由处理函数:只作用于这一条路由
server.GET("/hello", func(ctx *gin.Context) {
ctx.String(200, "hello")
})

// 中间件:作用于 server 上注册的所有路由
server.Use(func(ctx *gin.Context) {
// 请求到达前
fmt.Println("安检通过")
ctx.Next() // 继续交给下一个中间件或业务逻辑
// 响应返回后
})

ctx.Next() 是关键的枢纽——调用它,请求才会继续往后走。不调用 ctx.Next(),请求就被拦截了。

2.3 中间件的执行顺序

1
2
3
4
5
server.Use(middleware1)  // 先注册
server.Use(middleware2) // 后注册

// 请求流程:
// → middleware1 前半段 → middleware2 前半段 → 业务逻辑 → middleware2 后半段 → middleware1 后半段

先注册的先执行,和叠洋葱一样——一层层进去,再一层层出来。

image-20260621103936487

2.4 怎么用现成的中间件?

Gin 官方维护了一系列中间件:github.com/gin-contrib

1
2
# 以 CORS 中间件为例
go get github.com/gin-contrib/cors

然后在代码里 server.Use(cors.New(...)) 即可。

官方中间件质量参差不齐,有些有并发问题,用之前最好看一眼源码。目前 CORS 中间件是可靠的。


三、GORM 入门

image-20260621125419434

3.1 什么是 GORM

GORM 是 Go 语言最流行的 ORM 框架(对象关系映射)。简单说就是:用 Go 的结构体来操作数据库,不用手写 SQL

image-20260621125501867

  • 支持 MySQL、PostgreSQL、SQLite、SQL Server 等
  • 支持增删改查、事务、关联关系、自动建表
  • 有完整的中文文档

模型定义

image-20260621141951889

1
2
3
4
5
6
//定义表结构
type Product struct {
gorm.Model
Code string
Price uint
}

image-20260621142441800

3.2 快速起步

image-20260621125641895

泛型 API (>= v1.30.0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"context"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

//定义表结构
type Product struct {
gorm.Model
Code string
Price uint
}

func main() {
// 1. 连接数据库
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("连接数据库失败")
}

ctx := context.Background()

// 自动建表
db.AutoMigrate(&Product{})

// 创建
err = gorm.G[Product](db).Create(ctx, &Product{Code: "D42", Price: 100})

// 查询
product, err := gorm.G[Product](db).Where("id = ?", 1).First(ctx) // 查找对应主键的产品
products, err := gorm.G[Product](db).Where("code = ?", "D42").Find(ctx) // 查找 code 为 D42 的所有产品

// 更新 - 将产品价格更新为 200
err = gorm.G[Product](db).Where("id = ?", product.ID).Update(ctx, "Price", 200)
// 更新 - 更新多个字段
err = gorm.G[Product](db).Where("id = ?", product.ID).Updates(ctx, Product{Code: "D42", Price: 100})

// 删除 - 删除产品
err = gorm.G[Product](db).Where("id = ?", product.ID).Delete(ctx)
}

传统 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

type Product struct {
gorm.Model
Code string
Price uint
}

func main() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("连接数据库失败")
}

// 自动建表
db.AutoMigrate(&Product{})

// 创建
db.Create(&Product{Code: "D42", Price: 100})

// 查询
var product Product
db.First(&product, 1) // 查找对应主键的产品
db.First(&product, "code = ?", "D42") // 查找 code 为 D42 的所有产品

// 更新 - 将产品价格更新为 200
db.Model(&product).Update("Price", 200)
// 更新 - 更新多个字段
db.Model(&product).Updates(Product{Price: 200, Code: "F42"}) // 仅更新非零字段
db.Model(&product).Updates(map[string]interface{}{"Price": 200, "Code": "F42"})

// 删除 - 删除产品
db.Delete(&product, 1)
}

3.3 Debug 模式

1
db = db.Debug()  // 开启后,每次数据库操作都会打印 SQL 到控制台

开发阶段一定要开! 可以看到 GORM 实际生成的 SQL,确保和预期一致。

3.4 MySQL 的连接字符串

1
用户名:密码@tcp(主机:端口)/数据库名?charset=utf8mb4&parseTime=True

示例:root:root@tcp(localhost:13316)/vbook?charset=utf8mb4&parseTime=True

1
[username[:password]@] [protocol[(address) ]]/dbname[?param1=value1& ...&paramN=valueN]
1
2
//这是博主的
db, err := gorm.Open(mysql.Open("root:040725ge@tcp(localhost:3306)/test"))

不需要死记。复制公司已有项目的配置,改账号密码就行。

3.5 AutoMigrate:自动建表

1
db.AutoMigrate(&User{}, &Article{})
  • 表不存在 → 建表
  • 表已存在 → 同步字段(新增列)
  • 不会删除已有列,也不支持改名
  • 生产环境慎用,一般只在开发/测试中用

3.6 软删除

GORM 默认开启软删除:执行 Delete 时并不真正删除数据,而是设置 deleted_at 字段为当前时间。

1
2
3
4
5
6
7
db.Delete(&product)  // 实际 SQL: UPDATE products SET deleted_at=NOW() WHERE id=?

// 查询时自动过滤已软删除的记录
db.First(&product, 1) // WHERE deleted_at IS NULL

// 真要硬删除
db.Unscoped().Delete(&product) // DELETE FROM products WHERE id=?

老师观点:ORM 框架不应该内置软删除,这应该是应用层自己封装的事情。

3.7 GORM 的「坑」:Builder 模式返回值

1
2
3
4
5
db = db.Debug()  // Debug 返回的是新的 DB 实例,必须重新赋值!

// 如果写成这样就无效:
db.Debug() // 返回的新实例被丢弃了
db.Create(...) // 不会打印 SQL

所有链式调用都要记得把返回值接住。

3.8 学习方法

image-20260621141752100

  • GORM 中文文档很全面,不需要死记硬背
  • WhereFirstUpdate 等方法大多接受 interface{},传 string、map、struct 行为不同
  • 一切以 SQL 输出为准——打开 debug 模式,看生成的 SQL 是否符合预期

四、Docker Compose 启动 MySQL

image-20260621142530377

4.1 为什么用 Docker

不用在本机装 MySQL,一行命令就能起一个干净的环境,用完就停,不污染系统。

4.2 配置文件:docker-compose.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
version: '3.0'
# 我这个 docker compose 由几个服务组成
services:
mysql8:
image: mysql:8.0
restart: always
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: 你的密码(空格不要删)
volumes:
# - 初始化脚本
- ./script/mysql/:/docker-entrypoint-initdb.d/
ports:
# - 外部访问用 13316
- "13316:3306"

关键点

  • ports 的格式是 本机端口:容器端口
  • volumes 可挂载初始化脚本:容器启动时自动执行 init.sql

4.3 init.sql 示例

1
create database webook;

4.4 常用命令

image-20260621171442586

1
2
3
4
5
6
7
8
9
10
11
# 启动(前台,能看到日志)
docker compose up

# 后台启动
docker compose up -d

# 停止并清理
docker compose down

# 停止但不删数据,只要不是compose down,那就不会删数据
Ctrl + C

怎么确认启动成功? 看到日志中 ready for connections 就表示 MySQL 就绪了。

可能遇到的问题

执行up命令的时候失败了,可能原因是你没有启动docker,需要先启动。另外拉取mysql的时候需要等待一段时间,这是成功后的截图

image-20260621164802109

4.5 GoLand 连接数据库

GoLand 内置数据库工具:右侧边栏 → Database → + → Data Source → MySQL

填写连接信息:

  • Host: localhost
  • Port: 13316(和 docker-compose 里映射的一致)
  • User: root
  • Password: 你的密码(和docker-ccompose里面一致)
  • Database: webook

Test Connection → 看到 Succeeded 即成功。

注意:

如果要让你下载mysql驱动的话那就直接下载,不用有顾虑

然后测试连接一定要弄通顺,注意端口是13316不是3306,注意密码是和配置文件里面的一样而不是和你电脑上面那个mysql的密码一样


五、项目分层设计

image-20260621171543012

image-20260621171617258

image-20260621171719922

image-20260621172630821

职责 举例
domain 业务领域对象,和真实世界对应 UserArticle
repository 数据存储的抽象接口 UserRepository 定义 FindByID
dao 具体的数据库操作 UserDAO 用 GORM 实现查询
service 组合各种 repository,完成业务功能 UserService 调用 UserRepository + Cache

核心思想

  • repository 只关心「存数据」这件事,不关心存到哪(DB、缓存、文件、甚至 RPC 调用)
  • dao 才是真正操作数据库的地方
  • service 是总指挥,调度资源完成业务
  • domain 中的对象可能和数据库表结构不一样

六、核心概念速查

概念 一句话解释
跨域 前端和后端不同源,浏览器不让发请求
Preflight (OPTIONS) 浏览器先问后端「同不同意」,同意了才发真正请求
CORS 中间件 后端在响应里加上 Allow-Origin 等头,告诉浏览器「我同意」
Middleware 请求和业务逻辑之间的安检通道,解决所有路由的公共问题
ctx.Next() 中间件里放行请求去下一站
ORM 用结构体操作数据库,不用手写 SQL
AutoMigrate 自动根据结构体建表或更新表结构
软删除 Delete 时只标记删除时间,不真删数据
Docker Compose 一键启动 MySQL,用完即停