Chuyển tới nội dung chính

Exception Handling

Định nghĩa (Definition)

Xử lý ngoại lệ (Exception Handling) là cơ chế phát hiện, phản hồi, và phục hồi từ lỗi thời điểm chạy một cách khéo léo. C# sử dụng cách tiếp cận có cấu trúc với các khối try, catch, finally, và throw được xây dựng trên hệ thống phân cấp ngoại lệ .NET gốc tại System.Exception.

Khái niệm cốt lõi (Core Concepts)

Khối try / catch / finally (try / catch / finally Blocks)

try
{
var result = ParseInput(input);
SaveToDatabase(result);
}
catch (FormatException ex)
{
Console.WriteLine($"Invalid format: {ex.Message}");
}
catch (DbUpdateException ex)
{
Console.WriteLine($"Database error: {ex.Message}");
}
finally
{
CloseConnection(); // Always executes
}

Bắt Ngoại lệ Cụ thể (Catching Specific Exceptions)

Các khối catch được đánh giá theo thứ tự. Luôn bắt kiểu ngoại lệ cụ thể nhất trước:

try
{
File.ReadAllText(path);
}
catch (FileNotFoundException ex)
{
// Most specific first
HandleMissingFile(path);
}
catch (UnauthorizedAccessException ex)
{
HandlePermissionError(ex);
}
catch (IOException ex)
{
// More general — catches other IO errors
HandleIoError(ex);
}
// Do NOT add catch (Exception) here unless you truly need it

throw và Re-throw (throw and Re-throw)

throw vs throw ex

Sử dụng throw; để ném lại ngoại lệ trong khi bảo toàn dấu vết ngăn xếp gốc (original stack trace). Sử dụng throw ex; sẽ đặt lại dấu vết ngăn xếp, khiến việc gỡ lỗi khó hơn rất nhiều.

catch (SqlException ex)
{
_logger.LogError(ex, "Database operation failed");
throw; // Preserves stack trace
}

// Anti-pattern — NEVER do this:
catch (SqlException ex)
{
_logger.LogError(ex, "Database operation failed");
throw ex; // Destroys stack trace!
}

Khối finally (finally Block)

Khối finally luôn thực thi bất kể ngoại lệ có được ném hay bắt hay không. Sử dụng cho việc dọn dẹp:

FileStream? stream = null;
try
{
stream = File.OpenRead(path);
ProcessStream(stream);
}
finally
{
stream?.Dispose(); // Guaranteed cleanup
}
Ưu tiên khai báo using (Prefer using declarations)

Trong C# hiện đại, khai báo using xử lý việc giải phóng tự động và được ưu tiên hơn try/finally cho việc dọn dẹp tài nguyên:

using var stream = File.OpenRead(path);
ProcessStream(stream); // Dispose called automatically at scope end

Các Kiểu Ngoại lệ Phổ biến (Common Exception Types)

Kiểu Ngoại lệ (Exception Type)Khi nào Xảy ra (When It Occurs)
NullReferenceExceptionGiải tham chiếu tham chiếu null (Dereferencing a null reference)
ArgumentNullExceptionTruyền null cho tham số không nullable (Passing null to a non-nullable parameter)
ArgumentExceptionGiá trị đối số không hợp lệ (Invalid argument value)
ArgumentOutOfRangeExceptionĐối số ngoài phạm vi cho phép (Argument outside allowed range)
InvalidOperationExceptionTrạng thái đối tượng không cho phép thao tác (Object state does not allow the operation)
FormatExceptionĐịnh dạng chuỗi không hợp lệ cho phân tích (Invalid string format for parsing)
IndexOutOfRangeExceptionChỉ mục vượt ngoài giới hạn (Array/indexer out of bounds)
KeyNotFoundExceptionKhóa thiếu trong từ điển (Key missing from dictionary)
NotImplementedExceptionPhương thức chưa được triển khai (Method not yet implemented)
ObjectDisposedExceptionThao tác trên đối tượng đã giải phóng (Operating on a disposed object)
TimeoutExceptionThao tác vượt quá giới hạn thời gian (Operation exceeded time limit)
AggregateExceptionKết hợp nhiều ngoại lệ (phổ biến trong TPL) (Combines multiple exceptions - common in TPL)

Ngoại lệ Tùy chỉnh (Custom Exceptions)

Tạo ngoại lệ tùy chỉnh khi cần các kiểu lỗi đặc thù miền (domain-specific) mà người gọi có thể xử lý riêng biệt:

[Serializable]
public class PaymentFailedException : Exception
{
public decimal Amount { get; }
public string TransactionId { get; }

public PaymentFailedException() { }

public PaymentFailedException(string message)
: base(message) { }

public PaymentFailedException(string message, Exception innerException)
: base(message, innerException) { }

public PaymentFailedException(string message, decimal amount, string transactionId)
: base(message)
{
Amount = amount;
TransactionId = transactionId;
}

// Serialization constructor — required for proper serialization support
protected PaymentFailedException(
SerializationInfo info, StreamingContext context)
: base(info, context)
{
Amount = info.GetDecimal(nameof(Amount));
TransactionId = info.GetString(nameof(TransactionId))!;
}
}

Bộ lọc Ngoại lệ (Exception Filters - C# 6)

Sử dụng catch...when để lọc ngoại lệ trước khi vào khối catch:

try
{
ExecuteHttpCall(request);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
await Task.Delay(TimeSpan.FromSeconds(5));
retry = true;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return Result.NotFound();
}
catch (HttpRequestException ex)
{
// All other HTTP errors
return Result.Failure(ex.Message);
}

Bộ lọc ngoại lệ không đặt lại dấu vết ngăn xếp, lý tưởng cho ghi log có điều kiện:

catch (Exception ex) when (_logger.LogAndReturnFalse(ex))
{
// Never entered — LogAndReturnFalse returns false
// But the exception was logged with full stack trace
}

Ngoại lệ Bên trong (Inner Exceptions)

Chuỗi ngoại lệ để bảo toàn ngữ cảnh trong khi bao bọc lỗi cấp thấp hơn:

try
{
_httpClient.PostAsync(url, content).Wait();
}
catch (AggregateException ex)
{
throw new PaymentGatewayException(
"Failed to contact payment provider", ex.InnerException!);
}

Mệnh đề Bảo vệ (Guard Clauses)

Sử dụng mệnh đề bảo vệ (guard clause) để thất bại nhanh ở đầu phương thức:

public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order); // .NET 6+
ArgumentException.ThrowIfNullOrEmpty(order.Id); // .NET 8+

if (order.Items.Count == 0)
throw new ArgumentException("Order must contain items.", nameof(order));

if (order.Total <= 0)
throw new ArgumentOutOfRangeException(nameof(order), "Total must be positive.");

// Main logic starts here — all preconditions validated
FulfillOrder(order);
}

Khi nào Sử dụng (When to Use)

  • Thao tác I/O — gọi tệp, mạng, và cơ sở dữ liệu luôn có thể thất bại
  • Phân tích đầu vào bên ngoài — đầu vào người dùng, tệp cấu hình, tải trọng API (API payload)
  • Dọn dẹp tài nguyên — luôn ghép nối thu nhận với finally hoặc using
  • Xác thực miền — ném ngoại lệ tùy chỉnh cho vi phạm quy tắc nghiệp vụ
  • Lớp ranh giới — bắt và dịch ngoại lệ tại ranh giới dịch vụ/API

Lỗi thường gặp (Common Pitfalls)

Bắt kiểu cơ sở Exception (Catching Exception base type)

Tránh catch (Exception) trừ khi bạn đang ở bộ xử lý cấp cao nhất (ví dụ: middleware). Nuốt tất cả ngoại lệ che giấu lỗi và khiến việc gỡ lỗi cực kỳ khó khăn. Nếu phải bắt Exception, luôn ném lại hoặc ghi log với đầy đủ ngữ cảnh.

Sự cố ngoại lệ async void (async void exception crashes)

Ngoại lệ ném trong phương thức async void lan truyền đến ngữ cảnh đồng bộ hóa (synchronization context) và sẽ làm sập tiến trình. Luôn sử dụng async Task thay vì async void, ngoại trừ bộ xử lý sự kiện.

finally ném ngoại lệ (finally that throws)

Nếu khối finally ném ngoại lệ, nó thay thế ngoại lệ gốc. Giữ khối finally đơn giản — chỉ giải phóng tài nguyên.

Ưu tiên TryParse thay vì Parse+catch (Prefer TryParse over Parse+catch)

Để chuyển chuỗi thành số, sử dụng int.TryParse, decimal.TryParse, v.v. Phương thức Parse dựa trên ngoại lệ chậm hơn đáng kể khi thất bại là điều dự kiến:

// Good — no exception on invalid input
if (int.TryParse(input, out var number))
ProcessNumber(number);

// Bad — exception for expected control flow
try { ProcessNumber(int.Parse(input)); }
catch (FormatException) { HandleInvalidInput(); }

Tóm tắt (Key Takeaways)

  1. Luôn bắt kiểu ngoại lệ cụ thể nhất trước.
  2. Sử dụng throw; (không phải throw ex;) để ném lại và bảo toàn dấu vết ngăn xếp (stack trace).
  3. Sử dụng finally hoặc using cho việc dọn dẹp tài nguyên được đảm bảo.
  4. Tạo ngoại lệ tùy chỉnh cho lỗi đặc thù miền mà người gọi cần xử lý riêng biệt.
  5. Bộ lọc ngoại lệ (catch...when) mạnh mẽ cho xử lý có điều kiện mà không mất dấu vết ngăn xếp.
  6. Mệnh đề bảo vệ (guard clause) ở điểm vào phương thức giữ logic chính sạch sẽ.
  7. Không bao giờ nuốt ngoại lệ — tối thiểu phải ghi log.

Câu hỏi Phỏng vấn (Interview Questions)

Câu hỏi: Sự khác biệt giữa throwthrow ex là gì? throw; ném lại ngoại lệ đã bắt trong khi bảo toàn dấu vết ngăn xếp gốc (original stack trace). throw ex; đặt lại dấu vết ngăn xếp về phương thức hiện tại, che giấu nơi ngoại lệ ban đầu xảy ra. Luôn sử dụng throw; khi ném lại.

Câu hỏi: Sự khác biệt giữa finallycatch là gì? catch xử lý một kiểu ngoại lệ cụ thể và chỉ chạy khi ngoại lệ đó xảy ra. finally luôn chạy bất kể ngoại lệ có được ném hay bắt hay không. Sử dụng catch cho xử lý lỗi và finally cho dọn dẹp.

Câu hỏi: Khi nào nên tạo ngoại lệ tùy chỉnh? Tạo ngoại lệ tùy chỉnh khi người gọi cần xử lý lỗi đặc thù miền khác với lỗi chung. Đặt tên mô tả (ví dụ: InsufficientFundsException), kế thừa từ Exception, và bao gồm hàm tạo tuần tự hóa (serialization constructor).

Câu hỏi: Bộ lọc ngoại lệ (exception filter) là gì? Bộ lọc ngoại lệ (catch...when) là tính năng C# 6 đánh giá điều kiện trước khi vào khối catch. Nếu điều kiện là false, khối catch bị bỏ qua và ngoại lệ tiếp tục lan truyền. Bộ lọc bảo toàn dấu vết ngăn xếp và tránh ném lại.

Câu hỏi: Tại sao nên tránh async void? Ngoại lệ ném trong phương thức async void không thể bị bắt bởi người gọi và sẽ làm sập ứng dụng thông qua ngữ cảnh đồng bộ hóa (synchronization context). Sử dụng async Task để người gọi có thể await và xử lý ngoại lệ.

Tài liệu tham khảo (References)