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


蒼時弦也
技術文章
用 Ruby 的 Mixin 加入脈絡增加可讀性

Image by ArthurHidden on Freepik

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

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

Mixin 的運作

Ruby 的 Mixin 運作機制大致上來說不複雜,我們可以使用 includeextend 以及 perpend 這三個方法根據情境「插入模組」到繼承鏈之中,以比較常用的 include 為例子會像這樣。

module Attackable
  def attack(target)
    # ...
  end
end

class Actor
  include Attackable
end

pp Actor.ancestors
# => [Actor, Attackable, Object, Kernel, BasicObject]

簡單來說 Mixin 的運作是將原本搜尋方法的順序改變,從 ActorObject 的順序中,加入了Attackable 模組,因此就可以搶在 Object 之前找到 #attack 方法來使用,而 prependextend 則是跟 include 插入到不同的位置來達到不同的效果。

Ruby on Rails 的脈絡呈現

若是要舉例,用 Ruby on Rails 會容易些,因為在框架的設計上就有很深入的考慮到 DCI 的概念,如果還不清楚可以參考自然地在 Rails 中應用 Data Content Interaction 這篇文章的簡介。

大多數時候,我們的 Controller 就是作為「描述脈絡」的角色,因此通常會看到如下的實現。

class AttackController < ApplicationController
  before_action :find_player
  before_action :find_activate_battle

  def create
    @monster = @battle.monsters.find(params[:monster_id])
    @battle.attack(
      from: @player,
      target: @monster
    )
    @battle.save!

    render json: @battle.events
  end

  # ...
end

如果我們轉換成 Cucumber 的描述,就可以變成類似這樣的敘述

#language: zh-TW
功能: 戰鬥系統
  # AttackController
  場景: 玩家對指定怪物發起攻擊
    # before_action :find_player
    假定 這裡有一個玩家 "蒼時"
    # before_action :find_activate_battle
    而且 這裡有一個進行中的戰鬥
      | name    | monster_id | monster_type |
      | Slime-1 | 1          | Slime        |
    # @battle.attack(...)
    當 玩家對名為 "Slime-1" 的怪物攻擊
    # render json: @battle.events
    那麼 將會看到 "Attack Slime-1 success" 的結果

由此可見,像是 before_action 這些 DSL(Domain Specific Language,領域特訂語言)能夠用於「描述脈絡」而使用的。

如果想要擴充這些行為,在 Rails 可以使用基於 Ruby Mixin 所設計的 Concern 機制來增加可以用的「描述方式」

Mixin 的使用案例

要利用 Mixin 來增加脈絡的資訊,就用我最近針對 Feature Flag(特性切換)的實作來作為例子。

我使用的是 Flipper 這個套件,他能讓我使用類似這樣的方式保護某個尚未釋出的功能。

def index
  raise UnreleasedError unless Flipper.enabled?(:preview, current_user)

  # ...
end

假設不是指定的使用者,就無法使用「預覽版(Preview)」的功能,然而要在每個地方都重複寫 Flipper.enabled? 其實是有點麻煩的,因此大多數人都會封裝成一個方法。

但是,如果考慮到「脈絡」跟「DSL」的特性,我們會實現像這樣的 Concern 模組。

module Previewable
  class UnreleasedError < RuntimeError; end

  extend ActiveSupport::Concern

  included do
    helper_method :preview?
  end

  class_methods do
    def unreleased(**options)
      before_action -> { raise UnreleasedError unless preview? }, **options
    end
  end

  def preview?
    Flipper.enabled?(:preview, current_user)
  end
end

在這裡我們利用 extend 讓這個模組可以使用 ActiveSupport::Concern 的機制,這點大家應該不陌生,這其實就已經是一種脈絡的表現用來表明「這是一種 Concern」而我們會自然地使用 includedclass_methods 這兩個 DSL 來描述。

首先,我們先將 Flipper.enabled? 封裝成 preview? 來加強語意,接下來用 included 透過 helper_method 聲明「可以被 View 使用」同時在 class_methods 中定義了新的 DSL 叫做 unreleased(未釋出),他會在這個 Controller 開始前檢查「是否可以使用這個動作」

放到 Controller 裏面,就會發現「可讀性被提高」

class AutoAttackController < ApplicationController
  # 表示「包含預覽」
  include Previewable
  # 描述未釋出的部分,除了 index 動作外
  unreleased except: %i[index]

  def index
    # 如果是預覽模式,用不同畫面呈現
    return render :index_v2 if preview?

    # ...
  end

  # ...
end

基於 Mixin 的方式,我們會發現對於整個 Controller 的行為描述變的非常清晰,我們可以透過 DSL 的擴充對整個情境補充,或者方法來提供這個情境可以「做特定行為」

也許我們該反思,許多時候我們利用 Rails 的 Callback 機制(before_action)是否是在描述「前提」還是單純的想把過長的重複程式碼分離出去

本文引用自弦而時習之用 Ruby 的 Mixin 加入脈絡增加可讀性

如果你喜歡這篇文章,想要更深入了解技術,我們有開設蒼時弦也的 📒 「開發必學的需求分析法」以及「Rails 開發者的進階實戰」課程唷 ❤️️ 歡迎報名!