看到一个字段上的NotBlank很多人以为 JVM 会自动校验。其实注解只是元数据只有框架或我们自己的代码读取它、执行规则才会产生行为。反射也不等于“动态调用一切”的魔法它有访问控制、性能、模块边界和错误处理成本。本文从一个小型校验器出发解释何时该用、怎样限制复杂度。一、注解首先是契约不是动作定义注解时Target限定标注位置Retention决定保留阶段。只有运行期保留运行时反射才能读到。没有标注 Retention 的注解默认按 CLASS 保留无法按这里的方式读取。java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)java.lang.annotation.Target(java.lang.annotation.ElementType.RECORD_COMPONENT)interfaceRequired{Stringmessage()default不能为空;}注解成员需要有编译期可确定的类型与值它不能装入任意对象或执行一段表达式。设计参数时优先表达清楚的业务事实例如最小长度、是否必填避免把数据库查询、网络访问等隐藏在某个注解里。否则调用者看到一个普通数据对象却不知道校验会发生 I/O。二、一个完整可运行的 record 校验器保存为AnnotationDemo.java运行javac AnnotationDemo.java和java AnnotationDemo。校验器只接受 record只检查标注了Required的字符串组件。这是刻意收窄的边界不支持嵌套对象也不尝试修改私有字段。importjava.lang.annotation.ElementType;importjava.lang.annotation.Retention;importjava.lang.annotation.RetentionPolicy;importjava.lang.annotation.Target;importjava.lang.reflect.InvocationTargetException;importjava.lang.reflect.RecordComponent;importjava.util.ArrayList;importjava.util.List;Retention(RetentionPolicy.RUNTIME)Target(ElementType.RECORD_COMPONENT)interfaceRequired{Stringmessage()default不能为空;}publicclassAnnotationDemo{recordUser(RequiredStringname,Required(message邮箱不能为空)Stringemail){}staticListStringvalidate(Objectvalue){if(valuenull||!value.getClass().isRecord())thrownewIllegalArgumentException(record required);ListStringerrorsnewArrayList();for(RecordComponentcomponent:value.getClass().getRecordComponents()){Requiredrulecomponent.getAnnotation(Required.class);if(rulenull)continue;if(component.getType()!String.class)thrownewIllegalArgumentException(Required needs String);try{Stringtext(String)component.getAccessor().invoke(value);if(textnull||text.isBlank())errors.add(component.getName(): rule.message());}catch(IllegalAccessException|InvocationTargetExceptionex){thrownewIllegalStateException(cannot read component.getName(),ex);}}returnerrors;}publicstaticvoidmain(String[]args){System.out.println(validate(newUser(,aexample.com)));System.out.println(validate(newUser(Ada,aexample.com)));}}第一个结果包含name的错误第二个为空列表。示例 record 嵌在 public 类中校验器也在同类里因此访问器可被正常调用。如果把校验器移动到其他包而 record 不是 public反射调用可能因访问控制失败不能假定getAccessor()自动绕过 Java 可见性规则。三、反射的三类入口Class描述类型getDeclaredFields()可获取本类声明字段getMethods()可获取 public 方法包括继承的方法。读取某对象的字段值用Field.get调用方法用Method.invoke。这些 API 返回的是运行时描述不保证开发者想象中的“源码顺序”。需要稳定顺序时应定义显式排序依据不能把反射数组顺序当协议。示例选择getRecordComponents()因为 record 组件更接近这个输入模型的公开结构。直接反射私有字段、调用setAccessible(true)的教程虽然短却会绕过封装并在模块化应用中遇到包未开放的限制。反射失败要保留异常原因不宜吞掉后返回“校验通过”。四、模块化与可访问性的边界在模块系统中exports表示其他模块可使用公开 APIopens则允许指定包用于深度反射。框架要求opens时要明确开放给谁不能为了省事把整个应用所有包打开。类路径上的行为与命名模块中的行为可能不同所以组件打包后还应执行集成测试。Java 的访问控制不是“框架跑不起来时随手关掉的开关”。如果一个库必须访问业务对象的内部字段先考虑公开访问器、工厂函数或明确的映射接口。对于公开数据契约record 很合适对于可变实体要仔细界定构造、校验和持久化之间的生命周期。五、反射开销与缓存策略如果每个请求都扫描组件、查找注解和组装元数据会有重复工作。可按 Class 缓存已解析的规则与访问器并保持缓存值不可变。若应用支持热部署或插件卸载静态全局 Map 持有 Class 会阻止类加载器回收这时可考虑ClassValue或使用随插件生命周期清理的缓存。性能优化要通过基准和实际请求分布验证通常不是第一次写校验器的主要矛盾。Method.invoke还会包装目标方法异常上例捕获InvocationTargetException并保留原因。生产系统应区别“用户输入校验未通过”和“校验器实现异常”前者是可预期结果后者必须暴露为系统错误并报警。不能为了 API 返回统一格式而把反射错误伪装成用户填错了字段。六、校验分层与错误输出字段必填、长度、格式属于局部规则“邮箱是否已注册”要查数据库是业务规则。不要把两者塞进一个注解解释器局部校验可以确定、快速、无副作用业务校验则需要事务边界、并发约束和异常处理。即使请求通过“邮箱未注册”检查保存前仍需依赖数据库唯一约束避免并发请求同时通过检查。错误消息要可国际化输出字段名要稳定敏感值不应回显。对外返回可用机器可识别的代码和面向用户的文本而不是依赖中文句子做程序判断。对于批量导入收集所有字段错误比遇到第一个就停止更友好对于数据量很大的输入也要限制一次返回的错误总数。七、什么时候不要自己造校验框架上面的实现让注解与反射的关系一目了然但并不包含级联校验、组、容器元素、国际化、方法参数和框架集成等能力。生产项目若已有成熟校验机制优先沿用它的约定避免两个系统给出不同结果。需要自定义业务规则时可写普通函数再由边界层调用不必把每个if都变成注解。测试应覆盖注解缺失、空白、null、类型错误、访问失败、错误消息和多个字段同时失败。反射本身可以很强大但力量越大隐藏的调用路径越多。让元数据负责声明让校验器负责执行让业务层负责事务和持久化三者分开才能持续演进。八、注解生效位置别靠猜Java 中字段、构造参数、record 组件和访问器是不同的程序元素。注解写在 record 组件上时具体能够在什么位置看到还取决于注解的Target配置以及编译器传播规则。示例明确只查询RecordComponent.getAnnotation因此不假设它必然出现在字段或访问器上。若框架改为扫描字段测试应覆盖编译后的实际结构不能仅凭源码位置判断。继承也有边界Inherited主要影响按类查找注解的特定情形不能理解成“方法、接口及所有成员都会自动继承标记”。对同一类型允许标注多次时需要定义可重复注解及容器类型并明确多条规则的执行顺序。校验接口越宽泛边界行为越多把支持范围写在契约里比偷偷忽略未知注解更安全。九、错误汇总与递归处理若要递归校验订单中的地址、商品列表先定义最大深度、最大元素数和循环引用的处理方式。任意对象图可能存在父节点与子节点相互引用天真递归会无限循环或栈溢出。可以维护按对象身份判断的已访问集合并在入口设总节点上限。对一个请求返回成千上万条错误并不友好也可能成为资源消耗入口。字段路径需稳定例如items[3].name便于前端定位。错误输出尽量包含机器可识别代码和安全的显示文本不直接拼接用户提交的敏感值。嵌套校验的执行顺序和异常策略必须明确是尽量收集所有错误还是遇到无法访问对象结构时立即停止。反射基础设施异常不应混入普通表单错误列表。十、运行时反射与编译期处理的取舍运行时反射灵活适合数量有限、结构经常变化的元数据驱动场景缺点是错误可能在服务启动或请求到达时才暴露。编译期注解处理器可以提前检查标注是否合法、生成访问代码但增加构建流程和生成物维护成本。若服务长期稳定且性能关键可以评估编译期方案教学和小型工具没必要因担忧一点反射开销就引入复杂构建插件。即使选择成熟框架也应了解其何时触发校验控制器参数入口、方法调用代理和手工构造对象的执行路径未必相同。一个对象在 HTTP 层被校验不代表从消息队列或内部定时任务创建时也会被校验。把重要业务不变量放在领域对象或服务边界不能只依赖某个入口的注解触发机制。十一、给校验器做契约测试除了测试User的两个字段还应定义一个没有注解的 record确认结果为空定义一个错误标注到非字符串组件的 record确认立即报告配置错误。分别传入 null、普通类实例和空白字符串验证错误类型稳定。这样可以区分“用户输入不合法”和“开发者把注解用错了”两种问题。若将规则元数据做缓存测试不能只运行一个类要用不同 record 类型交替校验防止缓存键选错使规则串到别的类型上。并发测试则让多个线程共享校验器、分别处理不同对象确认缓存初始化没有竞争。缓存设计前先测量瓶颈避免为微小优化引入类加载器泄漏和额外同步成本。框架升级后集成测试应覆盖真实 HTTP、消息或批处理入口。注解存在并不代表入口一定调用了验证器最危险的情况是开发者看到标记便默认“数据已经干净”而另一条路径完全绕过它。把入口验证和领域不变量分别测试职责才不会模糊。参考资料Annotation API、RecordComponent API、Class API。