Files
schemaCheck/docs/实施方案.md
2026-07-14 17:47:11 +08:00

22 KiB
Raw Blame History

缓存序列化结构变更检测 — 实施方案

版本v0.2
日期2026-07-14
技术栈Java 11 + Maven + JavaParser
目标仓库:redisCheck(工具) / jnpf-java-cloud(被检测业务仓库)
当前阶段:Phase 1 + Phase 2 已完成Phase 3 待做


1. 背景与目标

1.1 背景

业务仓库 jnpf-java-cloud 是多模块 Java 微服务项目,广泛使用 Redis 缓存业务对象。当开发者修改 VO/DTO 字段、调整序列化包装结构、或变更 Redis 写入逻辑时,线上 Redis 中可能仍存在旧结构数据,导致:

  • 反序列化失败
  • 字段读取为空
  • 新旧结构并存引发隐蔽 Bug

典型变更示例(租户库信息缓存):

变更前(逻辑上等价于直接缓存 TenantVO

{
  "dbName": "",
  "linkList": [{ "id": "", "serviceName": "", "...": "..." }]
}

变更后(TenantDbContentCacheHelper 包装为 CacheEnvelope

{
  "vo": {
    "dbName": "",
    "linkList": [{ "id": "", "serviceName": "", "...": "..." }]
  },
  "expiresAtMs": 0
}

该变更在业务代码中已有真实对应:

  • Keytenant: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) 已支持W01/W02
JsonUtil.getObjectToString(obj) 已支持W03
RedisTemplate.opsForValue().set(key, obj) 直接写对象 已支持W04
RedisTemplate.opsForHash().put(key, field, obj) 已支持W05
StringRedisTemplate 写 JSON 字符串 已支持
锁 / 计数器 / token 简单值 默认忽略

2.2 Key 与实体映射

  • 不存在统一的「Key → 类型」注册中心
  • 存在大量 static final StringString.format(...)buildCacheKey(...) 等模式
  • 因此采用:写入点静态推断 + 可选 YAML 人工补充映射

2.3 多模块特征

  • pom.xml 下约 30+ 顶层模块、200+ 子模块
  • Java 版本混用8/9/10/11工具统一使用 JDK 11 编译运行
  • 公共工具类 RedisUtilJsonUtil 来自外部依赖,不在业务仓源码内 — 仅分析调用方,不深入依赖实现

3. 总体架构

3.1 交付形态

schemaCheck 仓库
  ├── 开发 Java 分析工具
  ├── mvn package 打 fat-jar
  ├── 发布到 Nexuscom.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 架构图

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 直写 / Hash后续可扩展读路径反向确认、报告落盘等

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 等复杂场景标记为低置信度提示。后续可选集成 lombok.ast 或 delombok 预处理。


5. 工程结构redisCheck 仓库)

schemaCheck/
├── pom.xml                               # 单模块工程(无父子结构)
├── docs/
│   ├── 实施方案.md
│   ├── 配置说明.md
│   └── CI集成说明.md
├── src/
│   ├── main/
│   │   ├── resources/
│   │   │   └── default-config.yaml       # 内置默认配置(随 jar 发布)
│   │   └── java/com/codechecker/cache/
│   │       ├── cli/                      # 命令行入口
│   │       ├── config/                   # 配置模型
│   │       ├── git/                      # Git 操作
│   │       ├── analyze/                  # 编排与工作树扫描
│   │       ├── detector/                 # Redis 写入点检测W01~W05
│   │       ├── schema/                   # Schema 提取与注解
│   │       ├── diff/                     # 结构对比
│   │       ├── key/                      # Key 推断
│   │       ├── report/                   # 报告 / 企微 Markdown
│   │       └── notify/                   # 企微通知
│   └── test/
│       ├── resources/fixtures/{tenant,lock,template}/
│       └── java/...
├── .gitea/
│   ├── workflows/cache-schema-check.yaml
│   └── config/cache-schema-check-config.yaml
└── target/                               # 构建产物

5.1 Maven 坐标

<groupId>com.codechecker</groupId>
<artifactId>cache-schema-checker</artifactId>
<version>1.0.0</version>

打包为 shaded/fat jar,主类:com.codechecker.cache.cli.CacheSchemaCheckerMain


6. 执行流程详解

6.1 CLI 参数

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获取策略

流水线使用 push 区间累计对比(方案:beforeafter

  1. --new-sha = gitea.shapush 后 tip
  2. --old-sha = gitea.event.beforepush 前 tip
  3. before 为空或全 0(新分支首次 push→ 跳过检测,exit 0
  4. workflow_dispatch 无 before 时回退 HEAD~1
  5. 浅克隆当前 tip--depth 1),再按需 git fetch --depth 1 <before>;统计 commit 数时 deepen 至 before 为祖先,或直接用事件 commits 数组长度(无需全量历史

一次 push 含多个 commit 时,只做 一次 检测,对比区间为整次 push 的累计 diffbefore..after),不会漏掉中间 commit 留下的结构变更。
不做「每个 commit 各告警一条」;中间引入又被末 commit 改回的净无变更,累计结果可能为「无变更」(符合阻断「最终结构」的目标)。
日志中的 commit 数:优先 gitea.event.commits;勿在未 deepen 时用浅库 rev-list(会少算)。

6.3 处理步骤

Step 1加载配置

读取 cache-schema-check-config.yaml,合并默认值(见 docs/配置说明.md)。

Step 2Git Diff 扫描

git diff --name-only {old-sha} {new-sha} -- '*.java'

输出变更 Java 文件列表。同时记录 diff hunks用于判断「是否仅注释/格式变更」。

Step 3构建双版本源码索引

old-shanew-sha 分别:

  1. git show {sha}:path/to/File.java 提取文件内容(无需完整 checkout 两个 worktree
  2. 解析为 CompilationUnit
  3. 建立 类全名 → CompilationUnit 索引(仅本仓 src/main/java

Step 4Redis 写入点检测

变更文件 中扫描以下 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) Hash 写出 value 类型
W06 JSON.parseObject(cacheValue, Xxx.class) 辅助反向确认读取类型 未做

忽略规则(自动):

  • value 为字符串字面量、数字、UUID"1" 等琐碎值
  • 方法名含 setIfAbsentincrementdeleteremoveexpire
  • key 匹配 ignore.key_patterns 配置(锁 / token / 登录计数等)

Step 5类型推断

对每个写入点的 expr,使用 JavaParser Symbol Solver 推断类型:

// 示例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: TenantVOexpiresAtMs: Long
  4. 递归展开 TenantVOdbName: StringlinkList: List<TenantLinkModel>
  5. 继续展开 TenantLinkModel 全部字段

注解处理

注解 行为 状态
@JSONField(serialize = false) 排除字段
@JSONField(name = "xxx") 字段名映射
@JsonIgnore 排除字段
@JsonProperty("xxx") 字段名映射
@JsonIgnoreProperties({...}) 类级忽略字段
@Schema 忽略(不影响序列化)

Step 6生成 JSON Schema

将 Java 类型转为统一的 TypeSchema 树:

{
  "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 7Schema Diff

对比同一写入点在 old/new 两个版本的 TypeSchema,输出 SchemaChange 列表。

变更类型(有结构差异即告警;block 下任意变更均阻断):

变更类型 示例
FIELD_REMOVED 删除 dbName
TYPE_CHANGED linkList 从数组变对象
WRAPPER_ADDED 顶层增加 vo 包装
FIELD_PATH_MOVED dbNamevo.dbName
FIELD_ADDED 新增 expiresAtMs
KEY_PATTERN_CHANGED key 常量变更
WRITE_POINT_REMOVED 删除缓存写入
WRITE_POINT_ADDED 新增缓存写入
LOW_CONFIDENCE 类型推断失败(仍提示,置信度较低)

Step 8报告与通知

生成 CheckReport,包含:

  • 仓库名、分支、old/new sha、提交人、时间
  • 按 Key 聚合的结构变更(骨架 before/after+ 字段级明细
  • 每项通用展示:Key位置Class#method:line)、类型、旧/新序列化骨架

企微 Markdown 规则:

  • 抬头不含 mode 汇总;正文按 Key 展示骨架
  • 删除字段:旧骨架中橙色 <font color="warning">
  • 新增字段:新骨架中绿色 <font color="info">
  • key 未解析时展示源码表达式 + 灰色「key 未解析)」
  • 单条超 4096 UTF-8 字节时按 Key 拆成多条依次发送

CI 控制台额外输出字段级变更明细(变更类型 + 位置 + 摘要),再打印与企微一致的 Markdown。

调用企微 Webhook 发送 Markdown支持 --dry-run 仅本地输出)。

Step 9退出码

条件 退出码
enabled=false / 无变更 0
mode=notify 且存在变更 0仍通知
mode=block 且存在任意结构变更 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 人工补充(配置)

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 调用:

java -jar cache-schema-checker.jar \
  --config .gitea/config/cache-schema-check-config.yaml \
  ...

工具启动时自动加载 jar 内 default-config.yaml,再与 --config 指定的业务配置深度合并。

详见 docs/配置说明.md。核心开关:

# 总开关false 时跳过检测与通知,流水线直接通过
enabled: true

# 运行模式notify仅通知| block检测到结构变更即阻断流水线
mode: notify

# 是否发送企微通知
notify:
  enabled: true
  webhook_url: ""   # 企微 Webhook 完整 URL兼容旧字段 webhook_env

企微消息约定见 docs/配置说明.md §5位置/类型为每个 Key 的通用项;删除字段橙色、新增字段绿色。


9. CI 集成方案

详见 docs/CI集成说明.md。核心流程:

# jnpf-java-cloud/.gitea/workflows/cache-schema-check.yaml要点
# 检出:浅克隆 tipdepth 1
# 检测:--old-sha = gitea.event.before--new-sha = gitea.sha
# 按需 fetch before 提交对象,覆盖一次 push 的多 commit 累计 diff

完整模板见 docs/CI集成说明.md / .gitea/workflows/cache-schema-check.yaml


10. 分阶段交付计划

Phase 1 — MVP

目标:跑通端到端链路,覆盖租户缓存典型场景。

任务 产出 状态
Maven 工程骨架 + CLI 可执行 fat-jar
Git diff 扫描 变更文件列表
W01~W03 写入点检测 覆盖 JSON 字符串写入
基础 Schema 提取 支持普通类、内部类、List、嵌套
Schema Diff 字段增删、包装、路径迁移
企微通知 按 Key 骨架 Markdown
notify/block / enabled 配置驱动
样本夹具测试 TenantVO/CacheEnvelope 等

验收标准

  • TenantDbContentCacheHelper 的结构变更能输出报告并通知
  • 流水线 push 后能收到企微通知
  • mode=block 时任意结构变更导致 exit 1

Phase 2 — 增强

任务 说明 状态
W04/W05 模式 RedisTemplate 直写对象、Hash 写入
注解完整支持 Fastjson/Jackson@JsonIgnoreProperties
Key 推断增强 String.format、常量拼接、buildXxxKey
忽略规则完善 锁/计数器/token/字面量/setIfAbsent
多模块性能 并行读文件、索引批量装载、manual_mappings
企微高亮 删除橙 warning / 新增绿 info;位置+类型通用项

Phase 3 — 运营

任务 说明
报告落盘 可选输出 JSON 报告文件
误报反馈 suppressions 按写入点 / change_types 精细忽略
更多业务场景覆盖 考勤、文件下载进度等

11. 测试策略

11.1 单元测试

  • SchemaDifferTest:纯字段路径对比逻辑
  • JavaSchemaExtractorTest:类字段展开、注解、内部类
  • RedisWritePointDetectorTest:各种写入 AST 模式匹配
  • RedisKeyResolverTest常量、format、拼接推断

11.2 样本夹具测试fixtures

「夹具」= 放在测试资源里的脱敏源码样本(不是连真实 Redis / 不是起 Gitea 流水线)。

路径:src/test/resources/fixtures/。测试代码加载这些 .txt/Java 片段,在内存中跑检测器 / Schema 对比,用来验证典型业务场景是否被正确识别。

夹具目录 验证点
fixtures/tenant/ 包装结构变更TenantVO → CacheEnvelope
fixtures/lock/ 锁/计数器/token 应被忽略
fixtures/template/ W04 Template 直写

11.3 端到端测试

jnpf-java-cloud 开测试分支,故意提交一个 VO 字段变更,验证流水线 + 企微通知。


12. 风险与限制

风险 影响 缓解措施
类型推断失败 漏报 标记 LOW_CONFIDENCE,配置 manual_mappings
Lombok 复杂注解 字段遗漏 基于源码字段 + 注解;后续 delombok
同一 key 多分支写不同类型 误报 报告注明置信度;人工 suppression
浅克隆拿不到 before 漏检 / exit 2 按 SHA fetch --depth 1 + deepen 兜底;见 CI 说明
一次 push 多 commit 旧方案仅看末 commit 会漏检 已改为 before..after 累计对比
依赖 jar 中的类型 字段展开不完整 配置 manual_mappings 补充
JsonUtil 实现不可见 序列化规则猜测 默认按字段名序列化;与 Fastjson 对齐

13. 已确认决策Grill 共识)

# 决策项 结论
1 阻断范围 block 模式下 任意结构变更均阻断exit 1
2 发布坐标 独立产物 com.codechecker:cache-schema-checker:1.0.0
3 配置归属 双层配置jar 内 default-config.yaml + 业务仓覆盖合并
4 上线策略 notify ,稳定后手动切 block
5 检测范围 src/main/java,不扫描测试代码

以上决策已纳入实施方案;Phase 1 / Phase 2 已交付,可进入 Phase 3 或业务仓全量观察。


14. 附录:关键类设计草图

WritePoint

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

public class SchemaChange {
    ChangeType changeType;      // FIELD_REMOVED, WRAPPER_ADDED, ...
    String keyPattern;
    String writeLocation;       // class#method:line
    String fieldPath;           // 如 vo.dbName
    String oldValue;
    String newValue;
    String message;             // 人类可读描述
}

CheckReport

public class CheckReport {
    String repository;
    String branch;
    String oldSha;
    String newSha;
    String modifier;
    String modifyTime;
    String mode;
    List<SchemaChange> changes;
    boolean blocked;
    int exitCode;
}