Java自定义注解实战:从原理到AOP与反射实现方法耗时与数据脱敏
1. 项目概述为什么我们需要自定义注解在Java开发中尤其是面对Spring Boot这类现代框架时注解Annotation几乎无处不在。从标记一个类是控制器Controller到自动注入依赖Autowired再到声明事务边界Transactional注解极大地简化了代码让配置变得声明式且优雅。但你是否曾想过这些内置注解虽然强大却未必能完全覆盖你项目中那些独特的业务逻辑比如你需要对接口的入参进行特定格式的校验或者想为某些方法自动记录耗时日志又或者需要对返回的敏感数据如手机号、身份证进行动态脱敏。这时掌握自定义注解的能力就从“锦上添花”变成了“雪中送炭”。自定义注解的本质是开发者为自己或团队定义的一套“标记语言”。它本身没有任何行为就像一张贴纸贴在代码的类、方法或字段上。真正的魔法在于结合Java的反射Reflection机制或框架的AOP面向切面编程能力我们可以在运行时“读取”这些贴纸并执行相应的逻辑。这实现了业务逻辑与核心代码的解耦让代码更加清晰、可维护也更具扩展性。很多面试中高频出现的“八股文”如注解原理、反射应用其最终落脚点往往就是考察你能否灵活运用这些基础技术解决实际问题。接下来我将从一个资深开发者的视角带你从零开始深入理解并动手实现一个完整的自定义注解应用。2. 注解基础与核心原理拆解在动手之前我们必须先打好地基理解注解是什么以及它是如何工作的。很多开发者对注解的使用停留在“照葫芦画瓢”的阶段一旦需要自定义就感到无从下手根源在于对原理理解不透。2.1 注解的本质与元注解Java注解是一种特殊的接口所有注解都隐式地继承了java.lang.annotation.Annotation接口。你可以把它理解为一种“元数据”Metadata即描述数据的数据。它本身不包含任何业务逻辑其作用是为被标注的代码元素类、方法、字段、参数等提供额外的信息。为了让注解本身也能被“注解”Java提供了“元注解”Meta-Annotation即用来定义注解的注解。这是自定义注解的起点你必须熟练掌握以下几个Target指定注解可以应用在哪些程序元素上。这是你必须首先明确的。它的取值来自ElementType枚举常用值包括TYPE类、接口、枚举FIELD字段包括枚举常量METHOD方法PARAMETER方法参数CONSTRUCTOR构造器LOCAL_VARIABLE局部变量ANNOTATION_TYPE注解类型本身PACKAGE包Retention指定注解的生命周期即注解信息保留到哪个阶段。这是决定注解何时起效的关键。它的取值来自RetentionPolicy枚举SOURCE仅存在于源代码中编译时就被丢弃。典型应用是Override、SuppressWarnings仅供编译器检查使用。CLASS注解信息会被保留在编译后的.class文件中但运行时不会被JVM加载。这是默认值但实际自定义注解中较少单独使用。RUNTIME注解信息不仅保存在.class文件中还会在运行时被JVM加载到内存。这是我们实现运行时逻辑如通过反射读取的必备条件。绝大多数业务相关的自定义注解都使用此策略。Documented被此元注解标注的注解在使用javadoc生成API文档时会被包含在文档中。这是一个可选的、用于提升文档完整性的注解。Inherited允许子类继承父类上使用的注解。注意这仅对Target(ElementType.TYPE)的类注解有效。如果父类的注解被Inherited标注那么子类即使没有显式标注也会被认为具有该注解。Repeatable(Java 8)允许在同一元素上重复使用相同的注解。这在需要为一个元素添加多个相同类型的标记时非常有用。注意Target和Retention(RetentionPolicy.RUNTIME)是自定义运行时注解的黄金搭档几乎每次定义都需要显式指定。2.2 反射机制注解能力的发动机注解本身是静态的它的力量来源于“反射”Reflection。反射允许程序在运行时Runtime检查类、接口、字段和方法的信息并能动态调用对象的方法或操作字段。对于注解而言反射API提供了以下核心类来读取注解信息Class代表一个类或接口。通过getAnnotation(ClassT)、getDeclaredAnnotations()等方法可以获取类上的注解。Method代表一个方法。通过getAnnotation(ClassT)、getParameterAnnotations()等方法可以获取方法及其参数上的注解。Field代表一个字段。通过getAnnotation(ClassT)等方法可以获取字段上的注解。Constructor代表一个构造器。同样可以获取其上的注解。Parameter(Java 8)代表一个参数。可以更精细地获取方法参数的注解。它们是如何协同工作的当你定义一个运行时注解Retention(RUNTIME)并把它贴在代码上后编译器会将这个注解信息写入.class文件。程序运行时JVM会加载这些类。当你的业务逻辑可能是一个工具类、一个AOP切面或一个Servlet过滤器执行时它通过反射API例如method.getAnnotation(MyAnnotation.class)去询问JVM“这个方法上有没有贴MyAnnotation这个标签” JVM会返回注解的实例如果存在。你的逻辑再根据这个注解实例里定义的属性值比如value”admin”来决定要执行什么操作比如权限校验。一个常见的误区认为注解能“自动”执行代码。其实不是注解只是一个被动的信息载体。是你写的、主动去读取注解的代码反射逻辑驱动了行为的执行。Spring框架之所以强大正是因为它内部封装了大量这样的反射逻辑在应用启动或方法调用时自动扫描、读取注解并执行相应操作。3. 从零开始定义你的第一个自定义注解理论讲得再多不如动手一试。我们从一个最经典的场景开始为方法添加执行耗时日志。这个需求几乎在每个项目里都会遇到用自定义注解来实现可以做到无侵入、可复用。3.1 定义注解ExecutionTime我们的目标是在任意方法上添加ExecutionTime注解该方法被调用时能自动在日志中打印其执行耗时。首先创建注解类。注解的定义很像接口但使用interface关键字。import java.lang.annotation.*; /** * 方法执行耗时日志注解。 * 被标注的方法在执行时会自动打印耗时信息到日志。 */ Target(ElementType.METHOD) // 这个注解只能用在方法上 Retention(RetentionPolicy.RUNTIME) // 注解信息在运行时可用这是关键 public interface ExecutionTime { /** * 时间单位默认为毫秒(ms)。 */ TimeUnit unit() default TimeUnit.MILLISECONDS; /** * 是否在方法执行前后都打印日志默认为true。 * 如果设为false则只在方法执行后打印总耗时。 */ boolean logBefore() default true; /** * 支持的时间单位枚举 */ enum TimeUnit { NANOSECONDS, MICROSECONDS, MILLISECONDS, SECONDS } }代码解读与设计思考Target(ElementType.METHOD)明确这个注解只用于方法。如果你错误地把它用在类上编译器会报错这能提前避免误用。Retention(RetentionPolicy.RUNTIME)这是灵魂所在。只有保留到运行时我们后续的AOP或反射工具类才能在方法执行时“看到”它。注解属性我们定义了unit和logBefore两个属性。注解的属性以“无形参的方法”形式声明其默认值用default关键字指定。TimeUnit unit()返回一个我们自定义的枚举类型让调用者可以灵活选择输出单位纳秒、微秒、毫秒、秒。提供默认值MILLISECONDS符合最常见的使用习惯。boolean logBefore()这是一个增强功能。有时我们可能只想记录总耗时而不需要方法开始的那条日志。通过这个开关增加了注解的灵活性。枚举TimeUnit将其定义为注解的内部枚举使得属性值类型安全用户只能从有限的选项中选择避免了传入错误字符串。这个注解定义本身没有任何逻辑它只是一份“说明书”说明了“如何记录耗时”。3.2 实现注解处理器AOP与反射两种方式定义了注解接下来就需要一个“处理器”来读取它并执行记录耗时的逻辑。在Spring Boot项目中最优雅的方式是使用AOP。对于非Spring环境或更底层的工具类则可以使用纯反射。方式一使用Spring AOP推荐AOP面向切面编程非常适合这种“横切关注点”的需求。它可以在不修改目标方法代码的情况下在其周围织入额外逻辑。第一步添加依赖在pom.xml中确保包含Spring Boot的AOP starter。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency第二步编写切面类import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.lang.reflect.Method; Aspect // 声明这是一个切面类 Component // 让Spring管理这个Bean public class ExecutionTimeAspect { private static final Logger log LoggerFactory.getLogger(ExecutionTimeAspect.class); /** * 环绕通知拦截所有被 ExecutionTime 注解标注的方法。 * param joinPoint 连接点代表被拦截的方法 * return 原方法的返回值 * throws Throwable 原方法可能抛出的异常 */ Around(annotation(com.yourpackage.annotation.ExecutionTime)) public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 获取方法签名和注解实例 MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); ExecutionTime executionTime method.getAnnotation(ExecutionTime.class); // 2. 获取注解属性 ExecutionTime.TimeUnit unit executionTime.unit(); boolean logBefore executionTime.logBefore(); // 3. 记录开始时间如果需要 long startTime System.nanoTime(); // 统一用纳秒获取最高精度 if (logBefore) { log.info([方法执行开始] {}.{}(), joinPoint.getTarget().getClass().getSimpleName(), method.getName()); } // 4. 执行目标方法 Object result; try { result joinPoint.proceed(); // 这行代码调用了原始的业务方法 } catch (Throwable throwable) { log.error([方法执行异常] {}.{}(), joinPoint.getTarget().getClass().getSimpleName(), method.getName(), throwable); throw throwable; // 异常继续向上抛出保证业务逻辑不受影响 } // 5. 计算耗时并转换单位 long endTime System.nanoTime(); long durationNanos endTime - startTime; double finalDuration convertNanosToUnit(durationNanos, unit); // 6. 记录结束日志 log.info([方法执行结束] {}.{}() 耗时: {}{}, joinPoint.getTarget().getClass().getSimpleName(), method.getName(), String.format(%.3f, finalDuration), // 保留3位小数 getUnitSymbol(unit)); return result; } /** * 将纳秒转换为指定单位的时间 */ private double convertNanosToUnit(long nanos, ExecutionTime.TimeUnit unit) { switch (unit) { case NANOSECONDS: return nanos; case MICROSECONDS: return nanos / 1_000.0; case MILLISECONDS: return nanos / 1_000_000.0; case SECONDS: return nanos / 1_000_000_000.0; default: return nanos / 1_000_000.0; // 默认毫秒 } } /** * 获取单位的符号表示 */ private String getUnitSymbol(ExecutionTime.TimeUnit unit) { switch (unit) { case NANOSECONDS: return ns; case MICROSECONDS: return μs; case MILLISECONDS: return ms; case SECONDS: return s; default: return ms; } } }AOP实现的核心要点Around(“annotation(...)”)这是切点表达式意思是“拦截所有被ExecutionTime注解标注的方法”。这是最精准的拦截方式。ProceedingJoinPoint代表被拦截的连接点即方法。proceed()方法用于执行原始业务逻辑这是环绕通知的核心。异常处理在try-catch中调用proceed()确保即使业务方法抛出异常我们也能记录到结束日志虽然是因为异常结束。这里选择将异常原样抛出保证切面不影响正常的业务异常流程。时间计算使用System.nanoTime()而非System.currentTimeMillis()因为前者提供的是单调递增的纳秒时间更适合测量短时间间隔不受系统时间调整的影响。单位转换根据注解属性进行灵活的数学计算使输出对用户友好。方式二使用反射工具类通用如果你的项目没有使用Spring AOP或者你想封装一个更通用的工具可以使用反射。但请注意这种方式需要你显式地调用工具类方法。import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class AnnotationProcessorUtil { public static Object invokeMethodWithTimeLog(Object target, Method method, Object... args) throws Throwable { // 检查方法是否被 ExecutionTime 注解 if (!method.isAnnotationPresent(ExecutionTime.class)) { // 如果没有注解直接反射调用 return method.invoke(target, args); } ExecutionTime annotation method.getAnnotation(ExecutionTime.class); ExecutionTime.TimeUnit unit annotation.unit(); boolean logBefore annotation.logBefore(); long start System.nanoTime(); if (logBefore) { System.out.printf([开始] 执行方法: %s%n, method.getName()); } Object result; try { method.setAccessible(true); // 如果方法是private的需要设置可访问 result method.invoke(target, args); } catch (InvocationTargetException e) { // 反射调用时业务方法抛出的异常会被包装在InvocationTargetException中 System.err.printf([异常] 执行方法 %s 时发生错误%n, method.getName()); throw e.getCause(); // 抛出真实的业务异常 } catch (IllegalAccessException | IllegalArgumentException e) { throw new RuntimeException(反射调用方法失败, e); } long end System.nanoTime(); double duration convertNanosToUnit(end - start, unit); System.out.printf([结束] 方法 %s 执行耗时: %.3f %s%n, method.getName(), duration, getUnitSymbol(unit)); return result; } // ... convertNanosToUnit 和 getUnitSymbol 方法同上此处省略 ... }使用方式MyService service new MyService(); Method method MyService.class.getDeclaredMethod(businessMethod”, String.class); Object result AnnotationProcessorUtil.invokeMethodWithTimeLog(service, method, “testArg”);反射方式的优缺点优点不依赖任何框架纯Java实现通用性强。缺点侵入性强需要改变方法调用方式从直接调用改为通过工具类调用非常不灵活难以大规模应用。因此在Spring Boot项目中AOP是绝对首选。3.3 在业务中应用注解定义好注解和切面后使用就变得极其简单完全无侵入。import org.springframework.stereotype.Service; Service public class UserService { ExecutionTime(unit ExecutionTime.TimeUnit.MILLISECONDS) // 使用默认配置记录前后日志单位毫秒 public User getUserById(Long id) { // 模拟数据库查询耗时 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new User(id, “张三”); } ExecutionTime(unit ExecutionTime.TimeUnit.SECONDS, logBefore false) // 只记录总耗时单位秒 public void batchProcessUsers(ListUser users) { // 模拟批处理耗时 try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } users.forEach(u - System.out.println(“处理用户: ” u.getName())); } }当调用getUserById方法时控制台会输出类似[方法执行开始] UserService.getUserById() [方法执行结束] UserService.getUserById() 耗时: 100.322ms当调用batchProcessUsers方法时由于logBefore false只会输出结束日志[方法执行结束] UserService.batchProcessUsers() 耗时: 2.001s4. 进阶实战实现动态数据脱敏注解现在我们来挑战一个更复杂、更贴近实际业务的需求动态数据脱敏。这也是网络热词中提到的场景。我们希望实现一个注解SensitiveData当它标注在对象的字段上时在序列化比如转换成JSON返回给前端时能根据字段类型如手机号、身份证、银行卡自动进行脱敏处理。这个需求比耗时日志更复杂因为它涉及到注解需要标注在字段上。处理时机是在对象序列化时通常需要与序列化框架如Jackson集成。脱敏规则需要根据字段类型动态判断。4.1 定义脱敏策略与注解首先我们定义一个脱敏策略枚举和核心注解。// SensitiveType.java - 脱敏类型枚举 public enum SensitiveType { /** 中文名只显示第一个字其余用*代替如张* */ CHINESE_NAME, /** 身份证号显示前6位和后4位如110105****1234 */ ID_CARD, /** 手机号显示前3位和后4位如138****8888 */ PHONE, /** 银行卡号显示前6位和后4位如622848****8888 */ BANK_CARD, /** 自定义通过注解的 customPattern 和 customReplacement 指定 */ CUSTOM }// SensitiveData.java - 脱敏注解 import java.lang.annotation.*; Target(ElementType.FIELD) // 用于字段 Retention(RetentionPolicy.RUNTIME) public interface SensitiveData { /** * 脱敏类型 */ SensitiveType type() default SensitiveType.CUSTOM; /** * 自定义脱敏正则表达式当type为CUSTOM时生效 * 例如”(\d{3})\d{4}(\d{4})” 匹配手机号中间4位 */ String customPattern() default ; /** * 自定义替换模板当type为CUSTOM时生效 * 使用 $1, $2... 引用正则分组 * 例如”$1****$2” */ String customReplacement() default ; }4.2 实现Jackson序列化器Jackson提供了JsonSerializer类允许我们自定义某个类型的序列化逻辑。我们将为Object类型创建一个通用的序列化器在序列化过程中检查字段注解并进行脱敏。import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.ser.ContextualSerializer; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import java.io.IOException; import java.lang.reflect.Field; import java.util.regex.Pattern; public class SensitiveDataSerializer extends StdSerializerObject implements ContextualSerializer { private SensitiveData sensitiveData; // 无参构造是必须的 public SensitiveDataSerializer() { super(Object.class); } // 用于传递注解信息的构造 public SensitiveDataSerializer(SensitiveData sensitiveData) { super(Object.class); this.sensitiveData sensitiveData; } Override public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException { if (value null) { gen.writeNull(); return; } String strValue value.toString(); String maskedValue maskString(strValue, sensitiveData); gen.writeString(maskedValue); } /** * 核心脱敏逻辑 */ private String maskString(String original, SensitiveData annotation) { if (original null || original.isEmpty()) { return original; } SensitiveType type annotation.type(); switch (type) { case CHINESE_NAME: if (original.length() 1) return original; return original.charAt(0) “*” (original.length() 2 ? original.substring(2).replaceAll(“.”, “*”) : “”); case ID_CARD: // 简单实现实际需考虑15位和18位身份证 if (original.length() 10) return “****”; return original.substring(0, 6) “****” original.substring(original.length() - 4); case PHONE: if (original.length() ! 11) return “****”; return original.substring(0, 3) “****” original.substring(7); case BANK_CARD: if (original.length() 10) return “****”; return original.substring(0, 6) “****” original.substring(original.length() - 4); case CUSTOM: String pattern annotation.customPattern(); String replacement annotation.customReplacement(); if (!pattern.isEmpty() !replacement.isEmpty()) { try { return original.replaceAll(pattern, replacement); } catch (Exception e) { // 正则错误返回原值或部分掩码 return “***[正则错误]***”; } } // 没有自定义规则默认隐藏中间部分 return defaultMask(original); default: return defaultMask(original); } } private String defaultMask(String str) { if (str.length() 2) return “*”; int maskLen Math.max(1, str.length() / 3); int start (str.length() - maskLen) / 2; StringBuilder sb new StringBuilder(str); for (int i start; i start maskLen i str.length(); i) { sb.setCharAt(i, ‘*’); } return sb.toString(); } /** * ContextualSerializer 接口方法为每个字段创建带有对应注解信息的序列化器实例 * 这是实现的关键它让序列化器能感知到当前正在序列化的字段上的注解。 */ Override public JsonSerializer? createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { if (property ! null) { // 获取字段上的 SensitiveData 注解 SensitiveData ann property.getAnnotation(SensitiveData.class); if (ann null) { // 尝试从字段本身获取某些情况下property的annotation可能为空 ann property.getMember().getAnnotation(SensitiveData.class); } if (ann ! null) { return new SensitiveDataSerializer(ann); // 返回一个携带了注解信息的新序列化器 } } // 如果字段没有 SensitiveData 注解则返回一个不脱敏的默认序列化器 // 这里为了简单返回nullJackson会使用默认序列化器。 // 更严谨的做法是返回 prov.findValueSerializer(property.getType(), property); return null; } }4.3 在实体类上应用注解现在我们可以在任何需要返回给前端的DTO或Entity类的字段上使用这个注解。import com.fasterxml.jackson.databind.annotation.JsonSerialize; public class UserDTO { private Long id; private String username; SensitiveData(type SensitiveType.CHINESE_NAME) private String realName; // 真实姓名 SensitiveData(type SensitiveType.PHONE) private String phoneNumber; // 手机号 SensitiveData(type SensitiveType.ID_CARD) private String idCard; // 身份证号 SensitiveData(type SensitiveType.BANK_CARD) private String bankCard; // 银行卡号 SensitiveData(type SensitiveType.CUSTOM, customPattern “(.{2}).*(.{2}.*)”, customReplacement “$1***$2”) private String email; // 邮箱自定义规则保留前2位和后2位及之后的部分 // 注意这里需要在字段的getter方法或字段本身上使用 JsonSerialize 来指定我们的序列化器 JsonSerialize(using SensitiveDataSerializer.class) SensitiveData(type SensitiveType.PHONE) public String getPhoneNumber() { return phoneNumber; } // 其他字段的getter方法也需要同样处理或者直接在字段上标注 JsonSerialize // 更优雅的方式使用 Jackson MixIn 或模块全局注册见下文 }这里有一个关键点仅仅有SensitiveData注解Jackson并不知道要用我们的SensitiveDataSerializer。我们需要通过JsonSerialize(using SensitiveDataSerializer.class)来建立关联。但每个字段都加两个注解太麻烦。4.4 全局注册序列化器更优雅的方案我们可以通过实现Jackson模块Module来全局注册我们的序列化器让它自动处理所有带有SensitiveData注解的字段。import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; import java.util.List; public class SensitiveDataModule extends SimpleModule { public SensitiveDataModule() { super(“SensitiveDataModule”); } Override public void setupModule(SetupContext context) { super.setupModule(context); // 添加一个BeanSerializerModifier在序列化时修改属性写入器 context.addBeanSerializerModifier(new BeanSerializerModifier() { Override public ListBeanPropertyWriter changeProperties(SerializationConfig config, BeanDescription beanDesc, ListBeanPropertyWriter beanProperties) { for (BeanPropertyWriter writer : beanProperties) { // 检查属性字段上是否有 SensitiveData 注解 SensitiveData ann writer.getAnnotation(SensitiveData.class); if (ann null) { // 如果属性上没有再检查对应的getter方法上是否有 ann writer.getMember().getAnnotation(SensitiveData.class); } if (ann ! null) { // 如果找到注解为此属性设置我们的自定义序列化器 writer.assignSerializer(new SensitiveDataSerializer(ann)); } } return beanProperties; } }); } }在Spring Boot中注册模块import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class JacksonConfig { Bean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new SensitiveDataModule()); // 注册我们的脱敏模块 return mapper; } }完成以上配置后你的实体类只需要使用SensitiveData注解即可无需再写JsonSerialize。public class UserDTO { // ... 其他字段 SensitiveData(type SensitiveType.PHONE) private String phoneNumber; // 仅此一个注解即可 // getter 和 setter ... }当这个UserDTO对象被Spring MVC的RestController返回时Jackson会自动调用我们的模块发现phoneNumber字段有SensitiveData注解并使用SensitiveDataSerializer进行序列化输出到JSON的数据就会是脱敏后的格式。5. 避坑指南与最佳实践在实际项目中应用自定义注解我踩过不少坑也总结了一些经验希望能帮你少走弯路。5.1 性能考量反射与缓存反射操作如getAnnotation,getMethod是有性能开销的尤其是在高频调用的方法上。在AOP切面或序列化器中如果每次执行都去反射获取注解信息会成为性能瓶颈。优化方案使用缓存。Aspect Component public class ExecutionTimeAspect { private static final Logger log ...; // 使用ConcurrentHashMap缓存方法和其对应的注解信息 private final MapMethod, ExecutionTime annotationCache new ConcurrentHashMap(); Around(“annotation(com.yourpackage.annotation.ExecutionTime)”) public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); // 从缓存中获取注解如果没有则反射获取并放入缓存 ExecutionTime executionTime annotationCache.computeIfAbsent(method, m - m.getAnnotation(ExecutionTime.class)); // ... 后续逻辑使用缓存的 executionTime 对象 ... } }对于Jackson序列化器由于createContextual方法在序列化器创建时调用一次之后该序列化器实例会被复用所以本身就有一定的缓存效果。但全局模块BeanSerializerModifier中的处理也是在启动或首次序列化某类型时进行的开销可控。5.2 注解继承与桥接方法在面向对象中子类会继承父类的方法。但是注解本身默认是不被继承的除非元注解了Inherited且仅对类注解有效。对于方法上的注解子类重写父类方法时默认不会继承父类方法上的注解。问题场景你在一个Service接口的某个方法上定义了ExecutionTime其实现类的方法上默认是没有这个注解的AOP切面会失效。解决方案在实现类方法上显式重新添加注解最简单但重复劳动。使用Spring的AliasFor进行桥接在接口和实现类之间定义桥接注解较为复杂。修改切点表达式这是最常用的方法。将annotation(...)改为within(...)或组合使用execution和annotation。// 拦截所有在类级别标注了 ExecutionTime 的类中的所有方法不常用 // Around(“within(com.yourpackage.annotation.ExecutionTime)”) // 更灵活拦截所有方法然后手动查找该方法或其声明类接口上的注解 Around(“execution(* com.yourpackage.service..*.*(..))”) // 定义包路径 public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); // 1. 先查当前方法 ExecutionTime ann method.getAnnotation(ExecutionTime.class); if (ann null) { // 2. 如果当前方法没有去查声明该方法的类可能是接口 Class? declaringClass method.getDeclaringClass(); ann declaringClass.getAnnotation(ExecutionTime.class); // 如果注解在类上 } if (ann null) { // 3. 如果还没有去查接口中的方法通过反射获取接口方法 for (Class? ifc : declaringClass.getInterfaces()) { try { Method ifcMethod ifc.getMethod(method.getName(), method.getParameterTypes()); ann ifcMethod.getAnnotation(ExecutionTime.class); if (ann ! null) break; } catch (NoSuchMethodException e) { // 接口中没有对应方法继续 } } } if (ann null) { // 如果最终都没找到注解直接执行原方法 return joinPoint.proceed(); } // ... 找到注解后的处理逻辑 ... }这种方法虽然代码多但提供了最大的灵活性可以处理复杂的注解继承和查找逻辑。5.3 与Spring生态的深度集成自定义注解的真正威力在于与Spring生态的无缝集成。除了上面用到的AOP还可以考虑以下方向参数校验注解结合javax.validation.ConstraintValidator创建自定义校验注解如ValidPhone、ValidIdCard。权限控制注解结合Spring Security或自定义拦截器实现类似PreAuthorize(“hasRole(‘ADMIN’)”)但更贴合业务的自定义权限注解如RequiresPermission(“user:delete”)。缓存注解增强Spring的Cacheable很好但你可能需要更细粒度的控制比如根据方法参数组合生成特定key或者实现“如果返回为空则不缓存空数据”网络热词中提到你可以自定义一个CacheableNullable注解来实现。分布式锁注解基于Redisson或Curator定义一个DistributedLock(key “#id”)注解在方法执行前尝试获取锁执行后自动释放。API日志/审计注解定义ApiLog自动记录接口的入参、出参、调用者、耗时等信息到数据库或日志系统。设计原则好的自定义注解应该像Spring原生注解一样即插即用对业务代码几乎零侵入。其复杂性应封装在背后的处理器切面、拦截器、序列化器中。5.4 测试策略自定义注解的逻辑通常依赖于运行时环境Spring容器、Jackson上下文因此单元测试需要一些技巧。AOP切面测试使用Spring Boot Test启动一个轻量级的测试上下文。SpringBootTest class ExecutionTimeAspectTest { Autowired private UserService userService; // 被切面代理的Bean Test void testExecutionTimeLog() { // 直接调用方法通过日志断言或捕获输出来验证切面是否生效 userService.getUserById(1L); // 可以使用如Logback的测试Appender来捕获日志进行断言 } }序列化器测试直接测试ObjectMapper。Test void testSensitiveDataSerializer() throws JsonProcessingException { ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new SensitiveDataModule()); UserDTO dto new UserDTO(); dto.setPhoneNumber(“13800138000”); dto.setIdCard(“110105199001011234”); String json mapper.writeValueAsString(dto); assertThat(json).contains(“138****8000”); assertThat(json).contains(“110105****1234”); assertThat(json).doesNotContain(“13800138000”); }纯工具类测试使用标准的JUnit即可主要测试反射逻辑和字符串处理函数。自定义注解是Java开发者从“框架使用者”迈向“框架塑造者”的关键一步。它要求你不仅理解语法更要深刻理解反射、代理、框架生命周期等底层机制。开始时可以从简单的日志、校验入手逐步挑战更复杂的序列化、事务、锁等场景。每一次成功的自定义注解实践都会让你对Java生态的理解更深一层也能让你设计出的系统更加灵活和优雅。记住注解是描述“做什么”的声明而真正的力量永远来自于你为实现它所编写的、那些精巧的处理器代码。

相关新闻

重邮计算机学院2025考研408统考:四专业对比与备考策略

重邮计算机学院2025考研408统考:四专业对比与备考策略

这次我们来看重庆邮电大学计算机学院2025年考研的最新变化——四个专业全部确定考408统考科目。对于27考研的同学来说,这既是机遇也是挑战,关键是要搞清楚哪个专业更适合自己。从最新考情来看,重邮计算机学院的计算机科学与技术、软件工程、网…

2026/9/30 12:26:54 阅读更多 →
Python链式赋值的原理、陷阱与最佳实践

Python链式赋值的原理、陷阱与最佳实践

1. 链式赋值的表面现象与潜在问题在Python中,链式赋值(Chained Assignment)是一种常见的语法糖,它允许我们使用类似a b c 10的形式同时为多个变量赋予相同的值。这种写法看起来简洁优雅,但背后却隐藏着一些值得深入…

2026/9/30 3:06:15 阅读更多 →
STM32驱动BY9301语音模块:从硬件连接到软件调试全攻略

STM32驱动BY9301语音模块:从硬件连接到软件调试全攻略

1. 从需求到选型:为什么是BY9301?最近在做一个需要语音提示的嵌入式设备,核心需求很简单:在特定事件发生时,比如设备启动成功、操作错误或者某个流程完成时,能播放一段预先录制好的语音。听起来是个很常见的…

2026/10/3 17:07:26 阅读更多 →

最新新闻

外网访问局域网FTP服务器:端口映射与被动模式配置指南

外网访问局域网FTP服务器:端口映射与被动模式配置指南

简介:PDF文档围绕外网无法访问局域网FTP服务器这一典型网络故障展开,主要面向网络管理员、运维人员以及需要搭建和维护FTP服务的学生与工程师。内容从FTP协议Port(主动)与Pasv(被动)两种工作模式的区别讲起…

2026/10/4 13:22:48 阅读更多 →
2025年全球AI Agent行业洞察报告|附19页PDF文件下载与TaoToken实践配置

2025年全球AI Agent行业洞察报告|附19页PDF文件下载与TaoToken实践配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 13:22:48 阅读更多 →
基于JavaWeb体育竞赛管理系统:从Servlet到数据库设计的毕设全指南

基于JavaWeb体育竞赛管理系统:从Servlet到数据库设计的毕设全指南

简介:面向JavaWeb开发者的体育竞赛管理系统毕业设计项目,完整覆盖运动员报名与成绩查询、管理员用户与参赛审核、裁判员成绩记录与公示等业务场景。系统采用JSPServlet经典架构,搭配MySQL 5.7数据库,前端通过JSP、HTML、CSS、Java…

2026/10/4 13:22:48 阅读更多 →
企业上云迁移方案设计:三张表+双检查点+灰度切流

企业上云迁移方案设计:三张表+双检查点+灰度切流

简介:本资源是一份面向企业IT架构师、云迁移工程师及数字化转型决策者的专业培训课件,聚焦企业上云迁移方案的系统性设计与落地实践,重点解决迁移流程混乱、风险识别不足、技术选型困难等现实痛点。课件为单文件PPTX格式(2.07MB&a…

2026/10/4 13:22:48 阅读更多 →
二. SCL 使用for循环 优化10台电机的起保停

二. SCL 使用for循环 优化10台电机的起保停

1. 先建一个起保停的FB2. 在建立一个FB块,用来调用 “起保停” 。 生成多重实例db。命名为【起保停_DB】3. 将刚刚生产的静态变量,换成数组4. 将数组里的DB拖进去5. 新建一个DB数据块USERDATA. a. 新建一个PLC数据类型b. 建立如下变量6. 使用for循环优化…

2026/10/4 13:22:48 阅读更多 →
从“搜不到“到“问就有“:用 GraphRAG 把散落的教学资料建成知识图谱

从“搜不到“到“问就有“:用 GraphRAG 把散落的教学资料建成知识图谱

从"搜不到"到"问就有":用 GraphRAG 把散落的教学资料建成知识图谱 【免费下载链接】graphrag A modular graph-based Retrieval-Augmented Generation (RAG) system 项目地址: https://gitcode.com/GitHub_Trending/gr/graphrag 教案、大…

2026/10/4 13:21:47 阅读更多 →

日新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 11:40:45 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 9:43:54 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 9:42:36 阅读更多 →