Ruby 使用 Monkey Patch 的建議

written | in ruby | comments

何謂 Monkey Patch (猴子補丁)

網路上的介紹已經很多且詳盡,故不在此詳細探討,若還不熟的朋友建議可以看 游擊補強或猴子補丁? 一文;但簡單的意思就是:

指執行時期動態改變程式碼

更白話一點就是 :

不修改既有原始碼情況下,動態修改程式運作行為的能力

Ruby 與 Monkey Patch

而 Ruby 作為動態語言以及擁有非常強大的 meta programming 能力,因此在使用 Monkey Patch 更是異常地簡單與常見。

動態地打開 class 或 module

由於這是 Ruby 的特性之一,因此我們可以直接打開一支已存在的 class / module 去增加或修改方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Array
   def to_s
     "Hi, I'm an array with #{inspect}'"
   end

   def push_foo
   concat %w(foo)
   end
end

['Apple'].to_s
=> "Hi, I'm an array with [\"Apple\"]'"
['Apple'].push_foo
=> ["Apple", "foo"]

這樣我們就幫所有的 Array 實體增加了一個 push_foo 以及修改了 to_s 方法。而也因為這樣的動態能力,賦予我們幾乎可在任何時候去拓展或修改我們想要的類別、物件行為,例如去更改一個 gem 或是 lib 的行為以達到我們的客製化需求。

動態修改的隱含風險

但根據 開閉原則 ,我們應該盡量避免 直接地 修改某個類別或模組,以免影響其他也使用該類別的物件,又或著其他人也同時去做了 Monkey Patch 進而相互影響等等,因此 Monkey Patch 也有很高的風險,諸如:

  1. 自身做了 Monkey Patch 但其目的可能與原來有所差異造成其他人因此得到非預期的效果
  2. 他人如果也同時對相同的檔案做了 Monkey Patch,那有一方的結果會難以預測
  3. 如果直接對某 lib/gem 做了 覆寫掉 的 Monkey Patch ,那接來如果該 lib/ gem 版本升級,此處將也無法得知。

Ruby 動態能力的強大,容易造成 Monkey Patch 的濫用,而這種濫用的隱含風險都是容易造成問題 (Bug) 的來源,往往又難以察覺。

但我們也並非因此就要放棄使用 Monkey Patch ,進一步地,我們應該找出有效易於組織的使用方式。

良好的 Monkey Patch 使用方式

如前所述我們得知 Monkey Patch 的風險,所以當我們使用這一技巧時,也要思考如何規避或將那些風險降至最小,而其中的要點就是有組織的管理,更白話一點,就是:

1. 易於使用、易於拆除

既然方便使用是 Monkey Patch 的一大吸引力,那麼一個易於拆卸下來的 Monkey Patch 更讓這個技巧趨於完善,畢竟即使是 Monkey Patch 也總有無法兼顧他人或是未來的時候。

而此時 Ruby 的另外一項利器便在此發揮作用,那就是 Module ,我們讓共有的方法或行為存在於一個特地(抽象化)的 module ,此時我們管理、追蹤的對象就從 被打開的 class 或 module 抽離出獨立的對象,新的module,因此更加容易管理,也更為安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module ActsAsJediArray
  def to_s
     "I\'m a Jedi array #{object_id}"
  end

   def push_foo
   concat %w(foo)
   end
end

Array.prepend ActsAsJediArray
[].push_foo
=> ['foo']
[].to_s
=> "I'm a Jedi array 70169792955940"
[].method(:to_s).owner
=> ActsAsJediArray

有時候僅針對某定的對象做 Monkey Patch 可能是更好的選擇,因為這樣並不會影響到該類別的其他對象,因此我們可以這樣做:

1
2
3
4
5
jedi_array = [].extend ActsAsJediArray
jedi_array.push_foo
=> ['foo']
jedi_array.to_s
=> "I'm a Jedi array 70169792955940"

如果想要動態建立 module 或是怕想 module 名字很麻煩還可以這樣:

1
2
3
4
5
6
7
8
9
Module.new do
  def to_s
    "I\'m a Jedi array #{object_id}"
  end

  def push_foo
    concat %w(foo)
  end
end.tap { |mod| some_array.extend mod }

如果不想使用這個 Monkey Patch 了,僅需把 extendinclude 的地方註解起來即可。

2. 集中管理、顯而易見

如果散亂在各地地使用 Monkey Patch ,那麼即使一時間意識到了問題題的存在,也難以找出問題的根源,所以約定的位置、使用一致的方式套用、並且有著一定的命名規則,那將能讓整個專案或團隊容易達成共識與默契地使用 Monkey Patch。

首先建議可以先增加一個目錄,把我們的 module 都擺到底下去,這樣我們可以更快、更直覺地找到關於 Monkey Patch 的 module 都定義在哪。

再來就是針對 module 設立通用的 namespace 像是 extensions 來提示目的,以及針對修改 class 或 module 明確的命名,通常可直接仿照其命名,因為搭配了 namespace 可以幫助我們與原生的做出區別以及一目瞭然存在的目的:

1
2
3
4
5
6
project:
  lib:
    extensions:
      array.rb
      string.rb
      some_class.rb

然後統一在一個位置裡使用

1
2
3
Grape.include Extensions::Grape
Rails.include Extensions::Rails
Array.prepend Extensions::Array

3. 兼顧未來、保持彈性

良好的程式設計原則告訴我們:『要易於拓展、難以修改』,那對 Monkey Patch 的使用更是如此,錯誤的使用 Monkey Patch 往往容易直接暴力地把方法或行為覆蓋掉,如果是自行開發的 lib 那可能影響還算小,但如果是來自於外部的 lib/gem ,並且做了版本、功能的更新,此時我們會將此忽略,那將會造成不良的影響。

由於我們使用 module 來管理 monkey patch ,以及受益於 Ruby 的繼承鏈關係,此時我們可以使用 super 來呼叫父類別或模組的行為,這樣即使父類別版本更新或方法修改了,我們也依然可以向前兼顧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  module JediArray
    def push(*)
      puts 'Use the force, Luke..'
      super
    end

    def some_other_method
      if meets_vader?
        run
      else
        super
      end
    end
  end

當試圖修改外部 lib/gem 時,請記得總是呼叫 super 以及確定參數的向前兼容。

結論

不單上述提到的方式,依然還有許多方法可以用來達成拓展功能、動態修改的目的,像是模組的精化 (refine)、方法別名、甚至是裝飾器等等,都是可行的替代方案。但更重要的是要思考可能的影響與變動,以及如何找出降低最多影響的可行方案以達到善用 Monkey Patch 而又不致於使之變成一把可怕的雙面刃。

參考:

3 Ways to Monkey-Patch Without Making a Mess


Comments

blog comments powered by Disqus