logoMột hệ thống khó hiểu thì cũng khó thay đổi
Common Closure Principle - CCP

Common Closure Principle - CCP là một trong các nguyên lý thành phần (Component Principles) quan trọng mà Robert C. Martin đưa ra. Nguyên lý nhấn mạnh vào việc xây dựng các thành phần/module có tính cohesion cao, giúp mang lại lợi ích trong quá trình bảo trì hệ thống phần mềm.

Nguyên lý CCP phát biểu như sau:

Trong thành phần, các class bị thay đổi bởi cùng mục đích và cùng thời điểm.

Phân tách chúng ra các thành phần khác nhau khi chúng thay đổi khác thời điểm và cho những mục đích khác nhau.

Đây chính xác là Single Responsibility Principle (SRP) phát biểu cho thành phần. SRP phát biểu rằng: mỗi class chỉ nên có một lý do để thay đổi. CCP phát biểu tương tự: mỗi thành phần/module cũng chỉ nên có một lý do để thay đổi.

Khi tiếp nhận yêu cầu mới, ta sẽ phải cập nhật code. Ta thà tất cả những cập nhật đó xảy ra trong một thành phần hơn là chia ra cho nhiều thành phần khác nhau. Bởi vì, nếu thay đổi xảy ra ở một thành phần, ta sẽ chỉ phải biên dịch lại thành phần đó, và các thành phần sử dụng nó. Các thành phần khác không thuộc phạm vi sẽ không phải làm gì cả.

Cùng xem ví dụ sau:

Hệ thống E-Commerce

(Ví dụ này sẽ đi xuyên suốt qua nhiều chủ đề thiết kế khác nhau)

Trong một hệ thống e-commerce có 3 nhóm chức năng:

  1. Quản lý khách hàng
  2. Quản lý đơn hàng
  3. Quản lý thanh toán

Sử dụng kiến trúc Hexagonal, ta triển khai hệ thống dưới dạng web service với các web service đóng vai trò adapter và phần logic lõi triển khai dưới dạng core Java và đóng gói dưới một file JAR. Dưới đây là thiết kế của tầng logic lõi này:

Hình 1: Các thành phần làm nên lõi nghiệp vụ của hệ thống ecommerce.

Nhìn vào hình 1 ta thấy, lõi nghiệp vụ của ứng dụng bao gồm hai thành phần chính: Service Layer ---(phụ thuộc vào)---> Domain Layer.

  • Domain Layer: là nơi khai báo các thực thể nghiệp vụ của hệ thống như Customer, Order, Payment hay một số aggregate, domain service (aggregatedomain service là các thuật ngữ của Domain Driven Design - DDD) khác phát sinh. Các thực thể này chứa dữ liệu nghiệp vụ và các luật nghiệp vụ lõi.
  • Service Layer: là nơi khai báo các "service" mà ứng dụng cung cấp. Nói cách khác, đây là nơi khai báo các use case hay chức năng của ứng dụng.

Việc phân chia thành hai thành phần như thế này rõ ràng là cách chia theo chiều ngang: lớp service sử dụng lớp domain bên trong. Đây là cách chia truyền thống và sau này bạn sẽ thấy nó khá khó để bảo trì.

Mục tiêu của việc chia thành phần/module là gì? Đầu tiên là để phân chia nhiệm vụ của chúng ra vì chúng làm những việc khác nhau. Nhưng một yếu tố quan trọng nữa mà thường ta không để ý: Ta muốn tạo ra những đầu việc tách bạch cho các team khác nhau. Cách chia theo chiều ngang như vậy là khá dễ vì ta đã quen như vậy, và hiện tại, ví dụ ta có 2 team phụ trách logic, thì tại sao ta không chia thành 2 lớp để cho 2 team này phụ trách từng lớp độc lập nhau?

Hệ thống triển khai dưới thiết kế kiến trúc như sau:

Hình 2: Mô hình triển khai hệ thống và phân chia việc cho các team.

Theo hình 2:

  • Có 3 web service, là tầng adapter trong Hexagonal Architecture. Mỗi web service này sử dụng database riêng, chúng chỉ sử dụng chung một thư viện nghiệp vụ là Core Business.
  • Lõi nghiệp vụ Core Business là nơi tập hợp toàn bộ nghiệp vụ như mô tả trong hình 1. Cách vẽ một component mang tên Core Business là cách vẽ gộp thông tin. Thực tế thì thành phần này là hai thành phần Service Layer và Domain Layer.
  • Dựa vào mô hình này, người ta đã phân chia công việc như sau:
    • Team A phụ trách Customer Service
    • Team B phụ trách cả Order Service và Payment Service vì hai service này có liên quan gần với nhau hơn.
    • Team C phụ trách toàn bộ nghiệp vụ lõi của cả 3 nhóm nghiệp vụ customer, order, và payment.
    • Chú ý rằng: Mỗi component trên chỉ được phụ trách bởi một team và một team có thể phụ trách nhiều component. Việc này giúp tránh xung đột khi có nhiều team cùng phát triển một thành phần.

Theo thời gian, các yêu cầu cập nhật dồn dập đổ vào hệ thống này. Dưới đây là bản ghi nhận lại sự thay đổi:

Hình 3: Change History: Các cột "t1", "t2",... thể hiện các mốc thời gian hệ thống tiếp nhận thay đổi và chỉnh sửa. Các thay đổi nghiệp vụ tại mỗi thời điểm ảnh hưởng tới các service thể hiện bằng hình tròn.

Change History thể hiện điều gì?

  • Thời gian đầu, có rất nhiều thay đổi và chúng đều liên quan tới hai nghiệp vụ Order và Payment.
  • Thời gian sau, có lẽ các yêu cầu về quản lý Customer được nhấn mạnh hơn nên các yêu cầu đổ về đây.

Nhưng đó là thể hiện về việc tác động nghiệp vụ. Còn đối với hệ thống và đội dev, mọi thời điểm họ đều phải re-test, re-build, và re-deploy cả 3 web service. Tại sao lại thế? Bởi vì theo kiến trúc ở hình 2, cả 3 service đều phụ thuộc vào module nghiệp vụ, mà module nghiệp vụ này đang tổng hợp tất cả nghiệp vụ của hệ thống. Do đó, bất kể yêu cầu mới tác động tới mảng nghiệp vụ nhỏ nào thì module này vẫn luôn phải re-deploy, và do đó, nó dẫn tới việc các service sử dụng nó cũng phải re-deploy.

Cụ thể, tại thời điểm t1, nhóm yêu cầu khiến nghiệp vụ liên quan tới Order và Payment cần cập nhật, tức là giả sử ta cần cập nhật hai nhóm được khoanh vùng dưới đây:

Hình 4: Thay đổi tại các thời t1, t2, t3, t4 tác động tới các class được khoanh vùng bên trong hai thành phần Service Layer và Domain Layer.

Tương tự cho các thời điểm t2, t3, và t4.

Sau khi cập nhật source code cho các class/interface được khoanh vùng như trong hình 4, Team C cần biên dịch, đóng gói hai module nghiệp vụ này. Họ cần phát hành chúng dưới dạng thư viện và cần gán cho nó tên phiên bản mới. (Điều này sẽ được nhắc tới tại nguyên lý REP). Đây là một kịch bản tương đối lý tưởng vì Team C chỉ cần quan tâm tới các class liên quan order và payment. Nhưng thực tế, rất có thể các class trong cùng một component có sự ánh xạ, phụ thuộc chằng chéo lên nhau. Các class liên quan đến customer có thể sử dụng những class liên quan đến order hay payment. Và các cập nhật tại order và payment có thể khiến cho customer hoạt động sai. Thường thì Team C sẽ phải thực hiện lại các bài test cho cả ba nhóm nghiệp vụ để đảm bảo rằng mọi thứ hoạt động hoàn hảo.

Team B cũng cần cập nhất code của họ ở Order Service và Payment Service (giả sử các yêu cầu mới cũng liên quan tới hai module này. Ngoài ra, họ cũng cần cập nhật hai thư viện mới mà Team C vừa phát hành. Họ biên dịch, thực hiện các bài test tích hợp, và cuối cùng là triển khai lại hai service.

Tới đây, mọi chuyện có vẻ rất bình thường nhưng nhìn lại hình 2 bạn thấy rằng Team A có thể không đứng ngoài cuộc. Thành phần Customer Service mà họ phụ trách lại có sự phụ thuộc vào Service Layer và Domain Layer - những thành phần vừa được cập nhật và phát hành dưới một phiên bản mới. Team A lúc này xem xét cẩn thận những cập nhật mới ở hai thành phần logic này và họ đứng giữa hai lựa chọn:

  1. Nếu các cập nhật đó tác động tới logic của họ, chắc chắn họ cần cập nhật Customer Service và sử dụng phiên bản nghiệp vụ mới phát hành. Đây là một thiếu sót khi yêu cầu từ khách hàng gửi tới, lúc này Team A không bị coi là tham gia cập nhật.
  2. Nếu các cập nhật đó không liên quan gì tới logic của họ.
    1. Họ có thể không cần làm gì và sử dụng nguyên phiên bản nghiệp vụ cũ. Điều này là tương đối rủi ro vì vào một thời điểm tương lai nào đó, nghiệp vụ của họ cần thay đổi, và lúc đó thường thì sẽ được cập nhật vào phiên bản mới nhất của nghiệp vụ. Điều này chúng ta sẽ bàn tới kỹ hơn ở phần nguyên lý REP.
    2. Họ cần phải cập nhật code để sử dụng phiên bản nghiệp vụ mới nhất. Điều này cũng khiến họ phải thực hiện lại các bài test, biên dịch lại và triển khai lại Customer Service của họ.

Tới lúc này, bạn đã nhận ra vấn đề rồi phải không? Có những thứ không liên hệ gì với những thứ được thay đổi một cách chủ động cũng bị tham gia vào vòng phát triển. Điều này dẫn đến nhiều rủi ro về chất lượng phần mềm, lãng phí nguồn lực cả của dev và test, và cả khiến cho thời gian để lên phiên bản mới lâu hơn đáng kể.

Khi tổ chức gặp phải liên tiếp các yêu cầu tại nhiều thời điểm như vậy, họ cần tái kiến trúc các thành phần để sao cho chúng đảm bảo được nguyên lý CCP cao hơn trước, tức là họ cần tạo ra những thành phần có tính cohesion cao hơn.

Hình 5: Phân tách thành phần ban đầu thành hai nhóm: Nhóm 1 cho nghiệp vụ customer, nhóm 2 cho nghiệp vụ sales bao gồm order và payment.

Hình 5 là một cách phân rã thành phần.

  • Do nghiệp vụ customer là tương đối khác biệt, các class của nó hướng tới cùng mục tiêu, được thay đổi cùng nhau tại cùng thời điểm. Ta tách nó ra và tạo ra các thành phần cho riêng nó: Customer Service Layer phụ trách use cases liên quan tới quản lý customer, Customer Domain Layer phụ trách tầng domain nghiệp vụ lõi.
  • Hai nghiệp vụ quản lý order và payment lại thường xuyên thay đổi cùng nhau, do đó, ta gom các class của chúng vào cùng một thành phần mang tên Sales Service Layer và Sales Domain Layer. Các class trong này có thể có sự phụ thuộc đan xen mạnh mẽ: class thuộc order sử dụng class thuộc payment và ngược lại. Điều này là chấp nhận được tại thời điểm phân chia do chúng có sự kết dính chặt chẽ với nhau, ta không cần tốn quá nhiều công sức để tách chúng ra.
  • Trong thực tế, người ta sẽ gom Service Layer và Domain Layer vào cùng một thành phần. Điều này hay xảy ra vì khi áp dụng những phương pháp như DDD, các Domain Entity sẽ được tạo ra cho một Bounded Context cụ thể, mà trong Bounded Context cụ thể thì các use case sẽ được gắn chặt với nó. Do đó, ta có thể có những thành phần sau một cách rút gọn: Customer Business Layer, Sales Business Layer.

Lúc này, kiến trúc chung sẽ trở thành như sau:

Hình 6: Kiến trúc cập nhật sau khi phân tách thành phần logic ra những thành phần nhỏ hơn.

Nhìn vào hình 6 ta thấy:

  • Các cập nhật xảy ra cho nhóm order và payment sẽ ảnh hưởng tới Sales Service Layer và Sales Domain Layer. Dẫn tới hai thành phần này phải thực hiện các quy trình phát triển, kiểm thử, và đóng gói. Tiếp tục kéo theo Order Service và Payment Service cần cập nhật, kiểm thử, và triển khai.
  • Nhưng các thành phần Customer Service Layer, Customer Domain Layer, và Customer Service thì nằm ngoài sự tác động đó. Các thành phần này vẫn giữ nguyên mà không cần phải làm gì cả.
  • Điều này cho thấy tổ chức có thể nâng cao được chất lượng phát triển phần mềm, tối ưu nguồn lực tốt hơn.
  • Khi tách thành phần như trên, nếu cần, tổ chức có thể gán tiếp Team D phụ trách những thành phần nhỏ hơn. Việc phân công công việc cũng thuận lợi hơn nữa.

Như vậy, qua ví dụ trên, chúng ta thấy được tầm quan trọng của nguyên lý CCP:

  • Giúp tăng tính cohesion của thành phần / module, hay nói cách khác là tăng tính module hóa của ứng dụng.
  • Giúp tăng khả năng tái sử dụng vì một module có tính cơ động cao, có thể sử dụng ở những nơi phù hợp. Ngoài ra, với sự phối hợp của DIP khả năng thích ứng của module còn cao hơn nữa.
  • Giúp tăng chất lượng sản phẩm nói chung. Ví một module được dùng lại tại nhiều nơi sẽ giúp nó có chất lượng ngày càng cao hơn.
  • Giúp tối ưu hóa nguồn lực và thời gian phát triển.

CCP có mối liên hệ chặt chẽ với OCP (Open/Closed Principle): OCP phát biểu rằng các class nên được đóng lại với các thay đổi nhưng mở ra cho việc mở rộng. Do việc đóng 100% không thể đạt được nên điều này cần phải thực hiện có chiến thuật: chúng ta thiết kế các class sao cho chúng được đóng với các thay đổi chung nhất mà ta có thể tính toán được. CCP mở rộng nguyên lý OCP bằng cách thu thập vào cùng một thành phần các class có khả năng bị thay đổi theo những cách giống nhau. Do đó, khi một thay đổi xảy ra, thay đổi đó có cơ hội tốt được hạn chế với số lượng tối thiểu các thành phần.

CCP như đã nói, chính là một SRP cho thành phần. Ta tóm gọn hai nguyên lý này chung lại như sau:

Thu thập những thứ thay đổi cùng thời điểm với cùng lý do lại với nhau. Phân chia những thứ đó thành các nhóm sao cho mỗi nhóm được thay đổi cùng thời điểm và với cùng lý do.

 

Tham khảo:

Chính sách trong hệ thống phần mềm.

Reuse/Release Equivalence Principle - REP.

Common Reuse Principle - CRP.

Giằng co giữa REP-CCP-CRP.

Single Responsibility Principle - SRP.

Open Closed Principle - OCP.

Bình luận
Gửi bình luận
Bình luận