被 @SneakyThrows 坑过之后,我终于搞懂了它的底层原理
之前在项目里写代码的时候,@SneakyThrows 用得挺随意的,Service 层标一个,Controller 里也标一个,觉得挺方便。后来有一次线上出了问题,排查了半天发现跟这个注解有关系,才决定认真研究一下。
这一研究,发现这个东西确实有说道。
先说说它解决了什么问题
写 Java 的应该都碰到过这种情况:
java
List<String> files = getFilePaths();
files.forEach(file -> {
// 这里编译报错,FileReader 要抛 IOException
// 但 forEach 里的 lambda 不允许抛受检异常
BufferedReader reader = new BufferedReader(new FileReader(file));
});
没办法,只能这么写:
java
files.forEach(file -> {
try {
BufferedReader reader = new BufferedReader(new FileReader(file));
// 业务逻辑
} catch (IOException e) {
throw new RuntimeException(e);
}
});
代码量直接翻倍,而且那个 try-catch 里其实没什么有意义的异常处理逻辑,纯粹是为了过编译器那一关。
然后 Lombok 告诉你:加个 @SneakyThrows 就行了。
java
@SneakyThrows
public String readLine(String path) {
return new BufferedReader(new FileReader(path)).readLine();
}
干净利落,编译器也不报错了。看起来挺美好。
它到底干了什么
我当时第一反应是,这注解是把异常给吞了吧?仔细一看源码,还真不是。
Lombok 处理完之后,生成的代码大概长这样:
java
public String readLine(String path) {
try {
return new BufferedReader(new FileReader(path)).readLine();
} catch (Throwable t) {
throw Lombok.sneakyThrow(t);
}
}
关键在 sneakyThrow 这个方法。点进去看:
java
public static RuntimeException sneakyThrow(Throwable t) {
return Lombok.<RuntimeException>uncheckedThrow(t);
}
private static <T extends Throwable> RuntimeException uncheckedThrow(Throwable t) throws T {
throw (T) t;
}
看到这行 throw (T) t 的时候,我愣了几秒。
这是个泛型的花活儿。编译的时候,编译器看到 (T) t,T 被推断成 RuntimeException,所以编译器觉得"哦,抛的是非受检异常,没问题,放行"。但到了运行时,泛型擦除,T 变成了 Throwable,实际上抛出来的还是原来的那个 IOException。
说白了就是钻了泛型擦除的空子,编译期和运行时的类型检查对不上,让受检异常偷偷溜过去了。
这个技巧其实早就有了,不是什么新东西。Scala 早就这么干了,Lombok 把它包装成了一个注解。
我为什么反对在业务代码里乱用
搞清楚了原理之后,再回头看自己之前的写法,问题就很明显了。
我当时在 Service 层这么写:
java
@SneakyThrows
public Order processOrder(OrderRequest request) {
// 里面可能抛 IOException、SQLException 什么的
saveOrderToFile(request);
return orderRepository.save(request);
}
调用方一看方法签名,没有任何 throws 声明,以为这方法不会抛出什么受检异常,于是就没写 try-catch。
结果线上出了 IO 异常,请求直接 500,调用方完全没有兜底逻辑。
这就是 @SneakyThrows 最大的问题:它让异常变得不可见了。
Java 设计受检异常,就是想让调用方知道"这个方法可能会出错,你得处理"。你用 @SneakyThrows 把这个信号给抹掉了,等于是在给后面维护代码的人挖坑。
而且你还没法正常 catch 它:
java
try {
service.processOrder(request);
} catch (IOException e) {
// 编译报错:这里不会抛出 IOException
// 编译器被骗了,但运行时确实会抛
}
编译器认为 processOrder 不会抛 IOException,所以你写 catch (IOException e) 直接编译不通过。你说气不气人。
那什么时候能用
也不是说完全不能用。有几种情况我觉得是合理的:
Lambda 和函数式接口里,这基本是它的主场:
java
executor.submit(() -> {
// 这里没法声明 throws,写 try-catch 又很啰嗦
doSomethingThatThrows();
});
Stream 操作里面,也是类似的情况:
java
list.stream()
.map(this::parseConfig)
.collect(Collectors.toList());
@SneakyThrows
private Config parseConfig(String path) {
return mapper.readValue(new File(path), Config.class);
}
这些地方你确实没什么好的选择,用 @SneakyThrows 比写一堆 try-catch 包装强。
但有一点要注意:即使用在这些地方,方法上最好加个注释说明可能抛什么异常,别让后面看代码的人猜。
java
/**
* 注意:可能抛出 FileNotFoundException
*/
@SneakyThrows
private Config parseConfig(String path) { ... }
跟事务配合还有个坑
java
@Transactional(rollbackFor = IOException.class)
@SneakyThrows
public void process() {
throw new IOException("失败了");
}
这个事务能正常回滚吗?能。因为 @SneakyThrows 只是骗了编译器,实际抛出来的还是 IOException,Spring 事务拦截器按实际异常类型判断,没问题。
但问题是,读代码的人看到 process 方法,没有任何迹象表明它会抛 IOException。万一哪天有人改了事务配置,把 rollbackFor 去掉了,出了 bug 都找不到原因。
我后来的使用原则
踩过坑之后,给自己定了几个规矩:
- Lambda、Stream、Runnable 这些没法声明 throws 的地方,可以用
- Controller、Service、接口定义,不许用
- 用了的话,必须加注释说明可能抛什么异常
其实说到底,@SneakyThrows 不是什么洪水猛兽,但你得知道它在干什么。它不是消灭了异常,只是把异常藏起来了。藏起来的东西,迟早会在你没想到的地方蹦出来。
写代码也是这样,编译器的报错虽然烦,但它是在保护你。想办法绕过它,往往意味着你将来要付出更大的代价去处理那些被绕过的东西。
基于 Lombok 1.18.x,JDK 8+