开发者社区 > 博文 > 防御性编程:让系统坚不可摧
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

防御性编程:让系统坚不可摧

  • liuhaozzu
  • 2024-07-17
  • IP归属:北京
  • 100浏览

    1. 引言

    面对复杂多变的运行环境、不可预测的用户输入以及潜在的编程错误,如何确保软件在遭遇异常情况时依然能够稳定运行,是每位开发者必须面对的挑战。防御性编程(Defensive Programming)正是为解决这一问题而生的一种编程范式,它强调在编程过程中预见并防范潜在的错误和异常情况,从而增强软件的健壮性和稳定性。作为一种细致、谨慎的编程方法,通过提前考虑并防范可能出现的错误,从而有效减少软件漏洞和故障。本文将详细介绍防御性编程的基本概念、关键策略,并通过实际案例展示其在实际项目中的应用。

    2. 防御性编程的基本概念

    防御性编程的核心思想在于承认程序总会存在问题和需要修改,因此聪明的程序员会提前考虑并防范可能的错误。它强调在编程过程中不仅要实现功能,还要确保程序在面对错误输入、异常情况和并发操作时能够稳定运行

    3. 防御性编程的核心原则

    3.1 风险识别

    非系统性风险:只影特定场景下的响单次调用,不对系统整体稳定性产生影响。比如空指针异常、数据越界等。

    系统性风险:导致整个服务不可用的风险。比如 死循环,分页查询pageSize过大等。

    3.2 防御原则

    1. 假设输入总是错误的:不依赖外部输入的绝对正确性,对所有输入进行验证和清理。
    2. 最小化错误的影响范围:通过异常处理、错误隔离等措施,限制错误对系统整体的影响。
    3. 使用断言进行内部检查:在代码的关键位置加入断言,确保程序状态符合预期。
    4. 代码清晰易懂:编写易于理解和维护的代码,便于团队成员发现潜在问题。
    5. 持续测试:通过单元测试、集成测试等手段,不断验证软件的正确性和稳定性。

    4. 防御性编程案例

    4.1 输入验证与清理

    场景

    用户输入数据到Web表单中,系统需要处理这些数据以进行后续操作。

    防御性编程实践

    风险识别:系统性风险,可能导致系统整体不可用。

    防御策略

    • 验证数据类型:确保用户输入的数据类型符合预期(如数字、字符串、日期等)。如果类型不匹配,应给出错误提示并要求用户重新输入。
    • 长度和范围检查:对于字符串、数字等类型的数据,进行长度和范围检查,确保它们不超过系统处理能力的限制。
    • 清理输入数据:去除输入数据中的非法字符或格式,如去除字符串两端的空格、将特殊字符转换为普通字符等。

    分页参数防御式编程案例

    下面以分页参数防御式编程为案例进行举例说明:

    场景描述
    假设开发一个Web API,该API需要根据用户请求返回特定数据的分页结果。分页请求包含以下参数:

    • pageSize:每页应显示的记录数。
    • pageNumber:用户请求的当前页码。

    防御性编程措施

    1. 验证pageSize:确保pageSize是一个正整数,并且不超过一个合理的最大值(例如100),以防止资源过度消耗。
    2. 验证pageNumber:确保pageNumber是一个正整数,并且不会请求到不存在的页码(即基于总记录数和pageSize计算出的最大页码之后)。
    3. 处理无效参数:如果参数无效,则返回清晰的错误消息,并可能设置一个默认的页码或每页记录数。
    4. 计算总页数:基于总记录数和pageSize计算总页数,以便在返回分页信息时包含给用户。

    示例代码(伪代码):

    public class PaginationService {        
        private static final int MAX_PAGE_SIZE = 100;        
            /**       
             * 获取分页信息并进行参数校验       
             *        
             * @param totalRecords 总记录数       
             * @param pageSize 每页记录数       
             * @param pageNumber 当前页码       
             * @return 分页信息,包括总页数、当前页码等       
             */      
             public PaginationInfo getPaginationInfo(int totalRecords, int pageSize, int pageNumber) {          
                 // 校验pageSize          
                 if (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) {              
                     throw new IllegalArgumentException("pageSize必须为正整数且不超过" + MAX_PAGE_SIZE);          
                 }            
                 // 校验pageNumber          
                 if (pageNumber <= 0) {              
                     pageNumber = 1; // 默认为第一页          
                 }            
                 // 计算总页数          
                 int totalPages = (totalRecords + pageSize - 1) / pageSize;            
                 // 确保pageNumber不超过总页数          
                 if (pageNumber > totalPages) {              
                     pageNumber = totalPages;          
                 }            
                 // 计算当前页的数据起始索引(可选,根据具体需求)          
                 int startIndex = (pageNumber - 1) * pageSize;            
                 // 返回分页信息          
                 return new PaginationInfo(totalPages, pageNumber, startIndex);      
             }        
             // PaginationInfo 是一个简单的类,用于封装分页信息 
             ...

    在这个例子中,getPaginationInfo 方法首先验证了 pageSizepageNumber 参数的有效性,确保了它们符合预期的约束条件。如果参数无效,方法会抛出一个 IllegalArgumentException 异常,这有助于调用者识别并处理错误情况。然后,方法计算了总页数,并根据需要调整了 pageNumber 以确保它不会超出范围。最后,方法返回了一个包含分页信息的 PaginationInfo 对象。

    这种防御性编程策略有助于防止因无效的分页参数而导致的程序错误,提高了API的健壮性和用户体验。

    4.2 预防死循环

    场景

    在循环或者遍历场景中,没有明确的退出机制。

    防御性编程实践

    风险识别:系统性风险,可能导致系统整体不可用。

    防御策略

    • 参数验证:检查涉及循环步长的入参是否有效。
    • 循环终止条件必达性确认:在涉及条件校验的场景中,避免等值条件判断,防止跳过循环终止点。
    • 日志记录:在关键位置添加日志记录,帮助调试和追踪问题。

    示例代码(Java):

    /**  
     * 生成时间段。  
     *  
     * @param startMinutes 开始时间
     * @param endMinutes 结束时间
     * @param interval 时间段间隔
     * @param duration 时间的时长
     * @return 时间段列表 
     */  
    public List<String> generateList(int startMinutes, int endMinutes, int interval, int duration) {
    
        List<String> result = new ArrayList<>();
        int nextStartTime = startMinutes;
    
        while (nextStartTime == endMinutes) {
            int currentStartMinutes = nextStartTime;
      
            int currentEndMinutes = currentStartMinutes + duration;
    
            result.add(currentStartMinutes + "-" + currentEndMinutes);
      
            nextBatchStartTime += interval;
        }
        return result;
    }

    针对以上代码,我们可以添加一些防御式编程的元素来确保代码的健壮性和可靠性。防御式编程侧重于预防错误的发生,包括输入验证、错误处理和边界条件检查。以下是修改后的代码,包含了防御式编程的改进:

    /** 
     * 生成时间段。 
     * 
     * @param startMinutes 开始时间
     * @param endMinutes 结束时间
     * @param interval 时间段间隔
     * @param duration 时间的时长
     * @return 时间段列表 
     */ 
    public List<String> generateList(int startMinutes, int endMinutes, int interval, int duration) {
        // 改进点1:校验 interval,以保证循环中的步长能够正向增长
        // 一般情况下,还需要对步长,和 endMinutes与startMinutes的区间大小做限制,避免生成“巨大”的列表。
        if (interval <= 0) {
            throw new IllegalArgumentException("Invalid parameters: interval must be positive integers.");
        }
        List<String> result = new ArrayList<>();
        int nextStartTime = startMinutes;
    
        //改进点2:避免使用等号做循环终止条件,以防跳过循环终止点。
        while (nextStartTime <= endMinutes) {
            int currentStartMinutes = nextStartTime;
      
            int currentEndMinutes = currentStartMinutes + duration;
    
            result.add(currentStartMinutes + "-" + currentEndMinutes);
      
            nextBatchStartTime += interval;
        }
        return result;
    }

    4.3 异常处理

    场景

    程序在读取文件、进行网络请求或执行其他可能失败的操作时,需要处理潜在的异常。

    防御性编程实践

    风险识别:非系统性风险。

    防御策略

    使用try-except语句:将可能抛出异常的代码块放在try语句中,并在except语句中捕获并处理这些异常。

    区分异常类型:根据实际需要捕获特定的异常类型,或捕获所有异常(使用Exception作为异常类型)。

    记录错误信息:在捕获异常后,记录详细的错误信息(如异常类型、错误消息、堆栈跟踪等),以便后续分析和调试。

    示例代码(Java):

    /**  
     * 读取文件内容。  
     *  
     * @param filePath 文件路径  
     * @return 文件内容,如果文件不存在或读取失败则返回null  
     */  
    public static String readFile(String filePath) {  
        try {  
            byte[] encoded = Files.readAllBytes(Paths.get(filePath));  
            return new String(encoded);  
        } catch (FileNotFoundException e) {  
            log.info("文件未找到:" + filePath);  
            return null;  
        } catch (Exception e) {  
            log.info("读取文件时发生错误:" + e.getMessage());  
            return null;  
        }  
    }  

    4.4 边界条件检查

    场景

    在循环、条件判断或数组访问等操作中,需要确保不会超出预期的范围或边界。

    防御性编程实践

    风险识别:系统性风险,可能导致系统整体不可用。

    防御策略

    • 检查循环条件:确保循环条件在每次迭代后都能正确更新,以避免无限循环。
    • 数组和集合访问:在访问数组、列表、字典等集合的元素之前,检查索引或键是否有效。
    • 边界值测试:对函数或方法的输入进行边界值测试,以确保它们在边界条件下也能正常工作。

    示例代码(Java):

    public class ArrayAccess {      
        public static void main(String[] args) {          
            int[] numbers = {1, 2, 3, 4, 5};          
            int index = getIndexFromUser(); // 假设这是从用户那里获取的索引                    
            if (index >= 0 && index < numbers.length) {              
                log.info(numbers[index]);          
            } else {              
                log.info("索引超出数组范围");          
            }      
        }                 
        
        // 假设这个方法从用户那里获取索引值,并进行基本的验证 
        private static int getIndexFromUser() {       
            // 为了示例,我们直接返回一个示例值          
            return 2; // 假设用户输入了有效的索引值2      
        }  
    }

    4.5 使用断言进行内部检查

    场景

    在代码的关键路径上,需要确保某些条件始终为真,否则程序将无法正确执行。

    防御性编程实践

    • 使用断言:在代码的关键位置添加断言(如Python的assert语句),以验证程序状态是否符合预期。如果断言失败,则抛出AssertionError异常。
    • 注意断言的使用场景:断言主要用于开发和测试阶段,用于捕获那些理论上不应该发生的错误。在生产环境中,应该依赖更健壮的错误处理机制。

    示例代码(Java):

    /**  
     * 计算年龄。  
     *  
     * @param birthYear 出生年份  
     * @return 年龄,如果输入无效则返回-1。  
     */  
    public static int calculateAge(int birthYear) {  
        // 输入验证:确保出生年份是一个合理的值  
        if (birthYear <= 0 || birthYear > java.time.Year.now().getValue()) {  
            // 抛出IllegalArgumentException来指示方法接收到了非法参数  
            throw new IllegalArgumentException("出生年份必须是一个大于0且小于当前年份的整数");  
        }  
      
        // 计算年龄  
        int currentYear = java.time.Year.now().getValue();  
        return currentYear - birthYear;  
    }  
      
    public static void main(String[] args) {  
        try {  
            // 假设我们从某个地方(如用户输入)获取了出生年份  
            int birthYear = 1990; // 这里直接赋值作为示例  
      
            int age = calculateAge(birthYear);  
                if (age != -1) { // 注意:这个例子中calculateAge实际上不会返回-1,但为了展示如何处理可能的异常情况,我们可以这样设计  
                    log.info("年龄是:" + age);  
                }  
      
            } catch (IllegalArgumentException e) {  
                // 捕获并处理IllegalArgumentException  
                log.info("错误:" + e.getMessage());  
            }  
      
            // 如果需要从用户输入中获取出生年份,你可以添加相应的逻辑来处理字符串到整数的转换和验证  
        }  
      
        // 注意:在这个例子中,我们没有直接使用assert,因为Java的assert主要用于调试,且默认是禁用的。  
        // 而是通过显式的条件检查和异常抛出来实现防御性编程。  

    5. 防御式编程的挑战

    5.1 是不是防御式代码越多越好呢?

    No,过度的防御式编程会使程序会变得臃肿而缓慢,增加软件的复杂度。

    要考虑好什么地方需要进行防御,然后因地制宜地调整进行防御式编程的优先级。

    一般在入口处或者接入层做通用性防御性编程,比如数据准入校验;但对于循环类逻辑,应始终在使用处做细节性防御

    5.2 通用性防御措施 优于 细节性的防御

    例如对于网络请求,一般是统一处理超时、鉴权、各种错误code,而不是在业务层个别处理

    5.3 根据使用场景,调整防御力度

    如项目内部使用的utils函数和公开发布的package,后者防御要求更高

    6. 结论

    防御性编程是一种积极主动的编程策略,它要求开发者在编写代码时,不仅要关注功能的实现,更要关注代码的健壮性和稳定性。通过预见并防范潜在的错误和异常情况,防御性编程能够显著提升软件的质量,减少因外部因素导致的程序崩溃,提升系统稳定性。

    文章中难免会有不足之处,希望读者能给予宝贵的意见和建议。谢谢!