蒼時弦也
蒼時弦也
資深軟體工程師
發表於

用 Ruby 的 Mixin 加入脈絡增加可讀性

大多數使用過 Ruby 的工程師都知道 Ruby 有一個特別的語言特性叫做 Mixin(混合)可以透過定義一個 Module(模組)然後被其他類別引用,如果從 DCI(Data Context Interaction)的角度來看,其實是一種脈絡的表現。

Mixin 的運作

Ruby 的 Mixin 運作機制大致上來說不複雜,我們可以使用 includeextend 以及 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 的運作是將原本搜尋方法的順序改變,從 ActorObject 的順序中,加入了 Attackable 模組,因此就可以搶在 Object 之前找到 #attack 方法來使用,而 prependextend 則是跟 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」而我們會自然地使用 includedclass_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)是否是在描述「前提」還是單純的想把過長的重複程式碼分離出去