Skip to content

4.9 - Những lỗi thường gặp ​

Tóm tắt: Tám lỗi dưới đây gặp trong gần như mọi codebase .NET đã chạy được vài năm. Điểm chung của chúng: không lỗi nào gây ra exception ngay lúc viết — chúng chỉ làm code khó đổi dần, cho tới lúc một yêu cầu nhỏ mất ba ngày. Đáng chú ý nhất là lỗi cuối: ToUpper(), ToLower() và string.Compare mặc định dùng văn hoá (culture) hiện tại của máy, nên trong văn hoá Thổ Nhĩ Kỳ "i".ToUpper() cho ra "İ" chứ không phải "I" — một lỗi từng làm hỏng phần phân quyền của nhiều hệ thống thật, và chỉ xuất hiện trên máy đặt đúng khu vực đó. Cách tránh: so sánh bằng string.Equals(a, b, StringComparison.OrdinalIgnoreCase), tức so từng ký tự, không phụ thuộc văn hoá.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Nhận ra tám lỗi thiết kế lớp phổ biến qua triệu chứng của chúng.
  • Giải thích hậu quả cụ thể của từng lỗi, không chỉ nói "không tốt".
  • Áp dụng cách sửa tương ứng.
  • Biết vì sao so sánh chuỗi phải nêu rõ StringComparison.

Cần biết trước: các bài 4.1 tới 4.8 của module, đặc biệt bài 4.2 (đóng gói), bài 4.3 (kế thừa) và bài 4.4 (interface). Bài tập 3 đo thời gian như bài 1.7.

Nội dung bài học ​

4.9.1 — Field công khai có thể thay đổi ​

csharp
// SAI
public class Order { public decimal Total; }

// ĐÚNG
public class Order { public decimal Total { get; private set; } }

Triệu chứng: bất kỳ ai cũng đặt được giá trị bất kỳ, kể cả số âm.

Hậu quả: không có chỗ nào chèn kiểm tra, và đổi field thành property sau này là thay đổi phá vỡ tương thích nhị phân — mọi assembly tham chiếu tới phải build lại.

4.9.2 — God class ​

csharp
// SAI — 1200 dòng, xử lý CRUD, email, báo cáo, chiết khấu, xuất Excel
public class CustomerManager { }

Triệu chứng: file dài hơn 500 dòng; constructor nhận bảy phụ thuộc trở lên; tên class chứa Manager, Helper, Util, Processor.

Hậu quả: không viết unit test nổi vì phải dựng cả bảy phụ thuộc; hai người sửa cùng file mỗi tuần nên xung đột liên tục; và đổi định dạng Excel lại làm vỡ test của phần chiết khấu.

Cách sửa — tách theo lý do thay đổi, không tách theo số dòng:

csharp
public class CustomerService     { }  // quy tắc nghiệp vụ
public class CustomerRepository  { }  // truy cập dữ liệu
public class CustomerExporter    { }  // xuất file
public class CustomerNotifier    { }  // gửi thông báo

Phép thử: "Ai là người yêu cầu thay đổi class này?" Nếu có nhiều hơn một nhóm người, hãy tách.

4.9.3 — Kế thừa để dùng lại code ​

csharp
// SAI — PremiumCustomer kế thừa Customer chỉ để thêm một mức chiết khấu
public class PremiumCustomer : Customer
{
    public decimal DiscountRate => 0.15m;
}

Triệu chứng: lớp con chỉ thêm một hai thuộc tính; hoặc tên lớp con là một trạng thái chứ không phải một loại.

Hậu quả: khách hàng thường nâng hạng lên premium thì làm sao? Bạn không đổi được kiểu của một object đang tồn tại. Phải tạo object mới và cập nhật mọi tham chiếu.

Cách sửa — trạng thái là dữ liệu, không phải kiểu:

csharp
public class Customer
{
    public CustomerTier Tier { get; private set; }
    public void Upgrade(CustomerTier tier) => Tier = tier;
}

public class DiscountPolicy
{
    public decimal GetRate(Customer c) => c.Tier switch
    {
        CustomerTier.Premium => 0.15m,
        CustomerTier.Gold    => 0.10m,
        CustomerTier.Regular => 0m,      // liệt kê đủ, không dùng _ (bài 4.8)
    };
}

public enum CustomerTier { Regular, Gold, Premium }

Không có nhánh _, nên khi thêm hạng Platinum, trình biên dịch báo CS8509 ở đúng chỗ này, như bài 4.8 đã trình bày.

Quy tắc: nếu một object có thể chuyển từ loại này sang loại kia trong đời nó, thì đó là trạng thái chứ không phải kiểu.

4.9.4 — Phụ thuộc thẳng vào lớp cài đặt ​

csharp
// SAI
public class OrderService
{
    private readonly SqlCustomerRepository _repo = new();
}

Triệu chứng: new một lớp hạ tầng ngay trong class nghiệp vụ.

Hậu quả: không test được nếu không có database thật; đổi ORM phải sửa tầng nghiệp vụ; và class tự quyết vòng đời của phụ thuộc thay vì để DI container lo.

Cách sửa: nhận qua constructor, khai báo bằng interface — chi tiết ở bài 4.4 và Module 7.

4.9.5 — Anemic model ​

csharp
// SAI — class chỉ là túi dữ liệu, quy tắc nằm rải rác ngoài kia
public class Order
{
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
}

// Ở service A
if (order.Status == OrderStatus.Draft) order.Status = OrderStatus.Confirmed;
// Ở service B — quên kiểm tra
order.Status = OrderStatus.Confirmed;

Triệu chứng: entity nghiệp vụ không có phương thức nào ngoài property.

Hậu quả: cùng một quy tắc được cài đặt ở nhiều chỗ, và chỉ cần một chỗ quên là dữ liệu sai.

Cách sửa: đưa hành vi vào entity — xem bài 4.2. Lưu ý DTO thì nên anemic; vấn đề chỉ nằm ở entity nghiệp vụ.

4.9.6 — Trả về null mà không nói rõ nghĩa ​

csharp
// Mơ hồ — null nghĩa là "không tìm thấy" hay "lỗi" hay "chưa có dữ liệu"?
// Và chữ ký không có dấu ?, nên người gọi không được nhắc là phải kiểm tra.
public Customer Find(int id) => _customers.GetValueOrDefault(id)!;

(_customers ở đây là một Dictionary<int, Customer>.)

Triệu chứng: chuỗi if (x != null) lan khắp nơi; NullReferenceException lúc chạy.

null tự nó không sai. Vấn đề là null không được khai báo hoặc mang nhiều nghĩa. Cách sửa, chọn theo ngữ nghĩa:

csharp
using System.Diagnostics.CodeAnalysis;   // cần cho NotNullWhen

// 1. Không tìm thấy là BÌNH THƯỜNG, và null chỉ có một nghĩa
//    -> trả Customer? (nullable reference types đang bật, mặc định trong project mới).
//    Dấu ? buộc người gọi kiểm tra null, trình biên dịch cảnh báo nếu quên.
public Customer? FindById(int id) => _customers.GetValueOrDefault(id);

// 2. Hoặc mẫu TryXxx, tiện khi dùng trong if
public bool TryFind(int id, [NotNullWhen(true)] out Customer? customer)
    => _customers.TryGetValue(id, out customer);

// 3. Không tìm thấy là BẤT THƯỜNG (id lấy từ chính hệ thống) -> ném ngoại lệ
public Customer GetById(int id) =>
    _customers.GetValueOrDefault(id)
    ?? throw new KeyNotFoundException($"Không có khách hàng {id}");

// 4. Danh sách rỗng -> trả collection rỗng, KHÔNG BAO GIỜ trả null
public IReadOnlyList<Order> GetOrders(int customerId) =>
    _orders.Where(o => o.CustomerId == customerId).ToList();   // rỗng vẫn là danh sách hợp lệ

[NotNullWhen(true)] nói với trình biên dịch: khi hàm trả true thì customer chắc chắn khác null, nên trong if (TryFind(id, out var c)) { ... } bạn dùng c mà không bị cảnh báo. Bài 5.6 dạy kỹ nullable reference types.

Trường hợp 4 là quy tắc tuyệt đối: phương thức trả về collection không bao giờ trả null. Người gọi phải foreach được mà không cần kiểm tra trước.

4.9.7 — Ngoại lệ dùng cho luồng thông thường ​

csharp
// SAI — người dùng gõ sai ngày là chuyện bình thường
try { date = DateTime.Parse(input); }
catch (FormatException) { date = DateTime.Today; }

Triệu chứng: try/catch bên trong vòng lặp; bắt FormatException hay KeyNotFoundException như một phần của logic.

Hậu quả: ném ngoại lệ tốn kém hơn trả về bool rất nhiều lần vì phải dựng stack trace. Trong vòng lặp hàng nghìn lần, đây là lỗi hiệu năng thật.

csharp
// ĐÚNG
if (!DateTime.TryParse(input, out var date))
    date = DateTime.Today;

Tiêu chí: thất bại này có bình thường không? Bình thường thì dùng TryXxx; bất thường thì mới ném.

4.9.8 — So sánh chuỗi không nêu rõ cách so sánh ​

Đây là lỗi tinh vi nhất trong danh sách, và nó có lịch sử làm hỏng hệ thống thật.

csharp
// RỦI RO — phụ thuộc văn hoá của máy đang chạy
if (role.ToUpper() == "ADMIN") { }
if (string.Compare(a, b) == 0) { }

Trong văn hoá Thổ Nhĩ Kỳ, "i".ToUpper() cho ra "İ" (chữ I có dấu chấm) chứ không phải "I". Nên "admin".ToUpper() == "ADMIN" trả về false trên máy đặt tr-TR. Đây là lỗi kinh điển tới mức có tên riêng: Turkish I problem.

csharp
// ĐÚNG — nêu rõ ý định
if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)) { }

Chọn kiểu so sánh:

KiểuDùng cho
OrdinalMã, định danh, khoá, đường dẫn, tên file
OrdinalIgnoreCaseNhư trên, không phân biệt hoa thường
CurrentCultureChuỗi hiển thị cho người dùng, khi sắp xếp
InvariantCultureRất hiếm; hầu như luôn có lựa chọn tốt hơn

Quy tắc: mọi so sánh chuỗi mang tính kỹ thuật đều dùng Ordinal. Chỉ dùng so sánh theo văn hoá khi thật sự đang sắp xếp danh sách cho người đọc.

Chuyện tương tự xảy ra với định dạng số và ngày: decimal.Parse("1,5") cho kết quả khác nhau giữa vi-VN và en-US. Với dữ liệu máy đọc, luôn dùng CultureInfo.InvariantCulture.

4.9.9 — Rà lại code của bạn ​

Danh sách rà soát lỗi thường gặp

  • Không có field public nào; chỉ có property.
  • Không có class nào tên Manager, Helper hay Util mà dài quá 500 dòng.
  • Không có constructor nào nhận từ 7 phụ thuộc trở lên.
  • Không kế thừa chỉ để dùng lại code; trạng thái được mô hình hoá bằng dữ liệu.
  • Không new lớp hạ tầng bên trong class nghiệp vụ.
  • Entity nghiệp vụ có phương thức mang nghĩa nghiệp vụ.
  • Phương thức có thể không tìm thấy thì khai báo kiểu trả về có dấu ?, hoặc dùng TryXxx.
  • Phương thức trả về collection không bao giờ trả null.
  • Không dùng try/catch cho luồng chạy thông thường.
  • Mọi so sánh chuỗi kỹ thuật đều nêu rõ StringComparison.Ordinal.
  • Parse số và ngày từ dữ liệu máy đều dùng InvariantCulture.

Bài tập áp dụng ​

Bài 1 — Tái hiện lỗi Turkish I ​

Viết một đoạn code so sánh "admin".ToUpper() == "ADMIN", đặt văn hoá hiện tại là tr-TR trước đó. Ghi lại kết quả. Sửa bằng StringComparison.OrdinalIgnoreCase và chạy lại.

Tiêu chí hoàn thành: bạn hiểu vì sao đây là lỗ hổng bảo mật, không chỉ là lỗi hiển thị.

Gợi ý và lời giải — Bài 1

Gợi ý. Tiếng Thổ Nhĩ Kỳ có bốn chữ i, không phải hai: i và İ là một cặp có chấm, ı và I là một cặp không chấm. Vậy "i".ToUpper() trong văn hoá Thổ cho ra chữ gì?

Lời giải.

csharp
using System.Globalization;   // cần cho CultureInfo

CultureInfo.CurrentCulture = new CultureInfo("tr-TR");

Console.WriteLine($"\"admin\".ToUpper() = {"admin".ToUpper()}");
Console.WriteLine($"ToUpper() == ADMIN -> {"admin".ToUpper() == "ADMIN"}");
Console.WriteLine($"OrdinalIgnoreCase  -> {string.Equals("admin", "ADMIN", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"\"ADMIN\".ToLower() = {"ADMIN".ToLower()}");

Kết quả chạy thật trên .NET 10 (Windows 11):

text
"admin".ToUpper() = ADMİN
ToUpper() == ADMIN -> False
OrdinalIgnoreCase  -> True
"ADMIN".ToLower() = admın

"admin".ToUpper() cho ADMİN — chữ I có chấm, ký tự Unicode U+0130, khác hoàn toàn với I thường U+0049.

Vì sao đây là lỗ hổng bảo mật. Xét đoạn kiểm tra quyền rất hay gặp:

csharp
if (user.Role.ToUpper() == "ADMIN")
    AllowDeleteEverything();

Trên máy chủ cấu hình văn hoá Thổ Nhĩ Kỳ, người dùng có vai trò admin không được nhận là quản trị viên — hệ thống từ chối người đúng. Nguy hiểm hơn là chiều ngược lại:

csharp
if (input.ToLower() != "admin")        // chặn đăng ký tên admin
    AllowRegistration(input);

Người dùng đăng ký tên ADMIN. Trong văn hoá Thổ, "ADMIN".ToLower() cho admın — chữ i không chấm — nên khác "admin" và lọt qua bộ lọc. Giờ hệ thống có hai tài khoản mà mắt người đọc đều thấy là "admin".

Ba nguồn khiến văn hoá đổi mà bạn không biết:

NguồnTình huống
Ngôn ngữ hệ điều hành của máy chủMáy chủ đặt ở vùng khác
Biến môi trường LANG trong containerImage cơ sở khác nhau có mặc định khác nhau
Header Accept-Language của người dùngKhi bật middleware bản địa hoá, văn hoá đổi theo từng request

Dòng cuối đáng sợ nhất: cùng một dòng code cho kết quả khác nhau tuỳ vào ai đang gọi.

Quy tắc — chia làm hai nhóm rõ ràng:

Mục đíchDùngVí dụ
So sánh trong logicStringComparison.Ordinal hoặc OrdinalIgnoreCaseVai trò, mã, khoá, tên file, giao thức
Hiển thị cho người dùngStringComparison.CurrentCultureSắp xếp danh sách tên để hiển thị

Mặc định nên là Ordinal. Chỉ dùng văn hoá khi bạn thật sự đang sắp xếp hoặc so sánh thứ tự để người đọc nhìn.

Cách phòng có hệ thống. Bật bộ phân tích để trình biên dịch cảnh báo mỗi khi so sánh chuỗi không nêu rõ cách so sánh:

xml
<PropertyGroup>
  <AnalysisMode>Recommended</AnalysisMode>
</PropertyGroup>

Build thử đoạn role.ToUpper() == "ADMIN" và string.Compare(a, b) == 0 với cấu hình này trên .NET 10, trong danh sách cảnh báo có:

text
warning CA1862: Prefer using 'string.Equals(string, StringComparison)' to perform a case-insensitive comparison, ...
warning CA1310: The behavior of 'string.Compare(string?, string?)' could vary based on the current user's locale settings. ...

Quy tắc CA1862 cảnh báo khi dùng ToUpper() hoặc ToLower() để so sánh; CA1310 yêu cầu nêu rõ StringComparison. Đây là hai cảnh báo đáng biến thành lỗi (<WarningsAsErrors>$(WarningsAsErrors);CA1862;CA1310</WarningsAsErrors>).

Ghi nhớ. Lỗi này có tên riêng — Turkish I problem — vì nó đã gây sự cố đủ nhiều để được đặt tên. Nó cũng là ví dụ điển hình cho một loại lỗi khó chịu: code đúng trên máy bạn, đúng trên CI, và sai trên đúng một môi trường.

Bài 2 — Săn god class ​

Class OrderService trong phần lời giải dài khoảng 900 dòng, gồm chín phương thức công khai (danh sách ở đầu lời giải, đọc danh sách đó trước). Với từng phương thức, trả lời câu hỏi "ai yêu cầu thay đổi phần này". Nếu có nhiều hơn một nhóm người, phác thảo cách tách thành các class nhỏ.

Tiêu chí hoàn thành: bạn tách theo người yêu cầu thay đổi, không tách theo số dòng.

Gợi ý và lời giải — Bài 2

Gợi ý. Trong dự án thật, bắt đầu từ những file dài nhất (lệnh find . -name "*.cs" -not -path "*/obj/*" | xargs wc -l | sort -rn | head liệt kê chúng). Nhưng số dòng chỉ là dấu hiệu, không phải tiêu chí. Tiêu chí là số lý do độc lập khiến file phải sửa.

Lời giải — ví dụ một god class 900 dòng:

csharp
public class OrderService
{
    public Task<Order> CreateOrderAsync(...) { }            // nghiệp vụ đặt hàng
    public Task CancelOrderAsync(...) { }                   // nghiệp vụ đặt hàng
    public Task<decimal> CalculateTaxAsync(...) { }        // quy định thuế
    public Task<decimal> CalculateShippingAsync(...) { }// hợp đồng vận chuyển
    public Task SendConfirmationEmailAsync(...) { }          // marketing
    public Task SendSmsAsync(...) { }                   // marketing
    public Task<byte[]> ExportPdfAsync(...) { }          // kế toán
    public Task SyncToErpAsync(...) { }                // hệ thống ERP bên ngoài
    public Task<Report> RevenueReportAsync(...) { }   // ban giám đốc
}

Bảng "ai yêu cầu thay đổi":

Nhóm phương thứcAi yêu cầu đổiTần suất đổi
Tạo, huỷ đơnPhòng kinh doanhCao
Tính thuếThay đổi quy định pháp luậtThấp, nhưng bắt buộc và gấp
Phí vận chuyểnĐàm phán lại hợp đồngTrung bình
Email, SMSMarketingRất cao
Xuất PDFKế toánThấp
Đồng bộ ERPĐội hệ thống ERPTrung bình
Báo cáoBan giám đốcCao

Bảy nhóm người khác nhau cùng có lý do sửa một file. Hậu quả cụ thể:

  • Marketing đổi mẫu email, và lần triển khai đó kéo theo mọi thay đổi chưa xong của các nhóm khác.
  • Hai người sửa hai việc không liên quan luôn gặp xung đột trong cùng file.
  • Bài kiểm thử cho việc tính thuế phải dựng cả IEmailSender và IPdfGenerator.
  • Không ai dám đụng vào file, vì không ai hiểu hết nó.

Phác thảo tách:

text
OrderService          -> chỉ còn tạo, huỷ, điều phối    (~120 dòng)
TaxCalculator         -> tính thuế                       (~80 dòng)
ShippingFeeCalculator -> tính phí vận chuyển             (~60 dòng)
OrderNotifier         -> email và SMS                    (~100 dòng)
OrderPdfExporter      -> xuất PDF                        (~150 dòng)
ErpSyncService        -> đồng bộ ERP                     (~120 dòng)
OrderReportQuery      -> báo cáo (chỉ đọc)               (~200 dòng)

OrderService còn lại chỉ điều phối. Phác thảo dưới đây dùng Result<Order> (kiểu trả về "thành công kèm giá trị, hoặc thất bại kèm lý do", có ở cuối bài tập 3):

csharp
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly ITaxCalculator _tax;
    private readonly IOrderNotifier _notifier;

    public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest req, CancellationToken ct)
    {
        var order = Order.Create(req.CustomerId, req.Lines);
        if (order.IsFailure) return order;

        order.Value.ApplyTax(await _tax.CalculateAsync(order.Value, ct));
        await _repo.AddAsync(order.Value, ct);
        await _notifier.NotifyNewOrderAsync(order.Value, ct);
        return order;
    }
}

Vì sao tách theo người yêu cầu chứ không theo số dòng. Một class 600 dòng chỉ phục vụ một nhóm người vẫn là class tốt — tách nó ra chỉ làm code khó theo dõi. Ngược lại, một class 80 dòng phục vụ ba nhóm người là class có vấn đề.

Số dòng là dấu hiệu, không phải bệnh. Bệnh là nhiều lý do độc lập để thay đổi — chính là nội dung của Single Responsibility Principle, và bài 5.1 sẽ bàn kỹ.

Cách tách an toàn từng bước. Đừng tách một lần. Mỗi lần lấy ra một nhóm phương thức, viết kiểm thử cho nó, merge, rồi mới làm nhóm tiếp theo — đúng tinh thần commit nguyên tử ở bài 3.4.

Bài 3 — Đo giá của ngoại lệ ​

Viết vòng lặp 100.000 lần phân tích chuỗi sai định dạng, một lần dùng try/catch với Parse, một lần dùng TryParse. Đo bằng Stopwatch và ghi lại tỉ lệ.

Tiêu chí hoàn thành: bạn có tỉ lệ cụ thể và nêu được khi nào vẫn nên dùng ngoại lệ.

Gợi ý và lời giải — Bài 3

Gợi ý. Chạy ở chế độ Release (dotnet run -c Release) và làm nóng trước, như bài 1.7 đã nêu.

Lời giải.

csharp
using System.Diagnostics;   // cần cho Stopwatch

const int N = 100_000;

// Làm nóng: chạy mỗi cách vài lần để JIT biên dịch xong
for (int i = 0; i < 1_000; i++)
{
    try { int.Parse("không phải số"); } catch (FormatException) { }
    int.TryParse("không phải số", out _);
}

var sw = Stopwatch.StartNew();
int failed1 = 0;
for (int i = 0; i < N; i++)
{
    try { int.Parse("không phải số"); }
    catch (FormatException) { failed1++; }
}
sw.Stop();
var tEx = sw.Elapsed.TotalMilliseconds;

sw.Restart();
int failed2 = 0;
for (int i = 0; i < N; i++)
    if (!int.TryParse("không phải số", out _)) failed2++;
sw.Stop();
var tTry = sw.Elapsed.TotalMilliseconds;

Console.WriteLine($"try/catch Parse : {tEx,7:F1} ms  ({tEx * 1000 / N:F2} micro-giây mỗi lần)");
Console.WriteLine($"TryParse        : {tTry,7:F1} ms  ({tTry * 1000 / N:F3} micro-giây mỗi lần)");
Console.WriteLine($"chậm gấp        : {tEx / tTry:F0} lần");

Kết quả đo thật trên .NET 10, chế độ Release (một máy laptop Windows, máy tính đặt định dạng số kiểu Việt Nam nên dấu thập phân là dấu phẩy):

text
try/catch Parse :   149,4 ms  (1,49 micro-giây mỗi lần)
TryParse        :     0,3 ms  (0,003 micro-giây mỗi lần)
chậm gấp        : 527 lần

Chạy thêm hai lần cho tỉ lệ 526 và 512. Con số tuyệt đối trên máy bạn sẽ khác; điều đáng tin là tỉ lệ vài trăm lần.

Vì sao ngoại lệ đắt. Ném một ngoại lệ không chỉ là trả về một giá trị. Runtime phải:

  1. Cấp phát đối tượng ngoại lệ trên heap.
  2. Đi ngược ngăn xếp để tìm khối catch phù hợp — chi phí tỉ lệ với độ sâu ngăn xếp.
  3. Thu thập dấu vết ngăn xếp, gồm tên phương thức, tên file và số dòng.
  4. Tháo dỡ các khung ngăn xếp và chạy mọi khối finally trên đường đi.

Bước 2 và 3 là phần đắt nhất, và chúng đắt hơn khi ngăn xếp sâu — nghĩa là trong ứng dụng thật, con số còn tệ hơn trong bài đo này.

Khi nào dùng cái gì:

Tình huốngDùngVì sao
Đầu vào người dùng có thể saiTryParse, Result<T>Sai là chuyện bình thường, không phải ngoại lệ
Tìm bản ghi có thể không tồn tạiTrả Customer? hoặc TryFind (mục 4.9.6)Không tìm thấy là kết quả hợp lệ
Vi phạm quy tắc nghiệp vụ dự đoán đượcResult<T>Xảy ra thường xuyên, cần thông điệp rõ
Tham số sai do lỗi lập trìnhNgoại lệĐây là lỗi thật, phải dừng ngay và ồn ào
Mất kết nối databaseNgoại lệThật sự bất thường, không dự đoán được
Trạng thái không thể xảy raNgoại lệPhát hiện sớm còn hơn chạy tiếp với dữ liệu sai

Nguyên tắc. Ngoại lệ dành cho những gì ngoại lệ — đúng như tên gọi. Nếu một tình huống xảy ra trong luồng bình thường của hệ thống, nó không phải ngoại lệ, và biểu diễn nó bằng giá trị trả về sẽ vừa nhanh hơn vừa rõ ràng hơn.

Một hiểu nhầm cần đính chính. Nhiều người kết luận từ con số này rằng "đừng bao giờ dùng ngoại lệ". Đó là kết luận sai. Trong bài đo trên, một ngoại lệ mất khoảng 1,5 micro-giây; nếu nó xảy ra một lần mỗi vài nghìn request, chi phí đó hoàn toàn không đáng kể so với một truy vấn database mất vài mili-giây. Vấn đề chỉ xuất hiện khi ngoại lệ nằm trong luồng thường xuyên hoặc trong vòng lặp.

Mẫu Result<T> thay thế:

csharp
public readonly record struct Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool ok, T? value, string? error) => (IsSuccess, Value, Error) = (ok, value, error);

    public static Result<T> Ok(T value) => new(true, value, null);
    public static Result<T> Fail(string error) => new(false, default, error);
}

Module 5 xây dựng đầy đủ mẫu này, và Module 9 ánh xạ nó sang mã trạng thái HTTP.

Tự kiểm tra ​

Vì sao đổi field public thành property lại là thay đổi phá vỡ?

Vì field và property được truy cập bằng chỉ thị IL khác nhau. Mọi assembly đã biên dịch dựa trên field sẽ không chạy được với phiên bản mới dùng property, mà phải build lại. Với mã nguồn trong cùng solution thì không thấy vấn đề, nhưng với thư viện phát hành ra ngoài thì đó là thay đổi phá vỡ tương thích nhị phân.

Nhận biết god class bằng cách nào?

Ba triệu chứng: file dài hơn khoảng năm trăm dòng, constructor nhận từ bảy phụ thuộc trở lên, và tên class chứa Manager, Helper, Util hay Processor. Phép thử dứt khoát là hỏi ai yêu cầu thay đổi class này — nếu có nhiều hơn một nhóm người thì nên tách.

Khi nào kế thừa là sai cách mô hình hoá?

Khi lớp con đại diện cho một trạng thái chứ không phải một loại. Nếu một object có thể chuyển từ loại này sang loại kia trong đời nó — khách thường nâng hạng lên premium — thì đó là trạng thái, vì bạn không đổi được kiểu của một object đang tồn tại. Hãy mô hình hoá bằng một thuộc tính dữ liệu.

Phương thức trả về collection có được trả null không?

Không bao giờ. Danh sách rỗng vẫn là một danh sách hợp lệ, và người gọi phải foreach được mà không cần kiểm tra trước. Trả null buộc mọi chỗ gọi thêm một nhánh kiểm tra, và chỉ cần quên một chỗ là NullReferenceException.

Vì sao không nên dùng ngoại lệ cho luồng thông thường?

Vì ném ngoại lệ tốn kém hơn trả về bool rất nhiều lần do phải dựng stack trace. Trong một vòng lặp chạy hàng nghìn lần, đó là lỗi hiệu năng thật. Tiêu chí quyết định là hỏi thất bại này có bình thường không: người dùng gõ sai định dạng là bình thường nên dùng TryParse.

Turkish I problem là gì?

ToUpper, ToLower và string.Compare mặc định dùng văn hoá hiện tại của máy. Trong văn hoá Thổ Nhĩ Kỳ, chữ i viết hoa thành İ có dấu chấm chứ không phải I thường. Nên so sánh kiểu role.ToUpper() bằng ADMIN trả về false trên máy đặt tr-TR, làm hỏng phần phân quyền. Cách tránh là luôn nêu rõ StringComparison.Ordinal hoặc OrdinalIgnoreCase cho mọi so sánh chuỗi mang tính kỹ thuật.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Không lỗi nào ở trên gây exception lúc viết. Chúng chỉ làm code khó đổi dần — đó là lý do chúng sống sót lâu.
  2. Trạng thái không phải kiểu. Nếu object đổi loại được trong đời nó, hãy dùng dữ liệu chứ đừng dùng kế thừa.
  3. Mọi so sánh chuỗi kỹ thuật đều nêu rõ Ordinal. Mặc định phụ thuộc văn hoá là một quả mìn theo khu vực người dùng.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.