1. 为什么注解和反射是绕不开的坎
刚入行那会儿,我最怕看框架源码。打开一个Spring的类,满屏的@Autowired、@Component、@RequestMapping,心里就一个念头:这些带@符号的东西到底在干嘛?为什么加个注解,对象就自动注入了?为什么写个@RequestMapping("/user"),浏览器就能访问到对应方法?
后来被逼着写了一个简易的ORM框架,才真正把注解和反射这两块硬骨头啃下来。说实话,注解和反射单独拎出来都不算特别复杂,但它们组合在一起,就是几乎所有Java框架的底层基石。你用的Spring、MyBatis、Hibernate、JUnit,甚至Lombok,底层都离不开这两个东西。
这篇文章我打算换个讲法。不按教科书那种“先讲定义再讲语法”的顺序来,而是直接从一个真实需求出发:手写一个能用的ORM框架。在实现过程中,需要用到注解的地方就讲注解,需要用到反射的地方就讲反射。这样学下来,你不仅知道它们是什么,更知道它们能干什么、为什么要这么设计。
适合谁看?如果你已经会写Java基础代码,但对注解和反射一直停留在“知道有这么个东西”的阶段,或者看框架源码时总觉得隔了一层纱,那这篇内容应该能帮你把这层纱捅破。如果你完全没接触过Java,建议先补一下基础语法再来看。
提醒:文中的代码示例为了演示核心逻辑,做了适当简化。实际生产环境的ORM框架要考虑连接池、缓存、事务、SQL注入防护等大量细节,不要直接拿demo上生产。
2. 注解的本质:给代码贴标签的学问
2.1 注解不是魔法,它只是一份元数据
很多人第一次接触注解,会觉得这东西很神秘。一个@Override加在方法上,编译器就能帮你检查方法签名对不对;一个@Deprecated加在类上,IDE就会给你画一条删除线。看起来像是注解本身在“执行”某些逻辑,但实际上,注解自己什么都不做。
注解的本质是元数据,也就是“描述数据的数据”。它就像你给一个纸箱贴的标签,标签上写着“易碎品”“向上”“防潮”。标签本身不会搬箱子,也不会保护箱子里的东西,它只是提供信息。真正起作用的是看到标签的人——搬运工会根据“易碎品”标签轻拿轻放,仓库管理员会根据“向上”标签调整堆放方向。
在Java里,这个“看到标签的人”就是注解处理器。编译器看到@Override会做检查,框架看到@Component会创建Bean,JUnit看到@Test会执行对应方法。注解只是信息载体,处理逻辑是另外写的。
理解这一点非常关键。很多初学者会问“为什么我加了这个注解没效果”,原因往往就是:没有对应的处理器去读取这个注解。你贴了标签,但没人看,自然不会有任何反应。
2.2 元注解:定义注解的注解
Java内置了几个元注解,专门用来定义其他注解。你可以把它们理解为“注解的注解”。最常用的四个:
@Target:指定这个注解可以用在什么地方。比如只能用在方法上、只能用在类上、可以用在字段上等。@Retention:指定这个注解保留到什么时候。是只在源码中保留(编译后丢弃),还是保留到class文件中(JVM运行时可以读取),还是保留到运行时。@Documented:指定这个注解是否包含在Javadoc中。@Inherited:指定这个注解是否能被子类继承。
其中@Retention是最关键的。它有三个取值:
| 取值 | 含义 | 典型用途 |
|---|---|---|
| SOURCE | 只在源码中保留,编译后丢弃 | @Override、@SuppressWarnings |
| CLASS | 保留到class文件,但JVM运行时不可见 | 一些编译期处理工具的注解 |
| RUNTIME | 保留到运行时,JVM可以通过反射读取 | Spring、MyBatis等框架的注解 |
只有RUNTIME级别的注解,才能被反射读取到。这是手写ORM框架时必须注意的第一件事。如果你定义了一个注解但忘了加@Retention(RetentionPolicy.RUNTIME),反射读取时就会得到null,然后你会花大量时间排查一个其实很简单的配置问题。
2.3 自定义注解的完整写法
定义一个注解的语法和定义接口很像,只是前面多了个@符号。比如我们要定义一个@Table注解,用来标记实体类对应的数据库表名:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Table { String value() default ""; }这里String value() default ""是注解的属性。注解的属性只能支持基本类型、String、Class、枚举、注解类型以及它们的数组。属性可以有默认值,也可以没有。如果没有默认值,使用注解时必须显式赋值。
再定义一个@Column注解,用来标记字段对应的数据库列名:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Column { String value() default ""; boolean nullable() default true; int length() default 255; }注意@Target的取值不同:@Table用在类上,所以是ElementType.TYPE;@Column用在字段上,所以是ElementType.FIELD。如果写错了,编译器会直接报错,这其实是好事,能帮你提前发现设计问题。
还有一个@Id注解,用来标记主键:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Id { boolean autoIncrement() default true; }这三个注解就是我们手写ORM框架的基础。定义好之后,就可以在实体类上使用了。
2.4 注解属性的默认值陷阱
这里有一个很容易踩的坑:注解属性的默认值是在编译期确定的,不能是运行时才能确定的值。比如你不能写String value() default System.getProperty("table.name"),因为注解的属性值必须是常量表达式。
另一个坑是,如果注解只有一个属性且名字叫value,使用时可以省略属性名。比如@Table("user")等价于@Table(value = "user")。但如果同时有多个属性,就必须写清楚属性名。这个规则在Spring的@RequestMapping里体现得很明显:@RequestMapping("/user")可以,但@RequestMapping(value = "/user", method = RequestMethod.GET)就必须写全。
还有一点,注解属性如果是数组,赋值时可以用花括号,但如果只有一个元素,花括号可以省略。比如@RequestMapping(method = RequestMethod.GET)和@RequestMapping(method = {RequestMethod.GET})是等价的。
3. 反射:在运行时“看透”一个类
3.1 反射到底在做什么
如果说注解是贴在代码上的标签,那反射就是在运行时读取这些标签、并操作类内部结构的能力。
正常情况下,我们写代码时是“编译期确定”的:你知道有个User类,知道它有name字段,所以你直接写user.getName()。但框架开发者面对的是未知的类——Spring不知道你会写什么Service,MyBatis不知道你会定义什么实体。它需要在运行时动态地获取类的信息:这个类有哪些字段?哪些方法?字段上有什么注解?然后根据这些信息做相应的处理。
反射就是干这个的。它允许你在运行时:
- 获取一个类的
Class对象 - 获取类的所有字段、方法、构造器
- 读取字段的值、调用方法、创建实例
- 读取类、字段、方法上的注解
Class对象是反射的入口。获取Class对象有三种方式:
// 方式一:通过类名.class Class<?> clazz1 = User.class; // 方式二:通过对象.getClass() User user = new User(); Class<?> clazz2 = user.getClass(); // 方式三:通过Class.forName(),最常用在框架中 Class<?> clazz3 = Class.forName("com.example.entity.User");框架中最常用的是第三种,因为框架往往只知道类的全限定名(比如从配置文件中读取),需要在运行时加载。
3.2 获取字段、方法和构造器
拿到Class对象后,就可以获取类的内部结构了。常用的方法有两组:getDeclaredXxx()和getXxx()。
getDeclaredFields():获取本类声明的所有字段,包括private的,但不包括继承来的。getFields():获取所有public字段,包括继承来的。getDeclaredMethods():获取本类声明的所有方法,包括private的。getMethods():获取所有public方法,包括继承来的。getDeclaredConstructors():获取本类声明的所有构造器。
在手写ORM框架时,我们通常用getDeclaredFields(),因为实体类的字段一般是private的,而且我们只关心本类声明的字段。
Class<?> clazz = Class.forName("com.example.entity.User"); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { System.out.println("字段名:" + field.getName()); System.out.println("字段类型:" + field.getType().getName()); }这里有一个细节:getDeclaredFields()返回的字段顺序不保证和源码中的顺序一致。JVM规范没有要求保留字段顺序,不同JVM实现可能返回不同的顺序。如果你的ORM框架依赖字段顺序(比如生成INSERT语句时),需要额外处理,比如通过@Column注解的order属性来指定顺序,或者用LinkedHashMap按名称排序。
3.3 访问私有成员:setAccessible的代价
反射可以访问private字段和方法,但需要先调用setAccessible(true):
Field field = clazz.getDeclaredField("name"); field.setAccessible(true); Object value = field.get(user);setAccessible(true)的作用是关闭Java的访问检查。这听起来很强大,但有几个需要注意的点:
第一,性能开销。访问检查被关闭后,JVM无法对字段访问做优化,反射调用比直接调用慢很多。在JDK 8之前,反射调用可能比直接调用慢几十倍甚至上百倍。JDK 8之后有所优化,但仍然有明显差距。所以框架通常会缓存反射获取的Field、Method对象,避免重复查找。
第二,模块化限制。JDK 9引入模块系统后,如果目标类在另一个模块中且没有导出对应的包,setAccessible(true)可能会抛出InaccessibleObjectException。这是很多老框架在JDK 9+上出问题的原因之一。
第三,安全限制。某些运行环境(如Applet、安全管理器开启的环境)会禁止setAccessible。虽然现在这些场景很少了,但了解这一点有助于理解为什么有些框架在特定环境下会报错。
实操建议:在ORM框架中,建议在初始化阶段一次性扫描所有实体类,把字段的
Field对象缓存到一个Map中,后续操作直接查缓存,避免每次操作都重新反射查找。这个优化在数据量大的场景下效果非常明显。
3.4 反射创建实例与调用方法
除了读取字段,反射还能创建实例和调用方法:
// 创建实例(调用无参构造器) Object instance = clazz.getDeclaredConstructor().newInstance(); // 调用方法 Method method = clazz.getDeclaredMethod("setName", String.class); method.invoke(instance, "张三");getDeclaredMethod需要传入方法名和参数类型列表。如果方法有重载,参数类型必须匹配。invoke的第一个参数是目标对象,后面是方法参数。
这里有一个常见的坑:基本类型和包装类型的匹配问题。比如方法签名是setAge(int age),你传Integer.class是找不到这个方法的,必须传int.class。反过来也一样。在写通用框架时,需要做类型转换处理。
4. 手写ORM底层demo:从零搭建映射引擎
4.1 整体设计思路
现在我们把注解和反射结合起来,手写一个简易的ORM框架。目标很明确:给一个实体类,自动生成对应的SQL语句,并能把查询结果映射回实体对象。
整体流程分三步:
- 解析实体类:通过反射读取类上的
@Table注解和字段上的@Column、@Id注解,构建一个“类到表”的映射关系。 - 生成SQL:根据映射关系,生成INSERT、SELECT、UPDATE、DELETE语句。
- 结果映射:执行查询后,把
ResultSet中的数据通过反射设置到实体对象的字段上。
这个demo不涉及数据库连接池、事务管理、缓存等高级特性,只聚焦最核心的映射逻辑。理解了这套逻辑,再看MyBatis、Hibernate的源码就不会觉得无从下手了。
4.2 实体类定义与Javabean规范
先定义一个符合Javabean规范的实体类:
@Table("t_user") public class User { @Id(autoIncrement = true) @Column("id") private Long id; @Column("user_name") private String userName; @Column("age") private Integer age; @Column("email") private String email; // 无参构造器 public User() {} // getter和setter public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }这里要强调一下Javabean规范,因为ORM框架严重依赖这些规范:
- 类必须是public的
- 必须有无参构造器(反射创建实例时需要)
- 字段是private的
- 通过getter/setter访问字段
- getter/setter命名遵循
getXxx/setXxx格式
为什么必须有无参构造器?因为反射创建实例时,最简单的做法就是调用无参构造器。如果只有带参构造器,框架就需要额外处理参数注入,复杂度会大幅上升。这也是为什么很多框架要求实体类必须有无参构造器。
4.3 表映射元数据的构建
接下来写一个TableInfo类,用来保存解析后的映射信息:
public class TableInfo { private String tableName; private String idColumnName; private String idFieldName; private boolean idAutoIncrement; private List<ColumnInfo> columns = new ArrayList<>(); // 省略getter/setter } public class ColumnInfo { private String columnName; private String fieldName; private Class<?> fieldType; private Field field; // 省略getter/setter }然后写一个解析器,通过反射读取注解,构建TableInfo:
public class TableInfoParser { private static final Map<Class<?>, TableInfo> CACHE = new ConcurrentHashMap<>(); public static TableInfo parse(Class<?> clazz) { return CACHE.computeIfAbsent(clazz, TableInfoParser::doParse); } private static TableInfo doParse(Class<?> clazz) { TableInfo tableInfo = new TableInfo(); // 解析类上的@Table注解 Table table = clazz.getAnnotation(Table.class); if (table == null) { throw new RuntimeException("实体类缺少@Table注解:" + clazz.getName()); } tableInfo.setTableName(table.value()); // 解析字段上的注解 Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { Column column = field.getAnnotation(Column.class); if (column == null) { continue; } ColumnInfo columnInfo = new ColumnInfo(); columnInfo.setColumnName(column.value()); columnInfo.setFieldName(field.getName()); columnInfo.setFieldType(field.getType()); columnInfo.setField(field); // 判断是否为主键 Id id = field.getAnnotation(Id.class); if (id != null) { tableInfo.setIdColumnName(column.value()); tableInfo.setIdFieldName(field.getName()); tableInfo.setIdAutoIncrement(id.autoIncrement()); } tableInfo.getColumns().add(columnInfo); } return tableInfo; } }这里用了ConcurrentHashMap做缓存,避免每次操作都重新解析。computeIfAbsent是JDK 8引入的方法,能保证线程安全且只解析一次。这个缓存设计在实际框架中非常常见,因为反射解析的开销不小,缓存后性能提升明显。
4.4 SQL生成器的实现
有了TableInfo,生成SQL就简单了。先写INSERT语句的生成:
public class SqlGenerator { public static String generateInsert(TableInfo tableInfo) { StringBuilder sql = new StringBuilder("INSERT INTO "); sql.append(tableInfo.getTableName()).append(" ("); List<ColumnInfo> columns = tableInfo.getColumns(); List<String> columnNames = new ArrayList<>(); List<String> placeholders = new ArrayList<>(); for (ColumnInfo column : columns) { // 自增主键不参与INSERT if (column.getColumnName().equals(tableInfo.getIdColumnName()) && tableInfo.isIdAutoIncrement()) { continue; } columnNames.add(column.getColumnName()); placeholders.add("?"); } sql.append(String.join(", ", columnNames)); sql.append(") VALUES ("); sql.append(String.join(", ", placeholders)); sql.append(")"); return sql.toString(); } }再写SELECT语句的生成:
public static String generateSelectById(TableInfo tableInfo) { StringBuilder sql = new StringBuilder("SELECT "); List<String> columnNames = new ArrayList<>(); for (ColumnInfo column : tableInfo.getColumns()) { columnNames.add(column.getColumnName()); } sql.append(String.join(", ", columnNames)); sql.append(" FROM ").append(tableInfo.getTableName()); sql.append(" WHERE ").append(tableInfo.getIdColumnName()).append(" = ?"); return sql.toString(); }UPDATE和DELETE类似,这里不一一展开。核心思路就是:从TableInfo中读取表名、列名,拼接成SQL字符串,用?作为占位符防止SQL注入。
注意:这里用
?占位符而不是直接拼接值,是为了防止SQL注入。在实际框架中,值的设置是通过PreparedStatement的setXxx方法完成的,而不是字符串拼接。这一点非常重要,千万不要为了省事直接拼SQL。
4.5 结果集到对象的映射
查询执行后,需要把ResultSet映射回实体对象。这是反射发挥核心作用的地方:
public class ResultSetMapper { public static <T> T map(ResultSet rs, Class<T> clazz) throws Exception { TableInfo tableInfo = TableInfoParser.parse(clazz); T instance = clazz.getDeclaredConstructor().newInstance(); for (ColumnInfo column : tableInfo.getColumns()) { Object value = rs.getObject(column.getColumnName()); if (value == null) { continue; } Field field = column.getField(); field.setAccessible(true); // 类型转换处理 Object convertedValue = convertType(value, field.getType()); field.set(instance, convertedValue); } return instance; } private static Object convertType(Object value, Class<?> targetType) { if (targetType.isInstance(value)) { return value; } // 处理基本类型和包装类型的转换 if (targetType == Long.class || targetType == long.class) { return ((Number) value).longValue(); } if (targetType == Integer.class || targetType == int.class) { return ((Number) value).intValue(); } if (targetType == String.class) { return value.toString(); } // 其他类型转换... return value; } }这里有几个关键点:
第一,field.setAccessible(true)是必须的,因为实体类的字段通常是private的。
第二,类型转换是必须的。数据库返回的Long可能对应Java的long,数据库的VARCHAR可能对应Java的String,但ResultSet.getObject()返回的类型和字段类型不一定完全匹配。特别是基本类型和包装类型之间,需要显式转换。
第三,null值处理。如果数据库字段为null,rs.getObject()返回null,此时不应该设置到基本类型字段上(会抛异常),应该跳过或者设置默认值。
4.6 完整的CRUD流程串联
把上面的组件串起来,一个简易的ORM就成型了:
public class SimpleOrm { private final DataSource dataSource; public SimpleOrm(DataSource dataSource) { this.dataSource = dataSource; } public <T> T selectById(Class<T> clazz, Object id) throws Exception { TableInfo tableInfo = TableInfoParser.parse(clazz); String sql = SqlGenerator.generateSelectById(tableInfo); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setObject(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return ResultSetMapper.map(rs, clazz); } } } return null; } public <T> int insert(T entity) throws Exception { Class<?> clazz = entity.getClass(); TableInfo tableInfo = TableInfoParser.parse(clazz); String sql = SqlGenerator.generateInsert(tableInfo); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { int index = 1; for (ColumnInfo column : tableInfo.getColumns()) { if (column.getColumnName().equals(tableInfo.getIdColumnName()) && tableInfo.isIdAutoIncrement()) { continue; } Field field = column.getField(); field.setAccessible(true); ps.setObject(index++, field.get(entity)); } return ps.executeUpdate(); } } }使用起来就像这样:
SimpleOrm orm = new SimpleOrm(dataSource); User user = orm.selectById(User.class, 1L); System.out.println(user.getUserName());到这里,一个能跑通CRUD的简易ORM就完成了。虽然离生产级框架还有很大距离,但核心的注解解析、反射操作、SQL生成、结果映射这几个环节都覆盖到了。理解了这套流程,再看MyBatis的MapperProxy、ResultSetHandler就不会觉得陌生。
5. 踩坑实录:那些年反射和注解给我上的课
5.1 注解没加RUNTIME导致读取为null
这是我最早踩的坑。定义了一个@Column注解,忘了加@Retention(RetentionPolicy.RUNTIME),结果运行时field.getAnnotation(Column.class)返回null。排查了半天,以为是反射写错了,最后才发现是注解的保留策略问题。
默认的@Retention是CLASS,不是RUNTIME。也就是说,如果你不显式写@Retention(RetentionPolicy.RUNTIME),注解在运行时是读不到的。这个默认值设计是有历史原因的,但对初学者来说确实容易踩坑。
经验:定义任何需要被反射读取的注解,第一件事就是加上
@Retention(RetentionPolicy.RUNTIME)。可以把这个当成肌肉记忆。
5.2 反射调用性能问题的真实数据
我做过一个简单的性能测试,对比直接调用和反射调用的差距:
| 调用方式 | 100万次耗时(ms) | 相对倍数 |
|---|---|---|
| 直接调用setter | 5 | 1x |
| 反射调用(不缓存Method) | 850 | 170x |
| 反射调用(缓存Method) | 120 | 24x |
| 反射调用(缓存+setAccessible) | 45 | 9x |
这个数据因JVM版本和硬件不同会有差异,但趋势是明确的:反射确实慢,但缓存后可以接受。在ORM框架中,字段的Field对象和Method对象都应该缓存起来,避免重复查找。setAccessible(true)也能带来明显的性能提升,因为它关闭了访问检查。
5.3 getDeclaredFields顺序不保证的坑
前面提到过,getDeclaredFields()返回的字段顺序不保证和源码一致。我写SQL生成器时,一开始假设字段顺序和源码一致,结果在某些JVM上生成的INSERT语句列顺序和值顺序对不上,导致数据错位。
解决方案有两种:一是通过注解属性显式指定顺序,二是在生成SQL时按列名排序。我选择了第二种,因为更简单可靠:
List<ColumnInfo> columns = tableInfo.getColumns(); columns.sort(Comparator.comparing(ColumnInfo::getColumnName));这样虽然列的顺序和源码不一致,但INSERT语句的列名和值是一一对应的,不会出错。
5.4 基本类型与包装类型的转换陷阱
反射设置字段值时,如果字段是int类型,你传一个Integer对象,field.set()是能正常工作的(自动拆箱)。但如果传null,就会抛IllegalArgumentException。同样,如果字段是long,你传Integer,也会抛异常。
在结果映射时,数据库返回的Number类型可能是Long、Integer、BigDecimal等,需要根据目标字段类型做转换。我写了一个TypeConverter工具类专门处理这个问题,核心逻辑就是判断目标类型,然后调用对应的xxxValue()方法。
5.5 JDK版本升级带来的反射限制
JDK 9引入模块系统后,反射访问非导出包的类会抛出InaccessibleObjectException。JDK 17进一步收紧了限制。如果你的框架需要反射访问JDK内部类(比如java.util包下的某些类),需要添加--add-opens启动参数。
这个问题在升级JDK版本时特别容易遇到。我的建议是:尽量不要反射访问JDK内部类,如果必须访问,在启动脚本中显式添加--add-opens参数,并做好版本兼容处理。
6. 从demo到生产:还需要考虑什么
6.1 连接池与资源管理
demo里每次操作都从DataSource获取连接,用完关闭。实际生产环境必须用连接池(如HikariCP、Druid),避免频繁创建销毁连接的开销。同时要确保Connection、PreparedStatement、ResultSet都在try-with-resources中正确关闭,防止资源泄漏。
6.2 SQL注入防护
demo中所有值都通过PreparedStatement的setObject设置,这是正确的做法。但表名、列名是通过字符串拼接的,这些来自注解,是开发者可控的,不存在注入风险。如果表名来自用户输入,就必须做白名单校验。
6.3 缓存策略
TableInfo的缓存用了ConcurrentHashMap,这是最基本的缓存。实际框架中还需要考虑:缓存失效(类被重新加载时)、缓存大小限制(避免内存泄漏)、多ClassLoader环境下的缓存隔离等。
6.4 事务管理
demo没有涉及事务。实际ORM框架需要支持事务,通常通过ThreadLocal绑定当前线程的Connection,在事务开始时关闭自动提交,事务结束时统一提交或回滚。Spring的@Transactional底层就是基于这个原理。
6.5 字段名与列名的映射策略
demo中通过@Column注解显式指定列名。实际框架通常还支持“驼峰转下划线”的自动映射策略,比如userName自动映射到user_name。这个策略可以通过配置开关控制,灵活适配不同的数据库命名规范。
7. 我个人的一些实操体会
写了这么多,最后分享几点我在实际项目中的体会。
第一,注解和反射的学习曲线是“先陡后平”的。刚开始接触时,各种概念和API容易让人晕头转向。但一旦理解了“注解是标签、反射是读取标签的工具”这个核心比喻,剩下的就是熟练度问题。建议一定要动手写一个demo,哪怕只是最简单的“读取注解并打印”,也比只看书强十倍。
第二,不要过度使用反射。反射很强大,但性能开销和代码可读性都是代价。在业务代码中,能用接口和多态解决的问题,尽量不要用反射。反射最适合的场景是框架开发、通用工具、动态代理这些“需要处理未知类型”的场景。
第三,注解设计要克制。我见过一些项目,自定义了十几个注解,每个注解有七八个属性,最后连开发者自己都记不清哪个注解该加在哪里。注解的设计应该遵循“最小够用”原则,属性越少越好,默认值越合理越好。
第四,多看源码。MyBatis的MapperAnnotationBuilder、Spring的AutowiredAnnotationBeanPostProcessor,这些都是注解和反射的经典应用。看源码时不要逐行读,先抓主干:它在哪里读取注解?读取后做了什么?把主干理清楚,细节自然就懂了。
第五,注意JDK版本兼容性。如果你的框架需要支持多个JDK版本,反射相关的代码要特别小心。JDK 8、11、17、21在反射行为上有不少差异,建议在CI中配置多版本测试,提前发现问题。
这个简易ORM的demo我后来又迭代了几个版本,加了简单的查询条件拼接、分页支持、一级缓存。每加一个功能,都对注解和反射的理解更深一层。如果你也想真正掌握这两块内容,建议不要只停留在看,动手写一个属于自己的小框架,哪怕功能很简单,收获也会远超预期。