Lưu trữ cho từ khóa: component

Clean Architecture – Chương 34. Chương Bỏ Sót

Tất cả lời khuyên mà bạn đọc được cho tới nay chắc chắn sẽ giúp bạn thiết kế phần mềm tốt hơn, bao gồm các lớp và các component có ranh giới được xác định rõ, trách nhiệm rõ ràng, và các phụ thuộc được quản lý. Nhưng hóa ra điều khủng khiếp lại nằm trong các chi tiết lúc triển khai, và bạn thực sự sẽ dễ dàng rơi vào rào cản cuối cùng đó nếu bạn không suy nghĩ kỹ.

Chúng ta hãy tưởng tượng rằng chúng ta đang xây dựng một cửa hàng sách online, và một use case mà chúng ta được đề nghị để triển khai là về các khách hàng có thể xem tình trạng đơn hàng của họ. Mặc dù đây là một ví dụ Java, nhưng các nguyên lý cũng có thể áp dụng tương tự cho các ngôn ngữ lập trình khác. Lúc này, chúng ta hãy đặt Kiến Trúc Tinh Gọn sang một bên và nhìn vào số lượng phương pháp để thiết kế và tổ chức code.

Đóng gói bởi layer

Đầu tiên, và có lẽ là phương pháp thiết kế đơn giản nhất là kiến trúc phân lớp theo chiều ngang truyền thống, chúng ta tách biệt code dựa theo nó làm cái gì từ quan điểm kỹ thuật. Đây thường được gọi là “đóng gói bởi layer”. Hình 34.1 chỉ ra nó trông như thế nào dưới dạng biểu đồ lớp UML.

Trong kiến trúc phân lớp thông thường này, chúng ta có một layer cho code web, một layer cho “quy tắc nghiệp vụ” của chúng ta, và một layer để lưu trữ. Nói cách khác, code được chia theo chiều ngang thành những layer để nhóm những thứ tương tự nhau. Trong một “kiến trúc phân lớp chặt chẽ”, các layer chỉ được phụ thuộc vào layer thấp hơn kế tiếp. Trong Java, các layer thường được triển khai thành các gói (package). Như bạn có thể thấy trong Hình 34.1, tất cả phụ thuộc giữa các layer (các gói) đều hướng xuống. Trong ví dụ này, chúng ta có những loại Java sau:

  • OrdersController: Một web controller, thứ gì đó giống như một Spring MVC controller, dùng để xử lý các yêu cầu từ web.
  • OrdersService: Một interface định nghĩa “quy tắc nghiệp vụ” liên quan tới các đơn hàng.
  • OrdersServiceImpl: Triển khai của dịch vụ đặt hàng.[1]
  • OrdersRepository: Một interface định nghĩa cách chúng ta truy cập tới thông tin đơn hàng được lưu trữ.
  • JbdcOrdersRepository: Triển khai của interface OrdersRepository.
Hình 34‑1 Đóng gói bởi layer

Trong bài viết “Presentation Domain Data Layering[2], Martin Fowler nói rằng áp dụng một kiến trúc phân lớp là một phương pháp hay để khởi đầu. Không phải riêng ông ấy nghĩ vậy. Nhiều cuốn sách, hướng dẫn, các khóa đào tạo, và code mẫu mà bạn tìm thấy cũng sẽ chỉ cho bạn theo con đường tạo ra một kiến trúc phân lớp. Đó là một cách rất nhanh để giúp thứ bạn làm ra chạy được mà không quá phức tạp. Vấn đề như Martin đã chỉ ra là một khi phần mềm của bạn phát triển về độ lớn và độ phức tạp, thì bạn sẽ nhanh chóng thấy rằng ba component đó là không đủ, và bạn sẽ cần nghĩ cách module hóa nó hơn nữa.

Một vấn đề khác như tôi đã nói, kiến trúc phân lớp không hề đề cấp bất cứ thứ gì về lĩnh vực nghiệp vụ (business domain). Hãy đặt code cho hai kiến trúc phân lớp, từ hai lĩnh vực nghiệp vụ rất khác nhau, cạnh nhau và chúng có thể trông giống nhau một cách kỳ lạ: web, các dịch vụ, và các kho lưu trữ (repository). Ngoài ra còn có một vấn đề lớn khác với kiến trúc phân lớp, nhưng chúng ta sẽ bàn tới vấn đề đó sau.

Đóng gói bởi chức năng

Một lựa chọn khác để tổ chức code của bạn là sử dụng kiểu “đóng gói bởi chức năng”. Đây là một phương pháp cắt dọc, dựa trên các chức năng liên quan, các khái niệm lĩnh vực, hoặc các gốc tổng hợp (để sử dụng thuật ngữ thiết kế hướng lĩnh vực). Trong các triển khai thông thường mà tôi đã thấy, tất cả các kiểu được đặt trong một gói Java đơn, được đặt tên để phản ánh khái niệm đang được nhóm lại.

Với cách này, như thấy ở Hình 34.2, chúng ta có các interface và các lớp tương tự như trước, nhưng tất cả chúng được đặt trong một gói Java đơn thay vì chia thành ba gói. Đây là một cách refactor đơn giản từ kiểu “đóng gói bởi layer”, nhưng bây giờ cách tổ chức cấp cao nhất của code lại có thể nói lên được về lĩnh vực nghiệp vụ. Bây giờ chúng ta có thể biết rằng code base này có gì đó để làm với đơn hàng thay vì web, các dịch vụ, và các kho lưu trữ. Một lợi ích khác đó là có thể dễ dàng tìm thấy tất cả code mà bạn cần sửa đổi trong trường hợp cần thay đổi use case “xem đơn hàng”. Tất cả nằm trong một gói Java duy nhất thay vì dàn trải nhiều nơi[3].

Tôi thường thấy các đội phát triển phần mềm nhận ra rằng họ có các vấn đề với cách phân lớp ngang (“đóng gói bởi layer”) và thay vào đó chuyển sang cách phân lớp dọc (“đóng gói bởi chức năng”). Theo ý kiến của tôi, cả hai đều chưa tối ưu. Nếu bạn đã đọc cuốn sách này tới đây, bạn có thể đang nghĩ rằng chúng ta có thể làm tốt hơn nữa – và bạn đã đúng.

Hình 34‑2 Đóng gói bởi chức năng

Ports and adapters

Như tôi đã nói, các phương pháp như “ports và adapters”, “hexagonal architecture”, “boundaries, controllers, entities”.v.v. hướng tới việc tạo ra một kiến trúc có phần code tập trung vào nghiệp vụ/lĩnh vực được độc lập và tách biệt khỏi những chi tiết triển khai kỹ thuật như framework và cơ sở dữ liệu. Để tổng kết, bạn thường nhìn code base như vậy được cấu thành bởi một “bên trong” (lĩnh vực) và một “bên ngoài” (hạ tầng), như được đề nghị trong Hình 34.3.

Hình 34‑3 Một code base với một bên trong và một bên ngoài

Vùng “bên trong” bao gồm tất cả các khái niệm lĩnh vực, trong khi đó vùng “bên ngoài” bao gồm các tương tác với thế giới bên ngoài (ví dụ các UI, cơ sở dữ liệu, tích hợp với hãng thứ ba). Quy tắc chính ở đây đó là “bên ngoài” phụ thuộc vào “bên trong” – không bao giờ ngược lại. Hình 34.4 cho thấy một phiên bản về cách use case “xem đơn hàng” có thể được triển khai.

Gói com.mycompany.myapp.doamin ở đây là “bên trong”, và các gói khác là “bên ngoài”. Lưu ý về cách luồng phụ thuộc hướng vào “bên trong”. Người đọc để ý kỹ sẽ thấy OrdersRepository từ các biểu đồ trước đã được đổi tên thành Orders. Việc này là do phương pháp thiết kế hướng lĩnh vực, nó khuyến khích việc đặt tên mọi thứ ở “bên trong” theo “ngôn ngữ lĩnh vực thường gặp”. Nói cách khác, chúng ta nói về “đơn hàng” khi chúng ta có một cuộc thảo luận về lĩnh vực đó, chứ không phải là “kho chứa đơn hàng”.

Hình 34‑4 Use case Xem đơn hàng

Cũng cần chỉ ra rằng đây là phiên bản đơn giản hóa của biểu đồ lớp UML trông thế nào, bởi vì nó thiếu nhiều thứ như interactor và đối tượng để sắp xếp dữ liệu qua các ranh giới phụ thuộc.

Đóng gói bởi component

Mặc dù tôi hoàn toàn đồng ý với các cuộc thảo luận về SOLID, REP, CCP, và CRP và phần lớn các lời khuyên trong cuốn sách này, tôi đi tới một kết luận hơi khác về cách tổ chức code. Vì vậy tôi sẽ biểu diễn lựa chọn khác ở đây, tôi gọi nó là “đóng gói bởi component”. Để bạn được biết về một số kinh nghiệm của tôi, tôi đã dành phần lớn sự nghiệp của mình để xây dựng phần mềm doanh nghiệp, chủ yếu bằng Java, qua nhiều lĩnh vực nghiệp vụ khác nhau.

Những hệ thống phần mềm này cũng thay đổi rất đa dạng. Một lượng lớn là nền web, nhưng một số khác là dạng client-server (máy khách-máy chủ)[4], phân tán, message-based (nền gói tin), hoặc một số loại khác. Mặc dù các công nghệ khác nhau, nhưng nhìn chung kiến trúc của hầu hết các hệ thống phần mềm đó đều dựa trên kiến trúc phân lớp truyền thống.

Tôi đã từng đề cập tới tới các nguyên nhân tại sao kiến trúc phân lớp lại bị xem như tệ, nhưng đó không phải là toàn bộ câu truyện. Mục đích của một kiến trúc phân lớp là để tách biệt code có cùng chức năng. Các thứ về web được tách biệt khỏi phần quy tắc nghiệp vụ, còn quy tắc nghiệp vụ thì lại được tách biệt khỏi việc truy cập dữ liệu. Như chúng ta đã thấy từ biểu đồ lớp UML, từ góc độ triển khai, một layer thường tương đương với một Java package. Từ góc độ về khả năng truy cập code, để OrdersController có thể có một phụ thuộc vào interface OrdersService, interface OrdersService cần được đánh dấu là public, bởi vì chúng ở trong các gói khác nhau. Tương tự như vậy, interface OrdersRepository cần được đánh dấu là public để nó có thể được nhìn thấy từ bên ngoài của gói repository, bởi lớp OrdersServiceImpl.

Trong một kiến trúc phân lớp chặt chẽ, các mũi tên phụ thuộc luôn phải hướng xuống, với các layer chỉ phụ thuộc vào layer thấp hơn liền kề. Điều này tạo ra một đồ thị phụ thuộc không vòng lặp, sạch đẹp, đạt được bằng cách đưa ra một số quy tắc về cách các thành phần trong một code base phải phụ thuộc vào nhau. Vấn đề lớn ở đây là chúng ta có thể ăn gian bằng cách đưa vào một số phụ thuộc không mong muốn, để vẫn tạo ra được một biểu đồ phụ thuộc không vòng lặp đẹp đẽ.

Hãy xem như có ai đó mới gia nhập đội của bạn, và bạn đưa cho người mới đó một use case khác liên quan tới Orders để triển khai. Do là người mới, anh ta muốn tạo ấn tượng mạnh và triển khai use case này càng nhanh càng tốt. Sau khi ngồi xuống với một tách cà phê được vài phút, anh ta phát hiện ra lớp OrdersController đang có, vì vậy anh ta quyết định đó là nơi bắt đầu trang web liên quan tới Orders mới. Nhưng nó cần một số dữ liệu Orders từ cơ sở dữ liệu. Anh chàng mới nảy ra ý tưởng: “Ồ, có một interface OrdersRepository cũng đã có sẵn đây rồi. Ta có thể đơn giản dependency-inject triển khai này vào controller của ta. Hoàn hảo!”. Sau một vài phút nữa hacking, trang web đã có thể hoạt động. Nhưng biểu đồ UML kết quả sẽ trông như ở Hình 34.5.

Các mũi tên phụ thuộc vẫn trỏ xuống, nhưng OrdersController bây giờ thêm vào đó lại bỏ qua OrdersService ở một số use case. Cách tổ chức này thường được gọi là một kiến trúc phân lớp thoải mái (relaxed layered architecture), tại đó các layer được phép bỏ qua các lớp liền kề. Trong một số tình huống, đây là kết quả mong muốn – lấy ví dụ, nếu bạn đang có tuân theo mẫu thiết kế CQRS[5]. Trong nhiều trường hợp khác, việc nhảy qua layer quy tắc nghiệp vụ là điều không mong muốn, đặc biệt nếu quy tắc nghiệp vụ đó chịu trách nhiệm để đảm bảo việc truy xuất được ủy quyền tới từng bản ghi dữ liệu, lấy ví dụ.

Mặc dù use case mới hoạt động, nhưng có lẽ nó không được triển khai theo cách mà chúng ta mong muốn. Tôi thấy điều này xảy ra nhiều với các đội mà tôi đã từng tư vấn, và nó thường được tiết lộ khi các đội này bắt đầu hình dung code base của họ sẽ thực sự trông như thế nào, thường là lần đầu tiên.

Hình 34‑5 Kiến trúc phân lớp thoải mái

Điều chúng ta cần ở đây là một hướng dẫn – một nguyên lý kiến trúc – nói thứ gì đó đại loại như “Web controller không bao giờ được truy cập repository trực tiếp.” Dĩ nhiên, câu hỏi ở đây là sự tuân thủ. Nhiều đội tôi đã từng gặp đơn giản nói rằng “Chúng tôi tuân thủ nguyên lý này thông qua kỷ luật tốt và các buổi review code, bởi vì chúng tôi tin tưởng các lập trình viên của chúng tôi.” Sự tự tin này nghe thì rất hay, nhưng tất cả chúng ta đều biết điều gì sẽ xảy ra khi ngân sách và deadline bắt đầu lờ mờ đến gần.

Một số lượng nhỏ hơn nhiều các đội phát triển đã nói với tôi rằng họ dùng các công cụ phân tích thống kê (như Ndpend, Structure101, Checkstyle) để kiểm tra và tự động ép buộc các vi phạm kiến trúc trong khi build. Bản thân bạn có thể nhìn thấy những quy tắc này; chúng thường được biểu diễn dưới dạng regular expression hoặc các chuỗi ký tự wildcard cho biết “các loại trong package **/web không được chấp nhận các loại trong **/data; và chúng được thực thi sau bước biên dịch.

Cách tiếp cận này hơi thô nhưng nó có thể thực hiện được công việc này, báo cáo các vi phạm của các nguyên lý kiến trúc mà bạn đã định nghĩa khi đội phát triển và bạn build không thành công. Vấn đề với cả hai cách tiếp cận này là chúng đều có thể sai, và vòng lặp phản hồi dài hơn bình thường. Nếu không được dùng cẩn thận, phương pháp này có thể biến code base thành một “quả bóng bùn lớn[6]”. Cá nhân tôi thích dùng trình biên dịch để ép buộc kiến trúc nếu có thể.

Điều này mang tới cho chúng ta lựa chọn “đóng gói bởi component”. Nó là một cách tiếp cận lai mọi thứ mà chúng ta đã thấy cho đến nay, với mục tiêu là gộp tất cả các trách nhiệm liên quan thành một component thô duy nhất trong một package Java. Đó là về việc lấy cái nhìn dịch vụ làm trung tâm của một hệ thống phần mềm, đó cũng là điều mà chúng ta đang thấy với kiến trúc micro service. Cũng giống như cách mà kiến trúc “ports and apdaters” coi web chỉ như một cơ chế truyền thông tin khác, “đóng gói bởi component” giúp cho giao diện người dùng tách biệt khỏi các component thô này. Hình 34.6 chỉ ra use case “xem đơn hàng” có thể trông như thế nào.

Về bản chất, phương pháp này gộp “quy tắc nghiệp vụ” và code bền vững thành một thứ duy nhất, mà tôi gọi đó là một “component”. Tôi đã trình bày định nghĩa về “component” trước đó trong cuốn sách, nói rằng:

Component là một đơn vị triển khai. Chúng là thực thể nhỏ nhất mà có thể được triển khai như là một phần của hệ thống. Trong Java, chúng là các file jar.

Hình 34‑6 Use case Xem đơn hàng

Định nghĩa của tôi về một component có khác một chút: “Đó là một nhóm các chức năng có quan hệ với nhau phía sau một interface sạch đẹp, được nằm bên trong một môi trường thực thi như là một ứng dụng.” Định nghĩa này đến từ “mô hình kiến trúc phần mềm C4[7]” của tôi, đó là một cách phân cấp đơn giản để nghĩ về các cấu trúc tĩnh của một hệ thống phần mềm dưới dạng các container, component, và class (hoặc code). Nó nói rằng một hệ thống phần mềm được làm từ một hoặc nhiều container (ví dụ như các ứng dụng web, ứng dụng di động, ứng dụng độc lập, cơ sở dữ liệu, hệ thống file), mỗi hệ thống chứa một hoặc nhiều component, đến lượt những component lại được triển khai bởi một hoặc nhiều class (hoặc code). Liệu mỗi component nằm trong một file jar riêng biệt là một vấn đề trực giao không?

Một lợi ích chính của phương pháp “đóng gói bởi component” là nếu bạn đang viết code mà cần làm điều gì đó với Orders, thì chỉ có một nơi duy nhất để tới đó là OrdersComponent. Bên trong component này, việc tách biệt các vấn đề vẫn được duy trì, vì vậy quy tắc nghiệp vụ được tách biệt khỏi dữ liệu lưu trữ, nhưng đó là một chi tiết triển khai component mà người sử dụng không cần thiết biết tới. Điều này na ná với thứ bạn có thể gặp nếu bạn sử dụng các micro-service hoặc Kiến Trúc Hướng Dịch Vụ – một OrdersService tách biệt mà đóng gói mọi thứ liên quan tới việc xử lý các đơn hàng. Điểm khác biệt chính là chế độ tách rời. Bạn có thể nghĩ component được định nghĩa tốt trong một ứng dụng nguyên khối là một bước đi vững chắc sang kiến trúc micro-service.

Điều khủng khiếp là ở các chi tiết triển khai

Bốn cách tiếp cận đều trông giống như những cách khác nhau để tổ chức code và do đó, có thể được coi là những phong cách kiến trúc khác nhau. Tuy nhiên, cảm nhận này bắt đầu mất đi rất nhanh nếu bạn có các chi tiết triển khai sai.

Một điều tôi thường thấy là việc sử dụng quá tự do câu lệnh public trong các ngôn ngữ như Java. Hầu như chúng ta, các lập trình viên, theo bản năng sử dụng từ khóa public mà không cần suy nghĩ nhiều. Nó nằm trong bộ nhớ cơ (muscle memory) của chúng ta. Nếu bạn không tin tôi, hãy nhìn vào các mẫu code của các cuốn sách, hướng dẫn, và các framework mã nguồn mở trên GitHub. Xu hướng này là rất rõ ràng, bất kể phong cách kiến trúc nào mà một code base hướng tới áp dụng – các layer ngang, các layer dọc, ports and adapters, hoặc thứ gì đó khác. Việc đánh dấu tất cả các loại của bạn là public nghĩa là bạn không tận dụng ưu điểm của các tiện ích mà ngôn ngữ lập trình của bạn cung cấp cho khả năng đóng gói. Trong một số trường hợp, không có gì ngăn cản ai đó viết code để khởi tạo một lớp triển khai cụ thể một cách trực tiếp, vi phạm phong cách kiến trúc dự định.

Tổ chức Vs Đóng gói

Nhìn vào vấn đề này theo cách khác, nếu bạn để tất cả các loại trong ứng dụng Java của bạn là public, thì các package đơn giản chỉ là một cơ chế tổ chức (một nhóm, giống như các thư mục), hơn là được dùng để đóng gói. Do các loại public có thể sử dụng ở bất cứ nơi nào trong code base, nên bạn có thể loại bỏ các package cho gọn bởi vì chúng cung cấp rất ít giá trị thực tế. Kết quả là nếu bạn bỏ qua các package (bởi vì chúng không cung cấp bất cứ ý nghĩa gì về mặt đóng gói và ẩn giấu), thì loại kiến trúc bạn muốn tạo ra không còn thực sự quan trọng. Nếu bạn nhìn lại vào các biều đồ UML ví dụ, các package Java trở thành một chi tiết không liên quan nếu tất các loại đều được đánh dấu là public. Về bản chất, cả bốn phương pháp kiến trúc được trình bày trước đó trong chương này chính xác là giống nhau khi chúng ta lạm dụng từ khóa public (Hình 34.7).

Hãy xem kỹ những mũi tên nằm giữa mỗi loại trong Hình 34.7: Tất cả chúng đều giống nhau bất kể bạn đang cố gắng áp dụng phương pháp kiến trúc nào. Về lý thuyết các phương pháp này rất khác nhau, nhưng về mặt cú pháp thì chúng giống hệt nhau. Hơn nữa, bạn có thể tranh luận rằng khi bạn để cho tất cả các loại là public, thì thứ mà bạn thực sự có chỉ là bốn cách để mô tả một kiến trúc phân lớp theo chiều ngang truyền thống. Đây là một thủ thuật khéo léo, và dĩ nhiên không ai lại để tất cả các kiểu Java của họ là public. Trừ khi họ làm vậy. Và tôi đã từng thấy điều đó.

Các từ khóa truy cập (access modifier) trong Java không phải hoàn hảo[8], nhưng bỏ qua chúng chỉ khiến chúng ta gặp rắc rối. Cách mà các loại Java được đặt trong các package thực tế có thể tạo ra một sự khác biệt lớn tới cách các loại có thể truy cập được (hoặc không thể truy cập được) khi các từ khóa truy cập của Java được áp dụng một cách thích hợp. Nếu tôi mang các package này trở lại và đánh dấu (bằng cách làm mờ đồ họa) những kiểu mà từ khóa truy cập có thể làm cho nó trở nên hạn chế hơn, thì bức tranh sẽ trở nên khá thú vị (Hình 34.8).

Hình 34‑7 Cả bốn phương pháp kiến trúc đều giống nhau

Di chuyển từ trái sang phải, trong phương pháp “đóng gói bởi layer”, các interface OrdersService OrdersRepository cần đặt là public, bởi vì chúng truyền các phụ thuộc từ các lớp bên ngoài của package xác định của chúng. Ngược lại, các lớp triển khai (OrdersServiceImpl JdbcOrdersRepository) có thể được làm hạn chế hơn (package protected). Không ai cần biết về chúng; chúng là một chi tiết triển khai.

Trong phương pháp “đóng gói bởi chức năng”, OrdersController cung cấp một điểm vào duy nhất bên trong package đó, vì vậy những thứ khác có thể được đặt là package protected. Một cảnh báo lớn ở đây đó là không gì khác trong code base, bên ngoài package này, có thể truy cập thông tin liên quan tới Orders trừ khi chúng đi qua controller đó. Điều này có thể là điều mong muốn hoặc không.

Trong phương pháp “ports and adpaters”, các interface OrdersService Orders truyền các phụ thuộc từ các package khác, vì vậy chúng cần được đặt là public. Một lần nữa, các lớp triển khai có thể được đạt là package protected và được chèn phụ thuộc lúc runtime.

Hình 34‑8 Các loại màu xám là nơi mà từ khóa truy cập có thể làm cho nó trở nên hạn chế hơn

Cuối cùng, trong phương pháp “đóng gói bởi component”, interface OrdersComponent có một phụ thuộc được truyền từ controller, còn mọi thứ khác có thể được đặt là package protected. Bạn càng có ít loại public, thì bạn càng có ít số lượng phụ thuộc. Bây giờ không có cách nào[9] mà code bên ngoài package đó có thể dùng interface hoặc triển khai OrdersRepository một cách trực tiếp, vì vậy chúng ta có thể dựa vào trình biên dịch để cưỡng ép nguyên lý kiến trúc này. Bạn có thể làm điều tương tự trong .NET với từ khóa internal, mặc dù bạn sẽ cần tạo ra một cụm riêng đối với mỗi component.

Để cho rõ ràng hoàn toàn, những gì mà tôi mô tả ở đây liên quan tới một ứng dụng nguyên khối, nơi tất cả code đều nằm trong một cây mã nguồn duy nhất. Nếu bạn đang xây dựng một ứng dụng như vậy (và nhiều người khác cũng vậy), thì tôi chắc chắn sẽ khuyến khích bạn nghiêng về phía sử dụng trình biên dịch để ép buộc các nguyên lý kiến trúc của bạn, hơn là dựa vào kỷ luật tự giác và các công cụ hậu biên dịch.

Các chế độ tách rời khác

Ngoài ngôn ngữ lập trình mà bạn đang sử dụng, thường có những cách khác để bạn có thể tách rời sự phụ thuộc mã nguồn của bạn. Với Java, bạn có các framework như OSGi và hệ thống module Java 9 mới. Với các hệ thống module, nếu được dùng đúng, thì bạn có thể tạo ra một sự phân biệt giữa các loại là public và các loại là published. Lấy ví dụ, bạn có thể tạo ra một module Orders có tất cả các loại được đánh dấu là public, nhưng lại chỉ phát hành một tập nhỏ trong những loại này để sử dụng bên ngoài. Còn lâu mới đến lúc đó, nhưng tôi rất háo hức về việc hệ thống module Java 9 sẽ cung cấp cho chúng ta một công cụ khác để xây dựng phần mềm tốt hơn, và một lần nữa khơi dậy sự quan tâm của mọi người về tư duy thiết kế.

Một tùy chọn khác là tách rời các phụ thuộc của bạn ở cấp độ mã nguồn, bằng cách phân chia code trên các cây mã nguồn khác nhau. Nếu chúng ta lấy ví dụ “ports and adapters”, chúng ta có thể có ba cây mã nguồn:

  • Mã nguồn dành cho nghiệp vụ và lĩnh vực (mọi thứ độc lập với việc lựa chọn framework và công nghệ): OrdersService, OrdersServiceImpl, Orders.
  • Mã nguồn dành cho web: OrdersControllers.
  • Mã nguồn dành cho lưu trữ dữ liệu: JdbcOrdersRepository.

Hai cây mã nguồn sau có phụ thuộc lúc biên dịch vào code nghiệp vụ và lĩnh vục, thứ mà bản thân không biết điều gì về code web hoặc lưu trữ dữ liệu. Từ quan điểm triển khai, bạn có thể làm việc này bằng cách cấu hình các module riêng biệt hoặc các dự án trong công cụ build của bạn (như Maven, Gradle, MSBuild). Tốt nhất là bạn nên lặp lại mẫu thiết kế này, có một cây mã nguồn tách riêng cho mỗi loại và mỗi component trong ứng dụng của bạn. Tuy nhiên, đấy là một giải pháp lý tưởng hóa, bởi vì có những vấn đề về hiệu suất, độ phức tạp, và các vấn đề bảo trì liên quan tới việc chia nhỏ mã nguồn của bạn theo cách này.

Một phương pháp đơn giản hơn mà một số người sử dụng cho code “ports and adapters” của họ là chỉ có hai cây mã nguồn:

  • Code lĩnh vực (phần “bên trong”)
  • Code hạ tầng (phần “bên ngoài”)

Điều này phản ánh một cách đẹp đẽ trên biểu đồ (Hình 34.9) mà nhiều người sử dụng để tổng kết kiến trúc “ports and adapters”, và có một phụ thuộc lúc biên dịch từ phần hạ tầng tới phần lĩnh vực.

Hình 34‑9 Code lĩnh vực và hạ tầng

Phương pháp để tổ chức mã nguồn này cũng hiệu quả, nhưng hãy cẩn thận đến những đánh đổi có thể xảy ra. Đó là thứ mà tôi gọi là “mẫu thiết kế xấu Périphérique của ports and adapters.” Thành phố Paris, nước Pháp, có một con đường vành đai được gọi là Đại lộ Périphérique, nó cho phép bạn đi vòng quanh Paris mà không cần đi vào những khu phức hợp của thành phố. Việc có tất cả code hạ tầng của bạn trong một cây mã nguồn duy nhất nghĩa là có thể xảy ra khả năng code hạ tầng trong một khu vực của ứng dụng của bạn (ví dụ như web controller) có thể gọi trực tiếp code trong vùng khác của ứng dụng của bạn (ví dụ như kho cơ sở dữ liệu), mà không cần đi qua phần lĩnh vực. Điều này đặc biệt đúng nếu bạn quên áp dụng các từ khóa truy cập thích hợp cho phần code đó.

Kết luận: Lời khuyên bỏ sót

Điểm chính của chương này là để nhấn mạnh rằng những ý định thiết kế tốt nhất của bạn có thể bị hủy hoại trong nháy máy nếu bạn không xem xét đến tính phức tạp của chiến thuật triển khai. Hãy nghĩ về việc chuyển thiết kế mong muốn của bạn thành các cấu trúc code như thế nào, cách để tổ chức code, và chế độ tách rời nào sẽ được áp dụng trong thời gian chạy runtime và trong thời gian biên dịch. Hãy để các lựa chọn mở nếu có thể nhưng hãy thực dụng, và tính đến cả kích thước đội phát triển, trình độ kỹ năng của họ, và độ phức tạp của giải pháp đi kèm với những giới hạn về thời gian và ngân sách của bạn. Hãy nghĩ về việc sử dụng trình biên dịch của bạn để giúp bạn ép buộc kiểu kiến trúc được lựa chọn, và đề phòng sự gắn kết xảy ra trong các khu vực khác, ví dụ như các mô hình dữ liệu. Điều khủng khiếp thật sự nằm trong các chi tiết lúc triển khai.


[1] Người ta có thể cho rằng đây là một cách kinh khủng để đặt tên một lớp, nhưng như chúng ta sẽ thấy sau này, có lẽ nó cũng không quan trọng lắm.

[2] https://martinfowler.com/bliki/PresentationDomainDataLayering.html

[3] Lợi ích này ít liên quan hơn nhiều đến các công cụ điều hướng của IDE hiện đại, nhưng có vẻ như đã có một thời kỳ phục hưng quay trở lại các trình soạn thảo văn bản gọn nhẹ, vì những lý do mà tôi rõ ràng là quá già để có thể hiểu được.

[4] Công việc đầu tiên của tôi sau khi tốt nghiệp đại học năm 1996 là xây dựng các ứng dụng máy tính để bàn máy khách-máy chủ với công nghệ gọi là PowerBuilder, một 4GL siêu hiệu quả, vượt trội trong việc xây dựng các ứng dụng dựa trên cơ sở dữ liệu. Vài năm sau, tôi xây dựng các ứng dụng máy khách-máy chủ bằng Java, ở đó chúng tôi phải tự xây dựng bộ kết nối cơ sở dữ liệu của riêng mình (đây là tiền thân của JDBC) và bộ công cụ GUI của riêng chúng tôi trên AWT. Đó là “tiến bộ” đối với bạn!

[5] Trong mẫu thiết kế Command Query Responsibiltiy Segregation, bạn có các mẫu riêng dành cho việc cập nhật và đọc dữ liệu.

[6] http://www.laputan.org/mud/

[7] Xem https://www.structurizr.com/help/c4 để biết thêm chi tiết.

[8] Lấy ví dụ trong Java, mặc dù chúng ta có xu hướng coi các package là dạng phân cấp, nhưng chúng ta không thể tạo ra các hạn chế truy cập dự trên mối quan hệ giữa package và subpackage. Bất cứ cấu trúc phân cấp nào mà bạn tạo ra chỉ nằm trên tên của những package đó, và cấu trúc thư mục trên ổ đĩa.

[9] Trừ khi bạn ăn gian và dùng cơ chế reflection của Java, nhưng xin hãy đừng làm như vậy!

Clean Architecture – Chương 26. Component Main

Trong mọi hệ thống, luôn có ít nhất một component tạo, điều phối và quan sát các component khác. Tôi gọi component này là Main.

Chi tiết cuối cùng

Component Main là chi tiết cuối cùng – chính sách cấp thấp nhất. Nó là điểm vào đầu tiên của hệ thống. Ngoài hệ điều hành thì không thứ gì phụ thuộc vào nó. Công việc của nó là tạo ra tất cả các Factory, Strategy, và các phương tiện toàn cục khác, và sau đó chuyển điều khiển cho các phần trừu tượng cấp cao của hệ thống.

Trong component Main, các phụ thuộc nên được chèn vào bởi một framework Dependency Injection (chèn phụ thuộc). Một khi chúng đã được chèn vào trong Main, Main sẽ phân phối những phụ thuộc này bình thường, mà không dùng framework.

Bạn hãy nghĩ Main như là component bẩn nhất trong tất cả các component bẩn.

Hãy xem component Main sau từ một phiên bản gần đây của Săn Tìm Wumpus. Lưu ý cách nó nạp tất cả các string mà chúng ta không muốn phần thân code của main biết.

public class Main implements HtwMessageReceiver {

  private static HuntTheWumpus game;

private static int hitPoints = 10;

private static final List<String> caverns = new ArrayList<>();

private static final String[] environments = new String[] {

  "bright",

  "humid",

  "dry",

  "creepy",

  "ugly",

  "foggy",

  "hot",

  "cold",

  "drafty",

  "dreadful"

};

private static final String[] shapes = new String[] {

  "round",

  "square",

  "oval",

  "irregular",

  "long",

  "craggy",

  "rough",

  "tall",

  "narrow"

};

private static final String[] cavernTypes = new String[] {

  "cavern",

  "room",

  "chamber",

  "catacomb",

  "crevasse",

  "cell",

  "tunnel",

  "passageway",

  "hall",

  "expanse"

};

private static final String[] adornments = new String[] {

    "smelling of sulfur",

    "with engravings on the walls",

    "with a bumpy floor",

    "",

    "littered with garbage",

    "spattered with guano",

    "with piles of Wumpus droppings",

    "with bones scattered around",

    "with a corpse on the floor",

    "that seems to vibrate",

    "that feels stuffy",

    "that fills you with dread"

  };

Đây là hàm main. Lưu ý cách nó dùng HtwFactory để tạo trò chơi. Nó truyền vào tên của lớp, htw.game.HuntTheWumpusFacade, bởi vì lớp đó thậm chí còn bẩn hơn Main. Điều này cản trở những thay đổi trong lớp đó khiến cho Main phải biên dịch lại/triển khai lại.

public static void main(String[] args) throws IOException {

game = HtwFactory.makeGame("htw.game.HuntTheWumpusFacade", new Main());

createMap();

BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); game.makeRestCommand().execute();
while (true) {

System.out.println(game.getPlayerCavern());

System.out.println("Health: " + hitPoints + " arrows: " +

game.getQuiver());

HuntTheWumpus.Command c = game.makeRestCommand();

System.out.println(">");
String command = br.readLine();

if (command.equalsIgnoreCase("e"))

c = game.makeMoveCommand(EAST);
else if (command.equalsIgnoreCase("w"))

c = game.makeMoveCommand(WEST);
else if (command.equalsIgnoreCase("n"))

c = game.makeMoveCommand(NORTH);

else i (command.equalsIgnoreCase("s"))

c = game.makeMoveCommand(SOUTH);

else if (command.equalsIgnoreCase("r"))

c = game.makeRestCommand();
else if (command.equalsIgnoreCase("sw"))

c = game.makeShootCommand(WEST);
else if (command.equalsIgnoreCase("se"))

c = game.makeShootCommand(EAST);
else if (command.equalsIgnoreCase("sn"))

c = game.makeShootCommand(NORTH);

else if (command.equalsIgnoreCase("ss"))

c = game.makeShootCommand(SOUTH);

else if (command.equalsIgnoreCase("q"))

return;

c.execute(); }

}

Cũng lưu ý rằng main tạo ra dòng đầu vào và bao gồm vòng lặp chính của trò chơi, biên dịch các lệnh đầu vào đơn giản, nhưng sau đó chuyển tất việc xử lý cho các component khác cấp cao hơn.

Cuối cùng, lưu ý rằng main tạo ra bản đồ.

private static void createMap() {
int nCaverns = (int) (Math.random() * 30.0 + 10.0);

while (nCaverns-- > 0)

caverns.add(makeName());

    for (String cavern : caverns) {

      maybeConnectCavern(cavern, NORTH);

      maybeConnectCavern(cavern, SOUTH);

      maybeConnectCavern(cavern, EAST);

      maybeConnectCavern(cavern, WEST);

}

String playerCavern = anyCavern();

game.setPlayerCavern(playerCavern);

game.setWumpusCavern(anyOther(playerCavern)); game.addBatCavern(anyOther(playerCavern));

game.addBatCavern(anyOther(playerCavern));

game.addBatCavern(anyOther(playerCavern));

game.addPitCavern(anyOther(playerCavern));

game.addPitCavern(anyOther(playerCavern));

game.addPitCavern(anyOther(playerCavern));

game.setQuiver(5); }

// nhiều code đã loại bỏ...

}

Điều muốn nói ở đây đó là Main là một module cấp thấp bẩn trong vòng tròn ngoài cùng của kiến trúc tinh gọn. Nó nạp mọi thứ cho hệ thống cấp cao, và sau đó chuyển điều khiển cho nó.

Kết luận

Hãy nghĩ Main giống như một plugin vào ứng dụng – một plugin thiết lập các điều kiện ban đầu và các cấu hình, tập hợp tất cả các tài nguyên bên ngoài, và sau đó chuyển điều khiển cho chính sách cấp cao của ứng dụng đó. Do nó là một plugin, nên có thể có nhiều Main component, mỗi một component Main cấu hình một kiểu cho ứng dụng của bạn.

Lấy ví dụ, bạn có thể có một plugin Main cho Dev, một cái khác cho Test, và một cái khác cho Production. Bạn cũng có thể có một plugin Main cho mỗi nước mà bạn phát hành ứng dụng tại đó, hoặc mỗi thẩm quyền, hoặc mỗi khách hàng.

Khi bạn nghĩ về Main như là một component plugin, phía sau một ranh giới kiến trúc, vấn đề về việc cấu hình sẽ trở nên dễ hơn rất nhiều để giải quyết.

Clean Architecture – Chương 14. Sự Gắn Kết Giữa Các Component

Ba nguyên lý tiếp theo sẽ giải quyết mối quan hệ giữa các component. Ở đây lại một lần nữa chúng ta sẽ gặp mối quan hệ giằng co giữa khả năng phát triển và thiết kế logic. Các lực tác động đến kiến trúc của một cấu trúc component bao gồm kỹ thuật, chính trị, và biến động.

Nguyên lý phụ thuộc không vòng lặp (ACYCLIC DEPENDENCIES PRINCIPLE)

Không cho phép có vòng lặp trong biểu đồ phụ thuộc component.

Bạn đã từng làm việc cả ngày, làm cho thứ gì đó hoạt động, và sau đó đi về nhà, chỉ khi đến sáng hôm sau mới thấy rằng chương trình của bạn không còn hoạt động nữa? Tại sao nó lại không chạy được nữa vậy? Bởi vì ai đó ở lại muộn hơn và đã thay đổi thứ gì đó mà bạn phụ thuộc vào nó! Tôi gọi điều này là “hội chứng sau buổi sáng”.

“Hội chứng sau buổi sáng” này xảy ra trong môi trường phát triển phần mềm nơi mà nhiều lập trình viên đang sửa đổi các file mã nguồn cùng nhau. Ở những dự án tương đối nhỏ với chỉ một vài lập trình viên thì điều này không phải là một vấn đề quá lớn. Nhưng một khi kích thước của dự án và đội ngũ phát triển tăng lên, thì những buổi sáng có thể sớm biến thành những cơn ác mộng. Không có gì lạ khi nhiều tuần trôi qua mà đội phát triển không thể xây dựng được một phiên bản ổn định của dự án. Thay vào đó, mọi người tiếp tục thay đổi và thay đổi code của họ, cố gắng để làm cho nó hoạt động được với những thay đổi mà ai đó đã làm trước đó.

Trải qua vài thập kỷ gần đây, hai giải pháp cho vấn đề này đã được phát triển, cả hai đều xuất phát từ ngành công nghiệp viễn thông. Đầu tiên là “build hàng tuần”, và thứ hai là Nguyên Tắc Phụ Thuộc Không Vòng Lặp (ADP).

Build hàng tuần

Việc build hàng tuần thường phổ biến trong các dự án cỡ trung bình. Nó sẽ hoạt động như thế này: Tất cả các lập trình viên sẽ phớt lờ những người khác trong bốn ngày đầu của tuần. Tất cả họ sẽ làm việc trên bản copy của mã nguồn trên máy cá nhân, và không phải lo ngại gì tới việc tích hợp công việc của họ trên mã nguồn tổng. Sau đó, vào ngày Thứ Sáu, họ sẽ tích hợp tất cả những thay đổi của họ và build hệ thống đó.

Cách tiếp cận này có ưu điểm tuyệt vời là cho phép các lập trình viên sống trong một thế giới biệt lập vào bốn trên năm ngày trong tuần. Dĩ nhiên nhược điểm của nó là một lượng lớn lỗi tích hợp sẽ xảy ra vào Thứ Sáu.

Không may thay, khi dự án lớn dần, thì nó càng trở nên ít khả năng để có thể hoàn thành tích hợp dự án vào Thứ Sáu. Gánh nặng tích hợp lớn dần cho đến khi nó bắt đầu ngốn cả vào ngày Thứ Bảy. Chỉ một vài ngày Thứ Bảy như vậy là đủ để thuyết phục các lập trình viên là việc tích hợp hệ thống nên được bắt đầu vào Thứ Năm – và vì vậy việc bắt đầu tích hợp đã chậm rãi tiến vào giữa tuần.

Khi mà tỷ lệ thời gian chu kỳ phát triển với thời gian tích hợp hệ thống giảm, thì hiệu suất công việc của đội phát triển cũng giảm theo. Cuối cùng tình trạng này trở nên khó chịu đến mức mà các lập trình viên, hoặc các quản lý dự án tuyên bố rằng lịch tích hợp hệ thống sẽ thay đổi sang build hai tuần một lần. Việc này đủ để giải quyết vấn đề lần này, nhưng thời gian tích hợp vẫn tiếp tục tăng dần theo kích thước của dự án.

Cuối cùng, kịch bản này sẽ dẫn tới một cuộc khủng hoảng. Để duy trì hiệu quả, lịch build hệ thống phải tiếp tục được kéo dài – nhưng việc kéo dài chu kỳ build sẽ dẫn tới những rủi ro dự án. Việc tích hợp và kiểm thử trở nên càng khó khăn hơn để thực hiện, và đội phát triển mất đi ích lợi từ việc nhận được những phản hồi nhanh chóng.

Loại bỏ các vòng lặp phụ thuộc

Giải pháp cho vấn đề này là phân chia môi trường phát triển ra thành những component có thể phát hành độc lập được. Các component này trở thành một đơn vị công việc mà đó có thể là trách nhiệm của một lập trình viên hoặc một nhóm các lập trình viên. Khi các lập trình viên hoàn thành một component, họ sẽ phát hành nó để các lập trình viên khác có thể sử dụng. Họ sẽ cho nó một số hiệu phiên bản và chuyển nó vào trong một thư mục để các đội khác có thể sử dụng. Sau đó họ tiếp tục chỉnh sửa component của họ trong phạm vi riêng của mình. Còn những người khác thì dùng phiên bản đã phát hành.

Khi một phiên bản mới của một component xuất hiện, các đội phát triển khác có thể quyết định xem họ có áp dụng ngay lập tức phiên bản mới hay không. Nếu họ quyết định là không thì họ chỉ việc đơn giản là tiếp tục dùng bản cũ. Một khi họ quyết định là họ đã sẵn sàng thì họ sẽ bắt đầu dùng phiên bản mới.

Do đó không một đội nào bị ảnh hưởng bởi đội khác. Việc thay đổi đối với một component sẽ không có ảnh hưởng ngay lập tức đối với các đội khác. Mỗi đội có thể tự quyết định xem khi nào họ sẽ điều chỉnh component của họ sang sử dụng phiên bản mới của các component của đội khác. Hơn nữa, việc tích hợp sẽ được tiến hành thành từng bước nhỏ. Không cần có lúc nào mà các lập trình viên buộc phải ngồi lại với nhau và tích hợp mọi thứ mà họ đang làm.

Đây là một quy trình rất đơn giản và hợp lý, và nó đang được sử dụng rộng rãi. Tuy nhiên, để cho nó vận hành được thành công thì bạn buộc phải quản lý cấu trúc phụ thuộc của các component. Không được có vòng lặp phụ thuộc lẫn nhau. Nếu có xuất hiện vòng lặp trong cấu trúc phụ thuộc thì “hội chứng sau buổi sáng” vẫn không thể tránh được.

Hãy xem biểu đồ component ở Hình 14.1. Nó chỉ ra một cấu trúc phổ biến của các component được hợp thành trong một ứng dụng. Chức năng của ứng dụng này không phải là vấn đề quan trong đối với mục đích của ví dụ này. Điều quan trọng là cấu trúc phụ thuộc của các component. Lưu ý rằng cấu trúc này là một đồ thị hữu hướng (directed graph). Các component là các node, và mối liên hệ phụ thuộc là các cạnh hữu hướng (directed edge).

Hình 14‑1 Biểu đồ component điển hình

Bạn cần lưu ý một điều nữa: Dù cho bạn bắt đầu từ component nào thì cũng không thể theo các mối quan hệ phụ thuộc và lại quay trở lại chính component đó. Cấu trúc này không có vòng lặp. Đây được gọi là đồ thị không vòng lặp hữu hướng (directed acyclic graph – DAG).

Bây giờ hãy xem điều gì sẽ xảy ra khi nhóm phát triển chịu trách nhiệm đối với component Presenters tạo ra một phiên bản mới cho component của họ. Thật dễ dàng để tìm ra ai là người sẽ bị ảnh hưởng bởi đợt phát hành này; bạn chỉ cần lần theo ngược lại các mũi tên phụ thuộc. Như vậy cả View Main đều sẽ bị ảnh hưởng. Hiện tại các lập trình viên đang làm việc với các component này sẽ phải quyết định là khi nào thì họ sẽ cần tích hợp công việc của họ với phiên bản mới của Presenters.

Cũng cần lưu ý rằng khi Main được phát hành, nó sẽ hoàn toàn không gây ảnh hưởng gì đối với bất cứ một component nào khác trong hệ thống. Chúng không biết gì về Main, và chúng cũng không quan tâm tới khi nào nó thay đổi. Điều này thật tuyệt. Nó có nghĩa là sự ảnh hưởng của việc phát hành Main sẽ tương đối nhỏ.

Khi các lập trình viên đang làm việc với component Presenters cũng sẽ giống như việc chạy một bài kiểm tra đối với component đó, họ chỉ cần build phiên bản Presenters của họ với phiên bản của component InteractorsEntities mà họ hiện đang dùng. Không một component nào khác trong hệ thống sẽ cần phải dính líu tới. Điều này thật tuyệt. Nó có nghĩa là các lập trình viên đang làm việc với component Presenters sẽ có tương đối ít công việc cần làm để thiết lập một bài kiểm tra, và họ sẽ có tương đối ít biến số cần phải cân nhắc tới.

Khi đến thời điểm phát hành toàn bộ hệ thống, thì quy trình này sẽ xử lý từ dưới lên. Đầu tiên component Entities được biên dịch, được kiểm tra, và được phát hành. Sau đó điều tương tự cũng được thực hiện với component Database Interactors. Những component Presenters, View, Controllers thực hiện tiếp theo và sau cùng là component Authorizer.Main. Quy trình này rất rõ ràng và dễ dàng xử lý. Chúng ta biết cách để build hệ thống này bởi vì chúng ta hiểu về mối quan hệ phụ thuộc giữa các phần của nó.

Ảnh hưởng của vòng lặp trong biểu đồ phụ thuộc component

Giả sử rằng một yêu cầu mới buộc chúng ta phải thay đổi một trong những lớp trong component Entities ví dụ như nó phải sử dụng một lớp trong Authorizer. Lấy ví dụ, chúng ta hãy cho rằng lớp User trong Entities dùng lớp Permissions trong Authorizer. Điều này sẽ tạo ra một vòng lặp phụ thuộc, như thấy ở Hình 14.2.

Vòng lặp này tạo ra ngay lập tức một số vấn đề. Lấy ví dụ, các lập trình viên đang làm việc với component Database biết rằng để phát hành nó, thì component này buộc phải tương thích với Entities. Tuy nhiên, với việc vòng lặp xuất hiện, thì component Database bây giờ cũng sẽ phải tương thích với Authorizer. Nhưng Authorizer lại phụ thuộc vào Interactors. Điều này làm cho việc phát hành Database trở nên khó khăn hơn rất nhiều. Theo hiệu ứng thì Entities, Authorizer, Interactors sẽ trở thành một component lớn – điều này có nghĩa là tất cả các lập trình viên đang làm việc với bất cứ component nào trong số này sẽ lại trải nghiệm “hội chứng sau buổi sáng” kinh khiếp. Chúng sẽ lại bị dẫm lên nhau bởi vì tất cả buộc phải dùng chính xác cùng một bản phát hành của component khác.

Hình 14‑2 Vòng lặp phụ thuộc

Nhưng đây chỉ là một phần của vấn đề nảy sinh. Hãy xem điều gì sẽ xảy ra khi chúng ta muốn kiểm tra component Entities. Thật thất vọng khi chúng ta sẽ thấy rằng chúng ta buộc phải build và tích hợp với cả Authorizer Interactors. Mức độ gắn kết (coupling) giữa các component này là một vấn đề, nếu không muốn nói là không thể chấp nhận được.

Bạn có thể băn khoăn tại sao bạn phải kèm quá nhiều thư viện khác, và quá nhiều thứ code bởi những người khác, chỉ để chạy một bài unit test đơn giản của một trong những lớp của bạn. Nếu bạn điều tra vấn đề một chút, bạn có lẽ sẽ khám phá ra rằng đó là do những vòng lặp trong biểu đồ phụ thuộc. Những vòng lặp như vậy làm cho chúng ta rất khó để tách biệt được các component. Việc unit test và phát hành trở nên rất khó khăn và dễ mắc lỗi. Thêm vào đó, các vấn đề trong khi build sẽ phát sinh tỷ lệ thuận với số lượng các module.

Hơn nữa, khi có các vòng lặp trong biểu đồ phụ thuộc, sẽ rất khó để xác định được thứ tự mà bạn sẽ phải build các component. Quả thực là có lẽ sẽ không có thứ tự chính xác. Điều này có thể dẫn tới một số vấn đề rất khó chịu trong nhiều ngôn ngữ như Java khi mà nó phải đọc các khai báo của chúng từ các file nhị phân đã biên dịch.

Phá vỡ vòng lặp

Chúng ta luôn có thể phá vỡ một vòng lặp của các component và phục hồi lại biểu đồ phụ thuộc không vòng lặp hữu hướng DAG. Có hai cơ chế chính để làm như vậy:

  1. Áp dụng Nguyên Lý Đảo Ngược Phụ Thuộc (DIP). Trong trường hợp như Hình 14.3, chúng ta có thể tạo ra một interface mà có các method lớp User cần. Sau đó chúng ta có thể đặt interface đó vào Entities và kế thừa nó trong component Authorizer. Điều này giúp đảo ngược sự phụ thuộc giữa Entities Authorizer, nhờ đó mà phá vỡ vòng lặp.
Hình 14‑3 Sự đảo ngược phụ thuộc giữa Entities và Authorizer
  • Tạo ra một component mới mà cả Entities Authorizer đều phụ thuộc vào nó. Di chuyển những lớp mà cả hai đều phụ thuộc vào trong component mới (Hình 14.4)
Hình 14‑4 Component mới mà cả Entities và Authorizer đều phụ thuộc vào

Sự thay đổi

Giải pháp thứ hai cho thấy cấu trúc component không phải là cố định khi xuất hiện sự thay đổi của các yêu cầu. Quả thực là khi ứng dụng lớn dần, thì cấu trúc phụ thuộc component cũng thay đổi và lớn dần theo. Do đó chúng ta phải luôn theo dõi sự xuất hiện của các vòng lặp trong biểu đồ cấu trúc phụ thuộc. Khi vòng lặp xảy ra, chúng buộc phải được phá vỡ bằng cách nào đó. Đôi khi điều này có nghĩa là tạo ra các component mới, điều này làm cho cấu trúc phụ thuộc lớn dần lên.

Thiết kế từ trên xuống

Các vấn đề mà chúng ta vừa thảo luận dẫn tới một kết luận không phải bàn cãi: Cấu trúc component không thể được thiết kế từ trên xuống. Nó không phải là một trong những thứ đầu tiên về hệ thống cần được thiết kế, mà đúng hơn là nó sẽ phát triển dần khi hệ thống lớn lên và thay đổi.

Một vài độc giả có thể thấy điều này không được trực quan. Chúng ta đều mong muốn rằng việc phân tách các component cũng sẽ giống như việc phân tách chức năng ở cấp cao.

Khi chúng ta thấy một nhóm lớn như một cấu trúc phụ thuộc component, chúng ta tin rằng các component có thể thể hiện các chức năng của hệ thống theo cách nào đó. Điều này có vẻ không phải là một thuộc tính của biểu đồ phụ thuộc component.

Thực tế, các biểu đồ phụ thuộc component rất ít khi dùng để mô tả chức năng của ứng dụng. Thay vào đó, chúng là một sơ đồ cho khả năng build (buildability)khả năng bảo trì (maintainability) của ứng dụng. Đây là lý do tại sao chúng không được thiết kế vào lúc bắt đầu của dự án. Lúc đó chưa có phần mềm nào để build hoặc bảo trì, nên không cần sơ đồ build và bảo trì làm gì cả. Nhưng khi mà có ngày càng nhiều các module ở giai đoạn đầu của việc triển khai và thiết kế, thì sự cần thiết của việc quản lý các phụ thuộc cũng tăng dần để cho dự án có thể được phát triển mà không gặp phải “hội chứng sau buổi sáng”. Hơn nữa, chúng ta muốn các thay đổi được cục bộ hóa nhất có thể, vì vậy chúng ta bắt đầu lưu ý tới SRP và CCP và sắp xếp các lớp có nhiều khả năng thay đổi vào cùng với nhau.

Một trong những lo ngại quan trọng hơn với cấu trúc phụ thuộc này là sự tách biệt của khả năng thay đổi. Chúng ta không muốn các component bị thay đổi thường xuyên và vì các nguyên nhân thất thường gây ảnh hưởng đến các component mà lẽ ra có thể đã ổn định. Lấy ví dụ, chúng ta không muốn những thay đổi về mặt thẩm mỹ của giao diện người dùng GUI sẽ gây ảnh hưởng tới các logic nghiệp vụ của chúng ta. Chúng ta không muốn việc thêm hoặc sửa đổi các báo cáo sẽ có ảnh hưởng tới các logic nghiệp vụ ở cấp cao nhất của chúng ta. Hệ quả là biểu đồ phụ thuộc component được tạo ra và đúc kết bởi các kiến trúc sư để bảo vệ tính ổn định của các component có giá trị cao khỏi những component dễ thay đổi.

Khi ứng dụng tiếp tục lớn lên, chúng ta bắt đầu trở nên lo ngại với việc tạo ra các thành phần có khả năng tái sử dụng. Lúc này, nguyên tắc CRP bắt đầu gây ảnh hưởng tới cấu thành của các component. Cuối cùng, khi các vòng lặp xuất hiện, nguyên tắc ADP được áp dụng và biểu đồ phụ thuộc component sẽ thay đổi và lớn dần.

Nếu chúng ta cố gắng thiết kế cấu trúc phụ thuộc component trước khi chúng ta thiết kế bất cứ lớp nào, chúng ta nhiều khả năng sẽ thất bại nặng nề. Chúng ta không biết nhiều về sự khép kín chung, chúng ta không biết gì về bất cứ thành phần tái sử dụng nào, và chúng ta hầu như chắc chắn sẽ tạo ra các component mà có vòng lặp phụ thuộc. Do đó cấu trúc phụ thuộc component sẽ tăng trưởng dần với thiết kế logic của hệ thống.

Nguyên lý phụ thuộc ổn định (Stable Dependencies Principle)

Phụ thuộc theo hướng ổn định.

Thiết kế không thể hoàn toàn cố định. Một vài thay đổi là cần thiết nếu thiết kế là để được bảo trì. Bằng cách tuân thủ Nguyên Tắc Khép Kín Chung (CCP), chúng ta tạo ra các component nhạy cảm với một số loại thay đổi nhưng miễn nhiễm với các cái khác. Một số các component này được thiết kế để thay đổi. Chúng ta dự tính chúng sẽ thay đổi.

Bất cứ component nào mà chúng ta dự tính sẽ bị thay đổi thì không được phụ thuộc vào một component khó thay đổi. Nếu không component có tính thay đổi cũng sẽ khó mà thay đổi được.

Sự éo le của phần mềm đó là một module mà bạn thiết kế để dễ dàng thay đổi lại có thể gây cho người khác khó có thể thay đổi do họ đơn giản là bị phụ thuộc vào nó. Dù không một dòng code nào trong module của bạn cần thay đổi, thì module của bạn cũng đột nhiên trở thành một thử thách lớn hơn để có thể thay đổi. Bằng cách tuân thủ Nguyên Tắc Phụ Thuộc Ổn Định (SDP), chúng ta sẽ đảm bảo rằng các module có khuynh hướng dễ thay đổi thì không bị phụ thuộc vào các module khó thay đổi hơn.

Tính ổn định

“Tính ổn định” có nghĩa là gì? Dựng một đồng xu bằng cạnh của nó. Liệu nó có phải ở trạng thái ổn định không? Bạn chắc hẳn sẽ nói là “không”. Tuy nhiên, trừ khi bị tác động, thì nó vẫn sẽ giữ nguyên ở vị trí đó trong một khoảng thời gian rất dài. Do vậy tính ổn định không phải liên quan trực tiếp tới tần suất thay đổi. Đồng xu không thay đổi, nhưng khó có thể nghĩ rằng là nó ổn định.

Từ điển Webster đã định nghĩa một thứ gì đó ổn định khi nó “không dễ di chuyển.” Tính ổn định liên quan tới lượng công cần thiết để tạo ra một thay đổi. Một mặt, đồng xu đang dựng không ổn định bởi vì nó cần rất ít công để có thể bị đổ. Mặt khác, một cái bàn rất ổn định bởi vì nó cần một nỗ lực đáng kể để có thể xoay được nó.

Điều này có liên quan tới phần mềm như thế nào? Nhiều yếu tố có thể khiến cho một component phần mềm khó có thể thay đổi được – lấy ví dụ, kích thước, độ phức tạp, và rõ ràng trong những đặc tính khác của nó. Chúng ta sẽ bỏ qua tất cả các yếu tố này và tập trung vào những thứ khác ở đây. Một cách chắc chắn để làm cho một component phần mềm khó có thể thay đổi đó là làm cho rất nhiều những component phần mềm khác phụ thuộc vào nó. Một component có rất nhiều phụ thuộc tới nó sẽ phải rất ổn định bởi vì nó đòi hỏi một lượng lớn công sức để có thể dung hòa bất cứ thay đổi nào với tất cả các component bị phụ thuộc vào nó.

Biểu đồ trong Hình 14.5 cho thấy X là một component ổn định. Ba component phụ thuộc vào X, vì vậy nó có ba nguyên nhân để không phải thay đổi. Chúng ta nói rằng X chịu trách nhiệm cho ba component đó. Ngược lại, x không phụ thuộc gì cả, vì vậy nó không bị ảnh hưởng nào bên ngoài khiến nó thay đổi. Chúng ta nói rằng nó độc lập.

Hình 14‑5 X là một component ổn định

Hình 14.6 cho thấy Y là một component rất không ổn định. Không có component nào phụ thuộc vào Y, vì vậy chúng ta nói rằng nó không chịu trách nhiệm. Y cũng phụ thuộc vào ba component, vì vậy những thay đổi có thể tới từ ba nguồn bên ngoài. Chúng ta nói rằng Y bị phụ thuộc.

Hình 14‑6 Y là một component rất không ổn định

Đo lường tính ổn định

Chúng ta có thể đo lường tính ổn định của một component như thế nào? Có một cách là đếm số lượng phụ thuộc vào và ra của component đó. Số này sẽ cho phép chúng ta tính toán tính ổn định vị trí (positional stability) của component đó.

  • Fan-in: các phụ thuộc vào. Hệ số đo lường này xác định số lượng các lớp bên ngoài component này mà chịu phụ thuộc vào các lớp nằm trong component này.
  • Fan-out: các phụ thuộc ra. Hệ số đo lường này xác định số lượng các lớp bên trong component này mà chịu phụ thuộc vào các lớp bên ngoài component này.
  • I: Tính không ổn định: I = Fan-out / (Fan-in + Fan-out). Hệ số đo lường này có khoảng từ 0 đến 1. I = 0 thể hiện một component có tính ổn định tối đa. I = 1 thể hiện một component có tính không ổn định tối đa.

Hệ số đo lường Fan-in Fan-out [1]được tính toán bằng cách đếm số lượng các lớp bên ngoài component đó mà có mối quan hệ phụ thuộc với các lớp bên trong component. Hãy xem ví dụ trong Hình 14.7.

Hình 14‑7 Ví dụ của chúng ta

Cho là chúng ta muốn tính toán tính ổn định của component Cc. Chúng ta thấy rằng có ba lớp bên ngoài Cc phụ thuộc vào các lớp trong Cc. Do vậy, Fan-in = 3. Ngoài ra, có một lớp bên ngoài Cc mà các lớp trong Cc bị phụ thuộc vào. Do vậy, Fan-out = 1 và I = ¼.

Trong ngôn ngữ C++, sự phụ thuộc này thường được biểu hiện bởi các lệnh #include. Thực vậy, hệ số I là cách dễ nhất để tính toán khi bạn sắp xếp mã nguồn sao cho chỉ có một lớp trong một file mã nguồn. Trong Java, hệ số I có thể được tính toán bằng cách đếm lệnh import và các tên gọi đủ điều kiện.

Khi hệ số I bằng 1, nó có nghĩa là không có một component nào khác phụ thuộc vào component này (Fan-in = 0), và component này phụ thuộc vào các component khác (Fan-out > 0). Tình huống này thể hiện component đang không ổn định tối đa; nó không chịu trách nhiệm và bị phụ thuộc. Việc không có gì phụ thuộc vào nó khiến cho component đó không có lý do gì để mà không thay đổi, và component mà nó phụ thuộc vào có thể cho phép nó có nhiều lý do để thay đổi.

Ngược lại, khi hệ số I = 0, nó có nghĩa là component đó được các component khác phụ thuộc vào (Fan-in > 0), nhưng bản thân nó không phụ thuộc vào bất cứ component nào khác (Fan-out = 0). Một component như vậy được gọi là có trách nhiệmđộc lập. Nó ở trạng thái ổn định nhất có thể. Những component phụ thuộc vào nó khiến nó khó bị thay đổi, và nó không bị phụ thuộc nào mà có thể ép nó phải thay đổi.

Nguyên tắc SDP nói rằng hệ số I của một component nên lớn hơn hệ số I của các component mà nó phụ thuộc vào đó. Điều này nghĩa là hệ số I nên giảm dần theo hướng của phụ thuộc.

Không phải tất cả các component cần phải ổn định

Nếu tất cả các component trong một hệ thống ở trạng thái ổn định tối đa thì nghĩa là hệ thống sẽ không thể thay đổi được. Đó không phải là một tình huống đáng mong đợi. Quả thực là chúng ta muốn thiết kế cấu trúc component của chúng ta sao cho một số component là không ổn định còn một số là ổn định. Biểu đồ trong Hình 14.8 cho thấy một cách cấu hình lý tưởng đối với ba component.

Các component không thể thay đổi được ở trên đỉnh và phụ thuộc vào các component ổn định ở bên dưới. Đặt các component không ổn định ở trên đầu của biểu đồ là một quy ước hữu ích bởi vì bất cứ mũi tên nào trỏ hướng lên sẽ vi phạm nguyên tắc SDP (và như chúng ta sẽ thấy sau này, đó là ADP).

Hình 14‑8 Một cấu hình lý tưởng đối với một hệ thống có ba component

Biều đồ hình 14.9 chỉ ra trường hợp nguyên lý SDP có thể bị vi phạm.

Hình 14‑9 Vi phạm SDP

Flexible là một component mà chúng ta thiết kế để dễ thay đổi. Chúng ta muốn Flexible không ổn định. Tuy nhiên, một vài lập trình viên đang làm việc trong component Stable lại bị phụ thuộc vào Flexible. Điều này vi phạm nguyên tắc SDP bởi hệ số I của Stable nhỏ hơn nhiều so với hệ số I của Flexible. Kết quả là Flexible sẽ không còn dễ dàng để thay đổi. Một thay đổi đối với Flexible sẽ buộc chúng ta phải xử lý cả Stable và tất cả những phụ thuộc vào nó.

Để khắc phục vấn đề này, chúng ta bằng cách nào đó phải phá vỡ sự phụ thuộc của Stable vào Flexible. Tại sao sự phụ thuộc này lại tồn tại? Chúng ta hãy coi như có một lớp C trong Flexible mà lớp U trong Stable cần dùng tới (Hình 14.10).

Hình 14‑10 U trong Stable sử dụng C trong Flexible

Chúng ta có thể sửa lỗi này bằng cách sử dụng nguyên lý DIP. Chúng ta tạo ra một interface được gọi là US và đặt nó vào một component tên là UServer. Chúng ta đảm bảo rằng interface này khai báo tất cả các method mà U cần phải dùng. Sau đó chúng ta cho C triển khai theo interface này như thấy ở Hình 14.11. Điều này sẽ phá vỡ sự phụ thuộc của Stable vào Flexible, và buộc cả hai component này phụ thuộc vào UServer. UServer rất ổn định (I = 0), và Flexible vẫn giữ được sự không ổn định cần thiết của nó (I = 1). Tất cả các phụ thuộc bây giờ chảy theo hướng giảm dần I.

Hình 14‑11 C triển khai interface US

Các component trừu tượng

Bạn có thể thấy lạ khi chúng ta tạo ra một component – trong ví dụ này, UService – không có gì cả mà chỉ có một interface. Component như vậy không có code thực thi được! Tuy nhiên, hóa ra đây lại là chiến thuật được sử dụng rất phổ biến và cần thiết khi dùng các ngôn ngữ kiểu tĩnh như Java và C#. Các component trừu tượng này rất ổn định và do đó là mục tiêu lý tưởng để các component ít ổn định phụ thuộc vào nó.

Khi dùng các ngôn ngữ kiểu động như Ruby và Python, các component trừu tượng này hoàn toàn không tồn tại, cũng như không tồn tại các phụ thuộc vào chúng. Cấu trúc phụ thuộc trong những ngôn ngữ như thế này đơn giản hơn nhiều bởi vì việc đảo ngược phụ thuộc không yêu cầu phải khai báo hoặc kế thừa các interface.

Nguyên lý trừu tượng ổn định (Stable Abstractions Principle)

Một component cần ổn định như trừu tượng.

Chúng ta đặt các logic nghiệp vụ cấp cao ở đâu?

Một vài phần trong hệ thống không nên thay đổi thường xuyên. Những phần này thể hiện cấu trúc và các quyết định logic nghiệp vụ ở cấp cao. Chúng ta không muốn những quyết định có tính kiến trúc và nghiệp vụ này dễ bị thay đổi. Do đó phần đóng gói các logic nghiệp vụ ở cấp cao của hệ thống cần được đặt trong các component ổn định (I = 0). Các component không ổn định (I = 1) chỉ nên bao gồm những phần dễ thay đổi mà chúng ta muốn chúng có thể nhanh chóng và dễ dàng thay đổi.

Tuy nhiên, nếu các logic nghiệp vụ ở cấp cao được đặt trong các component ổn định thì mã nguồn thể hiện những logic nghiệp vụ này sẽ khó có thể thay đổi được. Điều này có thể khiến cho kiến trúc tổng thể của hệ thống không được linh động. Làm cách nào mà một component ở trạng thái ổn định tối đa (I = 0)lại có đủ sự linh động để chống chịu với những thay đổi? Câu trả lời được tìm thấy trong nguyên lý OCP. Nguyên lý này nói cho chúng ta rằng có thể và kỳ vọng tạo ra các lớp có đủ sự linh động để mở rộng mà không cần phải sửa đổi. Loại lớp nào thỏa mãn được nguyên lý này? Các lớp trừu tượng.

Giới thiệu nguyên lý trừu tượng ổn định

Nguyên lý trừu tượng ổn định (SAP) thiết lập một mối liên hệ giữa tính ổn định và sự trừu tượng. Một mặt, nó nói rằng một component ổn định cũng nên là trừu tượng để tính ổn định của nó không cản trở nó khỏi việc được mở rộng. Một mặt, nó nói rằng một component không ổn định nên là một thực thể do tính không ổn định của nó cho phép các code thực thể trong nó dễ dàng được thay đổi.

Do vậy, nếu là một component ổn định, nó cần phải bao gồm các interface và các lớp trừu tượng để nó có thể được mở rộng. Các component ổn định có thể mở rộng và có tính linh động và không bị ràng buộc quá vào kiến trúc.

Các nguyên lý SAP và SDP kết hợp thành nguyên lý DIP đối với các component. Điều này là thực tế bởi vì SDP nói rằng các phụ thuộc nên chạy theo hướng của sự ổn định, và nguyên lý SAP nói rằng tính ổn định thể hiện sự trừu tượng. Do vậy các phụ thuộc sẽ chạy theo hướng của sự trừu tượng.

Tuy nhiên nguyên lý DIP là một nguyên lý liên quan tới các lớp – và với các lớp thì không có sắc thái xám. Dù một lớp có trừu tượng hay không. Sự kết hợp của nguyên lý SDP và SAP liên quan tới các component, và cho phép một component có thể trừu tượng một phần và ổn định một phần.

Đo lường mức độ trừu tượng

Hệ số A được dùng để đo lường mức độ trừu tượng của một component. Giá trị của nó đơn giản là tỷ lệ của số lượng interface và các lớp trừu tượng trong một component so với tổng số lớp có trong component đó.

  • Nc: Số lớp có trong component.
  • Na: Số lớp trừu tượng và interface trong component.
  • A: Mức độ trừu tượng. A = Na / Nc.

Hệ số A nằm trong khoảng từ 0 tới 1. A có giá trị = 0 thể hiện rằng component không có lớp trừu tượng nào cả. A có giá trị = 1 thể hiện rằng component chỉ gồm các lớp trừu tượng.

Chuỗi chính (main sequence)

Bây giờ chúng ta xác định mối liên hệ giữa tính ổn định (I) và sự trừu tượng (A). Để làm được điều này, chúng ta tạo ra một biểu đồ với A nằm ở trục dọc và I nằm trên trục ngang (Hình 14.12). Nếu chúng ta vẽ hai kiểu “đẹp” của các component trên biểu đồ này, chúng ta sẽ thấy rằng component có độ ổn định và trừu tượng tối đa nằm ở phía trên bên trái ở (0, 1). Component không ổn định và cụ thể tối đa nằm ở phía dưới bên phải ở (1, 0).

Hình 14‑12 Biểu đồ I/A

Không phải tất cả các component đều rơi vào một trong hai vị trí này, bởi vì các component thường có một mức độ trừu tượng và ổn định nhất định. Lấy ví dụ, chúng ta rất thường thấy một lớp trừu tượng kế thừa từ một lớp trừu tượng khác. Sự kế thừa này là một trừu tượng có một phụ thuộc. Do vậy, mặc dù nó có tối đa mức độ trừu tượng nhưng nó lại không có được tối đa sự ổn định. Sự phụ thuộc của nó sẽ làm giảm đi tính ổn định của nó.

Do chúng ta không thể ép buộc một quy định là tất cả các component hoặc là ở (0, 1) hoặc là ở (1, 0), chúng ta buộc phải xem như có quỹ tích các điểm trên biểu đồ A/I xác định được vị trí hợp lý cho các component. Chúng ta có thể suy ra quỹ tích đó là gì bằng cách tìm các vùng không nên có các component — nói cách khác, bằng cách xác định các vùng loại trừ (Hình 11.13).

Hình 14‑13 Các khu vực loại trừ

Vùng vất vả (Zone of Pain)

Hãy xem một component trong khu vực (0, 0). Đây là một component ổn định và có tính cụ thể cao. Một component như vậy không phải là điều chúng ta mong muốn bởi vì nó rất cứng nhắc. Nó không thể được mở rộng bởi vì nó không phải trừu tượng, và nó rất khó có thể thay đổi bởi vì tính ổn định của nó. Do vậy chúng ta bình thường không mong muốn nhìn thấy các component được thiết kế tốt nằm ở gần (0, 0). Khu vực quanh (0, 0) là một khu vực bị loại trừ và được gọi là Vùng Vất Vả (Zone of Pain).

Trong thực tế, một vài thành phần của phần mềm sẽ rơi vào trong Vùng Vất Vả. Một ví dụ đó là sơ đồ cơ sở dữ liệu (database schema). Sơ đồ cơ sở dữ liệu nổi tiếng dễ thay đổi, có tính cụ thể cực kỳ, và bị phụ thuộc cực cao. Đây là một nguyên nhân tại sao interface giữa các ứng dụng OO và cơ sở dữ liệu lại rất khó để quản lý, và tại sao việc cập nhật sơ đồ dữ liệu thường rất vất vả.

Một ví dụ khác của phần mềm nằm gần khu vực (0, 0) đó là các thư viện tiện ích cụ thể. Mặc dù một thư viện như vậy có hệ số I bằng 1, nhưng nó thực tế có thể không dễ thay đổi. Hãy xem component String làm ví dụ. Mặc dù tất cả các lớp trong nó đều cụ thể nhưng nó được dùng phổ biến đến mức việc thay đổi nó sẽ tạo ra một sự hỗn loạn ngay lập tức. Bởi vậy String có tính không thay đổi.

Các component không thay đổi vô hại trong vùng (0, 0) do chúng sẽ không bị thay đổi. Vì lý do đó, chỉ có những component phần mềm dễ thay đổi là sẽ gây ra vấn đề ở Vùng Vất Vả. Một component càng dễ thay đổi nằm trong Vùng Vất Vả thì nó lại càng “vất vả”. Quả là chúng ta có thể xem tính dễ thay đổi như là một trục thứ ba của biểu đồ. Với việc hiểu như vậy, Hình 14.3 chỉ ra mặt phẳng vất vả nhất, nơi mà có tính dễ thay đổi = 1.

Vùng Vô Dụng (Zone of Uselessness)

Hãy xem một component gần (1, 1). Vị trí này không phải là điều mong muốn bởi vì nó có mức trừu tượng tối đa, cũng như không có gì phụ thuộc vào nó. Những component như vậy là vô dụng. Do vậy vùng này được gọi là Vùng Vô Dụng.

Các thành phần phần mềm nằm trong vùng này thường là bị bỏ quên. Chúng là các lớp trừu tượng sót lại khi không lớp nào triển khai nó. Đôi khi, chúng ta thấy chúng trong các hệ thống, nằm trong bộ mã nguồn mà không được sử dụng.

Một component có một vị trí sâu trong Vùng Vô Dụng thì buộc phải gồm một lượng đáng kể các thành phần như vậy. Rõ ràng, sự hiện diện của những thành phần vô dụng là điều không ai mong muốn và cần phải được dọn dẹp.

Tránh các vùng loại trừ

Có vẻ rõ ràng là những component dễ thay đổi nhất nên được giữ càng xa cả hai vùng loại trừ này càng tốt. Quỹ tích của các điểm có khoảng cách xa nhất so với mỗi vùng này là một đường thẳng kết nối (1, 0) và (0, 1). Tôi gọi đường này là Chuỗi Chính (Main Sequence[2]).

Một component nằm trên Chuỗi Chính không “quá trừu tượng” đối với tính ổn định của nó, mà cũng không “quá không ổn định” đối với độ trừu tượng của nó. Nó không vô dụng mà cũng không quá gây vất vả. Nó được phụ thuộc tới độ nó là trừu tượng, và nó phụ thuộc vào những thứ khác tới độ nó là cụ thể.

Vị trí mong muốn nhất đối với một component là ở một trong hai điểm cuối của Chuỗi Chính. Các kiến trúc sư giỏi sẽ cố gắng đạt tới vị trí mà phần lớn các component của họ sẽ nằm ở hai đầu này. Tuy nhiên, theo kinh nghiệm của tôi, chỉ có một tỷ lệ nhỏ các componet trong một hệ thống lớn không hoàn toàn trừu tượng cũng không hoàn toàn ổn định. Những component này có những đặc tính tốt nhất nếu chúng nằm trên, hoặc gần với Chuỗi Chính.

Khoảng cách so với chuỗi chính

Điều này dẫn chúng ta tới hệ số đo lường cuối cùng. Nếu chúng ta mong muốn các component nằm trên hoặc sát với Chuỗi Chính, thì chúng ta có thể tạo ra một hệ số đo lường để đo xem một component còn cách mục tiêu lý tưởng này là bao xa.

  • D[3]: Khoảng cách. D = |A+I-1|. Hệ số này nằm trong khoảng [0, 1]. Giá trị của nó = 0 nghĩa là component này nằm trực tiếp trên Chuỗi Chính. Giá trị của nó = 1 nghĩa là component này cách xa nhất khỏi Chuỗi Chính.

Đưa ra hệ số này, một thiết kế có thể được phân tích để xem sự tương quan tổng thể của nó so với Chuỗi Chính. Hệ số D đối với mỗi component có thể được tính toán. Bất cứ component nào mà có giá trị D không gần với 0 thì có thể được xem xét và cấu trúc lại.

Việc phân tích thống kê của một thiết kế cũng khả thi. Chúng ta có thể tính toán giá trị trung bình và phương sai của tất cả các hệ số D đối với các component nằm trong một thiết kế. Chúng ta mong muốn một thiết kế thích hợp sẽ có giá trị trung bình và phương sai gần bằng 0. Phương sai có thể được dùng để thiết lập “các giới hạn điều khiển” để xác định xem component có phải “ngoại lệ” khi so với các component khác hay không.

Trong biểu đồ scatter ở Hình 14.14, chúng ta thấy một lượng lớn các component nằm dọc theo Chuỗi Chính, nhưng có một số lớn hơn độ lệch chuẩn (Z = 1) khỏi đường trung bình. Những component khác thường này đáng để được xem xét kỹ lại. Vì một vài lý do, chúng có thể rất trừu tượng mà rất ít bị phụ thuộc hoặc là rất cụ thể mà có nhiều phụ thuộc vào.

Hình 14‑14 Biểu đồ scatter của các component

Một cách khác để dùng hệ số này vẽ biểu đồ hệ số D của mỗi component theo thời gian. Biểu đồ trong Hình 14.15 là một ví dụ cho kiểu biểu đồ như vậy. Bạn có thể thấy rằng một số phụ thuộc kỳ lạ đã len lỏi vào component Payroll trong những đợt phát hành gần đây. Biểu đồ chỉ ra giới hạn điều khiển tại D = 0.1. Điểm R2.1 đã vượt quá giới hạn này, vì vậy chúng ta sẽ cần tìm hiểu tại sao component này lại cách quá xa so với Chuỗi Chính.

Hình 14‑15 Biểu đồ D của một component theo thời gian

Kết luận

Các hệ số quản lý phụ thuộc được mô tả trong chương này dùng để đo lường mức độ phù hợp của một thiết kế đối với mẫu thiết kế về mức độ phụ thuộc và mức độ trừu tượng, mà tôi nghĩ đó là một mẫu thiết kế “tốt”. Kinh nghiệm đã chỉ ra rằng một số phụ thuộc nhất định thì tốt còn một số thì tệ. Mẫu thiết kế này phản ánh được kinh nghiệm đó. Tuy nhiên, một hệ số đo không phải là thần thánh gì; nó chỉ đơn thuần là một sự đo lường so với một tiêu chuẩn không được quy định rõ ràng. Những hệ số này không phải là hoàn hảo, nhưng tôi hy vọng bạn vẫn sẽ thấy chúng hữu ích.


[1] Trong lần xuất bản trước, tôi đã dùng cặp tên Efferent Afferent (Ce Ca) tương ứng với Fan-out Fan-in. Đó là sự tỏ vẻ của tôi: Tôi đã thích sử dụng cách ẩn dụ về hệ thần kinh trung ương.

[2] Tác giả cầu xin độc giả tha thứ khi mượn một thuật ngữ quan trọng như vậy từ thiên văn học.

[3] Trong lần xuất bản trước, tôi đã gọi hệ số đo này là D’. Tôi không thấy lý do gì để tiếp tục như vậy.

Clean Architecture – Chương 13. Sự Gắn Kết Các Component

Lớp nào sẽ thuộc về component nào? Đây là một quyết định quan trọng, và đòi hỏi sự hướng dẫn từ những nguyên lý kỹ thuật phần mềm tốt. Không may thay, trải qua nhiều năm, quyết định này đều được đưa ra một cách tùy ý dựa gần như hoàn toàn vào bối cảnh.

Trong chương này chúng ta sẽ thảo luận ba nguyên lý của sự gắn kết component:

  • REP: Nguyên Lý Tái Sử Dụng/Phát Hành Tương Đương (Reuse/Release Equivalence Principle)
  • CCP: Nguyên Lý Khép Kín Chung (Common Closure Principle)
  • CRP: Nguyên Lý Tái Sử Dụng Chung (Common Reuse Principle)

Nguyên lý tái sử dụng/phát hành tương đương

Thập niên gần đây đã chứng kiến sự gia tăng của một loạt các công cụ quản lý module, ví dụ như Maven, Leiningen, và RVM. Những công cụ này đã đóng một vai trò quan trọng bởi vì trong suốt thời kỳ đó, một số lượng khổng lồ các component có thể tái sử dụng và các thư viện component đã được tạo ra. Chúng ta hiện nay đang sống trong kỷ nguyên của phần mềm tái sử dụng – hoàn thành một trong những lời hứa xưa nhất của mô hình hướng đối tượng.

Nguyên lý Tái Sử Dụng/Phát Hành Tương Đương (REP) là một nguyên lý có vẻ hiển nhiên, ít nhất là trong nhận thức muộn màng. Những người muốn tái sử dụng các component phần mềm sẽ không thể, và không nên tái sử dụng trừ khi những component này được kiểm soát theo một quy trình phát hành và được cung cấp số phiên bản phát hành.

Điều này không đơn giản bởi vì nếu không có các số phiên bản phát hành thì sẽ không có cách nào để đảm bảo tất cả các component được tái sử dụng sẽ tương thích với những cái khác. Hơn nữa nó cũng phản ánh thực tế là những lập trình viên phần mềm cần biết khi nào phiên bản mới được phát hành, và những thay đổi nào mà phiên bản mới đó sẽ mang lại.

Không có gì lạ khi các lập trình viên được thông báo về một bản phát hành mới và quyết định xem có tiếp tục sử dụng phiên bản cũ hay không, dựa trên những thay đổi trong bản phát hành đó. Bởi vậy quy trình phát hành buộc phải tạo ra những thông báo thích hợp và tài liệu cho mỗi lần phát hành để người dùng có thể đưa ra được những quyết định về việc khi nào và có nên tích hợp phiên bản mới vào hay không.

Từ quan điểm kiến trúc và thiết kế phần mềm, nguyên lý này có nghĩa là các lớp và module hình thành nên một component buộc phải thuộc vào một nhóm gắn kết chặt chẽ với nhau. Component không thể chỉ đơn giản bao gồm một mớ lộn xộn ngẫu nhiên các lớp và các module; thay vào đó, nó buộc phải có một chủ đề bao quát hoặc mục đích nào đó mà tất cả những module cùng chia sẻ với nhau.

Dĩ nhiên, đây là điều hiển nhiên. Tuy nhiên, có một cách khác để nhìn vào vấn đề này mà có lẽ nó không được hiển nhiên như vậy. Các lớp và các module được nhóm lại thành một component cần phải có khả năng phát hành cùng lúc với nhau. Thực tế là chúng chia sẻ cùng số hiệu phiên bản và cùng theo dõi phát hành, và được bao gồm trong cùng một tài liệu phát hành, nên chúng cần phải có nghĩa đối với cả tác giả lẫn người dùng.

Đây là một lời khuyên không rõ ràng lắm: Nói cái gì đó cần phải “có nghĩa” cũng chỉ giống như cái cách vẫy tay trong không khí và cố gắng tỏ ra vẻ có quyền hành. Lời khuyên này không rõ ràng bởi vì nó khó để giải thích chính xác mối gắn kết giữa các lớp và module lại với nhau thành một component. Mặc dù lời khuyên này không rõ ràng, nhưng bản thân nguyên lý này lại rất quan trọng, bởi vì các vi phạm rất dễ dàng để phát hiện – chúng không “có ý nghĩa” gì cả. Nếu bạn vi phạm nguyên lý REP, người dùng của bạn sẽ biết, và họ sẽ không ấn tượng với kỹ năng thiết kế kiến trúc của bạn.

Điểm yếu của nguyên lý này đã được bù đắp nhiều hơn bởi điểm mạnh của hai nguyên lý tiếp theo. Thực vậy, các nguyên lý CCP và CRP xác định rõ nguyên tắc này nhưng theo nghĩa tiêu cực.

Nguyên lý khép kín chung

Tập hợp thành component những lớp mà sẽ được thay đổi vì cùng nguyên nhân và vào cùng một thời điểm. Việc phân tách thành những component khác khi mà những lớp thay đổi vào những lúc khác nhau và vì những nguyên nhân khác nhau.

Đây là Nguyên Lý Đơn Nhiệm SRP được phát biểu lại đối với các component. Cũng như nguyên lý SRP nói rằng một lớp không nên có nhiều lý do để thay đổi, thì Nguyên Lý Khép Kín Chung (CCP) cũng nói rằng một component cũng không nên có nhiều lý do để thay đổi.

Đối với phần lớn các ứng dụng, khả năng bảo trì được sẽ quan trọng hơn là khả năng dùng được. Nếu code trong một ứng dụng buộc phải thay đổi, thì tốt hơn hết là tất cả những thay đổi đó nên xảy ra trong một component, hơn là bị phân tán qua nhiều component[1]. Nếu những thay đổi chỉ hạn chế trong một component, thì chúng ta sẽ chỉ cần triển khai lại một component bị thay đổi đó thôi. Các component khác không phụ thuộc vào component bị thay đổi sẽ không cần phải được kiểm tra hoặc triển khai lại.

Nguyên lý CCP nhắc chúng ta tập hợp cùng nhau vào một nơi tất cả các lớp có khả năng sẽ thay đổi vì cùng một nguyên nhân. Nếu hai lớp gắn kết với nhau quá chặt đến nỗi chúng luôn thay đổi cùng nhau thì khi đó chúng sẽ thuộc về cùng một component. Điều này cũng tối thiểu hóa các công việc liên quan tới việc phát hành, kiểm tra và triển khai lại phần mềm.

Nguyên lý này gắn kết chặt chẽ với Nguyên Lý Mở-Đóng (OCP). Thực vậy, nó được “khép kín” theo nghĩa của nguyên lý OCP với từ mà nguyên lý CCP đề cập. Nguyên lý OCP nói rằng các lớp nên đóng không cho sửa đổi nhưng lại mở ra cho phép mở rộng. Do việc khép kín 100% là điều không thể đạt được, nên việc khép kín ở đây phải hiểu là chiến lược. Chúng ta thiết kế các lớp sao cho chúng đóng đối với những dạng thay đổi thông thường nhất mà chúng ta có thể dự đoán được hoặc đã từng có kinh nghiệm.

Nguyên lý CCP nhấn mạnh bài học phải tập hợp vào cùng một component những lớp khép kín với những thay đổi cùng loại. Do đó, khi một thay đổi theo yêu cầu cần thực hiện thì thay đổi đó có khả năng cao là sẽ chỉ giới hạn thay đổi trong một số tối thiểu các component.

Tương tự với nguyên lý SRP

Như đã phát biểu trước đó, nguyên lý CCP là dạng component của nguyên lý SRP. SRP nói với chúng ta rằng nên tách các method vào các lớp khác nhau nếu chúng thay đổi vì những lý do khác nhau. CCP thì nói với chúng ta rằng việc phân tách lớp thành những component khác nhau nếu chúng thay đổi vì những lý do khác nhau. Cả hai nguyên lý có thể được tổng kết như sau:

Tập hợp cùng nhau những thứ thay đổi vào cùng một thời điểm và vì cùng một lý do. Phân tách những thứ thay đổi ở những thời điểm khác nhau hoặc vì những lý do khác nhau.

Nguyên lý tái sử dụng chung

Đừng ép người dùng một component phải phụ thuộc vào thứ mà họ không cần.

Nguyên Lý Tái Sử Dụng Chung (CRP) là một nguyên lý khác giúp chúng ta quyết định xem lớp nào và module nào nên được đặt vào trong một component. Nó nói rằng các lớp và các module có thể tái sử dụng cùng nhau sẽ thuộc về cùng một component.

Các lớp hiếm khi được tái sử dụng trong điều kiện cách ly. Thông thường, các lớp có thể tái sử dụng kết hợp với các lớp khác là một phần của một bộ trừu tượng tái sử dụng được. CRP nói rằng những lớp này cùng thuộc một component. Trong một component như vậy chúng ta sẽ thấy các lớp của nó sẽ có rất nhiều phụ thuộc lẫn nhau.

Một ví dụ đơn giản có thể là một lớp chứa (container class) và các lặp lại gắn với nó. Những lớp này được tái sử dụng cùng nhau bởi vì chúng được gắn kết chặt chẽ với nhau. Do đó chúng phải ở trong cùng một component.

Nhưng nguyên lý CRP nói cho chúng ta không chỉ về việc các lớp đặt cùng nhau trong một component: Nó còn nói với chúng ta về những lớp không được giữ cùng nhau trong một component. Khi một component dùng một component khác thì một phụ thuộc đã được tạo ra giữa các component. Có lẽ việc component sử dụng chỉ dùng duy nhất một lớp nằm trong component được sử dụng – cũng không hề làm yếu đi sự phụ thuộc. Khi đó component sử dụng vẫn phụ thuộc vào component được sử dụng.

Bởi vì sự phụ thuộc đó, mỗi khi component được sử dụng thay đổi thìcomponent sử dụng cũng sẽ cần những thay đổi tương ứng. Ngay cả khi những thay đổi đều không cần thiết cho component sử dụng, thì nó vẫn cần phải được biên dịch lại, kiểm tra lại, và triển khai lại. Đây là sự thật ngay cả khi component sử dụng không quan tâm gì tới thay đổi có trong component được sử dụng.

Do đó khi chúng ta phụ thuộc vào một component, thì chúng ta muốn đảm bảo rằng chúng ta phụ thuộc vào mọi lớp có trong component đó. Nói cách khác, chúng ta muốn đảm bảo rằng những lớp mà chúng ta đặt chúng vào trong một component là không thể tách rời khỏi nhau – nghĩa là không thể chỉ phụ thuộc vào một vài lớp mà không phụ thuộc vào các còn lại. Nếu không, chúng ta sẽ phải triển khai lại các component nhiều hơn cần thiết và lãng phí đáng kể công sức.

Do đó nguyên lý CRP nói cho chúng ta nhiều hơn về cách mà các lớp không nên có cùng nhau hơn là cách mà các lớp nên có cùng nhau trong cùng một component. CRP nói rằng các lớp không gắn kết chặt chẽ với nhau thì không nên ở trong cùng một component.

Mối liên hệ với ISP

Nguyên lý CRP là phiên bản khái quát của nguyên lý ISP. ISP khuyên chúng ta không phụ thuộc vào các lớp mà có các method chúng ta không sử dụng. CRP khuyên chúng ta không phụ thuộc vào component mà có các lớp mà chúng ta không sử dụng.

Tất cả những lời khuyên này có thể tổng kết thành một câu duy nhất:

Đừng phụ thuộc vào thứ mà bạn không cần.

Biểu đồ gắn kết component

Bạn có thể đã nhận ra rằng ba nguyên lý gắn kết này có khuynh hướng là đối đầu lần nhau. Nguyên lý REP và CCP là các nguyên lý bao gồm (inclusive principle): Cả hai đều có khuynh hướng làm cho các component lớn hơn. Nguyên lý CRP lại là một nguyên lý loại trừ (exclusive principle), nó hướng các component nhỏ đi. Đó là sự giằng co giữa các nguyên lý mà một kiến trúc sư giỏi cần phải tìm cách giải quyết.

Hình 13.1 là một biểu đồ sức căng[2] chỉ ra cách mà ba nguyên lý gắn kết này tương tác với nhau. Các cạnh của biểu đồ mô tả chi phí của việc từ bỏ nguyên tắc của đỉnh đối diện.

Hình 13‑1 Biểu đồ sức căng giữa các nguyên lý gắn kết

Một kiến trúc sư chỉ tập trung vào nguyên lý REP và CRP sẽ thấy quá nhiều component bị ảnh hưởng khi chỉ cần thực hiện những thay đổi đơn giản. Ngược lại, một kiến trúc sư tập trung quá nhiều vào CCP và REP sẽ gây ra việc tạo ra nhiều lần phát hành không cần thiết.

Một kiến trúc sư giỏi sẽ tìm ra được một điểm mà ở đó tam giác sức căng thỏa mãn được những lo ngại hiện giờ của đội phát triển, nhưng cũng cân nhắc tới việc những lo ngại này sẽ có thể thay đổi. Lấy ví dụ, trong giai đoạn đầu phát triển một dự án, nguyên lý CCP sẽ quan trọng hơn nguyên lý REP, bởi vì khả năng phát triển quan trọng hơn việc tái sử dụng.

Nói chung, các dự án thường có khuynh hướng bắt đầu từ phía tay phải của hình tam giác trên, nơi mà chỉ phải hy sinh khả năng tái sử dụng. Một khi dự án đã trưởng thành, và các dự án khác bắt đầu sử dụng nó, thì mức độ ưu tiên các nguyên lý sẽ trượt về phía trái. Điều này có nghĩa là cấu trúc component của một dự án có thể thay đổi theo thời gian và tùy mức độ trưởng thành của dự án. Nó liên quan nhiều hơn đến cách dự án đó được phát triển và sử dụng, hơn là những gì dự án đó thực sự làm.

Kết luận

Trong quá khứ, quan điểm của chúng tôi về sự gắn kết thì đơn giản hơn nhiều những gì nguyên lý REP, CCP, và CRP đã nói tới. Chúng tôi đã từng nghĩ rằng sự gắn kết chỉ đơn giản là một thuộc tính mà trong đó một module sẽ thực hiện một và chỉ một chức năng. Tuy nhiên, ba nguyên lý của sự gắn kết component đã mô tả sự thay đổi tính chất gắn kết phức tạp hơn nhiều. Để chọn các lớp để nhóm chúng lại thành các component thì chúng ta buộc phải cân nhắc đến phản lực liên quan tới khả năng tái sử dụng và khả năng phát triển. Việc cân bằng các lực này tùy theo mức độ đòi hỏi của ứng dụng là vấn đề không hề đơn giản. Hơn nữa, sự cân bằng thì hầu như luôn luôn có tính động. Ngày hôm nay việc phân chia như vậy là thích hợp có thể sẽ không còn thích hợp vào năm tới. Kết quả là thành phần của các component sẽ bị xáo trộn và tiến hóa theo thời gian khi mà trọng tâm của dự án thay đổi từ khả năng phát triển được sang khả năng tái sử dụng được.


[1] Xem phần “Vấn đề Kitty” trong Chương 27, “Các dịch vụ: Vĩ đại và Nhỏ.”

[2] Cảm ơn Tim Ottinger vì ý tưởng này.

Clean Architecture – Chương 12. Các Component

Các nguyên lý component

Nếu các nguyên lý SOLID cho chúng ta biết cách để sắp xếp các viên gạch để xây tường và phòng, thì các nguyên lý component sẽ cho chúng ta biết cách sắp xếp các phòng thành các tòa nhà. Những hệ thống phần mềm lớn, cũng giống như những tòa nhà lớn, đều được xây nên bởi những component nhỏ hơn. Trong Phần IV, chúng ta sẽ thảo luận các component phần mềm là cái gì, những yếu tố nào cấu thành nên chúng và chúng cần phải được kết hợp với nhau như thế nào trong một hệ thống.

Các component

Các component là các đơn vị triển khai code. Chúng là những thực thể nhỏ nhất có thể được triển khai như là một phần của hệ thống. Trong Java, chúng là những file jar. Trong Ruby, chúng là những file gem. Trong .Net, chúng là các DLL. Trong các ngôn ngữ biên dịch, chúng là tập hợp của các file nhị phân. Trong các ngôn ngữ thông dịch, chúng là tập hợp của các file nguồn. Trong tất cả các ngôn ngữ, chúng là một phần của việc triển khai code.

Các component có thể được liên kết với nhau thành một file thực thi duy nhất. Hoặc chúng có thể tập hợp với nhau thành một file chứa, ví dụ như file .war. Hoặc chúng cót thể được triển khai độc lập thành những plugin có thể nạp động riêng rẽ, ví dụ như .jar hoặc .dll hoặc file .exe. Cho dù cuối cùng chúng được triển khai như thế nào đi nữa thì các component được thiết kế tốt sẽ luôn duy trì được khả năng có thể triển khai độc lập và do đó chúng sẽ có thể phát triển một cách độc lập.

Lược sử các component

Vào những năm đầu của ngành phát triển phần mềm, các lập trình viên điều khiển vị trí bộ nhớ và bố cục của các chương trình của họ. Một trong những dòng đầu tiên của code chương trình sẽ là câu lệnh origin (gốc), nó khai báo địa chỉ mà chương trình sẽ được nạp.

Hãy xem chương trình PDP-8 đơn giản sau. Nó bao gồm một thủ tục con tên là GETSTR được dùng để nhận chuỗi ký tự từ bàn phím và lưu nó vào trong một bộ nhớ đệm. Nó cũng có một chương trình kiểm tra nhỏ để kiểm tra GETSTR.

 *200
 TLS
START,CLA
 TAD BUFR
 JMS GETSTR
 CLA
 TAD BUFR
 JMS PUTSTR
 JMP START
BUFR,3000
  
GETSTR,0
 DCA PTR
NXTCH,KSF
 JMP -1
 KRB
 DCA I PTR
 TAD I PTR
 AND K177
 ISZ PTR
 TAD MCR
 SZA
 JMP NXTCH
  
K177,177
MCR,-15

Lưu ý  lệnh *200 ở đầu chương trình này. Nó nói cho trình biên dịch là nó sẽ được nạp ở địa chỉ 2008.

Kiểu lập trình này là một khái niệm xa lạ với phần lớn lập trình viên ngày nay. Họ hiếm khi phải nghĩ về nơi mà một chương trình sẽ được nạp vào bộ nhớ của máy tính. Nhưng trong những ngày đầu đó, điều này là một trong những quyết định đầu tiên của một lập trình viên cần phải thực hiện. Vào thời điểm đó, các chương trình không có khả năng tái định vị trong bộ nhớ.

Vậy bạn truy cập một hàm trong thư viện vào những ngày đó như thế nào? Đoạn code phía trên đã mô tả phương pháp được sử dụng. Các lập trình viên thêm mã nguồn của các hàm thư viện vào trong code ứng dụng của họ, và biên dịch tất cả thành một chương trình[1] duy nhất. Các thư viện được lưu dưới dạng mã nguồn, chứ không phải dạng nhị phân.

Vấn đề với phương pháp này đó là trong thời kỳ đó, các thiết bị đều xử lý rất chậm và bộ nhớ thì rất đắt đỏ và do đó bị hạn chế. Các trình biên dịch cần thực hiện một số lần chuyển mã nguồn, nhưng bộ nhớ quá hạn chế để lưu giữ  được tất cả mã nguồn. Do đó, trình biên dịch đã phải đọc mã nguồn nhiều lần bằng các thiết bị chậm chạp.

Điều này mất rất nhiều thời gian – và thư viện hàm của bạn càng lớn thì trình biên dịch càng lâu. Việc biên dịch một chương trình lớn có thể mất hàng tiếng đồng hồ.

Để rút ngắn thời gian biên dịch, các lập trình viên đã tách biệt mã nguồn của thư viện hàm khỏi ứng dụng. Họ biên dịch thư viện hàm riêng và nạp thư viện ở dạng nhị phân vào một địa chỉ đã biết – ví dự như 20008. Họ đã tạo ra một bảng ký hiệu cho thư viện hàm và biên dịch thứ đó với code ứng dụng của họ. Khi họ muốn chạy một ứng dụng, họ sẽ nạp thư viện[2] hàm ở dạng nhị phân, và sau đó nạp ứng dụng. Bộ nhớ được bố trí giống như trong Hình 12.1.

Hình 12‑1 Bố trí bộ nhớ thời kỳ đầu

Cách này hoạt động tốt với điều kiện ứng dụng nằm vừa trong khoảng giữa địa chỉ 00008 và 17778. Nhưng các ứng dụng đã sớm phát triển ngày càng lớn hơn so với không gian nhớ được cấp phát cho chúng. Lúc đó, các lập trình viên phải chia ứng dụng của họ thành hai vùng địa chỉ, nhảy qua vùng địa chỉ thư viện hàm (Hình 12.2).

Hình 12‑2 Chia ứng dụng thành hai vùng địa chỉ

Hiển nhiên đây không phải là một phương pháp bền vững. Khi các lập trình viên thêm nhiều hơn các hàm vào thư viện hàm, nó sẽ vượt qua giới hạn này và họ sẽ phải cấp phát thêm không gian nhớ cho nó (trong ví dụ này là gần 70008). Sự phân mảnh này của các chương trình và thư viện là điều cần thiết khi bộ nhớ máy tính lớn lên.

Rõ ràng, chúng ta phải làm cái gì đó.

Khả năng tái định vị vùng nhớ

Giải pháp đó là các file nhị phân có thể tái định vị được. Ý tưởng này rất đơn giản. Trình biên dịch được thay đổi để xuất ra mã nhị phân mà có thể tái định vị được trong bộ nhớ bởi một bộ nạp (loader) thông minh. Bộ nạp này sẽ được cung cấp nơi nào để nạp mã có thể tái định vị đó. Mã này được gắn bằng nhiều cờ để nói với bộ nạp về những phần của dữ liệu nạp nào phải được thay thế để có thể nạp được tại địa chỉ lựa chọn. Thường thì điều này nghĩa là chỉ cần thêm địa chỉ đầu tiên vào bất cứ địa chỉ tham chiếu bộ nhớ nào trong file nhị phân.

Bây giờ lập trình viên có thể nói với bộ nạp này nơi nào để nạp thư viện hàm, và nơi nào để nạp ứng dụng. Trong thực tế, các bộ nạp sẽ chấp nhận vài file nhị phân đầu vào và đơn giản là nạp chúng trong bộ nhớ cái này cạnh cái kia, tái định vị chúng theo như cách nó nạp chúng. Điều này cho phép các lập trình viên chỉ phải nạp những hàm mà họ cần.

Trình biên dịch cũng được thay đổi để cung cấp tên hàm như là một metadata (siêu dữ liệu) trong file nhị phân có thể tái định vị được đó. Nếu một chương trình gọi một hàm thư viện, thì trình biên dịch sẽ cung cấp tên hàm như là một ngoại tham chiếu (external reference). Nếu một chương trình định nghĩa một hàm thư viện, trình biên dịch sẽ cung cấp tên hàm như một ngoại định nghĩa (external definition). Sau đó bộ nạp có thể liên kết ngoại tham chiếu đó với ngoại định nghĩa khi mà nó đã xác định được nơi nào nó có thể nạp được những định nghĩa này.

Và như vậy bộ nạp liên kết được ra đời.

Các bộ liên kết

Bộ nạp liên kết đó cho phép các lập trình viên chia chương trình của họ thành những phần có thể biên dịch được và có thể nạp tách biệt nhau. Phương pháp này hoạt động tốt khi mà các chương trình tương đối nhỏ được liên kết với những thư viện cũng tương đối nhỏ. Tuy nhiên, trong cuối những năm 1960 và đầu những năm 1970, các lập trình viên có nhiều tham vọng hơn, và các chương trình của họ ngày càng lớn hơn.

Cuối cùng, các bộ nạp liên kết này trở nên quá chậm chạp để có thể chịu đựng được. Các thư viện hàm được lưu trữ trên các thiết bị chậm như băng từ. Thậm chí cả đĩa cứng vào thời đó cũng khá chậm chạp. Trong khi sử dụng những thiết bị tương đối chậm này, các bộ nạp liên kiết lại phải đọc hàng tá, nếu không muốn nói là hàng trăm các thư viện nhị phân để phân giải những ngoại tham chiếu này. Khi các chương trình ngày một lớn hơn, và các hàm thư viện được tích lũy trong thư viện ngày một nhiều hơn, bộ nạp liên kết sẽ phải mất hàng giờ chỉ để nạp được chương trình đó.

Cuối cùng, việc nạp và việc liên kết đã được tách ra thành hai giai đoạn. Các lập trình viên đã lấy phần chậm – phần thực hiện việc liên kết – và đặt nó vào trong một ứng dụng riêng được gọi là bộ liên kết (linker). Đầu ra của bộ liên kết này là một file đã được liên kết mà một bộ nạp tái định vị có thể nạp rất nhanh. Điều này cho phép các lập trình viên chuẩn bị một file thực thi bằng cách sử dụng bộ liên kết chậm chạp đó, nhưng sau đó thì chúng lại có thể được nạp rất nhanh, vào bất cứ lúc nào.

Sau đó tới những năm 1980. Các lập trình viên lúc này làm việc với ngôn ngữ C hoặc một ngôn ngữ nào đó ở cấp cao hơn. Các chương trình của họ cũng lớn hơn cùng với tham vọng của họ. Các chương trình có hàng trăm nghìn dòng code lúc này không phải là điều gì bất thường cả.

Các module nguồn được biên dịch từ các file .c thành các file .o, và sau đó chuyển cho các bộ liên kết để tạo ra các file thực thi có thể được nạp nhanh chóng. Việc biên dịch mỗi một module này khá nhanh, nhưng việc biên dịch tất cả các module thì mất nhiều thời gian hơn một chút. Bộ liên kết thì thậm chí còn mất nhiều thời gian hơn nữa. Trong nhiều trường hợp, mỗi chu kỳ biên dịch – liên kết có thể mất tới một hoặc nhiều giờ.

Có vẻ như các lập trình viên đã phải chịu đựng việc mất nhiều thời gian biên dịch một cách không có hồi kết. Trải qua những năm 1960, 1970, và 1980, tất cả những thay đổi được thực hiện để tăng tốc độ công việc đều bị cản lại bởi tham vọng của các lập trình viên, và kích thước các chương trình mà họ viết. Họ có vẻ như không thể thoát được chu kỳ làm việc phải kéo dài hàng giờ. Thời gian nạp vẫn duy trì nhanh chóng, nhưng thời gian biên dịch – liên kết lại bị tắc nghẽn.

Dĩ nhiên chúng ta cũng gặp phải định luật Murphy về kích thước chương trình:

Các chương trình sẽ phát triển tới mức choán đầy tất cả thời gian biên dịch và liên kết khả dụng.

Nhưng Murphy không phải là đối thủ duy nhất trong vùng. Cùng với đó là Moore[3], và vào những năm cuối 1980, cả hai đã có một cuộc chiến. Moore đã chiến thắng cuộc chiến đó. Đĩa cứng đã bắt đầu co nhỏ lại và nhanh hơn đáng kể. Bộ nhớ máy tính đã bắt đầu rẻ đến mức mà nhiều dữ liệu trên đĩa cứng đã có thể lưu đệm trong RAM. Tốc độ máy tính cũng tăng từ 1 MHz lên 100 MHz.

Vào giữa những năm 1990, thời gian dùng để liên kết đã bắt đầu giảm nhanh hơn so với tham vọng tạo ra các chương trình ngày càng lớn của chúng ta. Trong nhiều trường hợp, thời gian liên kết đã giảm chỉ còn vài giây. Đối với những công việc nhỏ, ý tưởng về một bộ nạp liên kết lại trở nên khả thi.

Đây là kỷ nguyên của Active-X, các thư viện chia sẻ, và là sự khởi đầu của các file .jar. Máy tính và các thiết bị trở nên nhanh đến nỗi chúng ta có thể, lại một lần nữa, thực hiện việc liên kết cùng lúc vào thời điểm nạp. Chúng ta có thể liên kết vài file .jar, hoặc vài thư viện chia sẻ chỉ trong vài giây, và thực thi chương trình. Và vì vậy kiến trúc component plugin đã được ra đời.

Ngày nay chúng ta thường dùng các file .jar hoặc DLL hoặc các thư viện chia sẻ như là các plugin cho các ứng dụng đang có. Lấy ví dụ, nếu bạn muốn tạo ra một bản chỉnh sửa (mod) cho trò Mincecraft thì bạn chỉ cần đơn giản là đặt các file .jar sửa đổi của bạn vào một thư mục cụ thể nào đó. Nếu bạn muốn sử dụng Resharper trong Visual Studio, thì bạn chỉ cần đơn giản thêm vào các file DLL thích hợp.

Kết luận

Các file liên kết động, có thể được gắn vào nhau lúc chạy chương trình, là các component phần mềm trong các kiến trúc của chúng ta. Phải mất tới 50 năm, thì chúng ta mới tới được cái nơi mà kiến trúc component plugin đã trở thành mặc định thông thường, đối nghịch với những nỗ lực không biết mệt mỏi trước đây đã từng có.


[1] Công ty đầu tiên của tôi có hàng tá các bộ thẻ mã nguồn thư viện chương trình con trên kệ. Khi bạn viết một chương trình mới, bạn chỉ cần đơn giản lấy một trong những bộ thẻ đó và gắn nó vào cuối bộ thẻ của bạn.

[2] Thực tế, phần lớn những máy tính thời cổ này dùng bộ nhớ lõi (core memory), nó không bị xóa khi bạn tắt máy tính. Chúng tôi thường để thư viện hàm được nạp trong nhiều ngày vào thời điểm đó.

[3] Định luật Moore: Tốc độ máy tính, bộ nhớ và mật độ đèn bán dẫn sẽ tăng lên gấp đôi sau mỗi 18 tháng. Luật này duy trì từ năm 1950 cho tới năm 2000, nhưng sau đó, ít nhất là đối với tốc độ gia tăng xung nhịp đã bị dừng lại.