学习目标
- 理解 SSO(单点登录)的核心思想,能够独立讲清楚 OAuth2 授权码流程
- 掌握微信扫码登录的完整流程:构造授权 URL、处理回调、用 code 换 access_token、登录或注册
- 理解
state参数在防 CSRF 攻击中的作用,并能在代码中正确使用 - 掌握长短 token(access_token / refresh_token)机制,知道为什么不能用自动刷新代替前端触发刷新
- 能在 JWT 无状态机制下实现"退出登录",理解
ssid方案与降级策略 - 掌握配置模块的设计:来源、优先级、两次加载,能用
viper读取本地配置和 etcd 远程配置 - 理解结构化日志的级别与最佳实践,能用
zap打日志并抽象自己的Logger接口
前置知识(如果下面任意一点生疏,先回看对应章):
- 第03章 JWT:知道三段结构、验签、middleware 校验。
- 第03章 限流 / User-Agent 校验:理解"token 泄露"的风险。
- 第04章 面向接口编程 / 依赖注入:本章
jwt.Handler就是接口抽象的实践。 - 基本 HTTP 重定向、Cookie 概念。
本章你会动手做的事:
- 在纸上/白板上独立画出 OAuth2 授权码流程的时序图(不看书),标出 code 与 access_token 各自的去向。
- 给微信回调的
Callback加上state校验,并用一个"攻击者用别人 code 绑账号"的场景验证它能拦住。 - 用
ssid + Redis实现退出登录,并故意把 Redis 停掉,确认降级策略下已登录用户仍能访问。
一、SSO 与微信扫码登录
1.1 SSO 是什么
SSO(Single Sign-On,单点登录):用户在一个登录入口登录后,访问同一公司下所有互信的应用都不需要再次登录。
SSO 的常见实现有:
- OAuth2:授权码模式(最常用,本文重点)
- CAS:中央认证服务,多用于传统企业内网
- JWT + 共享:把 token 通过 Cookie 共享给同根域下的子应用
微信扫码登录本质就是一个 OAuth2 授权码流程:你作为用户,授权 webook 这个第三方应用拿到了 access_token,webook 就认为你登录了。
类比:SSO 像公司的"一卡通"——你在前台刷脸领了一张门禁卡,之后进大楼、进食堂、进健身房都不用再证明身份。OAuth2 授权码模式是"领卡"最安全的流程:卡不是直接塞你手里,而是前台先给你一个临时排队号(code),你拿号去后台换了真正的卡(access_token)。
1.2 OAuth2 授权码流程(补充知识)
OAuth2 是一个授权框架,授权码模式(Authorization Code)是最安全的流程:
sequenceDiagram
participant U as 用户
participant W as webook(客户端)
participant X as 微信(授权+资源服务器)
U->>W: 1. 点击登录
W->>X: 2. 重定向到微信授权页(带 client_id+redirect_uri+state)
U->>X: 3. 扫码确认
X->>W: 4. 重定向回 webook(带 code+state)
W->>X: 5. 用 code 换 access_token
X->>W: 6. 返回 access_token
W->>X: 7. 用 token 拿用户信息
X->>W: 8. 返回用户信息
W->>U: 9. 登录成功为什么要有 code 这一步? 因为 access_token 是敏感的,不能直接通过浏览器 URL 传递(会被 Referer 泄露)。所以先用浏览器传一个临时 code(短效、只能用一次),后端再用 code + secret 换 access_token,全程走服务端到服务端的安全通道。
1.3 微信扫码登录 API 详解
1.3.1 第一步:构造跳转到微信的 URL
跳转到微信时需要拼接这些参数:
| 参数 | 说明 |
|---|---|
appid | 微信开放平台注册的 ID |
redirect_uri | 微信跳回来的地址(必须与注册时填的域名一致) |
response_type | 固定为 code |
scope | 固定为 snsapi_login(登录权限) |
state | 随机数,防 CSRF |
1.3.2 第二步:微信跳回来,带 code
微信扫码确认后,会重定向到 redirect_uri?code=xxx&state=xxx。
1.3.3 第三步:用 code 换 access_token
后端用 appid + secret + code 调用微信接口,换取真正的 access_token。
1.3.4 微信返回的字段
授权码部分:
access_token:后续访问微信 API 的凭证(短 token)expires_in:access_token 的有效期refresh_token:access_token 过期后用它换新的
ID 部分:
open_id:用户在你这个应用下的唯一 IDunion_id:用户在你这个公司下的唯一 ID
1.3.5 open_id 与 union_id 的区别
假设你们公司在微信注册了 A、B 两个产品。对用户张伟:
- 张伟在 A 上有一个
open_id - 张伟在 B 上有另一个不同的
open_id - 但张伟在 A 和 B 上的
union_id是同一个
所以跨产品识别同一个用户,要用 union_id。
flowchart TD
subgraph A[产品A]
OA[open_id: aaa]
end
subgraph B[产品B]
OB[open_id: bbb]
end
OA --> U[union_id: 同一个]
OB --> U1.4 设计与实现
1.4.1 提供两个接口
// web/wechat.go
type OAuth2WechatHandler struct {
svc wechat.Service // 微信 OAuth2 服务
userSvc service.UserService // 用户服务
jwt jwt.Handler // JWT 处理
}
func (h *OAuth2WechatHandler) RegisterRoutes(server *gin.Engine) {
g := server.Group("/oauth2/wechat")
g.GET("/authurl", h.AuthURL) // 1. 获取微信授权 URL
g.GET("/callback", h.Callback) // 2. 处理微信回调
}
1.4.2 wechat.Service:构造授权 URL
// service/wechat/service.go
package wechat
import "github.com/google/uuid"
// Service 微信 OAuth2 服务抽象
type Service interface {
// AuthURL 生成跳转到微信的授权 URL
AuthURL(ctx context.Context, state string) (string, error)
// Verify 用 code 换 access_token 和用户信息
Verify(ctx context.Context, code string) (Result, error)
}
type Result struct {
OpenId string
UnionId string
AccessToken string
RefreshToken string
ExpiresIn int64
}
// 腾讯实现(构造 URL)
type WechatService struct {
appId string
appSecret string
redirectURL string
}
func NewWechatService(appId, appSecret, redirectURL string) *WechatService {
return &WechatService{appId: appId, appSecret: appSecret, redirectURL: redirectURL}
}
func (s *WechatService) AuthURL(ctx context.Context, state string) (string, error) {
// 步骤 1:拼接微信授权 URL(appid + 回调 + 固定 response_type/scope + state)
return fmt.Sprintf(
"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s"+
"&response_type=code&scope=snsapi_login&state=%s",
s.appId, url.QueryEscape(s.redirectURL), state), nil
}
1.4.3 AuthURL Handler
func (h *OAuth2WechatHandler) AuthURL(ctx *gin.Context) {
// 步骤 1:生成 state,用 UUID 防止被猜
state := uuid.New().String()
// 步骤 2:把 state 写入 Cookie(JWT 形式),等回调时校验
val := h.jwt.SetStateToken(ctx, state)
ctx.SetCookie("jwt-state", val, 600, "/", "", false, true)
// 步骤 3:构造微信授权 URL 返回给前端
url, err := h.svc.AuthURL(ctx, state)
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: url})
}
1.4.4 Callback Handler
func (h *OAuth2WechatHandler) Callback(ctx *gin.Context) {
// 步骤 1:校验 state(防 CSRF)
err := h.jwt.CheckState(ctx, ctx.Query("state"))
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 4, Msg: "非法请求"})
return
}
// 步骤 2:用 code 换微信用户信息
wechatRes, err := h.svc.Verify(ctx, ctx.Query("code"))
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
// 步骤 3:FindOrCreate:新用户直接注册,老用户直接登录
u, err := h.userSvc.FindOrOrCreate(ctx, wechatRes.UnionId)
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
// 步骤 4:设置登录态(长短 token)
if err = h.jwt.SetLoginToken(ctx, u.Id); err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Msg: "登录成功"})
}
1.5 state 参数:防 CSRF 攻击
1.5.1 攻击场景
理解 state 的核心是抓住"攻击者让你用他的临时授权码来绑定账号":
- 攻击者弄到一个绑定微信的临时授权码 A
- 正常用户登录成功
- 攻击者伪造页面诱导用户点击,攻击者带着正常用户的 Cookie + 攻击者的 code 去请求
- 结果:攻击者通过微信扫码登录,看到正常用户的数据
sequenceDiagram
participant A as 攻击者
participant V as 正常用户
participant W as webook
A->>V: 诱导点击(带着攻击者的 code)
V->>W: 正常用户的 Cookie + 攻击者的 code
Note over W: 若没校验 state, 把攻击者的微信绑到正常用户账号
W-->>A: 攻击者用自己微信登录, 看到正常用户数据1.5.2 state 如何解决
- 生成 AuthURL 时,生成 state 并存起来(JWT 或 Session)
- 微信跳回来时,把回调的 state 和我们存的 state 比较
如果不一致,说明有人伪造请求。
1.5.3 用 JWT 记录 state
用 JWT 记录 state 的好处:不用额外存 Redis,直接用 JWT 自带的 state 比较。注意要放在 Cookie 里,因为微信回调直接到后端,不经过前端。如果放 Header,前端代码需要主动加,但这里没法让前端加。
// 检查 state
func (h *JWTHandler) CheckState(ctx *gin.Context, state string) error {
// 步骤 1:从 Cookie 取出 state token
ck, err := ctx.Cookie("jwt-state")
if err != nil {
return fmt.Errorf("找不到 state cookie")
}
// 步骤 2:解析 JWT
var claims StateClaims
_, err = jwt.ParseWithClaims(ck, &claims, func(t *jwt.Token) (interface{}, error) {
return h.stateKey, nil
})
if err != nil {
return err
}
// 步骤 3:比对 state 是否一致
if claims.State != state {
return fmt.Errorf("state 不匹配")
}
return nil
}
现实情况:大部分公司都没处理 state。面试中刷亮点就要解释清楚:什么情况下不处理 state 会出问题(CORS 场景),以及如何解决。
⚠️ 新手必踩的坑:state 必须"存一份 + 比一次"。常见错误是只在 URL 里生成 state 却没在服务端留存,回调时无法比对——等于没防。正确做法是 AuthURL 时把 state 写进 Cookie(或 Session/Redis),Callback 时取出来和微信回传的 state 严格相等才放行。
二、长短 token 与登出
2.1 长短 token 的设计
在微信扫码登录里你已经看到:
access_token:短 token,用于访问资源,频繁使用,容易泄露refresh_token:长 token,access_token 过期后用它换新的,使用频率低,不容易泄露
2.2 为什么需要两个 token
核心矛盾:
- token 越长寿,用户体验越好(不用频繁登录)
- token 越长寿,泄露后危害越大
长短 token 的折中方案:
- 短 token 寿命短(如 30 分钟),即使泄露损失也小
- 长 token 寿命长(如 7 天),但只在登录和刷新时用,不直接访问资源,泄露风险小
flowchart TD
U[用户] -->|携带 access_token 访问资源| R[业务资源]
R -. access_token 过期 .-> F[返回 401]
F --> RF[前端用 refresh_token 换新的 access_token]
RF --> U2.3 改造已有代码
后端改造点:
- 登录成功时返回两个 token:access_token(短)+ refresh_token(长)
- 提供新的刷新 token 接口
refresh_token - 去掉 JWT middleware 中的"自动刷新过期时间"机制
前端改造点:
- 收到 401 响应时,调用
refresh_token拿新 token - 用新 token 重试原请求
// 设置长短 token
func (h *JWTHandler) SetLoginToken(ctx *gin.Context, uid int64) error {
// 步骤 1:生成 ssid,用于退出登录
ssid := uuid.New().String()
// 步骤 2:生成短 token(access_token,30 分钟)
accessToken, err := h.genToken(uid, ssid, h.accessKey, 30*time.Minute)
if err != nil {
return err
}
ctx.Header("x-jwt-token", accessToken)
// 步骤 3:生成长 token(refresh_token,7 天)
refreshToken, err := h.genToken(uid, ssid, h.refreshKey, 7*24*time.Hour)
if err != nil {
return err
}
ctx.Header("x-refresh-token", refreshToken)
return nil
}
2.4 refresh_token 接口
func (h *UserHandler) RefreshToken(ctx *gin.Context) {
// 步骤 1:从 Header 拿长 token
tokenStr := ctx.GetHeader("x-refresh-token")
if tokenStr == "" {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 2:解析长 token
claims := &RefreshClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return h.jwt.refreshKey, nil
})
if err != nil || claims.ExpiresAt.Before(time.Now()) {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 3:校验 ssid(用户是否已退出登录)
err = h.jwt.CheckSession(ctx, claims.Ssid)
if err != nil {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// 步骤 4:颁发新的短 token 和长 token
err = h.jwt.SetLoginToken(ctx, claims.Uid)
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Msg: "OK"})
}
2.5 为什么必须前端触发刷新?
面试常问:能不能在 JWT 中间件里发现短 token 过期就自动刷新?
不能。如果后端自动刷新,就和原来的"自动续期"机制一样了,违背了长短 token 的初衷。长短 token 的核心假设是:短 token 频繁使用容易泄露,所以寿命要短;长 token 只在登录和刷新时用,不容易泄露。如果中间件自动刷新长 token,长 token 也会变成"频繁使用",泄露风险上升。
2.6 长 token 泄露了怎么办?
但凡长 token 泄露,能做的事已经不多:
- 在长 token 里编码登录时的信息(如 User-Agent),校验时比对
- 长 token 只能用一次:每次刷新时颁发新的长 token,旧的作废(缓解但不能根治)
2.7 JWT 退出登录
Session 退出登录很简单:删 Cookie + 删 Redis 里的 Session 数据。
JWT 退出登录很麻烦:JWT 是无状态的,签发后无法主动作废(除非生成新 token)。多实例部署下,退出登录的请求落到实例 0,后续请求落到实例 1,实例 1 必须能感知到。
解决方案:用 Redis 记录"已退出登录的会话"。
2.8 ssid 方案
思路:每次登录生成一个 ssid(Session ID),写入长短 token。退出登录时把 ssid 加入 Redis 黑名单。校验时检查 ssid 是否在黑名单中。
// 登录时生成 ssid
ssid := uuid.New().String()
// 写入 access_token 和 refresh_token 的 claims
// 退出登录时,把 ssid 加入 Redis
func (h *JWTHandler) Logout(ctx *gin.Context, ssid string) error {
// 步骤 1:标记 ssid 不可用,过期时间和长 token 一致(7 天)
return h.cmd.Set(ctx, fmt.Sprintf("users:ssid:%s", ssid),
1, 7*24*time.Hour).Err()
}
// 校验时检查
func (h *JWTHandler) CheckSession(ctx *gin.Context, ssid string) error {
// 步骤 1:查 Redis 黑名单
cnt, err := h.cmd.Exists(ctx, fmt.Sprintf("users:ssid:%s", ssid)).Result()
if err != nil {
// 降级策略:Redis 挂了就不严格校验,保证登录用户可用
return nil
}
// 步骤 2:在黑名单里则拒绝
if cnt > 0 {
return errors.New("会话已失效")
}
return nil
}
flowchart TD
L[登录] --> G[生成 ssid 写入 token]
O[退出登录] --> B[ssid 加入 Redis 黑名单]
V[后续请求校验] --> C{Redis 里 ssid 存在?}
C -- 存在 --> R[拒绝: 会话失效]
C -- 不存在 --> OK[放行]
C -- Redis 挂了 --> OK⚠️ 新手必踩的坑:退出登录要让客户端也清掉 token。只把 ssid 写进 Redis 黑名单还不够——如果客户端仍带着旧 token 且没走到 Redis 校验(比如某些静态资源或中间件顺序问题),可能绕过。标准做法同时:① 服务端把 ssid 加入黑名单(让后续 JWT 校验失败);② 告诉客户端把长短 token 置为非法值。两层一起上才稳。
2.9 降级策略(面试加分点)
Redis 崩溃时,要不要严格执行 ssid 校验?
不严格执行。理由:
- 相比退出登录这种少数情况,绝大多数已登录用户是正常的
- 如果严格执行,Redis 一挂,所有登录用户全部无法通过校验,业务雪崩
- 降级策略:Redis 挂了就跳过 ssid 校验,保证大多数用户可用
2.10 退出登录后的清理
退出登录时:
- 把 ssid 加入 Redis 黑名单
- 把客户端 Cookie/Header 里的长短 token 设为非法值,让客户端在到达 Redis 之前就因 JWT 校验失败而返回 401
2.11 重构:抽出 JwtHandler
随着功能增加,JWT 相关代码散落在 UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 各处,屎山味道。重构方案:
// internal/jwt/handler.go
package jwt
type Handler interface {
// 设置登录态 token(写入响应头)
SetLoginToken(ctx *gin.Context, uid int64) error
// 生成短 token
GenerateToken(uid int64, ssid string) (string, error)
// 校验短 token
ParseToken(tokenStr string) (*Claims, error)
// 设置/校验 state(用于 OAuth2)
SetStateToken(ctx *gin.Context, state string) string
CheckState(ctx *gin.Context, state string) error
// 退出登录
Logout(ctx *gin.Context, ssid string) error
CheckSession(ctx *gin.Context, ssid string) error
}
// Redis 实现
type RedisJWTHandler struct {
cmd redis.Cmdable
accessKey []byte
refreshKey []byte
stateKey []byte
}
关键点:
- 把所有 JWT 操作内聚到
jwt.Handler - UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 都依赖
jwt.Handler接口 jwt.Handler不直接写响应,让调用者根据返回值自己写(保持职责单一)
2.12 为什么不用 user id 代替 ssid?
面试衍生:能不能用 user id 标识"已退出登录"?
可以,但要注意:
- 登录时要删除 user id 的记录
- 要处理"一边登录一边退出登录"的并发问题(用户登录会清掉记录,使退出登录失效)
所以不建议用 user id,建议用 UUID 生成的 ssid。每次登录生成新的 ssid,互不影响。
2.13 布隆过滤器方案(面试方案)
另一种"记录已退出登录会话"的方案是布隆过滤器:
- 退出登录时把 ssid 哈希到比特数组的 N 个位置置 1
- 校验时检查这些位置是否都为 1
缺点:
- 布隆过滤器有假阳性(说存在但实际不存在),所以还是要 Redis 兜底
- 布隆过滤器本身也要用 Redis 实现,至少访问一次 Redis
所以改进有限,主要作为面试亮点。
布隆过滤器原理
选取 N 个哈希函数,计算业务值的哈希,将比特数组对应位置置 1。查询时:
- 如果任一位置为 0 → 一定不存在
- 如果所有位置都为 1 → 可能存在(假阳性)
flowchart LR
S[ssid] --> H1[hash1]
S --> H2[hash2]
S --> H3[hash3]
H1 --> B1[bit 3]
H2 --> B2[bit 7]
H3 --> B3[bit 12]三、配置模块
3.1 配置来源
| 来源 | 适用场景 |
|---|---|
| 启动参数 | 命令行工具,如 mockgen -source=xxx |
| 环境变量 | 和实例有关的参数,如权重、分组 |
| 配置文件 | 当前环境的通用配置,如数据库连接 |
| 远程配置中心 | 除启动最少配置外的所有配置 |
个人建议:
- 少用启动参数(新人门槛高)
- 少用环境变量(要登录机器才知道值)
- 优先配置文件
- 大规模微服务集群再引入远程配置中心
3.2 来源的优先级
如果多个来源都有同一配置项,用哪个?取决于哪个更"权威"。个人实践:
命令行 > 环境变量 > 配置文件 > 远程配置中心
理由:命令行是我主动输入的;环境变量是我自己设置的;配置文件可能是同事写的;远程配置中心是公共的。
3.3 两次加载
使用远程配置中心时一般有两次加载:
第一次:加载最基本的配置
- 远程配置的连接信息(连上配置中心)
- 日志相关配置(确保后续能输出日志)
第二次:完全加载
- 读取所有依赖配置(数据库、Redis 等)
- 用于初始化各种组件
- 如果远程配置中心能覆盖第一次的配置,则覆盖并重新初始化(如重连日志平台)
flowchart TD
S[启动] --> L1[第一次加载: 连配置中心+日志]
L1 --> L2[第二次加载: 全量配置]
L2 --> I[初始化 DB/Redis/...]
I --> R[若远程覆盖, 重连日志平台]3.4 viper:读取本地配置
viper 是 Go 里 star 数最高的配置库:
go get github.com/spf13/viper
3.4.1 基本用法
viper.SetConfigName("config") // 文件名(不含扩展名)
viper.SetConfigType("yaml") // 文件类型
viper.AddConfigPath("./config") // 查找路径
viper.AddConfigPath(".") // 也可以多个路径
err := viper.ReadInConfig()
if err != nil {
panic(err)
}
注意 working directory:viper 从 Go 的 working directory 开始定位。如果在 GoLand 中跑,要确认 working directory 设置正确(一般是项目根目录)。
3.4.2 推荐写法:UnmarshalKey 到结构体
type DBConfig struct {
DSN string `mapstructure:"dsn"` // 直接用 DSN,不拆账号密码
}
func InitDB() *gorm.DB {
// 步骤 1:定义目标结构体
var cfg DBConfig
// 步骤 2:按 key 反序列化
err := viper.UnmarshalKey("db", &cfg)
if err != nil {
panic(err)
}
// 步骤 3:用 DSN 连库
db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{})
if err != nil {
panic(err)
}
return db
}
为什么不直接 viper.GetString("db.dsn")?因为:把相关配置收到一个结构体里更清晰;后续扩展字段方便;类型安全。
3.4.3 默认值
// 方式一:viper.SetDefault
viper.SetDefault("db.dsn", "localhost:3306")
// 方式二(推荐):结构体字段设默认值
type DBConfig struct {
DSN string `mapstructure:"dsn"`
}
func InitDB() *gorm.DB {
cfg := DBConfig{DSN: "localhost:3306"} // 默认值
_ = viper.UnmarshalKey("db", &cfg)
// 如果配置文件没有 db.dsn,cfg.DSN 仍是 localhost:3306
}
推荐方式二,因为默认值放在业务相应的地方。
3.4.4 直接传 io.Reader
viper.SetConfigType("yaml")
viper.ReadConfig(strings.NewReader(`
db:
dsn: "root:root@tcp(localhost:3306)/webook"
`))
测试或本地调试时方便,不用写配置文件。
3.5 不同环境加载不同配置
公司一般有几个环境:
- 开发环境(本地)
- 测试环境(测试人员用)
- 线上环境
viper 不内置这种支持。常见做法是启动时传入配置文件路径:
// 启动时:go run . --config=./config/dev.yaml
func InitConfig() {
configPath := flag.String("config", "config/dev.yaml", "配置文件路径")
flag.Parse()
viper.SetConfigFile(*configPath)
if err := viper.ReadInConfig(); err != nil {
panic(err)
}
}
3.6 远程配置中心:etcd
3.6.1 为什么需要
配置文件的缺点:不够灵活,难以接入加密解密、权限控制、实时更新。所以可以用远程配置中心。
3.6.2 用 Docker Compose 启动 etcd
# docker-compose.yaml
services:
etcd:
image: bitnami/etcd:latest
environment:
- ALLOW_NONE_AUTHENTICATION=yes # 开发环境不设密码
ports:
- "2379:2379"
3.6.3 用 viper 接入 etcd
import (
"github.com/spf13/viper"
_ "github.com/spf13/viper/remote" // 别忘了匿名引入 remote 包
)
func InitConfig() {
viper.SetConfigType("yaml")
err := viper.AddRemoteProvider("etcd", "localhost:2379", "/webook")
if err != nil {
panic(err)
}
err = viper.ReadRemoteConfig()
if err != nil {
panic(err)
}
}
3.6.4 在 etcd 中写入配置
# 直接读取 yaml 文件作为参数
etcdctl put /webook -- < config/dev.yaml
3.6.5 监听配置变更
viper.WatchConfig() // 监听文件变更
viper.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("配置变更:", e.Name)
})
// 监听远程配置
viper.WatchRemoteConfig()
应用场景:用配置做功能开关。新功能上线时开启,出问题立刻关闭,不用重新部署。
3.7 较好的实践
3.7.1 使用抽象的配置 API
不要在业务代码里直接操作 etcd API,否则应用和 etcd 耦合。借 viper 的 API 屏蔽底层实现差异(文件、etcd、Nacos)。如果担心 viper 不够好,可以照 viper API 抄一个公司内部统一的配置 API。
3.7.2 配置操作限定在初始化过程中
把配置操作放在 IoC 和 main 函数中。这样从 viper 换到别的框架时,只改初始化过程,别的代码不动。
缺点:如果要在 Service 层监听配置项变更,就违背了这个原则。
3.8 升职加薪:在公司引入远程配置中心
可以为公司提供:
- 统一配置接口
- Web 界面
- 权限控制(某部门只能读某路径下的配置)
- 版本控制(快速回滚)
- 环境控制(快速在环境间同步配置)
- 变更流程和审批流程
- 灰度发布
这些看起来很高级,实际上全是增删改查,前端写不出来而已。
四、日志模块
4.1 为什么需要日志
到现在我们都没用日志模块,缺点:
- 无法确认系统状态,出问题都不知道
- 出现问题时难以定位
4.2 日志级别
| 级别 | 含义 | 线上是否打印 |
|---|---|---|
| DEBUG | 辅助排查 | 一般不打印 |
| INFO | 中性描述发生了什么 | 打印 |
| WARN | 不好的事,但可容忍 | 打印 |
| ERROR | 需要关注的事 | 打印 |
注意:除非刷 KPI,否则不要引入很多级别。每多一级别,多一份学习成本。
4.3 什么时候打日志?打什么级别?
一个原则:如果你怀疑这个地方要不要打日志,那就打上。
个人习惯:
- 用 AOP 机制记录跟第三方交互的请求/响应(数据库、缓存、RPC),用 DEBUG
- 用 AOP 记录系统收到的请求和自己返回的响应,用 DEBUG
- 开发阶段用 DEBUG 记录关键中间结果
- 怀疑可能有问题但不严重的地方,记 WARN(频繁 WARN 才去看)
- 不可能出问题或有人攻击的地方,记 ERROR
宁滥勿缺。
4.4 接入 zap
go get -u go.uber.org/zap
// 初始化全局 Logger
logger, err := zap.NewDevelopment()
defer logger.Sync()
zap.ReplaceGlobals(logger)
4.4.1 基本打印
// ERROR:传 error
zap.L().Error("发送短信失败",
zap.Error(err),
zap.String("biz", "login"))
// WARN:短信发送太频繁
zap.L().Warn("短信发送太频繁", zap.Error(err))
// INFO:用户首次登录
zap.L().Info("新用户注册", zap.Int64("uid", u.Id))
关键细节:
- 绝对不会把 error 暴露给前端
- 敏感信息(手机号、密码)不能打日志
- 跟第三方交互的 req/resp 含手机号,只有线上不开 DEBUG 时才可打
4.5 不使用 zap 包变量(保持依赖注入)
zap.L() 是包变量,大部分中小型应用没问题。但一旦同一项目要用多个 Logger(如 user 相关日志用更安全的存储),就得全改一遍。
// 推荐写法:依赖注入
type UserService struct {
logger *zap.Logger
}
func NewUserService(repo repository.UserRepository, logger *zap.Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}
简化版(注了但没完全注):
type UserService struct {
logger *zap.Logger
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{
repo: repo,
logger: zap.L(), // 仍用全局,但封装在 New 里,将来改只改这里
}
}
4.6 抽象日志 API
直接用 zap 会强耦合,将来想换就换不动。所以可以抽象自己的日志 API。
4.6.1 风格一:经典 printf 风格
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
}
要求用户在 msg 里留占位符。
4.6.2 风格二:zap 风格(参数有名字)
type LoggerV1 interface {
Debug(msg string, fields ...Field)
Info(msg string, fields ...Field)
Warn(msg string, fields ...Field)
Error(msg string, fields ...Field)
}
type Field struct {
Key string
Value any
}
// 提供辅助方法 zap.String、zap.Int64 等
对日志分析更友好。
4.6.3 风格三:参数必须是偶数
// args 必须是偶数,假设参数都有名字
type LoggerV2 interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
}
折中方案,但没编译器检查,用户不按套路出牌没办法。
4.6.4 选用哪种
- 兼容性最好:
Logger - 认同参数要有名字:
LoggerV1 - 有完善 code review:
LoggerV2(否则不建议)
4.6.5 用 LoggerV1 封装 zap(适配器模式)
type ZapLogger struct {
l *zap.Logger
}
func NewZapLogger(l *zap.Logger) *ZapLogger {
return &ZapLogger{l: l}
}
func (z *ZapLogger) Debug(msg string, fields ...Field) {
// 步骤 1:把自定义 Field 转成 zap.Field
z.l.Debug(msg, z.toZapFields(fields)...)
}
func (z *ZapLogger) toZapFields(fields []Field) []zap.Field {
// 步骤 2:逐个转换
res := make([]zap.Field, 0, len(fields))
for _, f := range fields {
res = append(res, zap.Any(f.Key, f.Value))
}
return res
}
缺点:参数转换有额外内存分配和 CPU 消耗。这就是适配器模式。
4.7 适配器模式 vs 装饰器模式
| 模式 | 接口 | 用途 |
|---|---|---|
| 装饰器 | 同一接口 | 加新特性,不改变接口 |
| 适配器 | 不同接口 | 把 A 接口适配到 B 接口 |
4.8 在系统出入口记录日志
- 入口:系统收到请求、返回响应
- 出口:调用第三方
4.8.1 利用 Gin middleware 打日志
type AccessLog struct {
Method string
URL string
Body string
Resp string
}
func AccessLogger(logFn func(l AccessLog)) gin.HandlerFunc {
return func(ctx *gin.Context) {
// 步骤 1:读取请求 body(并还原,避免后续读不到)
body, _ := io.ReadAll(ctx.Request.Body)
ctx.Request.Body = io.NopCloser(bytes.NewReader(body))
// 步骤 2:包装 ResponseWriter,记录响应
respWriter := &responseWriter{ResponseWriter: ctx.Writer}
ctx.Writer = respWriter
// 步骤 3:执行下一个
ctx.Next()
// 步骤 4:打日志
logFn(AccessLog{
Method: ctx.Request.Method,
URL: ctx.Request.URL.String(),
Body: string(body),
Resp: respWriter.data.String(),
})
}
}
漏洞:没控制住 AccessLog 大小,攻击者可传巨长 URL 或巨大请求。
⚠️ 新手必踩的坑:记录请求/响应要防巨量 body。上面中间件直接把整个
body和resp写进日志,攻击者传一个 100MB 的 POST body 就能把磁盘打满。生产要截断(如只保留前 1KB)并限制单条日志大小。
4.8.2 GORM 打日志
GORM 自带日志配置,但用起来不方便:
gormLoggerFunc := glogger.NewFunc(func(sql string, args ...any) {
zap.L().Debug("SQL", zap.String("sql", sql), zap.Any("args", args))
}, glogger.Config{...})
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormLoggerFunc,
})
4.8.3 其他第三方
- 腾讯云短信 SDK:没暴露 AOP 接口,手动打
redis.Cmdable:可以通过封装接口打,但有上百个方法,一个个实现要命http.Client:没暴露 AOP 接口,手动打
没有AOP 机制且没有接口 = 垃圾。
4.9 打印日志技巧总结
- 宁滥勿缺,宁多打不少打
- 优先用 AOP 机制打
- 没 AOP 用装饰器
- 百万 QPS 之前,不要考虑打印日志的开销问题。打,狠狠打,往死里打!
4.10 升职加薪:为公司设计日志规范
要考虑:
- 什么情况打什么级别
- 什么情况打了什么日志就要告警
- 怎么保证看到日志能快速定位问题并修复
在规范严谨和易于推行之间取平衡。过于严苛推行不了;过于容易效果有限。建议:找一个大厂的日志规范,结合公司工程素养做削减适配。
4.11 面试:日志作为可观测性的一环
日志属于可观测性(Observability)范畴。可观测性是改善性能和可用性的前提——必须先观测到问题,才能优化。
话术:
我在接手系统时发现性能和可用性很差。准备分析问题时,发现可观测性做得非常差——零散、缺乏标准、不够完备。为此我先执行了日志规范……
五、工程实践要点
5.1 安全相关
- OAuth2 流程必须校验 state,否则有 CSRF 风险
- 长 token 应该有保护:User-Agent 绑定 / 一次性使用
- 退出登录必须用 ssid + Redis 黑名单
- 敏感信息(手机号、密码)不能打日志
5.2 降级策略
- Redis 挂了不严格校验 ssid(保证大多数用户可用)
- 配置中心挂了用本地配置兜底(如果有)
5.3 配置管理
- 优先配置文件
- 大规模集群再上远程配置中心
- 配置操作限定在初始化过程中
- 用 viper API 屏蔽底层差异
5.4 日志
- 宁滥勿缺
- 优先 AOP / 装饰器
- 百万 QPS 之前不考虑开销
自测题与动手练习
自测题(合上书能答出来,才算懂):
- OAuth2 授权码流程里,为什么浏览器先拿到的是
code而不是直接拿access_token?code和access_token分别走什么通道? open_id和union_id的区别是什么?跨产品(A、B 两个 App)识别同一用户该用哪个?- 不校验
state会出什么安全问题?服务端要怎么"存 + 比"才能防住 CSRF? - 为什么长短 token 方案里,刷新必须由前端触发、而不能在 middleware 里自动续期?
- JWT 是无状态的,怎么实现"退出登录"?
ssid + Redis 黑名单方案的降级策略是什么、为什么必须降级?
动手练习(建议真做一遍):
- 画 + 讲 OAuth2:不看任何资料,独立画出授权码流程时序图,并讲清每一步是谁调用谁、参数怎么传。
- 实现并验证 state 校验:给
Callback接上CheckState;写一个测试,模拟"攻击者带别人 code + 正常用户 Cookie"请求,确认返回"非法请求"。 - ssid 退出演练:本地起 Redis,实现
Logout/CheckSession;退出登录后确认后续请求被拒;再手动redis-cli shutdown,确认降级下已登录用户仍可用。
本章小结
第5章围绕"登录与运维基础设施"展开,从单点登录讲到日志系统,覆盖了一个 Go 后端核心的"非业务"能力。
关键回顾:
- SSO/OAuth2:微信扫码登录本质是 OAuth2 授权码流程,关键是 code→access_token 两步走,state 防 CSRF
- 长短 token:短 token 频繁使用易泄露,长 token 只在刷新时用,前端触发刷新而非后端自动续期
- JWT 退出登录:JWT 无状态无法主动作废,用 ssid + Redis 黑名单方案;Redis 挂了用降级策略
- 配置模块:来源(启动参数/环境变量/文件/远程中心),优先级(命令行 > 环境变量 > 文件 > 远程),两次加载(基础配置 + 全量配置)
- 日志:四大级别(DEBUG/INFO/WARN/ERROR),宁滥勿缺,优先 AOP,抽象自己的 Logger 接口(适配器模式封装 zap)
掌握这些内容,你已经具备了完整的登录鉴权能力和初步的可观测性建设能力,可以独立搭建一套生产可用的认证体系。下一章(第06章)进入发帖功能与缓存,把内容生产链路和缓存设计讲透。