防御性编程和进攻性编程

文章目录

在软件开发中,防御性编程和进攻性编程是确保代码质量和安全性的两种重要策略。本文将探讨这两种编程方法,并通过对应的代码示例来演示它们的应用。

参考文章:

防御性编程(Defensive Programming)

防御性编程是一种预防性的编程方法,旨在确保软件在面对错误输入或意外情况时仍能正常运行。这种方法强调代码的健壮性和错误处理能力。

关键原则包括:

  • 输入验证:始终验证外部输入,确保它们符合预期的格式和范围。
  • 错误处理:使用异常处理机制来捕获和处理可能发生的错误。
  • 代码审计:编写清晰、可读的代码,并进行代码审计以检测潜在的安全漏洞。

Golang 示例:

package main

import (
	"errors"
	"fmt"
)

// 定义一个函数,用于处理用户输入
func ProcessInput(input string) (string, error) {
	if len(input) == 0 {
		return "", errors.New("input cannot be empty")
	}
	// 假设我们期望的输入长度不超过1000个字符
	if len(input) > 1000 {
		return "", errors.New("input is too long")
	}
	// 处理输入...
	return input, nil
}

func main() {
	input := "用户输入的数据"
	output, err := ProcessInput(input)
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Processed input:", output)
	}
}

在上述代码中,ProcessInput 函数首先检查输入是否为空或过长,如果是,则返回错误。这是防御性编程的一个典型例子,通过检查和验证输入来防止错误。

关于大家日常调侃的“防御性编程”我们称它为忍者代码,关于忍者代码在 现代 JavaScript 教程 中讲的即讽刺幽默又真实全面,这些难懂的代码风格都是从真实的代码中提炼而来的,有的甚至是经验丰富的大神的代码,大家可以前往学习:《忍者代码指南:“防御性编程技巧”保卫你的职业生涯》

进攻性编程(Offensive Programming)

进攻性编程是防御性编程的补充,它的核心思想是“信任但验证”。这种方法假设内部代码是可靠的,但仍会进行必要的检查以防止潜在的错误。

关键原则包括:

  • 断言:使用断言来验证代码的关键部分,确保它们的正确性。
  • 信任内部数据:假设内部数据是可信的,但对外部来源的数据进行严格的验证。
  • 错误传播:当错误发生时,允许它们在调用堆栈中传播,直到被适当的处理程序捕获。

Golang 示例:

package main

import (
	"fmt"
)

// 定义一个枚举类型,表示交通信号灯的颜色
type TrafficLightColor int

const (
	TRAFFICLIGHT_RED TrafficLightColor = iota
	TRAFFICLIGHT_YELLOW
	TRAFFICLIGHT_GREEN
)

// 使用断言来确保只处理预期的枚举值
func TrafficLightColorName(c TrafficLightColor) string {
	switch c {
	case TRAFFICLIGHT_RED:
		return "red"
	case TRAFFICLIGHT_YELLOW:
		return "yellow"
	case TRAFFICLIGHT_GREEN:
		return "green"
	default:
		panic("invalid traffic light color")
	}
}

func main() {
	color := TRAFFICLIGHT_RED
	fmt.Println("The traffic light color is:", TrafficLightColorName(color))
}

在这个例子中,TrafficLightColorName 函数使用了panic来处理不期望的枚举值,这是进攻性编程的一个实例,它允许程序在遇到不可恢复的错误时失败。

防御性编程的进阶实践

防御式编程的艺术,不仅仅是编程技巧的展示,更是对代码质量和同事(包括未来的你)的尊重。

避免空值

在许多语言中,没有值的变量默认为null(或nilNone或其他一些变体)。空指针异常是一种常见的情况。

# 在方法的开头进行空值检查
def process_data(data):
    if data is None:
        raise ValueError("No data provided!")
    # ... 处理数据

记得在方法的开头进行空值检查。在条件允许的情况下,可以使用NotNull注解和编程语言中类似的特性。在前面校验变量是否为空意味着后面的代码可以安全地假定它是在处理真实的值,这将使你的代码更干净、更易读。

保持变量不可变

编译器或运行环境知道变量不会改变时就可以运转得更有效率。所以,在 JavaScript 中,优先使用constlet次之,var最后。

// 使用const声明不可变变量
const MAX_SIZE = 100;

// 只在提前知道未来会有修改时,再使用let
let currentSize = MAX_SIZE;

const 声明可以让那个浏览器运行时强制保持变量不变,也可以静态代码分析工具提前发现不合法的赋值操作。

使用类型提示和静态类型检查器

动态语言如 Python(从 Python 3.5 开始)、通过 Sorbet 校验的 Ruby(计划成为 Ruby 3 的一部分)和 JavaScript(通过 TypeScript)现在都对类型提示(类型声明和类型注解)和静态类型检查器有越来越强大的支持。类型提示会在动态定义的类型中让你明确指定一种变量的类型。

// TypeScript中使用类型注解
function greet(name: string): string {
  return `Hello, ${name}!`;
}

简单说,就是要用强类型语言或者类强类型语言。

验证输入

使用前置条件和后置条件的方式来校验方法中输入的变量。尽可能地提早拒绝不良输入。

// 确保参数大于0
function calculateArea(radius) {
  if (radius <= 0) {
    throw new Error("Radius must be greater than zero.");
  }
  return Math.PI * radius * radius;
}

如果一个参数应该大于 0,那就要确保它大于 0;如果一个参数是 IP 地址,那就要检查它是否是一个有效的 IP 地址。

善用异常

异常处理是编程中的一项重要技能,它可以帮助我们优雅地处理程序运行中的意外情况。但是,关键在于要正确地使用它们。我们不应该使用异常来控制程序的正常流程,而应该保留它们来处理真正的异常情况。

在 JavaScript 中,我们通常会遇到这样的情况:

function connectToServer(url) {
  if (!url) {
    throw new Error("URL cannot be empty!");
  }
  // 尝试连接到服务器
  // ...
}

在这个例子中,如果url参数为空,我们抛出一个错误,这是一个异常情况的合理使用。我们不应该返回特殊值(如nullundefined)来表示错误,因为这会使错误处理变得模糊不清。

早抛晚捕

早抛指的是在问题发生的地方尽快抛出异常。这样做的好处是,它可以提供更多的上下文信息,帮助我们快速定位问题。例如,在一个函数中,如果传入的参数不符合预期,我们应该立即抛出异常:

function calculateSquareRoot(number) {
  if (number < 0) {
    throw new Error("Cannot calculate square root of a negative number.");
  }
  return Math.sqrt(number);
}

在这个函数中,如果尝试计算负数的平方根,我们立即抛出一个错误,因为这是一个不合理的操作。

晚捕则是指在程序的更高层次捕获异常。这允许我们在一个集中的位置处理错误,而不是在每个可能出错的地方都写错误处理代码。在 Node.js 中,我们经常在回调函数中看到这种模式:

try {
  const result = calculateSquareRoot(-1);
} catch (error) {
  console.error("An error occurred:", error.message);
}

在这个例子中,我们在一个更高的层次捕获了异常,并打印了一个错误消息。这样,我们就可以在一个地方处理所有的错误,而不是在每个函数中都进行处理。

智能重试

在编程中,我们经常需要处理网络请求或者其他可能失败的操作。在这些情况下,简单地重试可能会导致更多的问题,比如服务器压力过大。这时候,我们就需要一个智能的重试策略,也就是所谓的“退避”(backoff)策略。

// exponentialBackoff 会根据重试次数计算退避时间,并在重试前等待
func exponentialBackoff(retryCount int) {
    backoff := time.Duration(math.Pow(2, float64(retryCount))) * time.Second
    fmt.Printf("Waiting %v before retrying...\n", backoff)
    time.Sleep(backoff)
}

func main() {
    maxRetries := 5
    for i := 0; i < maxRetries; i++ {
        // 这里是你的重试逻辑
        // ...

        // 如果需要重试,调用退避策略
        exponentialBackoff(i)
    }
}

在这个例子中,我们使用了指数退避策略,每次重试的等待时间都会增加。这样可以减少对服务器的压力,并且在遇到暂时性问题时,给予系统恢复的时间。

构建幂等系统

幂等性是指无论一个操作执行多少次,都会产生相同的结果。在网络编程中,这是一个非常重要的概念,特别是在处理重试逻辑时。

# 一个简单的幂等操作示例
def process_payment(payment_id, amount):
    if is_payment_processed(payment_id):
        return "Payment already processed."
    # 处理支付逻辑
    # ...
    mark_payment_as_processed(payment_id)
    return "Payment processed."

在这个 Python 函数中,我们首先检查支付是否已经被处理过了。如果是,我们就直接返回,不再进行任何操作。这样即使这个函数被多次调用,也只会处理一次支付,保证了操作的幂等性。

及时释放资源

当故障发生后,要确保清理所有的资源,释放你不再需要的内存、数据结构、网络套接字和文件句柄。

# 使用with语句自动管理资源
with open('file.txt', 'r') as f:
    # ...

f.close()之前发生的任何故障将阻止关闭文件指针。with语句会在调用路径离开代码块时自动关闭句柄。

结语

优秀的代码不仅要能工作,还要能抵御未来的挑战。

祝你编码愉快,加油!🚀


也可以看看


全国大流量卡免费领

19元月租ㆍ超值优惠ㆍ长期套餐ㆍ免费包邮ㆍ官方正品