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 : "购物车总价不能为负数";
    }
}

我现在的做法是:

  1. 使用Objects.requireNonNull()等工具方法进行基本的输入验证
  2. 使用断言(assert)来验证内部不变量,这些断言在测试时开启,在生产环境关闭
  3. 相信自己的代码能够维护对象的状态一致性

层次四:不可能情况防御(避免做)

这是防御性编程的极端——为那些理论上可能,但实际上几乎不可能发生的情况做防御。

// 不必要:为不可能的情况做防御
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层防御检查。但实际情况是:

  1. 调用方是我们的业务代码,几乎不会传入null的key或type
  2. 缓存在应用启动时就初始化,不会为null
  3. 类型转换失败的情况极少发生(我们使用类型安全的缓存键)

优化版本:基于统计的防御

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);
}

改变后:

  1. 移除了不必要的日志记录(日志I/O是昂贵的)
  2. 将内部状态检查改为assert,生产环境无开销
  3. 简化了类型转换逻辑

性能提升了约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:代码清晰度是最高防御

  • 清晰的代码是最好的防御,因为它减少了误解和误用的可能
  • 过度防御的代码往往更难理解和维护
  • 当你在“添加防御”和“保持代码清晰”之间犹豫时,选择清晰

结语:防御的艺术

我现在看待防御性编程,就像看待生活中的安全措施。合理的防御让人安心,过度的防御让人窒息。

在家门口安装一把好锁是合理的防御——它防范了可能发生的入室盗窃。但在每个房间门口都安装一把锁,甚至给每件家具都上锁,那就是病态了——它让你自己的日常生活变得不便,却未必能提供更多的安全。

写代码也是如此。我们在系统中那些真正脆弱的地方——边界、外部依赖、用户输入——建立坚固的防御。而在系统内部,我们依靠清晰的契约、良好的设计、完善的测试来保证正确性,而不是用检查堆砌起来的代码高墙。

好的防御性代码,不是让你觉得“这代码真安全”,而是让你几乎感觉不到防御的存在——它如此自然、如此恰如其分,以至于你专注于业务逻辑时,根本不会想起那些潜在的失败点,因为它们已经被优雅地处理了。

这就是防御性编程的边界:不是为了写出永远不会出错的代码,而是为了在出错时,能够清晰地知道为什么、在哪里、以及如何恢复。不是为了防范一切可能,而是为了在不降低代码可读性和可维护性的前提下,防范那些值得防范的风险。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:防御性编程的边界:是写出健壮代码,还是制造不必要的复杂?
▶ 本文链接:https://www.huangleicole.com/experience_summary/90.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏