# 缓存序列化结构变更检测 — 实施方案 > 版本:v0.1 > 日期:2026-07-13 > 技术栈:Java 11 + Maven + JavaParser > 目标仓库:`redisCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库) --- ## 1. 背景与目标 ### 1.1 背景 业务仓库 `jnpf-java-cloud` 是多模块 Java 微服务项目,广泛使用 Redis 缓存业务对象。当开发者修改 VO/DTO 字段、调整序列化包装结构、或变更 Redis 写入逻辑时,线上 Redis 中可能仍存在旧结构数据,导致: - 反序列化失败 - 字段读取为空 - 新旧结构并存引发隐蔽 Bug 典型变更示例(租户库信息缓存): **变更前(逻辑上等价于直接缓存 `TenantVO`):** ```json { "dbName": "", "linkList": [{ "id": "", "serviceName": "", "...": "..." }] } ``` **变更后(`TenantDbContentCacheHelper` 包装为 `CacheEnvelope`):** ```json { "vo": { "dbName": "", "linkList": [{ "id": "", "serviceName": "", "...": "..." }] }, "expiresAtMs": 0 } ``` 该变更在业务代码中已有真实对应: - Key:`tenant:db:content:{encode}` - 写入类:`jnpf.util.TenantDbContentCacheHelper#cacheSuccess` - Value 类型:`CacheEnvelope { TenantVO vo; Long expiresAtMs; }` ### 1.2 目标 在 **push 时** 自动执行检测: 1. 对比两次提交(`old-sha` vs `new-sha`)之间的代码差异 2. 识别 Redis value 序列化结构是否发生变更 3. 生成结构化变更报告 4. 通过企微机器人发送通知 5. 通过开关控制 **仅通知** 或 **阻断流水线** ### 1.3 非目标(第一版不做) - 不连接真实 Redis 实例做运行时校验 - 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码) - 不做全量历史扫描(仅 diff 触发) - 不替代 CodeChecker / AI Code Review 等现有能力 --- ## 2. 业务仓库调研结论 基于对 `jnpf-java-cloud` 的静态检索,得出以下判断(作为方案输入,不再向业务方重复确认): ### 2.1 Redis 序列化方式 | 类型 | 出现频率 | 第一版策略 | |------|----------|------------| | `JSON.toJSONString(obj)` / `JSONObject.toJSONString(obj)` | 高 | **重点支持** | | `JsonUtil.getObjectToString(obj)` | 高 | **重点支持**(按 Fastjson/Jackson 默认字段规则推断) | | `RedisTemplate.opsForValue().set(key, obj)` 直接写对象 | 中 | 第二阶段支持 | | `RedisTemplate.opsForHash().put(key, field, obj)` | 中 | 第二阶段支持 | | `StringRedisTemplate` 写 JSON 字符串 | 高 | **重点支持** | | 锁 / 计数器 / token 简单值 | 高 | **默认忽略** | ### 2.2 Key 与实体映射 - 不存在统一的「Key → 类型」注册中心 - 存在大量 `static final String`、`String.format(...)`、`buildCacheKey(...)` 等模式 - 第一版采用:**写入点静态推断 + 可选 YAML 人工补充映射** ### 2.3 多模块特征 - 根 `pom.xml` 下约 30+ 顶层模块、200+ 子模块 - Java 版本混用(8/9/10/11),工具统一使用 **JDK 11** 编译运行 - 公共工具类 `RedisUtil`、`JsonUtil` 来自外部依赖,不在业务仓源码内 — 仅分析调用方,不深入依赖实现 --- ## 3. 总体架构 ### 3.1 交付形态 沿用现有 `code-checker` 模式(见 `redisCheck/.gitea/demo.yaml`): ```text redisCheck 仓库 ├── 开发 Java 分析工具 ├── mvn package 打 fat-jar ├── 发布到 Nexus:com.codechecker:cache-schema-checker:{version} └── 提供默认配置模板 jnpf-java-cloud 仓库 ├── .gitea/workflows/cache-schema-check.yaml ├── .gitea/config/cache-schema-check-config.yaml └── push 时下载 jar 并执行检测 ``` ### 3.2 架构图 ```mermaid flowchart TB subgraph Gitea["Gitea Push Pipeline"] A[push 事件] --> B[浅克隆 old/new 提交] B --> C[下载 cache-schema-checker.jar] C --> D[java -jar 执行检测] end subgraph Checker["cache-schema-checker (JDK 11)"] D --> E[GitDiffScanner] E --> F[RedisWritePointDetector] F --> G[JavaSchemaExtractor] G --> H[SchemaDiffer] H --> I{有结构变更?} I -->|否| J[exit 0] I -->|是| K[ReportBuilder] K --> L[WeComNotifier] L --> M{mode=block 且含变更?} M -->|是| N[exit 1] M -->|否| J end ``` ### 3.3 核心设计原则 1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器 2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型 3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内 4. **可配置**:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置 5. **可演进**:第一版聚焦 JSON 字符串写入,后续扩展 Template 直写对象 --- ## 4. 技术选型 | 组件 | 选型 | 版本建议 | 说明 | |------|------|----------|------| | 语言 | Java | 11 | 与 CI Runner `jdk11` 对齐 | | 构建 | Maven | 3.8+ | 与现有私库发布流程一致 | | AST 解析 | JavaParser | 3.25.x | 完整 Java 语法树 | | 符号解析 | javaparser-symbol-solver-core | 3.25.x | 跨文件类型推断 | | 配置 | SnakeYAML | 2.x | 读取检测配置 | | HTTP 通知 | JDK HttpClient / OkHttp | 11 内置 / 4.x | 企微 Webhook | | 报告 | Jackson | 2.15.x | JSON/Markdown 报告序列化 | | 测试 | JUnit 5 | 5.10.x | 单元测试 + 夹具样本 | **不采用** Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。 **Lombok 处理策略**:第一版基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为「低置信度」并降级为 P2 提示。后续可选集成 `lombok.ast` 或 delombok 预处理。 --- ## 5. 工程结构(redisCheck 仓库) ```text redisCheck/ ├── pom.xml # 单模块工程(无父子结构) ├── docs/ │ ├── 实施方案.md │ ├── 配置说明.md │ └── CI集成说明.md ├── src/ │ ├── main/ │ │ ├── resources/ │ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布) │ │ └── java/com/codechecker/redis/ │ │ ├── cli/ # 命令行入口 │ │ ├── config/ # 配置模型 │ │ ├── git/ # Git 操作 │ │ ├── analyze/ # 编排与工作树扫描 │ │ ├── detector/ # Redis 写入点检测 │ │ ├── schema/ # Schema 提取 │ │ ├── diff/ # 结构对比 │ │ ├── key/ # Key 推断 │ │ ├── report/ # 报告 │ │ └── notify/ # 企微通知 │ └── test/ │ ├── resources/fixtures/tenant/ │ └── java/... ├── .gitea/ │ ├── workflows/cache-schema-check.yaml │ └── config/cache-schema-check-config.yaml └── target/ # 构建产物 ``` ### 5.1 Maven 坐标 ```xml com.codechecker cache-schema-checker 1.0.0-SNAPSHOT ``` 打包为 **shaded/fat jar**,主类:`com.codechecker.cache.cli.CacheSchemaCheckerMain` --- ## 6. 执行流程详解 ### 6.1 CLI 参数 ```bash java -jar cache-schema-checker-1.0.0.jar \ --config .gitea/config/cache-schema-check-config.yaml \ --repo-root /path/to/jnpf-java-cloud \ --old-sha abc123 \ --new-sha def456 \ --branch feature/xxx \ --modifier zhangsan \ --modify-time "2026-07-13 14:00:00" ``` | 参数 | 必填 | 说明 | |------|------|------| | `--config` | 是 | 检测配置文件路径 | | `--repo-root` | 是 | 业务仓库根目录 | | `--old-sha` | 是 | 对比基准提交 | | `--new-sha` | 是 | 当前提交 | | `--branch` | 否 | 分支名,用于报告展示 | | `--modifier` | 否 | 提交人(Gitea actor) | | `--modify-time` | 否 | 提交时间 | ### 6.2 对比基准(old-sha)获取策略 与 `demo.yaml` 保持一致,优先级: 1. 流水线显式传入 `--old-sha`(通常为 `HEAD~1`) 2. 若 `HEAD~1` 不存在(首次提交)→ 跳过检测,`exit 0` 3. 浅克隆 `--depth 2` 确保 `HEAD~1` 可用 > 不支持一次 push 多个 commit 时逐个分析;第一版仅对比 `HEAD~1..HEAD`。后续可扩展为 `before..after` 范围分析。 ### 6.3 处理步骤 #### Step 1:加载配置 读取 `cache-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。 #### Step 2:Git Diff 扫描 ```bash git diff --name-only {old-sha} {new-sha} -- '*.java' ``` 输出变更 Java 文件列表。同时记录 diff hunks,用于判断「是否仅注释/格式变更」。 #### Step 3:构建双版本源码索引 对 `old-sha` 和 `new-sha` 分别: 1. `git show {sha}:path/to/File.java` 提取文件内容(无需完整 checkout 两个 worktree) 2. 解析为 `CompilationUnit` 3. 建立 `类全名 → CompilationUnit` 索引(仅本仓 `src/main/java`) #### Step 4:Redis 写入点检测 在 **变更文件** 中扫描以下 AST 模式: | 模式 ID | 匹配表达式 | 提取信息 | |---------|------------|----------| | W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | | W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | | W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 | | W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 第二阶段 | | W05 | `redisTemplate.opsForHash().put(key, field, expr)` | 第二阶段 | | W06 | `JSON.parseObject(cacheValue, Xxx.class)` | 辅助反向确认读取类型 | **忽略规则**(自动): - value 为字符串字面量、数字、`UUID`、`"1"` 等 - 方法名含 `setIfAbsent`、`increment`、`delete`、`remove`、`expire` - key 匹配 `ignore_key_patterns` 配置 #### Step 5:类型推断 对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型: ```java // 示例:TenantDbContentCacheHelper.cacheSuccess CacheEnvelope envelope = new CacheEnvelope(); envelope.setVo(vo); envelope.setExpiresAtMs(expiresAtMs); redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl); ``` 推断链: 1. `JSON.toJSONString(envelope)` → 实参类型 `CacheEnvelope` 2. 定位 `CacheEnvelope` 类(内部类需支持 `Outer$Inner`) 3. 读取字段 `vo: TenantVO`、`expiresAtMs: Long` 4. 递归展开 `TenantVO` → `dbName: String`、`linkList: List` 5. 继续展开 `TenantLinkModel` 全部字段 **注解处理**(第一版): | 注解 | 行为 | |------|------| | `@JSONField(serialize = false)` | 排除字段 | | `@JSONField(name = "xxx")` | 字段名映射 | | `@JsonIgnore` | 排除字段 | | `@JsonProperty("xxx")` | 字段名映射 | | `@Schema` | 忽略(不影响序列化) | #### Step 6:生成 JSON Schema 将 Java 类型转为统一的 `TypeSchema` 树: ```json { "typeName": "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope", "fields": [ { "path": "vo", "javaType": "jnpf.model.TenantVO", "jsonType": "object", "children": [ { "path": "vo.dbName", "jsonType": "string" }, { "path": "vo.linkList", "jsonType": "array", "itemType": "jnpf.model.TenantLinkModel" } ] }, { "path": "expiresAtMs", "javaType": "java.lang.Long", "jsonType": "number" } ] } ``` #### Step 7:Schema Diff 对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。 **变更类型与严重级别**: | 变更类型 | 示例 | 默认级别 | |----------|------|----------| | `FIELD_REMOVED` | 删除 `dbName` | P0 | | `TYPE_CHANGED` | `linkList` 从数组变对象 | P0 | | `WRAPPER_ADDED` | 顶层增加 `vo` 包装 | P0 | | `FIELD_PATH_MOVED` | `dbName` → `vo.dbName` | P0 | | `FIELD_ADDED` | 新增 `expiresAtMs` | P1 | | `KEY_PATTERN_CHANGED` | key 常量变更 | P1 | | `WRITE_POINT_REMOVED` | 删除缓存写入 | P1 | | `WRITE_POINT_ADDED` | 新增缓存写入 | P2 | | `LOW_CONFIDENCE` | 类型推断失败 | P2 | #### Step 8:报告与通知 生成 `CheckReport`,包含: - 仓库名、分支、old/new sha、提交人、时间 - 变更列表(按严重级别排序) - 每项:Key 模式、写入位置(类#方法:行号)、旧结构、新结构、变更摘要 调用企微 Webhook 发送 Markdown 消息。 #### Step 9:退出码 | 条件 | 退出码 | |------|--------| | 无变更 / 仅 P2 | 0 | | `mode=notify` 且存在 P0/P1 | 0(仍通知) | | `mode=block` 且存在 P0/P1/P2 | 1 | | 配置错误 / 执行异常 | 2 | --- ## 7. Redis Key 推断策略 ### 7.1 自动推断 | 优先级 | 模式 | 示例 | 结果 | |--------|------|------|------| | 1 | 字符串字面量 | `"tenant:db:content:" + encode` | `tenant:db:content:*` | | 2 | 常量引用 | `CACHE_KEY_PREFIX + encode` | 追溯常量值 | | 3 | `String.format(CONST, args)` | `String.format(ATTENDANCE_BASE_SETTING_CACHE_KEY, tenantId)` | `fbt:attendance:base_setting:cache:*` | | 4 | 方法调用 | `buildCacheKey(encode)` | 读取方法内 return 表达式 | | 5 | 变量 | `redisKey` | `unknown-key` | ### 7.2 人工补充(配置) ```yaml manual_mappings: - id: tenant-db-content writer_method: "jnpf.util.TenantDbContentCacheHelper#cacheSuccess" key_pattern: "tenant:db:content:*" value_type: "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope" ``` 当自动推断置信度低时,以 `manual_mappings` 为准。 --- ## 8. 配置与开关设计 采用 **双层配置合并** 策略: | 层级 | 位置 | 职责 | |------|------|------| | 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则、严重级别默认值 | | 业务覆盖 | `jnpf-java-cloud/.gitea/config/cache-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings | 合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。 CLI 调用: ```bash java -jar cache-schema-checker.jar \ --config .gitea/config/cache-schema-check-config.yaml \ ... ``` 工具启动时自动加载 jar 内 `default-config.yaml`,再与 `--config` 指定的业务配置深度合并。 详见 `docs/配置说明.md`。核心开关: ```yaml # 总开关:false 时跳过检测与通知,流水线直接通过 enabled: true # 运行模式:notify(仅通知)| block(检测到结构变更即阻断流水线) mode: notify # 是否发送企微通知 notify: enabled: true webhook_env: WECOM_ROBOT_WEBHOOK ``` --- ## 9. CI 集成方案 详见 `docs/CI集成说明.md`。核心流程: ```yaml # jnpf-java-cloud/.gitea/workflows/cache-schema-check.yaml name: 缓存序列化结构检查 on: [push] jobs: cache-schema-check: if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }} runs-on: jdk11 steps: - name: 检出代码 run: | git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \ "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" . git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}" - name: 下载检测工具 run: | # 从 Nexus 下载 cache-schema-checker jar ... - name: 执行检测 env: WECOM_ROBOT_WEBHOOK: ${{ secrets.WECOM_ROBOT_WEBHOOK }} run: | OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "") [ -z "$OLD_SHA" ] && exit 0 java -jar /tmp/cache-schema-checker-1.0.0.jar \ --config .gitea/config/cache-schema-check-config.yaml \ --repo-root . \ --old-sha "$OLD_SHA" \ --new-sha "$(git rev-parse HEAD)" \ --branch "${{ gitea.ref_name }}" \ --modifier "${{ gitea.actor }}" \ --modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')" ``` --- ## 10. 分阶段交付计划 ### Phase 1 — MVP(约 1.5 周) **目标**:跑通端到端链路,覆盖租户缓存典型场景。 | 任务 | 产出 | |------|------| | Maven 工程骨架 + CLI | 可执行 fat-jar | | Git diff 扫描 | 变更文件列表 | | W01~W03 写入点检测 | 覆盖 JSON 字符串写入 | | 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 | | Schema Diff P0/P1 | 字段增删、包装、路径迁移 | | 企微通知 | Markdown 消息 | | notify/block 开关 | 配置驱动 | | 夹具测试 | TenantVO/CacheEnvelope 样本 | **验收标准**: - 对 `TenantDbContentCacheHelper` 的结构变更能输出 P0 报告 - 流水线 push 后能收到企微通知 - `mode=block` 时任意 P0/P1/P2 变更导致 exit 1 ### Phase 2 — 增强(约 1 周)✅ | 任务 | 说明 | 状态 | |------|------|------| | W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 | 已完成 | | 注解完整支持 | Fastjson/Jackson 注解(含 `@JsonIgnoreProperties`) | 已完成 | | Key 推断增强 | `String.format`、常量拼接追溯、`buildXxxKey` | 已完成 | | 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent 自动过滤 | 已完成 | | 多模块性能优化 | 并行读文件、索引批量装载、manual_mappings 覆盖 | 已完成 | ### Phase 3 — 运营(约 0.5 周) | 任务 | 说明 | |------|------| | 报告落盘 | 可选输出 JSON 报告文件 | | 误报反馈 | `suppressions` 配置支持按写入点忽略 | | 更多业务场景覆盖 | 考勤、文件下载进度等 | --- ## 11. 测试策略 ### 11.1 单元测试 - `SchemaDifferTest`:纯字段路径对比逻辑 - `JavaSchemaExtractorTest`:类字段展开、注解、内部类 - `RedisWritePointDetectorTest`:各种写入 AST 模式匹配 - `RedisKeyResolverTest`:常量、format、拼接推断 ### 11.2 夹具集成测试 在 `src/test/resources/fixtures/` 放置真实业务代码片段(从 `jnpf-java-cloud` 提取并脱敏),模拟 old/new 两个版本: | 夹具 | 验证点 | |------|--------| | `tenant-cache/` | 包装结构变更 P0 | | `attendance-base-setting/` | Map 结构缓存 | | `evaluate-config/` | VO 字段新增 P1 | | `lock-only/` | 应被忽略 | ### 11.3 端到端测试 在 `jnpf-java-cloud` 开测试分支,故意提交一个 VO 字段变更,验证流水线 + 企微通知。 --- ## 12. 风险与限制 | 风险 | 影响 | 缓解措施 | |------|------|----------| | 类型推断失败 | 漏报 | 标记 `LOW_CONFIDENCE`,配置 `manual_mappings` | | Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok | | 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression | | 浅克隆 parent 不可用 | 跳过检测 | `--depth 2`;文档明确要求 | | 一次 push 多 commit | 仅检最后一个 | 文档说明;后续扩展 range | | 依赖 jar 中的类型 | 字段展开不完整 | 配置 `manual_mappings` 补充 | | JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 | --- ## 13. 已确认决策(Grill 共识) | # | 决策项 | 结论 | |---|--------|------| | 1 | 阻断范围 | `block` 模式下 **P0/P1/P2 全部阻断**(exit 1) | | 2 | 发布坐标 | 独立产物 `com.codechecker:cache-schema-checker:1.0.0` | | 3 | 配置归属 | **双层配置**:jar 内 `default-config.yaml` + 业务仓覆盖合并 | | 4 | 上线策略 | 先 `notify` 观察 **1 周**,稳定后手动切 `block` | | 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 | 以上决策已纳入实施方案,可进入开发阶段。 --- ## 14. 附录:关键类设计草图 ### WritePoint ```java public class WritePoint { String filePath; int lineNumber; String enclosingClass; String enclosingMethod; String keyExpression; // 原始 AST 表达式 String resolvedKeyPattern; // 推断结果,如 tenant:db:content:* String valueExpression; String resolvedValueType; // 全限定类名 double confidence; // 0.0 ~ 1.0 } ``` ### SchemaChange ```java public class SchemaChange { Severity severity; // P0, P1, P2 ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ... String keyPattern; String writeLocation; // class#method:line String fieldPath; // 如 vo.dbName String oldValue; String newValue; String message; // 人类可读描述 } ``` ### CheckReport ```java public class CheckReport { String repository; String branch; String oldSha; String newSha; String modifier; String modifyTime; String mode; List changes; boolean blocked; int exitCode; } ```