大多數使用過 Ruby 的工程師都知道 Ruby 有一個特別的語言特性叫做 Mixin(混合)可以透過定義一個 Module(模組)然後被其他類別引用,如果從 DCI(Data Context Interaction)的角度來看,其實是一種脈絡的表現。
Mixin 的運作
Ruby 的 Mixin 運作機制大致上來說不複雜,我們可以使用 include
、 extend
以及 perpend
這三個方法根據情境「插入模組」到繼承鏈之中,以比較常用的 include
為例子會像這樣。
1module Attackable
2 def attack(target)
3 # ...
4 end
5end
6
7class Actor
8 include Attackable
9end
10
11pp Actor.ancestors
12# => [Actor, Attackable, Object, Kernel, BasicObject]
簡單來說 Mixin 的運作是將原本搜尋方法的順序改變,從 Actor
到 Object
的順序中,加入了 Attackable
模組,因此就可以搶在 Object
之前找到 #attack
方法來使用,而 prepend
和 extend
則是跟 include
插入到不同的位置來達到不同的效果。
Ruby on Rails 的脈絡呈現
若是要舉例,用 Ruby on Rails 會容易些,因為在框架的設計上就有很深入的考慮到 DCI 的概念,如果還不清楚可以參考自然地在 Rails 中應用 Data Content Interaction 這篇文章的簡介。
大多數時候,我們的 Controller 就是作為「描述脈絡」的角色,因此通常會看到如下的實現。
1class AttackController < ApplicationController
2 before_action :find_player
3 before_action :find_activate_battle
4
5 def create
6 @monster = @battle.monsters.find(params[:monster_id])
7 @battle.attack(
8 from: @player,
9 target: @monster
10 )
11 @battle.save!
12
13 render json: @battle.events
14 end
15
16 # ...
17end
如果我們轉換成 Cucumber 的描述,就可以變成類似這樣的敘述
1#language: zh-TW
2功能: 戰鬥系統
3 # AttackController
4 場景: 玩家對指定怪物發起攻擊
5 # before_action :find_player
6 假定 這裡有一個玩家 "蒼時"
7 # before_action :find_activate_battle
8 而且 這裡有一個進行中的戰鬥
9 | name | monster_id | monster_type |
10 | Slime-1 | 1 | Slime |
11 # @battle.attack(...)
12 當 玩家對名為 "Slime-1" 的怪物攻擊
13 # render json: @battle.events
14 那麼 將會看到 "Attack Slime-1 success" 的結果
由此可見,像是 before_action
這些 DSL(Domain Specific Language,領域特訂語言)能夠用於「描述脈絡」而使用的。
如果想要擴充這些行為,在 Rails 可以使用基於 Ruby Mixin 所設計的 Concern 機制來增加可以用的「描述方式」
Mixin 的使用案例
要利用 Mixin 來增加脈絡的資訊,就用我最近針對 Feature Flag(特性切換)的實作來作為例子。
我使用的是 Flipper 這個套件,他能讓我使用類似這樣的方式保護某個尚未釋出的功能。
1def index
2 raise UnreleasedError unless Flipper.enabled?(:preview, current_user)
3
4 # ...
5end
假設不是指定的使用者,就無法使用「預覽版(Preview)」的功能,然而要在每個地方都重複寫 Flipper.enabled?
其實是有點麻煩的,因此大多數人都會封裝成一個方法。
但是,如果考慮到「脈絡」跟「DSL」的特性,我們會實現像這樣的 Concern 模組。
1module Previewable
2 class UnreleasedError < RuntimeError; end
3
4 extend ActiveSupport::Concern
5
6 included do
7 helper_method :preview?
8 end
9
10 class_methods do
11 def unreleased(**options)
12 before_action -> { raise UnreleasedError unless preview? }, **options
13 end
14 end
15
16 def preview?
17 Flipper.enabled?(:preview, current_user)
18 end
19end
在這裡我們利用 extend
讓這個模組可以使用 ActiveSupport::Concern
的機制,這點大家應該不陌生,這其實就已經是一種脈絡的表現用來表明「這是一種 Concern」而我們會自然地使用 included
和 class_methods
這兩個 DSL 來描述。
首先,我們先將 Flipper.enabled?
封裝成 preview?
來加強語意,接下來用 included
透過 helper_method
聲明「可以被 View 使用」同時在 class_methods
中定義了新的 DSL 叫做 unreleased
(未釋出),他會在這個 Controller 開始前檢查「是否可以使用這個動作」
放到 Controller 裏面,就會發現「可讀性被提高」
1class AutoAttackController < ApplicationController
2 # 表示「包含預覽」
3 include Previewable
4 # 描述未釋出的部分,除了 index 動作外
5 unreleased except: %i[index]
6
7 def index
8 # 如果是預覽模式,用不同畫面呈現
9 return render :index_v2 if preview?
10
11 # ...
12 end
13
14 # ...
15end
基於 Mixin 的方式,我們會發現對於整個 Controller 的行為描述變的非常清晰,我們可以透過 DSL 的擴充對整個情境補充,或者方法來提供這個情境可以「做特定行為」
也許我們該反思,許多時候我們利用 Rails 的 Callback 機制(
before_action
)是否是在描述「前提」還是單純的想把過長的重複程式碼分離出去