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

Decoupling Ruby Code: Delegation vs Dependency Injection

Trong Object-Oriented Programming, một object thường depend on object khác để function.

Ví dụ, tạo class đơn giản run finance reports:

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

Ta nói FinanceReport depends on FinanceApi, dùng để pull info từ external payment processor.

Nhưng nếu muốn hit different API? Hoặc test class không hit external resources? Câu trả lời phổ biến: Dependency Injection.

Với Dependency Injection, không explicitly refer FinanceApi trong FinanceReport. Thay vào đó, pass as argument. Inject nó.

Dùng Dependency Injection, class trở thành:

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

Bây giờ class không biết FinanceApi object tồn tại! Pass any object miễn implement gross_income và total_costs.

Benefits:

  • Code ít "coupled" với FinanceApi.
  • Buộc dùng FinanceApi via public interface.
  • Pass mock/stub object trong tests để không hit real API.

Nhiều developers coi Dependency Injection là good thing (tôi cũng vậy!). Tuy nhiên, như mọi techniques, có trade-offs.

Code hơi opaque hơn. Khi explicitly dùng FinanceApi, rõ ràng values từ đâu. Không rõ bằng trong code dùng DI.

Nếu calls khác đi self, code verbose hơn. Thay vì OO "send message to object let it act", chuyển sang functional "inputs → outputs".

Case cuối (redirect calls would go to self) là focus hôm nay. Present alternative cho DI: thay đổi base class dynamically (kinda).

Problem To Solve

Back up một chút, bắt đầu từ problem led me down this path: PDF reports.

Client request generate various printable PDF reports — expenses, revenue, forecasted profits, v.v.

Dùng prawn gem, mỗi report là Ruby object subclassed từ Prawn::Document.

class CostReport < Prawn::Document
  def initialize(...)
    ...
  end
  def render
    text "Cost Report"
    move_down 20
    ...
  end
end

Good so far. Nhưng client muốn "Overview" report include portions từ all other reports.

Solution 1: Dependency Injection

Như đề cập, common solution: refactor dùng Dependency Injection. Thay vì reports call methods trên self, pass PDF document as argument.

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

Works, nhưng overhead. Mọi drawing method phải take pdf argument, mọi call prawn phải qua pdf argument.

DI có benefits: push toward decoupled components, allow mocks/stubs cho unit testing easier.

Nhưng scenario này không reap rewards. Chúng ta strongly coupled với prawn API, đổi PDF library khác almost certainly require rewrite.

Testing cũng không big concern, vì test generated PDF reports với automated tests quá cumbersome.

Vậy DI cho behavior muốn nhưng introduce overhead với minimal benefits. Xem option khác.

Solution 2: Delegation

Ruby stdlib cung cấp SimpleDelegator dễ implement decorator pattern. Pass object vào constructor, method calls forwarded to object.

Dùng SimpleDelegator, tạo base report class wrap prawn:

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

Update reports inherit từ class này, vẫn function như trước, dùng default document tạo trong initializer. Magic xảy ra ở 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

Ở đây SaleReport#sales_table và CostReport#costs_pie_chart unchanged, nhưng calls to prawn (ví dụ text(...), move_down 20) now forwarded to OverviewReport via SimpleDelegator.

Behavior-wise, essentially làm SalesReport như subclass của OverviewReport. Calls to prawn API now go SalesReport → OverviewReport → Prawn::Document.

How SimpleDelegator Works

SimpleDelegator dùng Ruby method_missing forward method calls to another object.

SimpleDelegator (hoặc subclass) receive method call. Nếu implement method, execute như object bình thường. However, nếu không define method, hit method_missing. method_missing attempt call method trên object given to constructor.

Simple example:

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 với ThingDecorator, overwrite some methods, let others fall through to default Thing object.

Trivial example không làm công bằng SimpleDelegator. Có thể nói "Subclass Thing cho same outcome?"

Có, đúng. Nhưng key difference: SimpleDelegator takes object delegate to as argument in constructor. Nghĩa là có thể pass different objects at runtime.

Điều này cho phép redirect calls đến prawn object trong Solution 2. Call single report, prawn calls go to new document tạo trong constructor. Overview report change để calls forwarded to its document.

Kết Luận

Dependency Injection probably best solution cho most decoupling problems most time.

Như mọi techniques, có trade-offs. Case tôi, overhead DI không worth benefits, nên tìm alternative.

Như mọi thứ Ruby, luôn có another way. Không reach cho solution này thường, nhưng nice addition to Ruby toolbelt cho situations này.