ใน 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 สำหรับสถานการณ์แบบนี้