服务端模块实现教程(HTTP/gRPC/中间件)

2025-08-07T14:11:02+08:00 | 37分钟阅读 | 更新于 2025-08-07T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 讲清 Kratos 分层与统一抽象:说出 transport → middleware → service → biz → data 各层职责,并解释 transport.Server 这个统一抽象为什么能让 HTTP、gRPC、定时任务、Kafka 消费者被同一套生命周期编排。
  2. 解释 Wire 编译期注入:说明 Wire 与运行时反射容器(如 dig)的区别,以及"依赖图有环或缺失直接编译报错"带来的好处。
  3. 设计洋葱模型中间件链:给 recovery / CORS / 限流 / 耗时 / 校验 / 缓存控制 / JWT 鉴权 7 个中间件排一个正确顺序,并讲清"为什么 recovery 最外层、JWT 最内层、CORS 必须早于 JWT(否则预检被拦)",以及"为什么限流要放在鉴权之前(防 auth 接口被刷)"。
  4. 读懂 JWT 鉴权中间件:说清白名单匹配、Bearer token 提取、用 X-Real-IP 而非 X-Forwarded-For 首值取客户端 IP、签名+过期+黑名单校验、自定义 context key 注入这四步。
  5. 讲清优雅启停:描述 kratos.App 收到 SIGINT/SIGTERM 后的启停顺序(逆序 Stop、等待在途请求完成),以及定时任务/Kafka 消费者如何"伪装成 transport.Server"复用这套机制。
  6. 按商用标准补齐上线能力:说出健康检查 /healthz /readyz(探 DB/Redis)、CORS 白名单、Content-Security-Policy 等安全头、TLS 在 nginx 终止并启用 HSTS、/api/ 请求体上限、密钥未配置即 fail-fast、用版本化迁移替代 AutoMigrate、统一错误响应不泄露内部细节,分别该放在哪一层、怎么改。

前置知识:

  • Go 语言基础(接口、context、defer/recover)。
  • 微服务与 gRPC / HTTP 双协议的基本概念。
  • 服务注册发现(etcd)的基本作用(本章用到但不展开)。
  • 建议先回看「用户模块」一章的 5.10 / 5.11(统一错误响应、安全头、密钥 fail-fast、健康检查)。

本章你会动手做的事:

  1. 读一遍 wire.go 与 wire_gen.go,理解 9 个 ProviderSet 是怎么被展开成线性初始化代码的。
  2. 把 NewHTTPServer 的中间件顺序故意调乱(如把 JWT 放到 CORS 之前),思考 OPTIONS 预检请求会被哪一层拦下、前端为什么会跨域失败。
  3. 在本地不配 etcd 的情况下启动 main,验证"配置为空 → registrar 为 nil → 不挂注册器"的空值防御链路;并故意不设置 JWT_SECRET,验证进程是否按 fail-fast 拒绝启动。
服务端模块统一抽象与装配transport.Server 统一生命周期Wire 编译期注入洋葱中间件链recovery 最外·JWT 最内CORS 早于 JWT·限流先于鉴权JWT 鉴权中间件Bearer+黑名单+版本号自定义 context key·白名单优雅启停与上线SIGTERM 逆序 Stop健康检查·安全头·fail-fast

一、技术栈与中间件

服务端模块是整个云盘的"启动入口"和"流量入口",承担协议适配、依赖装配、生命周期管理三大职责。下表汇总本模块用到的全部技术与中间件:

技术 / 中间件所属层用途说明
Go Kratos v3框架骨架提供 kratos.App 生命周期管理、HTTP/gRPC 双协议 transport.Server、middleware.Middleware 链式模型、统一 errors 错误规范、transport.FromServerContext 上下文取值等
Google Wire依赖注入编译期生成 wire_gen.go,把 server / data / biz / service / event / cache / lock / storage / scheduler 等多个 ProviderSet 装配为最终 *kratos.App,无运行时反射开销
Kratos transport/httpHTTP 服务器创建 *khttp.Server,注册 proto 自动生成的 HTTP 路由 + 手动 srv.Route("/").Handle(...) 注册流式 / 批量接口,并挂载 7 层中间件链
Kratos transport/grpcgRPC 服务器创建 *grpc.Server,预留 gRPC 协议入口(当前只挂载 recovery.Recovery())
Kratos middleware/recovery双协议中间件官方 panic 恢复中间件,gRPC 服务器专用;HTTP 侧用自研 recoveryMiddleware
CORSMiddlewareHTTP 中间件自研跨域处理:按白名单配置 Access-Control-Allow-Origin,正确处理 OPTIONS 预检并返回 204,绝不 *+credentials(修正点:源版缺失)
JWTAuthMiddlewareHTTP 中间件自研 JWT 鉴权:解析 Authorization: Bearer xxx,调用 TokenManager.VerifyToken 校验签名 + 黑名单(+版本号,见用户模块),将 user_id / username / token 注入 自定义类型 context key,支持白名单(修正点:源版用裸字符串 key,存在冲突风险)
recoveryMiddlewareHTTP 中间件自研 panic 恢复:defer recover() 捕获 goroutine panic,统一返回 InternalServer 500 错误,避免进程崩溃
TimingMiddlewareHTTP 中间件自研耗时监控:记录请求 op 与 duration_ms,输出到日志用于性能分析(修正点:亿级流量应升级为 metrics 直方图)
validateMiddlewareHTTP 中间件自研请求校验:拦截 nil 请求体,返回 BadRequest 400
CacheControlMiddlewareHTTP 中间件设置响应头 Cache-Control: no-store 等,防止浏览器缓存敏感数据
RateLimitMiddlewareHTTP 中间件自研基于客户端 IP 的滑动窗口限流,默认 600 次/分钟,惰性清理过期条目避免内存泄漏(修正点:源版取 X-Forwarded-For 首值、且为进程内存式;生产应取 X-Real-IP + 升级为 Redis 分布式 + 按账号锁定,见用户模块)
golang-jwt/jwt v5鉴权由 biz.TokenManager 封装,HS256 签名,支持 ParseUnverified 提取 JTI 用于黑名单
etcd v3 client / Kratos etcd registry服务注册NewEtcdClient 创建 etcd 客户端,NewEtcdRegistrar 创建服务注册器,TTL 15s,命名空间 /microservices
go.uber.org/automaxprocs运行时自动将 GOMAXPROCS 设为容器 CPU 配额,避免 cgroup 限制下的 P99 抖动
scheduler.ScheduledTaskServer定时任务实现 transport.Server 接口,把存储校准、回收站清理、孤儿分片清理等 Cron 任务纳入 Kratos 生命周期统一启停
event.ConsumerServer消息消费实现 transport.Server 接口,把 Kafka 消费者纳入 Kratos 生命周期,启动时消费、停止时优雅退出
/healthz · /readyzHTTP 健康检查存活 / 就绪探针,存活只探进程、就绪探 DB + Redis,供 K8s / 负载均衡探活(修正点:源版后端无此端点,需新增)

二、实现思路流程(总体)

服务端模块的整体实现遵循「Wire 装配 → 多 Server 构造 → Kratos App 编排 → 优雅启停 → 反向代理终止 TLS」的主线,端到端流程如下:

类比:把服务端想象成一家餐厅的开业准备——先按清单把厨房设备(Wire 装配依赖)一一就位,再分别开起前厅(HTTP)、后厨窗口(gRPC)、定时盘点(定时任务)、外卖接单(Kafka 消费者)几个工位,最后由店长(Kratos App)统一喊"开门营业 / 打烊收摊";门口还站着保安(nginx:TLS 终止、CORS、安全头、限流兜底),健康灯(/healthz /readyz)亮着才对外接客。

下面这张图就是这条主线的全貌:

flowchart TB
    A[Wire 依赖注入
9个ProviderSet装配] --> B[构造多 Server] B --> B1[HTTP Server
7层中间件链 + /healthz /readyz] B --> B2[gRPC Server
协议预留骨架] B --> B3[定时任务 Server
伪装transport.Server] B --> B4[Kafka 消费者 Server
伪装transport.Server] B1 --> C[Kratos App 统一编排] B2 --> C B3 --> C B4 --> C C --> D[app.Run 启动
注册etcd+监听信号] D --> E[收到SIGINT/SIGTERM
逆序Stop优雅退出 等待在途请求] E --> F[defer cleanup
关闭DB/Redis/Kafka/etcd] N[nginx 反向代理
TLS终止/HSTS/CORS/安全头/限流] --> B1
  1. Wire 依赖注入组装

    • cmd/server/wire.go 使用 //go:build wireinject 构建标签声明注入入口 wireApp,调用 wire.Build(...) 把 server.ProviderSet、data.ProviderSet、cache.ProviderSet、lock.ProviderSet、taskProviderSet、storage.ProviderSet、event.ProviderSet、biz.ProviderSet、service.ProviderSet 以及 newServers / newApp / newBizCache / newBizLocker 等构造器全部纳入注入图。
    • 执行 go generate 后由 Wire 在 wire_gen.go 生成完整的、线性的、可读的初始化代码:先建 TokenManager,再建 Data(MySQL/Redis/Kafka),逐层向上构造 Repo → Usecase → Service → Server,最后装配 *kratos.App 并返回 cleanup 函数。修正点:构造 TokenManager 时若 jwt_secret 未配置必须直接 log.Fatalf 退出,不能回退到硬编码默认值(见 5.8)。
  2. HTTP 服务器初始化(中间件链)

    • NewHTTPServer 通过 khttp.Middleware(...) 一次性挂载 7 个中间件,顺序为:recoveryMiddleware → CORSMiddleware → RateLimitMiddleware(600, time.Minute) → TimingMiddleware → validateMiddleware → CacheControlMiddleware → JWTAuthMiddleware(tm, whitelist...)。
    • 顺序设计遵循「先兜底(recovery)→ 再跨域(CORS 早处理预检)→ 再限流(防刷、护 auth 接口)→ 再监控 → 再校验 → 再缓存控制 → 最后鉴权」的分层防护原则。修正点:CORS 必须早于 JWT,否则浏览器 OPTIONS 预检不带 token 会被 JWT 中间件直接 401,导致前端跨域全部失败。
    • 通过 userv1.RegisterUserServiceHTTPServer(srv, us) 等 proto 生成函数自动注册标准 RESTful 路由,再通过 srv.Route("/").Handle(...) 手动注册 4 个特殊接口(用户存储统计、批量软删除、文件流式预览、健康检查)。
  3. gRPC 服务器初始化

    • NewGRPCServer 通过 grpc.Middleware(recovery.Recovery()) 挂载官方 recovery 中间件,再根据配置 c.Grpc.Network/Addr/Timeout 设置监听参数。
    • 当前版本 gRPC 仅作协议预留(注释 gRPC 服务注册将在后续迭代中添加),但骨架已就绪,后续可直接通过 filev1.RegisterFileServiceServer(srv, fs) 等接口注册 gRPC 服务,并追加 JWT 中间件(注意届时也要按白名单放行 gRPC 反射/健康检查)。
  4. 多 Server 合并(newServers)

    • newServers 把 grpcServer、httpServer、scheduledTaskServer、consumerServer 4 个实现了 transport.Server 接口的组件组装为 []transport.Server 切片。
    • 关键设计:定时任务服务器和 Kafka 消费者服务器都"伪装"成 transport.Server,这样就能复用 Kratos App 的统一启停机制,避免在 main 中写多份 goroutine 启停代码。
  5. Kratos App 生命周期管理(newApp)

    • newApp 接收 logger、servers 切片、registrar,通过 kratos.ID/Name/Version/Metadata/Logger/Server/Registrar 等 Option 创建 *kratos.App。
    • app.Run() 内部会:注册服务到 etcd → 启动所有 transport.Server 的 Start() → 监听 SIGINT/SIGTERM → 收到信号后调用所有 Server 的 Stop()(HTTP 走 http.Server.Shutdown,停止接收新连接并等待在途请求处理完)→ 调用 cleanup 函数关闭数据库/Redis/Kafka/etcd 连接。
  6. 优雅启停

    • main 中 defer cleanup() 确保 wireApp 返回的清理函数(关闭 etcd、MySQL、Redis、Kafka writer)在 app.Run() 返回后执行。
    • Kratos 自身监听系统信号,确保在收到 Ctrl+C 或 kill 时先停止接收新请求、等待在途请求处理完再退出,实现零中断发布。修正点:docker / K8s 部署还应配置 stop_grace_period / terminationGracePeriodSeconds(如 30s),确保 SIGTERM 等待窗口足够长,不会在在途请求未完成时被强杀。

三、面试常问知识点与难点

1. Kratos 微服务框架架构

Kratos 采用「传输层(transport)→ 中间件层(middleware)→ 服务层(service)→ 业务层(biz)→ 数据层(data)」的分层模型。transport.Server 是统一的 server 抽象,HTTP 和 gRPC 都实现该接口,从而可以被 kratos.App 统一编排。中间件以 func(Handler) Handler 的函数式装饰器模式串联,支持跨协议复用。

2. Wire 编译期依赖注入原理

Wire 与 uber-go/dig 不同,它在编译期通过代码生成把依赖图展开为线性的初始化代码(wire_gen.go),没有运行时反射、没有容器查找开销。开发者只需在 wire.go 中声明 wire.Build(provider1, provider2, ...),Wire 会根据每个 provider 的参数类型和返回值类型自动推导依赖关系,若依赖图存在环或缺失会直接编译报错,把运行时错误提前到编译期。

3. HTTP 与 gRPC 双协议支持

Kratos 通过 proto 定义一次服务接口,通过 protoc-gen-go-http 和 protoc-gen-go-grpc 生成两套 server 接口,业务代码(service 层)只写一份即可同时服务 HTTP 和 gRPC。本项目 NewHTTPServer / NewGRPCServer 都接收同一组 *service.XxxService,体现了「一份业务,多协议出口」的设计。

4. 中间件链设计模式(洋葱模型)

Kratos 中间件本质是装饰器模式:Middleware = func(Handler) Handler。多个中间件通过 khttp.Middleware(a, b, c) 串联后形成洋葱模型,请求从外到内依次执行 a_before → b_before → c_before → handler → c_after → b_after → a_after。这种设计让横切关注点(鉴权、限流、日志)与业务代码完全解耦。商用顺序要点:recovery 必须最外层(兜住一切 panic);CORS 必须早于 JWT(否则 OPTIONS 预检撞 JWT 被 401);限流放在鉴权之前,既能挡住对登录等匿名接口的血肉(防爆破),又避免无谓的 JWT 计算。

5. JWT 无状态鉴权

JWT 由 Header、Payload、Signature 三段组成,服务端用 secret 对前两段做 HMAC-SHA256 签名,验签时无需查询数据库(无状态)。本项目用 golang-jwt/jwt v5,自定义 Claims 包含 user_id 和 username,签发时还生成 JTI(JWT ID)作为唯一标识,用于黑名单精确失效。黑名单以 blacklist:token:{jti} 为 key 存 Redis,TTL 等于 token 剩余有效期,自然过期自动清理。关于「令牌版本号(token_version)解决改密/封禁集体失效」的完整机制,见用户模块 5.3 / 5.4,服务端中间件只需调用 VerifyToken 即可共享全部撤销能力。

6. panic 恢复中间件

Go 语言中一个 goroutine panic 会导致整个进程崩溃。recoveryMiddleware 用 defer func() { if r := recover(); r != nil {...} }() 捕获 panic,把 panic 转为 errors.InternalServer 错误返回,同时用 log.Error 记录堆栈,保证单个请求的异常不影响其他请求和进程稳定性。

7. 优雅启停(Graceful Shutdown)

Kratos App 内部监听 SIGINT/SIGTERM,收到信号后调用所有 transport.Server 的 Stop(ctx) 方法,HTTP 服务器会调用 http.Server.Shutdown 停止接受新连接并等待在途请求完成。本项目还把定时任务和 Kafka 消费者也封装为 transport.Server,从而实现"一份生命周期代码管所有组件"。注意:K8s 的 terminationGracePeriodSeconds 必须 ≥ 在途请求最长耗时,否则 kubelet 会发 SIGKILL 强杀,优雅退出形同虚设。

8. 限流算法(滑动窗口)与客户端 IP 取值

RateLimitMiddleware 采用固定窗口 + 惰性清理的简化版滑动窗口:以 client IP 为 key,记录 count 和 windowStart,窗口内累计请求超过 maxRequests(默认 600/分钟)返回 TooManyRequests。清理策略是每隔 window/2 时间全量扫描 map 删除过期条目,避免后台 goroutine,也避免高频请求时每次都扫描的开销。关键修正:客户端 IP 绝不能信任 X-Forwarded-For 的首值(它可由客户端伪造、且经多层代理累加),必须取自反向代理(nginx)基于 $remote_addr 填充的 X-Real-IP。多实例与防单账号撞库场景,应升级为「Redis + Lua 分布式限流 + 账号失败锁定」(见用户模块 5.6 / 5.10)。

9. 服务注册发现(etcd)

NewEtcdRegistrar 用 etcd.New(client, etcd.Namespace("/microservices"), etcd.RegisterTTL(15*time.Second)) 创建注册器。服务启动时把 kratos.App 的 ID/Name/Version/Addr 写入 etcd 的 /microservices/{name}/{id} key,TTL 15s,通过 keepalive 续约;服务停止时 key 自动过期,调用方通过 watch etcd 即可感知上下线,实现服务发现。

10. CORS 与浏览器预检(商用新增)

跨域资源共享(CORS)是浏览器对"网页向不同源发起请求"的安全机制。简单请求(如 GET + 少数头)直接发;而带 Authorization、自定义头或非简单方法的请求,浏览器会先发一个 OPTIONS 预检,后端必须返回 Access-Control-Allow-Origin / Allow-Methods / Allow-Headers 并给 204,真实请求才会发出。致命坑:预检请求不带 Authorization token,若 CORS 中间件排在 JWT 之后,预检会被 JWT 中间件判为"缺 token"直接 401,前端所有跨域调用一律失败。另一致命坑:Access-Control-Allow-Origin: * 与 Access-Control-Allow-Credentials: true 不能共存,否则任何网站都能带用户 cookie 调用你的接口。商用必须按白名单返回具体来源、凭据仅在可信源开启。

11. 健康检查与就绪探针(商用新增)

容器编排(K8s / Docker Compose)靠"探针"判断实例能否接流量:/healthz(liveness)只探进程是否活着,失败会被重启;/readyz(readiness)探依赖(DB / Redis)是否可用,失败则把实例从负载均衡摘除但不重启。二者分离很关键——DB 抖动时 readyz 失败让流量绕行,进程仍存活待恢复;若混为一谈,短暂依赖故障也会触发无谓重启。

12. 密钥外置与 fail-fast(商用新增)

JWT 密钥、DB 密码、Redis 密码等敏感配置必须从环境变量 / 密钥管理(Vault / KMS)注入,绝不允许硬编码默认值。校验原则:缺失强密钥时进程必须 log.Fatalf 立即退出(fail-fast),否则攻击者可用默认密钥伪造任意令牌,等于把门钥匙贴在门上。

13. 版本化迁移 vs AutoMigrate(商用新增)

db.AutoMigrate 适合本地开发,但生产环境有风险:它每次启动对比 struct 自动加列,无法表达"删除列 / 改类型 / 数据回填",且多实例并发启动时可能竞争改表、造成结构漂移。商用应使用 golang-migrate / Atlas 等版本化迁移:迁移脚本随代码入库、带版本号、只前向执行、可在 CI 中预检,结构变更可评审、可回滚。

14. 统一错误响应与信息泄露(商用新增)

对外错误响应只应包含稳定 code 与用户可懂的 message,绝不能把 err.Error()(可能含 SQL 语句、文件路径、堆栈)直接返回前端。对未知(非 Kratos)错误统一回 internal server error,真实原因仅入日志供排查。这既防信息泄露,也避免把内部实现细节暴露给攻击者。


四、亿级流量优化思路

服务端模块在亿级流量场景下,需要从协议层、中间件层、注册发现层、生命周期层、部署层多维度优化:

  1. gRPC 连接池与多路复用:gRPC 基于 HTTP/2,单 TCP 连接支持多路复用,但默认连接数有限。客户端应通过 grpc.WithDefaultCallOptions + KeepaliveParams 调优,服务端通过 grpc.MaxConcurrentStreams 提高单连接并发流数,减少 TCP 连接数。

  2. HTTP 长连接 + 连接复用:调整 http.Server 的 IdleTimeout、ReadHeaderTimeout,启用 Keep-Alive,避免每次请求都握手。反向代理层(Nginx/Envoy)也要开启 upstream 长连接,并把 proxy_http_version 设为 1.1。

  3. 中间件性能监控与采样:TimingMiddleware 当前每个请求都打日志,亿级流量下日志本身会成为瓶颈。优化方向:用 metrics(Prometheus histogram)替代日志,按 op 维度聚合 P50/P95/P99;对健康检查等高频接口采样打日志。

  4. 限流熔断降级:当前 RateLimitMiddleware 是单机内存限流,亿级流量下需升级为分布式限流(Redis + Lua 滑动窗口 / 令牌桶),多实例共享计数;并对登录/重置等接口按「账号 + IP」维度失败锁定(见用户模块)。同时引入熔断器(如 sony/gobreaker),下游依赖(MySQL/Redis/MinIO)故障时快速失败,避免雪崩。关键接口(预览、下载)做降级,返回兜底图或限流提示。

  5. 服务注册发现优化:etcd 注册的 TTL 当前 15s,亿级流量下可能感知过慢。优化:缩短 TTL 到 5s,配合 etcd watch 实现秒级上下线感知;客户端做本地缓存 + health check,避免每次请求都查 etcd。

  6. 负载均衡:gRPC 默认 round-robin,亿级流量下应根据后端负载(CPU、连接数、延迟)做加权负载均衡(grpc.WithBalancerConfig 自定义 picker)。HTTP 层通过 Nginx least_conn 或一致性哈希(按 user_id 哈希到同一节点,提升本地缓存命中率)。

  7. 连接复用与对象池:TokenManager.VerifyToken 每次都 jwt.ParseWithClaims 会产生中间对象,高频接口可用 sync.Pool 复用 Claims 结构体;Redis 操作复用连接池,避免频繁建连。

  8. goroutine 与 GOMAXPROCS:本项目通过 _ "go.uber.org/automaxprocs" 自动设置 GOMAXPROCS 为容器 CPU 配额,避免 Kubernetes 环境下默认值过大导致调度抖动。亿级流量下还需关注 runtime.GOMAXPROCS、GOGC、GOMEMLIMIT 的协同调优。

  9. 异步化与批量化:当前 TimingMiddleware 同步打日志、限流同步加锁。优化:日志异步化(channel + batch flush),限流计数用 atomic 替代 mutex(单机场景),或 Redis + Lua(分布式场景)。

  10. 优雅启停的连接排空:亿级流量下停止时在途请求量大,需要更长的 drain 时间。优化:app.Run() 前先从 etcd 注销(让 LB 不再转发新流量),等待几秒排空在途请求,再调用 Stop;同时 K8s terminationGracePeriodSeconds 设为 30s 以上,HTTP 服务器设置 Shutdown(ctx) 的超时 context,避免无限等待。

  11. 边缘安全兜底:CORS、安全响应头、请求体上限、基础限流尽量在 nginx / 网关层完成,让后端只处理合法流量,减少无效计算与攻击面。


五、详细实现流程与代码解析

5.1 Wire 依赖注入组装(ProviderSet + wireApp + wire_gen)

实现思路

Wire 依赖注入分三步:

  1. 各层声明 ProviderSet:把该层所有的构造函数注册到一个 wire.ProviderSet,例如 server.ProviderSet 注册了 NewGRPCServer、NewHTTPServer、NewEtcdClient、NewEtcdRegistrar。
  2. wire.go 声明注入入口:用 //go:build wireinject 构建标签隔离,调用 wire.Build(...) 把所有 ProviderSet 和额外的构造器(newServers、newApp、newBizCache、newBizLocker)传入,Wire 会自动推导依赖图。
  3. wire_gen.go 是 Wire 生成的可执行代码:用 //go:build !wireinject 隔离,里面是线性的、可读的初始化代码,main 直接调用 wireApp(...) 即可。

关键代码

internal/server/server.go:声明 server 层的 ProviderSet。

package server

import (
	"github.com/google/wire"
)

// ProviderSet 是 server 层的依赖注入集合。
// wire.NewSet 把构造函数注册为一个 ProviderSet,
// Wire 会根据它们的参数类型和返回值类型自动推导依赖关系。
// 修正点:新增 CORSMiddleware 的构造已并入 NewHTTPServer,无需单独 Provider。
var ProviderSet = wire.NewSet(
	NewGRPCServer,    // 创建 gRPC 服务器
	NewHTTPServer,    // 创建 HTTP 服务器(含中间件链 + 健康检查)
	NewEtcdClient,    // 创建 etcd 客户端(用于服务注册)
	NewEtcdRegistrar, // 创建 etcd 服务注册器
)

cmd/server/wire.go:声明注入入口,注意构建标签 //go:build wireinject。

//go:build wireinject
// +build wireinject

package main

import (
	"log/slog"

	"cloud-disk/internal/biz"
	"cloud-disk/internal/conf"
	"cloud-disk/internal/data"
	"cloud-disk/internal/data/cache"
	"cloud-disk/internal/data/lock"
	"cloud-disk/internal/data/storage"
	"cloud-disk/internal/event"
	"cloud-disk/internal/server"
	"cloud-disk/internal/service"

	"github.com/go-kratos/kratos/v3"
	"github.com/google/wire"
)

// wireApp 是 Wire 注入入口。
// 参数:5 个配置对象(Server/Data/Auth/Storage/Etcd)+ logger。
// 返回:*kratos.App(应用本体)+ cleanup 清理函数 + error。
func wireApp(*conf.Server, *conf.Data, *conf.Auth, *conf.Storage, *conf.Etcd, *slog.Logger) (*kratos.App, func(), error) {
	panic(wire.Build(
		server.ProviderSet,    // HTTP/gRPC/etcd 服务器
		data.ProviderSet,      // MySQL/Redis/Kafka 数据层
		cache.ProviderSet,     // 多级缓存
		lock.ProviderSet,      // 分布式锁
		taskProviderSet,       // 定时任务管理器 + 任务服务器
		storage.ProviderSet,   // 存储后端(MinIO/本地)
		event.ProviderSet,     // Kafka 事件
		biz.ProviderSet,       // 业务用例
		service.ProviderSet,   // 服务层
		newServers,            // 把 4 个 server 组装为切片
		newApp,                // 创建 *kratos.App
		newBizCache,           // data/cache 适配 biz.Cache
		newBizLocker,          // data/lock 适配 biz.Locker
	))
}

cmd/server/wire_gen.go(Wire 生成,截取关键部分):把注入图展开为线性初始化代码。

// Code generated by Wire. DO NOT EDIT.

package main

// wireApp init kratos application.
func wireApp(confServer *conf.Server, confData *conf.Data, auth *conf.Auth, confStorage *conf.Storage, etcd *conf.Etcd, logger *slog.Logger) (*kratos.App, func(), error) {
	// 1. 创建 TokenManager(biz 层,用于 JWT 签发/校验)
	//    修正点:若 auth.JwtSecret 为空,NewTokenManager 内部会 log.Fatalf 退出,
	//    不会回退到硬编码默认值(详见 5.8)。
	tokenManager := biz.NewTokenManager(auth)

	// 2. 创建 Data(MySQL + Redis + Kafka writer),返回 cleanup 关闭函数
	dataData, cleanup, err := data.NewData(confData)
	if err != nil {
		return nil, nil, err
	}
	db := dataData.DB                  // 取出 GORM DB
	userRepo := data.NewUserRepo(db)   // 创建用户 Repo
	client := dataData.RDB             // 取出 Redis 客户端

	// 3. 多级缓存 → 适配为 biz.Cache 接口
	cacheCache := cache.NewMultiLevelCache(client)
	bizCache := newBizCache(cacheCache)

	// 4. 分布式锁 → 适配为 biz.Locker 接口
	data_Lock := confData.Lock
	lockLock := lock.NewLock(data_Lock, client)
	locker := newBizLocker(lockLock)

	// 5. biz → service 层逐层构造
	userUsecase := biz.NewUserUsecase(userRepo, tokenManager, bizCache, locker)
	userService := service.NewUserService(userUsecase)
	// ... file / recycle / share 同理,省略 ...

	// 6. 创建 HTTP / gRPC 服务器(共享同一组 service)
	grpcServer := server.NewGRPCServer(confServer, tokenManager, userService, fileService, recycleService, shareService)
	httpServer := server.NewHTTPServer(confServer, tokenManager, userService, fileService, recycleService, shareService)

	// 7. 定时任务服务器(封装为 transport.Server)
	taskManager := newTaskManager(userUsecase, recycleUsecase, bizStorage)
	scheduledTaskServer := scheduler.NewScheduledTaskServer(taskManager)

	// 8. Kafka 消费者服务器(封装为 transport.Server)
	consumerServer := event.NewConsumerServer(consumer, eventHandlerService, redisIdempotencyStore, v)

	// 9. 多 Server 合并为切片
	v2 := newServers(grpcServer, httpServer, scheduledTaskServer, consumerServer)

	// 10. etcd 客户端 + 注册器
	clientv3Client, cleanup2, err := server.NewEtcdClient(etcd)
	if err != nil {
		cleanup()           // 失败时先释放前面申请的资源
		return nil, nil, err
	}
	registrar := server.NewEtcdRegistrar(clientv3Client)

	// 11. 创建 Kratos App
	app := newApp(logger, v2, registrar)

	// 12. 返回 app + 组合 cleanup(注意调用顺序:后申请的先释放)
	return app, func() {
		cleanup2()  // 关闭 etcd
		cleanup()   // 关闭 MySQL/Redis/Kafka
	}, nil
}

要点:Wire 生成的 cleanup 函数严格遵循"后申请先释放"的栈式顺序,避免资源泄漏。如果中途某步失败,会先释放已申请的资源再返回 error,这就是 if err != nil { cleanup(); return nil, nil, err } 模式的作用。


5.2 HTTP 服务器初始化与中间件链(含 CORS 与健康检查)

实现思路

NewHTTPServer 完成四件事:

  1. 通过 khttp.Middleware(...) 挂载 7 个中间件,形成洋葱模型中间件链(修正点:加入 CORS 层)。
  2. 根据 c.Http.Network/Addr/Timeout 设置监听参数(network 类型、地址、超时)。
  3. 通过 proto 生成的 RegisterXxxHTTPServer 注册标准 RESTful 路由,再通过 srv.Route("/").Handle(...) 手动注册特殊接口,新增 /healthz 与 /readyz 健康检查端点。
  4. 用 khttp.ErrorEncoder(...) 把 pkg/response 的统一错误响应接入(避免泄露内部错误,见 5.8)。

类比:中间件链像一个洋葱(或一摞同心圆)。请求从最外层一层层往里穿,到达业务 handler 后再一层层原路返回。越靠外层越"兜底"——万一里层 panic 或出错,外层依然能把异常接住、转成正常响应。所以 recovery 必须最外层;CORS 要早于 JWT,否则预检被拦;JWT 必须最内层(先确认"你是谁",才放你进业务)。

这张图展示一次跨域请求穿过 7 层中间件的过程(注意 OPTIONS 预检在 CORS 层就短路返回,不会走到 JWT):

flowchart LR
    REQ[请求] --> M1[recovery
兜底panic] M1 --> M2[CORS
预检短路/设跨域头] M2 --> M3[RateLimit
限流防刷] M3 --> M4[Timing
耗时监控] M4 --> M5[validate
非空校验] M5 --> M6[CacheControl
禁用缓存] M6 --> M7[JWTAuth
鉴权注入user_id] M7 --> H[业务Handler] H --> M7 M7 --> M6 M6 --> M5 M5 --> M4 M4 --> M3 M3 --> M2 M2 --> M1 M1 --> RESP[响应]

关键代码

internal/server/http.go(核心部分,商用修正版):

package server

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"
	"time"

	filev1 "cloud-disk/api/file/v1"
	recyclev1 "cloud-disk/api/recycle/v1"
	sharev1 "cloud-disk/api/share/v1"
	userv1 "cloud-disk/api/user/v1"
	"cloud-disk/internal/biz"
	"cloud-disk/internal/conf"
	"cloud-disk/internal/data"
	"cloud-disk/internal/service"
	"cloud-disk/pkg/response" // 修正点:统一错误响应

	perrors "github.com/go-kratos/kratos/v3/errors"
	khttp "github.com/go-kratos/kratos/v3/transport/http"
)

// 三个手动路由的操作名常量,用于中间件白名单匹配
const (
	UserStorageOp = "/api/v1/user/storage"
	FileDeleteOp  = "/api/v1/file/delete"
	FilePreviewOp = "/api/v1/file/{id}/preview"
)

// NewHTTPServer 创建一个带有中间件链和服务路由的 HTTP 服务器。
func NewHTTPServer(c *conf.Server, tm *biz.TokenManager, us *service.UserService, fs *service.FileService, rs *service.RecycleService, ss *service.ShareService) *khttp.Server {
	// === 第一步:组装中间件链 ===
	// 修正点:在 recovery 之后、限流之前插入 CORSMiddleware,
	// 保证 OPTIONS 预检早于 JWT 被处理,避免跨域请求整体失败。
	var opts = []khttp.ServerOption{
		khttp.Middleware(
			recoveryMiddleware(),
			CORSMiddleware(c.GetCorsAllowedOrigins()...), // 修正点:按白名单配置可信源
			RateLimitMiddleware(600, time.Minute),         // 默认 600 次/分钟
			TimingMiddleware(),
			validateMiddleware(),
			CacheControlMiddleware(),
			JWTAuthMiddleware(tm,
				// === JWT 白名单:以下路径不需要登录即可访问 ===
				"/user.v1.UserService/Register",
				"/user.v1.UserService/Login",
				"/user.v1.UserService/RefreshToken",
				"/user.v1.UserService/ResetPassword",
				"/user.v1.UserService/GetSecurityQuestion",
				"/share.v1.ShareService/AccessShare",
				"/share.v1.ShareService/GetShareDetail",
				FileDeleteOp, // 手动路由加入白名单,由 handler 自行验证 token
			),
		),
		// 修正点:接入统一错误响应,避免把内部错误/堆栈泄露给前端
		khttp.ErrorEncoder(response.ErrorEncoder),
	}

	// === 第二步:根据配置设置监听参数 ===
	if c.Http.Network != "" {
		opts = append(opts, khttp.Network(c.Http.Network))
	}
	if c.Http.Addr != "" {
		opts = append(opts, khttp.Address(c.Http.Addr))
	}
	if c.Http.Timeout != nil {
		opts = append(opts, khttp.Timeout(c.Http.Timeout.AsDuration()))
	}
	srv := khttp.NewServer(opts...)

	// === 第三步:注册 proto 自动生成的 RESTful 路由 ===
	userv1.RegisterUserServiceHTTPServer(srv, us)
	filev1.RegisterFileServiceHTTPServer(srv, fs)
	recyclev1.RegisterRecycleServiceHTTPServer(srv, rs)
	sharev1.RegisterShareServiceHTTPServer(srv, ss)

	// === 第四步:手动注册特殊路由 ===
	srv.Route("/").Handle("GET", "/api/v1/user/storage", getUserStorageHandler(us))
	srv.Route("/").Handle("POST", "/api/v1/file/delete", getFileDeleteHandler(rs, tm))
	srv.Route("/").Handle("GET", "/api/v1/file/{id}/preview", getFilePreviewHandler(fs, tm))

	// === 第五步:修正点——注册健康检查端点(供 K8s/Compose 探活)===
	// liveness:只探进程;readiness:探 DB + Redis。
	srv.Route("/").Handle("GET", "/healthz", healthLivenessHandler())
	srv.Route("/").Handle("GET", "/readyz", healthReadinessHandler(dataDB, dataRDB))

	return srv
}

要点:跨域场景下 CORS 必须早于 JWT;健康检查端点不应进入 JWT 鉴权链,否则探针不带 token 会被 401,编排系统误判实例不健康。readiness 必须真去 ping DB/Redis,否则"进程活着但依赖挂了"时仍接流量会大面积失败。


5.3 gRPC 服务器初始化

实现思路

NewGRPCServer 当前作为协议预留骨架,只挂载官方 recovery.Recovery() 中间件防止 panic 崩溃,后续可通过 filev1.RegisterFileServiceServer(srv, fs) 等接口注册 gRPC 服务。修正点:启用 gRPC 时必须同步追加 JWT 中间件(与 HTTP 一致),否则 gRPC 入口完全无鉴权;且 gRPC 的反射/健康检查接口应加入白名单,避免被 JWT 误拦。

关键代码

internal/server/grpc.go:

package server

import (
	"cloud-disk/internal/biz"
	"cloud-disk/internal/conf"
	"cloud-disk/internal/service"

	"github.com/go-kratos/kratos/v3/middleware/recovery"
	"github.com/go-kratos/kratos/v3/transport/grpc"
)

// NewGRPCServer 使用给定的配置和服务创建一个新的 gRPC 服务器。
func NewGRPCServer(c *conf.Server, tm *biz.TokenManager, us *service.UserService, fs *service.FileService, rs *service.RecycleService, ss *service.ShareService) *grpc.Server {
	var opts = []grpc.ServerOption{
		grpc.Middleware(
			recovery.Recovery(),
			// 修正点:启用 gRPC 业务时此处追加 JWTAuthMiddleware(tm, whitelist...),
			// 与 HTTP 共用同一套白名单与校验逻辑,避免 gRPC 入口裸奔。
		),
	}
	if c.Grpc.Network != "" {
		opts = append(opts, grpc.Network(c.Grpc.Network))
	}
	if c.Grpc.Addr != "" {
		opts = append(opts, grpc.Address(c.Grpc.Addr))
	}
	if c.Grpc.Timeout != nil {
		opts = append(opts, grpc.Timeout(c.Grpc.Timeout.AsDuration()))
	}
	srv := grpc.NewServer(opts...)

	// 当前版本只搭骨架,未注册具体 gRPC 服务
	_ = tm
	_ = us
	return srv
}

要点:HTTP 与 gRPC 共享同一组 *service.XxxService,体现了 Kratos「一份业务,多协议出口」的设计。后续要启用 gRPC,只需(1)在 grpc.Middleware 中追加 JWT 中间件并配白名单,(2)调用 filev1.RegisterFileServiceServer(srv, fs) 等注册函数即可。


5.4 JWT 鉴权中间件(令牌校验 + 接口白名单 + 黑名单检查 + 自定义 context key)

实现思路

JWTAuthMiddleware 是服务端最核心的中间件,完成四件事:

  1. 白名单匹配:先检查 transport.Operation()(proto 路由)和 Request().URL.Path(手动路由),命中白名单直接放行。
  2. token 提取:从 Authorization 请求头取 Bearer xxx 前缀的 token 字符串。
  3. 校验与注入:调用 tokenManager.VerifyToken(ctx, tokenStr) 校验签名 + 过期 + 黑名单(+ 版本号,见用户模块),通过后将 user_id / username / token 写入 自定义类型 context key(修正点:源版用裸字符串 "user_id",存在与其他包 key 冲突风险)。
  4. 客户端 IP:供限流/审计使用的真实 IP 由 CORS/限流层从 X-Real-IP 取,中间件本身不重复解析。

黑名单机制在 biz.TokenManager.VerifyToken 内部完成:验签通过后查询 Redis 中 blacklist:token:{jti} 是否存在,存在则返回 ErrTokenExpired,实现登出即时失效。「令牌版本号(token_version)解决改密/封禁集体失效」由 VerifyToken 统一比对,详见用户模块 5.3 / 5.4,本中间件无需改动即可继承该能力。

关键代码

internal/server/middleware.go(JWTAuthMiddleware 部分,商用修正版):

// 修正点:自定义 context key 类型,避免与其他包裸字符串 key 冲突
type ctxKey string

const (
	ctxKeyUserID   ctxKey = "user_id"
	ctxKeyUsername ctxKey = "username"
	ctxKeyToken    ctxKey = "token"
)

// JWTAuthMiddleware 返回一个验证 JWT token 的中间件。
func JWTAuthMiddleware(tokenManager *biz.TokenManager, whitelist ...string) middleware.Middleware {
	whitelistMap := make(map[string]bool, len(whitelist))
	for _, p := range whitelist {
		whitelistMap[p] = true
	}

	return func(handler middleware.Handler) middleware.Handler {
		return func(ctx context.Context, req interface{}) (interface{}, error) {
			// === 第一步:白名单检查 ===
			if tr, ok := transport.FromServerContext(ctx); ok {
				op := tr.Operation()
				if whitelistMap[op] {
					return handler(ctx, req)
				}
				if ht, ok := tr.(interface{ Request() *http.Request }); ok {
					if whitelistMap[ht.Request().URL.Path] {
						return handler(ctx, req)
					}
				}
			}

			// === 第二步:从 Authorization 头提取 Bearer token ===
			var tokenStr string
			if tr, ok := transport.FromServerContext(ctx); ok {
				header := tr.RequestHeader()
				auth := header.Get("Authorization")
				if strings.HasPrefix(auth, "Bearer ") {
					tokenStr = strings.TrimPrefix(auth, "Bearer ")
				}
			}
			if tokenStr == "" {
				return nil, errors.Unauthorized("MISSING_TOKEN", "缺少认证令牌")
			}

			// === 第三步:校验 token(传入 ctx,便于内部查 Redis/DB)===
			// 修正点:签名 + 过期 + 黑名单 + 版本号,均在这一步完成。
			claims, err := tokenManager.VerifyToken(ctx, tokenStr)
			if err != nil {
				return nil, err
			}

			// === 第四步:用自定义 key 把用户信息注入 context ===
			// 修正点:ctxKey 自定义类型,下游用 CtxUserID/CtxUsername/CtxToken 读取。
			ctx = context.WithValue(ctx, ctxKeyUserID, claims.UserID)
			ctx = context.WithValue(ctx, ctxKeyUsername, claims.Username)
			ctx = context.WithValue(ctx, ctxKeyToken, tokenStr)

			return handler(ctx, req)
		}
	}
}

func CtxUserID(ctx context.Context) uint64 {
	if id, ok := ctx.Value(ctxKeyUserID).(uint64); ok {
		return id
	}
	return 0
}
func CtxUsername(ctx context.Context) string {
	if name, ok := ctx.Value(ctxKeyUsername).(string); ok {
		return name
	}
	return ""
}
func CtxToken(ctx context.Context) string {
	if t, ok := ctx.Value(ctxKeyToken).(string); ok {
		return t
	}
	return ""
}

要点:VerifyToken 必须是 fail-closed(Redis/DB 故障时宁可拒绝,见用户模块 5.3),否则登出/封禁会被绕过;context key 用自定义类型而非裸字符串,是 Kratos 官方推荐做法,避免多中间件间 key 互相覆盖。


5.5 Recovery 中间件(panic 恢复)

实现思路

Go 语言中 goroutine panic 会让整个进程崩溃。recoveryMiddleware 用 defer recover() 捕获 handler 执行过程中的 panic,把 panic 转换为 errors.InternalServer 错误返回给客户端(HTTP 500),同时用 log.Error 记录错误堆栈,保证单个请求异常不影响其他请求和进程稳定性。修正点:对外只回通用 internal server error,具体堆栈仅入日志(与 5.8 统一错误响应一致,不泄露细节)。

关键代码

internal/server/middleware.go:

// recoveryMiddleware 从 panic 中恢复并返回 500 错误。
// 必须放在中间件链的最外层,确保能捕获内层所有 panic。
func recoveryMiddleware() middleware.Middleware {
	return func(handler middleware.Handler) middleware.Handler {
		return func(ctx context.Context, req interface{}) (reply interface{}, err error) {
			// defer + recover 是 Go 标准 panic 捕获模式
			defer func() {
				if r := recover(); r != nil {
					if e, ok := r.(error); ok {
						err = e
					} else {
						// 修正点:对外统一回 InternalServer,不把 r 直接序列化出去
						err = errors.InternalServer("PANIC", "internal server error")
					}
					// 记录 panic 详情,便于事后排查
					log.Error("panic recovered", "err", r)
				}
			}()

			reply, err = handler(ctx, req)
			return
		}
	}
}

要点:

  • recoveryMiddleware 用了命名返回值(reply interface{}, err error),这是 defer 中修改返回值的前提——非命名返回值在 defer 中无法修改。
  • 它放在 khttp.Middleware(...) 的第一个位置,意味着它是洋葱模型的最外层,能捕获内层所有中间件和 handler 的 panic。
  • gRPC 服务器用的是官方 recovery.Recovery(),原理一致,但官方版本还会记录完整堆栈。

5.6 Timing 中间件(请求耗时监控)

实现思路

TimingMiddleware 记录每个请求的操作名(op)和耗时(duration_ms),输出到日志用于性能监控。它从 transport.FromServerContext(ctx).Operation() 获取 op(proto 路由会自动设置,手动路由需在 handler 内 khttp.SetOperation 显式设置)。修正点:亿级流量下应升级为 Prometheus histogram(见第四章 3),避免每个请求都打日志成为瓶颈;健康检查等高频端点应跳过打点。

关键代码

internal/server/middleware.go:

// TimingMiddleware 记录请求耗时用于性能监控。
func TimingMiddleware() middleware.Middleware {
	return func(handler middleware.Handler) middleware.Handler {
		return func(ctx context.Context, req interface{}) (interface{}, error) {
			start := time.Now()

			op := "unknown"
			if tr, ok := transport.FromServerContext(ctx); ok {
				op = tr.Operation()
			}

			reply, err := handler(ctx, req)
			duration := time.Since(start)

			// 修正点:高频/健康检查端点可跳过,或改为 metrics 上报
			if op != "/healthz" && op != "/readyz" {
				log.Info("api.timing",
					"op", op,
					"duration_ms", duration.Milliseconds(),
					"err", err,
				)
			}
			return reply, err
		}
	}
}

要点:

  • TimingMiddleware 放在 recoveryMiddleware 之后,确保即使 handler panic,timing 也能在 recoveryMiddleware 的 defer recover 之前记录到 err。
  • 它放在 RateLimitMiddleware 之后,意味着被限流拒绝的请求不会进入 timing 统计——这是合理的,限流拒绝发生在业务之前。

5.7 多 Server 合并与 Kratos App 生命周期管理(newServers + newApp + 优雅启停)

实现思路

Kratos 的 transport.Server 是统一抽象:只要实现 Start(ctx) error 和 Stop(ctx) error 两个方法,就能被 kratos.App 统一编排。本项目把 4 个组件都封装为 transport.Server:

  1. *kgrpc.Server(gRPC 服务器)
  2. *khttp.Server(HTTP 服务器)
  3. *scheduler.ScheduledTaskServer(定时任务服务器)
  4. *event.ConsumerServer(Kafka 消费者服务器)

类比:这 4 个组件就像 4 个"工位",虽然有的不接客(定时任务、Kafka 消费者不监听端口),但只要它们都遵守"上班 Start、下班 Stop“同一套规矩(transport.Server 接口),店长(kratos.App)就能用同一份排班表统一管,不用为每个工位单独写一套启停代码。

这张图展示统一生命周期的编排与退出顺序:

flowchart TB
    subgraph 统一抽象
        S1[HTTP Server]
        S2[gRPC Server]
        S3[定时任务 Server]
        S4[Kafka 消费者 Server]
    end
    S1 --> APP[kratos.App]
    S2 --> APP
    S3 --> APP
    S4 --> APP
    APP --> RUN[Run: 启动全部Start
注册etcd + 监听信号] RUN --> STOP[收到SIGTERM: 逆序Stop
HTTP/gRPC先停接收新请求] STOP --> DRAIN[等待在途请求完成
再停消费者/定时任务] DRAIN --> CLN[cleanup后释放
关DB/Redis/Kafka/etcd]

newServers 把它们组装为 []transport.Server 切片,newApp 用 kratos.Server(servers...) 一次性注册,kratos.App.Run() 会依次启动所有 server 并监听信号实现优雅启停。

关键代码

cmd/server/main.go(newApp + newServers + main,节选):

func newApp(logger *slog.Logger, servers []transport.Server, registrar registry.Registrar) *kratos.App {
	opts := []kratos.Option{
		kratos.ID(id),
		kratos.Name(Name),
		kratos.Version(Version),
		kratos.Metadata(map[string]string{}),
		kratos.Logger(logger),
		kratos.Server(servers...),
	}
	if registrar != nil {
		opts = append(opts, kratos.Registrar(registrar))
	}
	return kratos.New(opts...)
}

func newServers(
	grpcServer *kgrpc.Server,
	httpServer *khttp.Server,
	scheduledTaskServer *scheduler.ScheduledTaskServer,
	consumerServer *event.ConsumerServer,
) []transport.Server {
	return []transport.Server{grpcServer, httpServer, scheduledTaskServer, consumerServer}
}

func main() {
	flag.Parse()

	// === 1. 初始化 logger(结构化日志,slog)===
	logger := log.NewLogger(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		AddSource: true,
		Level:     slog.LevelInfo,
	})).With(
		slog.String("service.id", id),
		slog.String("service.name", Name),
		slog.String("service.version", Version),
	)
	log.SetDefault(logger)

	// === 2. 加载配置(支持环境变量展开,envsource)===
	c := config.New(
		config.WithSource(
			conf.NewEnvExpandSource(file.NewSource(flagconf)),
		),
	)
	defer c.Close()
	if err := c.Load(); err != nil {
		panic(err)
	}

	var bc conf.Bootstrap
	if err := c.Scan(&bc); err != nil {
		panic(err)
	}

	// === 3. Wire 依赖注入:构造 App + cleanup ===
	app, cleanup, err := wireApp(bc.Server, bc.Data, bc.Auth, bc.Storage, bc.Etcd, logger)
	if err != nil {
		panic(err)
	}
	defer cleanup() // 确保资源释放(即使 panic 也会执行)

	// === 4. 启动并等待停止信号(优雅启停)===
	// 修正点:K8s 部署时 terminationGracePeriodSeconds 需 ≥ 在途请求最长耗时,
	// 否则 kubelet 在优雅窗口结束前发 SIGKILL,在途请求被强杀。
	if err := app.Run(); err != nil {
		panic(err)
	}
}

etcd 注册器创建(internal/server/registry.go,节选):

// NewEtcdClient 根据配置创建 etcd 客户端。
func NewEtcdClient(c *conf.Etcd) (*clientv3.Client, func(), error) {
	// 防御性检查:配置为空或没有 endpoint,直接返回 nil
	if c == nil || len(c.Endpoints) == 0 {
		slog.Warn("etcd 配置为空,跳过 etcd 客户端创建")
		return nil, func() {}, nil
	}
	// ... 构造 clientv3.Config ...
	client, err := clientv3.New(cfg)
	if err != nil {
		return nil, nil, err
	}
	return client, func() { _ = client.Close() }, nil
}

// NewEtcdRegistrar 创建 etcd 服务注册器。client 为 nil 时返回 nil。
func NewEtcdRegistrar(client *clientv3.Client) registry.Registrar {
	if client == nil {
		return nil
	}
	return etcd.New(
		client,
		etcd.Namespace("/microservices"),
		etcd.RegisterTTL(15 * time.Second),
	)
}

要点:

  1. 统一生命周期:把定时任务和 Kafka 消费者封装为 transport.Server 是巧妙设计——它们不需要监听网络端口,但需要随进程启停。复用 Kratos 的 Start/Stop 接口,避免在 main 中写多份 goroutine + WaitGroup 代码。
  2. 优雅启停顺序:app.Run() 收到信号后,按 servers 切片逆序 Stop()(最后注册的最先停止),通常先停 HTTP/gRPC(不再接收新请求)→ 再停消费者(停止拉取消息)→ 最后停定时任务;并等待在途请求完成。cleanup 函数在所有 server 停止后才执行,关闭数据库/Redis/Kafka 连接。
  3. etcd 注册的容错:NewEtcdClient 在配置为空时返回 nil,NewEtcdRegistrar 接到 nil client 也返回 nil registrar,newApp 检查 registrar != nil 才挂载注册器。整条链路都做了空值防御,确保本地开发不配 etcd 也能跑。

5.8 部署与上线安全(CORS / 安全头 / TLS / 健康检查 / 密钥 fail-fast / 版本化迁移 / 错误响应 / 请求体上限)

这一节把"能跑"和"能商用上线"之间的差距一次补齐。下面每条都对应一个源码里真实存在的问题或缺失,直接用正确实现替换,不再单列清单。

5.8.1 CORS 中间件(修正点:源版完全缺失)

跨域必须走白名单,且必须早于 JWT 处理 OPTIONS 预检。Access-Control-Allow-Origin 与 credentials 不能同时使用 *。

// CORSMiddleware 按白名单处理跨域,正确处理 OPTIONS 预检。
// allowedOrigins 来自配置(如 https://cloud.example.com),绝不接受 "*"。
func CORSMiddleware(allowedOrigins ...string) middleware.Middleware {
	allowSet := make(map[string]bool, len(allowedOrigins))
	for _, o := range allowedOrigins {
		allowSet[o] = true
	}
	return func(handler middleware.Handler) middleware.Handler {
		return func(ctx context.Context, req interface{}) (interface{}, error) {
			if tr, ok := transport.FromServerContext(ctx); ok {
				if ht, ok := tr.(interface{ Request() *http.Request }); ok {
					r := ht.Request()
					origin := r.Header.Get("Origin")
					// 同源或没有 Origin(非浏览器请求)直接放行
					if origin == "" || allowSet[origin] {
						if h, ok := tr.(khttp.Transporter); ok {
							h.ReplyHeader().Set("Access-Control-Allow-Origin", origin)
							// 修正点:仅在可信源开启凭据,绝不与 "*" 共存
							if allowSet[origin] {
								h.ReplyHeader().Set("Access-Control-Allow-Credentials", "true")
							}
							h.ReplyHeader().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
							h.ReplyHeader().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
							h.ReplyHeader().Set("Access-Control-Max-Age", "600")
						}
						// 预检请求直接短路返回 204,不再进入 JWT 等后续中间件
						if r.Method == http.MethodOptions {
							return nil, nil
						}
					}
				}
			}
			return handler(ctx, req)
		}
	}
}

5.8.2 健康检查端点(修正点:源版后端无 /healthz /readyz)

liveness 只探进程,readiness 真连 DB / Redis,否则依赖抖动也会被持续接流量。

// healthLivenessHandler 存活探针:进程能响应即视为活着。
func healthLivenessHandler() func(ctx khttp.Context) error {
	return func(ctx khttp.Context) error {
		return ctx.Result(http.StatusOK, map[string]string{"status": "ok"})
	}
}

// healthReadinessHandler 就绪探针:探 DB + Redis 真实可用。
func healthReadinessHandler(db *gorm.DB, rdb *redis.Client) func(ctx khttp.Context) error {
	return func(ctx khttp.Context) error {
		// 修正点:readiness 必须探真实依赖,否则"进程活但 DB 挂"仍接流量会大面积失败
		if db != nil {
			sqlDB, _ := db.DB()
			if sqlDB == nil || sqlDB.Ping() != nil {
				return ctx.Result(http.StatusServiceUnavailable, map[string]string{"status": "db unavailable"})
			}
		}
		if rdb != nil {
			if rdb.Ping(ctx.Request().Context()).Err() != nil {
				return ctx.Result(http.StatusServiceUnavailable, map[string]string{"status": "redis unavailable"})
			}
		}
		return ctx.Result(http.StatusOK, map[string]string{"status": "ok"})
	}
}

5.8.3 统一错误响应(修正点:源版 pkg/response 对未知错误返回 err.Error(),泄露内部细节)

对外只暴露稳定 code + 用户可读 message,真实原因仅入日志。

// fromError 将错误转为统一响应。
// 修正点:非 Kratos 错误不再返回 err.Error(),避免泄露 SQL/堆栈/路径。
func fromError(err error) *Response {
	if err == nil {
		return Success(nil)
	}
	se := errors.FromError(err)
	if se != nil {
		return &Response{Code: int(se.Code), Message: se.Message}
	}
	// 未知错误:统一回内部错误,详情进日志由调用方记录
	return &Response{Code: http.StatusInternalServerError, Message: "internal server error"}
}

该编码器需通过 khttp.ErrorEncoder(response.ErrorEncoder) 接入(见 5.2),否则仍是 Kratos 默认编码器。

5.8.4 密钥 fail-fast(修正点:源版 biz/auth.go 有硬编码默认密钥)

// NewTokenManager 生产化:未配置强密钥直接启动失败。
func NewTokenManager(c *conf.Auth) *TokenManager {
	expire := 24 * time.Hour
	refreshExpire := 7 * 24 * time.Hour
	secret := ""
	if c != nil {
		if c.JwtExpire != nil {
			expire = c.JwtExpire.AsDuration()
		}
		if c.JwtRefreshExpire != nil {
			refreshExpire = c.JwtRefreshExpire.AsDuration()
		}
		secret = c.JwtSecret
	}
	// 修正点:绝不回退到硬编码默认值,否则任何人可用默认密钥伪造令牌
	if len(secret) < 32 {
		log.Fatalf("auth.jwt_secret 必须配置且长度≥32,否则拒绝启动(fail-fast)")
	}
	return &TokenManager{
		secret:          []byte(secret),
		expire:          expire,
		refreshExpire:   refreshExpire,
		blacklistPrefix: "blacklist:token:",
	}
}

对应的 configs/config.yaml / deploy/.env.example 已通过 ${JWT_SECRET:...} 占位,但默认值必须足够随机且部署脚本校验(如 deploy.sh 已校验未改默认 JWT_SECRET 则拒绝部署)。生产环境密钥应来自 KMS / Secret,而非写在 yaml 默认值里。

5.8.5 版本化迁移替代 AutoMigrate(修正点:源版 data.go 用 db.AutoMigrate)

AutoMigrate 适合开发,生产应改用 golang-migrate / Atlas,迁移脚本随代码入库、带版本号、可评审可回滚。

// NewData 中替换 AutoMigrate 为版本化迁移(示意)。
func NewData(c *conf.Data) (*Data, func(), error) {
	db, err := NewGormDB(c.Database)
	if err != nil {
		return nil, nil, err
	}
	// 修正点:生产用版本化迁移,而非 db.AutoMigrate。
	// 例如在 main 或 CI 中执行:
	//   migrate -path migrations -database "$DSN" up
	// 此处仅做迁移后连接可用性校验。
	if err := db.Exec("SELECT 1").Error; err != nil {
		return nil, nil, fmt.Errorf("db not ready: %w", err)
	}
	// ... redis / kafka 初始化、cleanup ...
}

5.8.6 nginx 安全头、TLS/HSTS 与请求体上限(修正点:源版保留废弃的X-XSS-Protection、缺CSP、client_max_body_size 0)

http {
    # 性能与超时(略)

    # 修正点:安全响应头
    # 1) 移除已废弃的 X-XSS-Protection(现代浏览器已忽略,且可能引入漏洞)
    # 2) 新增 Content-Security-Policy,限制脚本/连接来源
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'" always;

    server {
        listen 443 ssl http2;
        # 修正点:TLS 在 nginx 终止,并启用 HSTS 强制 HTTPS
        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

        # 修正点:一般 /api/ 限制请求体(防超大 JSON 打爆内存),上传路由单独放开
        location /api/ {
            client_max_body_size 2m;          # 普通接口 2MB 上限
            proxy_pass http://backend:8000/api/;
            # ... proxy_set_header / 超时(略)...
        }
        # 上传走独立路由,放宽到对象存储单文件上限
        location /api/v1/file/upload {
            client_max_body_size 1024m;       # 与后端/MinIO 上限对齐
            proxy_request_buffering off;      # 流式上传,不缓冲到磁盘
            proxy_pass http://backend:8000/api/v1/file/upload;
        }
    }
}

安全头由 nginx 统一下发即可,无需在每个后端响应里重复设置;CORS 头由 5.8.1 的中间件下发,二者职责分清。


自测题与动手练习

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

面试官
为什么本项目把定时任务服务器和 Kafka 消费者服务器也实现 transport.Server 接口?如果不这么做,main 里会多写什么代码?优雅退出时它们的 Stop 应该先于还是后于 HTTP 服务器被调用,为什么?
候选人
只要实现 Start/Stop,它们就能被 kratos.App 用同一套生命周期统一编排,直接复用优雅启停逻辑;否则你得在 main 里手写 goroutine、WaitGroup、独立监听 os.Signal 和各自的停机逻辑。优雅退出时它们应先于 HTTP 服务器 Stop——先停止接收新消息/新任务,避免在途 HTTP 请求被新事件打断。
面试官
Kratos 中间件是装饰器模式(func(Handler) Handler)。一次请求在洋葱模型里"进"和"出"分别经过哪些阶段?若把 JWT 放在 CORS 之前,浏览器跨域请求会发生什么?
候选人
请求从最外层 recovery 逐层"进"到最内层 service 执行业务,响应再原路逐层"出"回来。若 JWT 在 CORS 之前,浏览器 OPTIONS 预检不带 Authorization,会被 JWT 中间件直接 401 拦截,CORS 头来不及下发,前端报跨域失败。
面试官
JWTAuthMiddleware 为什么要同时检查 transport.Operation() 和 Request().URL.Path?context key 为什么要用自定义类型而非裸字符串?
候选人
proto 自动生成的路由靠 transport.Operation() 识别,手动 srv.Route 注册的流式/批量路径要用 Request().URL.Path 匹配;只查一个会漏掉另一类路由的鉴权。裸字符串 key 易与其他库/中间件冲突,自定义类型 key 保证唯一且类型安全。
面试官
为什么限流要放在 JWT 之前?这与"登录接口防爆破"矛盾吗?客户端 IP 为什么不能取 X-Forwarded-For 首值?
候选人
限流在鉴权之前先挡高频匿名请求,护住登录等匿名接口、防止被刷爆;这与登录防爆破不矛盾——限流是粗粒度防刷,单账号撞库靠用户模块的用户级失败计数+锁定。X-Forwarded-For 首值由客户端可控、可伪造,应取可信边缘下发的 X-Real-IP。
面试官
健康检查为什么要分 /healthz(liveness)和 /readyz(readiness)?若只用一个、且 readiness 去探 DB,DB 短暂抖动会怎样?
候选人
liveness 只表进程活着,readiness 表能否服务(探 DB/Redis)。若只用一个且 readiness 探 DB,DB 短暂抖动会让实例被判不健康,K8s 摘流量甚至重启,造成可用率抖动;分开后 liveness 稳定、readiness 才随依赖漂移。
面试官
为什么 JWT 密钥不能有硬编码默认值?生产化怎么做(fail-fast)?统一错误响应为什么不能把 err.Error() 直接返回前端?
候选人
硬编码默认密钥等于公开密钥,任何人可伪造 token。生产用 fail-fast:启动即校验 JWT_SECRET 非空且长度足够(如 ≥32 位),缺失/过短直接拒绝启动。统一错误响应只返业务码+安全信息,不直返 err.Error() 以免泄露内部堆栈/路径。
面试官
生产环境为什么要用版本化迁移替代 AutoMigrate?AutoMigrate 在"删除列/改类型/多实例并发启动"时分别有什么风险?
候选人
AutoMigrate 适合开发、生产不可控:删列会直接丢数据;改类型可能锁表/丢精度;多实例并发启动时各 Pod 同时 AutoMigrate 会争抢锁甚至结构错乱。版本化迁移(golang-migrate/Atlas)带版本号、随代码入库、可评审可回滚。
面试官
nginx 里 X-XSS-Protection 为什么该移除?Access-Control-Allow-Origin: * 与 credentials: true 为什么不能共存?client_max_body_size 0 对 /api/ 有什么隐患?
候选人
X-XSS-Protection 已被现代浏览器废弃且可能引入漏洞,应改用 CSP。* 允许任意源,若 credentials: true 则任意网站可带用户凭证跨域读数据,故二者不能共存(应白名单+显式源)。client_max_body_size 0 不限制请求体,超大 JSON 会打爆后端内存,/api/ 应设上限(如 2m),上传走独立路由放宽。

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

  1. 把 NewHTTPServer 的中间件顺序改成 JWTAuth → CORSMiddleware → ...,用浏览器从一个不同源的前端页发起带 Authorization 的请求,观察 OPTIONS 预检是否被 JWT 中间件 401 拦截、控制台报什么跨域错误;再改回正确顺序验证恢复。
  2. 在本地把 JWT_SECRET 留空启动 main,确认进程是否按 fail-fast 直接退出;再把密钥改成短于 32 位的弱值,确认同样被拒绝。
  3. 仿照 healthReadinessHandler,在本地临时把 Redis 停掉,访问 /readyz 确认返回 503 且不被负载均衡转发;恢复 Redis 后返回 200。
  4. 用 golang-migrate 写一条 CREATE TABLE 的初始迁移,执行 up / down,体会版本化迁移相对 AutoMigrate 的可回滚与可评审优势。
  5. 修正 nginx 配置:去掉 X-XSS-Protection、加 Content-Security-Policy、把 /api/ 的 client_max_body_size 设为 2m 并给上传路由单独放开,用 curl -d 发一个超大 body 验证普通接口被拒、上传接口放行。

本章小结

复习提示:
  • 统一抽象是主线:transport.Server(只要实现 Start/Stop)让 HTTP、gRPC、定时任务、Kafka 消费者被 kratos.App 用同一套 lifecycle 编排,避免重复启停代码;Wire 在编译期把 9 个 ProviderSet 展开成线性、可读、无反射开销的 wire_gen.go。
  • 中间件是洋葱模型:recovery 最外层兜底、JWT 最内层鉴权;商用修正关键两点——CORS 必须早于 JWT(否则 OPTIONS 预检被 401,跨域全失败),限流放在鉴权之前(护住登录等匿名接口、防爆破);context 用自定义 key 类型防冲突。
  • JWT 无状态 + 黑名单 + 版本号:验签无需查库,黑名单用 jti 作 key、TTL 跟随 token 剩余有效期,登出即时失效且自动过期清理;版本号(token_version)解决改密/封禁集体失效(详见用户模块)。
  • 优雅启停保证零中断:收到 SIGTERM 后逆序 Stop(先停接收新请求)并等待在途请求完成;配合 K8s terminationGracePeriodSeconds 才能避免被强杀。
  • 上线安全必须补齐:健康检查 /healthz /readyz(readiness 探 DB/Redis)、CORS 白名单(绝不 *+credentials)、Content-Security-Policy 等安全头(移除废弃 X-XSS-Protection)、nginx 终止 TLS + HSTS、/api/ 请求体上限、密钥未配置即 fail-fast、版本化迁移替代 AutoMigrate、统一错误响应不泄露内部细节——这些是"教学版能跑"到"商用可上线"的差距。
  • 过渡:下一章可深入文件模块,看流式上传/下载如何复用这里的鉴权中间件、CORS 与 nginx 上传放行策略,以及 UpdateUsedStorageAtomic 如何与请求体上限协同防护存储越界。
About Me

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

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

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

目标

学AI,加油!加油!