Files
schemaCheck/docs/实施方案.md
dongzi 114b053733
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
feat: 1、流水线总开关配置 2、删除p0p1p2级别阻断
2026-07-14 10:58:05 +08:00

639 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Redis 序列化结构变更检测 — 实施方案
> 版本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
├── 发布到 Nexuscom.codechecker:redis-schema-checker:{version}
└── 提供默认配置模板
jnpf-java-cloud 仓库
├── .gitea/workflows/redis-schema-check.yaml
├── .gitea/config/redis-schema-check-config.yaml
└── push 时下载 jar 并执行检测
```
### 3.2 架构图
```mermaid
flowchart TB
subgraph Gitea["Gitea Push Pipeline"]
A[push 事件] --> B[浅克隆 old/new 提交]
B --> C[下载 redis-schema-checker.jar]
C --> D[java -jar 执行检测]
end
subgraph Checker["redis-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/redis-schema-check.yaml
│ └── config/redis-schema-check-config.yaml
└── target/ # 构建产物
```
### 5.1 Maven 坐标
```xml
<groupId>com.codechecker</groupId>
<artifactId>redis-schema-checker</artifactId>
<version>1.0.0-SNAPSHOT</version>
```
打包为 **shaded/fat jar**,主类:`com.codechecker.redis.cli.RedisSchemaCheckerMain`
---
## 6. 执行流程详解
### 6.1 CLI 参数
```bash
java -jar redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-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加载配置
读取 `redis-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
#### Step 2Git 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 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)` | 第二阶段 |
| 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<TenantLinkModel>`
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 7Schema 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/redis-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。
CLI 调用:
```bash
java -jar redis-schema-checker.jar \
--config .gitea/config/redis-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/redis-schema-check.yaml
name: Redis序列化结构检查
on: [push]
jobs:
redis-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 下载 redis-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/redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-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 注解 |
| Key 推断增强 | `String.format`、常量追溯 |
| 忽略规则完善 | 锁/计数器/token 自动过滤 |
| 多模块性能优化 | 并行解析、缓存索引 |
### 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:redis-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<SchemaChange> changes;
boolean blocked;
int exitCode;
}
```