Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Làm Chủ Các Nguyên Tắc Thiết Kế SOLID Cho Ứng Dụng Ruby Mạnh Mẽ

Tất cả ứng dụng phần mềm đều thay đổi theo thời gian. Thay đổi làm cho phần mềm có thể gây ra các vấn đề cascading không mong đợi. Tuy nhiên, thay đổi là không thể tránh khỏi, vì chúng ta không thể xây dựng phần mềm không thay đổi. Yêu cầu phần mềm tiếp tục thay đổi khi phần mềm phát triển. Những gì chúng ta có thể làm là thiết kế phần mềm sao cho nó có khả năng chịu đựng thay đổi. Thiết kế phần mềm đúng cách có thể tốn thời gian và công sức ở đầu, nhưng về dài hạn, nó tiết kiệm thời gian và công sức. Phần mềm tightly coupled (chặt chẽ) là dễ vỡ, và chúng ta không thể dự đoán điều gì xảy ra với thay đổi. Đây là một số hiệu ứng của phần mềm thiết kế kém:

  • Nó gây ra sự bất động (immobility).
  • Thay đổi code tốn kém.
  • Dễ dàng thêm phức tạp hơn là làm cho phần mềm đơn giản hơn.
  • Code không thể quản lý được.
  • Mất nhiều thời gian cho developer để hiểu cách chức năng của nó hoạt động.
  • Thay đổi một phần của phần mềm thường làm hỏng phần khác, và chúng ta không thể dự đoán vấn đề nào thay đổi có thể mang lại.

Bài báo Design Principles and Design Patterns liệt kê các triệu chứng sau của phần mềm "thối rữa":

  • Rigidity (Cứng nhắc): Rất khó thay đổi code mà không gây ra vấn đề, vì thay đổi ở một phần đòi hỏi phải thay đổi ở các phần khác của code.
  • Fragility (Dễ vỡ): Thay đổi code thường làm hỏng hành vi của phần mềm. Nó thậm chí có thể làm hỏng các phần không liên quan trực tiếp đến thay đổi.
  • Immobility (Bất động): Mặc dù một số phần của ứng dụng có thể có hành vi tương tự, chúng ta không thể tái sử dụng code và phải duplicate chúng.
  • Viscosity (Nhớt nhát): Khi phần mềm khó thay đổi, chúng ta cứ thêm phức tạp vào phần mềm thay vì làm cho nó tốt hơn.

Thiết kế phần mềm sao cho các thay đổi có thể được kiểm soát và dự đoán là cần thiết.

Các nguyên tắc thiết kế SOLID giúp giải quyết các vấn đề này bằng cách decouple (tách rời) các chương trình phần mềm. Robert C. Martin giới thiệu các khái niệm này trong bài báo Design Principles and Design Patterns, và Michael Feathers tạo ra acronym sau đó.

Nguyên tắc thiết kế SOLID bao gồm năm nguyên tắc:

  • Single Responsibility Principle (Nguyên tắc Trách Nhiệm Đơn Độc)
  • Open/Closed Principle (Nguyên tắc Mở/Đóng)
  • Liskov Substitution Principle (Nguyên tắc Thay Thế Liskov)
  • Interface Segregation Principle (Nguyên tắc Phân Tách Interface)
  • Dependency Inversion Principle (Nguyên tắc Đảo Ngược Phụ Thuộc)

Chúng ta sẽ khám phá từng cái để hiểu cách các nguyên tắc này giúp xây dựng phần mềm thiết kế tốt trong Ruby.

Single Responsibility Principle - SRP

Giả sử cho phần mềm quản lý nhân sự, chúng ta cần chức năng tạo user, thêm lương nhân viên, và tạo phiếu lương nhân viên. Khi xây dựng, chúng ta có thể thêm các chức năng này vào một class đơn, nhưng cách tiếp cận này gây ra sự phụ thuộc không mong muốn giữa các chức năng. Nó đơn giản khi bắt đầu, nhưng khi mọi thứ thay đổi và yêu cầu mới nảy sinh, chúng ta sẽ không thể dự đoán chức năng nào thay đổi sẽ làm hỏng.

Một class nên có một, và chỉ một lý do để thay đổi - Robert C Martin

Đây là sample code nơi tất cả chức năng trong một class đơn:

class User
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # Code để đọc từ database,
    # generate payslip
    # và ghi nó vào file
    self.send_email
  end

  def send_email
    # code để gửi email
    employee.email
    month
  end
end

Để generate payslip và gửi cho user, chúng ta có thể khởi tạo class và gọi phương thức generate payslip:


month = 11
user = User.new(employee, month)
user.generate_payslip

Bây giờ, có yêu cầu mới. Chúng ta muốn generate payslip nhưng không muốn gửi email. Chúng ta cần giữ chức năng hiện tại như cũ và thêm một payslip generator mới cho báo cáo nội bộ mà không gửi email, vì nó cho mục đích nội bộ. Trong giai đoạn này, chúng ta muốn đảm bảo payslip hiện tại gửi cho nhân viên vẫn hoạt động.

Cho yêu cầu này, chúng ta không thể tái sử dụng code hiện có. Chúng ta hoặc cần thêm flag vào phương thức generate_payslip nói nếu true thì gửi email ngược lại không. Điều này có thể làm được, nhưng vì nó thay đổi code hiện có, nó có thể làm hỏng chức năng hiện tại.

Để chắc chắn chúng ta không làm hỏng gì, chúng ta cần tách rời các logic này thành các class riêng biệt:


class PayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # Code để đọc từ database,
    # generate payslip
    # và ghi nó vào file
  end
end

class PayslipMailer
  def initialize(employee)
    @employee = employee
  end

  def send_mail
    # code để gửi email
    employee.email
    month
  end
end

Tiếp theo, chúng ta có thể khởi tạo hai class này và gọi phương thức của chúng:


month = 11
# General Payslip generator
generator = PayslipGenerator.new(employee, month)
generator.generate_payslip
# Send Email
mailer = PayslipMailer.new(employee, month)
mailer.send_mail

Cách tiếp cận này giúp tách rời trách nhiệm và đảm bảo thay đổi có thể dự đoán. Nếu chúng ta chỉ cần thay đổi chức năng mailer, chúng ta có thể làm vậy mà không thay đổi report generation. Nó cũng giúp dự đoán bất kỳ thay đổi nào trong chức năng.

Giả sử chúng ta cần thay đổi định dạng trường month trong email thành Nov thay vì 11. Trong trường hợp này, chúng ta sẽ sửa đổi class PayslipMailer, và điều này sẽ đảm bảo không có gì thay đổi hoặc bị hỏng trong chức năng PayslipGenerator.

Mỗi khi bạn viết một đoạn code, hãy tự hỏi sau đó. Trách nhiệm của class này là gì? Nếu câu trả lời của bạn có "và" trong đó, hãy tách class thành nhiều class. Class nhỏ luôn tốt hơn class lớn, chung chung.

Open/Closed Principle - OCP

Bertrand Meyer khởi xướng nguyên tắc open/closed trong cuốn sách Object-Oriented Software Construction.

Nguyên tắc nói, "các thực thể phần mềm (class, module, function, v.v.) nên mở cho extension (mở rộng) nhưng đóng cho modification (sửa đổi)". Điều này nghĩa là chúng ta nên có thể thay đổi hành vi mà không thay đổi thực thể.

Trong ví dụ trên, chúng ta có chức năng gửi payslip cho nhân viên, nhưng nó rất chung chung cho tất cả nhân viên. Tuy nhiên, yêu cầu mới nảy sinh: generate payslip dựa trên loại nhân viên. Chúng ta cần logic payroll generation khác nhau cho nhân viên full-time và contractor. Trong trường hợp này, chúng ta có thể sửa đổi PayslipGenerator hiện có và thêm các chức năng này:

class PayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # Code để đọc từ database,
    # generate payslip
    if employee.contractor?
      # generate payslip cho contractor
    else
      # generate payslip bình thường
    end
    # và ghi nó vào file
  end
end

Tuy nhiên, đây là bad pattern. Bằng cách làm vậy, chúng ta đang sửa đổi class hiện có. Nếu chúng ta cần thêm logic generation dựa trên hợp đồng nhân viên, chúng ta cần sửa đổi class hiện có, nhưng làm vậy vi phạm nguyên tắc open/closed. Bằng cách sửa đổi class, chúng ta rủi ro làm thay đổi không mong muốn. Khi có gì thay đổi hoặc thêm, điều này có thể gây ra vấn đề không biết trong code hiện có. Những if-else này có thể ở nhiều nơi hơn trong cùng class. Vì vậy, khi chúng ta thêm loại nhân viên mới, chúng ta có thể bỏ lỡ nơi những if-else này hiện diện. Tìm và sửa đổi tất cả có thể rủi ro và tạo ra vấn đề.

Chúng ta có thể refactor code này sao cho chúng ta có thể thêm chức năng bằng cách extend (mở rộng) chức năng nhưng tránh thay đổi thực thể. Vì vậy, hãy tạo class riêng cho mỗi cái và có cùng phương thức generate cho mỗi cái:

class ContractorPayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate
    # Code để đọc từ database,
    # generate payslip
    # và ghi nó vào file
  end
end
class FullTimePayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate
    # Code để đọc từ database,
    # generate payslip
    # và ghi nó vào file
  end
end

Đảm bảo chúng có cùng tên phương thức. Bây giờ, thay đổi class PayslipGenerator để dùng các class này:

GENERATORS = {
  'full_time' => FullTimePayslipGenerator,
  'contractor' => ContractorPayslipGenerator
}

class PayslipGenerator
  def initialize(employee, month)
    @employee = employee
    @month = month
  end

  def generate_payslip
    # Code để đọc từ database,
    # generate payslip
    GENERATORS[employee.type].new(employee, month).generate()
    # và ghi nó vào file
  end
end

Ở đây, chúng ta có constant GENERATORS map class sẽ được gọi dựa trên loại nhân viên. Chúng ta có thể dùng nó để xác định class nào gọi. Bây giờ, khi chúng ta phải thêm chức năng mới, chúng ta chỉ cần tạo class mới cho đó và thêm nó vào constant GENERATORS. Điều này giúp extend class mà không làm hỏng gì hoặc cần nghĩ về logic hiện có. Chúng ta có thể dễ dàng thêm hoặc loại bỏ bất kỳ loại payslip generator nào.

Liskov Substitution Principle - LSP

Nguyên tắc thay thế Liskov nói, "nếu S là subtype của T, thì object của type T có thể được thay thế bằng object của type S".

Để hiểu nguyên tắc này, hãy hiểu vấn đề trước. Dưới nguyên tắc open/closed, chúng ta thiết kế phần mềm sao cho nó có thể được extend. Chúng ta tạo subclass Payslip generator làm một việc cụ thể. Cho caller, class mà họ đang gọi là không biết. Các class này cần có cùng hành vi để caller không thể phân biệt được sự khác biệt. Bằng hành vi, chúng ta nghĩa là các phương thức trong class nên nhất quán. Các phương thức trong các class này nên có đặc điểm sau:

  • Có cùng tên
  • Nhận cùng số lượng arguments với cùng kiểu dữ liệu
  • Trả về cùng kiểu dữ liệu

Hãy nhìn ví dụ payslip generator. Chúng ta có hai generator, một cho nhân viên full-time và một cho contractor. Bây giờ, để đảm bảo các payslip này có hành vi nhất quán, chúng ta cần kế thừa chúng từ một base class. Hãy định nghĩa base class gọi là User.

class User
  def generate
  end
end

Subclass chúng ta tạo trong ví dụ open/close principle không có base class. Chúng ta sửa đổi nó để có base class User:

class ContractorPayslipGenerator < User
  def generate
    # Code để generate payslip
  end
end
class FullTimePayslipGenerator < User
  def generate
    # Code để generate payslip
  end

Tiếp theo, chúng ta định nghĩa một tập phương thức bắt buộc cho bất kỳ subclass nào kế thừa class User. Chúng ta định nghĩa các phương thức này trong base class. Trong trường hợp của chúng ta, chúng ta chỉ cần một phương thức, gọi là generate.

class User
  def generate
    raise "NotImplemented"
  end
end

Ở đây, chúng ta đã định nghĩa phương thức generate, có statement raise. Vì vậy, bất kỳ subclass nào kế thừa base class cần có phương thức generate. Nếu nó không có, nó sẽ raise lỗi phương thức không được implement. Theo cách này, chúng ta có thể chắc chắn subclass là nhất quán. Với điều này, caller luôn có thể chắc chắn phương thức generate có mặt.

Nguyên tắc này giúp substitute bất kỳ subclass nào dễ dàng mà không làm hỏng gì và không cần làm nhiều thay đổi.

Interface Segregation Principle - ISP

Nguyên tắc phân tách interface áp dụng cho ngôn ngữ tĩnh, và vì Ruby là ngôn ngữ động, không có khái niệm interface. Interface định nghĩa quy tắc abstraction giữa các class.

Nguyên tắc nói,

Client không nên bị buộc phụ thuộc vào interface mà họ không sử dụng. - Robert C. Martin

Điều này nghĩa là tốt hơn là có nhiều interface hơn là một interface chung chung mà bất kỳ class nào cũng có thể dùng. Nếu chúng ta định nghĩa một interface chung chung, class phải phụ thuộc vào định nghĩa mà nó không dùng.

Ruby không có interface, nhưng hãy nhìn khái niệm class và subclass để xây dựng cái gì đó tương tự.

Trong ví dụ dùng cho Liskov substitution principle, chúng ta thấy subclass FullTimePayslipGenerator được kế thừa từ class chung User. Nhưng User là class rất chung chung và có thể chứa phương thức khác. Nếu chúng ta phải có chức năng khác, như Leave, nó sẽ phải là subclass của User. Leave không cần có phương thức generate, nhưng nó sẽ phụ thuộc vào phương thức này. Vì vậy, thay vì có class chung chung, chúng ta có thể có class cụ thể cho việc này:

class Generator
  def generate
    raise "NotImplemented"
  end
end
class ContractorPayslipGenerator < Generator
  def generate
    # Code để generate payslip
  end
end
class FullTimePayslipGenerator < Generator
  def generate
    # Code để generate payslip
  end
end

Generator này cụ thể cho payslip generation, và subclass không cần phụ thuộc vào class User chung chung.

Dependency Inversion Principle - DIP

Dependency inversion là nguyên tắc áp dụng để decouple các module phần mềm.

Module high-level không nên phụ thuộc vào module low-level; cả hai nên phụ thuộc vào abstraction.

Thiết kế, sử dụng các nguyên tắc trên mô tả, hướng chúng ta tới nguyên tắc dependency inversion. Bất kỳ class nào có single responsibility cần thứ từ class khác để làm việc. Để generate payroll, chúng ta cần truy cập database, và chúng ta cần ghi vào file khi report được generate. Với single responsibility principle, chúng ta cố gắng chỉ có một việc cho một class đơn. Nhưng, những thứ như đọc từ database và ghi vào file cần được thực hiện trong cùng class.

Quan trọng là loại bỏ các dependency này và decouple logic business chính. Điều này sẽ giúp code linh hoạt khi thay đổi, và thay đổi trở nên có thể dự đoán. Dependency cần được inverted, và caller của module nên có control over dependency. Trong payslip generator của chúng ta, dependency là nguồn dữ liệu cho report; code này nên được tổ chức sao cho caller có thể chỉ định nguồn. Control của dependency cần được inverted và có thể dễ dàng sửa đổi bởi caller.

Làm Chủ Các Nguyên Tắc Thiết Kế SOLID Cho Ứng Dụng Ruby Mạnh Mẽ

Trong ví dụ trên, module ContractorPayslipGenerator control dependency, vì xác định đâu đọc dữ liệu và cách lưu output được control bởi class. Để đảo ngược điều này, hãy tạo class UserReader đọc dữ liệu user:

class UserReader
  def get
    raise "NotImplemented"
  end
end

Bây giờ, giả sử chúng ta muốn nó đọc dữ liệu từ Postgres. Chúng ta tạo subclass của UserReader cho mục đích này:

class PostgresUserReader < UserReader
  def get
    # Code để đọc dữ liệu từ Postgres
  end

Tương tự, chúng ta có thể có reader từ FileUserReader, InMemoryUserReader, hoặc bất kỳ loại reader nào chúng ta muốn. Chúng ta bây giờ cần sửa đổi class FullTimePayslipGenerator để nó dùng PostgresUserReader như một dependency.

class FullTimePayslipGenerator < Generator
  def initialize(datasource)
    @datasource = datasource
  end

  def generate
    # Code để generate payslip
    data = datasource.get()
  end
end

Caller bây giờ có thể pass PostgresUserReader như dependency:

datasource = PostgresUserReader.new()
FullTimePayslipGenerator.new(datasource)

Caller có control over dependency và có thể dễ dàng thay đổi nguồn khi cần.

Inverting dependency không chỉ áp dụng cho class. Chúng ta cũng cần invert configurations. Ví dụ, khi kết nối Postgres server, chúng ta cần cấu hình cụ thể, như DBURL, username, và passwords. Thay vì hardcode các cấu hình này trong class, chúng ta cần truyền chúng xuống từ caller.

class PostgresUserReader < UserReader
  def initialize(config)
    config = config
  end

  def get
    # initialize DB với config
    self.config
    # Code để đọc dữ liệu từ Postgres
  end
end

Cung cấp config bởi caller:


config = { url: "url", user: "user" }
datasource = PostgresUserReader.new(config)
FullTimePayslipGenerator.new(datasource)

Caller bây giờ có complete control over dependency, và change management dễ dàng và ít đau đớn hơn.

Kết Luận

Thiết kế SOLID giúp decouple code và làm cho thay đổi ít đau đớn hơn. Quan trọng là thiết kế chương trình sao cho chúng decoupled, reusable, và responsive to change. Tất cả năm nguyên tắc SOLID bổ sung cho nhau và nên co-exist. Một codebase thiết kế tốt là linh hoạt, dễ thay đổi, và vui để làm việc. Bất kỳ developer mới nào có thể nhảy vào và dễ dàng hiểu code.

Thực sự quan trọng để hiểu loại vấn đề nào SOLID giải quyết và tại sao chúng ta làm điều này. Hiểu vấn đề giúp bạn đón nhận các nguyên tắc thiết kế và thiết kế phần mềm tốt hơn.