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

Decoupling Ruby Code: Delegation vs Dependency Injection (การแยกความผูกพัน: Delegation เทียบ Dependency Injection)

ใน Object-Oriented Programming object หนึ่งมัก depend on อีก object หนึ่งเพื่อทำงาน

ตัวอย่าง สร้าง class เรียบง่ายสำหรับรัน finance reports:

class FinanceReport
  def net_income
    FinanceApi.gross_income - FinanceApi.total_costs
  end
end

เราบอกได้ว่า FinanceReport depends on FinanceApi ซึ่งใช้ดึงข้อมูลจาก external payment processor

แต่ถ้าอยากเปลี่ยน API ในอนาคต? หรือที่น่าจะเกิดขึ้นมากกว่า: อยาก test class นี้โดยไม่ hit external resources? คำตอบทั่วไปคือใช้ Dependency Injection

กับ Dependency Injection เราไม่ refer FinanceApi โดยตรงใน FinanceReport แทนเป็นการส่งเป็น argument เรา inject มัน

ใช้ Dependency Injection class กลายเป็น:

class FinanceReport
  def net_income(financials)
    financials.gross_income - financials.total_costs
  end
end

ตอนนี้ class ไม่รู้จัก FinanceApi object เลย! ส่ง object ใดก็ได้ที่ implement gross_income และ total_costs

มีประโยชน์หลายอย่าง:

  • โค้ด less "coupled" กับ FinanceApi
  • บังคับให้ใช้ FinanceApi ผ่าน public interface
  • ส่ง mock หรือ stub object ใน tests ได้ ไม่ต้อง hit real API

นักพัฒนาส่วนใหญ่คิดว่า Dependency Injection ดีมาก (ผมด้วย!) แต่เช่นทุกเทคนิค มี trade-offs

โค้ด opaque ขึ้นเล็กน้อย ตอน refer FinanceApi โดยตรง ชัดเจนว่าค่ามาจากไหน ไม่ชัดเจนเท่าเดิมใน DI

ถ้าคอลล์ไปที่ self โค้ดจะ verbose ขึ้น แทนที่จะใช้ OO paradigm "ส่งข้อความให้ object ทำงาน" เรากลายเป็น functional paradigm "inputs → outputs"

กรณีสุดท้ายนี้ (redirect คอลล์ที่จะไป self) คือสิ่งที่ผมอยากดูวันนี้ ผมจะเสนอ alternative สำหรับสถานการณ์เหล่านี้: เปลี่ยน base class แบบ dynamic (แบบประมาณ)

ปัญหาที่ต้องแก้

ถอยหลังมาดูปัญหาที่นำผมมาทางนี้: PDF reports

ลูกค้าขอ generate PDF reports แบบพิมพ์ได้หลายรายงาน — รายงานรายจ่ายทั้งหมด รายได้ กำไรคาดการณ์ปีหน้า ฯลฯ

เราใช้ prawn gem สร้าง PDFs แต่ละรายงานเป็น Ruby object subclassed จาก Prawn::Document

ประมาณนี้:

class CostReport < Prawn::Document
  def initialize(...)
    ...
  end

  def render
    text "Cost Report"
    move_down 20
    ...
  end
end

ดีจนถึงที่นี่ แต่: ลูกค้าอยากได้ "Overview" report ที่ รวมส่วนจากรายงานอื่นๆ ทั้งหมด

Solution 1: Dependency Injection

เหมือนที่กล่าว วิธีแก้ปัญหาแบบนี้คือ refactor ใช้ Dependency Injection นั่นคือ แทนที่จะให้ reports เรียก methods บน self เราส่ง PDF document เป็น argument

จะได้ประมาณนี้:

class CostReport < Prawn::Document
  ...
  def title(pdf = self)
    pdf.text "Cost Report"
    pdf.move_down 20
    ...
  end
end

ทำงานได้ แต่มี overhead ทุก drawing method ต้องรับ pdf argument ทุกคอลล์ prawn ต้องผ่าน pdf argument

DI มีประโยชน์: ผลักดันให้ decoupled components และส่ง mocks/stubs ทำ unit testing ง่ายขึ้น

แต่ในสถานการณ์เรา ไม่ได้รับประโยชน์เหล่านี้ เรา strongly coupled กับ prawn API แล้ว เปลี่ยน PDF library จะต้อง rewrite ทั้งหมด

Testing ไม่ใช่กังวลใหญ่ เพราะ test generated PDF reports ด้วย automated tests ยุ่งยากเกินไป

DI ให้ behavior ที่ต้องการ แต่เพิ่ม overhead ด้วยประโยชน์น้อย มาดูอีก option

Solution 2: Delegation (การมอบหมาย)

Ruby standard library มี SimpleDelegator วง่ายสำหรับ decorator pattern ส่ง object เข้า constructor แล้ว method calls ไปยัง delegator จะ forward ไปยัง object ของคุณ

ใช้ SimpleDelegator สร้าง base report class wrap prawn:

class PrawnWrapper < SimpleDelegator
  def initialize(document: nil)
    document ||= Prawn::Document.new(...)
    super(document)
  end
end

update reports ให้ inherit จาก class นี้ จะทำงานเหมือนเดิม ใช้ default document ที่สร้างใน initializer ความมหัศจรรย์เกิดขึ้นตอนใช้ใน overview report:

class OverviewReport < PrawnWrapper
  ...
  def render
    sales = SaleReport.new(..., document: self)
    sales.sales_table
    costs = CostReport.new(..., document: self)
    costs.costs_pie_chart
    ...
  end
end

SaleReport#sales_table และ CostReport#costs_pie_chart คงเดิม แต่คอลล์ไป prawn (เช่น text(...), move_down 20) ตอนนี้ forward ไป OverviewReport ผ่าน SimpleDelegator ที่สร้าง

จากมุมมอง behavior เราทำให้เหมือน SalesReport เป็น subclass ของ OverviewReport ในกรณีนี้ คอลล์ไป prawn API ไป SalesReport → OverviewReport → Prawn::Document

SimpleDelegator ทำงานอย่างไร

SimpleDelegator ใช้ Ruby method_missing forward method calls ไป object อื่น

SimpleDelegator (หรือ subclass) รับ method call ถ้ามี method นั้น รันปกติ แต่ถ้าไม่มี จะ hit method_missing ซึ่งจะพยายาม call method นั้นบน object ที่ส่งใน constructor

ตัวอย่างง่าย:

require 'simple_delegator'
class Thing
  def one; 'one'; end
  def two; 'two'; end
end

class ThingDecorator < SimpleDelegator
  def two; 'three!'; end
end

ThingDecorator.new(Thing.new).one #=> "one"
ThingDecorator.new(Thing.new).two #=> "three!"

subclass SimpleDelegator ด้วย ThingDecorator เรา override methods บางส่วน ให้ที่เหลือ fallback ไป Thing object

ตัวอย่างนี้ไม่ทำให้ SimpleDelegator โดดเด่น คุณอาจบอกว่า "subclass Thing ให้ผลลัพธ์เดียวกันไหม?"

ใช่ ให้ผลเดียวกัน แต่ความแตกต่างสำคัญ: SimpleDelegator รับ object ที่จะ delegate เป็น argument ใน constructor หมายความว่า เราส่ง object ต่างๆ ได้ใน runtime

นี่คือสิ่งที่ทำให้ redirect คอลล์ไป prawn object ใน Solution 2 ได้ ถ้าเรียก single report คอลล์ prawn ไป document ใหม่ใน constructor แต่ overview report เปลี่ยนให้ forward ไป document ของ มันเอง

สรุป

Dependency Injection น่าจะเป็น best solution สำหรับ most decoupling problems most of the time

แต่มี trade-offs ในกรณีผม overhead จาก DI ไม่คุ้มค่ากับประโยชน์ จึงหา solution อื่น

ใน Ruby มีทางอื่นเสมอ ผมไม่ค่อยใช้ solution นี้บ่อย แต่เป็นการเพิ่ม tool ที่ดีใน Ruby toolbelt สำหรับสถานการณ์แบบนี้