ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

rea:基于正则与注解的轻量级字段校验工具设计与实战

rea:基于正则与注解的轻量级字段校验工具设计与实战

第一次见到 rea 这个名字,我以为是哪个框架的缩写,问了才知道,它就是 Regex Entity Annotation 三个词拼起来的产物,说白了就是一个基于正则表达式的轻量级字段校验工具。这名字起得相当朴素,但用起来反而很顺手,因为它解决的问题非常聚焦:后端项目里 POJO 字段的格式校验和预处理代码,总是越写越长、越写越散,今天这个接口手写一段 if,明天那个接口又复制粘贴一遍,时间一长,整个项目里充满了各种规则不一致的“一次性校验代码”。rea 做的事情很克制,只用一个注解把正则约束和字段绑定起来,再由统一入口完成校验和轻量清洗。这篇文章我会把设计思路、核心实现、实操过程和踩坑记录都过一遍,适合正在做后端基础设施、或者想在老项目里低成本引入参数校验规范的读者。

1. 为什么会有 rea 这个项目:我踩过的参数校验坑

1.1 一段典型的“胶水校验代码”是怎么腐烂的

我相信很多后端开发都见过这样的代码。接口一进来,先是一连串的 if,从手机号到邮箱再到订单号,每个字段都手写正则,报错信息还要拼字符串。比如这样:

if (user.getPhone() == null || !user.getPhone().matches("^1[3-9]\\d{9}$")) { return "手机号格式不正确"; } if (user.getEmail() == null || !user.getEmail().matches("^[\\w.+-]+@[\\w-]+\\.[\\w]{2,}$")) { return "邮箱格式不正确"; } if (user.getUsername() == null || user.getUsername().length() < 3) { return "用户名长度不能小于3"; }

这种代码最要命的不是丑,而是每写一次都有差异。有人用Pattern.matches,有人事先编译好 Pattern,有人直接用 String 的 matches 方法;报错信息有的带字段名,有的不带;空值处理也不统一,有人用null判断,有人用StringUtils.isBlank。等接口多了以后,同一个手机号规则可能在十几个地方各写一遍,哪天运营商号段更新,你根本不知道要去改哪里。

我最初做 rea 就是因为受够了这种状态。当时一个内部模拟项目里要加一批新接口,光是参数校验就写了一百多行重复代码。于是我把校验规则抽到统一工具类里,后来又发现,与其写一堆静态方法,不如让规则直接长在字段上,谁定义的字段,谁负责声明规则。这个思路演变成注解之后,整个项目清爽了不少。

1.2 现有校验框架的厚重感与适配成本

可能有人会问,Java 生态里不是已经有很成熟的校验规范了吗?确实有,而且功能很强,注解齐全,还能分组、嵌套、国际化。但它的问题在于,它是一个通用型重型框架,引入之后会拖进来一整套规范的实现。如果项目已经成型,尤其是老项目,你想加一个自定义校验注解,得考虑它对现有 Bean Validator 的兼容,得配置 Validation 的 Provider,还得处理 Spring Boot 版本升级带来的行为变化。

对于只想解决“字段正则校验”这一个问题的团队来说,这套东西实在有点重。它像一个装修公司,你想换一盏灯,它却给你出全套软装方案。rea 的思路正好反过来:不做通用校验框架,只做“正则 + 字段”之间最短的那条路径。你不需要理解 JSR 规范,不需要配置什么 Provider,一个注解,一个校验器,完事。

1.3 rea 的设计目标:只做正则与字段之间的最短路径

设计 rea 的时候我给自己定了三条原则,后来所有迭代都没有偏离它们。

第一条,零外部依赖。框架本身只依赖 JDK 的注解和反射能力,不引入任何第三方库,这样无论你用的是 Spring Boot、纯 Servlet,还是自研的 RPC 框架,都能直接塞进去用。

第二条,窄接口。对外只暴露注解和校验器两个核心概念,不搞 SPI、不搞扩展机制,避免选择困难症。

第三条,声明即文档。字段上写了什么规则,就是什么规则,不需要到别处查配置。哪怕是新来的同事,看一眼字段上的注解,就知道这个字段的格式约束是什么。

2. 核心实现逻辑:注解、缓存、校验链路

2.1 注解定义:一个 @ReaCheck 够不够

rea 的核心是一个叫@ReaCheck的注解。有人可能会想,为什么不做成手机号一个注解、邮箱一个注解、身份证一个注解?那样语义更明确。我也试过,但后来发现,规则太多会变成注解爆炸,而且很多规则是有业务定制的,比如订单号可能是“字母+日期+流水号”,这种如果单独建注解,建到后面根本记不住有哪些。所以 rea 只保留一个注解,真正干活的是pattern()这个属性。

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @Repeatable(ReaChecks.class) public @interface ReaCheck { String pattern(); String message() default "字段格式不正确"; String code() default "FIELD_INVALID"; boolean required() default true; boolean trim() default false; }

几个设计点我展开说一下。首先是@Repeatable,这个很重要,因为某些字段可能同时要满足多个正则,比如一个登录账号字段,可能是手机号也可能是邮箱,这时候就需要同一个字段挂两个@ReaCheck。如果不支持重复注解,就得被迫做一个“或”逻辑的正则,那种正则可读性很差,也不好维护。其次是required(),默认是 true,表示不能为空,但也支持 false,此时字段允许为 null 或空串,只有非空时才执行正则校验。最后是trim(),这个属性看起来不起眼,但实际使用率特别高,很多系统用户输入时不自觉会带前后空格,如果校验前不做处理,正则很容易误报。

配套的容器注解长这样:

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface ReaChecks { ReaCheck[] value(); }

2.2 元数据扫描:反射不再慢的三个技巧

早年一提到反射就是“性能差”,其实反射在 JDK 8 之后做了大量优化,加上合理的缓存,性能完全可以接受。rea 做元数据扫描时,我从一开始就决定不做启动期全量扫描,而是采用按需解析加缓存。

做法是这样的:用一个ConcurrentHashMap<Class<?>, ParsedClass>做缓存,ParsedClass 里面保存这个类的字段元数据列表。每次校验某个对象的时候,先从缓存取,如果没取到,就去解析类的全部字段,然后把字段上的@ReaCheck注解提取出来,连同 Pattern 编译好的对象一起放缓存里。

解析字段的时候有两个容易踩的坑。第一个是只遍历当前类声明的字段,导致父类字段上的注解被忽略。Java 的注解默认是不继承的,你子类继承父类的字段,字段上的注解不会跟着过来。所以 rea 必须手动往上遍历父类,直到 Object 为止。第二个坑是私有字段的访问权限,模块化之后的 JDK 对setAccessible(true)有更严格的限制,处理不好就抛InaccessibleObjectException。实操上,如果目标类的模块不是开放的,会要求系统属性--add-opens。为了避免这种运行时尴尬,rea 的校验器不是直接给字段赋值,而是只读取字段值,通过try-catch把访问异常转换成校验异常,保证框架不会因为某个字段访问不了就崩溃。

2.3 校验链路:从字段到 ReaResult

校验的统一入口是ReaValidator,对外只提供一个方法:

public List<ReaResult> validate(Object target) { List<ReaResult> results = new ArrayList<>(); if (target == null) { return results; } ParsedClass parsed = cache.get(target.getClass()); if (parsed == null) { parsed = parse(target.getClass()); cache.put(target.getClass(), parsed); } for (ParsedField field : parsed.fields()) { Object value = field.read(target); for (ReaRule rule : field.rules()) { ReaResult result = rule.check(field.name(), value); if (result != null) { results.add(result); } } } return results; }

返回的ReaResult是一个轻量对象,包含三个信息:字段名、错误码、错误消息。设计上故意不做嵌套校验,因为一旦每个字段还能嵌套对象,复杂度会急剧上升,定位问题时也会绕来绕去。你要校验嵌套对象,就直接针对那个对象再调一次 validate,代码层面完全可控,也更好测试。

字段值校验这一步有一个很多人都会忽略的细节:String.matches()实际调用的Pattern.matches()是“全字符匹配”,也就是说,正则必须匹配整个字符串,而不是找到某个子串。这跟 Python、JavaScript 里常见的find行为不一样。所以 rea 在内部用matcher.matches()而不是matcher.find(),避免出现“正则只要包含匹配片段就算通过”的假阳性。

2.4 对空值策略的细化

空值策略我单独拿出来说,是因为这是校验逻辑里最容易出分歧的点。有的字段是必填的,有的字段是选填的,选填字段里又有“填了就必须符合格式”和“填了空串也允许”的区别。rea 的处理逻辑是:

public ReaResult check(String fieldName, Object value) { if (value == null) { return required ? new ReaResult(fieldName, code, message) : null; } String str = value.toString(); if (trim) { str = str.trim(); } if (str.isEmpty()) { return required ? new ReaResult(fieldName, code, message) : null; } if (!pattern.matcher(str).matches()) { return new ReaResult(fieldName, code, message); } return null; }

如果字段值为 null,且 required 为 false,直接放行。如果是空串,且 required 为 false,也放行。但如果值不为空,就必须用正则校验。这个逻辑看起来简单,却是从几个真实业务场景里磨出来的。比如用户更新资料的时候,邮箱可以不填,但一旦填了就必须是合法邮箱,这时候required=false加正则就刚好符合需求。

2.5 预处理器:校验之前先做归一化

第一个版本里 trim 逻辑是放在校验器内部的,后来我把它彻底拆出来,做了个独立的“预处理器”概念。理由很简单,校验和修正是两件事,混在一起会让规则难以理解。举个例子,用户传了一个手机号" 13800138000 ",带着空格。校验之前先 trim 掉空格,校验能过,但如果后续你还想把这个干净的手机号存进数据库,你就需要把修正后的值写回去。这就要求预处理不只是“看看”,而是“改改”。

rea 里预处理的实现不是通过反射去改原字段,而是在返回结果时附带一个可选的 cleaned 值。比如:

public final class ReaResult { private final String fieldName; private final String code; private final String message; private final Object cleanedValue; }

如果规则执行了 trim,cleanedValue 就是去除空格之后的值。调用方拿到结果后,如果希望写回,就自己调 setter。这种做法的好处是框架永远不碰你的对象数据,安全性好,也避免反射赋值带来的各种边界问题。

3. 实操过程:真正把 rea 用起来

3.1 在项目里引入 rea 的完整步骤

假设你用的是 Maven,第一步是在 pom.xml 里加依赖。rea 发布到内部仓库时的坐标大致长这样:

<dependency> <groupId>com.example.common</groupId> <artifactId>rea</artifactId> <version>1.0.0</version> </dependency>

依赖加好之后,最好先写一个冒烟测试,确认注解能正常解析。这一步很多人会跳过,但我建议做一下,因为可以尽早暴露出注解保留策略、JDK 模块访问等环境问题。

public class ReaSmokeTest { static class Demo { @ReaCheck(pattern = "^\\d+$", message = "必须是数字") public String num; } public static void main(String[] args) { Demo demo = new Demo(); demo.num = "abc"; List<ReaResult> results = ReaValidator.validate(demo); results.forEach(r -> System.out.println(r.fieldName() + ":" + r.message())); } }

如果控制台输出了num:必须是数字,说明环境没问题。接下来就是接入业务代码,最简单的方式是在 Controller 或 Service 入口处调用ReaValidator.validate(),如果返回列表不为空,直接抛业务异常,由全局异常处理器统一转成 HTTP 400。

3.2 一个用户注册场景的完整示例

我用一个用户注册的场景把 rea 串起来。假设有这样一个入参 DTO:

public class RegisterRequest { @ReaCheck( pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确", code = "PHONE_INVALID") private String phone; @ReaCheck( pattern = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "邮箱格式不正确", code = "EMAIL_INVALID", required = false) private String email; @ReaCheck( pattern = "^[a-zA-Z][a-zA-Z0-9_]{2,19}$", message = "用户名需以字母开头,长度3到20位", code = "USERNAME_INVALID", trim = true) private String username; @ReaRepeatedCheck({ @ReaCheck(pattern = ".*\\d.*", message = "密码必须包含数字"), @ReaCheck(pattern = ".*[a-zA-Z].*", message = "密码必须包含字母"), @ReaCheck(pattern = "^.{8,32}$", message = "密码长度8到32位") }) private String password; // 省略 getter / setter }

这段代码里有一个地方值得细看:password 字段用了多个@ReaCheck,组合出“同时满足包含数字、包含字母、长度 8 到 32 位”的规则。这比写一个超长正则要清晰得多,而且报错信息各自独立,用户能明确知道到底哪个条件没满足。

控制层的写法很常规:

@PostMapping("/register") public String register(@RequestBody RegisterRequest request) { List<ReaResult> results = ReaValidator.validate(request); if (!results.isEmpty()) { throw new BizException(results.get(0).code(), results.get(0).message()); } // 业务逻辑... return "ok"; }

3.3 正则表达式选型:现场写正则的常见误区

写 rea 的注解时,最费神的往往是正则本身。我整理了几个常用并且实测稳定的正则,供你直接抄。

先说手机号。现在国内号段基本都在 13 到 19 开头,我推荐用宽松匹配:^1[3-9]\d{9}$。这个正则可以覆盖绝大多数情况,而且遇到未来新号段不容易失效。

再说邮箱。邮箱正则网上版本很多,很多过度设计的正则反而会拒绝合法邮箱。我推荐用^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$。它允许点号、下划线、百分号这些合法字符,同时强制要求顶级域名至少两个字母,实际使用中误报率很低。

然后是用户名。如果规则是“字母开头,允许字母数字下划线,长度 3 到 20 位”,就写^[a-zA-Z][a-zA-Z0-9_]{2,19}$。注意这里长度是 {} 里的 2,19,加上开头的第一个字母,合起来正好是 3 到 20 位,新手经常在这里数错。

最后是纯数字金额,比如^[0-9]+(\.[0-9]{1,2})?$,表示整数部分至少一位,小数部分最多两位,正则本身很短,但对小数点位置的表达很典型。

3.4 错误码和消息的约定

rea 的注解里code()和message()是两个容易使用混乱的属性。我的经验是,message 给用户看,要通俗;code 给前端和调用方看,要稳定且可读。比如你可以定义一套业务错误码前缀,接口参数校验一律以PARAM_开头,前端拿到之后能判断要不要做特定提示。

很多团队还会遇到多语言需求。rea 的第一版没有做国际化,message 直接写在注解里。后来我做了个简单的二次封装:在 Controller 层捕获 ReaResult 后,根据 code 去资源文件里找翻译。只要 code 稳定,翻译映射就做得到,这样的设计比在框架内嵌 i18n 灵活得多。

4. 常见问题与排查技巧实录

4.1 “注解加了就是不生效”的三种原因

我在某个业务项目里推广 rea 时,第一周就收到了七八个“不生效”的反馈。排查下来,真正的原因基本就三类。

第一类是注解的 Retention 策略写错了。如果你用的@Retention(RetentionPolicy.CLASS),运行时反射根本拿不到注解,自然不生效。rea 要求RUNTIME,这个必须写对。

第二类是校验入口没走。很多时候方法是直接调了另一个组件,绕过了 Controller 层的ReaValidator.validate(),或者事务方法内部自调用,经过动态代理的方法才会被拦截,自调用直接走原始对象,校验逻辑就被跳过了。

第三类是字段名拼写错误。反射拿字段是按名称精确匹配的,字段叫phoneNumber,你在注解上写fieldName = "phone",那当然匹配不上。rea 的设计避免了这个问题,因为注解就挂在字段上,不需要额外声明字段名,但如果你做了自定义的二次封装,就容易踩这个坑。

4.2 正则测试通过但 Java 里不通过

这是新手最容易怀疑框架 bug 的场景。其实原因通常非常朴素:正则表达式在 Java 字符串里存在双重转义。比如你想表达\d,写进 Java 代码的 String 必须是"\\d"。如果你的注解写成了"^\d+$",Java 会把\d解析成d,实际生成的正则就变成了^d+$,当然匹配不了数字。

另外我见过有人用在线正则调试工具测好表达式,复制进代码后还是不对劲,多半是复制的时候把反斜杠的转义符弄丢了。遇到这类问题,第一件事就是打印pattern()的实际内容,看看反斜杠是不是成对出现的。

4.3 性能问题的定位与优化

rea 刚发布内部版本时,有同事反馈某个接口压测 QPS 从 2000 掉到了 1600。我一开始以为反射开销太大,后来用火焰图一看,真正的时间消耗在Pattern.matcher()之前,也就是Pattern.compile()反复执行。原因很简单,最早版本我没有缓存编译好的 Pattern,每次校验都重新编译正则,那可是要从头到尾解析一遍正则语法的。

优化办法很直接:解析字段时就把正则字符串Pattern.compile()好,后面校验直接复用。如果你想省事,也可以直接调Pattern.compile(regex)后放进静态缓存。不过要提醒一点,Pattern 默认线程安全,可以共享,不需要每次 new。

4.4 与 Spring AOP 配合时的坑

rea 本身不依赖 Spring,但很多项目是在 Spring 环境里用的。常见做法是写一个切面,在 Controller 方法执行前自动校验入参。这里有两个坑。

第一个坑是切面表达式写太宽或太窄。太宽了,静态资源请求也能匹配到切面,校验空对象会浪费性能;太窄了,部分接口漏掉。我的建议是只对标注了@ReaValidated的方法生效,用自定义注解控制范围。第二个坑是 Spring 的 AOP 默认走代理,如果目标方法是 final 的,代理无法覆盖,切面不会生效。这个从外部看起来就像 rea 没有工作。

4.5 常见问题速查表

现象常见原因快速排查办法
注解完全不生效Retention 不是 RUNTIME检查注解定义,打印注解是否存在于运行时
同一正则有的接口生效有的不生效校验入口被跳过确认所有需要校验的入口都调用了 validate
正则测试通过但 Java 匹配失败反斜杠转义丢失打印实际 pattern 字符串
校验前后值不一致字段没做 trim 写回开启 trim 后,用 cleanedValue 更新对象
高并发下耗时变高Pattern 未缓存确认缓存里保存的是 Pattern,而不是正则字符串
父类字段校验不到只解析当前类字段手动遍历父类字段元数据
Spring 切面不生效目标方法为 final,或自调用去掉 final,或通过代理对象调用

5. 进阶玩法:从运行时反射走向编译期生成

5.1 注解处理器的基本思路

如果你觉得运行时反射还是不够优雅,或者想彻底榨干性能,可以让 rea 支持编译期注解处理。思路是写一个AbstractProcessor,在编译阶段扫描所有带@ReaCheck的字段,然后生成一个静态的校验类。因为规则在编译时就已经确定,运行时就完全不需要反射去找注解了。

核心处理器的关键部分长这样:

@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith(ReaCheck.class)) { VariableElement field = (VariableElement) element; TypeElement owner = (TypeElement) field.getEnclosingElement(); ReaCheck annotation = field.getAnnotation(ReaCheck.class); generateValidationMethod(owner, field, annotation); } return true; }

编译期处理有个好处:如果注解写错了,比如正则表达式本身就是非法语法,编译会直接报错,而不是等到运行时接口调用了才炸出来。对于追求稳定性的项目来说,这个体验好得多。

5.2 生成的静态校验器大概长什么样

处理器生成的代码核心就是一个准备好 Pattern 的静态校验方法,大致这样:

public class RegisterRequest$$ReaValidator { private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); public static List<ReaResult> validate(RegisterRequest request) { List<ReaResult> results = new ArrayList<>(); if (request.getPhone() != null && !PHONE_PATTERN.matcher(request.getPhone()).matches()) { results.add(new ReaResult("phone", "PHONE_INVALID", "手机号格式不正确")); } if (request.getEmail() != null && !request.getEmail().isEmpty() && !EMAIL_PATTERN.matcher(request.getEmail()).matches()) { results.add(new ReaResult("email", "EMAIL_INVALID", "邮箱格式不正确")); } return results; } }

跑起来之后,反射调用全部消失,Pattern 都是静态单例,每个字段的校验逻辑一目了然,性能跟手写代码几乎没差别。代价是你需要在构建流程里引入注解处理器模块,对于刚接触的项目,构建链复杂度会增加,所以要评估一下团队能不能接受。

5.3 什么时候不要用 rea

最后也得泼一盆冷水。rea 的本质是一个正则校验工具,正则不是万能的。如果你要校验的是“两个字段密码是否一致”“某个字段的值必须在数据库里存在”“金额要大于 0 且是某个比例的倍数”这类跨字段、跨系统或者需要业务上下文的规则,靠正则根本实现不了。碰到这些情况,老老实实写业务代码,或者引入可靠的规则引擎,而不是非要把所有校验塞进注解里。

我个人的体会是,任何工具都该有自己的边界。rea 的边界非常清楚:单字段、格式类、有正则可表述的校验。出了这个边界,我不会去硬改它,团队也不会怪它不够强大。因为它唯一的目标,就是解决最普遍、最让人头疼的那一类格式问题。

写在最后的经验和建议

踩过几次坑之后,我形成了一个习惯:凡是新引入 rea 注解的字段,我都会顺手写一个单元测试,把几个典型的合法值和非法值都测一遍。很多线上事故都是从一个格式校验的“漏网之鱼”开始的,测试的成本永远比线上排查低得多。

还有一个细节我一直保留着:在开发环境专门写了一个小页面,输入实体类名就能预览这个类上所有字段的校验规则。虽然 rea 的设计是注解即文档,但当字段变多、规则变多之后,一眼扫过注解列表还是容易眼花。这个页面其实只做了反射解析加上树形展示,实现成本很低,但对维护者友好程度极高。如果你打算把这套思路用到自己的项目里,我强烈建议你也做一个类似的“规则地图”。

如果你觉得 rea 这种思路适合你们团队,我建议不要急着去扩展它的功能,先拿一两个真实接口试跑,把错误码规范、写回策略、跟前端的约定都定下来,再逐步铺开。你会发现,大部分校验需求根本用不到复杂的框架能力,一个注解加一个校验器,真的就够了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进