很多朋友问我,Spring 源码都看完一遍了,为什么还是说不清楚 IoC 到底是什么?我的建议就一句话:扔掉 Spring,自己用 JDK 的注解加反射手写一个 IoC 容器,再整合 JDBC 把 CRUD 跑通,你会比看十遍源码都管用。这个项目就是这么来的:不引入任何框架,只用原生 JDK 实现基于注解的 IoC,容器负责 Bean 的扫描、注册、实例化、依赖注入,JDBC 模块负责数据访问,最终落成一组可用的 CRUD 方法。适合刚学完反射和注解、但对 Spring 原理心里没底的开发者,也适合想要一个轻量数据访问层的小型工具项目。我把整个设计和实现过程中的关键取舍、代码逻辑、踩过的坑都整理在下面,项目正文里没有写清楚的细节,这篇补全。
1. 写这个容器前,我先给自己定了这四条规矩
1.1 容器管什么、不管什么
手写 IoC 最容易犯的毛病是:什么都想做。搞完单例又搞原型,搞完字段注入又想构造器注入,最后还要做 AOP、事件监听,项目还没到 JDBC 那一步就已经被自己的扩展欲望淹没了。我开动前先明确了这个迷你容器的工作边界,就四条:
第一,只管理标注了 @Component、@Service、@Repository 注解的 Bean。接口和抽象类不实例化,没有注解的普通类不管。第二,实例化策略只做单例,不做原型。JDBC CRUD 这个场景里,数据源、模板类、Service 全是无状态或共享状态,单例完全够用,做原型反而增加复杂度。第三,依赖注入只做字段注入。构造器注入在自研容器里需要额外处理参数解析和类型匹配,优先级不高,字段注入结合反射已经能解决 90% 的问题。第四,生命周期回调只预留 @PostConstruct 一个入口,不搞 BeanPostProcessor 那一套复杂的扩展机制。四个边界定完,整个代码量立刻控制在十几个类以内。
1.2 模块划分与最终取舍
四个规矩确定后,项目的模块结构就很清楚了。我拆了三层:容器层、数据访问层、业务层。容器层里有扫描器 ClassPathScanner、注册表 BeanDefinitionRegistry、应用上下文 AnnotationApplicationContext;数据访问层有数据源 SimpleDataSource、模板类 JdbcTemplate、映射接口 RowMapper;业务层就是 UserService、UserDao 以及对应的实体类和 Mapper 接口。
这里有一个很关键的取舍:JDBC 层绝对不依赖容器层的任何内部接口,它只面向 DataSource 和实体类编程。这样设计的好处是,JDBC 模块可以脱离容器单独测试,以后想替换成连接池或者换别的 IoC 容器,数据访问层完全不用改动。我在一开始就坚持 JDBC 层和容器层之间只通过构造方法传 DataSource,而不是直接去容器里 getBean,事实证明这个决定让后边联调轻松很多。
还有一个取舍值得说。项目里我用一套简单的 SQL 映射规则:直接用 ? 占位符,不使用命名参数。命名参数需要额外的 SQL 解析器把 #{name} 替换成 ?,同时还要维护参数名和位置的对应关系,这已经接近 MyBatis 的复杂度了。这个项目要解决的是 JDBC 整合的最小闭环,所以我选择最朴素的 ? 占位符,把命名参数、注解参数解析放到 Mapper 代理那一层去做,这样职责清晰,也方便单独测试。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 注解定义和包扫描:容器第一次看到Bean的过程
2.1 自定义注解:四个够用的注解
注解是这件事的入口。整个项目只需要四个自定义注解,它们的定义都很简单,但有几个关键点必须说清楚。
@Component、@Service、@Repository 这三个注解的 @Retention 必须是 RetentionPolicy.RUNTIME,否则运行期反射拿不到。@Target 是 ElementType.TYPE,表示标在类上。@Autowired 的 @Target 是 ElementType.FIELD,标在字段上。我自己还加了一个 @Param 注解,@Target 是 ElementType.PARAMETER,用来给 Mapper 接口方法的参数命名,这个后面讲参数绑定的时候会详细说为什么必须加它。
java复制@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {
String value() default "";
}
注解的 value() 用来指定 Bean 名称。不写的话,默认按类名首字母小写生成。这个规则和 Spring 是一致的:UserDao 默认 Bean 名就是 userDao,指定了 @Service("userBiz") 就用 userBiz。名称解析逻辑虽然简单,但必须在一开始就定好,不然容器和业务代码对 Bean 名的约定不一致,排查起来非常痛苦。
2.2 ClassPathScanner:包名变成真实路径的细节
有了注解,接下来就是让容器能扫到这些类。IoC 容器启动的第一步永远是包扫描,这一步有几个细节很值得展开说。
java复制public Set<Class<?>> scan(String basePackage) {
Set<Class<?>> classes = new HashSet<>();
String basePath = basePackage.replace('.', '/');
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
if (classLoader == null) {
classLoader = ClassPathScanner.class.getClassLoader();
}
try {
Enumeration<URL> resources = classLoader.getResources(basePath);
while (resources.hasMoreElements()) {
URL url = resources.nextElement();
if ("file".equals(url.getProtocol())) {
File dir = new File(url.toURI());
scanDirectory(dir, basePackage, classes);
} else if ("jar".equals(url.getProtocol())) {
scanJar(url, basePackage, classes);
}
}
} catch (Exception e) {
throw new IllegalStateException("包扫描失败: " + basePackage, e);
}
return classes;
}
这里要强调的是,必须先拿 Thread.currentThread().getContextClassLoader(),而不是直接用 ClassPathScanner.class.getClassLoader()。在普通 main 方法里两者基本没区别,但在 Web 容器或某些插件化加载环境下,上下文类加载器才能正确找到应用自己的类路径。用错类加载器最常见的表现是:本地 IDE 跑得好好的,一打包运行就扫不到类。
还有一个我踩过的坑:扫描目录时只认 .class 文件,内部类、匿名类、接口都要过滤掉。另外 Class.forName(className) 这个调用默认会触发类的静态初始化,也就是说 Bean 还没创建,static 块先执行了。Spring 在扫描阶段用的是 Class.forName(name, false, classLoader) 来避免初始化副作用,我这里也改成同样写法。
2.3 BeanDefinition:为什么不能把Class直接塞进Map
扫描完成后,关键一步是把类的信息转成 BeanDefinition 再注册,而不是直接把 Class<?> 塞进 Map。什么区别?Class 只知道这个类长什么样,但 Bean 还有名称、作用域、是否懒加载、依赖关系这些元信息,这些东西需要独立的结构来承载。我那段时间写项目最大的体会就是:容器内部不直接操作 Class,而是操作描述 Bean 的元数据对象,后续扩展才不别扭。
java复制public class BeanDefinition {
private String beanName;
private Class<?> beanClass;
private List<String> injectedFieldNames = new ArrayList<>();
public BeanDefinition(String beanName, Class<?> beanClass) {
this.beanName = beanName;
this.beanClass = beanClass;
}
}
注册阶段的逻辑是:遍历扫描到的类,过滤掉接口和抽象类,检查类上有没有 @Component、@Service、@Repository 注解,有就解析 Bean 名,再通过反射遍历所有 @Autowired 字段,把字段名存进 injectedFieldNames,最后把 BeanDefinition 放进 ConcurrentHashMap。注册阶段不做实例化,这样容器启动的时间开销可控,也为后面两阶段启动打基础。
3. 实例化与依赖注入:先全部创建,再统一填字段
3.1 两阶段启动:实例化和依赖注入为什么必须分开
在写容器启动流程时,我最初的一版是边创建 Bean 边注入依赖,也就是遇到一个类,先 new 出来,然后立即注入它依赖的字段。这个方案看起来顺理成章,但很快就暴露了问题:如果 A 依赖 B,B 又依赖 C,创建 A 时发现 B 还没创建,就得递归去创建 B,创建 B 时又递归去创建 C。一旦出现循环依赖,整个递归直接栈溢出。即使没有循环依赖,这种深度优先的创建顺序也会让容器启动过程变得很难调试,你根本不知道当前创建到哪一层了。
所以我把启动拆成两个阶段:第一阶段遍历所有 BeanDefinition,通过无参构造器反射生成实例,全部放进一个以 beanName 为 key 的单例池里;第二阶段再遍历所有实例,逐个处理 @Autowired 字段注入。先把 Bean 都创建出来再统一装配,看似多了一次遍历,实际上让启动流程的每一步都可以单独调试,出现依赖缺失时也能准确定位。
java复制public void refresh(String basePackage) {
Set<Class<?>> classes = scanner.scan(basePackage);
for (Class<?> clazz : classes) {
if (isBeanCandidate(clazz)) {
BeanDefinition bd = createBeanDefinition(clazz);
registry.register(bd);
}
}
for (BeanDefinition bd : registry.getAll()) {
Object instance = bd.getBeanClass().getDeclaredConstructor().newInstance();
singletonPool.put(bd.getBeanName(), instance);
}
for (BeanDefinition bd : registry.getAll()) {
injectDependencies(singletonPool.get(bd.getBeanName()), bd);
}
}
3.2 字段注入的实现与setAccessible的边界
字段注入的代码不长,但里面有两个问题值得展开。第一个是 setAccessible(true) 的权限问题,第二个是通过类型还是名称找依赖。
依赖查找的顺序我设计成先按名称,再按类型。@Autowired 字段的名字是 userDao,就从单例池里取 userDao,取不到就在所有实例里找字段类型匹配的对象。这个顺序和 Spring 按类型优先的设计不同,但在这类项目里按名称优先更直观,因为大多数开发者的字段命名和 Bean 名是一致的。如果按名称找不到,再按类型兜底,能解决一些字段名不合常规别名的情况。
java复制private void injectDependencies(Object bean, BeanDefinition bd) {
Field[] fields = bd.getBeanClass().getDeclaredFields();
for (Field field : fields) {
if (!field.isAnnotationPresent(Autowired.class)) {
continue;
}
Object dependency = singletonPool.get(field.getName());
if (dependency == null) {
dependency = singletonPool.values().stream()
.filter(v -> field.getType().isAssignableFrom(v.getClass()))
.findFirst().orElse(null);
}
if (dependency == null) {
throw new IllegalStateException("无法注入依赖: " + field.getName());
}
field.setAccessible(true);
field.set(bean, dependency);
}
}
关于 setAccessible(true),补充一句。在 JDK17 之后,模块系统对 setAccessible 有强封装限制,但限制的是 JDK 内部模块的类,对用户自己写在 classpath 上的类来说,设置私有字段仍然是可以正常工作的。如果你遇到 InaccessibleObjectException,多半是目标类在 JDK 模块里而不是你的业务包,这种情况不要用反射 setAccessible 硬闯,应该改用构造器或者公开方法。
3.3 循环依赖:不解决,但要给出能定位问题的报错
手写容器阶段我不建议实现三级缓存之类的循环依赖解决方案,因为那会让容器复杂度翻倍。但不解决不代表不处理,至少要有一个能准确报错的机制。我的做法是注入阶段如果发现某个字段找不到对应的 Bean,不是简单抛一个空指针,而是把当前 Bean 名称、字段名、已经注入的 Bean 列表全部打印出来,完整的人工依赖链一眼就能看出问题。
比如报错会是:无法注入依赖: userDao,当前Bean=userService,已注入=[dataSource, jdbcTemplate]。看到这条信息,你立即知道 userService 想要一个 userDao 但没有,如果 userDao 也依赖 userService,那就可以确认是循环依赖,赶紧从设计层面拆掉。这种报错设计对调试非常关键,比 Spring 那种帮你自动解决了问题的方式更能训练对依赖关系的理解。
4. JDBC整合层:数据访问不能和容器耦合在一起
4.1 DataSource取舍:先用最简单实现,但要知道坑在哪
到了 JDBC 整合这一步,第一个要解决的问题是 DataSource 从哪里来。最省事的做法是直接用 DriverManager.getConnection(),每次操作都创建连接,用完就关。这种实现最简单,但问题也很明显:每次 CRUD 都要经历 TCP 建连、身份认证、释放连接的过程,在小并发场景下性能开销感人。
所以我写了一个非常简单的 SimpleDataSource,核心思路是维护一个连接对象的容器,池子固定大小,取连接时如果池子里有就直接用,没有就新建,归还时放回池子而不是关闭。这个实现和 HikariCP 的借用机制形似,但缺少了连接有效性检测、空闲回收、最大生命周期这些能力,只能算是教学版。如果实际项目要用,直接换成 HikariCP 是最好的选择,因为我们的 JDBC 层面向 DataSource 接口,SimpleDataSource 只是 DataSource 的实现之一,替换时不用改业务代码。这个设计的好处在这里真正体现出来了。
java复制public class SimpleDataSource implements DataSource {
private final String url;
private final String username;
private final String password;
private final ArrayDeque<Connection> pool = new ArrayDeque<>();
@Override
public Connection getConnection() throws SQLException {
Connection conn = pool.poll();
if (conn == null || conn.isClosed()) {
conn = DriverManager.getConnection(url, username, password);
}
return conn;
}
public void returnConnection(Connection conn) {
pool.offer(conn);
}
}
池子实现时有个容易忽略的点:从池子里拿出来的连接不能真的被关闭,否则缓存到池子里的全是一堆 closed 连接。所以 JDBC 模板类里拿到的 Connection 要么交给调用方自己管理,要么在内部包装一层代理,重写 close() 方法让它归还池子。我初期图省事直接用真实连接,结果 CRUD 调第二次就报连接已关闭,排查了很久才发现是连接池把已关闭连接还给了下一个请求。现在想想,在自研代码里做连接池,最稳妥的方式是封装代理连接,把 close() 语义改成归还。
4.2 参数绑定:PreparedStatement上的setObject没那么省心
SQL 防注入必须用 PreparedStatement,这个没什么可讨论的。真正麻烦的是参数绑定环节。很多人会写一个很潇洒的 for 循环,把参数数组遍历一遍,统一 ps.setObject(i + 1, args[i]),看起来没问题,实测会发现不少隐雷。
第一个坑是 null 值。ps.setObject(index, null) 在 MySQL 驱动下可能报 Unable to determine data type 之类的错误,因为驱动无法从 null 推断出列类型。所以模板方法里对参数做了判断,参数为 null 时调用 ps.setNull(index, Types.NULL),否则才走 setObject。
第二个坑是日期类型。java.util.Date 直接传给 setObject,MySQL 驱动会转成 java.sql.Timestamp,但如果你插入的字段映射到 DATE 类型,时间部分可能有截断问题。我的实体类里日期字段统一用 LocalDateTime,在参数绑定阶段做一个显式的类型转换:LocalDateTime 转换为 Timestamp,LocalDate 转换为 java.sql.Date,避免驱动判型猜错。
java复制private void bindParameters(PreparedStatement ps, Object[] args) throws SQLException {
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if (arg == null) {
ps.setNull(i + 1, Types.NULL);
} else if (arg instanceof LocalDateTime) {
ps.setTimestamp(i + 1, Timestamp.valueOf((LocalDateTime) arg));
} else if (arg instanceof LocalDate) {
ps.setDate(i + 1, Date.valueOf((LocalDate) arg));
} else {
ps.setObject(i + 1, arg);
}
}
}
4.3 ResultSet转实体:列名到属性的三段式映射
查询结果的映射是 JDBC 整合层最核心的部分,也是最能体现代码水平的地方。我一开始想得很简单,把 ResultSet 里每一列的列名和实体类的字段名一一对应不就行了。结果第一条 SQL 就给我一个下马威:数据库列名是 user_name,实体字段是 userName,直接 rs.getObject("userName") 在 MySQL 下也许能通过列别名匹配,但在别的数据库上很可能查不到。
所以我把映射逻辑拆成三段。第一段取列名,优先使用 ResultSetMetaData.getColumnLabel() 而不是 getColumnName(),因为列名可能被 SQL 别名覆盖,Label 才是真正用于 getXxx() 的键。第二段做下划线转驼峰:user_name 转换成 userName,create_time 转换成 createTime。第三段是反射赋值之前,根据目标字段类型对 rs.getObject(i) 的结果做一次转换,因为数据库返回的类型和 Java 字段类型不一定一致,比如数据库的 BIGINT UNSIGNED 可能映射到 Long,但有些驱动会返回 BigInteger。
java复制private <T> T mapRowToEntity(ResultSet rs, Class<T> entityClass) throws Exception {
T entity = entityClass.getDeclaredConstructor().newInstance();
ResultSetMetaData metaData = rs.getMetaData();
for (int i = 1; i <= metaData.getColumnCount(); i++) {
String columnLabel = metaData.getColumnLabel(i);
String propertyName = underlineToCamel(columnLabel);
Field field = findField(entityClass, propertyName);
if (field == null) {
continue;
}
Object value = rs.getObject(i);
field.setAccessible(true);
field.set(entity, convertValue(value, field.getType()));
}
return entity;
}
这段代码还有个隐藏细节:findField 要支持在父类里找字段。如果实体类有公共的 BaseEntity,里面存了 id、createTime 这些公共字段,只查 getDeclaredFields() 会漏掉父类字段。我写了一个递归向上查找的版本,先把 clazz.getDeclaredFields() 加进来,再一层层往父类找,直到 Object 为止。
5. CRUD落地:从单表操作到批量写入的一线实战
5.1 四个基础CRUD方法:够用且短小
容器和 JDBC 模板都跑通之后,真正的 CRUD 落地反而变得很快。我在 UserDao 里实现了五个基础方法:findById、findAll、save、updateById、deleteById。JdbcTemplate 提供 query、queryOne、update 三个核心方法,DAO 层只需要组织 SQL 和参数就行。
java复制@Repository
public class UserDao {
@Autowired
private JdbcTemplate jdbcTemplate;
public User findById(Long id) {
String sql = "SELECT id, user_name, age, create_time FROM t_user WHERE id = ?";
return jdbcTemplate.queryOne(sql, User.class, id);
}
public int save(User user) {
String sql = "INSERT INTO t_user(user_name, age, create_time) VALUES(?, ?, ?)";
return jdbcTemplate.update(sql,
user.getUserName(), user.getAge(), user.getCreateTime());
}
public int updateById(User user) {
String sql = "UPDATE t_user SET user_name = ?, age = ? WHERE id = ?";
return jdbcTemplate.update(sql,
user.getUserName(), user.getAge(), user.getId());
}
public int deleteById(Long id) {
String sql = "DELETE FROM t_user WHERE id = ?";
return jdbcTemplate.update(sql, id);
}
}
JdbcTemplate.update 的实现核心是:先通过 DataSource 拿连接,创建 PreparedStatement,绑定参数,执行 executeUpdate(),最后在 finally 块里释放资源并把连接归还池子。这里有一个经常被忽略的点:查询操作里 ResultSet 必须在 try-with-resources 中管理,否则连接归还后 ResultSet 可能已经失效,尤其在使用连接池的情况下,连接没有真正关闭,ResultSet 还可能持有过期数据。
5.2 @Param为什么必须存在:编译后参数名只剩arg0
这是整个项目里我最想展开讲的坑。最初我在写 Mapper 代理时打算通过反射拿方法参数的原始名字,比如 User findById(Long id),期望反射出来的参数名是 id。实测发现,Java 编译默认不带 -parameters 参数,method.getParameters()[0].getName() 返回的是 arg0。这个发现直接解释了一个困扰很多人的问题:为什么 MyBatis 这种框架要求在接口方法参数上加 @Param 注解。
如果编译时加上 -parameters 参数,反射确实能拿到真实的参数名,但问题是这个编译选项不是默认行为。你的代码到了别人机器上,或者换了构建工具,很可能就没有这个参数了。所以靠谱的做法是显式声明参数名。@Param("id") 不是可有可无的装饰,而是参数名的契约。
java复制@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
String value();
}
在 Mapper 代理里,解析参数名的逻辑是这样的:先看参数上有没有 @Param 注解,有就用注解的值,没有才走 parameter.isNamePresent() ? parameter.getName() : "arg" + index 的兜底逻辑。我把 @Param 的值作为占位符的 key,在 SQL 解析阶段把 #{id} 替换成 ?,同时记录 id 对应的参数位置,运行时根据参数名映射到具体值。
param注解报错 最常见的原因有三个:第一,SQL 里写了 #{userName},但方法参数上 @Param("userName") 和实际参数不对应;第二,方法有多个参数时只给其中一个加了 @Param,其他参数会被默认命名;第三,在 Mapper 代理拦截到方法调用时,args 数组的顺序和 SQL 占位符的顺序搞反了。这三个问题我在项目里都碰到过,排查的办法是在 SQL 解析出结果后打印一条日志,把方法名、参数名映射、参数值都输出来,一眼就能看出来哪个环节对不上。
5.3 批量插入与批量更新:executeBatch的真相
CRUD 到了批量场景就开始有意思了。批量插入的第一反应是写一个 for 循环反复调用 save(),这在数据量小的时候看不出问题,但数据量到几千条时,每一条都是一次数据库往返,性能很差。批量插入的正确姿势是 PreparedStatement 的 addBatch 加 executeBatch。
java复制public int[] batchInsert(List<User> users) {
String sql = "INSERT INTO t_user(user_name, age) VALUES(?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (User user : users) {
ps.setString(1, user.getUserName());
ps.setInt(2, user.getAge());
ps.addBatch();
}
return ps.executeBatch();
}
}
写到这里看起来完成了,但如果你用的是 MySQL,有一个参数不加等于白干:rewriteBatchedStatements=true。这个参数必须写在 JDBC URL 里,比如 jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true。不加这个参数时,MySQL 驱动收到 executeBatch() 仍然会把 SQL 一条一条发给服务端,性能没有任何提升;加了之后驱动会把多条 INSERT 重写成一条多 VALUES 的 INSERT,减少网络往返。
批量更新的收益没有批量插入那么明显,因为 UPDATE 通常不能像 INSERT 那样直接拼接成多 VALUES。MySQL 支持在 executeBatch 中对 UPDATE 做重写,但条件各异时效果有限。另外一个真实的经验是:批量数据操作一定要控制批次大小,比如每 500 条提交一批,避免单条 SQL 过长。MySQL 的 max_allowed_packet 对单条 SQL 大小有限制,批量太大直接报错。
5.4 queryTimeout与连接参数:JDBC URL不是垃圾桶
热搜词里有一条非常典型:JDBC URL 中添加 querytimeout 参数。见过有人这么写:jdbc:mysql://localhost:3306/db?queryTimeout=5,然后发现完全没生效。这不是你的代码问题,而是把 queryTimeout 放错了位置。
MySQL 连接器认识的是 connectTimeout、socketTimeout 这类网络超时参数,单位是毫秒。queryTimeout 是 JDBC 规范里 Statement.setQueryTimeout(int seconds) 设置的语句执行超时,它压根不是 URL 参数。个别数据库驱动确实支持在 URL 里配置 queryTimeout,但 MySQL 驱动不认识它,会直接忽略或者抛 Unsupported connection property 警告。
在我这个项目里,JdbcTemplate 增加了一个 queryTimeout 字段,创建 PreparedStatement 之后调用 ps.setQueryTimeout(queryTimeout),这样所有 SQL 统一生效。思路是:连接层面的超时靠 URL 参数,语句层面的超时靠 Statement 设置,两者各管各的,不要混用。这个区分在排查线上慢 SQL 时价值极大,能很清楚地判断是等待连接超时、执行超时还是网络传输超时。
6. 编译期那些看起来吓人的提示:增量注解进程问题复盘
6.1 “jps 增量注解进程已禁用”在说什么
项目跑起来之后,某个版本突然在 IDEA 编译输出里看到一行:java: jps 增量注解进程已禁用。部分重新编译的编译结果可能不准确。使用构建进程。第一次看到这个提示的人很容易慌,以为代码有问题。实际上它并不是程序 bug,而是 IntelliJ IDEA 自带的 JPS 增量编译器在处理注解处理器时的一种自我保护机制。
JPS 做增量编译时,只重新编译修改过的类和它们依赖的类。但如果项目里有注解处理器,比如 Lombok、MapStruct,或者某些自定义的 AbstractProcessor,这些处理器可能会根据已有代码生成新类,增量编译的范围没法保证覆盖所有受影响的文件,编译器就不敢用增量模式,干脆禁用并输出提示。IDEA 的中文提示翻译成英文是 JPS incremental annotation processing is disabled 之类的意思,目的就是告诉你:我只能退回到全量编译,编译结果可能比预期慢,但不会错。
关键问题来了:我这个项目里的注解都是 RetentionPolicy.RUNTIME,在运行期才读取,根本不会触发编译期的注解处理。所以如果你的项目里也出现了这行提示,排查方向应该放在是否引入了 Lombok、QueryDSL 这类编译期注解处理器,而不是怀疑自己的运行时注解写错了。
6.2 遇到这类提示的排查链路
针对这个提示,我整理了一套自己的排查链路,分享出来供参考。第一步,看提示出现在哪个模块的编译过程中,如果在引入 Lombok 的工程里出现,直接判断为 JPS 在注解处理器存在时放弃增量编译,不影响最终产物,可以接受。第二步,如果想消除提示,在 IDEA 的 Settings | Build, Execution, Deployment | Compiler | Java Compiler 里给编译参数加上 -proc:none,前提是项目不需要编译期注解处理;但如果你依赖 Lombok,千万别这么干,否则生成的 getter/setter 全没了。第三步,更规范的做法是把注解处理器交给 Maven 或 Gradle 管,在 maven-compiler-plugin 里显式声明 annotationProcessorPaths,让 IDEA 识别到构建系统自己管理注解处理,JPS 就不会再介入。
我在项目里还遇到过一种类似的情况:自己写了一个 AbstractProcessor 用来生成 Mapper 实现类,结果每次改接口后报错说找不到生成类,清理缓存重启才好。根因就是 JPS 的增量编译和注解处理器的协调出了问题。后来我把自定义处理器从运行时 classpath 中拆出来,用独立的 processor 模块管理,问题就消失了。这给自研注解框架提了个醒:编译期注解处理和运行期反射读取是两码事,注解定义尽量保持 RUNTIME 保留策略,能少碰编译期生成就少碰。
7. 进一步提升:事务、Mapper代理和测试策略
7.1 用JDK动态代理实现@Transactional
CRUD 跑通后,下一步很自然就是事务。JDBC 的事务控制本身很简单:拿连接,setAutoCommit(false),执行多条 SQL,成功 commit(),失败 rollback()。麻烦的是把这段逻辑从业务代码里剥离出去,做成声明式事务。
我实现了一个极简 @Transactional 注解,通过 JDK 动态代理包住 Service 层的对象。执行目标方法前,从数据源拿一个连接,开启事务,把连接绑定到当前线程的 ThreadLocal 上,这样 DAO 层拿到的连接和事务管理器用的是同一个连接。方法执行成功就提交,抛异常就回滚。
java复制public class TransactionProxy {
public Object createProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(proxy, method, args) -> {
if (!method.isAnnotationPresent(Transactional.class)) {
return method.invoke(target, args);
}
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
try {
Object result = method.invoke(target, args);
conn.commit();
return result;
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
dataSource.returnConnection(conn);
TransactionContext.clear();
}
});
}
}
这里必须用 ThreadLocal 传递同一个连接,否则 Service 调 Dao 时,Dao 从数据源重新拿一个连接,事务就散了。JDK 动态代理有个前提:目标类必须实现了接口,ServiceImpl 可以,但如果你写了个没有接口的 Service 类,JDK 代理就无能为力了,需要 CGLIB 子类代理。这个限制在自研阶段先知道就好,真的需要无接口类时直接引入 CGLIB 或者 Spring 的 AopProxyFactory。
7.2 让Mapper接口自动生成实现类
DAO 层写多了会发现一个规律:SQL 和实体类结构高度相似,findById、findAll、save 都是模板代码。所以我把 DAO 层进一步抽象,做了一个 Mapper 接口的代理工厂。只定义接口,不写实现类,通过 java.lang.reflect.Proxy 动态生成代理对象,代理里解析方法名和注解拿到 SQL,然后交给 JdbcTemplate 执行。
java复制public class MapperProxyFactory {
public static <T> T create(Class<T> mapperInterface, JdbcTemplate jdbcTemplate) {
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface},
new MapperProxy(mapperInterface, jdbcTemplate));
}
}
这个方向和 MyBatis 的 Mapper 机制已经很像了,但还做得比较粗糙。SQL 的来源我用了两种方式:方法上有自定义 @Select、@Insert 注解就用注解里的 SQL;没有注解就按照"方法名加实体类"的约定生成。如果到这里还想继续深挖,可以给注解的 SQL 属性加上 SpEL 表达式支持,比如 @Select("SELECT * FROM ${tableName}") 里通过注解属性去解析表达式上下文。搜索引擎里那个"spring自定义注解属性如何使用spel表达式"其实就是这类需求。自研的话用 SpelExpressionParser 就能实现,但要特别注意表达式的解析时机和缓存,不然每次调用都解析一遍,性能损耗不小。
