Clean code series: Part 7 - Error Handling

1. Clean Code rồi tại sao còn cần Error Handling?

Nghe có vẻ kỳ lạ khi nói về xử lý lỗi trong cuốn sách về Code sạch. Xử lý lỗi là một trong những điều mà tất cả chúng ta phải làm khi chúng ta lập trình. Xử lý lỗi quan trọng nhưng nếu nó che lấp đi logic xử lý thì nó đó là một đoạn code dởm (bad code).

Những điểm chính trong chương này:

  • Clean Code - Mã sạch không có nghĩa là không có lỗi
  • Xử lý lỗi là điều tất yếu phải làm khi lập trình
  • Đảm bảo rằng code thực hiện đúng những gì nó cần làm
  • Xử lý lỗi là quan trọng, nhưng nếu nó che khuất logic thì đó là một sai lầm

2. Các nguyên tắc xử lý lỗi

2.1. Use Exceptions Rather Than Return Codes

Sử dụng ngoại lệ hơn là trả ra mã lỗi

Trở lại quá khứ xa xôi có nhiều ngôn ngữ không có ngoại lệ. Trong những ngôn ngữ các kỹ thuật xử lý và báo cáo lỗi bị hạn chế. Bạn có thể đặt lỗi cờ hoặc trả lại mã lỗi mà người gọi có thể kiểm tra Vấn đề với các phương pháp này là chúng gây lộn xộn, khó xử lý cho người đọc. Người gọi hàm phải check lỗi ngay lập tức sau khi gọi nó. Không may, nó quá dễ để quên. Cách tốt hơn là bạn bắn ra một ngoại lệ khi bạn gặp một lỗi. Mã sẽ sạch đẹp hơn và code logic không bị che khuất đi bởi việc xử lý lỗi.

Ví dụ:
DeviceController.java (bad code)

public class DeviceController { 
    ...
    public void sendShutDown() { 
        DeviceHandle handle = getHandle(DEV1); 
        // Kiểm tra trạng thái của thiết bị
        if (handle != DeviceHandle.INVALID) {
            // Lưu trạng thái thiết bị vào record field
            retrieveDeviceRecord(handle);
            // Nếu không bị treo, hãy tắt
            if (record.getStatus() != DEVICE_SUSPENDED) {
                pauseDevice(handle); 
                clearDeviceWorkQueue(handle); 
                closeDevice(handle);
            } else {
                logger.log("Thiết bị bị treo. Không thể tắt");
            }
        } else {
            logger.log("Xử lý không hợp lệ cho: " + DEV1.toString()); 
        }
    } 
    ...
}

Nên tách thành code như sau:
DeviceController.java (Good code with exceptions)

public class DeviceController { 
    ...
    public void sendShutDown() {
        try {
            tryToShutDown();
        } catch (DeviceShutDownError e) {
            logger.log(e); }
        }
        
    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        pauseDevice(handle); 
        clearDeviceWorkQueue(handle); 
        closeDevice(handle);
    }
    
    private DeviceHandle getHandle(DeviceID id) {
        ...
        throw new DeviceShutDownError("Xử lý không hợp lệ cho: " + id.toString()); 
        ...
    }
    ...
}

2.2. Write Your Try-Catch-Finally Statement First

Viết khối Try-Catch-Finally trước tiên

Một trong những thứ quan trọng về ngoại lệ là chúng được "define a scope" (định nghĩa các phạm vi) với chương trình của bạn. Khi bạn thực thi code trong khối try, trong khối try-cactch-finally. Bạn đang nói rõ hơn là đoạn code thực thi này có thể bị gián đoạn ở một vài chỗ, cái sẽ được xử lý trong catch

Trong cách này, khối try giống như một transactions (phiên giao dịch). Catch của bạn sẽ không được thực thi nếu không có vấn đề gì với try. Với lý do này, thật là một good-practice để bắt đầu một khối try-catch-finally khi bạn viết code có thể xảy ra ngoại lệ.

Ví dụ với PHP:

public static function createModel($request)
{
    try {
        $book = Book::forge();
        $book->title = $request['title'];
        $book->author = $request['author'];
        $book->price = $request['price'];
        $book->cover_img = $request['cover_img'];
        $book->save();
    } catch (Exception $ex) {
        throw new Exception($ex->getCode());
    }
}

Viết khối Test trước -< Phong cách TDD

Xét ví dụ dưới đây. Chúng ta cần viết hàm đọc file và đọc một số object được serialized.
Chúng ta bắt đầu với unit test theo phong cách TDD, xác định rằng sẽ có Exception khi file không tồn tại:

@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("Tập tin không hợp lệ"); 
}

Test case trên kiểm tra hàm này:

public List<RecordedGrip> retrieveSection(String sectionName) { 
    // Fake return cho đến khi chúng ta có một triển khai thực sự
    return new ArrayList<RecordedGrip>();
}

Test case trên sẽ failed, vì không có StorageException được trả ra. Vì vậy chúng ta cần sửa hàm để Test case trên pass:

public List<RecordedGrip> retrieveSection(String sectionName) { 
    try {
        FileInputStream stream = new FileInputStream(sectionName);
    } catch (Exception e) {
        throw new StorageException("Lỗi truy xuất", e); 
    }
    return new ArrayList<RecordedGrip>();
}

Lúc này, Test case trên đã pass. Chúng ta có thể thu hẹp loại ngoại lệ mà chúng ta catch được để phù hợp với loại thực sự được đưa ra từ phương thức khởi tạo của FileInputStream: FileNotFoundException:

public List<RecordedGrip> retrieveSection(String sectionName) { 
    try {
        FileInputStream stream = new FileInputStream(sectionName);
        stream.close();
    } catch (FileNotFoundException e) {
        throw new StorageException("Lỗi truy xuất", e); 
    }
    return new ArrayList<RecordedGrip>(); 
}

Bây giờ chúng ta đã xác định phạm vi với cấu trúc try-catch, chúng ta có thể sử dụng TDD để xây dựng phần còn lại của logic mà chúng ta cần. Logic đó sẽ được thêm vào giữa quá trình tạo FileInputStreamclose, và có thể giả định rằng không có gì sai.

Cố gắng viết các Test case buộc xảy ra các trường hợp ngoại lệ, sau đó thêm các xử lý để điều chỉnh các Test case cho phù hợp.
Điều này giúp bạn làm rõ các logic xử lý trong try trước, sau đó các ngoại lệ sẽ được xử lý riêng biệt ở catch.

2.3. Use Unchecked Exceptions

Sử dụng Unchecked Exceptions

Checked Exception: Các lỗi được phát hiện trong quá trình biên dịch. Đây là các Exception thường không phải do logic chương trình tạo ra. Ví dụ như khi chương trình đọc file mà file không tồn tại, kết nối đến cơ sở dữ liệu mà không thể kết nối được.

𝐔𝐧𝐂𝐡𝐞𝐜𝐤𝐞𝐝 𝐄𝐱𝐜𝐞𝐩𝐭𝐢𝐨𝐧: Đây là các ngoại lệ thường xuất hiện do lỗi logic của lập trình mà tại thời điểm biên dịch không thể phát hiện ra.
Ví dụ như lỗi: NullPointerException, ArrayIndexOutOfBoundsException.

Tại sao nên dùng Unchecked Exceptions?

✅ Ví dụ chương trình của bạn gọi 3 phương thức A(), B(),C(). Trong đó A sẽ gọi A1(), A2(), A3(), B sẽ gọi B1(),B2(), B3(); C sẽ gọi C1(), C2(). và các phương thức Ai, Bi,Ci này đều bắn ra các Checked Exception khác nhau. Khi đó chương trình chính của bạn sẽ phải quản lý rất nhiều exception. Dẫn đến code sẽ dài dòng hơn và logic chính bị che mờ do phần xử lý lỗi.

✅ Việc chuyển đổi các Checked Exception về Unchecked Exception sẽ làm chương trình gọn gàng hơn nhiều. Nhưng bạn phải rất cẩn trọng và hiểu được mình đang làm gì nếu theo cách này, và tốt nhất luôn luôn có Unit Test.

2.4. Provide Context with Exceptions

Cung cấp ngữ cảnh có ngoại lệ

Ý tưởng của nguyên tắc xử lý lỗi này là cung cấp 1 ngữ cảnh bắn exception rõ ràng, kèm theo message để có thể dễ dàng tìm ra vị trí lỗi.

Mỗi ngoại lệ mà bạn "ném ra" phải cung cấp đủ ngữ cảnh để xác định nguồn và vị trí của một lỗi. Tạo ra các thông báo lỗi và truyền nó cùng với ngoại lệ của bạn. Đề cập đến hoạt động đó, lỗi gì và vì sau lại lỗi.

Ví dụ nếu bạn đăng nhập vào ứng dụng của mình, truyền đủ thông tin nhất có thể để bạn có thể log lại các lỗi mỗi khi bạn bắt được, hãy ghi lỗi ra log một cách chi tiết nhất có thể để dễ dàng debug.

Ví dụ:

public function store(CategoryRequest $request)
{
    $this->authorize('create', Category::class);
    try {
        $data = $request->only('name');
        $this->cateRepository->create($data);
        
        return redirect()->route('categories.index')
            ->with('message', trans('admin.category.success_add'));
    } catch (Exception $ex) {
        Log::useDailyFiles(config('app.file_log'));
        Log::error($ex->getMessage());
        
        return redirect()->route('categories.index')
            ->with('error', trans('admin.category.error'));
    }
}

2.5. Define Exception Classes in Terms of a Caller’s Needs

Xác định các loại ngoại lệ theo nhu cầu từ phía gọi hàm

Phần này để cập đến việc xử lý lỗi đến từ bên thứ 3 (Có thể là API hoặc các dịch vụ bên thứ 3). Có rất nhiều cách để phân loại lỗi. Bạn có thể phân loại lỗi theo nguồn gốc của chúng: chúng đến từ thành phần nào, loại lỗi gì, thiết bị hay mạng hay lỗi do lập trình. Tuy nhiên, khi chúng ta nên định nghĩa các lớp ngoại lệ trong một ứng dụng, mối quan tâm quan trọng nhất của chúng ta nên là chúng bị bắt như thế nào.

Xem xét về một ví dụ về try catch cơ bản:

    ACMEPort port = new ACMEPort(12);
    try { 
        port.open();
    } catch (DeviceResponseException e) { 
        reportPortError(e);
        logger.log("Device response exception", e);
    } catch (ATM1212UnlockedException e) { 
        reportPortError(e); 
        logger.log("Unlock exception", e);
    } catch (GMXError e) { 
        reportPortError(e);
        logger.log("Device response exception");
    } finally { 
        ...
    }

Chúng ta có thể thấy đoạn code chỉ thực hiện mở 1 port từ bên thứ 3 mà xử lý nhiều ngoại lệ quá 😄. Có quá nhiều đoạn mã xử lý lỗi giống nhau được lặp lại và bạn không thể theo dõi. Trong hầu hết các trường hợp xử lý ngoại lệ, công việc chúng tôi làm là tương đối chuẩn bất kể nguyên nhân thực sự. Chúng tôi phải ghi lại lỗi và đảm bảo rằng chúng tôi có thể xử lý. Để giải quyết nó, ta sẽ tạo một lớp mới chuyên dụng xử lý riêng cho bên thứ 3.

Đoạn code viết lại như sau:

    LocalPort port = new LocalPort(12); 
    try {
        port.open();
    } catch (PortDeviceFailure e) {
        reportError(e);
        logger.log(e.getMessage(), e);
    } finally {
        ...
    }

Với lớp LocalPort là lớp chuyên dụng xử lý các ngoại lệ có thể xảy ra khi mở port ACMEPort

public class LocalPort {
    private ACMEPort innerPort;
    public LocalPort(int portNumber) { 
        innerPort = new ACMEPort(portNumber);
    }
    public void open() { 
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        } 
    }
    ...
}

2.6. Define the Normal Flow

Xác định Normal Flow

Nếu như bạn theo dõi các lời khuyên từ phần trước, bạn sẽ kết thúc với ý niệm tách biệt code logic nghiệp vụ và code xử lý lỗi. Phần lớn code của bạn sẽ bắt đầu trông giống như một thuật toán chưa được sắp xếp. Tuy nhiên, quá trình thực hiện điều này đẩy phát hiện lỗi đến các cạnh của chương trình của bạn (kiểu đẩy các phần xử lý error xuống dưới). Bạn bọc các "external API" để ném vào các error và định nghĩa các xử lý nó.

Nhưng đôi khi bạn không muốn bỏ qua hẳn nó mà vẫn muốn xử lý thêm thì sao? Xem xét ví dụ sau:

try {
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
    m_total += getMealPerDiem();
}

Trong ví dụ này, nếu bữa ăn được mở rộng, nó trở thành một phần của tổng số. Exception lại hỗn độn với code logic. Nó sẽ tốt hơn nếu bạn không dùng try-catch, không xử lý đặc biệt trong catch, code của bạn có thể trông đơn giản hơn, như thế này:

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

Làm thế nào để code có thể đơn giản như trên?

Chúng ta có thể thay đổi ExpenseReportDAO để nó luôn trả về một đối tượng MealExpense (Chi phí ăn uống). Nếu không có Chi phí ăn uống, nó trả về một đối tượng MealExpense trả về cho mỗi diem (công tác phí) như là tổng số của nó:

public class PerDiemMealExpenses implements MealExpenses {
    public int getTotal() {
        // return the per diem default
    }
}

2.7. Đừng trả về Null - Đừng truyền Null

Xem xét ví dụ sau: (Bad code)

public void registerItem(Item item) {
    if (item != null) {
        ItemRegistry registry = peristentStore.getItemRegistry();
        if (registry != null) {
            Item existing = registry.getItem(item.getID());
            if (existing.getBillingPeriod().hasRetailOwner()) {
            existing.register(item);
            }
        }
    }
}

Khi chúng ta trả về null, chúng ta chủ yếu tạo ra công việc cho chính chúng ta và đẩy vấn đề khi người gọi. Tất cả phải mất là một mất kiểm tra null để gửi một ứng dụng quay ra khỏi tầm kiểm soát. Bạn có để ý là trên thực tế khi có một check null, bạn lại phải tạo 1 block lồng bên trong 1 khối if? Điều gì sẽ xảy ra nếu persistentStore là null? Nó sẽ sinh ra một NullPointerException trong khi chạy và ai đó có thể bắt trên cùng hoặc là không?

Xem xét đoạn code sau:

List<Employee> employees = getEmployees();
if (employees != null) {
    for(Employee e : employees) {
        totalPay += e.getPay();
    }
}

Nên được sửa thành:

List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}

và trả về danh sách trống hơn là null:

public List<Employee> getEmployees() {
    if( .. there are no employees .. )
        return Collections.emptyList();
}

Nếu bạn code theo cách này, bạn sẽ giảm thiểu cơ hội cho NullPointerException và code của bạn sẽ rõ ràng hơn.

Tương tự, với việc bạn truyền vào Null cũng khiến bạn xử lý đau đầu không kém:

public class MetricsCalculator
{
    public double xProjection(Point p1, Point p2) {
        return (p2.x – p1.x) * 1.5;
    }
    ...
}

Chuyện gì sẽ xảy ta nếu truyền null vào như 1 đối số

calculator.xProjection(null, new Point(12, 13));

Chúng ta nhận được một NullPointerException tất nhiên. Vậy làm sao đến fix nó:

public class MetricsCalculator
{
    public double xProjection(Point p1, Point p2) {
        if (p1 == null || p2 == null) {
            throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection");
        }
        return (p2.x – p1.x) * 1.5;
    }
}

Liệu nó có ngon hơn không? nó có thể tốt hơn một chút là nhận về exception null nhưng hãy nhớ rằng chúng ta lại phải sinh ra một thứ xử lý cho InvalidArgumentException? Hàm xử lý đó nên làm gì? Nó có bất kì hành động nào tốt không?

Dùng assert trong Java:

public class MetricsCalculator
{
    public double xProjection(Point p1, Point p2) {
    assert p1 != null : "p1 should not be null";
    assert p2 != null : "p2 should not be null";
    return (p2.x – p1.x) * 1.5;
    }
}

Đoạn code trên chạy tốt, nhưng vẫn chưa giải quyết được vấn đề. Nếu có ai dó truyền vào null, chúng ta vẫn bị lỗi runtime error Trong hầu hết các ngôn ngữ lập trình, chưa có cách tốt nhất để xử lý với null, cái mà có thể gây ra một "tai nạn" cho người gọi.

Do đó, trong trường hợp này, cách tiếp cận tốt nhất là cấm truyền null như là một case mặc định.

Khi bạn lập trình, bạn viết mã với ý thức mặc định rằng một biến null trong paramenter truyền vào là dấu hiệu của bất thường thì sẽ ít gây ra lỗi hơn nhiều

3. Kết luận

  • Sử dụng các exception thay vì trả về giá trị để các lớp gọi nó đỡ phải xử lý tiếp.
  • Viết các đoạn code thành Try-Catch-Finally để dễ quan sát các xử lý code và ngoại lệ.
  • Cung cấp đầy đủ thông tin ngoại lệ nhất (có thể ghi log) để dễ dàng debug.
  • Nếu exception có code logic hoặc các exception gây ra bởi bên thứ 3, cân nhắc "wrapping"- đóng gọi lại trong một lớp ngoại lệ mới ta xây dựng.
  • Chưa có cách hoàn hảo nhất để xử lý với null, hạn chế truyền và nhận nó vào các hàm.

Hi vọng một vài lưu ý trên sẽ trên giúp bạn xử lý lỗi tốt hơn trong code của mình ❤️