ตอนเริ่มเขียน Ruby professionally ปี 2011 สิ่งที่ประทับใจมากที่สุดคือ flexibility 感覺 Ruby ทำอะไรได้หมด เหนือภาษาแข็งตัวอย่าง C# และ Java Ruby programs ดูเหมือน มีชีวิต
คิดดูสิ: ทำอะไรได้บ้างใน Ruby define/delete methods ตามใจ เรียก methods ที่ไม่มีอยู่ สร้าง nameless classes จากอากาศ มัน wild มาก
แต่นี่แค่เริ่มต้น ใช้ techniques นี้ในโค้ดตัวเองได้ Ruby ยังให้ apply กับทุกอย่างที่โหลดใน VM กล่าวคือ mess with others' code ได้ง่ายเท่าโค้ดตัวเอง
Monkeypatches คืออะไร?
Monkeypatches "monkey with" existing code โค้ดที่มีอยู่มักเป็น code ที่คุณไม่มี direct access เช่น gems หรือ Ruby standard library Patches มัก designed เปลี่ยนพฤติกรรม original code: fix bug, improve performance ฯลฯ
Monkeypatches ไม่ซับซ้อน: reopen ruby classes modify behavior โดยเพิ่ม/override methods
Reopening idea นี้ core ของ Ruby object model ใน Java class define ได้ครั้งเดียว แต Ruby classes (และ modules) define หลายครั้งได้ define class ครั้งที่ 2, 3, 4... = reopening methods ใหม่เพิ่มเข้า existing class definition เรียกใช้บน instances ได้
ตัวอย่างสั้น illustrate reopening concept:
class Sounds def honk "Honk!" end end class Sounds def squeak "Squeak!" end end sounds = Sounds.new sounds.honk # => "Honk!" sounds.squeak # => "Squeak!" สังเกต: #honk และ #squeak ใช้ได้บน Sounds class ผ่าน reopening magic
โดยปกติ monkeypatching = reopening classes ใน 3rd-party code
Monkeypatching อันตรายไหม?
ถ้าประโยคก่อนขวัญคุณ นั่นดี มันอาจ cause real chaos ถ้าทำ carelessly
ลองคิด: ถ้าเรา redefine Array#<<:
class Array def <<(*args) # do nothing 😈 end end 4 บรรทัดนี้ ทุก array instance ในโปรแกรมพังทั้งหมด
เพิ่มเติม: original implementation ของ #<< หายไป นอกจาก restart Ruby process ไม่มีทางคืน
When Monkeypatching Goes Horribly Wrong
ปี 2011 ผมทำงานบริษัท social networking ใหญ่ Codebase = massive Rails monolith Ruby 1.8.7 ร้อยๆ engineers commit ทุกวัน pace เร็วมาก
ทีมตัดสินใจ monkeypatch String#% ทำ plurals 易しく สำหรับ internationalization ตัวอย่าง 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 เขียน patch deploy production... แล้วพบว่าไม่ทำงาน Users เห็น strings ด้วย literal %{...} แทน pluralized text ไม่ได้ความ Development บน laptop ทำงานดี ทำไม production ไม่ได้?
คิดว่า bug ใน Ruby แต่ production Rails console ให้ผลต่าง development ทั้งคู่ same Ruby version → rule out Ruby stdlib bug มีอะไรกำลังเกิด
หลายวันหา co-worker track down Rails initializer ที่เพิ่ม อีก implementation ของ String#% ที่ไม่เคยเห็น ยิ่งซับซ้อน: implementation ก่อนหน้านี้มี bug ผล production console ต่างจาก Ruby docs
ไม่จบแค่นั้น: หา earlier monkeypatch พบอีก สามตัว ทั้งหมด patch method เดียวกัน มองกันใน horror มันทำงานได้ยังไง??
สรุป: inconsistent behavior เพราะ Rails eager loading Development: lazy load (load เมื่อ require) Production: load ทุก Ruby files ตอน init → throw monkey wrench เข้า monkeypatching
Consequences ของ Reopening Class
ในกรณีนี้ แต่ละ monkeypatch reopen String class replace existing #% method ด้วยอีกตัว Pitfalls ใหญ่:
- Patch ตัวสุดท้าย "ชนะ" behavior ขึ้นกับ load order
- เข้าถึง original implementation ไม่ได้
- Patches ไม่ทิ้ง audit trail หายากมากตอนหา
ไม่แปลกใจ เราเจอทั้งหมด
เริ่มแรกไม่รู้ว่ามี monkeypatches อื่น เพราะ bug ใน winning method ดูเหมือน original implementation พัง พบ competing patches ไม่บอกตัวไหนชนะโดยไม่ใส่ puts เยอะ
สุดท้ายพบตัวไหนชนะใน development ตัวอื่นชนะใน production ยาก programmatically บอก patch ไหน applied last (Ruby 1.8 ไม่มี Method#source_location)
ใช้เวลาอย่างน้อย 1 สัปดาห์ debug ปัญหา avoidable ได้
ตัดสินใจ introduce LocalizedString wrapper class พร้อม #% method Monkeypatch String กลายเป็น:
class String def localize LocalizedString.new(self) end end When Monkeypatching Fails
จากประสบการณ์ monkeypatches ล้มเหลว 2 เหตุผล:
- Patch เองพัง ใน codebase กล่าวมา มี competing implementations หลายตัว และ method ที่ "ชนะ" ไม่ทำงาน
- Assumptions ไม่ valid Host code updated patch ไม่ apply ได้เหมือนเดิม
มาดู bullet 2 รายละเอียด
Even the Best-Laid Plans...
Monkeypatching ล้มเหลวเหมือนเหตุที่คุณถึงมัน — เพราะไม่มี access original code ด้วยเหตุนั้น original code เปลี่ยนได้ภายใต้คุณ
ตัวอย่างใน gem ที่ app depend:
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 ผิด! Sales tax ควร apply กับ full amount ไม่ใช่ discounted amount Submit PR รอ maintainer merge เพิ่ม monkeypatch ใน app:
class Sale private def sales_tax if @tax_rate @amount * @tax_rate else 0 end end end ทำงานสมบูรณ์ Check in ลืมมันไป
ทุกอย่างปกติ นาน แล้ว finance team email ทำไมบริษัทไม่เก็บ sales tax เดือนแล้ว
สับสน ขุด issue พบ co-worker updated gem ที่มี Sale class โค้ดใหม่:
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 rename @tax_rate → @sales_tax_rate Monkeypatch check old @tax_rate ซึ่งเป็น nil เสมอ ไม่มี error raise App ทำงานต่อเหมือนไม่มีอะไร
ทำไมต้อง Monkeypatch?
จากตัวอย่าง อาจคิดว่า monkeypatching ไม่คุ้มค่ากับความเสี่ยง ทำไมเราทำ? 3 use-cases หลัก:
- Fix broken/incomplete 3rd-party code
- Quick test changes ใน development
- Wrap existing functionality ด้วย instrumentation/annotation code
บางกรณี วิธีเดียว แก้ bug/performance ใน 3rd-party code คือ monkeypatch
But with great power comes great responsibility
Monkeypatching Responsibly
ผมชอบ frame monkeypatching conversation รอบ responsibility แทน good/bad มัน cause chaos ถ้าทำ poorly แต่ถ้าทำด้วย care และ diligence ไม่มีเหตุผล avoid เมื่อ situation warrants
กฎที่พยายามปฏิบัติ:
- Wrap patch ใน module ชื่อชัดเจน ใช้
Module#prependapply - Ensure patching the right thing
- Limit patch's surface area
- Give yourself escape hatches
- Over-communicate
ต่อไปใช้กฎเหล่านี้เขียน monkeypatch สำหรับ Rails DateTimeSelector ให้ optionally skip rendering discarded fields นี่คือ change ที่พยายามทำไปเมื่อปีก่อน รายละเอียดที่นี่
ไม่ต้องรู้ discarded fields มาก แค่รู้ว่า replace single method build_hidden ด้วย method ที่ effectively do nothing
เริ่มกัน!
ใช้ Module#prepend
ใน codebase ที่เจอ ทุก implementations ของ String#% applied โดย reopen String class Augmented drawbacks list:
- Errors ดูมาจาก host class/module ไม่ใช่ patch code
- Methods ที่ define ใน patch replace existing methods ชื่อเดียวกัน → ไม่มีทาง invoke original implementation
- ไม่รู้ว่า patches ไหน applied → ไม่รู้ method ไหน "ชนะ"
- Patches ไม่ทิ้ง audit trail หายากตอนหา
ดีกว่า: wrap patch ใน module ใช้ Module#prepend ทำให้เรียก original implementation ได้ และ Module#ancestors แสดง patch ใน inheritance hierarchy หาง่ายถ้าผิดพลาด
สุดท้าย prepend statement เรียบง่าย comment out ได้ถ้าต้องการ disable patch
ต้นแบบ module สำหรับ Rails monkeypatch:
module RenderDiscardedMonkeypatch end ActionView::Helpers::DateTimeSelector.prepend( RenderDiscardedMonkeypatch ) Patch the Right Thing
ถ้าเอาแค่หนึ่งอย่างจากบทความ ให้เป็นนี้: อย่า apply monkeypatch เว้นแต่รู้ว่าพัชสิ่งที่ถูกต้อง ในกรณีส่วนใหญ่ verify programmatically ได้ว่า assumptions ยังคง valid (Ruby ล่ะ) Checklist:
- Class/module ที่ patch มีอยู่จริง
- Methods มีอยู่และ arity ถูกต้อง
- ถ้า code ใน gem check gem version
- Bail out ด้วย helpful error message ถ้า assumptions ไม่ hold
Patch code assume constant ActionView::Helpers::DateTimeSelector มีอยู่และเป็น class/module
Check Class/Module
Ensure constant exists ก่อน patch:
module RenderDiscardedMonkeypatch end const = begin Kernel.const_get('ActionView::Helpers::DateTimeSelector') rescue NameError end if const const.prepend(RenderDiscardedMonkeypatch) end แต่วั่น local variable (const) เลอะ global scope แก้:
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 และ arity ถูกต้อง ถ้าไม่ hold → something's 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 ถ้า upgrade อาจต้อง update patch (หรือ 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 พบ discrepancy ระหว่าง expectations และ reality ควร raise error หรือ print helpful warning เป้าหมาย: alert คุณและ co-workers เมื่อผิดปกติ
Modify Rails patch:
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 ใน monkeypatch อาจดู innocuous แต่ methods ที่ define ผ่าน Module#prepend จะ override existing ones ผ่าน inheritance แม้ host class/module ดูไม่ define method นั้น ก็ยากรู้แน่ใจ ยัง apply กับ singleton class methods (ใน class << self)
พยายาม define แค่ methods ที่ intend to patch
Modify Rails patch ให้ replace แค่ #build_hidden method:
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
ทำ monkeypatch functionality opt-in ได้เมื่อ control invocation ของ patched code ในกรณี Rails patch ทำได้ผ่าน @options hash ใน 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 เรียก date_select helper ด้วย option ใหม่ opt-in ได้ ไม่ affect codepaths อื่น:
date_select(@user, :date_of_birth, { order: [:month, :day], render_discarded: false }) Over-Communicate
คำแนะนำสุดท้าย อาจสำคัญที่สุด — communicate ว่า patch ทำอะไร และเมื่อไหร่ควร re-examine เป้าหมาย: eventually remove patch altogether Responsible monkeypatch มี comments:
- Describe patch ทำอะไร
- Explain why necessary
- Outline assumptions
- Specify future date ทีมควร reconsider alternatives (pull updated gem)
- Links to PRs, blog posts, StackOverflow
อาจ print warning หรือ fail test ในวันที่กำหนด เพื่อ urge team reconfirm assumptions และ consider ว่ายังจำเป็นไหม
Final version ของ Rails date_select patch พร้อม comments และ 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. . 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) # make sure the class we want to patch exists; # make sure the #build_hidden method exists and accepts exactly # two arguments unless const && mtd && mtd.arity == 2 raise "Could not find class or method when patching "\ "ActionView's date_select helper. Please investigate." end # if rails has been upgraded, make sure this patch is still # necessary 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 # actually apply the patch const.prepend(InstanceMethods) end private def find_const Kernel.const_get('ActionView::Helpers::DateTimeSelector') rescue NameError # return nil if the constant doesn't exist end def find_method(const) return unless const const.instance_method(:build_hidden) rescue NameError # return nil if the method doesn't exist 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 สรุป
เข้าใจว่าบาง suggestions อาจดู overkill Rails patch มี defensive verification code มากกว่า actual patch code!
คิด verification code เหล่านี้เป็น scabbard สำหรับ broadsword ง่ายกว่าจะไม่ถูกตัดถ้าห่มด้วย protection layer
สำคัญคือ ผม confident deploy responsible monkeypatches ไป production Irresponsible ones = time bombs รอ cost time, money, developer health
P.S. อยากอ่าน Ruby Magic posts ทันทีเผยแพร่? สมัครรับ Ruby Magic newsletter!