1. 背景:当“简单校验”变成“多版本矩阵”
做 Java 后端的朋友应该都有过这种经历:一个用户对象,上线时只有一个正则校验,比如手机号 ^1[3-9]\\d{9}$,写在校验工具类里,所有接口共用。后来产品说,我们要兼容老版本 App,老版本传的手机号可能带 +86 前缀;再后来,海外版上线,邮箱、手机号格式又不一样;再后来,为了合规,某个字段在 V3 版本中必须校验得更严格。
于是你发现,原本一个 validateUser(User user) 方法里,开始堆满了 if (version.equals("V1"))、if (version.equals("V2")) 这种分支。最可怕的是,这种分支会散落在 Service 层、Controller 层、DTO 转换层,每个地方都判断一次版本,规则稍微改一点,就得全局搜索“哪个地方还在用旧正则”。
我经历过一次线上事故:某个字段在 V2 版本已经放宽了规则,但网关层还在用 V1 的正则做前置拦截,结果老用户的数据被挡在门外,报错信息还提示“格式不正确”。排查了半天,才发现校验逻辑被复制到了三个地方,改了其中两处,漏了一处。
后来我花了两周时间,把整个项目的字段校验重构了一套“多版本正则校验策略”。核心思路其实很简单:把校验规则按版本建模,每个字段在每个版本下有独立的规则定义,运行时根据请求版本动态选择规则执行。 这篇文章我就把整个设计思路和落地代码整理出来,包括踩过的坑、易混淆的业务场景、性能注意事项,希望对你手头的老项目改造有参考价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么不建议在代码里堆 if-version 判断
2.1 散落式校验的三个隐藏代价
大部分团队一开始都会用最直觉的方式处理多版本差异——在方法里写分支。比如:
java复制public void validateUser(UserDTO user, String version) {
if ("V1".equals(version)) {
validatePhoneV1(user.getPhone()); // 宽松规则
} else if ("V2".equals(version)) {
validatePhoneV2(user.getPhone()); // 严格规则
} else {
validatePhoneV3(user.getPhone()); // 最新规则
}
// 再来 email 的分支
// 再来 nickname 的分支
}
这个写法在字段少、版本少的时候没问题。但一旦字段从 3 个变成 20 个,版本从 2 个变成 5 个,这个方法的圈复杂度会暴涨。更麻烦的是:
第一,规则变更影响面不可控。你想修改 V2 版本的手机号规则,必须定位到所有包含 "V2".equals(version) 的方法,漏一个就出事故。IDE 的全局搜索能帮你,但不能保证每个分支都是规则的“唯一来源”。
第二,规则本身没法复用。同一套 V2 校验规则,可能在接口 A 中叫做“入参校验”,在接口 B 中叫做“落库校验”,这应该是同一份规则代码,但散落式写法会导致规则实现被复制粘贴,之后两边演变成不同的版本。
第三,新增版本必须改老代码。每增加一个 V4 版本,你就要在每个校验方法里新增一条 else if。这种“开闭原则”的违背在短期没什么,但累积到后面,每次发版都在一堆旧代码里找地方插入,风险越来越大。
2.2 多版本规则的真正业务本质
要设计一个好的方案,先得理解多版本规则的业务本质。它不是简单的“版本号不同走不同逻辑”,而是:同一业务字段,在不同契约版本下,有不同的合法性约束条件。 这个约束条件由三部分组成:
- 版本区间:某条规则适用于哪个版本范围。常见的模式包括“V1 到 V2 用旧规则,V3 以后用新规则”,或者“V1 单独一套,V2+ 共用一套”。
- 字段路径:规则作用在哪个字段上。这个字段在 DTO 中的嵌套层级可能很深,比如
user.profile.phone。 - 规则内容:一般就是正则表达式,但也可以扩展为枚举白名单、长度范围、自定义 Predicate 等。
把这三者组合成一个“规则单元”,再按字段和版本建立索引,运行时只需要查一次索引就能拿到当前版本应该执行的所有校验规则。这就是多版本策略的核心模型。
所以我在设计时,没有去写“校验管理器”这种听起来高大上的东西,而是先定义了三个基础概念:
code复制1. FieldRule:字段规则,描述某个字段在某个版本区间内的校验规则
2. VersionedValidator:版本化校验器,根据版本号从规则表中取出规则并执行
3. ValidationContext:校验上下文,携带版本号、字段值、字段路径
这三个概念对应到你现有的代码里,改造过程可以很平滑:原有的 validateUser 方法变成“空壳”,内部只调用 VersionedValidator,把具体规则全部迁到规则定义中。
3. 基础设计:字段规则的版本化抽象与注册表
3.1 版本区间的表达方式
首先要解决“版本规则适用区间”的表达。推荐用一个 VersionRange 类,包含 minVersion 和 maxVersion,默认 minVersion 为最低版本,maxVersion 为空表示“无上限”。
java复制public class VersionRange {
private final String minVersion;
private final String maxVersion; // null 表示不限制最大版本
public VersionRange(String minVersion, String maxVersion) {
this.minVersion = minVersion;
this.maxVersion = maxVersion;
}
public boolean contains(String version) {
return compareVersion(version, minVersion) >= 0
&& (maxVersion == null || compareVersion(version, maxVersion) <= 0);
}
public static int compareVersion(String v1, String v2) {
String[] arr1 = v1.split("\\.");
String[] arr2 = v2.split("\\.");
int len = Math.max(arr1.length, arr2.length);
for (int i = 0; i < len; i++) {
int num1 = i < arr1.length ? Integer.parseInt(arr1[i]) : 0;
int num2 = i < arr2.length ? Integer.parseInt(arr2[i]) : 0;
if (num1 != num2) {
return Integer.compare(num1, num2);
}
}
return 0;
}
}
版本比较这里要特别注意:不要直接用字符串的 compareTo,因为 "V10" 会比 "V9" 小,除非你统一用 V01 这种补零格式。但业务上很难保证所有版本号都能补零,所以自己实现一个数值分段比较是更稳妥的做法。如果你们用的是纯数字版本号,比如 1.0.0、2.1.3,直接把 compareVersion 抽成公共工具就行。
3.2 字段规则的定义结构
接下来定义规则实体。这里我参考了规则引擎里“条件-动作”的建模思路:
java复制public class FieldRule {
private String fieldName; // 字段名,如 "phone"
private VersionRange versionRange; // 适用版本范围
private String regexPattern; // 正则表达式
private String errorMessage; // 校验失败时的错误提示
private int order; // 同一字段有多条规则时的执行顺序
// 构造器、getter/setter 省略
}
这里有一个非常重要的设计点:regexPattern 是模板,不是直接编译后的正则。 什么意思?因为在多版本场景下,规则之间经常有“继承+微调”的关系。比如 V1 的手机号规则是 ^1[3-9]\\d{9}$,V2 允许带 +86 前缀,那 V2 的规则可以写成 ^\\+?86?1[3-9]\\d{9}$,但这些表达式经常包含重复部分。如果规则表里存的是直接表达式,后续要修改“基础号码段”时,就得把所有相关版本的正则都改一遍。
我的做法是:规则表里支持占位符。比如在项目配置文件中定义:
properties复制rule.phone.base = 1[3-9]\\d{9}
rule.phone.v1.regex = ^{base}$
rule.phone.v2.regex = ^(\\+86)?{base}$
rule.phone.v3.regex = ^((\\+86)|(\\+86 ))?{base}$
运行时把 {base} 替换为实际的公共片段。这样可以有效避免版本间表达式复制粘贴导致的不一致问题。
3.3 规则注册表:从配置到内存索引
规则的定义放在配置文件里(YAML 或 properties 都行),但运行时不能每次都解析字符串。需要一个 RuleRegistry,在应用启动时加载规则,构建一个以 fieldName -> List<FieldRule> 为结构的索引,按 order 排序,然后对外提供查询接口:
java复制public interface RuleRegistry {
List<FieldRule> getRules(String fieldName, String version);
}
实现类 RegexRuleRegistry 的关键逻辑是:
java复制@Override
public List<FieldRule> getRules(String fieldName, String version) {
List<FieldRule> rules = ruleIndex.get(fieldName);
if (rules == null) {
return Collections.emptyList();
}
return rules.stream()
.filter(rule -> rule.getVersionRange().contains(version))
.sorted(Comparator.comparingInt(FieldRule::getOrder))
.collect(Collectors.toList());
}
这个查询接口是后续所有校验的入口。它本身不做正则匹配,只负责“选出当前版本应该执行的规则”。这样做能让校验逻辑和规则选择逻辑完全分离,符合单一职责。
4. 落地实现:基于正则策略的校验器完整代码
4.1 校验上下文的定义
校验上下文需要携带字段路径、字段值和版本号。这里字段路径我建议用点号分隔的字符串表达,方便从 Map 或嵌套 DTO 中取数:
java复制public class ValidationContext {
private final Object rootObject; // 根对象,通常是 DTO
private final String fieldPath; // 如 "profile.phone"
private final Object fieldValue; // 该路径对应的值
private final String version; // 当前请求版本
// 构造器、getter/setter 省略
public static ValidationContext of(Object rootObject, String fieldPath,
Object fieldValue, String version) {
return new ValidationContext(rootObject, fieldPath, fieldValue, version);
}
}
为什么不直接在 ValidationContext 里传整个 DTO + 字段名字符串,让校验器用反射取字段值?因为反射在批量校验大量字段时性能不佳,而且嵌套字段的反射代码写起来很啰嗦。我建议在调用方(如 ValidatingFilter)把需要校验的字段先提取成扁平结构,再逐个交给校验器。这样代码更直观,也方便做单元测试。
4.2 版本化校验器核心实现
校验器是本文的核心类。它的职责很简单:接收一个 ValidationContext,从注册表取出规则,执行正则匹配,收集失败信息。
java复制public class VersionedRegexValidator {
private final RuleRegistry ruleRegistry;
private final PatternCache patternCache;
public VersionedRegexValidator(RuleRegistry ruleRegistry, PatternCache patternCache) {
this.ruleRegistry = ruleRegistry;
this.patternCache = patternCache;
}
public List<String> validate(ValidationContext context) {
List<String> errors = new ArrayList<>();
List<FieldRule> rules = ruleRegistry.getRules(context.getFieldPath(), context.getVersion());
if (rules.isEmpty()) {
// 当前版本没有该字段规则,视为不校验
return errors;
}
for (FieldRule rule : rules) {
String value = context.getFieldValue() == null ? null : context.getFieldValue().toString();
if (value == null || value.isEmpty()) {
// 空值策略:根据规则配置决定是否允许
if (rule.isRequired()) {
errors.add(rule.getErrorMessage() != null
? rule.getErrorMessage()
: context.getFieldPath() + "不得为空");
}
continue;
}
Pattern pattern = patternCache.getPattern(rule.getRegexPattern());
if (!pattern.matcher(value).matches()) {
errors.add(rule.getErrorMessage() != null
? rule.getErrorMessage()
: context.getFieldPath() + "格式不正确");
}
}
return errors;
}
}
有几个细节值得单独说一下:
关于 matches() vs find():校验时我建议用 Matcher.matches() 而不是 find()。两者的区别是,matches() 要求整个字符串完全匹配正则表达式的完整模式,而 find() 只是查找子串。比如正则 \\d{3},用 find() 时字符串 "abc123def" 也能通过;但用 matches() 时它要求整个字符串就是 3 位数字,因此不通过。字段校验通常意味着“整个值必须符合规则”,所以 matches() 是正确的选择。
4.3 正则的并发编译与缓存
正则表达式 Pattern.compile() 是一个比较重的操作。在规则数量少时没感觉,但如果一个接口每秒处理上千个请求,每个请求校验 20 个字段,那就意味着每秒要编译几万个 Pattern,性能损耗非常大。所以必须做缓存。
我写了一个 PatternCache:
java复制public class PatternCache {
private final Map<String, Pattern> cache = new ConcurrentHashMap<>();
private final int maxSize;
public PatternCache(int maxSize) {
this.maxSize = maxSize;
}
public Pattern getPattern(String regex) {
return cache.computeIfAbsent(regex, key -> {
if (cache.size() > maxSize) {
// 简易淘汰:清空,避免内存无限增长
// 生产环境建议用 Caffeine 等本地缓存实现
cache.clear();
}
return Pattern.compile(key);
});
}
}
这段代码在生产环境我建议替换为 Caffeine 或 Guava Cache,因为它们支持基于时间和容量的自动淘汰策略。我这样写只是为了展示一个无外部依赖的最小实现。实际项目里,直接用 Caffeine:
java复制Cache<String, Pattern> patternCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterAccess(1, TimeUnit.HOURS)
.build();
Pattern pattern = patternCache.get(regex, Pattern::compile);
为什么选择 expireAfterAccess 而不是 expireAfterWrite?因为规则表在运行期基本不变,Pattern 一旦编译就可用很久。用 expireAfterAccess 可以确保那些长期不用的旧版本规则 Pattern 能被淘汰出内存,而不是常驻。
4.4 注解驱动的字段校验绑定
规则注册表已经把“字段-版本-规则”绑定好了,但调用方怎么知道“哪个 DTO 的哪个字段需要走校验”?我建议用注解标记:
java复制@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface VersionedRegexValidate {
String fieldPath() default ""; // 如果为空,默认取字段名
String versionParam() default ""; // 从请求哪里取版本号,默认取 header X-API-Version
}
然后在 DTO 字段上标注:
java复制public class UserDTO {
@VersionedRegexValidate(fieldPath = "phone", versionParam = "X-Api-Version")
private String phone;
@VersionedRegexValidate(fieldPath = "email")
private String email;
}
但这里我要提醒:注解驱动的校验虽然好看,但不要把逻辑复杂化。 很多团队一开始用 AOP 拦截 DTO 上带注解的字段,自动校验、自动抛异常,但后来发现一个很麻烦的问题:同一个 DTO 在不同接口中的校验策略并不完全一致。比如“创建用户”接口要求手机号必填,“更新资料”接口允许手机号为空。这种“根据操作类型变化”的维度,用注解 + AOP 表达起来很别扭。
所以我的建议是:注解只负责声明“这个字段参与版本化校验”,至于具体走哪条规则、是否必填,全部交给 RuleRegistry 去查。 这样规则变更不需要动 DTO 代码。如果出现了“同一个字段在不同接口规则不同”的需求,可以在 VersionedRegexValidator 的调用入口传入一个 scene(场景)参数,作为规则查询的额外维度。
5. 进阶能力:规则继承、缺省回退与动态切换
5.1 版本区间继承与前向兼容
多版本系统最常见的需求是“前向兼容”——新版本没明确指定规则的字段,应当沿用上一版本的规则,而不是直接不校验。这个需求用 VersionRange 的区间表达能覆盖一部分,但还有更复杂的场景:
V1 的 phone 规则是 A,V2 没有定义 phone 规则,V3 定义了规则 C。请问 V2 应该用 A 还是 C?
答案是 A。因为 V2 未定义时,应该自动继承“距离最近的、版本号小于等于当前版本的规则”。这就是缺省回退逻辑。我实现的时候在 RuleRegistry 中加了一个回退查询方法:
java复制public List<FieldRule> getRulesWithFallback(String fieldName, String version) {
List<FieldRule> rules = getRules(fieldName, version);
if (!rules.isEmpty()) {
return rules;
}
// 回退:找到小于等于当前版本、且区间包含当前版本之前某个版本的最大版本规则
return ruleIndex.getOrDefault(fieldName, Collections.emptyList()).stream()
.filter(rule -> VersionRange.compareVersion(version, rule.getVersionRange().getMinVersion()) >= 0)
.sorted((r1, r2) -> VersionRange.compareVersion(r2.getVersionRange().getMinVersion(),
r1.getVersionRange().getMinVersion()))
.limit(1)
.collect(Collectors.toList());
}
但这里有个业务坑:“继承上一版本规则”并不总是安全的。 有时候 V2 删除字段规则,是因为该字段在 V2 已经废弃,不再需要校验。如果做自动回退,反而会把老规则重新带回。所以我在设计时给规则加了一个 fallbackMode 属性:
INHERIT:当前版本无规则时,自动继承最近版本规则。IGNORE:当前版本无规则时,不校验。
配置方式:
properties复制rule.phone.v2.fallbackMode=INHERIT
rule.age.v2.fallbackMode=IGNORE
这两个模式本质上对应了“字段未变,沿旧规则”和“字段已废弃或已放宽,不再校验”两种业务语义。这个设计在评审时说服了很多同事,因为它是从业务场景推出来的,而不是拍脑袋加的开关。
5.2 多规则组合的 AND / OR 语义
单字段多版本校验还有一个容易忽略的维度:同一字段当前版本可能配置了多条规则,它们之间是“并且”还是“或者”的关系。
比如手机号字段,在 V3 版本中,既要满足“11 位数字”的格式规则,又要满足“不等于黑名单号段”的枚举规则,这两条就是 AND 关系。但有些字段,比如“备注信息”,规则是“如果为空则通过,如果不为空则长度不能超过 200”,这其实也是 AND——只是第一条是空值判断。
但也有 OR 的场景:比如“手机号或邮箱二者至少填一个”这种跨字段校验就不算单字段规则了,属于对象级校验。单字段的 OR 语义比较少见,但确实存在:比如某个标识符字段,允许传入“用户 ID(纯数字)”或“用户昵称(字母开头)”,这两种格式是“或”的关系。这种情况下一条正则写不出来,需要两条规则取 OR。
我在 FieldRule 中加了 operator 字段,取值 AND 或 OR。校验器在同一个字段的多条规则间按 operator 聚合:
java复制// 同一字段同一版本的规则按顺序执行
// 默认 AND:所有规则都过才通过
// 遇到 OR:任一条通过即视为通过
实现时要注意:同一个字段的规则之间不建议混用 AND 和 OR,会让人很难理解。 我的建议是 OR 场景用一条规则在代码里组合——也就是在正则表达式层直接写 (规则A|规则B)。反而更直观,也方便复用。比如:
java复制^(\\d{6}|[a-zA-Z]\\w{5,19})$
这条正则表达式的语义就是“6 位数字或字母开头的 6~20 位字符”。所以实际代码中,我把 operator 保留下来,但默认都是 AND,OR 场景基本都收敛在正则层解决了。
5.3 基于调用方版本动态切换策略
最后说一下“动态切换”这个容易被误解的概念。很多文章会把“策略模式”和“多版本切换”混为一谈,但这里我说的“动态切换”是指在运行时根据请求上下文动态选择规则,而不是在 Spring 容器中根据配置选择不同的 Bean。
实现上面已经提到了:ValidationContext 携带 version,RuleRegistry 根据 version 动态选择规则。但真正的调用入口需要一个“从请求头提取版本号”的步骤。以 Spring MVC 为例:
java复制@Component
public class ValidationInterceptor implements HandlerInterceptor {
private final VersionedRegexValidator validator;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String version = request.getHeader("X-Api-Version");
if (version == null) {
version = "V1"; // 默认版本
}
// 把这个版本放入 ThreadLocal 或请求上下文
VersionContextHolder.set(version);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
VersionContextHolder.clear();
}
}
然后用一个工具方法在业务代码中快速校验:
java复制public class Validators {
private static VersionedRegexValidator validator;
public static void check(Object dto) {
String version = VersionContextHolder.get();
// 利用反射或预先构建的字段映射,遍历 dto 中带 @VersionedRegexValidate 的字段
List<String> errors = validator.validateFields(dto, version);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
}
这里有一个非常容易踩的坑:版本号的传递要清理干净。 如果用了 ThreadLocal,在 afterCompletion 中必须 clear(),否则线程池复用线程时会串版本号。我见过一次线上故障,就是因为拦截器里设置了版本号但没清理,导致后续请求一直沿用上一个请求的版本,所有校验规则都错乱。
6. 测试与易混淆点:几个容易被忽略的业务陷阱
6.1 不同版本规则的“正向兼容”与“逆向兼容”测试
多版本校验的测试难点不在于“当前版本规则测试”,而在于版本之间差异的回归测试。你改了 V3 的规则,不能只测 V3,还得确认 V1、V2 的规则没受任何影响。我在项目中建立了一套规则测试矩阵:
| 版本 | phone 规则 | email 规则 | nickname 规则 |
|---|---|---|---|
| V1 | ^1[3-9]\\d{9}$ |
^\\w+@\\w+\\.\\w+$ |
^[a-zA-Z0-9_]{4,20}$ |
| V2 | ^(\\+86)?1[3-9]\\d{9}$ |
^\\w+@[a-zA-Z0-9.]+$ |
^[a-zA-Z0-9_]{2,20}$ |
| V3 | `^((\+86)? | (0086))?1[3-9]\d{9}$` | ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ |
测试用例不能只测“每个版本的合法值”,还要测“边界值”,比如:
- 版本号恰好等于某个规则区间的上下界(如 V2 的
maxVersion上界与 V3 的minVersion下界是否无缝衔接) - 传入一个
V1.5的版本号,应该走哪个区间的规则 - 传入一个不存在的版本号,比如
V99,应该走最新规则还是直接报错
我在项目中定下的策略是:未知版本号按“最新可用规则”处理。 原因是在实际业务中,新版本总是向后兼容的,如果收到一个未来版本号,说明客户端已经升级到更新版本,后端应该尽量用最宽松的规则接纳它,而不是直接拒绝。如果未来版本号真的对应了不兼容的契约,网关层会有专门的版本校验拦截,不会走到字段校验这一步。
6.2 正则表达式本身的 ReDoS 风险
聊到正则校验,绕不开正则回溯攻击(ReDoS)。一个恶意的超大字符串加上一个复杂正则,可能让 CPU 卡死在回溯上。虽然大多数后端接口的字段值都有长度限制,但多版本场景下,老版本规则往往没有长度上限,这本身就是风险点。
举例:老版本 nickname 规则是 ^[a-zA-Z0-9_]{4,20}$,这个安全;但如果你把规则写成 ^([a-zA-Z0-9_]+)*$,在输入为 30 个字符的 a 结尾加一个 ! 时,回溯次数会非常恐怖。虽然我们自己的规则一般不会这样写,但要注意:从配置文件中加载的正则表达式,是外部输入,需要在启动时做安全校验,至少检查:
- 是否包含嵌套量词(如
(a+)+) - 是否包含过多的
|分支 - 是否在未知输入长度下可导致指数级回溯
我建议在规则加载时用一个简单的黑名单正则去检测危险模式,并在测试环境跑一遍基准测试,确保每条规则在 1MB 随机字符串上能在 10ms 内返回结果。
6.3 一个完整的回归测试用例
最后给一个完整的测试用例框架,用 JUnit 5 实现。核心思想是:一个测试方法只验证一个版本的规则,用 @ParameterizedTest 的方法做多组输入输出断言:
java复制class UserDTOValidationTest {
private VersionedRegexValidator validator;
@BeforeEach
void setUp() {
RuleRegistry registry = new YamlRuleRegistry("rules.yaml");
PatternCache cache = new PatternCache(100);
validator = new VersionedRegexValidator(registry, cache);
}
@ParameterizedTest
@CsvSource({
"V1, 13812345678, true",
"V1, +8613812345678, false",
"V2, +8613812345678, true",
"V2, 13812345678, true",
"V3, 008613812345678, true",
"V3, +86 13812345678, true"
})
void testPhoneValidation(String version, String phone, boolean expected) {
ValidationContext context = ValidationContext.of(
new UserDTO(), "phone", phone, version);
List<String> errors = validator.validate(context);
assertEquals(expected, errors.isEmpty());
}
}
这种测试矩阵看起来简单,但它是保证多版本策略不回归的最有效手段。我强烈建议你把所有字段、所有版本、所有边界值都写成这种表格化测试,后续任何人改规则,跑一遍测试就能立刻知道影响范围。
7. 老项目改造的落地顺序与踩坑经验
7.1 改造步骤:先建规则库,再改业务代码
如果你现在的项目里已经有大量散落的版本校验代码,不要试图一步到位重构。我的建议是分三步走:
第一步,盘点现有校验规则。把所有接口用到的字段校验规则整理出来,按“字段 + 版本”的矩阵列出。这一步不需要写代码,纯人工梳理。你会发现很多规则看起来相同,实际却因为“上次紧急修 bug 时改了一处没改另一处”而出现了细微不一致。先用表格把差异暴露出来。
第二步,建立规则配置文件。把梳理出的规则按我前面介绍的格式写入 YAML 文件,同时补上版本区间的上下界和错误提示信息。此时先不接校验器,只是让规则“有地方可去”。
第三步,逐个接口替换校验逻辑。从修改频率最低、风险最小的接口开始,把原来散落的 if-version 逻辑替换为统一的 validator.validate(context)。每替换完一个接口,跑一次全量测试。替换过程中,你大概率会发现一些“散落逻辑”本身就有 bug,比如老版本规则写错了,此时正好借机修正,但要在提交说明中注明。
我用这个方法在大概 3 周内完成了 30 多个接口的校验逻辑收敛,体感最大的收获不是代码量减少,而是排查问题的半径大幅缩短。以前线上报一个“某个版本用户信息校验失败”,要打开五六个文件、搜索版本号、对比规则;现在只要查一条配置,1 分钟内能定位到是哪个字段、哪个版本、哪条规则。
7.2 版本号从字符串变成对象的演进方向
最后聊一个可能让你少走弯路的点:一开始就把版本号定义为字符串,但设计成可替换的接口。 我见过一些项目,版本号用的是枚举 VersionEnum { V1, V2, V3 },好处是编译期能检查,坏处是每加一个版本都要改枚举、重新发版。而多版本校验的核心诉求是“规则热更新”,如果版本号枚举在代码里写死,规则配置得再灵活也受限。
我现在的做法是:版本号用字符串,但对外提供 VersionParser 接口,目前实现是“纯数字分段解析”。以后如果版本号变成 2024.1.0-RC1 这种带预发布标识的形式,只需要新增一个解析器,校验器不需要改。
7.3 关于“覆盖全部字段”的执念
还有一个经验想分享:不要试图让所有字段都走统一校验框架。 有些字段的校验规则非常复杂,牵扯到数据库查询、第三方接口、业务状态机,不适合用正则表达。我的方案中保留了“黑名单绕过”机制:在 DTO 对应字段上标识 @VersionedRegexValidate(ignore = true),让这些字段走原来的业务校验逻辑。框架只负责那些“规则简单、稳定、适合用正则表达”的字段。
这样做的好处是,你不会因为“强制统一”而引入不必要的复杂度。我在实际项目中也发现,最需要多版本化管理的字段,恰恰是那些规则经常变化、且各版本差异明显的字段(手机号、邮箱、身份证号、昵称等),而像 status、type 这类枚举型字段,用正则反而别扭,不如保留原来的枚举白名单校验。
文章最后想说的是,多版本正则校验策略本质上不是“正则”的问题,而是“规则的组织方式”问题。你把规则的表达、存储、查询和版本语义理清楚了,用什么正则表达式反而是最不重要的部分。希望这篇文章能给你在改造老项目时提供一些参考。如果你们团队有类似的需求,建议先画一张“字段-版本规则矩阵”的表格,让整个团队对现状达成一致,再动手写代码,效果会好很多。
