Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เชี่ยวชาญหลักการออกแบบ SOLID สำหรับ Ruby Applications ที่แข็งแกร่ง

ซอฟต์แวร์ทุกตัวเปลี่ยนแปลงตามเวลา การเปลี่ยนแปลงอาจก่อให้เกิด Cascading Issues ที่ไม่คาดคิด แต่การเปลี่ยนแปลงเป็นสิ่งหลีกเลี่ยงไม่ได้ เราไม่สามารถสร้างซอฟต์แวร์ที่ไม่เปลี่ยนแปลงได้ Software Requirements เปลี่ยนไปเรื่อยๆ ขณะที่ซอฟต์แวร์โตขึ้น สิ่งเราทำได้คือออกแบบซอฟต์แวร์ให้ทนทานต่อการเปลี่ยนแปลง การออกแบบที่ดีอาจใช้เวลาและความพยายามตอนเริ่ม แต่ Long-term ประหยัดเวลาและความพยายาม Software ที่ Tightly Coupled แตกหักง่าย และเราไม่คาดการณ์ได้ว่าการเปลี่ยนแปลงจะเกิดอะไรขึ้น นี่คือผลกระทบของ Software ที่ออกแบบไม่ดี:

  • ก่อให้เกิด Immobility (ย้าย/นำกลับมาใช้ใหม่ไม่ได้)
  • การเปลี่ยนโค้ดมีต้นทุนสูง
  • เพิ่ม Complexity ง่ายกว่าทำให้ Software เรียบง่ายขึ้น
  • Code ไม่สามารถ Manage ได้
  • Developer ใช้เวลานานมากเข้าใจ Functionality
  • เปลี่ยนส่วนหนึ่งมักทำให้ส่วนอื่นพัง และไม่คาดเดา Issues ได้

Paper "Design Principles and Design Patterns" ระบุอาการของ Rotting Software:

  • Rigidity (ความแข็งตัว): ยากมากที่จะเปลี่ยนโค้ดโดยไม่ก่อปัญหา เพราะเปลี่ยนส่วนหนึ่ง ต้องเปลี่ยนส่วนอื่นๆ ตาม
  • Fragility (ความเปราะบาง): เปลี่ยนโค้ดมักทำให้ Behavior พัง แม้ส่วนที่ไม่เกี่ยวข้องโดยตรง
  • Immobility (ความไม่เคลื่อนย้าย): แม้บางส่วนจะมี Behavior คล้ายกัน แต่ Reuse Code ไม่ได้ ต้อง Duplicate
  • Viscosity (ความ粘稠): Software ยากต่อการเปลี่ยนแปลง เราก็เพิ่ม Complexity แทนที่จะปรับปรุง

จึงจำเป็นต้องออกแบบ Software ให้ Changes ควบคุมและคาดเดาได้

หลักการออกแบบ SOLID ช่วยแก้ปัญหาเหล่านี้โดย Decouple Software Programs Robert C. Martin เสนอใน Paper "Design Principles and Design Patterns" และ Michael Feathers สร้าง Acronym SOLID

หลักการ SOLID ประกอบ 5 原則:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

เราจะสำรวจแต่ละอย่าง เพื่อเข้าใจว่าช่วยสร้าง Well-designed Software ใน Ruby ได้อย่างไร

Single Responsibility Principle - SRP

สมมติ HR Management Software ต้องการ: สร้าง User, เพิ่ม Salary พนักงาน, สร้าง Payslip พนักงาน ตอนสร้าง อาจใส่ทั้งหมดใน Class เดียว แต่ Approach นี้ก่อ Unwanted Dependencies ระหว่าง Functionalities เริ่มต้น Simple แต่เมื่อ Requirements เปลี่ยน เราจะไม่คาดเดาได้ว่า Change จะทำลาย Functionality ไหน

A class should have one, and only one reason to change — Robert C Martin

ตัวอย่างโค้ดทุก Functionality ใน Class เดียว:


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

  def generate_payslip
    # Code to read from database,
    # generate payslip
    # and write it to a file
    self.send_email
  end

  def send_email
    # code to send email
    employee.email
    month
  end
end

Generate Payslip และส่ง Email:


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

Requirement ใหม่: Generate Payslip แต่ไม่ส่ง Email ต้องเก็บ Existing Functionality และเพิ่ม Payslip Generator ใหม่สำหรับ Internal Reporting โดยไม่ส่ง Email (เพราะ Internal Purpose) ระหว่างนี้ต้องการ Existing Payslip ให้ทำงานต่อไป

สำหรับ Requirement นี้ Reuse Existing Code ไม่ได้ ต้องเพิ่ม Flag ใน generate_payslip บอกถ้า True ส่ง Email False ไม่ส่ง ทำได้ แต่เปลี่ยน Existing Code อาจทำลาย Existing Functionality

เพื่อไม่ให้พัง ต้อง Decouple Logics ออกเป็น Classes แยก:


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

  def generate_payslip
    # Code to read from database,
    # generate payslip
    # and write it to a file
  end
end

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

  def send_mail
    # code to send email
    employee.email
    month
  end
end

Initialize สอง Classes และเรียก Methods:


month = 11
# General Payslip
generator = PayslipGenerator.new(employee, month)
generator.generate_payslip

# Send Email
mailer = PayslipMailer.new(employee, month)
mailer.send_mail

Approach นี้ Decouple Responsibilities และให้ Predictable Change ถ้าต้องการเปลี่ยน Mailer Functionality ทำได้โดยไม่เปลี่ยน Report Generation ช่วย Predict การเปลี่ยนแปลง Functionality ได้

สมมติต้องเปลี่ยน Format Month Field ใน Email เป็น Nov แทน 11 กรณีนี้แก้ PayslipMailer Class และมั่นใจว่า PayslipGenerator ไม่เปลี่ยนแปลงหรือพัง

ทุกครั้งเขียนโค้ด ถามตัวเอง: Class นี้มี Responsibility อะไร? ถ้าคำตอบมี "และ" (And) ให้แยก Class เป็นหลาย Classes Smaller Classes ดีกว่า Large Generic Classes

Open/Closed Principle - OCP

Bertrand Meyer ก่อตั้ง OCP ในหนังสือ "Object-Oriented Software Construction"

หลักการระบุ: "Software Entities (Classes, Modules, Functions, etc.) ควร Open for Extension แต่ Closed for Modification" หมายถึงเราควรเปลี่ยน Behavior ได้โดยไม่เปลี่ยน Entity เอง

ในตัวอย่างก่อน มี Payslip-sending สำหรับ Employee แบบ Generic ทุกคน แต่ Requirement ใหม่: Generate Payslip ตาม Employee Type ต้องการ Payroll Logic แตกต่างสำหรับ Full-time และ Contractor กรณีนี้อาจแก้ PayslipGenerator เดิมและเพิ่ม Functionality:


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

  def generate_payslip
    # Code to read from database,
    # generate payslip
    if employee.contractor?
      # generate payslip for contractor
    else
      # generate a normal payslip
    end
    # and write it to a file
  end
end

แต่นี่เป็น Bad Pattern เพราะเรา Modify Existing Class ถ้าต้องเพิ่ม Generation Logic ตาม Contract ใหม่ ต้อง Modify Class เดิม ซึ่งละเมิด OCP การ Modify Class เสี่ยง Unintended Changes เมื่อมีการเปลี่ยนแปลงหรือเพิ่มเติม อาจก่อ Issues ใน Existing Code If-else อาจอยู่หลายที่ใน Class เดียวกัน ตอนเพิ่ม Employee Type ใหม่ อาจ Miss ที่ที่ If-else อยู่ การหาและแก้ทั้งหมด Risky และอาจก่อปัญหา

เรา Refactor ให้ Extend Functionality ได้โดยไม่เปลี่ยน Entity สร้าง Class แยกสำหรับแต่ละ Type มี Method generate เหมือนกัน:


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

  def generate
    # Code to read from the database,
    # generate payslip
    # and write it to a file
  end
end

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

  def generate
    # Code to read from the database,
    # generate payslip
    # and write it to a file
  end
end

ให้ Method Name เหมือนกัน แล้วแก้ PayslipGenerator ให้ใช้ Classes นี้:


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

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

  def generate_payslip
    # Code to read from database,
    # generate payslip
    GENERATORS[employee.type].new(employee, month).generate()
    # and write it to a file
  end
end

ที่นี่มี Constant GENERATORS Map Class ที่จะเรียกตาม Employee Type ใช้มัน決め เรียก Class ไหน ตอนเพิ่ม Functionality ใหม่ สร้าง Class ใหม่ เพิ่มใน GENERATORS Constant ช่วย Extend Class ได้โดยไม่พังอะไรหรือต้องคิด Existing Logic เพิ่ม/ลบ Payslip Generator Type ได้ง่าย

Liskov Substitution Principle - LSP

LSP ระบุ: "ถ้า S เป็น Subtype ของ T Object ของ Type T อาจแทนที่ด้วย Object ของ Type S ได้"

เข้าใจ Principle นี้ ต้องเข้าใจ Problem ก่อน ภายใต้ OCP เราออกแบบให้ Extend ได้ สร้าง Subclass Payslip Generator ทำงานเฉพาะเจาะจง สำหรับ Caller Class ที่เรียก Unknown Classes เหล่านี้ต้องมี Behavior เดียวกัน Caller จึงแยกความแตกต่างไม่ได้ Behavior หมายถึง Methods ใน Class ควร Consistent Methods ควรมีลักษณะ:

  • ชื่อเดียวกัน
  • รับ Arguments จำนวนเท่ากัน Data Type เดียวกัน
  • Return Data Type เดียวกัน

ดูตัวอย่าง Payslip Generator มี 2 Generators Full-time และ Contractor เพื่อให้ Payslips มี Consistent Behavior ต้อง Inherit จาก Base Class สร้าง Base Class User:


class User
  def generate
  end
end

Subclass ใน OCP Example ไม่มี Base Class แก้ให้มี Base Class User:


class ContractorPayslipGenerator < User
  def generate
    # Code to generate payslip
  end
end

class FullTimePayslipGenerator < User
  def generate
    # Code to generate payslip
  end
end

กำหนด Set of Methods ที่ Subclass ที่ Inherit User ต้องมี Define ใน Base Class ในกรณีนี้ต้องแค่ Method เดียว: generate


class User
  def generate
    raise "NotImplemented"
  end
end

Define generate Method มี raise Statement Subclass ที่ Inherit Base Class ต้องมี generate Method ถ้าไม่มี จะ Raise Error ว่า Method Not Implemented ด้วยวิธีนี้ ทำให้ Subclass Consistent Caller มั่นใจได้ว่า generate Method มีอยู่เสมอ

Principle นี้ช่วย Substitute Subclass ใดๆ ได้ง่ายโดยไม่พังและไม่ต้องเปลี่ยนแปลงมาก

Interface Segregation Principle - ISP

ISP ใช้กับ Static Languages Ruby เป็น Dynamic Language ไม่มี Interface Concept Interfaces กำหนด Abstraction Rules ระหว่าง Classes

หลักการระบุ:

Clients should not be forced to depend upon interfaces that they don't use. — Robert C. Martin

หมายถึง: ใช้หลาย Interfaces ดีกว่า Generalized Interface ที่ Class ใดๆ ใช้ได้ ถ้า Define Generalized Interface Class ต้อง Depend On Definition ที่มันไม่ใช้

Ruby ไม่มี Interfaces แต่ดู Class/Subclass Concept สร้างอะไรคล้ายกัน

ใน LSP Example เราเห็น FullTimePayslipGenerator Inherit จาก Generic Class User แต User Generic มาก อาจมี Methods อื่นๆ ถ้าต้องการ Functionality Leave ก็ต้องเป็น Subclass ของ User Leave ไม่ต้องมี generate Method แต่จะ Depend On Method นี้ แทน Generic Class ให้ Specific Class:


class Generator
  def generate
    raise "NotImplemented"
  end
end

class ContractorPayslipGenerator < Generator
  def generate
    # Code to generate payslip
  end
end

class FullTimePayslipGenerator < Generator
  def generate
    # Code to generate payslip
  end
end

Generator นี้เฉพาะ Payslip Generation Subclass ไม่ต้อง Depend On Generic User Class

Dependency Inversion Principle - DIP

Dependency Inversion เป็น Principle Decouple Software Modules

High-level Module ไม่ควร Depend On Low-level Module ทั้งคู่ควร Depend On Abstraction

Design ด้วย Principles ข้างบน นำเราไปสู่ DIP Class ที่มี Single Responsibility ต้องการสิ่งจาก Classes อื่นๆ เพื่อทำงาน Generate Payroll ต้อง Access Database และ Write File หลัง Generate Report ด้วย SRP เราพยายามให้ Class เดียวมี Job เดียว แต Database Read และ File Write ต้องทำใน Class เดียวกัน

สำคัญที่จะ Remove Dependencies และ Decouple Main Business Logic จะช่วย Code Fluid ระหว่าง Change และ Change กลายเป็น Predictable Dependency ต้อง Invert Caller ของ Module ควร Control Dependency ใน Payslip Generator Dependency คือ Data Source สำหรับ Report Code ควร Organize ให้ Caller ระบุ Source ได้ Control ของ Dependency ต้อง Invert และแก้ไขได้ง่ายโดย Caller

เชี่ยวชาญหลักการออกแบบ SOLID สำหรับ Ruby Applications ที่แข็งแกร่ง

ในตัวอย่าง ContractorPayslipGenerator Module Control Dependency การ决定 อ่าน Data ที่ไหน และ Store Output ยังไง Controlled โดย Class ให้ Invert สร้าง UserReader Class อ่าน User Data:


class UserReader
  def get
    raise "NotImplemented"
  end
end

สมมติอยากอ่านจาก Postgres สร้าง Subclass ของ UserReader:


class PostgresUserReader < UserReader
  def get
    # Code to read data from Postgres
  end

คล้ายกัน สร้าง FileUserReader, InMemoryUserReader หรือ Reader Types อื่นๆ ได้ ตอนนี้แก้ FullTimePayslipGenerator ให้ใช้ PostgresUserReader เป็น Dependency:


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

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

Caller สามารถ Pass PostgresUserReader เป็น Dependency:


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

Caller มี Control Over Dependency และเปลี่ยน Source ได้ง่ายตอนต้องการ

Inverting Dependency ไม่ได้แค่ Classes Configurations ก็ต้อง Invert เช่น เชื่อม Postgres ต้อง Configs เฉพาะ: DBURL, Username, Passwords แทน Hardcode ใน Class ให้ Pass Down จาก Caller


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

  def get
    # initialize DB with the config
    self.config
    # Code to read data from Postgres
  end
end

Provide Config โดย Caller:


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

Caller มี Complete Control Over Dependency Change Management ง่ายและน้อยเจ็บปวด

สรุป

SOLID Design ช่วย Decouple Code ทำให้ Change น้อยเจ็บปวด สำคัญที่จะออกแบบ Programs ให้ Decoupled, Reusable, Responsive to Change ทั้ง 5 Principles เสริมสร้างกัน และควร Co-exist Well-designed Codebase ยืดหยุ่น เปลี่ยนง่าย สนุกต่อการทำงาน Developer ใหม่ Jump in เข้าใจ Code ได้ง่าย

สำคัญมากที่จะเข้าใจว่า SOLID แก้ปัญหา Type ไหน และทำไมเราทำสิ่งนี้ การเข้าใจ Problem ช่วยให้คุณยอมรับ Design Principles และออกแบบ Software ได้ดีขึ้น