Khởi Tạo Module
Trong thiết kế theo mô-đun, bài toán khó nhất chính là việc xác định thứ tự khởi tạo và hủy module. Nếu không xét đến việc tải/xóa module động, chúng ta có thể đơn giản hóa thiết kế đáng kể.
Vấn đề hủy module thực ra lại đơn giản nhất. Việc thoát an toàn chỉ cần đảm bảo giải phóng tài nguyên hệ thống. Đây là công việc của hệ điều hành - khi tiến trình bị kết thúc, toàn bộ tài nguyên sẽ được thu hồi tự động. Vì vậy tôi chọn cách không làm gì cả.
Khởi tạo lại phức tạp hơn. Tôi tách riêng quá trình tải module và khởi tạo module. Giai đoạn tải là tĩnh, không có sự phụ thuộc lẫn nhau. Về bản chất, đây chỉ là quá trình nạp đoạn mã và dữ liệu vào bộ nhớ (trong triển khai, tuyệt đối tránh việc chạy mã ngầm định như việc khởi tạo tự động các đối tượng toàn cục trong C++).
Quy trình khởi tạo được thực hiện theo cơ chế lười biếng (lazy initialization) - tức là chỉ khởi tạo khi cần dùng. Mỗi module khi khởi tạo sẽ tự động gọi hàm khởi tạo của các module phụ thuộc (việc này là bắt buộc, nếu không sẽ không lấy được handle của module khác để sử dụng). Nhờ đó thứ tự khởi tạo giữa các module sẽ tự động được sắp xếp hợp lý. Miễn là không có vòng lặp phụ thuộc khởi tạo, hệ thống sẽ hoạt động ổn định.
Trong thực tế, chúng tôi gặp phải vấn đề: một số module cần tham số để khởi tạo. Khi cần truyền tham số, quy trình khởi tạo trở nên phức tạp. Ví dụ: module kết xuất 3D cần handle cửa sổ để khởi tạo, nhưng handle này chỉ có được sau khi module quản lý cửa sổ được khởi tạo và tạo ra cửa sổ. Các module khác không thể tự tạo cửa sổ được.
Ở phiên bản trước, chúng tôi thiết kế một vùng dữ liệu chung trong module quản lý để trao đổi thông tin khởi tạo. Thiết kế kiểu “registry” này khiến tôi luôn cảm thấy bất an. Trong lần refactor này, tôi quyết định loại bỏ hoàn toàn.
Khi refactor đến hôm nay, vấn đề cũ vẫn tái diễn. Trong lúc thư giãn tối qua, sau khi đánh bại con Rồng Sấm (Rathalos) trong Monster Hunter 2, tôi bỗng nảy ra ý tưởng giải pháp cực kỳ đơn giản.
Đầu tiên, hàm khởi tạo của mọi module vẫn không cần nhận tham số. Giống như thiết kế Singleton, các module tự khởi tạo bản thân. Người dùng đầu tiên của Singleton sẽ kích hoạt việc khởi tạo lười biếng. Nếu Singleton cần tham số để xây dựng, độ phức tạp mã sẽ tăng vọt - điều này rõ ràng không hợp lý.
Một giải pháp khác là để module tự đọc cấu hình bên ngoài để khởi tạo. Đây là cách tiếp cận theo nguyên tắc thiết kế module: “Giải quyết vấn đề nội bộ, giảm truyền dữ liệu”. Tuy nhiên không phải lúc nào cũng áp dụng được vì nhiều công việc phải làm theo từng bước, cần kết quả bước trước để thực hiện bước sau.
Thực ra, chúng ta chỉ cần một module độc lập đảm nhiệm việc kết nối các giai đoạn khởi tạo.
Lấy lại ví dụ về phụ thuộc giữa module kết xuất 3D và cửa sổ. Về bản chất, các module cấp cao không cần trực tiếp điều khiển việc khởi tạo các module nền tảng. Chúng chỉ cần đảm bảo các module nền tảng đã sẵn sàng khi chúng khởi tạo. Vì vậy, ta tạo một module độc lập để thực hiện việc khởi tạo cần thiết: tạo một cửa sổ giả lập, lấy handle và truyền cho module kết xuất 3D. Điều này đã đủ cho mục đích kiểm thử đơn vị. Khi ứng dụng phức tạp hơn, chỉ cần thay thế module này là xong. Tôi đặt tên module này là launch3d.
Chương trình kiểm thử chỉ cần phụ thuộc vào launch3d là có thể đảm bảo các module nền tảng được khởi tạo đầy đủ.
Câu chuyện này mang đến bài học: Khi mở engine cho developer thứ ba, không nhất thiết phải cung cấp giao diện tầng cao. Toàn bộ thiết kế nên phẳng, cho phép tùy biến và thay thế cả các thành phần tầng thấp lẫn tầng cao. Đó mới là thiết kế tốt thực sự.