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

Làm Chủ Monkeypatching An Toàn Trong Ruby: Best Practices Cho Code Maintainable

Khi mới viết Ruby professionally 2011, điều ấn tượng nhất là flexibility. Cảm giác mọi thứ possible với Ruby. So với rigidity của C#/Java, Ruby programs gần như sống.

Có thể: define/delete methods tại will, call methods không tồn tại, conjure nameless classes từ thin air. Absolutely wild.

Nhưng không dừng ở đó. Apply techniques vào own code, Ruby cũng cho apply vào bất cứ gì loaded vào VM. Tức là mess với code người khác dễ như code mình.

Monkeypatches Là Gì?

Monkeypatches "monkey with" existing code. Thường là code không direct access: gems, Ruby stdlib. Patches designed alter behavior: fix bug, improve performance, etc.

Monkeypatches unsophisticated nhất: reopen ruby classes, modify behavior bằng add/override methods.

Reopening idea core đến Ruby object model. Java: classes defined once. Ruby: classes/modules defined multiple times. Define class lần 2, 3, 4... = reopening. Methods mới added vào existing class definition, callable trên instances.

Ví dụ class reopening:

class Sounds
  def honk
    "Honk!"
  end
end

class Sounds
  def squeak
    "Squeak!"
  end
end

sounds = Sounds.new
sounds.honk   # => "Honk!"
sounds.squeak # => "Squeak!"

Cả #honk và #squeak available trên Sounds nhờ reopening.

Essentially, monkeypatching = reopening classes trong 3rd-party code.

Monkeypatching Có Nguy Hiểm?

Nếu câu trước scare you, đó là good thing. Monkeypatching, especially carelessly, gây chaos thực sự.

Tưởng tượng redefine Array#<<:

class Array
  def <<(*args)
    # do nothing 😈
  end
end

4 lines code → mọi array instance toàn chương trình bị broken. Original implementation mất. Không cách nào recover trừ restart Ruby process.

Khi Monkeypatching Goes Horribly Wrong

2011, làm prominent social networking company. Codebase: massive Rails monolith Ruby 1.8.7. Several hundred engineers contribute daily, pace rất fast.

Team quyết định monkeypatch String#% để viết plurals dễ hơn cho i18n. Ví dụ patch:

replacements = { horse_count: 3, horses: { one: "is 1 horse", other: "are %{horse_count} horses" } }
# "there are 3 horses in the barn"
"there %{horse_count:horses} in the barn" % replacements

Deploy production... không work. Users thấy literal %{...} thay vì pluralized text. Patch work perfect dev env trên laptop. Tại sao production fail?

Ban đầu nghĩ bug Ruby, sau đó thấy production Rails console khác development console. Cùng Ruby version → rule out bug stdlib. Something else.

Vài ngày head-scratching, co-worker track down Rails initializer thêm another implementation String#% chưa ai thấy. Implementation này cũng có bug → production console khác Ruby docs.

Không dừng đó. Track down earlier monkeypatch → tìm thấy không kém hơn 3 monkeypatches khác, tất cả patch cùng method. Look at each other in horror. How did this ever work??

Chalk up inconsistent behavior đến Rails eager loading. Dev: lazy load (chỉ load khi require). Production: load all Ruby files at init. Throw big monkey wrench vào monkeypatching.

Consequences Của Reopening Class

Mỗi monkeypatch reopen String class, effectively replace existing #% method. Major pitfalls:

  • Patch cuối cùng applied "wins" → behavior dependent on load order
  • Không cách nào access original implementation
  • Patches để lại almost no audit trail → rất khó find later

Không surprising, ran into all.

Ban đầu không biết có monkeypatches khác. Vì bug winning method, appeared original implementation broken. Khi discover competing patches, impossible tell which won không thêm copious puts.

Cuối cùng, even khi discover method won dev, production khác. Programmatically difficult tell patch applied last (Ruby 1.8 không có Method#source_location wonderful).

Spent ít nhất 1 week figure out, time wasted chasing avoidable problem.

Cuối cùng introduce LocalizedString wrapper class với #% method. String monkeypatch trở thành:

class String
  def localize
    LocalizedString.new(self)
  end
end

Khi Monkeypatching Fails

Experience: monkeypatches thường fail 2 reasons:

  • Patch itself broken. Codebase trên: several competing implementations, winning method không work.
  • Assumptions invalid. Host code updated, patch không còn apply as written.

Chi tiết bullet 2:

Even The Best-Laid Plans...

Monkeypatching fail vì cùng lý do bạn reach for it: không access original code. Chính vì thế original code có thể change out from under you.

Ví dụ gem app depend on:

class Sale
  def initialize(amount, discount_pct, tax_rate = nil)
    @amount = amount; @discount_pct = discount_pct; @tax_rate = tax_rate
  end
  def total
    discounted_amount + sales_tax
  end
  private
  def discounted_amount
    @amount * (1 - @discount_pct)
  end
  def sales_tax
    if @tax_rate
      discounted_amount * @tax_rate
    else
      0
    end
  end
end

Wait, sales tax nên apply full amount, không phải discounted. Submit PR gem. Waiting maintainer merge, add monkeypatch app:

class Sale
  private
  def sales_tax
    if @tax_rate
      @amount * @tax_rate
    else
      0
    end
  end
end

Work perfect. Check in và forget.

Fine lâu. Then finance team email: tại sao company không collect sales tax 1 tháng?

Confused, dig issue → notice co-worker recently updated gem chứa Sale. Updated code:

class Sale
  def initialize(amount, discount_pct, sales_tax_rate = nil)
    @amount = amount; @discount_pct = discount_pct; @sales_tax_rate = sales_tax_rate
  end
  def total
    discounted_amount + sales_tax
  end
  private
  def discounted_amount
    @amount * (1 - @discount_pct)
  end
  def sales_tax
    if @sales_tax_rate
      discounted_amount * @sales_tax_rate
    else
      0
    end
  end
end

Maintainer renamed @tax_rate → @sales_tax_rate. Monkeypatch check old @tax_rate (luôn nil). Không ai notice vì không errors raised. App chug along như nothing happened.

Tại Sao Monkeypatch?

Cho thấy monkeypatching không worth potential headaches. Tại sao vẫn làm? 3 major use-cases:

  • Fix broken/incomplete 3rd-party code
  • Quick test change(s) trong development
  • Wrap existing functionality với instrumentation/annotation code

Một số cases, chỉ viable way address bug/performance issue 3rd-party code là monkeypatch.

But with great power comes great responsibility.

Monkeypatching Responsibly

Frame conversation around responsibility thay vì good/bad. Monkeypatching gây chaos khi poorly done. Nhưng done with care/diligence, no reason avoid khi situation warrants.

Rules tôi follow:

  1. Wrap patch trong module obvious name, dùng Module#prepend apply
  2. Đảm bảo patch đúng thứ
  3. Limit patch surface area
  4. Cho escape hatches
  5. Over-communicate

Dùng rules viết monkeypatch cho Rails DateTimeSelector optionally skip rendering discarded fields. Change tôi tried make Rails vài năm trước. Details here.

Không cần biết discarded fields. Cuối ngày, replace single method build_hidden với method effectively do nothing.

Dùng Module#prepend

Codebase trước: tất cả implementations String#% applied bằng reopen String class. Augmented drawbacks:

  • Errors appear originated từ host class/module thay vì patch code
  • Methods defined trong patch replace existing methods same name → no way invoke original
  • Không biết patches nào applied → methods nào "won"
  • Almost no audit trail → khó find later

Better: wrap patch in module, apply với Module#prepend. Free call original implementation, Module#ancestors show patch trong inheritance hierarchy → easier find nếu wrong. Simple prepend statement dễ comment out disable patch.

Beginnings module Rails monkeypatch:

module RenderDiscardedMonkeypatch
end
ActionView::Helpers::DateTimeSelector.prepend(RenderDiscardedMonkeypatch)

Patch The Right Thing

Nếu take one thing: đừng apply monkeypatch trừ khi biết patch đúng code. Thường verify programmatically assumptions still hold (this is Ruby). Checklist:

  1. Class/module patch exists
  2. Methods exist và right arity
  3. Code patch trong gem → check gem version
  4. Bail out helpful error message nếu assumptions không hold

Patch code assume constant ActionView::Helpers::DateTimeSelector exists và là class/module.

Check Class/Module

Ensure constant exists trước patch:

module RenderDiscardedMonkeypatch
  def self.apply_patch
    const = begin
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    if const
      const.prepend(RenderDiscardedMonkeypatch)
    end
  end
end
RenderDiscardedMonkeypatch.apply_patch

Leaked local variable const global scope. Fix:

module RenderDiscardedMonkeypatch
  def self.apply_patch
    const = begin
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    if const
      const.prepend(self)
    end
  end
end
RenderDiscardedMonkeypatch.apply_patch

Check Methods

Introduce patched build_hidden. Check exists và right arity (2 args). Nếu không hold → something wrong:

module RenderDiscardedMonkeypatch
  class << self
    def apply_patch
      const = find_const
      mtd = find_method(const)
      if const && mtd && mtd.arity == 2
        const.prepend(self)
      end
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
  end
  def build_hidden(type, value)
    ''
  end
end
RenderDiscardedMonkeypatch.apply_patch

Check Gem Versions

Check Rails version đúng. Nếu Rails upgraded, patch cần update (hoặc remove):

module RenderDiscardedMonkeypatch
  class << self
    def apply_patch
      const = find_const
      mtd = find_method(const)
      if const && mtd && mtd.arity == 2 && rails_version_ok?
        const.prepend(self)
      end
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
    def rails_version_ok?
      Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
    end
  end
  def build_hidden(type, value)
    ''
  end
end
RenderDiscardedMonkeypatch.apply_patch

Bail Out Helpfully

Verification code uncover discrepancy → raise error hoặc print helpful warning. Alert team khi something amiss:

module RenderDiscardedMonkeypatch
  class << self
    def apply_patch
      const = find_const
      mtd = find_method(const)
      unless const && mtd && mtd.arity == 2
        raise "Could not find class or method when patching ActionView's date_select helper. Please investigate."
      end
      unless rails_version_ok?
        puts "WARNING: It looks like Rails has been upgraded since ActionView's date_select helper was monkeypatched in #{__FILE__}. Please reevaluate the patch."
      end
      const.prepend(self)
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
    def rails_version_ok?
      Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
    end
  end
  def build_hidden(type, value)
    ''
  end
end
RenderDiscardedMonkeypatch.apply_patch

Limit Surface Area

Define helper methods trong monkeypatch có vẻ innocuous, nhưng methods defined via Module#prepend override existing ones qua inheritance. Khó know for sure host class/module không define method đó. Chỉ define methods intend to patch.

Cũng apply methods trong singleton class (class << self).

Modify Rails patch chỉ replace 1 method #build_hidden:

module RenderDiscardedMonkeypatch
  class << self
    def apply_patch
      const = find_const
      mtd = find_method(const)
      unless const && mtd && mtd.arity == 2
        raise "Could not find class or method when patching ActionView's date_select helper. Please investigate."
      end
      unless rails_version_ok?
        puts "WARNING: It looks like Rails has been upgraded since ActionView's date_selet helper was monkeypatched in #{__FILE__}. Please reevaluate the patch."
      end
      const.prepend(InstanceMethods)
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
    def rails_version_ok?
      Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
    end
  end
  module InstanceMethods
    def build_hidden(type, value)
      ''
    end
  end
end
RenderDiscardedMonkeypatch.apply_patch

Give Yourself Escape Hatches

Khi possible, make monkeypatch functionality opt-in. Chỉ option nếu control where patched code invoked. Rails patch doable qua @options hash trong DateTimeSelector:

module RenderDiscardedMonkeypatch
  class << self
    def apply_patch
      const = find_const
      mtd = find_method(const)
      unless const && mtd && mtd.arity == 2
        raise "Could not find class or method when patching ActionView's date_select helper. Please investigate."
      end
      unless rails_version_ok?
        puts "WARNING: It looks like Rails has been upgraded since ActionView's date_selet helper was monkeypatched in #{__FILE__}. Please reevaluate the patch."
      end
      const.prepend(InstanceMethods)
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
    def rails_version_ok?
      Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
    end
  end
  module InstanceMethods
    def build_hidden(type, value)
      if @options.fetch(:render_discarded, true)
        super
      else
        ''
      end
    end
  end
end
RenderDiscardedMonkeypatch.apply_patch

Nice! Callers opt-in bằng date_select helper với option mới. No other codepaths affected:

date_select(@user, :date_of_birth, { order: [:month, :day], render_discarded: false })

Over-Communicate

Last advice, perhaps most important: communicate patch does gì và khi nào re-examine. Goal: eventually remove patch altogether. Responsible monkeypatch include comments:

  • Describe patch does gì
  • Explain why necessary
  • Outline assumptions
  • Specify future date team should reconsider alternatives (pull updated gem)
  • Links relevant PRs, blog posts, StackOverflow

Có thể print warning hoặc fail test predetermined date urge team reconfirm assumptions.

Final version Rails date_select patch với comments và date check:

# ActionView's date_select helper provides the option to "discard" certain
# fields. Discarded fields are (confusingly) still rendered to the page
# using hidden inputs, i.e. <input type="hidden" />. This patch adds an
# additional option to the date_select helper that allows the caller to
# skip rendering the chosen fields altogether. For example, to render all
# but the year field, you might have this in one of your views:
#
# date_select(:date_of_birth, order: [:month, :day])
#
# or, equivalently:
#
# date_select(:date_of_birth, discard_year: true)
#
# To avoid rendering the year field altogether, set :render_discarded to
# false:
#
# date_select(:date_of_birth, discard_year: true, render_discarded: false)
#
# This patch assumes the #build_hidden method exists on
# ActionView::Helpers::DateTimeSelector and accepts two arguments.
#
module RenderDiscardedMonkeypatch
  class << self
    EXPIRATION_DATE = Date.new(2021, 8, 15)
    def apply_patch
      if Date.today > EXPIRATION_DATE
        puts "WARNING: Please re-evaluate whether or not the ActionView date_select patch present in #{__FILE__} is still necessary."
      end
      const = find_const
      mtd = find_method(const)
      unless const && mtd && mtd.arity == 2
        raise "Could not find class or method when patching ActionView's date_select helper. Please investigate."
      end
      unless rails_version_ok?
        puts "WARNING: It looks like Rails has been upgraded since ActionView's date_select helper was monkeypatched in #{__FILE__}. Please re-evaluate the patch."
      end
      const.prepend(InstanceMethods)
    end
    private
    def find_const
      Kernel.const_get('ActionView::Helpers::DateTimeSelector')
    rescue NameError
    end
    def find_method(const)
      return unless const
      const.instance_method(:build_hidden)
    rescue NameError
    end
    def rails_version_ok?
      Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
    end
  end
  module InstanceMethods
    # :render_discarded is an additional option you can pass to the
    # date_select helper in your views. Use it to avoid rendering
    # "discarded" fields, i.e. fields marked as discarded or simply
    # not included in date_select's :order array. For example,
    # specifying order: [:day, :month] will cause the helper to
    # "discard" the :year field. Discarding a field renders it as a
    # hidden input. Set :render_discarded to false to avoid rendering
    # it altogether.
    def build_hidden(type, value)
      if @options.fetch(:render_discarded, true)
        super
      else
        ''
      end
    end
  end
end
RenderDiscardedMonkeypatch.apply_patch

Kết Luận

Hiểu một số suggestions trên có vẻ overkill. Rails patch chứa defensive verification code nhiều hơn actual patch code!

Nhưng think of extra code as sheath cho broadsword. Dễ avoid getting cut nếu enveloped in protection layer.

Quan trọng: tôi confident deploy responsible monkeypatches production. Irresponsible ones = time bombs cost time, money, developer health.

P.S. Đăng ký Ruby Magic newsletter để đọc sớm nhất!