AI摘要
我记得第一次听说“防御性编程”这个词,是在一本大学教材里。书上说:“要假设所有可能出错的地方都会出错,提前预防。” 当时的我,像得到了圣旨。我开始在每行代码周围筑起高墙——每个方法都进行严格的前置检查,每个返回值都判空,每个异常都捕获处理。
直到我的导师看着我写的代码,皱着眉头说:“你这代码,看着安全,其实像在沼泽里走路——每一步都小心翼翼,但反而更容易陷进去。”
那是我第一次意识到,防御性编程有一个隐秘的边界:过度的防御,本身就成了新的问题。
第一章:从“处处设防”到“有的放矢”
最初的狂热:检查一切,怀疑一切
六年前,我刚加入一个支付相关的项目。我的第一个任务很简单:计算订单金额。需求是“根据商品单价和数量计算总价,应用折扣”。
我写出了这样的代码:
public BigDecimal calculateOrderTotal(List<Item> items, Discount discount) {
// 第一层防御:参数检查
if (items == null) {
throw new IllegalArgumentException("商品列表不能为null");
}
if (discount == null) {
throw new IllegalArgumentException("折扣不能为null");
}
// 第二层防御:集合内容检查
if (items.isEmpty()) {
return BigDecimal.ZERO;
}
BigDecimal total = BigDecimal.ZERO;
for (Item item : items) {
// 第三层防御:集合元素检查
if (item == null) {
continue; // 跳过null项
}
// 第四层防御:元素属性检查
BigDecimal price = item.getPrice();
if (price == null) {
throw new IllegalStateException("商品价格不能为null");
}
Integer quantity = item.getQuantity();
if (quantity == null) {
throw new IllegalStateException("商品数量不能为null");
}
if (quantity <= 0) {
throw new IllegalStateException("商品数量必须大于0");
}
BigDecimal itemTotal = price.multiply(new BigDecimal(quantity));
// 第五层防御:计算结果检查
if (itemTotal.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalStateException("商品总价不能为负数");
}
total = total.add(itemTotal);
}
// 第六层防御:折扣计算检查
BigDecimal discountAmount = discount.calculate(total);
if (discountAmount == null) {
throw new IllegalStateException("折扣计算结果不能为null");
}
if (discountAmount.compareTo(total) > 0) {
throw new IllegalStateException("折扣金额不能超过总金额");
}
BigDecimal finalAmount = total.subtract(discountAmount);
// 第七层防御:最终结果检查
if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
return BigDecimal.ZERO;
}
return finalAmount;
}我自豪地把这段代码提交给了我的导师。他看了很久,然后问了我一个问题:“如果这个方法在项目的100个地方被调用,有1%的几率会传入null的items,那么你抛出IllegalArgumentException的概率是多少?”
我计算了一下:“1%。”
他继续问:“那么,这个异常会在哪里被捕获和处理?”
我愣住了。我根本没想过这个问题。
“更重要的是,”他说,“如果你的方法的调用者遵循了约定——他们从不传入null,那你这些检查就是在为永远不会发生的情况付出代价。代码的复杂度、可读性、维护成本,都在为这1%的可能性买单。”
第一次反思:契约与责任
我的导师给我画了一个简单的图:
调用者(Client) <-- 契约(Contract) --> 方法(Method)“在软件设计中,每个方法都与它的调用者有一个隐式或显式的契约。这个契约定义了:作为调用者,你应该提供什么样的输入;作为方法,我会返回什么样的输出,或者在什么情况下会抛出什么异常。”
“你的代码,”他指着我的那一堆if语句,“把所有的责任都揽在了自己身上。你在说:‘我不信任任何调用者,所以我要检查一切。’但这样做有两个问题。”
“第一,你重复了检查。调用者可能已经检查过了,你又在内部检查一次。第二,你模糊了契约。现在调用者不知道哪些检查是你做的,哪些是他们应该做的。”
他建议我重写这个方法,先明确契约:
/**
* 计算订单总金额(应用折扣后)
*
* 契约:
* 1. 调用者必须确保items非null,且不包含null元素
* 2. 调用者必须确保discount非null
* 3. 每个item必须有非null且有效的price和quantity
* 4. 如果违反契约,将抛出相应的运行时异常
*
* 后置条件:
* 1. 返回值永远是非负数
* 2. 返回值已应用折扣
*/
public BigDecimal calculateOrderTotal(List<Item> items, Discount discount) {
// 注意:这里我们不检查items是否为null,因为契约要求调用者保证
BigDecimal total = BigDecimal.ZERO;
for (Item item : items) {
// 我们可以相信契约:item不为null,price和quantity有效
BigDecimal itemTotal = item.getPrice().multiply(
new BigDecimal(item.getQuantity())
);
total = total.add(itemTotal);
}
BigDecimal finalAmount = total.subtract(discount.calculate(total));
// 确保后置条件:金额非负
return finalAmount.max(BigDecimal.ZERO);
}“但是,”我担心地问,“如果有人不遵守契约怎么办?”
“那就让他们在测试阶段发现问题,”我的导师说,“或者在代码审查时发现问题。我们通过单元测试来验证契约,通过代码审查来教育团队成员遵守契约。不要把运行时的性能消耗和代码复杂度,用来防范应该在开发阶段就解决的问题。”
第二章:防御的四个层次
随着时间的推移,我开始把防御性编程分为四个层次,每个层次有不同的问题和解决方案。
层次一:外部边界防御(必须做)
这是防御性编程最合理的地方——系统的边界。比如:
- REST API的输入验证
- 文件读取前的存在性检查
- 数据库连接失败的重试机制
- 第三方API调用的超时和降级
在这个层次,防御是必须的。因为边界之外,是你无法控制的世界。
// 好的防御:在Controller层验证输入
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
// Spring会自动验证@Valid注解标注的约束
// 如果request不符合约束,请求根本不会进入这个方法
return orderService.createOrder(request);
}
// CreateOrderRequest内部使用注解定义契约
public class CreateOrderRequest {
@NotNull
@Size(min = 1, max = 10)
private List<OrderItemRequest> items;
@NotNull
@Positive
private BigDecimal amount;
// getters and setters
}层次二:内部契约防御(谨慎做)
系统内部,模块与模块、方法与方法之间。这是最需要智慧的地方。
我的经验法则是:越靠近底层的代码,防御应该越严格;越靠近上层的代码,防御可以越宽松。
为什么?因为底层代码被更多地方调用,出错的影响面更大。而上层代码通常有更明确的上下文。
// 底层工具类:严格防御
public class StringUtils {
/**
* 安全的字符串转整数,转换失败返回默认值
* 因为这是通用工具,不知道会被谁调用
*/
public static int safeParseInt(String str, int defaultValue) {
if (str == null || str.trim().isEmpty()) {
return defaultValue;
}
try {
return Integer.parseInt(str.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
}
// 上层业务方法:信任契约
public class OrderService {
private final OrderRepository repository;
/**
* 这里不检查repository是否为null
* 因为它在构造时注入,Spring保证了非null
* 这是框架层面的契约
*/
public Order getOrder(String orderId) {
// 这里也不检查orderId是否为null或空
// 因为调用方是Controller,已经验证过了
return repository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}层次三:数据完整性防御(看情况)
这个层次最微妙。比如,一个对象的方法被调用时,检查对象的状态是否有效。
public class ShoppingCart {
private List<Item> items;
private BigDecimal total;
// 初始版本:过度防御
public void addItem(Item item) {
if (item == null) {
throw new IllegalArgumentException("商品不能为null");
}
if (items == null) {
items = new ArrayList<>(); // 防御惰性初始化
}
if (total == null) {
total = BigDecimal.ZERO; // 防御惰性初始化
}
items.add(item);
total = total.add(item.getPrice());
// 防御性检查:确保不变量
if (total.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalStateException("购物车总价不能为负数");
}
}
// 改进版本:明确不变量,信任内部状态
public void addItem(Item item) {
// 这里只检查外部输入
Objects.requireNonNull(item, "商品不能为null");
// 我们信任构造器和addItem方法本身维护了不变量
// 如果items为null,那是我们的bug,应该在测试中发现
items.add(item);
total = total.add(item.getPrice());
// 断言而不是运行时检查(测试环境开启,生产环境关闭)
assert total.compareTo(BigDecimal.ZERO) >= 0 : "购物车总价不能为负数";
}
}我现在的做法是:
- 使用
Objects.requireNonNull()等工具方法进行基本的输入验证 - 使用断言(assert)来验证内部不变量,这些断言在测试时开启,在生产环境关闭
- 相信自己的代码能够维护对象的状态一致性
层次四:不可能情况防御(避免做)
这是防御性编程的极端——为那些理论上可能,但实际上几乎不可能发生的情况做防御。
// 不必要:为不可能的情况做防御
public String getInitial(String name) {
if (name == null || name.isEmpty()) {
return "";
}
// 防御:理论上String.charAt(0)在字符串非空时不会抛出异常
// 但万一呢?万一JVM有bug呢?
try {
return String.valueOf(name.charAt(0)).toUpperCase();
} catch (StringIndexOutOfBoundsException e) {
// 这行代码永远不会执行(理论上)
log.error("不可能发生的异常", e); // 这行日志永远不会被看到
return "";
}
}
// 更好:清晰表达意图
public String getInitial(String name) {
if (name == null || name.isEmpty()) {
return "";
}
// 这里我们不捕获StringIndexOutOfBoundsException
// 因为如果它发生了,那就是JVM的bug或者内存损坏
// 让异常抛出,让系统崩溃,因为这是不可恢复的错误
return String.valueOf(name.charAt(0)).toUpperCase();
}我曾经为这种“不可能”的异常添加过日志记录。然后我的日志系统被这些永远不会发生的“错误”淹没。更重要的是,它掩盖了真正的问题:如果charAt(0)真的抛出了异常,那一定是内存损坏或JVM的严重问题,应该让应用快速失败,而不是悄悄吞掉异常。
第三章:防御的成本与收益
真正让我重新审视防御性编程的,是一次性能调优经历。我使用了一个性能分析工具,发现在一些热点路径上,超过30%的CPU时间花在了各种防御性检查上。
案例:缓存获取方法
// 初始版本:充分防御
public <T> T getFromCache(String key, Class<T> type) {
// 防御1:参数检查
if (key == null || key.trim().isEmpty()) {
throw new IllegalArgumentException("缓存键不能为空");
}
if (type == null) {
throw new IllegalArgumentException("类型不能为null");
}
// 防御2:缓存本身检查
if (cache == null) {
throw new IllegalStateException("缓存未初始化");
}
// 防御3:获取值并检查
Object value = cache.get(key);
if (value == null) {
return null;
}
// 防御4:类型转换检查
if (!type.isInstance(value)) {
log.warn("缓存类型不匹配,key: {}, 期望: {}, 实际: {}",
key, type, value.getClass());
return null;
}
// 防御5:安全转换
try {
return type.cast(value);
} catch (ClassCastException e) {
log.error("缓存转换异常", e);
return null;
}
}这个方法被高频调用,每次调用都执行了5层防御检查。但实际情况是:
- 调用方是我们的业务代码,几乎不会传入null的key或type
- 缓存在应用启动时就初始化,不会为null
- 类型转换失败的情况极少发生(我们使用类型安全的缓存键)
优化版本:基于统计的防御
public <T> T getFromCache(String key, Class<T> type) {
// 只保留最必要的检查:参数非空
// 因为这是公共方法,被多方调用
Objects.requireNonNull(key, "缓存键不能为null");
Objects.requireNonNull(type, "类型不能为null");
Object value = cache.get(key);
if (value == null) {
return null;
}
// 使用assert而不是运行时检查
// 在生产环境,这个assert会被JVM忽略,没有性能开销
assert cache != null : "缓存未初始化";
assert type.isInstance(value) :
String.format("缓存类型不匹配,key: %s, 期望: %s, 实际: %s",
key, type, value.getClass());
return type.cast(value);
}改变后:
- 移除了不必要的日志记录(日志I/O是昂贵的)
- 将内部状态检查改为assert,生产环境无开销
- 简化了类型转换逻辑
性能提升了约25%,而代码更加清晰了。
第四章:现代语言的帮助
现代编程语言和框架提供了更好的工具来帮助我们平衡防御与简洁。
1. Optional:明确表达“可能没有值”
// 以前:防御性null检查让代码缩进严重
public String getUserEmail(Long userId) {
if (userId == null) {
return null;
}
User user = userRepository.findById(userId);
if (user == null) {
return null;
}
String email = user.getEmail();
if (email == null || email.trim().isEmpty()) {
return null;
}
return email;
}
// 现在:使用Optional,链式调用,清晰表达意图
public Optional<String> getUserEmail(Long userId) {
return Optional.ofNullable(userId)
.flatMap(userRepository::findById)
.map(User::getEmail)
.filter(email -> !email.trim().isEmpty());
}2. 注解验证:将防御移到框架层
// 使用注解声明契约,让框架处理验证
@Service
@Validated // 启用方法参数验证
public class OrderService {
public Order createOrder(
@NotNull @Valid CreateOrderRequest request,
@NotNull @Min(1) Long userId) {
// 这里不需要检查request和userId是否为null
// 框架已经帮我们验证了
// 我们可以专注于业务逻辑
}
}3. 不可变对象:减少状态不一致的可能
// 使用不可变对象,很多防御就不需要了
@Value // Lombok注解,生成不可变类
@Builder
public class OrderItem {
@NotNull
String productId;
@NotNull
@Size(min = 1, max = 100)
String productName;
@NotNull
@Positive
BigDecimal price;
@NotNull
@Min(1)
Integer quantity;
// 构造函数自动进行null检查(如果使用@Builder.Default可以处理默认值)
// 所有字段都是final的,不能在创建后修改
// 这消除了很多运行时状态检查的需要
}第五章:我的防御性编程原则
经过六年的实践,我形成了自己的防御性编程原则:
原则1:契约优于检查
- 先明确方法或类的契约(前置条件、后置条件、不变量)
- 在文档、注解或方法签名中明确表达契约
- 相信调用者会遵守契约,但提供清晰的错误信息当他们违反时
原则2:外部严格,内部宽松
- 系统边界处(API入口、文件/网络I/O)严格防御
- 内部模块间基于明确的接口契约
- 同一模块内的方法可以信任彼此
原则3:让错误尽早暴露
- 使用断言(assert)验证内部不变量,这些断言在测试时开启
- 对于不可恢复的错误(如内存不足),不要尝试防御,让它失败
- 在开发阶段通过测试发现契约违反,而不是在运行时通过防御代码处理
原则4:防御的是“预期可能发生”的问题,不是“理论上可能”的问题
- 为网络超时、文件不存在、用户输入错误做防御
- 不为JVM崩溃、内存损坏、不可能发生的异常做防御
原则5:代码清晰度是最高防御
- 清晰的代码是最好的防御,因为它减少了误解和误用的可能
- 过度防御的代码往往更难理解和维护
- 当你在“添加防御”和“保持代码清晰”之间犹豫时,选择清晰
结语:防御的艺术
我现在看待防御性编程,就像看待生活中的安全措施。合理的防御让人安心,过度的防御让人窒息。
在家门口安装一把好锁是合理的防御——它防范了可能发生的入室盗窃。但在每个房间门口都安装一把锁,甚至给每件家具都上锁,那就是病态了——它让你自己的日常生活变得不便,却未必能提供更多的安全。
写代码也是如此。我们在系统中那些真正脆弱的地方——边界、外部依赖、用户输入——建立坚固的防御。而在系统内部,我们依靠清晰的契约、良好的设计、完善的测试来保证正确性,而不是用检查堆砌起来的代码高墙。
好的防御性代码,不是让你觉得“这代码真安全”,而是让你几乎感觉不到防御的存在——它如此自然、如此恰如其分,以至于你专注于业务逻辑时,根本不会想起那些潜在的失败点,因为它们已经被优雅地处理了。
这就是防御性编程的边界:不是为了写出永远不会出错的代码,而是为了在出错时,能够清晰地知道为什么、在哪里、以及如何恢复。不是为了防范一切可能,而是为了在不降低代码可读性和可维护性的前提下,防范那些值得防范的风险。