Skip to content

Releases: EthanCodeCraft/xlgo-core

v1.4.0

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 13 Jul 08:57

v1.4.0 - instance+facade 一致性收尾

把 cron/storage/cache/ratelimit 四个"纯全局"包按 database.Manager/jwt.Manager 模式实例化,trace 纳入 App 生命周期,清理 WithoutWire 技术债。单进程多 App 部署成为一等公民。

为什么发这个版本

database.Manager/logger.LogManager/config.Manager 早已是"实例 + 全局默认 facade"模式,但 cron/storage/cache/ratelimit 仍是纯全局--App 不持 per-App 实例,只绑包级全局 facade。后果:

  • 多 App 互相污染:App A Shutdown 会停掉 App B 的 cron 调度器与限流器;App B Init 覆盖 App A 的 storage driver;cache/ratelimit 硬编码 database.GetRedis(),多 App 下 A 的缓存读到 B 的 Redis。
  • trace 未纳生命周期trace.Close 从未被调用,OTel exporter 后台 goroutine/连接泄漏。
  • WithoutWire 空函数技术债:v1.1.0 删 wire 包后遗留的 no-op,违反框架"不保留 deprecated wrapper"纪律。

本版本收尾这四包的实例化,补齐 trace 生命周期,并修复连带的多 App 跨 App 误杀问题。

⚠️ 破坏性变更(升级前必读)

1. xlgo.WithoutWire() 删除

v1.1.0 删 wire 包后遗留的空 no-op,全仓无引用。直接删除调用即可,无行为变化

2. cron.AddTask(...)App.Init 之前注册不再生效

App 现持专属调度器,Init 时创建并提升为全局默认。pre-Init 调包级 cron.AddTask 会注册到 init 默认调度器、被 App swap 丢弃。

// ❌ 旧(pre-Run 全局注册,v1.4.0 失效)
cron.AddTask("cleanup", cron.Every(5*time.Minute), cleanup)
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithCron())

// ✅ 新(WithCronTask 注册到 App 调度器,蕴含启用 cron)
app := xlgo.New(xlgo.WithConfig(cfg),
    xlgo.WithCronTask("cleanup", cron.Every(5*time.Minute), cleanup),
)

post-Init 的包级 cron.AddTask 仍可用(代理到 App 调度器);standalone(无 App)用法不变。

3. middleware.StopRateLimiters() 语义收窄

仅停默认 Registry 的限流器。App Shutdown 改调自己的 registry.Stop()不再误停其他 App

4. commitReplacedResources 不再关闭 previousDB/previousRedis/loggerSnapshot

多 App 下 "previous" 可能是另一个 App 的活跃资源,关闭致 redis: client is closed 或 logger writer 误杀。单 App 无影响(init 默认无资源)。手动 database.InitRedis 后再 App.Init 的旧 client 关闭由用户负责。

5. cache/ratelimit redis 注入(签名扩展)

  • cache:新增 NewRedisCacheWithRedis(client) / CacheManager.InitWithRedis(client) / SwapDefaultCacheManager(m)NewRedisCache() 仍存在(standalone)。
  • middlewareRedisRateLimiter 新增 WithRedisClient(client) 选项。

✨ 新增

WithTrace - trace 纳入 App 生命周期

app := xlgo.New(xlgo.WithConfigPath("config.yaml"), xlgo.WithTrace())
# config.yaml
trace:
  enabled: true
  service_name: my-service
  endpoint: localhost:4318
  exporter_type: otlp-http   # otlp-http / otlp-grpc / stdout
  sample_ratio: 1.0
  # insecure: true           # 明文 collector 需开启
  # propagator: w3c           # w3c(默认) / b3 / jaeger

Inittrace.Init + 装 trace.MiddlewareShutdowntrace.Close(此前从未调用,致 exporter 泄漏)。不做实例隔离--OTel TracerProvider 是进程级全局单例,多 App 不要同时开 WithTrace

App-bound 限流中间件(多 App per-App 计数隔离)

// 包级 LoginRateLimit() 多 App 下仅绑最后 Init 的 App;
// 用 app-bound 实现真正的 per-App 计数隔离:
app.GetRouter().GET("/login", app.RateLimitRegistry().LoginRateLimit(), handler)

新增 App getter(绕过全局 facade,直取 App 实例)

  • app.Scheduler() - cron 调度器
  • app.Cache() - 缓存服务
  • app.RedisClient() - Redis 客户端(注入 RedisRateLimiter 等)
  • app.RateLimitRegistry() - 限流器 Registry

其它

  • cron/storage/cache/middlewareSwapDefault*(App Init 失败回滚用)
  • config.TraceConfig + Config.Trace 字段
  • storage.StorageManager.Close() - 闭合 Init/Close 生命周期(io.Closer 断言,当前 no-op,为未来驱动预留收口点)

🐛 修复

  • 多 App cron 隔离:App A Shutdown 不再停掉 App B 的调度器。
  • 多 App storage/cache 隔离:App B Init 不再覆盖 App A 的 driver / cache redis;cache 不再硬编码全局 Redis。
  • 多 App 限流器:App A Shutdown 不再重置 App B 的 loginLimiter 计数(原 StopRateLimiters 全局置 nil 致 B 下次请求懒创建新 limiter、计数清零,稳态客户端借 A 的 Shutdown 窗口绕过限流)。
  • trace exporter 泄漏App.Shutdown 现调 trace.Close
  • 多 App logger/redis 跨 App 误杀commitReplacedResources 不再关闭非己所有的 previous 资源。

多 App 边界(使用前知悉)

说明
包级限流 facade LoginRateLimit 等多 App 下仅绑最后 Init 的 App;per-App 计数隔离用 app.RateLimitRegistry()
CustomRateLimit(包级) 首请求须在 Init 后(否则 cleanup goroutine 泄漏);app-bound 路径无此问题
trace 进程全局共享,多 App 任一 Shutdown 关全局导出
StorageManager.Close 当前 no-op(oss.Client 无 Close),未来驱动实现 io.Closer 即自动收口

✅ 验证

  • go build + go vet + go test -race ./... 全绿。
  • 新增契约测试:trace lifecycle、storage/cron/cache/ratelimit 实例隔离 + 多 App 不串 + rollback 还原、cache 多 Redis 隔离、ratelimit 多 App Shutdown 不重置计数 + per-App 计数隔离、storage Close 各路径。
  • 独立对抗性复审:无 CRITICAL/HIGH,发现项全部处置。

升级

go get github.com/EthanCodeCraft/xlgo-core@v1.4.0

⚠️ 含破坏性变更,升级前请阅读"破坏性变更"并按迁移示例调整。完整变更见 CHANGELOG.md

v1.0.3

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 12 Jul 14:49

同义可失败 API 收敛 + 注释/文档一致性修复 + 前序评审收口。

API 收敛gpt_clear_function.md 计划):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛 cache/jwt/storage/ratelimit 四个包的历史双轨 API。自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

注释/文档一致性glm_note_report.md 报告):A/B/C 三档共 23 项注释/文档修复,无运行逻辑变更。

前序评审收口:M13 cron panic、M1 App 生命周期状态机、M3 logger 生命周期临界区;config 模块评审(H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫);database 模块评审(H-db-1 后台探活/启动 ping 经 pingWithTimeout 3s 约束)。

项目处于初级阶段,无下游用户,放心引入破坏性变更。go vet + go build + go test -race ./... 全绿。

Breaking ⚠️

  • PostgresDSN 默认 sslmode 由 disable 改为 prefer(M-config-2):原 PostgresDSN 硬编码 sslmode=disable,生产 DB 流量明文。新增 DatabaseConfig.SSLMode 字段,空值默认 prefer(优先加密、失败回退明文),可显式配置 disable/allow/prefer/require/verify-ca/verify-full(非法值在 Validate 阶段报错)。依赖明文 Postgres 连接的下游若因 prefer 回退行为受影响,请显式设置 ssl_mode: disable。MySQL 不受影响(用 TLS/TLSRootCA)。

  • cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 ErrRedisNotReadySet / Delete / DeleteByPattern / Incr / IncrBy / Decr / GetTTL / SetExpire / GetRaw / SetRaw 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 errors.Is(err, cache.ErrRedisNotReady) 或显式启用 Redis。

  • cache.WithLock / cache.WithLockAutoExtend 未获取到锁时返回 ErrLockNotAcquired:旧行为返回 nil 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 time.NewTicker(0) 崩溃。

  • cache.WithLock / cache.WithLockAutoExtend 的业务函数签名改为 func(context.Context) error:旧签名 func() error 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 ErrLockFuncNil

  • test.Request.Execute() 改为返回 *test.Response:旧返回值是 *httptest.ResponseRecorder,与文档示例中的 resp.AssertOK(t) / resp.ParseJSON(...) 不一致;现在 Execute() 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 ExecuteRecorder()

  • config.Get() / (*config.Manager).Get() 改为返回配置副本:旧行为暴露内部 *Config,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 config.Set(cfg)

  • config.Set() / (*config.Manager).Set() 现在返回 error:非 nil 配置会先执行 Validate(),非法配置不会覆盖旧配置,并返回 ErrInvalidConfig 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。

  • config.GetViper() / (*config.Manager).GetViper() 改为返回 viper 快照:旧行为暴露内部可变 *viper.Viper;现在修改返回对象不会影响全局配置。常规读取请使用 GetString / GetInt / GetBool / GetStringMap

  • 未知数据库 driver 不再静默回退 MySQLconfig.DatabaseConfig.DSN() 对非空但未注册的 driver 返回空字符串,database.Dialector 返回初始化即失败的 Dialector;空 driver 仍保持默认 MySQL。下游若使用自定义数据库驱动,需先通过 database.RegisterDialectconfig.RegisterDSNBuilder 注册。

  • database.InitDB / (*database.Manager).InitDB / InitDBWithReplicas 必须显式传入 context.Context:旧 API 无法取消初始化过程中的 Ping 与重试等待,shutdown 或启动失败回滚时可能长时间卡住。现在调用方必须传入生命周期 ctx;普通测试或一次性脚本可使用 context.Background(),App 初始化会使用 App root ctx。

  • database.SetDefaultManager / database.SetDefaultRedisManager 会关闭被替换的旧 manager:旧行为只做 atomic 替换,直接调用会遗留旧 DB/Redis 连接池。现在普通 Set 表示“接管全局默认资源并释放旧资源”;需要失败回滚或延迟释放旧资源的初始化流程请改用新增 database.SwapDefaultManager / database.SwapDefaultRedisManager

  • jwt.ParseToken 开始校验 issuer,RefreshToken 使用 jwt.refresh_expire:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 refresh_expire,未配置时回退 expireGenerateTokenWithCustomExpiry 现在拒绝非正过期时间,InvalidateTokenByID("") 返回 ErrEmptyJTI

  • App.Init()sync.Once 改为生命周期状态机(app.go,M1):5 态 stateCreated/Initializing/Initialized/Stopping/Stopped + lifecycleMu(RWMutex) + initMu(Mutex)。Shutdown 后或 Init 失败后再调 Init() 返回新增导出错误 xlgo.ErrAppClosed(原 sync.Once "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。

  • App.Go() 在 Shutdown 开始或 Init 失败后为 no-op(app.go,M1):state >= stateStopping 时拒绝 wg.Add 直接返回,避免与 Shutdownwg.Wait 竞争 sync.WaitGroup 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。

  • 生命周期 hook 不允许重入调用 Init/Shutdown/Run(app.go,M1):initMu 非重入,hook(OnInit/OnStart/OnReady/OnStop)内调用会自锁死锁。需在 hook 内触发关闭应改用信号通道由主流程处理。

  • xlgo.WithConfig(cfg) 改为快照语义并在 Init 时校验(app.go,M1):传入配置会深拷贝到 App 私有快照,调用方后续修改原 cfg 不再影响 App;非法配置在 Init 返回中文校验错误。依赖“修改原 cfg 指针动态影响 App”的下游需改为重新创建 App 或使用配置管理器。

  • logger.DefaultLogger = m 直接赋值不再驱动包级 facade(logger/logger.go,M3):为消除 SetDefaultLogManager() 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。logger.DefaultLogger 仍保持 *LogManager 类型,旧的 logger.DefaultLogger.Init/Close/SetLevel/GetLevel 直接调用仍可用;替换默认 manager 请使用 logger.SetDefaultLogManager(m)

  • logger.Init 拒绝明显非法日志配置(logger/logger.go,M3):空日志目录、负数 MaxSize/MaxBackups/MaxAge 现在直接返回错误。依赖零值日志配置启动 WithLogger() 的下游需显式设置 Log.Dir 与非负轮转参数。

  • 限流器非法配置改为 fail-fast(middleware/ratelimit.go,M8):NewRateLimiter / NewRedisRateLimiter / NewRedisRateLimiterFailClosed 现在对 rate <= 0window <= 0 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。

  • handler.BindJSON 默认限制 JSON body 为 1MiB(handler/handler.go,M6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 handler.BindJSONWithMaxBytes(c, req, maxBytes) 显式声明上限。

  • cron 非法任务配置改为 fail-fast(cron/cron.go,M13):AddTask 拒绝 nil schedule / nil handler;Every(<=0)Daily/Weekly 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。

  • cron.ParseCron 非法表达式改为 fail-fast panic(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 ParseCronStrict 处理 error;确实需要旧回退语义时改用新增 ParseCronOrDefault

  • repository 查询保护默认开启(repository/repository.go,M5/N5):FindAll 默认最多返回 DefaultFindAllLimit=1000 条;明确需要全表扫描时改用 FindAllUnboundedFindPage* / QueryBuilder.Page 会归一化 page/pageSize 并限制 MaxPageSize=100MaxPage=10000Find*Ordered / QueryBuilder.Order 只接受简单字段排序(如 created_at DESC, id ASC),复杂表达式/raw SQL 会返回 ErrUnsafeOrderUpdateBatch 字段名不合法返回 ErrUnsafeField

  • 同义可失败 API 收敛:错误不再吞并(cache/jwt/storage/ratelimit):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛历史双轨 API。详见下述分项;自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

    • cacheCacheService.Get / Exists 改为 (bool, error)--命中 (true,nil)、未命中 (false,nil)、Redis 未就绪/命令错误/反序列化失败返回 (false,err)。删除 cache.GetE / cache.ExistsE / CacheGetter / CacheExistChecker / redisCache.GetE / redisCache.ExistsE(不保留 deprecated wrapper)。新增包级 cache.Get(ctx,key,dest) (bool,error) / cache.Exists(ctx,key) (bool,error)GetWithPrefix 改为 (bool,error)。迁移:hit, err := cache.Get(ctx, key, &v); if err != nil { return err }; if !hit { /* miss */ }

    • jwtTokenBlacklist.IsBlacklisted 改为 (bool, error),删除 IsBlacklistedEIsTokenRevoked 改为 (bool, error)ParseToken 默认从 fail-open 改为 fail-closed--黑名单后端不可检查时返回 ErrBlacklistUnavailable 拒绝该 Token(无 Redis 部署不再支持可靠撤销)。删除 ParseTokenFailClosed(主 API 已 fail-closed)。保留 ParseTokenWithBlacklistPolicy(token, policy) + BlacklistPolicy/BlacklistFailOpen/BlacklistFailClosed 供显式 fail-open(仅无 Redis 或低安全场景)。迁移:无 Redis 部署需启用 Redis,或显式 jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen)

    • storageStorage.Exists 改为 (bool, error)--存在 (true,nil)、不存在 (false,nil)、未初始化/路径非法/穿越/后端错误返回 (false,err)LocalStorage.Exists 区分 os.IsNotExist(not found)与其他 os.Stat 错误;OSSStorage.Exists 区分 OSS 404/NoSuchKey(not found)与鉴权/网络错误。包级 storage.Exists 同步签名,未初始化返回 ErrStorageNotInitialized。新增 ErrReadTooLargeLocalStorage.Get / OSSStorage.Get 读取超 maxReadBytes 上限原误用 ErrInvalidPath(路径无效语义不贴切),改为 ErrReadTooLarge。迁移:ok, err := storage.Exists(p); if err != nil { return err }; if !ok { /* not found */ }

    • ratelimit:Redis 限流器策略收敛为配置型 API。NewRedisRateLimiter(keyPrefix, rate, window, opts ...RedisRateLimiterOption) 新增可变参数,WithFailClosed(true) 替代原 NewRedisRateLimiterFailClosedRedisRateLimit / CustomRedisRateLimit / RedisRateLimitWithIdentifier 同步加 opts 参数。删除 NewRedisRateLimiterFailClosed / RedisRateLimitFailClosed / CustomRedisRateLimitFailClosedUploadRedisRateLimit 由 fail-open 改为 fail-closed(上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效)。迁移:middleware.RedisRateLimit("k", 100, middleware.WithFailClosed(true)) 替代原 RedisRateLimitFailClosed("k", 100)

Security 🔒

  • MySQL 连接支持 TLS(M-config-2):DatabaseConfig.TLS 为 true 时 MySQLDSN 追加 tls=true(go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 DatabaseConfig.TLSRootCA(PEM 路径)可指定私有 CA/自签证书,由 database 包在 InitDBRegisterTLSConfig 注册命名配置(config.MySQLTLSConfigName);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。
  • Postgres 连接支持 sslmode 配置(M-config-2):见 Breaking 项,默认 prefer 优先加密。

Fixed 🐛

  • config Set(cfg) 与 viper 视图同源修复(H-config-1):Set 原只更新类型化视图 m.cfgm.v(viper)停留旧值,导致 Get()GetString/GetInt/GetBool/GetViper 返回不同世界(违反 C1 单一配置源)。现在 Set 用 mapstructure 将 *Config 重建为不含 AutomaticEnv 的 viper 视图,保证 Get 与 GetString 同源。
  • config 热重载回调 panic 隔离(M-config-1):单个 onChange 回调 panic 原会传播致 watcher 泄漏、后续热更新静默失效。现在每个回调独立 recover(标准库 log 记录 + 堆栈),不阻断后续回调、不杀 watcher。
  • App.Shutdown 停止 configManager watcher(M-config-3):closeResources 末尾新增 configManager.StopWatcher(),用户对 App 的 configManager 调 LoadWithWatch/StartWatcher 后由 Shutdown 统一收口,避免关闭后遗留监听 goroutine(违反 C7)。
  • config Clone 切片字段覆盖守卫(M-config-4):新增反射测试枚举 Config 所有切片/map 字段,断言 Clone 深拷贝;新增切片字段未同步 fixture 或 Clone 时测试失败,形成机械守卫。
  • config watchLoop 增加 ctx 逃生通道(L-config-2):原仅靠 w.Events 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。StopWatcher 改为 cancel ctx + Close watcher 双重退出。
  • config Validate 连接池交叉校验(L-config-3):MaxOpenConns>0MaxIdleConns>MaxOpenConns 视为配置错误。
  • config 哨兵错误改用 errors.New(L-config-4);DSN() 去除冗余 TrimSpace(L-config-5);DSN/MySQLDSN/PostgresDSN/Addr nil receiver 防御(L-config-6)。
  • database 后台探活/启动 ping 经 pingWithTimeout 3s 约束(H-db-1,M11 修复不完整):pingWithTimeout 原只加到包级 HealthCheck(),未覆盖方法 (*Manager).HealthCheck(被后台探活 probeOnce master 与 /health 端点共用)、probeOnce 从库 ping、InitDB/InitDBWithReplicas 启动 ping。挂起 DB(连接活但不响应)下这些路径的 PingContext 无 ctx deadline 无限阻塞,...
Read more

v1.2.0

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 04 Jul 04:03

v1.2.0 — 破坏性版本:4 轮评审收口 + 主线A 并发治理统一

发布日期:2026-07-04
版本:v1.2.0(破坏性版本)
上一版本v1.1.1
验证go vet + go build + go test -race ./... 全绿


概述

v1.2.0 是 xlgo 框架的破坏性版本,核心是 4 轮对抗性评审收口的全部 CRITICAL/HIGH/MEDIUM 修复,加上 主线A「包级可变全局并发治理统一」——把框架内所有包级可变全局一律收敛到 atomic.Pointer / sync.Once / 锁,根除"局部全对、全局全错"的并发安全隐患。

本轮无新增功能,全部为结构性修复与对齐。三条主线闭合:

  1. 包级可变全局保护统一(config/cache/jwt/trace/database/redis/storage/validation/router/response 全部 atomic.Pointer
  2. 资源生命周期在重建路径上彻底释放(trace sync.Once、app OnReady、http transport、ws Hub)
  3. 跨文件失败语义/契约一致(Redis 不可用、健康检查、WithConfig 契约)

⚠️ 破坏性变更(升级前必读)

1. 包级可变全局一律 atomic.Pointer(主线A)

database.DefaultRedis / database.DefaultManager / storage.DefaultStorage / validation.Validator 由裸指针改为 atomic.Pointer[T],消除无锁置换/读取的数据竞争。

迁移

旧 API 新 API
database.DefaultRedis.Init(cfg) database.InitRedis(cfg)database.DefaultRedis.Load().Init(cfg)
database.DefaultManager = myDB database.SetDefaultManager(myDB)
database.DefaultManager.Master() database.GetDB()database.DefaultManager.Load().Master()
storage.DefaultStorage.X(...) storage.X(...) facade 或 storage.DefaultStorage.Load().X(...)
validation.Validator.Struct(s) validation.ValidateStruct(s)validation.Validator.Load().Struct(s)

2. jwt.DefaultJWT 包级变量删除

jwt.GetDefaultJWT() / jwt.SetDefaultJWTManager()

3. repository.FindWhereOrdered / FindPageWhereOrdered 签名变更(H-15)

args []anyargs ...any;因 Go 变长参数须为末尾参数,order 前置于 query

// 新签名
FindWhereOrdered(ctx, order, query string, args ...any)
FindPageWhereOrdered(ctx, page, pageSize int, order, query string, args ...any)

4. response.ResponseData / RequestID 去掉 omitempty(M-38/M-39)

datarequest_id 字段在所有响应中恒存在(失败时 data:null、未装 RequestID 中间件时 request_id:"")。下游严格按 schema 解析不再缺字段。

5. cache.IsLocked / GetLockTTL / ForceUnlock Redis 不可用改返 ErrRedisNotReady(M-E)

锁操作(正确性相关)Redis 不可用返 ErrRedisNotReady;cache 数据操作(Get/Set/Incr,性能层)保持 best-effort 静默。调用方 errors.Is(err, cache.ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。

6. config.Load() 返回深拷贝(M-G)

新增 (*Config).Clone() 深拷贝所有切片字段(CORS/Upload/Storage 白名单);Load() 与 reload 回调返 Clone。Get() 仍返回内部只读指针(热路径零分配),需可变副本用 Clone()

7. handler.GetPagepage 上限 10000(M-D)

?page=999999999 产生超大 OFFSET 拖垮 DB(深分页 DoS)。超过钳制到 MaxPage,需更深遍历改游标/keyset 分页。

8. utils.EqualsIgnoreCasestrings.EqualFold(L-C)

原仅 ASCII 字节折叠('A'-'Z'+32)对非 ASCII(如 É/é)误判为不等;现 Unicode 大小写折叠,行为更正确且更快。

9. utils.ReadFileFileExists 前置检查(M-F)

消除 TOCTOU 竞态,直接 os.ReadFile。文件不存在返 *os.PathError,用 errors.Is(err, os.ErrNotExist) 判断(原字符串 "file not found" 不再返回)。

10. App.Init()sync.Once

多次调用返回首次执行结果(含错误),不再"第二次直接返回 nil"。

11. xlgo.WithConfig(cfg) 不再调用 config.Set(cfg)

配置不再写入全局状态。依赖 config.Get() 取注入配置的下游改用 WithConfigPath(或 NewFullStack)。

12. database.RedisClient 不再可外部访问

包级变量改 unexported。所有消费者用 database.GetRedis();测试注入用 database.SetTestRedisClient(c)


Bug修复(4 轮评审)

第一轮:CRITICAL + HIGH

  • U1 UUIDShort 生成错误(保留破折号)→ ReplaceAll
  • M1 filterSensitiveFields 假过滤(密码仍可见)→ 编译期正则真抹除
  • A1 App.Init 并发竞态 → sync.Once
  • D3/CK1 Redis 客户端访问竞态与不一致 → unexported + 单源 GetRedis
  • M2 Metrics in-flight gauge 泄漏 → defer Dec()
  • R1 FailWithError 丢弃 Detail → 走 ToResponse

第二轮:并发纪律红线 + 生命周期/泄漏修复

  • C-1/H-4 DefaultRedis 并发竞态 + redisClient 双源 → atomic.Pointer + 单源
  • H-13 validation.Validator 无锁读写 → atomic.Pointer
  • H-11 storage 死代码全局 + DefaultStorage 裸指针 → 删除 + atomic.Pointer
  • H-10 response.Error.WithDetail 并发不安全 → 返回拷贝
  • H-6 RedisRateLimiter.failClosed 数据竞争 → atomic.Bool
  • H-7 GetCSRFToken 裸断言 + 非恒定时间比较 → comma-ok + subtle.ConstantTimeCompare
  • H-14/M-64/M-65 trace.Close sync.Once 泄漏 + Init 回滚不全 → 去 sync.Once,Swap+Shutdown
  • H-8/H-9 ws.Hub.Stop double-close panic + WaitGroup Add/Wait 竞态 → stopOnce + runDone channel
  • H-1 app.OnReady 失败资源泄漏 → 走 Shutdown()
  • H-2 app.Go + Init 失败 goroutine 泄漏 → cancel rootCtx + 限时等 wg
  • H-12 utils.HTTPClient.SetSkipTLS 数据竞争 → 写锁下重建 transport+client

第三轮:P0 安全阻断 + P1 并发/资源收口

  • #1 JWT 算法混淆(alg confusion)→ WithValidMethods + *jwt.SigningMethodHMAC 断言
  • #2 JWT 空密钥 fail-closed → ErrEmptySecret
  • #3 JWT 不支持算法拒绝 → ErrUnsupportedAlgorithm
  • #4 上传大小实测封顶(不信任客户端 file.Size)→ enforceUploadSize/enforceMaxReader
  • #5 HTTP header/cookie map 竞态 → 写锁 + snapshotHeadersCookies 快照
  • #6 HTTP SSRF 防护 → NewSSRFSafeHTTPClient + net.Dialer.Control 拦截内网/元数据 IP
  • #7–#21 P1 并发/资源/泄露收口:console 写锁、config TOCTOU + debounce timer Stop、CSRF body 复原、cron StopWithTimeout + WithCron() 生命周期、database m.cfg 锁内读写、isTransientDBError 移除过宽子串、router applyOnce + 排序、logger 查询脱敏、response Detail 门控、validation 密码/手机号强化 + RegisterValidation 检错、trace noop + defer span.End() + 低基数 span 名、storage Abs fail-closed、CLI 硬化(输入校验 + 失败回滚)

第四轮:终审剩余项收口

  • H-A 从库连接池 MaxOpenConns/2 截断(配置 ≤1 时变 0 无限制)→ replicaMaxOpenConnsmax(1,/2)
  • H-B router.GroupWithMiddlewareGroup nil panic → 改走 ensureRegistry()
  • M-A JWT 黑名单无超时 + 吞错 → context.WithTimeout(1s) + .Result() 显式错误
  • M-B Recover 响应已写出时无效写 500 → c.Writer.Written() 守卫
  • M-C RedisRateLimiter 多次 GetRedis() nil-deref 窗口 → 取一次 rdb 复用
  • M-D handler.GetPage 深分页 DoS → MaxPage=10000
  • M-E Redis 不可用失败语义统一 → ErrRedisNotReady(见破坏性变更 #5)
  • M-F HashFile/ReadFile OOM 与 TOCTOU → 流式 io.Copy + 去 TOCTOU
  • M-G config.Get() 切片别名 → Clone() 深拷贝(见破坏性变更 #6)
  • M-H cron.checkAndRun 持锁 spawn 阻塞管理 API → 锁内收集、锁外 spawn
  • L-A compress 解压残留文件 → os.Remove
  • L-B utils 正则重编译 → 包级 regexp.MustCompile
  • L-C EqualsIgnoreCase 非 ASCII 误判 → strings.EqualFold
  • L-D redisLimiters 死代码 → 删除
  • L-E logger.Logger 导出变量 → 标 Deprecated
  • L-H ws.SetCheckOrigin 无锁写 → 文档约束"仅启动前调用"
  • L-J cron.RunTasks.ctx → 文档说明 Stop 后行为

未修(评估后决定):L-K model 时间戳 omitemptytime.Time 的 omitempty 是 no-op,报告建议无效,真修需 *time.Time,暂不做);L-I response.writeResp nil-c(nil *gin.Context 是程序员错误,panic 恰当,加守卫反掩盖 bug)。


🔒 安全

  • JWT:算法混淆 / 空密钥 / 不支持算法全部 fail-closed(WithValidMethods + HMAC 断言 + ErrEmptySecret/ErrUnsupportedAlgorithm)。
  • 上传:大小实测封顶(不信任客户端 file.Size),超限清理半截文件。
  • HTTP:SSRF 防护(拦截回环/私有/链路本地/元数据 IP,覆盖重定向每一跳);headers/cookies map 并发安全。
  • CSRF:token 恒定时间比较(subtle.ConstantTimeCompare)+ comma-ok 类型断言。
  • 鉴权路径:JWT 黑名单 Redis 操作 1s 超时(防 Redis 挂起阻塞每个请求的鉴权)。
  • 限流:登录防爆破场景 fail-closed(Redis 故障时拒绝,防限流静默失效)。

📚 文档与脚手架

  • 脚手架对齐cmd/xlgoRepositoryMake 模板 FindByName 改用 FindOne(走 readConn 读写分离,避免 GetDB 不路由的 M-35 footgun);CLI 已硬化(项目名/模块路径校验 + 失败回滚)。
  • 文档核验:README/GUIDE 逐行对照源码核验,修复 7 处差异——
    • README DB 示例 users := ...Find(&users) 自引用声明编译错误
    • GUIDE xlgo.StartServer(engine, 8080) 不存在 → engine.Run(":8080")
    • GUIDE app.token_expire 死键(v1.1.0 已移除 AppConfig.TokenExpire
    • GUIDE jwt.expire: 86400 类型错(time.Duration 需字符串 "24h"
    • GUIDE storage.Init(&cfg.Storage) 吞错误 → 显式 err 处理
    • GUIDE 登录示例吞 jwt.GenerateToken 错误 + time.Duration 双重转换
    • GUIDE 验证规则表补 phone_strict / username
  • CHANGELOG/README 新增 v1.2.0 条目;GUIDE 文档版本同步至 v1.2.0。

升级指南(v1.1.1 → v1.2.0)

  1. go get github.com/EthanCodeCraft/xlgo-core@v1.2.0
  2. 全局搜索旧 API 并按"破坏性变更"章节迁移:
    • database.DefaultRedis. / database.DefaultManager. / storage.DefaultStorage. 直接方法调用 → facade 或 .Load()
    • database.DefaultManager = database.SetDefaultManager()
    • jwt.DefaultJWTjwt.GetDefaultJWT()
    • validation.Validator.Structvalidation.ValidateStruct
    • database.RedisClientdatabase.GetRedis()
    • FindWhereOrdered / FindPageWhereOrdered 旧签名 → 新签名(order 前置,args ...any
  3. 若依赖 response.Responsedata/request_id 在失败时缺失:现在两字段恒存在,按需调整下游 schema 解析。
  4. 若调用 cache.IsLocked 等锁操作:检查 Redis 不可用时的错误处理,改用 errors.Is(err, cache.ErrRedisNotReady)
  5. 若用 utils.ReadFile 判文件不存在:改用 errors.Is(err, os.ErrNotExist)
  6. 验证:go vet ./... && go build ./... && go test -race ./...

脚手架生成的项目(xlgo new)默认只走稳定 facade,不直接引用任何被改类型的符号,开箱即编译通过。


验证

go vet  -buildvcs=false ./...          # EXIT 0
go build -buildvcs=false ./...          # EXIT 0
go test  -buildvcs=false -race -count=1 ./...   # 全 ok,EXIT 0

完整变更历史:见 CHANGELOG.md[1.2.0] 章节。
评审报告:本轮修复依据 deepseek v4 Pro / GLM 5.2 / Claude opus 4.8 三方独立评审 + 终审交叉核验。

v1.1.1

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 23 Jun 15:22

v1.1.1

v1.1.0 的补丁发布:补 ServerConfig.Host 字段、统一面向用户文案为中文、修正 README 过时/错误描述。

Added ✨

ServerConfig.Host(绑定地址)

server 新增 host 字段控制监听地址:

  • host: ""(默认)→ :8080,监听所有接口(0.0.0.0),向后兼容
  • host: "127.0.0.1" → 仅本机(前面有 nginx 时常用)
  • host: "10.0.0.5" → 绑定内网网卡

避免生产环境无意暴露在 0.0.0.0。

Changed 🔄

面向用户文案统一中文

v1.1.0 前部分面向用户/调用的文案为英文,与其余中文不一致。本次统一为中文:

  • middleware/recover.go"Panic recovered""panic 已恢复""Panic: %v""服务器内部错误: %v"
  • middleware/logger.go:5 处日志消息改中文
  • middleware/metrics.go:3 个 Prometheus Help 改中文
  • app.go / database/manager.go / logger/logger.go:英文 error 改中文

保留英文:JSON 字段名、health 状态枚举、Prometheus metric Name、MySQL 错误串匹配、技术专有名词。

Fixed 🐛

README 错误描述修正

v1.1.0 后 README 存在过时/错误描述,照抄会导致新用户启动失败:

  • 删除目录结构里已移除的 wire/
  • 快速开始配置示例jwt.secret 补足 ≥32 字节(否则被 Validate 拦截启动失败);expire: 86400(int 秒)改为 expire: "24h"time.Duration),补 refresh_expire/issuer/algorithm
  • server 段补 host/timeout/response_mode 字段
  • v1.0.2 更新日志标注 WithWire 已移除
  • 目录结构补 v1.1.0 新文件(metrics/timeout/validate/mode 等)
  • 框架特性段重写,补全 v1.1.0 能力
  • 响应格式段补 Mode 开关与 Custom API

升级说明 🛠️

从 v1.1.0 升级无破坏性变更,host 字段默认空,行为与 v1.1.0 一致。

升级命令go get github.com/EthanCodeCraft/xlgo-core@v1.1.1

完整变更见 CHANGELOG.md

v1.1.0

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 23 Jun 15:14

v1.1.0 — HA & Manager 化

本版本定位为 HA & Manager 化 release:高可用与生产就绪改进 + 组件 Manager 化。对应体检报告 #10-#24。
⚠️ 含少量破坏性变更,升级前请阅读「升级说明」。

Breaking ⚠️

  • 删除 wire 包及 WithWire Option(WithoutWire 保留空 stub 兼容)
  • 删除 AppConfig.TokenExpire(与 JWTConfig.Expire 重复)
  • JWTConfig.Expireint(秒)改为 time.Duration"24h"
  • 删除 StartServerWithPortGracefulShutdown 双轨函数

Added ✨

组件 Manager 化(#10)

storage / cache / redis / jwt / logger 五组件新增 XxxManager + DefaultXxx + SetDefaultXxxManager,包级 facade 保留兼容存量。支持多实例与测试注入 mock。

Lifecycle Hooks(#12)

WithHook(Hook{OnInit/OnStart/OnReady/OnStop}),覆盖 Init/启动/就绪/关闭各阶段。

App.Go + in-flight goroutine(#22)

App.Go(func(ctx)) 启动受管理的后台 goroutine,Shutdown 时 cancel + wg.Wait(带超时)。

Server 参数配置化(#13)

server 新增 read_timeout/write_timeout/idle_timeout/shutdown_timeout/max_header_bytes/tls/unix_socket/response_mode,支持 TLS 与 unix socket。

JWTConfig time.Duration(#14)

jwt.expire/refresh_expire 用 Duration("24h"/"168h"),新增 issuer/algorithm

Config Validate(#16)

Config.Validate()Manager.Load 后自动调用,启动期拦截非法配置。

response REST 模式(#15)

response.SetMode(ModeBusiness|ModeREST),默认兼容存量;ModeREST 按错误码映射 HTTP status。新增 response.Custom()

livez / readyz(#17)

/livez(存活,始终 200)+ /readyz(就绪,依赖检查 503),K8s probe 友好。

Prometheus metrics(#18)

/metrics 端点 + middleware.Metrics() 采集 http_requests_total/http_request_duration_seconds/http_requests_in_flight

请求级 Timeout 中间件(#19)

middleware.Timeout(d),下游走 c.Request.Context() 级联取消。

依赖健康自愈(#21)

主库后台探活 + replica 健康剔除,/readyz//health 联动 503。新增 conn_max_idle_time/health_check_interval/health_check_failure_threshold 配置。

RequestID 默认装入(#24)

App.Init 无条件装入 RequestID()(Recovery 前),响应/panic 日志均带 request_id。移除 gin.Recovery() 双重保险。

升级说明 🛠️

  1. wire 包删除:移除 wire import 与 wire.InitServices()/WithWire() 调用。cache.Init() 现由 WithRedis 自动触发。
  2. AppConfig.TokenExpire 删除:改用 jwt.expire。grep 清理 token_expire
  3. JWTConfig.Expire 类型变更:YAML expire: 86400expire: "24h";代码 time.Duration(cfg.JWT.Expire) * time.Secondcfg.JWT.Expire
  4. StartServerWithPort / GracefulShutdown 删除:改用 App.Run() / App.Shutdown()
  5. JWT 密钥长度:Validate 要求启用 JWT 时 secret ≥32 字节,短密钥启动期被拦截。
  6. 配置文件jwt.expire 必须改为 Duration 字符串;server.read_timeout 等可选(缺省回退)。

升级命令go get github.com/EthanCodeCraft/xlgo-core@v1.1.0

完整变更见 CHANGELOG.md

v1.0.4

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 22 Jun 15:35

v1.0.4 — DX & Docs Release

定位:开发体验与文档改进,无破坏性 API 变更。对应体检报告 #25/#27/#28/#29/#30。

完整变更说明见 CHANGELOG.md


✨ Added

CLI 多模板(#28)

xlgo new 新增 --template 参数,支持三种脚手架模板:

xlgo new myapp --template minimal     # 轻量 HTTP,无 MySQL/Redis 依赖
xlgo new myapp --template api         # 标准业务 API,含分层目录(默认)
xlgo new myapp --template fullstack   # 全组件,NewFullStack 一键启用
模板 说明 适用场景
minimal 仅 logger + health + 示例路由,目录最小化 第一次接触 xlgo、纯 HTTP 服务
api handler/model/repository/service 分层 + MySQL/Redis/JWT 配置 标准业务 API(默认)
fullstack NewFullStack 全组件 + Swagger + Storage 全功能应用

examples/ 目录(#29)

新增两个可运行示例,帮助快速上手:

  • examples/minimal/ — 50 行可跑,不依赖外部服务
    go run ./examples/minimal
  • examples/full/ — MySQL + Redis + JWT + user CRUD 完整示例(登录发 token、认证路由、创建/查询用户)
  • examples/README.md — 运行说明与接口文档

docs/ 文档结构(#30)

  • 新增 docs/ 目录,docs/plans/ 归档历史规划与体检报告
  • 新增 docs/README.md 文档索引
  • 归档:Version_Update_Plan_v1.0.2.mdVersion_v1.0.2_report.md、早期 report.md(→ v2.0-review.md
  • CHANGELOG.md / GUIDE.md 按惯例保留在仓库根目录

🔄 Changed

模块路径文档改进(#25)

经评估保留 xlgo-core 模块路径——-core 后缀反映这是 xlgo 多产品系列(xlgo-core / xlgo-orm / xlgo-ai ...)的核心产品,不去掉。模块路径(github.com/EthanCodeCraft/xlgo-core)与包名(xlgo)不同是 Go 惯例(cf. github.com/gin-gonic/gin → 包名 gin)。

改进文档说明,消除新用户 go mod tidy 撞墙的困惑:

  • README 快速开始新增「模块路径与包名」小节,给出完整 import 示例:
    import xlgo "github.com/EthanCodeCraft/xlgo-core"
  • CLAUDE.md Import Path Note 措辞明确化,说明 module path / package name / -core 语义

Without* Option 定位文档化(#27)

经调研 Without* 系列 Option 有真实用例(测试覆盖「先开再关」语义 + NewFullStack 后排除单项),不删除、不标 Deprecated,改为文档化其定位:

  • app.go WithoutLogger 注释说明:Without* 主要用于 NewFullStack / RunFullStack 启用全部组件后排除个别项
  • README 用法示例:xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes()) 全组件但关 Swagger

升级说明

v1.0.4 无破坏性变更,从 v1.0.3 升级只需:

go get github.com/EthanCodeCraft/xlgo-core@v1.0.4
go mod tidy

Full Changelog: v1.0.3...v1.0.4

v1.0.3

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 22 Jun 14:17

v1.0.3 — Bug Fix Release

定位:bug fix release。收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查 + 版本号治理 + 文档对齐。

完整变更说明见 CHANGELOG.md


⚠️ Breaking Changes(升级前必读)

1. 错误码体系重构(response 包)

修复 CodeSuccessCodeInvalidParams 撞码的生产级 bug(两者都等于 1,导致业务错误响应被前端误判为成功)。

常量 旧值 新值
response.CodeSuccess 1 0
response.CodeFail 0 1

移除response.CodeInvalidParamsresponse.ErrInvalidParams

迁移

// ❌ 旧(编译失败)
response.FailWithError(c, response.ErrInvalidParams)

// ✅ 业务侧自定义错误码
var ErrInvalidParams = response.NewError(40001, "参数错误")
response.FailWithError(c, ErrInvalidParams)

// ✅ 或直接通用失败
response.Fail(c, "用户名格式错误")

前端if (resp.code === 1)if (resp.code === 0)

新增 _errorCodeUniquenessGuard 编译期防撞码 map,任何后续 Code* 常量重复都会在 go build 阶段直接报错。

2. 清理 v1.0.2 兼容别名(database 包)

// ❌ 移除
database.InitMySQL(cfg)
database.InitMySQLWithReplicas(cfg, replicas)
(*Manager).InitMySQL / InitMySQLWithReplicas

// ✅ 改用(驱动由 cfg.Database.Driver 决定)
database.InitDB(cfg)
database.InitDBWithReplicas(cfg, replicas)

3. CORS 中间件行为收紧(Security 修复)

Access-Control-Allow-Credentials 不再永远是 true。如果你之前依赖"默认允许凭证"的隐式行为,需显式启用:

cors:
  allowed_origins: ["https://your-frontend.example"]
  allow_credentials: true

🐛 Fixed

  • JWT JTI 生成忽略 rand.Read 错误(jwt)— generateJTI() 改为 (string, error)GenerateToken / GenerateTokenWithCustomExpiry 传播错误
  • QueryBuilder.Page 统计行数被残留 Limit 截断(repository)— countDB 加 .Limit(-1).Offset(-1) 清除残留分页条件
  • OSS / 本地存储文件名冲突(storage)— 新增 uniqueFilename(now, ext)<unixNano>-<8字节crypto/rand hex>.<ext>),4 处上传路径统一改用
  • 数据库重试策略对不可恢复错误无效(database)— 新增 isTransientDBErrorAccess denied / Unknown database / invalid DSN / unknown driver 等首次出现即返回,不再无意义重试 5 次

🔒 Security

CORS 中间件按 W3C 规范修复

  1. Access-Control-Allow-Credentials 只在显式启用且 Origin 非 * 时才发送
  2. * + credentials: true 场景回显具体 Origin(避免被浏览器拒绝)
  3. 自动加 Vary: Origin,防止 CDN/网关缓存串扰
  4. 非白名单 Origin 不再被回显

📦 Dependencies

  • go mod tidy 补全 postgres 方言传递依赖jackc/pgx 家族、golang.org/x/syncgorm.io/driver/postgres 提升为直接依赖

  • 安全补丁升级(无 API 变更):

    依赖
    golang.org/x/crypto v0.49.0 v0.53.0
    golang-jwt/jwt/v5 v5.2.1 v5.3.1
    gorilla/websocket v1.5.1 v1.5.3
  • 暂缓升级(留待 v1.0.4 / v1.1):gin v1.9→v1.12、validator v10.19→v10.30、gorm v1.25→v1.31、aliyun-oss-go-sdk v2→v3(major 破坏性)等


🗑️ Removed

  • 死代码 database.DBResolverBeforeQuery 从未注册到 GORM callback chain)。需要 callback 级自动读写分离请接入官方 gorm.io/plugin/dbresolver
  • 292 行"评分/理由"自夸注释(23 个文件)— AI 生成遗留,对外发布不专业

✨ Added

  • console 包显式 level 控制SetLevel / WithLevel / LevelSilentatomic.Int32 并发安全。console 是开发期彩色 stdout 工具,不写文件、不感知环境
  • 新增测试:jwt / repository / storage / database / router / middleware / app
  • 文档:新增 CHANGELOG.mdVersion_v1.0.2_report.md;更新 README / GUIDE 顶部更新日志

🔄 Changed

  • 文件重命名 database/mysql.go → database/manager.go(导入路径无变化,公开 API 全部保留)
  • Logger 拆分三独立 core 修复 Tee 重复写入:Loggerapp.logAPILog()api.logDBLog()database.log,互不串扰。新增 logger.Close(),生产默认级别调整为 Info

    升级注意:日志目录会新增 logs/app.log,运维采集脚本如有需要请补上

  • 版本号常量化app.go 新增 const Version 作为唯一版本号来源,CLI / 脚手架模板引用之,发版只需改一处

升级速查

  1. 全局替换前端 code === 1(成功)→ code === 0
  2. 移除 response.ErrInvalidParams / CodeInvalidParams 引用,改业务自定义
  3. database.InitMySQL*database.InitDB*
  4. 检查 CORS 配置:若依赖凭证,显式设 allow_credentials: true
  5. 运维:日志采集补 logs/app.log
go get github.com/EthanCodeCraft/xlgo-core@v1.0.3
go mod tidy

Full Changelog: v1.0.1...v1.0.3

v1.0.1

Choose a tag to compare

@EthanCodeCraft EthanCodeCraft released this 30 Apr 15:34
init