聊聊防御式編程
前言
有些小伙伴在工作中,可能經常遇到這樣的場景:線上系統突然崩潰,排查發現是因為一個預料之外的輸入參數;或者用戶反饋某個功能異常,最終定位到是外部服務返回了異常數據。
這些問題往往不是核心邏輯的錯誤,而是因為我們沒有做好充分的防御。
作為一名老司機,我見證過太多因為缺乏防御意識導致的線上事故。
今天我就從淺入深,帶你徹底掌握防御式編程的精髓,希望對你會有所幫助。
1. 什么是防御式編程?
在深入具體技術之前,我們先明確防御式編程的概念。
防御式編程不是一種具體的技術,而是一種編程哲學和思維方式。
核心理念
防御式編程的核心思想是:程序應該能夠在面對非法輸入、異常環境或其他意外情況時,依然能夠保持穩定運行,或者以可控的方式失敗。
有些小伙伴在工作中可能會說:"我的代碼已經處理了所有正常情況,為什么還需要防御?"
這是因為在復雜的生產環境中,我們無法預知所有可能的情況:
- 用戶可能輸入我們未曾預料的數據
- 外部服務可能返回異常響應
- 網絡可能突然中斷
- 磁盤可能空間不足
- 內存可能耗盡
為什么需要防御式編程?
從架構師的角度看,防御式編程的價值體現在:
- 提高系統穩定性:減少因邊緣情況導致的系統崩潰
- 提升可維護性:清晰的錯誤處理讓問題定位更簡單
- 增強用戶體驗:優雅的降級比直接崩潰更好
- 降低維護成本:預防性代碼減少線上緊急修復
讓我們通過一個簡單的例子來感受防御式編程的差異:
// 非防御式編程
public class UserService {
public void updateUserAge(User user, int newAge) {
user.setAge(newAge); // 如果user為null,這里會拋出NPE
userRepository.save(user);
}
}
// 防御式編程
public class UserService {
public void updateUserAge(User user, int newAge) {
if (user == null) {
log.warn("嘗試更新年齡時用戶對象為空");
return;
}
if (newAge < 0 || newAge > 150) {
log.warn("無效的年齡輸入: {}", newAge);
throw new IllegalArgumentException("年齡必須在0-150之間");
}
user.setAge(newAge);
userRepository.save(user);
}
}
看到區別了嗎?
防御式編程讓我們提前發現問題,而不是等到異常發生。
好了,讓我們開始今天的主菜。
我將從最基本的參數校驗,逐步深入到復雜的系統級防御,確保每個知識點都講透、講懂。
2.防御式編程的核心原則
防御式編程不是隨意地添加if判斷,而是有章可循的。
下面我總結了幾大核心原則。
原則一:對輸入保持懷疑態度
這是防御式編程的第一原則:永遠不要信任任何外部輸入。
無論是用戶輸入、外部API響應、還是配置文件,都應該進行驗證。
示例代碼
public class UserRegistrationService {
// 非防御式寫法
public void registerUser(String username, String email, Integer age) {
User user = new User(username, email, age);
userRepository.save(user);
}
// 防御式寫法
public void registerUserDefensive(String username, String email, Integer age) {
// 1. 檢查必需參數
if (StringUtils.isBlank(username)) {
throw new IllegalArgumentException("用戶名不能為空");
}
if (StringUtils.isBlank(email)) {
throw new IllegalArgumentException("郵箱不能為空");
}
if (age == null) {
throw new IllegalArgumentException("年齡不能為空");
}
// 2. 檢查參數格式
if (username.length() < 3 || username.length() > 20) {
throw new IllegalArgumentException("用戶名長度必須在3-20個字符之間");
}
if (!isValidEmail(email)) {
throw new IllegalArgumentException("郵箱格式不正確");
}
// 3. 檢查業務規則
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年齡必須在0-150之間");
}
if (userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("用戶名已存在");
}
// 4. 執行業務邏輯
User user = new User(username.trim(), email.trim().toLowerCase(), age);
userRepository.save(user);
}
private boolean isValidEmail(String email) {
String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$";
return email != null && email.matches(emailRegex);
}
}
深度剖析
有些小伙伴在工作中可能會覺得這些校驗很繁瑣,但其實它們各自有重要作用:
- 空值檢查:防止NPE,這是Java中最常見的異常
- 格式驗證:確保數據符合預期格式,避免后續處理出錯
- 業務規則驗證:在數據進入核心業務前就發現問題
- 數據清理:去除前后空格、統一格式等
重要原則:校驗要盡早進行,在數據進入系統邊界時就完成驗證。
為了更直觀理解輸入驗證的層次,我畫了一個驗證流程圖:

原則二:善用斷言和異常
斷言和異常是防御式編程的重要工具,但它們的使用場景有所不同。
斷言(Assertions)
斷言用于檢查在代碼正確的情況下永遠不應該發生的條件。
public class Calculator {
public double divide(double dividend, double divisor) {
// 使用斷言檢查內部不變性
assert divisor != 0 : "除數不能為0,這應該在調用前被檢查";
// 但對外部輸入,我們仍然需要正常檢查
if (divisor == 0) {
throw new IllegalArgumentException("除數不能為0");
}
return dividend / divisor;
}
public void processPositiveNumber(int number) {
// 這個斷言表達:這個方法只應該處理正數
// 如果傳入負數,說明調用方有bug
assert number > 0 : "輸入必須為正數: " + number;
// 業務邏輯
}
}
注意:Java斷言默認是關閉的,需要通過-ea參數啟用。在生產環境中,通常不建議依賴斷言。
異常處理
異常應該根據情況分層處理:
public class FileProcessor {
// 不好的異常處理
public void processFile(String filePath) {
try {
String content = Files.readString(Path.of(filePath));
// 處理內容...
} catch (Exception e) { // 捕獲過于寬泛
e.printStackTrace(); // 在生產環境中無效
}
}
// 好的異常處理
public void processFileDefensive(String filePath) {
// 1. 參數校驗
if (StringUtils.isBlank(filePath)) {
throw new IllegalArgumentException("文件路徑不能為空");
}
try {
// 2. 讀取文件
String content = Files.readString(Path.of(filePath));
// 3. 處理內容
processContent(content);
} catch (NoSuchFileException e) {
log.error("文件不存在: {}", filePath, e);
throw new BusinessException("文件不存在: " + filePath, e);
} catch (AccessDeniedException e) {
log.error("沒有文件訪問權限: {}", filePath, e);
throw new BusinessException("沒有文件訪問權限: " + filePath, e);
} catch (IOException e) {
log.error("讀取文件失敗: {}", filePath, e);
throw new BusinessException("讀取文件失敗: " + filePath, e);
}
}
private void processContent(String content) {
if (StringUtils.isBlank(content)) {
log.warn("文件內容為空");
return;
}
try {
// 解析JSON等可能拋出異常的操作
JsonObject json = JsonParser.parseString(content).getAsJsonObject();
// 處理JSON...
} catch (JsonSyntaxException e) {
log.error("文件內容不是有效的JSON", e);
throw new BusinessException("文件格式不正確", e);
}
}
}
深度剖析
有些小伙伴在工作中可能會混淆檢查型異常和非檢查型異常的使用場景:
- 檢查型異常:調用方應該能夠預期并處理的異常(如IOException)
- 非檢查型異常:通常是編程錯誤,調用方不應該捕獲(如IllegalArgumentException)
最佳實踐:
- 使用具體的異常類型,而不是通用的Exception
- 提供有意義的錯誤信息
- 保持異常鏈,不要丟失根因
- 在系統邊界處統一處理異常
原則三:資源管理和清理
資源泄漏是系統不穩定的常見原因。
防御式編程要求我們確保資源被正確釋放。
示例代碼
public class ResourceService {
// 不好的資源管理
public void copyFileUnsafe(String sourcePath, String targetPath) throws IOException {
FileInputStream input = new FileInputStream(sourcePath);
FileOutputStream output = new FileOutputStream(targetPath);
byte[] buffer = new byte[1024];
int length;
while ((length = input.read(buffer)) > 0) {
output.write(buffer, 0, length);
}
// 如果中間拋出異常,資源不會被關閉!
input.close();
output.close();
}
// 傳統的防御式資源管理
public void copyFileTraditional(String sourcePath, String targetPath) throws IOException {
FileInputStream input = null;
FileOutputStream output = null;
try {
input = new FileInputStream(sourcePath);
output = new FileOutputStream(targetPath);
byte[] buffer = new byte[1024];
int length;
while ((length = input.read(buffer)) > 0) {
output.write(buffer, 0, length);
}
} finally {
// 確保資源被關閉
if (input != null) {
try {
input.close();
} catch (IOException e) {
log.error("關閉輸入流失敗", e);
}
}
if (output != null) {
try {
output.close();
} catch (IOException e) {
log.error("關閉輸出流失敗", e);
}
}
}
}
// 使用try-with-resources(推薦)
public void copyFileModern(String sourcePath, String targetPath) throws IOException {
try (FileInputStream input = new FileInputStream(sourcePath);
FileOutputStream output = new FileOutputStream(targetPath)) {
byte[] buffer = new byte[1024];
int length;
while ((length = input.read(buffer)) > 0) {
output.write(buffer, 0, length);
}
} // 資源會自動關閉,即使拋出異常
}
// 復雜資源管理場景
public void processWithMultipleResources() {
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
try {
conn = dataSource.getConnection();
stmt = conn.prepareStatement("SELECT * FROM users WHERE age > ?");
stmt.setInt(1, 18);
rs = stmt.executeQuery();
while (rs.next()) {
// 處理結果
}
} catch (SQLException e) {
log.error("數據庫操作失敗", e);
throw new BusinessException("數據查詢失敗", e);
} finally {
// 按創建順序的逆序關閉資源
closeQuietly(rs);
closeQuietly(stmt);
closeQuietly(conn);
}
}
private void closeQuietly(AutoCloseable closeable) {
if (closeable != null) {
try {
closeable.close();
} catch (Exception e) {
log.warn("關閉資源時發生異常", e);
// 靜默處理,不掩蓋主要異常
}
}
}
}
深度剖析
有些小伙伴在工作中可能沒有意識到,資源管理不當會導致:
- 文件句柄泄漏:最終導致"Too many open files"錯誤
- 數據庫連接泄漏:連接池耗盡,系統無法響應
- 內存泄漏:對象無法被GC回收
防御式資源管理要點:
- 使用try-with-resources(Java 7+)
- 在finally塊中關閉資源
- 關閉資源時處理可能的異常
- 按創建順序的逆序關閉資源
為了理解資源管理的正確流程,我畫了一個資源生命周期圖:

防御式編程的高級技巧
掌握了基本原則后,我們來看看一些高級的防御式編程技巧。
使用Optional避免空指針
Java 8引入的Optional是防御空指針的利器。
public class OptionalExample {
// 不好的做法:多層null檢查
public String getUserEmailBad(User user) {
if (user != null) {
Profile profile = user.getProfile();
if (profile != null) {
Contact contact = profile.getContact();
if (contact != null) {
return contact.getEmail();
}
}
}
return null;
}
// 使用Optional的防御式寫法
public Optional<String> getUserEmailGood(User user) {
return Optional.ofNullable(user)
.map(User::getProfile)
.map(Profile::getContact)
.map(Contact::getEmail)
.filter(email -> !email.trim().isEmpty());
}
// 使用方法
public void processUser(User user) {
Optional<String> emailOpt = getUserEmailGood(user);
// 方式1:如果有值才處理
emailOpt.ifPresent(email -> {
sendNotification(email, "歡迎使用我們的服務");
});
// 方式2:提供默認值
String email = emailOpt.orElse("default@example.com");
// 方式3:如果沒有值,拋出特定異常
String requiredEmail = emailOpt.orElseThrow(() ->
new BusinessException("用戶郵箱不能為空")
);
}
}
不可變對象的防御價值
不可變對象天生具有防御性,因為它們的狀態在創建后就不能被修改。
// 可變對象 - 容易在不知情的情況下被修改
public class MutableConfig {
private Map<String, String> settings = new HashMap<>();
public Map<String, String> getSettings() {
return settings; // 危險!調用方可以修改內部狀態
}
public void setSettings(Map<String, String> settings) {
this.settings = settings; // 危險!外部map的修改會影響內部
}
}
// 不可變對象 - 防御式設計
public final class ImmutableConfig {
private final Map<String, String> settings;
public ImmutableConfig(Map<String, String> settings) {
// 防御性拷貝
this.settings = Collections.unmodifiableMap(new HashMap<>(settings));
}
public Map<String, String> getSettings() {
// 返回不可修改的視圖
return settings;
}
// 沒有setter方法,對象創建后不可變
}
// 使用Builder模式創建復雜不可變對象
public final class User {
private final String username;
private final String email;
private final int age;
private User(String username, String email, int age) {
this.username = username;
this.email = email;
this.age = age;
}
// getters...
public static class Builder {
private String username;
private String email;
private int age;
public Builder username(String username) {
this.username = Objects.requireNonNull(username, "用戶名不能為空");
return this;
}
public Builder email(String email) {
this.email = Objects.requireNonNull(email, "郵箱不能為空");
if (!isValidEmail(email)) {
throw new IllegalArgumentException("郵箱格式不正確");
}
return this;
}
public Builder age(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年齡必須在0-150之間");
}
this.age = age;
return this;
}
public User build() {
// 在build時進行最終驗證
if (username == null || email == null) {
throw new IllegalStateException("用戶名和郵箱必須設置");
}
return new User(username, email, age);
}
}
}
深度剖析
有些小伙伴在工作中可能覺得不可變對象創建麻煩,但它們帶來的好處是巨大的:
- 線程安全:無需同步,可以在多線程間安全共享
- 易于緩存:可以安全地緩存,因為狀態不會改變
- 避免意外的狀態修改:不會被其他代碼意外修改
- 簡化推理:對象的狀態是確定的
系統級的防御式編程
除了代碼層面的防御,我們還需要在系統架構層面考慮防御措施。
斷路器模式
在微服務架構中,斷路器是重要的防御機制。
// 簡化的斷路器實現
public class CircuitBreaker {
private final String name;
private final int failureThreshold;
private final long timeout;
private State state = State.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
enum State { CLOSED, OPEN, HALF_OPEN }
public CircuitBreaker(String name, int failureThreshold, long timeout) {
this.name = name;
this.failureThreshold = failureThreshold;
this.timeout = timeout;
}
public <T> T execute(Supplier<T> supplier) {
if (state == State.OPEN) {
// 檢查是否應該嘗試恢復
if (System.currentTimeMillis() - lastFailureTime > timeout) {
state = State.HALF_OPEN;
log.info("斷路器 {} 進入半開狀態", name);
} else {
throw new CircuitBreakerOpenException("斷路器開啟,拒絕請求");
}
}
try {
T result = supplier.get();
// 請求成功,重置狀態
if (state == State.HALF_OPEN) {
state = State.CLOSED;
failureCount = 0;
log.info("斷路器 {} 恢復關閉狀態", name);
}
return result;
} catch (Exception e) {
handleFailure();
throw e;
}
}
private void handleFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (state == State.HALF_OPEN || failureCount >= failureThreshold) {
state = State.OPEN;
log.warn("斷路器 {} 開啟,失敗次數: {}", name, failureCount);
}
}
}
// 使用示例
public class UserServiceWithCircuitBreaker {
private final CircuitBreaker circuitBreaker;
private final RemoteUserService remoteService;
public UserServiceWithCircuitBreaker() {
this.circuitBreaker = new CircuitBreaker("UserService", 5, 60000);
this.remoteService = new RemoteUserService();
}
public User getUser(String userId) {
return circuitBreaker.execute(() -> remoteService.getUser(userId));
}
}
限流和降級
// 簡單的令牌桶限流
public class RateLimiter {
private final int capacity;
private final int tokensPerSecond;
private double tokens;
private long lastRefillTime;
public RateLimiter(int capacity, int tokensPerSecond) {
this.capacity = capacity;
this.tokensPerSecond = tokensPerSecond;
this.tokens = capacity;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
refill();
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
private void refill() {
long now = System.nanoTime();
double seconds = (now - lastRefillTime) / 1e9;
tokens = Math.min(capacity, tokens + seconds * tokensPerSecond);
lastRefillTime = now;
}
}
// 使用限流和降級
public class OrderService {
private final RateLimiter rateLimiter;
private final PaymentService paymentService;
public OrderService() {
this.rateLimiter = new RateLimiter(100, 10); // 100容量,每秒10個令牌
this.paymentService = new PaymentService();
}
public PaymentResult processPayment(Order order) {
// 限流檢查
if (!rateLimiter.tryAcquire()) {
log.warn("系統繁忙,觸發限流");
return PaymentResult.rateLimited();
}
try {
return paymentService.process(order);
} catch (Exception e) {
log.error("支付服務異常,觸發降級", e);
// 降級策略:返回排隊中狀態
return PaymentResult.queued();
}
}
}
防御式編程的陷阱與平衡
防御式編程不是越多越好,過度防御也會帶來問題。
避免過度防御
// 過度防御的例子
public class OverDefensiveExample {
// 過多的null檢查,使代碼難以閱讀
public String processData(String data) {
if (data == null) {
return null;
}
String trimmed = data.trim();
if (trimmed.isEmpty()) {
return "";
}
// 即使知道trimmed不為空,還是繼續檢查...
if (trimmed.length() > 1000) {
log.warn("數據過長: {}", trimmed.length());
// 但仍然繼續處理...
}
// 更多的檢查...
return trimmed.toUpperCase();
}
}
// 適度防御的例子
public class BalancedDefensiveExample {
public String processData(String data) {
// 在方法入口處統一驗證
if (StringUtils.isBlank(data)) {
return "";
}
String trimmed = data.trim();
// 核心業務邏輯,假設數據現在是有效的
return transformData(trimmed);
}
private String transformData(String data) {
// 內部方法可以假設輸入是有效的
if (data.length() > 1000) {
// 記錄日志但繼續處理
log.info("處理長數據: {}", data.length());
}
return data.toUpperCase();
}
}
性能考量
防御性檢查會帶來性能開銷,需要在關鍵路徑上權衡。
public class PerformanceConsideration {
// 在性能敏感的方法中,可以延遲驗證
public void processBatch(List<String> items) {
// 先快速處理,最后統一驗證
List<String> results = new ArrayList<>();
for (String item : items) {
// 最小化的必要檢查
if (item != null) {
results.add(processItemFast(item));
}
}
// 批量驗證結果
validateResults(results);
}
private String processItemFast(String item) {
// 假設這個方法很快,不做詳細驗證
return item.toUpperCase();
}
}
總結
經過以上詳細剖析,相信你對防御式編程有了更深入的理解。
下面是我總結的一些實用建議:
核心原則
- 對輸入保持懷疑:驗證所有外部輸入
- 明確失敗:讓失敗盡早發生,提供清晰的錯誤信息
- 資源安全:確保資源被正確釋放
- 優雅降級:在部分失敗時保持系統可用
- 適度防御:避免過度防御導致的代碼復雜
實踐指南
| 場景 | 防御措施 | 示例 |
|---|---|---|
| 方法參數 | 入口驗證 | Objects.requireNonNull() |
| 外部調用 | 異常處理 | try-catch特定異常 |
| 資源操作 | 自動清理 | try-with-resources |
| 并發訪問 | 不可變對象 | final字段、防御性拷貝 |
| 系統集成 | 斷路器 | 失敗閾值、超時控制 |
| 高并發 | 限流降級 | 令牌桶、服務降級 |
我的建議
有些小伙伴在工作中,可能一開始覺得防御式編程增加了代碼量,但從長期來看,它的收益是巨大的:
- 投資思維:前期的一點投入,避免后期的大規模故障
- 團隊共識:在團隊中建立防御式編程的文化和規范
- 工具支持:使用靜態分析工具發現潛在問題
- 代碼審查:在CR中重點關注防御性措施
記住,防御式編程的目標不是創建"完美"的代碼。
而是創建健壯的代碼——能夠在面對意外時依然保持穩定的代碼。
最后說一句(求關注,別白嫖我)
如果這篇文章對您有所幫助,或者有所啟發的話,幫忙關注一下我的同名公眾號:蘇三說技術,您的支持是我堅持寫作最大的動力。
求一鍵三連:點贊、轉發、在看。
關注公眾號:【蘇三說技術】,在公眾號中回復:進大廠,可以免費獲取我最近整理的10萬字的面試寶典,好多小伙伴靠這個寶典拿到了多家大廠的offer。
更多項目實戰在我的技術網站:http://www.susan.net.cn/project

浙公網安備 33010602011771號