Vỡ lòng về bộ nguyên tắc thiết kế SOLID

Kiến trúc

Hôm nay phần mềm của bạn vẫn phục vụ tốt với bộ tính năng mà nó cung cấp, ngày mai người dùng có thể sẽ yêu cầu bạn sản xuất tính năng mới, và họ sẽ luôn làm thế. Bạn đang sản xuất dở phần mềm, hôm nay bạn có 50% tính năng và ngày mai sẽ làm 51%. Chúng ta gọi công cuộc thêm tính năng mới này là “mở rộng” phần mềm.

Khi viết phần mềm, chúng ta luôn phải quan tâm đến việc duy trì khả năng mở rộng của phần mềm. Dân sản xuất phần mềm truyền nhau hài ngôn sau để nói về việc mất khả năng mở rộng:

Chúng ta sẽ dành 10% thời gian đầu tiên của dự án để phát triển 90% tính năng, và chúng ta sẽ dùng 90% thời gian sau đó để phát triển 10% tính năng cuối cùng (mà không biết có xong không).

Khả năng mở rộng phần mềm cũng giống như khả năng xây thêm tầng cho một ngôi nhà. Khả năng đó trước hết đến từ hai thứ: vật liệu tốt, kiến trúc tốt, và nền móng tốt. Đối ứng với phần mềm thì vật liệu tốt chính là mã sạch và kiến trúc tốt là thiết kế.

Nguyên tắc thiết kế

Mã sạch cũng giống như gạch tốt. Dĩ nhiên gạch tốt là một trong những điều đầu tiên chúng ta cần quan tâm đến khi xây dựng một ngôi nhà. Nhưng để ngôi nhà được vững chắc và duy trì được khả năng sử dụng cao khi xây dựng thêm các tầng mới thì chỉ gạch tốt là chưa đủ. Chúng ta cần đến thiết kế. Và như thế chúng ta tìm đến các nguyên tắc SOLID.

Nếu như các nguyên tắc Mã sạch hướng dẫn chúng ta viết nên các hàm và các class tốt, các nguyên tắc SOLID chỉ ra cách đặt các hàm và cấu trúc dữ liệu và các class, cũng như cách các class nội kết với nhau. Dụng ngôn “class” không có nghĩa rằng SOLID chỉ áp dụng cho phần mềm hướng đối tượng. Class chỉ thuần túy là một tập các hàm và các dữ liệu được nhóm lại với nhau. Bất kỳ phần mềm nào cũng có những nhóm như thế, cho dù chúng có được gọi là class hay không. Và SOLID nhắm đến những nhóm này.

Mục tiêu của SOLID là giúp tạo ra những cấu trúc phần mềm cấp trung mang tính uyển chuyển, dễ hiểu, và có khả năng dùng làm cơ sở để tạo thành những component có thể dùng chung cho nhiều hệ thống phần mềm. “Cấp trung” nói lên thực tế rằng chúng ta dùng đến SOLID khi tập trung vào cách kết cấu các khối và cấu phần trong phần mềm, thay vì đi vào chi tiết mã lệnh.

SOLID

Các nguyên tắc SOLID không được hình thành ngay một lúc mà trải qua một lịch sử dài. Trong quá trình tìm kiếm các nguyên tắc cốt lõi khi thiết kế các cấu trúc cấp trung, các thợ cả trong nghành thủ công phần mềm đã tạo ra nhiều bộ nguyên tắc khác nhau. Trong số có tới nay còn lại năm nguyên tắc dễ hiểu, thanh nhã, và đủ tốt; mà nếu sắp xếp theo đúng thứ tự thì chúng ta sẽ có tính từ solid (rắn chắc).

Sơ bộ, SOLID gồm 5 nguyên tắc như sau:

  • Single Responsibility Principle – nguyên tắc này yêu cầu mỗi khối phần mềm chỉ có một lý do để thay đổi.
  • Open-Closed Principle – phần mềm luôn phải ở trạng thái sao cho có thể thay đổi hành vi của chúng bằng cách thêm mã mới thay vì sửa mã cũ.
  • Liskov Substitution Principle – phần mềm luôn phải ở trạng thái mà mỗi thành phần đều có thể thay thế mà không ảnh hưởng đến hành vi của nó.
  • Interface Segregation Principle – tránh tạo ra quan hệ phụ thuộc với những thứ không dùng đến
  • Dependency Inversion Principle – mã triển khai chính sách cấp cao không được phụ thuộc vào triển khi chi tiết ở mức thấp. Thay vì thế chi tiết nên phụ thuộc vào chính sách.

Các nguyên tắc này đã được mô tả trong rất nhiều ấn phẩm trong suốt nhiều năm. Bài viết này sẽ đi vào ý nghĩa thiết kế đằng sau chúng, thay vì tiếp tục các tranh luận chi tiết trong khái niệm.

Nguyên tắc Đơn Trách Nhiệm

Đơn Trách Nhiệm, hay Trách Nhiệm Duy Nhất, có lẽ là nguyên tắc được nghe đến nhiều nhất và bị hiểu sai nhiều nhất trong số các nguyên tắc SOLID. Cứ mười anh lập trình viên thì phải đến hơn chín anh cho rằng nguyên tắc này phát biểu điều gì đó liên quan đến mỗi hàm (hay tệ hơn – mỗi class???) chỉ được làm một việc.

Thật ra không thể trách được, quả thật là có một nguyên tắc như thế. Một hàm chỉ được phép làm một và chỉ một, việc. Chúng ta áp dụng nguyên tắc đó khi thực hiện tái cấu trúc những hàm lớn thành những hàm nhỏ hơn; chúng ta áp dụng nguyên tắc đó lại mức thấp của công việc viết mã. Nhưng đó không phải là một trong các nguyên tắc SOLID, càng không phải Nguyên Tắc Đơn Trách nhiệm.

Trong lịch sử, Nguyên Tắc Đơn Trách Nhiệm từng được phát biểu như sau:

Một module chỉ được có một và chỉ một lý do để thay đổi.

Các sản phẩm phần mềm thay đổi là để đáp ứng người dùng và các bên liên quan. Họ là các lý do để thay đổi mà nguyên tắc nói tới. Thế nên Đơn Trách Nhiệm có thể được phát biểu lại như sau:

Một module chỉ phải chịu trách nhiệm thay đổi trước một và chỉ một người dùng hay bên liên quan.

Những từ “người dùng” và “bên liên quan” không thực sự đúng cho lắm. Đôi khi có nhiều hơn một người dùng hay bên liên quan muốn hệ thống thay đổi nhưng theo cùng một cách. Cái chúng ta thực sự muốn nói đến là một nhóm — một hay nhiều người ra yêu cầu thay đổi. Chúng ta gọi nhóm đó là một tác nhân. Theo đó, định nghĩa cuối cùng của Nguyên Tắc Đơn Trách Nhiệm sẽ là:

Một mô-đun chỉ phải chịu trách nhiệm trước một, và chỉ một, tác nhân.

Tiếp theo là đến ý nghĩa của khái niệm mô-đun. Định nghĩa đơn giản nhất của mô-đun là một tập tin mã nguồn. Đa phần chúng ta có thể dùng định nghĩa đó. Có vài ngôn ngữ và môi trường phát triển đặc thù không sử dụng tập tin để chứa mã nguồn của chúng. Tuy nhiên dù trong bất kỳ môi trường nào cũng luôn tồn tại các nhóm cấu trúc dữ liệu và hàm tụ về một hướng, mà chúng ta gọi là sự ngưng tụ, sự ngưng tụ cố kết các thành phần rời rạc lại với nhau và tạo thành mô-đun. Nói tóm lại, mô-đun là một tập hợp ngưng tụ các hàm và cấu trúc dữ liệu.

Giờ chúng ta sẽ làm sáng tỏ Nguyên Tắc Đơn Trách Nhiệm thông qua một vài ví dụ về sự vi phạm nó.

Triệu chứng: xung đột nghiệp vụ

Trong ví dụ dưới đây có lớp Employee của một chương trình tính lương. Nó có các phương thức calculatePay() được sử dụng bởi phòng kế toán để tính lương, phục vụ giám đốc tài chính; reportHours() được sử dụng bởi phòng nhân sự để tính ngày công, phục vụ giám đốc nhân sự; và save() được sử dụng bởi các quản trị viên cơ sở dữ liệu để lưu thông tin nhân viên, phục vụ giám đốc công nghệ. Các giám đốc rõ là các tác nhân khác nhau, và lớp này đã vi phạm Nguyên Tắc Đơn Trách Nhiệm.

Bằng việc ngưng tụ cả ba phương thức vào cùng một lớp, các nhà phát triển đã ràng buộc ba tác nhân khác nhau với nhau. Một yêu cầu thay đổi nào đó từ phía COO sẽ có thể gây ảnh hưởng tới nghiệp vụ của CTO.

Lấy ví dụ, calculatePay()reportHours sử dụng cùng một công thức để quy số giờ làm việc thành giờ làm việc tiêu chuẩn (những giờ làm việc quá giờ sẽ có hệ số cao hơn 1 chẳng hạn). Và bởi khử mã lặp nên các nhà phát triển đã đặt công thức này vào một hàm dùng chung tên là regularHous():

Rồi một ngày, CFO quyết định rằng công thức cần phải thay đổi một chút, nhưng đội ngũ của COO thì không có nhu cầu với thay đổi này, do họ dùng con số giờ làm việc tiêu chuẩn cho những mục đích khác nhau.

Người lập trình viên được giao nhiệm vụ triển khai thay đổi không nhận ra rằng regularHours cũng được sử dụng bởi reportHours, anh ta đã thực hiện sửa đổi, kiểm thử cẩn thận, đội ngũ của CFO đã kiểm tra, chức năng hoạt động như mong muốn, được nghiệm thu và được đưa vào thực tế.

Đội ngũ của COO phải rất lâu sau mới nhận ra rằng những con số mà họ dựa vào để báo cáo và ra quyết định đang có vấn đề. Và tới khi đó thì hậu quả đã là rất nhiều tài nguyên và nỗ lực đã bị lãng phí.

Bất kỳ ai có thâmtrong nghành cũng đều đã thấy những chuyện tương tự. Chúng xảy ra bởi vì chúng ta đã đặt những mã nguồn phụ thuộc vào những tác nhân khác nhau lại gần với nhau quá. Nguyên Tắc Đơn Trách Nhiệm dặn chúng ta đặt chúng xa ra.

Giải pháp

Có nhiều giải pháp khác nhau cho vấn đề này. Mỗi giải pháp lại đặt các hàm vào các lớp khác nhau. Có lẽ cách dễ nhận thấy nhất đó là tách dữ liệu khỏi các hàm, và đặt các hàm chức năng vào trong những lớp chỉ chứa đủ mã nguồn cần thiết để chức đó hoạt động. Các lớp mang chức năng không biết về sự tồn tại của nhau, do đó tránh được bất kỳ sự xung đột nghiệp vụ nào.

Nhược điểm của giải pháp này chính là việc lập trình viên sẽ phải quan tâm đến những ba lớp khác nhau. Cách giải quyết là sử dụng mẫu thiết kế Facade:

Lớp EmployeeFacade chứa rất ít mã. Nó chỉ chịu trách nhiệm khởi tạo và ủy thác công việc cho các lớp mang chức năng.

Một số nhà phát triển thích giữ những nghiệp vụ quan trọng nhất được gần với dữ liệu hơn. Điều này có thể được thực hiện bằng cách giữ phương thức quan trọng nhất trong lớp Employee ban đầu, và dùng lớp này làm facade cho các hàm chức năng nhỏ hơn.

Bạn có thể sẽ cảm thấy muốn chối bỏ các giải pháp trên bởi việc mỗi lớp chỉ chứa một hàm trông không được tự nhiên lắm. Thực tế điều này hiếm khi xảy ra. Mỗi lớp luôn chứa cả các hàm riêng tư, chẳng hạn để phục vụ cho chức năng tính lương, tính giờ làm hay lưu tồn dữ liệu, và số lượng của chúng thường không ít.

Nguyên Tắc Đóng/Mở

Nguyên Tắc Đóng/Mở được phát biểu như sau:

Một tạo tác phần mềm nên mở cửa cho sự mở rộng nhưng đóng lại với những sửa đổi

Nói cách khác, một tạo phẩm phần mềm phải hành xử theo lối có khả năng mở rộng mà không phải sửa đổi tạo phẩm đó. Đây, cơ bản là lý do khiến chúng ta nghiên cứu về kiến trúc phần mềm. Nếu chỉ một vài mở rộng đơn giản theo yêu cầu cũng kéo theo những thay đổi lớn trên phần mềm, thì rõ ràng các kiến trúc sư của hệ thống phần mềm đó đã trên bờ vực mất kiểm soát kiến trúc đến nơi.

Hầu hết người học thiết kế phần mềm đều coi Nguyên Tắc Đóng/Mở như một nguyên tắc hướng dẫn khi thiết kế các lớp và mô-đun. Nhưng không chỉ thế, nguyên tắc này còn phát huy ý nghĩa của nó khi chúng ta thiết kế các cấu phần của hệ thống phần mềm.

Hãy xem xét ví dụ sau.

Ví dụ: hệ thống báo cáo tài chính

Hãy tưởng tượng chúng ta có một hệ thống hiển thị thông tin tài chính tổng hợp lên trang web. Danh mục thông tin rất dài, và có thể cuộn để xem. Các số âm được hiển thị bằng màu đỏ.

Bây giờ các bên liên quan yêu cầu một khối thông tin tương tự nhưng ở định dạng sẵn sàng để in lên máy in đen trắng. Danh mục được phân trang, và các số âm được bao quanh bởi cặp dấu ngoặc đơn.

Rõ ràng phải viết thêm mã mới, nhưng bao nhiêu mã cũ sẽ phải thay đổi? Hệ thống phần mềm được thiết kế tốt sẽ giảm số lượng mã cũ phải sửa xuống tối thiểu. Lý tưởng nhất là không có.

Làm thế nào? Bước đầu tiên là tổ chức phân tách những cấu phần chịu trách nhiệm bởi những tác nhân khác nhau, sau đó tổ chức các mối phụ thuộc giữa các cấu phần đó một cách hợp lý.

Với Nguyên Tắc Đơn Trách Nhiệm, chúng ta có được cái nhìn tổng thể về luồng dữ liệu như dưới đây. Thủ tục Phân tích Tài chính sẽ tạo ra Dữ liệu Báo cáo, thứ sau đó được định dạng cho phù hợp bởi hai Trình tạo Báo cáo.

Bước tiếp theo là tổ chức các mối phụ thuộc trong mã nguồn sao cho các thay đổi từ một trong các trách nhiệm không kéo theo thay đổi trên các trách nhiệm còn lại. Đồng thời cũng phải đảm bảo rằng hành vi có thể được mở rộng mà không cần phải sửa đổi mã nguồn của hành vi cũ.

Chúng ta tổ chức các tiến trình vào các lớp và phân bổ các lớp đó vào các cấu phần, được thể hiện bằng các khu vực với đường bao kép, như trong hình dưới đây. Các cấu phần bao gồm Controller, Interator, Database, các Presenter, và các View.

Trong sơ đồ, các ký hiệu I là các interface, DS là các cấu trúc dữ liệu, các mối quan hệ dùng đến được thể hiện bởi các mũi tên hở, và các quan hệ thi hành hay kế thừa được thể hiện bởi các mũi tên kín.

Hãy để ý vào các mũi tên cắt ngang qua đường biên giữa các cấu phần, chúng là những mũi tên đơn hướng. Những mũi tên hướng về những cấu phần mà chúng ta muốn bảo vệ khỏi sửa đổi.

Nếu cấu phần A cần được bảo vệ khỏi những thay đổi từ cấu phần B, thì cấu phần B nên phụ thuộc vào cấu phần A.

Chúng ta muốn bảo vệ Controller khỏi những thay đổi của các Presenter. Chúng ta muốn bảo vệ các Presenter khỏi những thay đổi trong các View. Chúng ta muốn bảo vệ Interactor khỏi những thay đổi ở — bất cứ nơi nào khác. Các thay đổi trên View, Presenter, Controller, hay Database sẽ không làm thay đổi Interactor.

Tại sao Interactor lại được lưu tâm như vậy? Interactor chứa những quy tắc nghiệp vụ, chứa các chính sách ở cấp cao nhất của ứng dụng. Interactor là trung tâm và tất cả các cấu phần khác là thiết bị ngoại vi.

Controller là ngoại vi của Interactor, nhưng là trung tâm đối với Presenter; và tương tự như thế, Presenter là trung tâm của các View. Chúng ta gọi đây là hệ thống cấp bậc bảo vệ. View là cấu phần ở cấp thấp nhất, vì vậy chúng ít được bảo vệ nhất, Presenter có cấp cao hơn View nhưng thấp hơn so với ControllerInteractor.

Đó la sự hoạt động của Nguyên Tắc Đóng/Mở ở mức độ kiến trúc. Người kiến trúc sư thực hiện phân tách các chức năng bằng cách đặt ra những câu hỏi như thế nào, tại sao, bao giờ các thay đổi sẽ xuất hiện, sau đó tổ chức các chức năng riêng biệt vào một hệ thống phân cấp. Các cấu phần nằm ở mức cao hơn trong hệ thống được bảo vệ khỏi những thay đổi nằm ở cấu phần thấp hơn.

Hướng điều khiển

Đừng hoảng hốt trước mức độ chi tiết quá mức của thiết kế ở trên, hầu hết các sự phức tạp trong sơ đồ là để đảm bảo rằng các mối phụ thuộc băng qua đường biên giữa các cấu phần được chỉ theo đúng hướng.

Ví dụ: <I>FinancialDataGateway tồn tại giữa FinancialReportGeneratorFinancialDataMapper là để để đảo ngược sự phụ thuộc mà lẽ ra đã chỉ từ hướng Interactor đến Database. Tương tự với <I>FinancialReportPresenter và hai giao diện View.

Ẩn giấu thông tin

Giao diện <I>FinancialReportRequester phục vụ một cho mục đích khác. Nó ở đó để đảm bảo FinancialReportController không bị biết quá nhiều về cấu trúc bên trong của Interactor. Nếu không có nó, Controller sẽ có quá nhiều mối phụ thuộc vào FinancialEntities.

Các phụ thuộc bắc cầu vi phạm nguyên tắc chung rằng các thực thể phần mềm không nên phụ thuộc vào những thứ chúng không trực tiếp sử dụng. Chúng ta sẽ gặp lại nguyên tắc đó khi nói về Nguyên Tắc Phân Tách Giao Diện.

Vì vậy, tuy nói rằng ưu tiên hàng đầu của chúng ta là bảo vệ Interactor khỏi các thay đổi của Controller, nhưng chúng ta cũng muốn bảo vệ Controller khỏi các thay đổi của Interactor bằng cách ẩn đi cấu trúc nội bộ của Interactor.

Kết luận

Nguyên Tắc Đóng/Mở là một trong những lực lượng ngầm lèo lái kiến trúc của hệ thống phần mềm. Mục tiêu là làm cho hệ thống dễ dàng mở rộng mà không phải gây ra những thay đổi có tác động lớn. Mục tiêu này được thực hiện bằng cách quy hoạch hệ thống thành các cấu phần và phân bố các cấu phần đó vào một hệ thống có thứ bậc để bảo vệ các cấu phần cấp cao khỏi những thay đổi trong các cấu phần cấp thấp hơn.

Nguyên Tắc Thay Thế Liskov

Năm 1988, Barbara Liskov, để định nghĩa một kiểu con, đã viết như sau, trong paper Thông báo SIGPLAN 23, 5Trừu tượng hóa và phân cấp dữ liệu (tháng 5 năm 1988).

Điều muốn có ở đây là một cái gì đó giống như thuộc tính thay thế như sau đây: Nếu với mỗi đối tượng o1 có kiểu S tồn tại một đối tượng o2 có kiểu T sao cho với tất cả các chương trình P được xác định theo T thì hành vi của P không thay đổi khi lấy o1 thay thế cho o2, thì S là một kiểu con của T.

Ý tưởng này được gọi là Nguyên Tắc Thay Thế Liskov, để hiểu nó chúng ta hãy xem xét một số ví dụ.

Dẫn dắt quan hệ kế thừa

Tưởng tượng rằng chúng ta có một lớp có tên là License như trong hình dưới đây. Lớp này có một phương thức có tên là calcFee(), được gọi bởi ứng dụng Billing. License có hai lớp “con”: PersonalLicenseBusinessLicense. Chúng dùng các thuật toán khác nhau để tính phí.

Thiết kế này phù hợp với Nguyên Tắc Thay Thế Liskov vì hành vi của ứng dụng Billing không phụ thuộc, theo bất kỳ cách nào, vào bất kỳ kiểu nào trong hai kiểu con mà nó sử dụng. Cả hai kiểu con đều có thể thay thế cho kiểu License.

Vấn đề Hình vuông/Hình chữ nhật

Pha kinh điển về vi phạm Nguyên Tắc Thay Thế Liskov là vấn đề hình vuông/hình chữ nhật nổi tiếng (hoặc khét tiếng, tùy theo quan điểm của bạn), được thể hiện trong hình dưới đây.

Trong ví dụ này, Square không nên là một kiểu con của Rectanglewidth và và height của Rectangle có thể thay đổi độc lập; ngược lại, widthheight của Square phải thay đổi cùng nhau. Nếu User tin rằng đối tượng đang dùng là một Rectangle, họ có thể sẽ rối tinh lên. Đoạn mã sau cho thấy tại sao:

Rectangle r = …
r.setW(5);
r.setH(2);
assert(r.area() == 10);

Nếu r ở trên là một Square thì phép assert sẽ nắm chắc thất bại. Với các sắp đặt quan hệ kế thừa như hiện tại, cách duy nhất để khử sự vi phạm Nguyên Tắc Thay Thế Liskov là thêm các cơ chế để User có thể phát hiện xem r có phải là một Square hay không. Điều này sẽ khiến hành vi của User bị phụ thuộc vào kiểu dữ liệu mà nó sử dụng, và mất khả năng thay thế sang các kiểu dữ liệu khác.

Tác động mở rộng lên kiến trúc

Nguyên Tắc Thay Thế không chỉ như một hướng dẫn để dẫn dắt các mối quan hệ kế thừa. Nó còn có ảnh hưởng thấy rõ đến kiến trúc của hệ thống trên khía cạnh các giao diện và các triển khai của chúng.

Các giao diện có thể nằm ở nhiều dạng thức. Có thể là một giao diện Java, được triển khai bởi một số lớp; hay dưới dạng một lớp Ruby với khung các phương thức; hoặc cũng có thể là một tập hợp các dịch vụ đáp ứng cùng một giao diện REST… Dù là ở tình huống nào, Nguyên Tắc Thay Thế Liskov được áp dụng vì có những người dùng cần đến những giao diện được thiết kế tốt cũng như triển khai của chúng.

Cách tốt nhất để hiểu Nguyên Tắc Thay Thế Liskov từ quan điểm kiến trúc là xem xét những gì xảy ra với kiến trúc của một hệ thống khi nguyên tắc bị vi phạm.

Dịch vụ gọi xe

Giả sử chúng ta đang xây dựng một cổng tích hợp cho nhiều dịch vụ gọi xe công cộng. Khách hàng sử dụng cổng của chúng ta để tìm phương tiện đi lại phù hợp nhất, không kể là từ công ty nào. Khi khách đã có quyết định, chúng ta sẽ chuyển hướng yêu cầu của họ đến tài xế thông qua một dịch vụ RESTful.

Bây giờ giả sử rằng URI của dịch vụ chuyển tiếp được đặt trong cơ sở dữ liệu các tài xế. Một khi hệ thống của chúng ta chọn được trình tài xế phù hợp với yêu cầu của người dùng, nó sẽ lấy URI từ cơ sở dữ liệu của tài xế và sau đó thực hiện chuyển tiếp.

Giả sử tài xế Bob tại công ty Purple Cab có URI như sau:

purplecab.com/do/Bob

Hệ thống của chúng ta sẽ nối thông tin từ khách hàng vào URI này và PUT:

purplecab.com/do/Bob
    /pickupAddress/24 Maple St.
    /pickupTime/153
    /destination/ORD

Dễ thấy rằng như vậy, tất cả các dịch vụ chuyển tiếp, bất kể công ty nào, đều sẽ có giao diện REST giống nhau. Chúng đều cần các tham số pickupAddress, pickupTimedestination.

Bây giờ, giả sử lập trình viên của công ty taxi Acme không đọc kỹ tài liệu. Họ viết tắt tham số destination. Acme là khách hàng lớn nhất của chúng ta và bà ngoại của CEO Acme là vợ mới của CEO của chúng ta, blah blah đại loại thế. Vậy là chúng ta phải thay đổi hệ thống của mình.

Rõ ràng là chúng ta cần thêm một trường hợp ngoại lệ. Yêu cầu chuyển tiếp đến trình điều khiển Acme sẽ phải sử dụng một bộ quy tắc khác với các trình điều khiển khác.

Và thế là một chỉ lệnh rẽ nhánh xuất hiện.

if (driver.getDispatchUri().startsWith("acme.com")) {
 // ...

Không một kiến trúc sư hệ thống nào xứng đáng với số muối mình từng ăn vào sẽ cho phép một chỉ lệnh như vậy tồn tại trong hệ thống. Sự tồn tại của từ acme đặt mã nguồn trước vô số lỗi nguy hiểm và bí ẩn, đấy là chưa kể tới các rủi ro an ninh.

Thử nghĩ điều gì sẽ xảy ra nếu Acme mua về Purple Cab, thống nhất tất cả các hệ thống, nhưng vẫn duy trì thương hiệu và website riêng biệt? Chẳng lẽ lại phải phải bổ sung thêm một chỉ lệnh rẽ nhánh khác?

Kiến trúc sư của chúng ta sẽ tìm phương án cách ly hệ thống khỏi các vấn đề như thế này, bằng cách tạo ra một loại mô-đun-kiến-tạo-yêu cầu-chuyển-tiếp, được điều khiển bởi một cơ sở dữ liệu có khả năng cấu hình trông như dưới đây:

URI             Dispatch Format
---
Acme.com        /pickupAddress/%s/pickupTime/%s/dest/%s
*.*             /pickupAddress/%s/pickupTime/%s/destination/%s

Và thế là anh ta phải đối phó thêm với một lượng lớn thấy rõ các cơ chế và mô-đun mới. Chỉ vì có một dịch vụ con không tuân thủ nguyên tắc thay thế.

Kết luận

Nguyên Tắc Thay Thế Liskov có thể, và nên được áp dụng tại mức kiến trúc. Một vi phạm đơn giản về khả năng thay thế có thể khiến kiến trúc của hệ thống bị xâm nhiễm bởi một lượng lớn các cơ chế bổ sung.

Nguyên Tắc Phân Tách Giao Diện

Nguyên Tắc Phân Tách Giao Diện được lấy tên từ sơ đồ dưới đây:

Trong tình huống trên, có một số người dùng sử dụng đến các hoạt động của OPS. Giả sử rằng User1 sử dụng đến op1, User2 sử dụng op2User3 sử dụng op3.

Bạn cứ thử nghĩ mà xem, thế rồi mã nguồn của User1 sẽ phụ thuộc vào op2op3, mặc dù nó không gọi chúng. Vấn đề này có thể giải quyết bằng cách tách các hoạt động của OPS vào các giao diện riêng như dưới đây:

Một trường hợp khác, tương tự, nhưng ở cấp độ cao hơn, khi hệ thống của bạn phụ thuộc vào một framework F, và F thì bị ràng buộc với một cơ sở dữ liệu D. Điều này đã khiến cho hệ thống của bạn bị ràng buộc với D, mặc dù có khi bạn chẳng hề dùng đến.

Những phụ thuộc như thế có thể gây ra điều gì xấu? Mã nguồn bị buộc phải biên dịch lại khi các mối phụ thuộc có thay đổi là điều thứ nhất. Thứ hai, một khi các phụ thuộc có vấn đề, vấn đề đó có thể gây ra lỗi ở mô-đun của bạn, mặc dù bạn không dùng đến bất kỳ tính năng nào ở đó.

Kết luận

Bài học rút ra ở đây là những mối phụ thuộc không cần thiết có thể gây ra những rắc rối không ngờ tới. Hãy tránh những mối phụ thuộc đó.

Nguyên Tắc Đảo Ngược Phụ Thuộc

Nguyên Tắc Đảo Ngược Phụ Thuộc cho chúng ta biết rằng các hệ thống linh hoạt nhất là những hệ thống trong đó các phụ thuộc mã nguồn chỉ đề cập đến trừu tượng, chứ không phải các cụ thể hóa.

Trong những ngôn-ngữ-định-kiểu-tĩnh, Java chẳng hạn, phát biểu trên có nghĩa là các chỉ lệnh use, import, hay include chỉ nên tham chiếu đến các mô-đun chứa giao diện, lớp trừu tượng hay các định nghĩa trừu tượng khác. Không nên tham chiếu đến bất kỳ thứ gì cụ thể.

Quy tắc tương tự cũng được áp dụng cho các ngôn-ngữ-định-kiểu-động, như Ruby hay Python. Các phụ thuộc mã nguồn không nên tham chiếu đến các mô-đun cụ thể. Tuy nhiên trong các ngôn ngữ này, việc phân định rõ một mô-đun có được gọi là cụ thể hay không khó khăn hơn đôi chút. Mô-đun cụ thể là những mô-đun mà các hàm của nó đã được triển khai.

Rõ ràng, coi phát biểu trên là một quy tắc là phi thực tế. Các hệ thống phần mềm phải phụ thuộc vào nhiều công cụ cụ thể. Ví dụ, lớp String trong Java là một lớp cụ thể, và sẽ không tố chút nào nếu cố ép String trở thành lớp trừu tượng. Không thể tránh phụ thuộc mã nguồn vào lớp java.lang.string, và không nên tránh.

Khi cân nhắc cẩn thận, lớp String rất ổn định. Nó rất hiếm khi thay đổi và các thay đổi đó luôn được kiểm soát chặt chẽ. Lập trình viên và các kiến trúc sư không phải lo lắng về những thay đổi thường xuyên và thất thường của String.

Với những lý do tương tự, chúng ta nên bỏ qua những hoạt động ổn định của hệ điều hành cũng như các công cụ nền tảng khi nói đến Nguyên Tắc Đảo Ngược Phụ Thuộc. Chúng ta chấp nhận những phụ thuộc đó bởi chúng ta có thể tin tưởng chúng sẽ không thay đổi.

Thứ cần lưu tâm là những triển khai cụ thể trong hệ thống của chúng ta. Đó là những mô-đun mà chúng ta đang tích cực phát triển và đang trải qua thay đổi thường xuyên.

Những thành phần trừu tượng ổn định

Mỗi thay đổi trên một giao diện trừu tượng luôn kéo theo thay đổi trên những triển khai cụ thể của nó. Ngược lại, các thay đổi của triển khai cụ thể không phải lúc nào cũng (thậm chí thường là không) yêu cầu thay đổi giao diện mà chúng triển khai. Do đó, các giao diện ít biến đổi hơn so với các triển khai.

Các nhà thiết kế phần mềm và kiến trúc sư giỏi sẽ cố gắng để giảm thiểu sự biến đổi của các giao diện. Họ sẽ cố gắng tìm cách thêm chức năng vào triển khai mà không cần phải thay đổi giao diện. Điều này nằm trong mọi bài học vỡ lòng về thiết kế.

Như vậy là muốn kiến trúc phần mềm được ổn định thì cần tránh đi sự phụ thuộc vào những gì cụ thể và dễ thay đổi. Điều này kéo theo một danh sách các hành dụng khi viết mã:

  • Không tham chiếu các lớp cụ thể dễ thay đổi. Thay vào đó, tham chiếu đến các giao diện trừu tượng. Quy tắc này áp dụng cho tất các các ngôn ngữ, cho dù được định kiểu tĩnh hay động. Nó cũng đặt ra những hạn chế khắt khe cho thao tác tạo ra các đối tượng và thường bắt buộc sử dụng mẫu thiết kế Abstract Factory.
  • Không kế thừa các lớp cụ thể dễ thay đổi. Đây thật ra là một hệ quả của quy tắc trên, nhưng được viết ra tường minh để nhấn mạnh. Trong các ngôn ngữ định kiểu tĩnh, kế thừa là mạnh mẽ và cứng nhắc nhất trong số tất cả các mối quan hệ trong mã nguồn; do đó nó nên được sử dụng thật cẩn thận. Trong các ngôn ngữ định kiểu động, sự kế thừa ít gặp vấn đề hơn, nhưng nó vẫn là một sự phụ thuộc, và sử dụng phụ thuộc một cách cẩn trọng luôn là một hành động khôn ngoan.
  • Không ghi đè các hàm cụ thể. Các hàm cụ thể thường mang theo các phụ thuộc mã nguồn. Khi ghi đè một hàm, chúng ta cũng kế thừa luôn các mối phụ thuộc của nó. Nếu muốn quản lý được các phụ thuộc của hàm, chúng ta nên khiến hàm trở thành hàm trừu tượng và sau đó kiến tạo nhiều triển khai khác nhau.
  • Không bao nhắc đến tên của bất kỳ thứ gì cụ thể và dễ thay đổi. Đây thực ra là hồi quy của chính nguyên tắc đầu tiên.

Kết luận

Chúng ta sẽ dành 10% thời gian đầu tiên của dự án để viết nên 90% tính năng. Sau đó chúng ta sẽ dành 90% thời gian còn lại để viết 10% tính năng cuối cùng (mà không biết có xong không).

Các nguyên tắc SOLID không phải là những hướng dẫn thực hành, chúng cũng không được viết ra bởi tính quân phiệt hay kể cả đồng thuận. SOLID xuất phát từ các vấn đề thực tế, rằng sự vi phạm chúng sẽ ngay lập tức dẫn đến những hệ quả rất xấu cho phần mềm. Sự vi phạm các nguyên tắc thiết kế sẽ khiến cho phần mềm ngày càng trở nên khó bổ sung tính năng mới hơn.

Khoảng cách giữa mã nguồn và các nguyên tắc thiết kế được lấp đầy thông qua tái cấu trúc, hoặc áp dụng các mẫu thiết kế, hoặc cả hai. Tái cấu trúc giúp phát hiện mà gỡ bỏ những mã có “mùi” – mà thường là dấu hiệu của một bộ phận kiến trúc không tốt. Trong khi đó các mẫu thiết kế đưa ra những giải pháp hoàn thiện và ổn định để tạo nên các thiết kế cục bộ mà thỏa mãn các nguyên tắc thiết kế.

_

Leave a Comment

Your email address will not be published. Required fields are marked *