Unlimited Plugins, WordPress themes, videos & courses! Unlimited asset downloads! From $16.50/m
Advertisement
  1. Code
  2. PHP
Code

SOLID: Phần 2 - Nguyên tắc Open/Closed

by
Difficulty:IntermediateLength:LongLanguages:
This post is part of a series called The SOLID Principles.
SOLID: Part 1 - The Single Responsibility Principle
SOLID: Part 3 - Liskov Substitution & Interface Segregation Principles

Vietnamese (Tiếng Việt) translation by Andrea Ho (you can also view the original English article)

Single Responsibility (SRP), Open/Closed (OCP), Liskov's Substitution, Interface Segregation, và Dependency Inversion. Năm nguyên tắc agile sẽ đính hướng cho bạn mỗi khi cần viết code.

Định nghĩa

Các yếu tố của phần mềm (class, module, function) nên được hỗ trợ để mở rộng, nhưng không cho phép sửa đổi.

Nguyên tắc Open/Closed, ngắn gọn là OCP, được đề xuất bởi Bertrand Mayer, một lập trình viên người Pháp, lần đầu tiên xuất bản cuốn sách của anh Object-Oriented Software Construction vào năm 1988.

Nguyên tắc này đã trở nên phổ biến vào đầu những năm 2000 khi nó trở thành một trong những nguyên tắc SOLID được xác định nghĩa bởi Robert C. Martin trong cuốn sách Agile Software Development, Principles, Patterns, and Practices và sau đó được xuất bản lại trong phiên bản C# có tên là Agile Principles, Patterns, and Practices in C#.

Điều chúng ta đang nói đến ở đây là để thiết kế các module, class và function của chúng ta theo cách mà khi một chức năng mới cần thiết, chúng ta không nên thay đổi phần code hiện tại mà thay vào đó là viết code mới sẽ được đoạn code hiện tại sử dụng. Nghe có vẻ hơi lạ, đặc biết nếu chúng ta làm việc với Java, C, C++ hoặc C#, ở đó không chỉ áp dụng cho code nguồn mở và còn cho phần nhị phân. Chúng tôi muốn tạo các tính năng mới theo cách mà chúng ta không cần phải triển khai lại các file nhị phân, file thực thi hoặc file DLL hiện có.

OCP trong nối cảnh của SOLID

Khi chúng tôi tiến hành với các hướng dẫn này, chúng tôi có thể đặt từng nguyên tắc mới trong bối cảnh của các nguyên tắc đã được thảo luận. Chúng ta từng thảo luận về Single Responsibility (SRP), đã trình bày rằng một module nên chỉ có một lý do để thay đổi. Nếu chúng ta nghĩ về OCP và SRP, chúng ta có thể quan sát thấy chúng bổ sung cho nhau. Code được đặc biệt thiết kế với SRP sẽ gần với các nguyên tắc OCP hoặc dễ tuân thủ các nguyên tắc đó. Khi chúng ta có code có một lý do duy nhất để thay đổi nó, việc giới thiệu một tính năng mới sẽ tạo ra một lý do thứ hai cho thay đổi đó. Vì vậy, cả SRP và OCP sẽ bị vi phạm. Trong cùng một phương pháp, nếu chúng ta có code chỉ nên thay đổi khi chức năng chính của nó thay đổi và sẽ không thay đổi khi một tính năng mới được thêm vào nó, do đó việc tuân thủ OCP nghĩa là chủ yếu sẽ tuân thủ SRP.

Điều này không có nghĩa là SRP luôn dẫn đến OCP hoặc ngược lại, nhưng hầu hết các trường hợp, nếu một trong số chúng được tuân thủ, thì việc đạt được điều còn lại khá đơn giản.

Ví dụ rõ ràng về vi phạm OCP

Từ quan điểm thuần túy về mặt kỹ thuật, nguyên tắc Open/Closed rất đơn giản.n Mối quan hệ đơn giản giữa hai class, giống như một class dưới đây vi phạm OCP.

violate1

Class User sử dụng class Logic trực tiếp. Nếu chúng ta cần khai triển một class Logic thứ hai và điều này cho phép chúng ta sử dụng cả class hiện tại và class mới, thì class Logic hiện tại sẽ cần phải được thay đổi. User trực tiếp bị trói buộc với việc triển khai của Logic, không có cách nào để tạo một một Logic mới mà không ảnh hưởng đến class hiện tại. Và khi chúng ta bàn về các ngôn ngữ kiểu tĩnh, rất có thể class User cũng sẽ yêu cầu thay đổi. Nếu chúng ta đang nói về các ngôn ngữ biên dịch, chắc chắn cả thực thi User và thực thi Logic hoặc thư viện động sẽ yêu cầu biên dịch lại và triển khai lại cho khách hàng của chúng ta, một quá trình chúng ta luôn muốn tránh bất cứ khi nào có thể.

Cho tôi xem code

Chỉ dựa vào lược đồ trên, người ta có thể suy luận rằng bất kỳ class nào trực tiếp sử dụng một class khác sẽ thực sự phá võ nguyên tắc Open/Closed. Và nghiêm khắc mà nói điều này đúng. Tôi thấy khá thú vị khi tìm ra các giới hạn, thời điểm khi bạn thấy ranh giới và quyết định rằng thật khó tuân theo OCP hơn là sửa đổi code hiện có, hoặc chi phí cho kiến ​​trúc không biện minh cho chi phí của việc thay đổi code hiện có.

Giả sử chúng ta muốn viết một class có thể cung cấp tiến trình dưới dạng phần trăm cho một file được download thông qua ứng dụng của chúng tôi. Chúng tôi sẽ có hai class chính, ProgressFile, và tưởng tượng chúng tôi sẽ sử dụng chúng như trong bài test dưới đây.

Trong bài test này, chúng tôi là người dùng của Progress. Chúng tôi muốn thu được giá trị dưới dạng phần trăm, bất kể kích thước thực tế của file. Chúng tôi sử dụng File làm nguồn thông tin cho Progress. Một file có độ dài tính theo byte và một trường được gọi là sent biểu thị số lượng dữ liệu được gửi đến một file đang download. Chúng tôi không quan tâm về cách các giá trị này được cập nhật trong ứng dụng. Chúng ta có thể giả định rằng có một số logic làm việc đó cho chúng ta, vì vậy trong bài test, chúng ta có thể xét giá trị cho chúng một cách rõ ràng.

Class File chỉ là một đối tượng dữ liệu đơn giản có chứa hai trường. Tất nhiên trong thực tế, class File có lẽ sẽ chứa những thông tin và hành vi khác, như tên file, đường dẫn, đường dẫn tương đối, thư mục hiện tại, loại file, quyền truy xuất và v.v.

Progress chỉ đơn giản là một class lấy một File trong constructor của nó. Để rõ ràng, chúng tôi xác định kiểu cho biến trong tham số của constructor. Có một method duy nhất trong Progress, getAsPercent(), sẽ lấy các giá trị sent và length của File và chuyển đổi chúng thành dạng phần trăm. Đơn giản, và hiệu quả.

Code này có vẻ đúng, tuy nhiên đã vi phạm nguyên tắc Open/Closed. Nhưng tại sao? Và như thế nào?

Thay đổi các yêu cầu

Mỗi ứng dụng dự kiến được tiến hoá theo thời gian sẽ cần các tính năng mới. Một tính năng mới cho ứng dụng của chúng tôi có thể là cho phép phát trực tuyến nhạc, thay vì chỉ download các file. Độ dài của File được biểu thị bằng byte, thời lượng của nhạc tính bằng giây. Chúng tôi muốn cung cấp một thanh tiến trình đẹp cho người nghe của chúng tôi, nhưng chúng tôi có thể tái sử dụng những gì đã có?

Chúng tôi không thể. Phần tiến trình đã bị trói buộc vào File. Nó chỉ hiểu các file, thậm chí dù nó cũng có thể được áp dụng cho nội dung về âm nhạc. Nhưng để làm được điều đó chúng ta phải sửa đổi nó, chúng ta cho Progress biết về MusicFile. Nếu thiết kế của chúng ta tuân thủ OCP, chúng ta sẽ không cần chạm đến File hoặc Progress. Đơn giản là chúng ta có thể sử dụng Progress hiện tại và áp dụng class này cho Music.

Giải pháp 1: Tận dụng lợi thế của bản chất PHP

Các ngôn ngữ gán kiểu động có những ưu điểm của việc đoán biết các kiểu đối tượng trong runtime. Điều này cho phép chúng ta loại bỏ typehint khỏi constructor của Progress và code sẽ vẫn hoạt động.

Giờ chúng ta có thể đưa bất cứ thứ gì vào Progress. Và ý tôi là bất cứ điều gì, theo nghĩa đen:

Và class Music giống như class phía trên sẽ hoạt động tốt. Chúng tôi có thể test nó một cách dễ dàng với một bài test tương tự như với File.

Vì vậy, về cơ bản, bất kỳ nội dung có thể đo lường nào đều có thể được sử dụng với class Progress. Có lẽ chúng ta nên diễn tả điều này trong code bằng cách thay đổi tên của biến đó:

Nhưng có một vấn đề lớn với cách tiếp cận này. Khi chúng ta có File được chỉ định như một typehint, chúng ta yên tâm về những gì class của chúng ta có thể xử lý. Điều hoàn toàn rõ ràng và nếu có điều gì xảy ra, chúng ta sẽ biết lỗi đó.

Nhưng nếu không có typehint, chúng ta phải dựa vào thực tế là bất cứ điều gì xảy ra, sẽ có hai biến public của với tên chính xác là "length" và "sent". Nếu không chúng ta sẽ có một di sản bị bác bỏ.

Refused bequest: một class ghi đè lên một phương thức của một class cơ sở theo cách mà contract của class cơ sở không được biết đến từ class dẫn xuất. ~Nguồn Wikipedia.

Đây là một trong những code smell được trình bày chi tiết trong khóa học cao cấp Detecting Code Smells. Tóm lại, chúng tôi không muốn có kết quả là việc gọi phương thức hoặc truy cập các trường trên các đối tượng không tuân thủ contract. Khi chúng tôi đã có một typehint, nó đã giúp xác định contract Các trường và method của class File. Giờ chúng ta có thể gửi bất kỳ thứ gì, thậm chỉ một string và sẽ nhận được một lỗi tệ hại.

Một bài test như thế, chúng tôi gửi trong một string đơn giản, sẽ trả về một yêu cầu bị từ chối:

Trong cả hai trường hợp cho kết quả như nhau, nghĩa là các code hỏng, trường hợp đầu tiên tạo ra một thông điệp ổn. Tuy nhiên điều này rất tối nghĩa. Không có cách nào để biết biến đó là gì - một string trong trường hợp của chúng ta - và những thuộc tính nào đã được tìm kiếm và không được tìm thấy. Thật khó để gỡ lỗi và giải quyết vấn đề. Lập trình viên cần mở class Progress và đọc hiểu nó. Trong trường hợp này, khi chúng ta không xác định rõ typehint, contract được xác định bởi behavior của Progress. Đó là một contract tiềm ẩn, chỉ có Progress biết đến nó. Trong ví dụ của chúng ta, nó được định nghĩa bởi sự truy cập vào hai trường, sentlength, trong phương thức getAsPercent(). Trong thực tế, contract tiềm ẩn có thể rất phức tạp và khó phát hiện bằng cách khi chỉ thoáng nhìn qua một class.

Giải pháp này chỉ được khuyến khích nếu không có đề xuất nào khác dưới đây có thể dễ dàng được triển khai hoặc nếu chúng yêu cầu những thay đổi lớn trong kiến trúc, mà điều này không xứng đáng với nỗ lực cần tiêu tốn.

Giải pháp 2: Sử dụng Strategy Design Pattern

Đây là giải pháp phổ biến nhất và có lẽ là giải pháp thích hợp nhất để tuân thủ OCP. Đơn giản và hiệu quả.

strategy

Strategy Pattern đơn giản chỉ giới thiệu việc sử dụng của interface. Interface là một loại entity đặc biệt trong lập trình hướng đối tượng (OOP), nó định nghĩa một contract giữa một class client và một class server. Cả hai class sẽ tuân thủ contract để đảm bảo có behavior ưng ý. Có thể có một số class server không liên quan, đ1o là các class tuân theo cùng một contract do đó có khả năng phục vụ cùng một class client.

Trong một interface, chúng ta chỉ có thể định nghĩa hành vi. Đó là lý do tại sao thay vì trực tiếp sử dụng các biến public, chúng ta phải suy nghĩ về việc sử dụng các getters và setters. Thích ứng với các class khác sẽ không quá khó vào thời điểm này. IDE của chúng tôi có thể thực hiện hầu hết công việc.

Chúng tôi thường bắt đầu các bài test của mình. Sẽ cần phải sử dụng setters để xét các giá trị. Nếu chúng là bắt buộc, những setter này cũng có thể được xác định trong interface Measurable. Tuy nhiên, hãy cẩn thận những gì bạn đưa vào. Giao diện là xác định contract giữa tiến trình class client Progress và các class server khác nhau như FileMusic. Progress có cần thiết lập các giá trị không? Chắc là không. Vì vậy, các setters có thể không cần thiết để được xác định trong interface. Ngoài ra, nếu bạn định nghĩa các setters ở đó, bạn sẽ buộc tất cả các server class triển khai các setters. Đối với một số class đó, có setters là việc hợp lý, nhưng với những class khác có thể xử lý hoàn toàn khác. Chuyện gì sẽ xảy ra nếu ta muốn sử dụng class Progress để hiển thị nhiệt độ của lò nướng? Class OvenTemperature có thể được khởi tạo với các giá trị trong constructor hoặc lấy thông tin từ class thứ ba. Ai biết được chứ? Có những setter trong class đó có vẻ kỳ dị.

Class File được sửa đổi một chút để đáp ứng các yêu cầu trên. Bây giờ class này triển khai interface Measurable các interface đo lường và có setters và getters cho các lĩnh vực chúng tôi đang quan tâm. Music giống như vậy, bạn có thể test nội dung của nó trong code nguồn được đính kèm. Chúng ta gần như hoàn tất.

Progress cũng cần một bản cập nhật nhẹ. Bây giờ chúng ta có thể chỉ định một kiểu, sử dụng typehinting, trong constructor. Kiểu được mong đợi là Measurable. Bây giờ chúng ta có một contract rõ ràng. Progress có thể bảo đảm các phương thức truy cập sẽ luôn hiện diện vì chúng được định nghĩa trong interface Measurable. FileMusic cũng có thể bảo đảm chúng có thể cung cấp tất cả những gì cần thiết cho Progress bằng cách triển khai tất cả các phương thức trên interface, đây là một yêu cầu khi một class triển khai một interface.

Design pattern này được giải thích chi tiết hơn trong khóa học Agile Design Patterns.

Ghi chú việc đặt tên interface

Mọi người có xu hướng đặt tên các interface với một ký tự hoa I trước chúng, hoặc với từ Interface kèm theo ở cuối cùng, như IFile hoặc FileInterface. Đây là notation cũ từ những tiêu chuẩn lỗi thời. Chúng ta quá nhiều Hungary notation cũ hoặc nhu cầu cần phải xác định kiểu của một biến hoặc đối tượng trong tên của nó để dễ dàng nhận diện nó hơn. IDE định nghĩa bất cứ điều gì trong một trăm giây. Điều này cho phép chúng ta tập trung vào những gì chúng ta thực sự khó hiểu.

Các interface thuộc về các client của nó. Vâng. Khi bạn muốn đặt tên cho một interface, bạn phải nghĩ về client và quên đi việc triển khai. Khi chúng tôi đặt tên cho interface Measurable, chúng tôi đã suy nghĩ về Progress. Nếu tôi là một progress, thì tôi sẽ cần những gì để có thể tính ra phần trăm? Câu trả lời đơn giản, một cái gì đó chúng ta có thể đo lường. Do đó tên Measurable.

Một lý do khác là việc triển khai có thể từ nhiều domain khác nhau. Trong trường hợp này, chúng tôi có file và music. Nhưng chúng tôi rất có thể sử dụng lại Progress của chúng tôi trong trình mô phỏng đua xe. Trong trường hợp đó, các class được đo sẽ là Speed, Nhiên liệu, v.v. Tốt phải không?

Giải pháp 3: Dùng Template Method Design Pattern

Mẫu Template Method rất giống với strategy, nhưng thay vì một interface, nó sử dụng một class abstract. Chúng tôi khuyên bạn nên sử dụng pattern Template Method khi chúng tôi có client rất cụ thể cho ứng dụng của chúng tôi, với khả năng sử dụng lại giảm và khi các class server có chung behavior.

template_method

Design pattern này được giải thích chi tiết hơn trong khóa học Agile Design Pattern.

Một góc nhiều cao cấp hơn

Vậy, tất cả điều này ảnh hưởng đến kiến ​​trúc cao cấp của chúng ta như thế nào?

HighLevelDesign

Nếu hình ảnh trên đại diện cho kiến ​​trúc hiện tại của ứng dụng của chúng tôi, việc bổ sung một module với 5 class mới (các class màu xanh) sẽ ảnh hưởng đến thiết kế của chúng tôi một cách vừa phải (class màu đỏ).

HighLevelDesignWithNewClasses

Trong hầu hết các hệ thống, bạn không thể mong đợi việc hoàn toàn không gây ảnh hưởng đến code hiện tại khi các class mới được đưa vào. Tuy nhiên, việc tuân thủ nguyên tắc Open/Closed sẽ làm giảm đáng kể các class và module yêu cầu thay đổi liên tục.

Như với bất kỳ nguyên tắc nào khác, hãy cố gắng không suy nghĩ về mọi thứ từ trước. Nếu bạn làm như vậy, bạn sẽ tạo ra một interface cho mỗi class của bạn. Thật khó khăn để hiểu và duy trì một thiết kế như vậy. Thông thường, cách an toàn nhất để là suy nghĩ về các khả năng và nếu bạn có thể xác định liệu có các loại server class khác không. Nhiều lúc bạn có thể dễ dàng hình dung một tính năng mới hoặc có thể tìm thấy một tính năng này trên backlog của dự án sẽ tạo ra một server class khác. Trong những trường hợp đó, hãy thêm interface ngay từ đầu. Nếu bạn không thể xác định, hoặc nếu bạn không chắc chắn - hầu hết thời gian - chỉ cần bỏ qua nó. Hãy để lập trình viên sau này, hoặc thậm chí là chính bạn, bổ sung cho interface khi bạn cần một triển khai thứ hai.

Tổng kết

Nếu bạn tuân thủ kỷ luật và bổ sung interface ngay khi server thứ hai được yêu cầu, việc thay đổi sẽ diễn ra rất dễ dàng và không nhiều. Hãy rằng, nếu code yêu cầu thay đổi một lần, thì có thể sẽ có lần thứ hai. Khi khả năng đó biến thành hiện thực, OCP sẽ giúp bạn tiết kiệm rất nhiều thời gian và công sức.

Cảm ơn bạn đã đọc bài viết.

Advertisement
Advertisement
Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.