Hành trình đi tìm mã nguồn dễ bảo trì và con đường đến ECS


Dịch từ bài viết
The Quest for Maintainable Code and The Path to ECS

Mục lục

Lời nói đầu

Trong bài viết có thể gây tranh cãi này, tôi sẽ giải thích vì sao tôi tin ECS là một kiểu mẫuparadigm tuyệt vời để phát triển một bộ mã nguồn dễ bảo trì hơn (do đó ít tốn kém hơn). Tôi cũng sẽ khảo sát ý tưởng chuẩn hoá các phương án tối ưubest practice bằng cách áp dụng bộ nguyên lý SOLID, dù thoạt nhìn bộ nguyên lý này có vẻ không phù hợp với thiết kế ECS cho lắm.

Bài viết này tiếp nối luồng tư tưởng đã hình thành từ các bài trước (có thể có ích cho bạn, nhưng không nhất thiết phải đọc), và trong những năm qua đã mở rộng hơn nhờ vào phản hồi chi tiết từ các đồng nghiệp của tôi trong lúc phát triển 2 tựa game thương mại, cũng như phản hồi từ cộng đồng sử dụng Svelto. Luồng tư tưởng này chủ yếu bắt nguồn từ ý kiến cá nhân tôi, vậy nên tôi sẽ để ngỏ khả năng tranh luận, vì phản hồi từ người khác cũng là một phần trong cái tiến trình tôi dùng để đẩy hệ lập luận này tiến xa hơn nữa. Vì vậy lâu lâu tôi cũng sẽ cập nhật nội dung bài viết này, nên lâu lâu bạn mở ra đọc lại cũng là một ý hay.

Dành cho ai?

Bài viết này dành cho người đã biết về thiết kế Entitythực thể-Componenthợp phần-Systemhệ thống và muốn tìm hiểu các lợi ích khi vận dụng nó vào những mục đích khác, ngoài mục đích tăng hiệu năng nhờ lập trình theo thiết kế hướng dữ liệu.

Bài viết cũng nhắm đến những ai có hứng thú tìm hiểu các giải pháp thay thế lập trình hướng đối tượng nhằm phát triển một bộ mã nguồn dễ bảo trì hơn.

Thôi, dài quá!

Mục đích bài viết này là thiết lập một nền tảng cho hệ lập luận của tôi về kiểu mẫu ECS, và giải thích vì sao ECS đã thuyết phục được tôi rằng nó có thể cung cấp một khung phát triểnframework đơn giản hơn để tạo ra một mã nguồn dễ bảo trì hơn so với các giải pháp hướng đối tượng quá mức cồng kềnh khác.

Dựa trên kinh nghiệm sử dụng ECS trong phát triển các game dạng dịch vụgames as a service đã thương mại được vài năm, tôi giả thuyết rằng trao cho lập trình viên một bộ quy tắc thiết kế vừa khắt khe hơn cũng vừa đơn giản hơn sẽ giúp họ tập trung hơn vào nhiệm vụ phát triển giải thuật chứ không phải vào hình thức lập trình ra giải thuật đó. Giảm bớt trách nhiệm phải quyết định hình thức lập trình sao cho trừu tượng, dễ tái sử dụng, dễ bảo trì có thể giúp ta giảm được số nợ kĩ thuật tích tụ theo thời gian, cũng như đạt được một quy trình phát triển gọn gàng hơn.

Tôi cũng cảnh báo rằng chỉ ứng dụng ECS thôi thì không đủ để đảm bảo ta sẽ đạt được các mục tiêu đó, vì nó có thể bị dùng sai, nhất là khi ta cố bắt tư duy hướng đối tượng phải thích nghi với ECS. Vì lý do này, tôi cũng diễn lại bộ nguyên lý phát triển phần mềm SOLID theo hướng ECS, vì cá nhân tôi quan sát thấy về cơ bản bộ nguyên lý này rất có ích khi cần phát triển một bộ mã ECS dễ bảo trì. Nhưng tôi không phải người duy nhất thử làm chuyện này, tôi nghĩ bạn cũng nên xem qua bài diễn thuyết thú vị này của Maxim Zacks (tác giả Entitas).

Thế nào là mã nguồn dễ bảo trì?

Tôi định nghĩa mã nguồn dễ bảo trì là một bộ mã dễ đọc, dễ mở rộng, dễ cải tiếnrefactor. Nó thường nằm trong một nền mãcodebase vẫn đang tiến hoá và không bao giờ ngừng biến đổi. Ta có thể tìm thấy loại nền mã này từ các sản phẩm dạng dịch vụ đang vận hành và vẫn sẽ phục vụ người dùng trong nhiều năm nữa. Vì các sản phẩm này phải tiến hoá để giữ chân người dùng, bản thân mã nguồn của nó cũng liên tục biến đổi qua vô số vòng lặp phát triển. Cá nhân tôi dám chắc trong kiểu môi trường này, các chỉ dẫn lập trình lẫn xét duyệt mã nguồn là không đủ để đảm bảo bộ mã sẽ không suy sụp theo thời gian với kết cục là một đống nợ kĩ thuật tốn kém khó quản lý.

Lịch sử cho thấy các công ty làm game đã và đang đánh giá thấp chi phí ($$$) thật sự của công tác bảo trì mã nguồn mà chỉ chú tâm vào chi phí phát triển. Tuy nhiên, giờ đây ai cũng biết rằng bảo trì mã nguồn thường tốn kém hơn việc phát triển nó 1 (càng đúng hơn với các game dạng dịch vụ), và đó là lý do vì sao ta cần phải lập trình cho thật tốt ngay từ đầu. Đừng bao giờ rơi vào sai lầm viết ra các “bản thử nghiệmprototype” nhưng không bao giờ sửa lại cho đúng trước khi đưa vào sản phẩm chính thức. Nói vậy nghĩa là, tôi mong các bạn nhận ra rằng bản thân công đoạn “sửa lại” cũng đã bao hàm một lượng chi phí trong đó, vậy sao ta không làm thật tốt ngay từ đầu? Người ta thường nghĩ phát triển các đoạn mã hiệu suất caoefficient mà không chứng minh được tính hữu dụng là một khoản đầu tư trả trước đắt đỏ. Nhưng điều đó chỉ đúng khi ta buộc phải nghĩ về cách thiết kế mã nguồn thay vì tập trung toàn lực vào mục đích hoạt động của nó.

Về bản chất, hầu hết các lập trình viên đều lười. Nhưng nếu áp dụng đúng chỗ, tính lười nhác đó lại thật sự có ích. Vì một lập trình viên lười nhác có khuynh hướng chọn giải pháp đơn giản nhất cho một vấn đề. Một lập trình viên lười nhác gạo cội cũng sẽ biết cách triển khai một đoạn mã sao cho hiệu quả nhất nhưng đỡ tốn công nhất. Tuy vậy, có một việc mà lập trình viên lười nhác thường không làm: họ không nghĩ về hậu quả phát sinh từ các giải pháp được chọn. Vì điều quan trọng nhất với họ là làm sao hoàn thành công việc sớm nhất có thể nên thường không xét đến các hệ quả dài hạn ảnh hưởng đến khả năng bảo trì.

Tuy nhiên, khi việc dự đoán các hệ quả này có vẻ quá rắc rối, lập trình viên có thể sẽ rơi vào hai cái bẫy: xây dựng quá mức cần thiết đề phòng mọi tình huống có thể xảy ra hoặc không thèm quan tâm nữa, cho đến khi mọi thứ đã quá trễ khiến bất cứ lựa chọn đổi hướng nào cũng trở nên quá tốn kém và ta buộc phải chấp nhận các thoả hiệp không hiệu quả.

Đây là lý do vì sao tôi tin rằng tốt hơn hết ta nên sử dụng các khung phát triển cứng rắn hơn. Vài người đã nhầm lẫn khi tôi dùng tính từ “cứng rắnrigid” cho khung phát triển. Cứng rắn có nghĩa là khung phát triển (hoặc thậm chí ngôn ngữ lập trình) phải cấp sẵn một hoặc một vài giải pháp cho một vấn đề cụ thể. Cứng rắn cũng có thể hiểu là “khắt khestrict”, tức là “không có khả năng thoả hiệp, không linh hoạt” hoặc “không thể bị thay đổi hay bị sửa lại cho phù hợp”. Tóm lại là nó không thể bị uốn nắn, làm cho thích nghi, hay bị bắt phải thoả hiệpcompromise để tránh bị dùng sai vì những lầm lẫn phát sinh từ kiến thức và giả định của lập trình viên.

Theo đó, khi đề nghị một lập trình viên dùng một ngôn ngữ đa kiểu mẫu như C++ hay C# để phát triển tựa game nào đó thì kết quả thu được sẽ là một nồi lẩu thập cẩm đủ các loại giải pháp bị áp dụng lung tung. Ai cũng biết lập trình viên lười nhác thường có tiếng thích sao chép các đoạn mã đã chạy tốt ra những chỗ khác, thành ra chẳng có gì khó để hình dung bộ mã nguồn sẽ lộn xộn đến mức nào nếu ta không áp đặt các lối thứcdirection cứng rắn.

Nguồn gốc vấn đề ở đâu?

Hệ lập luận của tôi đã hình thành từ khá lâu khi tôi nhận ra dù bản thân rất hài lòng với các giải pháp đang dùng, tôi lại không thể tái sử dụng chúng vì không tìm được mẫupattern chung nào giữa các vấn đề này. Tôi cứ phải tạo lại thứ đã có hết lần này đến lần khác - một vấn đề thường gặp trong ngành này. Mẫu ở đây không phải các mẫu để phát triển giải thuật, mà là các mẫu thiết kế để triển khai mã nguồn sao cho dễ đọc, dễ mở rộng, dễ cải tiến.

Cuối cùng tôi kết luận rằng trong lập trình hướng đối tượng, các vấn đề thiết kế luôn xoay quanh hoạt động giao tiếp liên đối tượnginter-objects communication. Bất cứ khi nào cần thiết lập giao tiếp giữa các đối tượng, dù thông qua sự kiệnevent, thông điệpmessage, trình điều phốimediator hay bất cứ cách nào khác, ta luôn phải có một hiểu biết nhất định về các đối tượng liên quan. Theo tôi thấy, hoạt động giao tiếp này chính là nguồn cơn tạo ra mọi rắc rối trên đời. Tất cả các dạng trừu tượng lẫn các phương án tương đối tốt nhằm giảm độ phụ thuộccoupled đều có mục đích tối thượng là tăng khả năng kiểm soát hoạt động giao tiếp liên đối tượng này.

Trong C++, bất kỳ dạng giao tiếp liên đối tượng nào cũng có đầu cuối là các lời gọi hàm trực tiếp trên từng đối tượng. Bởi vậy tôi đã phân vân: liệu bao đóngencapsulation có phải một phần của vấn đề không?

Bao đóng tốt hay xấu?

Trong các bài viết trước, tôi có nhắc đến bao đóng rất nhiều, nhưng tôi đã nhận ra cách tôi hiểu về nó lại quá hạn hẹp. Vấn đề nằm trong cách tôi giải thích, nhưng không phải vì một lỗi nào đó trong khái niệm về bao đóng.

Trong Lập trình hướng Đối tượngObject-oriented Programming, bao đóng dữ liệu hoạt động tốt với các kiểu dữ liệu trừu tượngabstract. Lúc tạo ra một cấu trúc dữ liệu, ta thường tạo thêm các hàm công khaipublic để quản lý cấu trúc đó. Bao đóng rất phù hợp để che giấu sự phức tạp bên trong. Đây là mục đích của khái niệm bao đóng và lập trình hướng đối tượng. Ta có thể đóng gói tính phức tạp của một lớp đối tượngclass bên trong một “hộp đen” mà không cần biết mục đích của nó là gì. Bao đóng chính là thứ một lúc nào đó sẽ thúc đẩy các lập trình viên hướng thủ tục chuyển sang hướng đối tượng.

Tuy nhiên, bao đóng không ngăn đối tượng khỏi bị dùng sai. Nếu tránh được bất kỳ dạng giao tiếp liên đối tượng nào khi phát triển phần mềm, ta đã có thể viết ra các chương trình có tính bao đóng hoàn hảo. Nếu có thể thiết kế sao cho một lớp đối tượng chỉ giải quyết một vấn đề duy nhất, ta đã có được kịch bản hoàn hảo cho một nền mã dễ bảo trì. Nền mã đó sẽ rất gọn gàng, có tính mô-đun cao và hoàn toàn không phụ thuộc lẫn nhau.

Trên thực tế, kịch bản này không thể nào xảy ra, vì bất cứ phần mềm hữu dụng nào cũng không thể hoạt động nếu không có hoạt động giao tiếp. Hệ quả là bộ mã nguồn tuyệt đẹp kia nhiều khả năng sẽ trở thành một nồi lẩu thập cẩm vì các đối tượng phải kết nối để giao tiếp với nhau.

Nhưng bao đóng có thật sự dự phần vào vấn đề này không?

Có thể ví von như sau: bao đóng cho phép ta chế tạo một chiếc xe vận hành hoàn hảo, nhưng không thể ngăn tài xế phá huỷ nó. Lập trình hướng đối tượng không thể ngăn ta dùng sai các đối tượng bao đóng hoàn hảo đó. Chiếc xe có hàm ThắngLại()TăngTốc(), nhưng chỉ một mình tính bao đóng không thể ngăn tôi dùng hàm TăngTốc() thay vì ThắngLại(). Hoặc một ví dụ ngớ ngẩn hơn nhằm chứng minh quan điểm này: một lớp đối tượng nọ có hàm ĐầuTiên() và hàm TiếpTheo(), nhưng không gì có thể ngăn tôi gọi hàm TiếpTheo() trước khi gọi hàm ĐầuTiên().

Khái niệm bao đóng sinh ra từ nhu cầu muốn giới hạn phạm viscope của các trạng thái có thể truy xuất toàn cụcglobal accessible. Tuy nhiên, xét theo định nghĩa bao đóng chuẩn, các đối tượng singleton vẫn có thể được truy xuất toàn cục mà không hề mất đi tính bao đóng. Nghịch lý là ta có thể truy cập các đối tượng đã được bao đóng hoàn hảo này ở bất cứ đâu.

Sự thật là các phương thức công khai của các lớp bao đóng đúng chuẩn chỉ ngăn không cho ta thiết lập các trạng thái không hợp lệ, mà không hề ngăn các trạng thái hợp lệ bị thiết lập không đúng thời điểm. Vấn đề không còn liên quan đến bao đóng dữ liệu nữa, mà liên quan đến quyền kiểm soátcontrolquyền sở hữuownership trên các đối tượng.

Logic trong các hàm công khai có nhiệm vụ kiểm soát các trạng thái của đối tượng, nhưng vì ta có thể gọi các hàm công khai này ở bất kỳ đâu, liệu bản thân đối tượng có thật sự kiểm soát được các trạng thái của nó hay không? Bằng cách nào đối tượng có thể ngăn trạng thái nội tại của nó khỏi bị thiết lập giá trị hợp lệ ở các thời điểm không ngờ tới nếu ta có thể gọi các hàm công khai này bất cứ lúc nào?

Mặc dù trong ngữ cảnh đa luồng và song song các hệ quả không mong muốn vì dùng sai này chắc chắn sẽ xảy ra, ta vẫn cần xem việc triển khai mã nguồn dễ bảo trì là điều quan trọng cơ bản.

Mọi lý thuyết thiết kế tốt đều xoay quanh vấn đề làm sao ngăn ngừa các phương thức công khai bị sử dụng lung tung. Hãy nhìn lại thiết kế Singleton một chút. Singleton là loại đối tượng có thể được truy xuất toàn cục, nhưng bên trong chúng lại chứa các trạng thái bí mậtprivate có thể bị biến đổi bởi các hàm công khaipublic.

Thậm chí không cần ví dụ nào, hãy nhớ lại xem đã bao nhiêu lần bạn phải gỡ lỗidebug các trạng thái nội tại của đối tượng singleton vì chúng bị thay đổi không đúng dự tính? Tuy nhiên, ngay cả khi không dùng Singleton, cái gì có thể ngăn ta truyền các đối tượng lung tung khắp nơi? Truyền phụ thuộcdependency injection quá mức chính là thứ làm tăng độ phụ thuộc trong mã nguồn và cản trở công tác bảo trì. Tôi đã từ bỏ giải pháp ngăn chứa IoCIoC container là vì nó rất dễ bị dùng sai, bị lạm dụng nếu bản thân ta không thấm nhuần tư tưởng đảo ngược quyền kiểm soát. Các phiên bảninstance độc nhất có thể truyền đến mọi nơi quá dễ dàng, đến nỗi, dù nhìn thế nào chúng cũng không khác gì các đối tượng singleton, vậy nên hệ quả tương tự vẫn sẽ xảy ra dù về cơ bản chúng không thuộc một lớp static nào.

Cho nên, bản thân thiết kế Singleton không phải vấn đề. Vấn đề là không gì có thể kiểm soát quyền sở hữu các đối tượng singleton này. Bất kỳ thiết kế nào gặp phải các vấn đề tương tự, kể cả các lớp static có thể thay đổi trạng thái nội tại cũng đều như thế thôi. Tôi nói về Singleton để cho bạn biết đâu là mẫu thiết kế tệ nhất về khoản kiểm soát đối tượng. Thật ra tôi chưa bao giờ dùng Singleton trong game nên tôi không có ví dụ thực tế nào về nhược điểm này, nhưng tôi đã chứng kiến Event Buskênh sự kiện (hệ thống truyền tin dựa trên Singleton) gây ra các hiệu ứng tồi tệ vì giới hạn tương tự. Vấn đề thật sự xung quanh thiết kế Singleton là việc mã nguồn phải phụ thuộc vào cách triển khai cụ thểimplementation chứ không phụ thuộc vào dạng trừu tượng hoá của chúng, nhưng tôi sẽ không bàn thêm về chủ đề này nữa.

Lấy SOLID làm nền tảng lập luận

Tôi thấy rõ rằng hầu hết các mẫu thiết kế của Bộ TứGang of Four bằng cách này hay cách khác đều tìm kiếm một giải pháp kiểm soát quyền sở hữu đối tượng. Hạn chế tôi gặp phải với các mẫu thiết kế này là ta không thể dùng chúng nếu không hiểu rõ vấn đề mà chúng phải giải quyết 2. Giải pháp duy nhất giúp tôi hiểu được vì sao Bộ Tứ tồn tại là nghiên cứu và hiểu rõ những điều đơn giản hơn: bộ nguyên lý SOLID, mà theo tôi chính là nền tảng để mọi mã nguồn OOP có được một thiết kế tốt.

Tôi sẽ liệt kê các nguyên lý SOLID sau, trong lúc tiến hành kiểm thử tính hữu hiệu của chúng khi ứng dụng vào ECS. Còn bây giờ, tôi muốn nói về nguyên lý duy nhất có mục đích giải quyết vấn đề quyền sở hữu, cùng các hệ quả khi áp dụng nó: nguyên lý Nghịch đảo Phụ thuộcDependency Inversion.

Trừu tượng, Đảo ngược Kiểm soát và Nguyên tắc Hollywood

Thật ra thuật ngữ “trừu tượng” sẽ có nghĩa khác nhau tuỳ theo ngữ cảnh. Trong ngữ cảnh OOP, ta thường trừu tượng hoá các triển khai cụ thể bằng cách sử dụng giao diệninterface. Những chỗ sử dụng các giao diện này sẽ không phụ thuộc vào cách triển khai cụ thể nữa. Trong kiến trúc phần mềm, khi nói về các cấp trừu tượngabstraction layer thì “trừu tượng” dùng để xác định tính khái quátgeneric và khả năng tái sử dụng của phần logic trong mối tương quan với các phần mã chuyên biệtspecialized nhất.

Dù các lớp đối tượng (một dạng của bao đóng) rất hữu dụng khi cần che giấu tính phức tạp, chúng lại đặt ra một vấn đề mới: bằng cách này hay cách khác, có khả năng chúng sẽ phải phụ thuộc vào các phương thức có thể truy xuất của chính mình. Tôi dùng từ “có thể truy xuấtaccessible” chứ không phải “công khaipublic” vì, giả dụ, khi ta truyền một trình điều phốimediator hoặc một trình quan sátobserver vào một lớp để đăng ký phương thức bí mật của lớp đó, ta đã mở ra khả năng sử dụng phương thức bí mật đó như một phương thức công khai. Dù có bao nhiêu cấp trừu tượng được thêm vào đi nữa, đến cuối cùng phương thức đó vẫn được gọi bởi một đối tượng khác.

Có thể bạn không đồng tình với điểm này, nhưng tôi đoán ta sẽ có nhu cầu vận dụng trừu tượng hoá vào giải quyết các vấn đề phát sinh vì hàm công khai. Tất nhiên, trừu tượng hoá thiên về cách lập trình sao cho không phụ thuộc vào bất kỳ triển khai cụ thể nào, nhưng liệu nó có làm giảm độ phụ thuộc giữa các đối tượng không? Xét trên khía cạnh quyền sở hữu đối tượng thì phụ thuộc vào một giao diện không khác mấy so với phụ thuộc vào một triển khai cụ thể. Còn xét trên khả năng độc lập khỏi phần mã chuyên biệt thì thật sự trừu tượng hoá lại tạo khác biệt rất lớn: công tác cải tiến mã nguồn sẽ đơn giản hơn nhiều vì ta chỉ cần thay đổi cách triển khai các đối tượng là đã có thể thay đổi hành vi của chương trình. Nhưng điều đó không có nghĩa mã nguồn sẽ giảm được độ phụ thuộc.

Và đó chính là vấn đề mà nguyên lý Nghịch đảo Phụ thuộcDependency Inversion cố giải quyết bằng các ứng dụng của nó. Nguyên lý này phát biểu như sau:

  1. Mô-đun cấp cao không nên phụ thuộc vào mô-đun cấp thấp. Cả hai phải phụ thuộc vào các thành phần trừu tượng.
  2. Thành phần trừu tượng không được phụ thuộc vào các triển khai cụ thể. Triển khai cụ thể nên phụ thuộc vào các thành phần trừu tượng.

Trong lúc trừu tượng ở đây được dùng theo định nghĩa OOP, thì mẹo để hiểu các phát biểu trên nói gì chính là nghiên cứu một ứng dụng của nguyên lý này trên khía cạnh các cấp trừu tượng. Ví dụ, trong trường hợp đơn giản nhất, ta có thể xem phần mã chương trìnhapplication code cấp thấp và phần mã khung phát triểnframework code cấp cao là hai cấp trừu tượng tách biệt. Tuy nhiên tách ra các phần lớn như vậy không phải lúc nào cũng tối ưu, thường thì tách nhỏ hơn nữa sẽ có ích hơn. Ta sẽ sớm thấy lý do vì sao.

Ta đang có một nền mã được chia thành nhiều cấp, nguyên lý Nghịch đảo Phụ thuộc đề xuất rằng cấp trừu tượng cao hơn không được phụ thuộc vào các giao diện khai báo ở cấp trừu tượng thấp hơn. Nói đơn giản hơn thì điều đó có nghĩa gì?

Giả dụ, trong một khung phát triển nọ, ta có một phương thức tổng hợp ảnh tên Renderer trong lớp Rendering. Phương thức Renderer nhận vào một giao diện IRenderable. Giao diện IRenderable này cũng nằm trong khung phát triển đó chứ không nằm trong mã nguồn chương trình. Mã nguồn chương trình chỉ cần triển khai một đối tượng bằng giao diện này (thường là khai báo một phương thức tên Render) để khung phát triển có thể dùng đối tượng đó theo cách của nó.

Phần mã trừu tượng hơn phải cung cấp các giao diện, và phần mã ít trừu tượng hơn phải sử dụng các giao diện đó.

Hệ quả trực tiếp đầu tiên của nguyên lý này chính là ứng dụng Đảo ngược Kiểm soátInversion of Control: khung phát triển nắm quyền kiểm soát (và quyền sở hữu) các đối tượng sinh ra bởi phần mã ít trừu tượng hơn. Nói chung, ta không cần quan hệ giữa các đối tượng trong cùng một cấp trừu tượng, ta chỉ cần quan hệ giữa các cấp trừu tượng mà thôi.

Khi ứng dụng IoC ta đã làm tăng tính dễ bảo trì lên rất nhiều rồi, vì ta đã đặt ra các quy tắc kiểm soát và sở hữu đối tượng. Nhưng còn nhiều thứ phải xem xét nữa. IoC là một mẫu thiết kế chứ không phải một nguyên lý. Khi mới tiếp cận, IoC khá khó hiểu, vì các bài viết khác nhau có vẻ đang nói về các dạng IoC khác nhau. Tuy nhiên tôi nghĩ ta có hai dạng chính mà tôi gọi là Đảo ngược Kiểm soát LuồngInversion of Flow ControlĐảo ngược Kiểm soát Khởi tạoInversion of Creation Control.

Đảo ngược Kiểm soát Khởi tạo thì đơn giản: khi ứng dụng nó, ta không cần phải tạo ra bất kỳ phụ thuộc nào bên trong đối tượng, vì các phụ thuộc này luôn phải được tạo ra rồi truyền đi từ một gốc kết hợpcomposition root. Ta có thể có nhiều gốc kết hợp trong một chương trình, tuy nhiên đây lại là một câu chuyện khác đã được bàn trong các bài viết cũ của tôi.

Trong khi đó Đảo ngược Kiểm soát Luồng mới là cái ta đang bàn đến. Có thể nói vui đó là Nguyên tắc Hollywood: “đừng gọi cho chúng tôi, chúng tôi sẽ gọi cho bạn”. Vậy nghĩa là sao?

Nghĩa là các cấp trừu tượng cao hơn không chỉ cung cấp các giao diện để đối tượng triển khai, mà cuối cùng sẽ kiểm soát các đối tượng đó bằng cách gọi phương thức công khai của chúng thông qua các giao diện kia.

Quan trọng là ta phải hiểu được rằng trách nhiệm của phần logic hoạt động thực tế đã được đẩy lên cấp cao hơn. Các đối tượng được triển khai cụ thể vẫn đảm bảo tính bao đóng và logic nội tại của chúng vẫn có thể phức tạp, nhưng phạm vi của chúng đã bị giới hạn bởi các giao diện được khung phát triển quy định. Chính khung phát triển sẽ quyết định các đối tượng này sẽ được dùng vào việc gì và cuối cùng sẽ cho phép chúng giao tiếp với nhau.

Đây là lúc ta có thể sẽ băn khoăn: liệu chỉ bàn về chương trình và khung phát triển thì có hạn hẹp quá không? Sẽ ra sao nếu tôi muốn áp dụng IoC vào các phần logic không được triển khai sẵn trong khung phát triển? Đây là lý do vì sao tách thành các khối lớn lại phản tác dụng vì nguyên lý này cần được áp dụng sâu rộng bằng cách thêm nhiều cấp trừu tượng mịn hơn vào chương trình.

Ví dụ, dù hệ thống Rendering kiểm soát phần logic chung của các tính năng đồ hoạ, nó lại không biết gì về phần logic chung của nhân vật có khả năng tấn công. Lúc này ta có thể thiết kế ra một lớp CharacterAttackManager để kiểm soát tất cả các đối tượng triển khai giao diện IAttack. Ví dụ này dẫn ngay đến phần kế tiếp:

Đảo ngược Kiểm soát Luồng trong OOP

IoC có đầy đủ nguyên liệu để tạo ra mã nguồn thật sự dễ bảo trì. IoC đề cao trừu tượng hoá và mô-đun hoá. Tất cả các mô-đun bất biến đều được bao đóng ở các cấp cao hơn và đều độc lập với nhau. Các đối tượngobject có thể triển khaiimplement nhiều giao diệninterface để tạo ra một tổ hợp gồm nhiều hành vibehaviour khác nhau. Nhờ đó ta sẽ biết rõ các cách kiểm soát những đối tượng này cũng như cái gì sẽ sử dụng các giao diện đó.

Có rất nhiều cách triển khai IoC, nhưng cách áp dụng tốt nhất tôi biết chính là mẫu Chiến thuậtStrategy pattern của Bộ Tứ, hoặc tốt hơn nữa: mẫu Hợp phầnComponent pattern trong cuốn Game Programming Patterns. Trên thực tế, trong khi mẫu Chiến thuật thường xác lập quan hệ 1:1 giữa bên điều phốicontroller và bên “triển khaiimplementation”, thì mẫu Hợp phần lại có quan hệ 1:N mạnh mẽ hơn khi cho phép một trình quản lýmanager kiểm soát nhiều hợp phầncomponent.

Trong khi mẫu Chiến thuật có thể thấy trong các thiết kế mức khung phát triển như Model-View-Controller, mẫu Hợp phần lại thường gặp trong các hệ thống phát triển game như Unreal (theo tôi nhớ là vậy) và Unity (lớp MonoBehaviour chính là một hợp phần).

Trong kịch bản này, lớp Renderer không chỉ vẽ một đối tượng, mà phải vẽ tất cả các đối tượng IRenderable, vì như vậy sẽ có ích hơn rất nhiều.

Trong hệ thống phát triển game, mẫu thiết kế Hợp phần luôn được áp dụng thông qua các lớp Manager. Mỗi Manager sẽ lặp qua danh sách các đối tượng triển khai cùng một giao diện tại nhiều thời điểm khác nhau trong luồng xử lý một khung hình. Ví dụ PhysicManager, UpdateManagerRenderingManager là 3 lớp quản lý khác nhau và hàm Tick() của mỗi lớp sẽ được gọi lần lượt theo thứ tự. Mỗi hàm Tick() này sẽ gọi tới hàm PhysicUpdate(), hoặc Update() hoặc Render() của mọi đối tượng được đăng ký với Manager tương ứng.

Tiến trình của hệ thống MonoBehaviour trong Unity cũng có chút tương đồng. Mỗi lớp kế thừa MonoBehaviour có thể tuỳ ý triển khai các phương thức như Awake(), Start(), Update()FixedUpdate(). Tuy nhiên phương thức RenderUpdate() lại không tồn tại. Vì sao? Trong khi Unity giao cho ta triển khai phần logic trong các hàm có khả năng tuỳ biến kia, nó lại không cần ta xác định cách vẽ đối tượng, bởi bản thân Unity đã cấp sẵn các phần logic cần thiết cho việc này rồi. Nó chỉ cần dữ liệu lấy được từ hệ thống MonoBehaviour mà thôi.

Hiển nhiên, tuy Unity có thể cấp sẵn nhiều lớp quản lý “tổng quát” nhất có thể, không bao giờ nó có thể cấp sẵn mọi lớp quản lý cho mọi tình huống một tựa game có thể có, và đó là tại sao Unity phải giao việc triển khai các phần logic này cho ta.

Tuy nhiên, nếu khung phát triển (Unity) và phần logic người dùng không phân biệt rạch ròi như vậy, liệu ta có thể triển khai tất cả logic trong game dưới dạng các lớp quản lý không? Dĩ nhiên là được, và đó chính là IoC.

Một khi tất cả các hành vi có thể tổ hợp (logic trong game) được triển khai dưới dạng các lớp quản lý, bản thân các hợp phần sẽ không cần thực hiện bất cứ logic nào nữa. Toàn bộ phần logic được triển khai dưới dạng các hành vi ít nhiều trừu tượng (hoặc tổng quát) được bao đóng trong các lớp quản lý 3, sau được kết hợp với nhau bằng cách sử dụng các giao diện mà các lớp quản lý này cung cấp. Khi đó các hợp phần sẽ biến thành các gói dữ liệu đơn thuần, tạo thành… ECS!

Cuối cùng thì tôi cũng giải thích xong toàn bộ con đường đến ECS. Không chỉ là vấn đề hiệu năng (dù vẫn cần đó!), mà còn là vấn đề đảo ngược quyền kiểm soát tuyệt đối!

ECS có 3 khái niệm nền tảng:

Hệ thốngSystem chính là các lớp quản lý. Về bản chất, đây thường là một khối logic phi trạng thái được thực thi trên các Hợp phần của Thực thể. Phần này chứa toàn bộ các logic cần thiết để thực thi một hành vi nhất định, vậy nên chúng chỉ cần dữ liệu của các thực thể - chính là các Hợp phần.

Thực thểEntity thường được xem là các mã định danhid. Tôi thì cho rằng ta nên hình dung thực thể là một tập các hợp phần đã được xác định rõ. Nó không khác gì các đối tượng không chứa logic. Dữ liệu của chúng chính là một tập các:

Hợp phầnComponent thường là các cấu trúc dữ liệu được lưu trữ liên kế nhau trong bộ nhớ tuỳ theo kiểu dữ liệu của chúng.

Trước khi bàn đến phần thứ hai, nếu bạn muốn tìm hiểu một góc nhìn khác nhưng cũng khá giống tôi, hãy xem qua bài viết (và video liên quan) của Brian Will: Object-Oriented Programming: A Disaster Story.

Áp dụng SOLID vào ECS như thế nào?

ECS có phải một kiểu mẫuparadigm không? Nếu định nghĩa “kiểu mẫu” là một kiểu dáng, hoặc một cách thức lập trình, thì ECS hẳn là một kiểu mẫu. Còn nếu “kiểu mẫu” cần giải quyết mọi vấn đề có thể xảy ra trong lập trình, thì tôi không chắc cái cách quản lý dữ liệu kiểu ECS có thể đảm đương mọi loại giải thuật hay không. Tôi đoán khả năng đó cũng có thể xảy ra nếu ta có một giải pháp ECS đúng đắn cùng các công cụ có sẵn trong một ngôn ngữ thủ tụcprocedural language đơn giản. Tuy nhiên, điều quan trọng là ECS đặt ra một lối thức khắt khe để giải quyết hầu hết các vấn đề nên ta có thể phát triển một tựa game bằng ECS 100% (tôi đã chứng minh điều này bằng Svelto.ECS ở công ty Freejam).

Quay lại với những thứ ít “triết học” hơn, giờ ta hãy bàn về cách dùng ECS sao cho đúng. Trong phần thứ hai này, tôi sẽ nghiên cứu xem liệu bộ nguyên lý SOLID, dù được tạo ra cho kiểu mẫu hướng đối tượng, có thể áp dụng được cho kiểu mẫu ECS không. Bởi nếu ta dùng ECS để phát triển toàn bộ chương trình chứ không chỉ những phần cần “tốc độ cao”, ta cần một số chỉ dẫn nhằm đảm bảo tính dễ bảo trì qua thời gian.

Hãy bắt đầu từ sự thật cơ bản nhất: cũng giống việc ngăn chứa IoC sẽ trở thành một công cụ phiền toái nếu ta không có tư duy đảo ngược quyền kiểm soát, bất cứ nền mã nào sử dụng một giải pháp ECS phức tạp cũng sẽ gặp khó khăn trong công tác bảo trì nếu không có các cấp trừu tượng. Trên thực tế, triển khai một nền mã ECS không có cấp trừu tượng nào về cơ bản chính là lập trình thủ tục với dữ liệu có thể truy xuất toàn cục. Một mình ECS là không đủ để tạo ra một nền mã dễ bảo trì, dễ cải tiến (hãy tin tôi, tôi có bằng chứng!), nhưng nó cho ta thấy cái tư duy đúng đắn để tạo ra những công cụ mà ta vẫn phải biết cách dùng đúng.

Một khi đã nắm vững, ECS sẽ thúc đẩy ta cấu trúc mã nguồn thành các cấp trừu tượng khác nhau, mỗi cấp lại chứa các hệ thống và các hợp phần ít nhiều có thể tái sử dụng tuỳ theo mức độ trừu tượng hoặc chuyên biệt của chúng. Thậm chí ta còn có thể đặt những hệ thống xử lý các hợp phần có thể tái sử dụng vào một cấp trừu tượng ở mức khung phát triển để có thể dùng lại trong các sản phẩm khác. Ví dụ mã nguồn hệ thống vật lý ECS của Unity có tính trừu tượng cao hơn mã nguồn các tựa game sẽ sử dụng nó. Các hệ thống và hợp phần của một tựa game là chuyên biệt và độc nhất, trong khi các hệ thống và hợp phần của khung vật lý ECS của Unity đã được trừu tượng hoá (so với phần game) và có thể sử dụng lại.

So với OOP, ECS nên cung cấp một khung phát triển cho phép ta giảm lượng thời gian phải nghĩ về cách thiết kế mã nguồn, và tăng lượng thời gian triển khai giải thuật thực tế, vì như tôi đã trình bày, kiểu mẫu ECS tạo điều kiện cho ta đảo ngược quyền kiểm soát tuyệt đối.

Có vẻ như với ECS, bao đóng dữ liệu không còn tồn nữa. Dù có thể lập luận rằng một khi giải được bài toán quyền kiểm soát và quyền sở hữu đối tượng, bao đóng sẽ không còn quan trọng nữa, thì kết luận bao đóng không tồn tại trong ECS cũng không hoàn toàn chính xác. Chỉ khi ta có thể truy xuất bất cứ loại hợp phần nào từ bất cứ hệ thống nào thì bao đóng mới thật sự biến mất. Nhưng tôi đố bạn quản lý được một mã nguồn như vậy đó! Vậy nên hãy tiếp cận lập luận này từ một góc nhìn khác trước khi nối mọi thứ lại với nhau: Một hệ thống có trách nhiệm gì? Hệ thống là phần logic bao đóng thể hiện một hành vi cụ thể đã định rõ của một thực thể, và được áp dụng lên các thực thể có chung một tập các hợp phần nhất định. Nguyên tắc là hệ thống có thể đọc dữ liệu bất biếnimmutable từ mọi thực thể nó cần đến, nhưng nó chỉ có thể thay đổi dữ liệu của một tập thực thể nhất định, là các thực thể cần có hành vi đó.

Đây chính là ứng dụng của nguyên lý Đơn nhiệmSingle Responsibility, mà ta cần diễn lại cho phần hệ thống bằng phát biểu sau:

Không bao giờ có nhiều hơn một lý do để thực thể thay đổi

HOẶC

Tập kết các hợp phần có cùng lý do thay đổi, phân ly các hợp phần có lý do thay đổi khác nhau

Khi áp dụng nguyên lý này, các hệ thống sẽ trở thành các khối logic có tính mô-đun cao được áp dụng lên một tập tối thiểu những thực thể bị ảnh hưởng bởi hành vi cụ thể đó. Thay vì dùng giao diện để tổ hợp hành vi, ở đây ta dùng tổ hợp dữ liệudata composition. Một thực thể càng có nhiều hợp phần thì càng có nhiều hành vi độc lập. Tuy nhiên mã nguồn của một hành vi chỉ được phép biến đổi dữ liệu vì một lý do duy nhất. Ví dụ một hệ thống không được phép biến đổi cả hai hợp phần HealthComponentPositionComponent của một thực thể, vì các hợp phần này thường có lý do biến đổi khác nhau. Nguyên lý này củng cố cái ý tưởng cho rằng cho dù một hệ thống (hay hành vi của thực thể) muốn đọc bao nhiêu hợp phần bất biến cũng được, nó cần phải xác định rõ lý do để biến đổi một tập các hợp phần nhất định.

Vì một hệ thống được xem như một hành vi cụ thể, ta có thể diễn lại nguyên lý Đóng MởOpen–Closed như sau:

Các hành vi không cho phép sửa đổi, nhưng cho phép mở rộng

Nhờ tổ hợp dữ liệu, ta có thể mở rộng các hành vi của một thực thể bằng cách ghép thêm các hợp phần mới vào, nhưng ta không bao giờ cần phải sửa đổi một hành vi đã hoàn thiện. Nếu thực thể cần một hành vi mới, ta nên thay cái cũ bằng cái mới, bằng cách chỉnh sửa tập hợp phần của thực thể đó. Để không làm ảnh hưởng đến hành vi của những thực thể vẫn sử dụng logic cũ, ta sẽ tạo ra một hệ thống mới thay vì sửa lại hệ thống cũ. Hành vi mới cũng có thể là phần mở rộng của hành vi cũ, không phải bằng kế thừa, mà chỉ đơn giản là áp cả hai lên cùng một thực thể bằng cách cho hệ thống cũ xử lý trước rồi đến hệ thống mới.

Video này trình bày thêm những hệ quả khả dĩ khi áp dụng đúng các nguyên lý trên.

ECS đơn giản hoá việc thiết kế phần logic cho toàn chương trình trên khía cạnh các cấp trừu tượng. Ta có thể mô hình hoá mọi thứ dưới dạng các hệ thống, từ logic tổng hợp hình ảnh, cho đến logic tấn công của nhân vật. Phần mã khung phát triển và phần mã chương trình sẽ không còn khác biệt nào nữa. Tuy nhiên mọi thứ vẫn được thiết kế theo nhiều cấp trừu tượng với mục đích là bao đóng các hành vi dùng chung trong các hệ thống đã được xác định rõ.

Có thể nói các thực thể chuyên biệt được tạo ra từ việc tổ hợp các hợp phần. Thực thể được định nghĩa là một tập các hợp phần tương đối mịn. Số lượng và kích thước các hợp phần có thể thay đổi tuỳ vào độ chi tiết của thực thể.

Độ chi tiết là mức độ phân tách một thứ lớn hơn thành các phần tử nhỏ hơn, hoặc là số lượng nhóm những phần tử nhỏ khó phân biệt hơn kết hợp với nhau tạo thành các thứ lớn dễ phân biệt hơn.
(theo từ điển)

Vì chuyên biệt hoá thực thể được xem như tổ hợp các hợp phần thay vì triển khai giao diện mới, ta có thể diễn lại nguyên lý Khả dĩ Thay thế của LiskovLiskov Substitution như sau:

Hành vi trừu tượng hơn phải có khả năng sử dụng thực thể chuyên biệt hơn thông qua một tập con các hợp phần của thực thể đó

Không bàn về cách tôi diễn lại nguyên lý Khả dĩ Thay thế thì đơn giản quá. Rõ ràng là nguyên lý này được tạo ra cho OOP với các đối tượng chuyên biệt hoá bằng kế thừa. Khi các đối tượng được chuyên biệt hoá bằng kế thừa, nguyên lý này lại rất giống lý thuyết Thiết kế bằng Giao ướcdesign by contract trong Thiết kế Hướng Nghiệp vụdomain driven design. Tuy nhiên, khi các đối tượng chỉ triển khai các giao diện mà không có kế thừa, nguyên lý này vẫn áp dụng được. Trong cả hai trường hợp, nguyên lý Khả dĩ Thay thế chỉ phát biểu đơn giản rằng lớp đối tượng đảm trách các phụ thuộc không bao giờ được ép một phụ thuộc về kiểu chuyên biệt hơn để chạy các logic có trong đó.

Cũng tương tự, ta có thể phát biểu rằng một hệ thống không bao giờ cần biết hết toàn bộ các hợp phần cấu thành một thực thể nhất định, nhưng hệ thống vẫn có thể sử dụng thực thể đó thông qua các hợp phần mà nó đảm trách.

Ta sẽ cần thêm sức tưởng tượng để có thể áp dụng nguyên lý Phân tách Giao diệnInterface Segregation. Về cơ bản, nguyên lý này phát biểu rằng nhiều giao diện “nhỏ” sẽ tốt hơn một giao diện “to”. Các giao diện để đối tượng triển khai cần thon gọn nhất có thể, để khi các giao diện không liên quan bị thay đổi ta cũng không cần biên dịch lại phần mã phụ thuộc vào những giao diện không bị chỉnh sửa. Mặc dù việc mô-đun phụ thuộc vào một giao diện “to” không vi phạm các nguyên lý trước, nhưng những thay đổi ở phía giao diện “to” đó, vì lý do nào đi nữa, cũng sẽ khiến phần logic không liên quan bị ảnh hưởng. Quan trọng hơn, bất cứ đối tượng nào triển khai một giao diện “to” sẽ phải triển khai tất cả các phương thức khai báo trong giao diện đó, làm giảm tính mô-đun và cản trở việc tổ hợp hành vi.

Lại lần nữa, trong ECS ta có thể áp dụng nguyên lý này vào các hợp phần. Hợp phần là các cấu trúc dữ liệu có khả năng phát triển đến vô tận, qua thời gian sẽ có nhiều dữ liệu khác được thêm vào. Điều gì khiến ta phải tạo ra nhiều hợp phần nhỏ thay vì một ít hợp phần to? Về lý thuyết ta có thể cho tất cả dữ liệu của một thực thể vào một hợp phần duy nhất. Nhưng làm vậy ta sẽ không thể tái sử dụng chúng để tổ hợp các hành vi cho thực thể, và hệ quả là ta phải tạo ra các hành vi (hệ thống) không thể tái sử dụng cho các thực thể chuyên biệt khác được.

Vậy nên ta có thể phát biểu nguyên lý Phân tách Giao diện cho ECS như sau:

Không được bắt một hành vi phải phụ thuộc vào dữ liệu mà nó không dùng

Nguyên lý này chỉ có hiệu lực nếu các hợp phần tạo nên thực thể được cung cấp bởi hệ thống. Trong OOP, các lớp quản lý trừu tượng hơn phải cung cấp giao diện để các đối tượng triển khai, còn trong ECS các hành vi của thực thể phải quy định các hợp phần thực thể đó cần phải có. Vậy nên, nếu ta thiết kế các hành vi trước tiên, thực thể sẽ trở thành một tổ hợp các hợp phần sẽ được dùng bởi các hệ thống liên quan.

Một ví dụ thực tiễn hơn: nếu hành vi WalkingCharacterBehaviour cần một hợp phần có tên PositionComponent, và hành vi DamagingCharacterBehaviour cần hợp phần HealthComponent, thì thực thể CharacterEntity sẽ được định nghĩa là gồm hai hợp phần PositionComponentHealthComponent.

Trong ECS, ta có thể đặt ra định nghĩa mới cho thuật ngữ “trừu tượng hoá”, liên hệ đến việc phân ly các hành vi có thể tái sử dụng (độ chi tiết). Khi đó một hành vi trừu tượng hơn sẽ trở thành hành vi chung cho nhiều loại thực thể, và các hợp phần dùng trong hành vi này cũng trở nên ít chuyên biệt hơn.

Theo ý tôi, một trong các lợi ích to lớn của ECS là khả năng thiết kế ra các hợp phần có thể tái sử dụng mà không cần lo về các chi phí mào đầuoverhead. Xét về khía cạnh công sức phải bỏ ra, không có khác biệt nào giữa việc tạo ra một hợp phần to với việc tạo ra nhiều hợp phần nhỏ hơn (và dễ tái sử dụng hơn), miễn là các hệ thống liên quan tới các hợp phần này vẫn có nghĩa.

Kết luận

Đây là một bài viết này chưa hoàn chỉnh vì tôi cần phải đưa thêm vài ví dụ về các phương án tốt lẫn xấu trong ECS, và cách ứng dụng ECS vào nhiều khía cạnh trong việc phát triển game, nhưng vì bài viết này đã quá dài rồi, tôi quyết định chỉ gói gọn trong phần lý thuyết mà thôi.

Một vài vấn đề cơ sở khác vẫn cần phải được bàn thêm như: quyền sở hữu dữ liệu được quản lý như thế nào khi ta không dùng đối tượng nữa; hay phương án nào cho hoạt động giao tiếp liên hệ thống trong ECS. Nếu có nhiều bạn thấy hứng thú về đề tài này, tôi sẽ viết một bài khác có nhiều ví dụ thực tiễn hơn. Nếu quan tâm hãy báo tôi biết nhé.

Và bởi vì tôi không phải chuyên gia học thuật, có thể tôi đã đưa ra một vài định nghĩa quá chủ quan. Nếu bạn thấy tôi cần bàn thêm về cái gì thì cũng báo tôi biết luôn nhé.


  1. Tra cứu thêm: software development vs. maintenance cost 

  2. Không thấu hiểu nên người ta thường diễn giải lung tung. Đến tôi cũng không thoát! 

  3. Nếu một lớp quản lý chỉ triển khai một hành vi cụ thể được dùng bởi một hợp phần duy nhất thì cũng không vấn đề gì.