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