
第一次见到 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 做元数据扫描时我从一开始就决定不做启动期全量扫描而是采用按需解析加缓存。做法是这样的用一个ConcurrentHashMapClass?, ParsedClass做缓存ParsedClass 里面保存这个类的字段元数据列表。每次校验某个对象的时候先从缓存取如果没取到就去解析类的全部字段然后把字段上的ReaCheck注解提取出来连同 Pattern 编译好的对象一起放缓存里。解析字段的时候有两个容易踩的坑。第一个是只遍历当前类声明的字段导致父类字段上的注解被忽略。Java 的注解默认是不继承的你子类继承父类的字段字段上的注解不会跟着过来。所以 rea 必须手动往上遍历父类直到 Object 为止。第二个坑是私有字段的访问权限模块化之后的 JDK 对setAccessible(true)有更严格的限制处理不好就抛InaccessibleObjectException。实操上如果目标类的模块不是开放的会要求系统属性--add-opens。为了避免这种运行时尴尬rea 的校验器不是直接给字段赋值而是只读取字段值通过try-catch把访问异常转换成校验异常保证框架不会因为某个字段访问不了就崩溃。2.3 校验链路从字段到 ReaResult校验的统一入口是ReaValidator对外只提供一个方法public ListReaResult validate(Object target) { ListReaResult 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也放行。但如果值不为空就必须用正则校验。这个逻辑看起来简单却是从几个真实业务场景里磨出来的。比如用户更新资料的时候邮箱可以不填但一旦填了就必须是合法邮箱这时候requiredfalse加正则就刚好符合需求。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; }如果规则执行了 trimcleanedValue 就是去除空格之后的值。调用方拿到结果后如果希望写回就自己调 setter。这种做法的好处是框架永远不碰你的对象数据安全性好也避免反射赋值带来的各种边界问题。3. 实操过程真正把 rea 用起来3.1 在项目里引入 rea 的完整步骤假设你用的是 Maven第一步是在 pom.xml 里加依赖。rea 发布到内部仓库时的坐标大致长这样dependency groupIdcom.example.common/groupId artifactIdrea/artifactId version1.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; ListReaResult results ReaValidator.validate(demo); results.forEach(r - System.out.println(r.fieldName() : r.message())); } }如果控制台输出了num:必须是数字说明环境没问题。接下来就是接入业务代码最简单的方式是在 Controller 或 Service 入口处调用ReaValidator.validate()如果返回列表不为空直接抛业务异常由全局异常处理器统一转成 HTTP 400。3.2 一个用户注册场景的完整示例我用一个用户注册的场景把 rea 串起来。假设有这样一个入参 DTOpublic 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) { ListReaResult 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 ListReaResult validate(RegisterRequest request) { ListReaResult 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 这种思路适合你们团队我建议不要急着去扩展它的功能先拿一两个真实接口试跑把错误码规范、写回策略、跟前端的约定都定下来再逐步铺开。你会发现大部分校验需求根本用不到复杂的框架能力一个注解加一个校验器真的就够了。