187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
# 业务基础模块
|
||
|
||
第二批的共享 Redis 中间件、可靠队列、多平台存储及其他模块见 [扩展模块](ADVANCED.md)。下文的单进程约束指本页原有默认实现。
|
||
|
||
这批能力从 `v0.7.0-preview` 提供。统一示例见
|
||
[examples/modules](examples/modules/README.md)。业务入口继续使用 `omni.New`,中间件依赖
|
||
`httpx`,不依赖具体服务端网络实现。各模块按需创建、注入 Service,再注册到应用关闭流程。
|
||
|
||
## 请求追踪与基础指标
|
||
|
||
`omni.New()` 的默认链包含 RequestID、访问日志(可配置开关)和 Recovery;无需重复挂载。业务通过 `c.Context()` 传递 requestId,`logx.FromContext(ctx)` 取得该 App 的 Logger。手动组装 httpx 链时,也可使用 `middleware.RequestID("X-Request-ID")`、`middleware.AccessLog()` 和 `middleware.Recovery()`。
|
||
|
||
轻量进程指标直接使用 `metrics`,无需 OTel collector:
|
||
|
||
```go
|
||
// import omni "git.yuntuo.hk/k2safe/go-omni"
|
||
// import "git.yuntuo.hk/k2safe/go-omni/metrics"
|
||
app := omni.New()
|
||
measurements := metrics.New()
|
||
app.Use(measurements.Middleware())
|
||
app.GET("/metrics", measurements.Handler())
|
||
// 也可以直接读取 measurements.Snapshot()。
|
||
```
|
||
|
||
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。
|
||
|
||
## 缓存:cache 与 cache/redis
|
||
|
||
```go
|
||
store, err := cache.NewMemory(cache.MemoryConfig{MaxEntries: 4096, MaxBytes: 32 << 20})
|
||
if err != nil { return err }
|
||
application.OnShutdown(store.Close)
|
||
loader, err := cache.NewLoader(store, cache.LoaderConfig{MaxLoads: 128, Timeout: 5 * time.Second})
|
||
if err != nil { return err }
|
||
value, err := loader.GetOrLoad(ctx, "tenant:42:product:7", time.Minute,
|
||
func(ctx context.Context) ([]byte, error) { return repository.LoadProduct(ctx, "42", "7") })
|
||
```
|
||
|
||
- `Store` 统一 `Get/Set/Delete`,返回字节切片;业务可用 JSON 编解码。
|
||
- 内存缓存使用 LRU,同时限制条目数和 key/value 总字节数;字节上限不包含 Go 对象开销。
|
||
读写复制切片,过期时懒清理,没有每个条目一个 goroutine。
|
||
- TTL 为零表示不过期,负值拒绝;缺失返回 `cache.ErrMiss`。删除不存在的 key 成功。
|
||
- Loader 合并**同进程、同 key**的并发回源,最多同时运行 `MaxLoads` 个不同 key 的加载。
|
||
单个调用者取消只停止等待;共享加载有独立超时,加载函数必须响应 context。
|
||
超限返回 `ErrBusy`,缓存故障直接返回错误,不自动当成 miss 冲击数据库。
|
||
- 同 key 的调用必须具有相同数据含义、权限范围和 TTL;加载采用首个调用者的 context 值。
|
||
多租户 key 必须含租户标识。进程间合并、分布式锁、缓存更新与数据库事务一致性由业务另外设计。
|
||
|
||
Redis 适配器借用调用方创建的 go-redis 客户端和连接池:
|
||
|
||
```go
|
||
client := goredis.NewClient(&goredis.Options{
|
||
Addr: "127.0.0.1:6379", PoolSize: 16, MaxRetries: -1,
|
||
DialTimeout: time.Second, ReadTimeout: time.Second,
|
||
WriteTimeout: time.Second, PoolTimeout: time.Second,
|
||
ContextTimeoutEnabled: true,
|
||
})
|
||
// 启动时先使用有截止时间的 context 执行 client.Ping(ctx)。
|
||
store, err := rediscache.New(client, "billing:") // cache/redis 的导入别名
|
||
if err != nil { _ = client.Close(); return err }
|
||
application.OnShutdown(client.Close)
|
||
// store 可直接传入 cache.NewLoader。
|
||
```
|
||
|
||
连接失败与 Redis 错误保持原样;只有 Redis nil 映射为 `ErrMiss`。Redis TTL 精度为毫秒,
|
||
正的亚毫秒 TTL 提升到 1ms。适配器不负责关闭借用的客户端,Redis 服务端容量策略由部署配置。
|
||
连接池和超时配置参考 [go-redis 官方生产使用说明](https://redis.io/docs/latest/develop/clients/go/produsage/)。
|
||
|
||
## 出站 HTTP:httpc
|
||
|
||
```go
|
||
client, err := httpc.New(httpc.Config{
|
||
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
|
||
Timeout: 3*time.Second, DialTimeout: time.Second,
|
||
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
|
||
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
|
||
IdleConnTimeout: 60*time.Second, MaxResponseBytes: 1<<20,
|
||
})
|
||
if err != nil { return err }
|
||
application.OnShutdown(client.Close)
|
||
err = client.JSON(ctx, "GET", serviceURL, nil, &result,
|
||
httpc.WithHeader("Authorization", "Bearer "+token),
|
||
)
|
||
```
|
||
|
||
启动时创建并复用 Client。代理可选环境、直连、固定地址;不配置时保留标准库原代理策略。JSON 支持单次请求头和响应限额,不保存 Client 全局 Token。新阶段超时与空闲连接参数可选,0 沿用默认。
|
||
|
||
成功响应读取有界;output=nil 流式丢弃,不整体缓存。小错误体只在大小/时间预算内尝试读完以复用连接;未知或大错误体及时关闭。Do 保留标准流式 Request/Response,由调用者限量读取并关闭 body。Close 拒绝新调用并关闭自有池空闲连接,项目先排空在途工作。
|
||
|
||
自定义 Transport 借用、不修改、不关闭;不能同时指定池/代理/阶段选项。总超时持续覆盖 HTTP 等待、重试和读取 body,但不抢占 JSON 编解码或不遵守 context 的自定义代码。MaxRetries 默认 0,只对可重放 GET/HEAD/OPTIONS 重试;3xx 不跟随。标准库和借用 Transport 自身仍可能重放,不能据此承诺业务只执行一次。
|
||
|
||
完整参数、NO_PROXY、TLS、安全日志、重试、签名兼容说明见 [httpc 指南](httpc/README.md),可运行 [HTTP Client 示例](examples/httpClient/README.md)。
|
||
|
||
## 请求保护:middleware
|
||
|
||
```go
|
||
limiter, err := middleware.NewRateLimiter(middleware.RateLimitConfig{Rate: 20, Burst: 40})
|
||
if err != nil { return err }
|
||
idempotency, err := middleware.NewIdempotency(middleware.IdempotencyConfig{
|
||
Scope: func(c *httpx.Context) (string, error) {
|
||
principal, _ := auth.FromContext(c)
|
||
return principal.UserID, nil // 多租户应用应组合租户和用户 ID
|
||
},
|
||
})
|
||
if err != nil { return err }
|
||
api := application.Group("/api", middleware.Timeout(3*time.Second),
|
||
auth.RequireAuth(authenticator), limiter.Middleware())
|
||
api.POST("/orders", createOrder, idempotency.Middleware())
|
||
```
|
||
|
||
限流为进程内令牌桶,`Rate` 为每秒额度,`Burst` 为突发容量。默认按已解析的可信客户端 IP,
|
||
可用 `Key` 按已认证身份分桶。额度不足返回 **429 + Retry-After**;key 数超过 `MaxKeys`
|
||
返回 **503**。满容量时不会踢掉活跃桶让攻击者绕过限流;空闲桶经过完整补充周期后才允许回收。
|
||
|
||
Timeout 设置协作式 deadline,不额外启动 Handler goroutine。业务及下游调用必须响应 context;
|
||
超时后尚未提交响应时返回 **504**,已经提交的响应保持原状态。它不能强制中断任意 Go 代码,
|
||
也不能替代网络层读写超时。不要在业务代码中异步持有 `httpx.Context`。
|
||
|
||
幂等保护适用于小型、非流式写接口:
|
||
|
||
- 必须先认证/鉴权,再执行幂等中间件;`Scope` 必须取自服务端验证过的身份。
|
||
GET/HEAD/OPTIONS 直接通过,写请求要求 `Idempotency-Key`,长度 1..128 个非空格 ASCII 字符。
|
||
- 同 Scope + key + 方法/URL/Content-Type/body 重放首次响应;内容改变返回 **409**。
|
||
正在执行或结果不可重放的相同请求也返回 **409**,不并发执行第二次。
|
||
- 默认最多 32 个处理中请求、256 条记录、16 MiB 保存的响应头/body,单次请求体 1 MiB、
|
||
响应 body 64 KiB。请求超限 **413**,容量满 **503**,响应超限 **500**。
|
||
- panic、5xx、超时、响应超限、Set-Cookie、Cache-Control:no-store 或保存容量不足时保留失败记录,
|
||
阻止在 TTL 内自动重执行业务。业务可能已经提交,调用方应查询实际结果后处理。
|
||
- 完成记录默认保留 10 分钟;在途记录不会因 TTL 到期而允许重复执行。
|
||
重放保留原响应 body、原 `requestId` 与原 `X-Request-ID`,增加 `Idempotency-Replayed:true`;
|
||
本次访问日志仍使用本次请求 ID。
|
||
- 记录仅在当前进程有效,重启、TTL 到期和多副本切换后均无法保证去重。支付、库存等关键写入
|
||
应在数据库事务内配合唯一业务键。该实现不提供跨进程 exactly-once,也不支持 SSE/流式/升级连接。
|
||
|
||
## 后台执行:task
|
||
|
||
```go
|
||
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128, TaskTimeout: 30*time.Second})
|
||
if err != nil { return err }
|
||
application.OnShutdown(jobs.Close) // 在注册数据库/缓存关闭之后注册,先排空任务
|
||
future, err := jobs.Submit(serviceContext, "refreshCatalog", refreshCatalog)
|
||
if err != nil { return err } // 满队列立即返回 ErrQueueFull
|
||
err = future.Wait(ctx) // 可选;不等待时,任务失败仍通过 OnError/logx 报告
|
||
err = jobs.Every("refreshCatalog", time.Minute, refreshCatalog)
|
||
```
|
||
|
||
固定 worker 和有界队列控制资源。任务保留提交 context;需要独立于 HTTP 请求执行时使用服务
|
||
context,或明确使用 `context.WithoutCancel` 保留日志关联值。任务仍受 Runner 的超时/退出控制。
|
||
任务函数与 `OnError` 回调应及时返回,尤其要响应取消;panic 被转为失败,worker 继续工作。
|
||
|
||
`Every` 使用固定间隔,第一次在一个间隔后执行;同一计划上轮尚未完成、排队或队列满时跳过。
|
||
默认最多 128 个计划,名称不可重复。`Stats` 提供 active/queued/completed/failed/skipped。
|
||
它是进程内任务执行器,没有任务持久化、失败自动重试、cron 表达式、跨副本选主或崩溃恢复。
|
||
|
||
`Shutdown(ctx)` 停止接收和调度,排空已接受任务;截止时间到后取消任务并及时返回。
|
||
忽略取消的任务不能被 Go 强杀,可能继续运行。`Close` 默认给排空 10 秒。
|
||
|
||
## 增加业务模块:go-omni add
|
||
|
||
```sh
|
||
go build -o /tmp/go-omni ./cmd/go-omni
|
||
/tmp/go-omni add orderItems --dir /path/to/billing
|
||
```
|
||
|
||
生成 `internal/orderItems/{module.go,model,repository,service,handler,README.md}`,包含 name/createdAt
|
||
示例模型、内存 CRUD、输入校验、错误映射及可注入 Repository。模块名使用 lowerCamelCase。
|
||
|
||
在现有启动代码中导入模块后调用 `orderItems.Register(application, repository, authMiddleware)`。
|
||
脚手架不自动修改现有路由、main、go.mod 或数据库,已有目录不会覆盖。
|
||
内存 Repository 仅为可运行示例:重启丢数据,列表未分页;正式业务应实现自己的持久化 Repository。
|
||
|
||
## 验证入口
|
||
|
||
```sh
|
||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...
|
||
go vet ./...
|
||
go build ./...
|
||
python3 scripts/verifyModules.py --goproxy https://goproxy.cn,direct
|
||
```
|
||
|
||
Linux runner 顺序运行两个容器:全量 race/vet/build/独立生成项目,随后真实 Redis 适配验证。
|
||
每次最多一个容器,限制 2 CPU、2 GiB(不额外使用 swap)、256 pids;Redis 测试容器禁外网,
|
||
只用内部 loopback,不发布端口。Redis 集成二进制为静态非 race,适配器单测在全量 race 中覆盖。
|
||
结束清理本轮容器,并记录提交、源文件摘要、镜像 ID、日志摘要和原有容器是否仍运行。
|
||
|
||
本轮通过结果和原始证据见 [业务模块验收](performance/modulesAcceptance.md)。
|