自定义注解
本章定位:掌握使用
@interface关键字定义自己的注解,理解注解成员的声明规则(类型限制、默认值)、value()特殊成员的语法糖,以及标记注解的使用场景。通过飞翔科技 ORM 框架和测试框架两个实战项目融会贯通。
什么是自定义注解?
JDK 内置的五个注解只能覆盖通用场景。在真实项目中,框架开发者需要定义自己的注解来表达领域语义。
飞翔科技后端架构师白歌在设计 ORM 框架雏形时,看着满屏的 XML 映射文件摇头:"如果能用注解直接在实体类上声明表和字段的映射关系,代码会比现在清晰十倍。"于是她定义了一套
@Table/@Column注解——这就是自定义注解的典型应用。
定义语法:@interface
自定义注解使用 @interface 关键字,本质上定义了一个继承自 java.lang.annotation.Annotation 的接口。
// 最简单的自定义注解——标记注解(无成员)
public @interface TestMethod {
}
// 带成员的自定义注解——类似定义接口中的方法
public @interface Column {
String name(); // 成员:列名
String type(); // 成员:数据类型
}
注解成员详解
成员声明方式
注解成员以类似无参方法的形式声明,返回值类型即为成员的类型:
public @interface MyAnnotation {
String name(); // String 类型成员
int order(); // int 类型成员
boolean enabled(); // boolean 类型成员
Class<?> target(); // Class 类型成员
}
允许的成员类型
并不是所有 Java 类型都可以作为注解成员。JDK 8 规定注解成员只能使用以下类型:
| 类型类别 | 具体类型 | 示例 |
|---|---|---|
| 基本数据类型 | int、long、float、double、boolean、byte、short、char | int order(); |
| String | java.lang.String | String name(); |
| Class | java.lang.Class<?> | Class<?> mapper(); |
| 枚举 | 任意枚举类型 | ElementType target(); |
| 注解 | 另一个注解类型 | Constraint constraint(); |
| 以上类型的数组 | 一维数组 | String[] aliases(); |
特别注意:不允许使用包装类型(
Integer、Boolean等)、集合类型(List、Map)、或自定义类作为注解成员类型。这个限制源于注解数据必须能被编译器以紧凑的二进制格式写入.class文件的常量池。
默认值:default 关键字
注解成员可以使用 default 关键字指定默认值。使用注解时,有默认值的成员可以不赋值:
public @interface Column {
String name(); // 必填成员
String type() default "VARCHAR"; // 可选成员:默认 VARCHAR
int length() default 255; // 可选成员:默认 255
boolean nullable() default true; // 可选成员:默认允许 null
}
// 使用时:必填成员 name 必须赋值,其他可选
@Column(name = "student_name") // 使用所有默认值
@Column(name = "email", type = "VARCHAR", length = 100) // 部分覆盖默认值
@Column(name = "age", type = "INT", length = 3, nullable = false) // 全部覆盖
value() 特殊成员
当注解中定义了一个名为 value 的成员时,如果使用注解时仅给 value 赋值且没有给其他成员赋值,可以省略成员名 value =:
public @interface Table {
String value(); // 特殊成员:表名
String schema() default "public"; // 普通成员
}
// ====== 三种使用方式 ======
// 方式一:完整写法——给 value 和其他成员赋值时,必须写成员名
@Table(value = "students", schema = "university")
// 方式二:value 语法糖——仅给 value 赋值,可省略成员名
@Table("students") // 等价于 @Table(value = "students")
// 方式三:仅给其他成员赋值——此时 value 没有提供,必须显式写成员名
@Table(schema = "university") // ✗ 编译错误!value 没有默认值时必须提供
语法糖生效条件:
- 成员名为
value - 使用注解时只给
value赋值(或value有默认值,仅其他成员被赋值时不可省略value =) - 语法糖仅对
value()有效,其他成员名不可省略
标记注解
标记注解(Marker Annotation) 不包含任何成员,仅作为类型标记存在:
// 标记注解:没有任何成员
public @interface TestMethod {
}
// 使用:不需要加括号
@TestMethod
public void testEnrollStudent() {
// 测试代码
}
JDK 内置的 @Override 和 @Deprecated 也都是标记注解。
用途:标记注解虽然不承载数据,但反射代码可以检测其存在与否,从而做出判断。JUnit 4 中的
@Test就是一个标记注解(JUnit 5 的@Test增加了成员属性)。
完整示例一:飞翔科技 ORM 框架模拟
场景描述
架构师白歌设计了一个轻量级 ORM 框架雏形,让开发人员可以通过 @Table 和 @Column 注解直接在实体类上声明数据库映射关系。后端开发小崔使用这些注解定义 Student 实体,并编写 SimpleORM 工具类通过反射解析注解生成 SQL 建表语句。
注解定义
import java.lang.annotation.*;
/**
* 表映射注解 —— 标记一个类对应数据库中的某张表
*/
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,供反射读取
@Target(ElementType.TYPE) // 只能用于类/接口上
public @interface Table {
String value(); // 表名(语法糖,可省略成员名)
}
/**
* 列映射注解 —— 标记一个字段对应表中的某一列
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) // 只能用于字段上
public @interface Column {
String name(); // 列名(必填)
String type() default "VARCHAR"; // 列类型
int length() default 255; // 列长度
boolean nullable() default true; // 是否允许 null
boolean primaryKey() default false; // 是否主键
}
实体类定义
/**
* 学生实体 —— 映射到 students 表
*/
@Table("students")
public class Student {
@Column(name = "id", type = "BIGINT", length = 20, nullable = false, primaryKey = true)
private Long id;
@Column(name = "name", length = 50, nullable = false)
private String name;
@Column(name = "age", type = "INT", length = 3)
private Integer age;
@Column(name = "major", length = 100)
private String major;
@Column(name = "email", length = 200)
private String email;
// 无注解字段——不会被映射(如 transient 用途)
private String tempCache;
// 构造方法
public Student() {}
public Student(Long id, String name, Integer age, String major, String email) {
this.id = id;
this.name = name;
this.age = age;
this.major = major;
this.email = email;
}
// Getter 和 Setter(省略部分)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public String getMajor() { return major; }
public void setMajor(String major) { this.major = major; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
ORM 工具类:注解解析器
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
/**
* 简易 ORM 工具 —— 通过读取 @Table 和 @Column 注解生成建表 SQL
*/
public class SimpleORM {
/**
* 根据实体类的注解生成 CREATE TABLE 语句
* @param entityClass 实体类
* @return SQL 建表语句
*/
public static String generateCreateTableSQL(Class<?> entityClass) {
// 1. 获取 @Table 注解
Table tableAnnotation = entityClass.getAnnotation(Table.class);
if (tableAnnotation == null) {
throw new IllegalArgumentException(
"类 " + entityClass.getSimpleName() + " 缺少 @Table 注解"
);
}
String tableName = tableAnnotation.value();
// 2. 遍历所有字段,收集 @Column 注解
List<String> columnDefs = new ArrayList<>();
List<String> primaryKeys = new ArrayList<>();
for (Field field : entityClass.getDeclaredFields()) {
Column columnAnnotation = field.getAnnotation(Column.class);
if (columnAnnotation == null) {
continue; // 没有 @Column 注解的字段跳过
}
// 构建列定义
StringBuilder colDef = new StringBuilder();
colDef.append(" ").append(columnAnnotation.name()).append(" ");
colDef.append(columnAnnotation.type());
// 特殊处理 VARCHAR 类型加长度
if ("VARCHAR".equalsIgnoreCase(columnAnnotation.type())) {
colDef.append("(").append(columnAnnotation.length()).append(")");
}
if (!columnAnnotation.nullable()) {
colDef.append(" NOT NULL");
}
columnDefs.add(colDef.toString());
if (columnAnnotation.primaryKey()) {
primaryKeys.add(columnAnnotation.name());
}
}
if (columnDefs.isEmpty()) {
throw new IllegalStateException(
"类 " + entityClass.getSimpleName() + " 没有任何 @Column 字段"
);
}
// 3. 组装 SQL
StringBuilder sql = new StringBuilder();
sql.append("CREATE TABLE ").append(tableName).append(" (\n");
sql.append(String.join(",\n", columnDefs));
if (!primaryKeys.isEmpty()) {
sql.append(",\n PRIMARY KEY (")
.append(String.join(", ", primaryKeys))
.append(")");
}
sql.append("\n);");
return sql.toString();
}
}
测试主类
public class ORMDemo {
public static void main(String[] args) {
System.out.println("========== 飞翔科技 ORM 建表SQL生成 ==========\n");
// 生成 Student 表的建表 SQL
String studentTableSQL = SimpleORM.generateCreateTableSQL(Student.class);
System.out.println("【Student 表建表SQL】");
System.out.println(studentTableSQL);
// 反射查看 Student 类上的注解
System.out.println("\n【反射验证——Student 类注解信息】");
Table table = Student.class.getAnnotation(Table.class);
System.out.println("表名: " + table.value());
for (java.lang.reflect.Field field : Student.class.getDeclaredFields()) {
Column col = field.getAnnotation(Column.class);
if (col != null) {
System.out.printf("字段:%s → 列名:%s,类型:%s,长度:%d,可空:%s,主键:%s%n",
field.getName(),
col.name(),
col.type(),
col.length(),
col.nullable(),
col.primaryKey()
);
}
}
}
}
运行输出:
========== 飞翔科技 ORM 建表SQL生成 ==========
【Student 表建表SQL】
CREATE TABLE students (
id BIGINT NOT NULL,
name VARCHAR(50) NOT NULL,
age INT,
major VARCHAR(100),
email VARCHAR(200),
PRIMARY KEY (id)
);
【反射验证——Student 类注解信息】
表名: students
字段:id → 列名:id,类型:BIGINT,长度:20,可空:false,主键:true
字段:name → 列名:name,类型:VARCHAR,长度:50,可空:false,主键:false
字段:age → 列名:age,类型:INT,长度:3,可空:true,主键:false
字段:major → 列名:major,类型:VARCHAR,长度:100,可空:true,主键:false
字段:email → 列名:email,类型:VARCHAR,长度:200,可空:true,主键:false
完整示例二:@TestMethod 测试框架模拟
场景描述
小崔希望编写一个简易的单元测试框架,能够自动发现和运行带有 @TestMethod 标记的方法,并统计通过/失败的测试数量。
注解定义
import java.lang.annotation.*;
/**
* 测试方法标记注解
* value 成员:优先级(数字越小优先级越高),默认 5
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TestMethod {
int value() default 5; // 优先级(1最高,9最低)
}
测试运行器
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 简易测试运行器 —— 扫描并执行 @TestMethod 标记的方法
*/
public class TestRunner {
private int passed = 0;
private int failed = 0;
private final List<String> failures = new ArrayList<>();
/**
* 运行指定对象的测试方法
*/
public void runTests(Object testInstance) {
Class<?> clazz = testInstance.getClass();
System.out.println("=== 开始运行测试:" + clazz.getSimpleName() + " ===\n");
// 1. 收集所有带 @TestMethod 的方法
List<Method> testMethods = new ArrayList<>();
for (Method method : clazz.getDeclaredMethods()) {
TestMethod testAnnotation = method.getAnnotation(TestMethod.class);
if (testAnnotation != null) {
testMethods.add(method);
}
}
// 2. 按优先级排序(value 越小越优先)
testMethods.sort(Comparator.comparingInt(m -> m.getAnnotation(TestMethod.class).value()));
// 3. 逐个执行
for (Method method : testMethods) {
TestMethod annotation = method.getAnnotation(TestMethod.class);
System.out.printf("[优先级 %d] 执行测试:%s ... ", annotation.value(), method.getName());
try {
method.setAccessible(true);
method.invoke(testInstance);
passed++;
System.out.println("通过");
} catch (InvocationTargetException e) {
failed++;
String reason = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
failures.add(method.getName() + " —— " + reason);
System.out.println("失败!原因:" + reason);
} catch (Exception e) {
failed++;
failures.add(method.getName() + " —— " + e.getMessage());
System.out.println("错误:" + e.getMessage());
}
}
// 4. 输出统计
System.out.println("\n=== 测试汇总 ===");
System.out.printf("通过:%d | 失败:%d | 总计:%d%n", passed, failed, passed + failed);
if (!failures.isEmpty()) {
System.out.println("\n失败详情:");
failures.forEach(f -> System.out.println(" ✗ " + f));
}
}
public int getPassed() { return passed; }
public int getFailed() { return failed; }
}
测试用例类
/**
* 学生服务测试用例
*/
public class StudentServiceTest {
@TestMethod(1) // 最高优先级
public void testSaveStudent() {
System.out.println("(模拟) 保存学生到数据库...");
// 模拟成功
}
@TestMethod(2)
public void testFindById() {
System.out.println("(模拟) 按 ID 查询学生...");
// 模拟成功
}
@TestMethod(3)
public void testDeleteStudent() {
System.out.println("(模拟) 删除学生...");
throw new RuntimeException("删除接口尚未实现!"); // 模拟失败
}
@TestMethod(5) // 默认优先级
public void testEnrollStudent() {
System.out.println("(模拟) 学生入学流程...");
// 模拟成功
}
@TestMethod(4)
public void testExportReport() {
System.out.println("(模拟) 导出学生报表...");
throw new AssertionError("报表格式不正确"); // 模拟失败
}
}
运行入口
public class TestFrameworkDemo {
public static void main(String[] args) {
TestRunner runner = new TestRunner();
runner.runTests(new StudentServiceTest());
}
}
运行输出:
=== 开始运行测试:StudentServiceTest ===
[优先级 1] 执行测试:testSaveStudent ... (模拟) 保存学生到数据库...
通过
[优先级 2] 执行测试:testFindById ... (模拟) 按 ID 查询学生...
通过
[优先级 3] 执行测试:testDeleteStudent ... (模拟) 删除学生...
失败!原因:删除接口尚未实现!
[优先级 4] 执行测试:testExportReport ... (模拟) 导出学生报表...
失败!原因:报表格式不正确
[优先级 5] 执行测试:testEnrollStudent ... (模拟) 学生入学流程...
通过
=== 测试汇总 ===
通过:3 | 失败:2 | 总计:5
失败详情:
✗ testDeleteStudent —— 删除接口尚未实现!
✗ testExportReport —— 报表格式不正确
深度原理:注解成员的存储与动态代理
关键机制:
- 注解的存储:注解的使用信息(成员名-值映射)被写入
.class文件属性表中的RuntimeVisibleAnnotations条目 - 动态代理:当调用
getAnnotation()时,JVM 不会去加载或实例化注解接口本身,而是创建一个java.lang.reflect.Proxy动态代理对象,该对象实现了注解接口并持有成员值 - 成员访问:通过代理对象调用
value()等成员方法时,代理返回编译时写入的值
可以用如下代码验证:
// 验证注解实例是动态代理对象
Table table = Student.class.getAnnotation(Table.class);
System.out.println(table.getClass().getName());
// 输出类似:com.sun.proxy.$Proxy1
易错场景
反例一:注解成员类型使用了不允许的类型
小崔想给 @Column 加一个"校验规则"列表,自然地想用 List<String>:
// ❌ 错误:注解成员不能用集合类型
public @interface Column {
String name();
String type() default "VARCHAR";
List<String> validations(); // 编译错误!Invalid type for annotation member
}
编译报错:
错误: 注解成员类型无效; 应为 '原始类型', 'String', 'Class', 枚举, 注解
或以上类型的数组
纠正:使用一维数组替代:
// ✅ 正确:用数组代替集合
public @interface Column {
String name();
String type() default "VARCHAR";
String[] validations() default {}; // 字符串数组
}
// 使用:花括号括起来的数组
@Column(name = "email", validations = {"email", "notBlank", "maxLength:200"})
反例二:value 语法糖使用不当
小崔在自定义注解中同时定义了 value 和其他有默认值的成员,但使用时企图省略 value = 却失败了:
// 注解定义
public @interface Task {
String value(); // 任务名称
String assignee() default "小崔"; // 负责人
}
// ❌ 错误:同时给 value 和 assignee 赋值时,不能省略 value =
@Task("学生报表", assignee = "白歌") // 编译错误!
编译器报错原因是:当有多个成员需要赋值时,value 语法糖失效。
纠正:两种情况分别处理:
// ✅ 方式一:仅给 value 赋值,可省略
@Task("学生报表")
// ✅ 方式二:给 value 和其他成员同时赋值,value 必须写全
@Task(value = "学生报表", assignee = "白歌")
反例三:注解成员有默认值但试图使用 null
小崔将 @Column 中的 type() 默认值设为 ""(空字符串),想通过判断空字符串来代表"不指定"。但代码审查时白歌指出更好的做法:
// ❌ 不良实践:用空字符串作为"未指定"的标记
public @interface Column {
String name();
String type() default ""; // 语义不清
}
// ✅ 推荐:使用更明确的默认值
public @interface Column {
String name();
String type() default "VARCHAR"; // 清晰表达默认类型
}
设计原则:注解成员的默认值应当具有明确的业务语义,避免使用空字符串或特殊魔法值代表"未指定"。
面试考点
Q1:自定义注解和接口有什么区别?
① 关键字不同:注解用
@interface,接口用interface;② 注解自动继承java.lang.annotation.Annotation,接口不自动继承;③ 注解成员只能用特定的几种类型(基本类型、String、Class、枚举、注解及其数组),接口方法无此限制;④ 注解成员可以有默认值(default),接口方法在 JDK 8 之前不能,JDK 8 引入default方法后才支持;⑤ 注解不能被实例化或实现,接口可以被类实现。
Q2:注解的 value() 有什么特殊之处?为什么可以省略成员名?
value()是注解中的约定俗成的特殊成员名。Java 语言规范(JLS §9.7.3)明确规定:如果在注解使用时只提供一个元素值且该值对应的成员名为value,则可以省略value =。这是编译器层面的语法糖,而非 JVM 级别的特性。当注解中有多个成员需要赋值时,就不能省略。
Q3:注解成员为什么不能用包装类型(如 Integer)而只能用基本类型(int)?
这主要是设计上的简洁性考量。注解数据必须紧凑地存储在
.class文件的常量池中,基本类型和 String 都有直接对应的常量池条目(CONSTANT_Integer_info、CONSTANT_String_info等),而包装类型的自动装箱/拆箱会增加编译器和运行时的复杂度。此外,基本类型的默认值语义清晰(0、false),而包装类型null可能带来更多的歧义。
Q4:标记注解(Marker Annotation)有什么实际用途?
标记注解虽然不携带数据,但可用于"打标签"——在运行时通过反射检测其存在与否来决定程序行为。例如 JUnit 4 的
@Test就是标记注解,测试运行器扫描到带有该标记的方法即视为测试用例。Spring 的@Repository本身不携带数据,但 Spring 容器检测到它会自动进行数据访问异常翻译。标记注解是一种轻量级的"类型开关"。
Q5:如何在运行时判断一个方法上是否存在某个注解?
使用反射 API:
Method.isAnnotationPresent(MyAnnotation.class)判断是否存在;或Method.getAnnotation(MyAnnotation.class)获取注解实例(不存在返回 null)。注意,这要求注解的@Retention必须是RUNTIME,否则反射无法读取。
小结
| 知识要点 | 核心内容 |
|---|---|
| 定义语法 | public @interface 注解名 { 成员声明; } |
| 成员类型 | 基本类型、String、Class、枚举、注解、及上述类型的数组 |
| 默认值 | 使用 default 关键字,有默认值的成员在使用时可省略 |
| value() | 特殊成员,仅给 value 赋值时省略 value = |
| 标记注解 | 无成员注解,仅作为类型标记存在 |
| 反射读取 | 通过 getAnnotation() / isAnnotationPresent() 读取 |
| 底层实现 | 运行时通过动态代理(java.lang.reflect.Proxy)返回注解实例 |
自定义注解让开发者能够在 Java 类型系统中表达自己的领域语义。但要真正控制注解在什么位置可用、保留到哪个阶段、是否能继承——这就需要元注解的加持了。