---
title: "用 Ruby 的 Mixin 加入脈絡增加可讀性"
date: 2023-01-11T00:00:00+08:00
publishDate: 2023-01-11T00:00:00Z
lastmod: 2023-01-11T11:18:35+08:00
tags: ["Ruby","經驗","Rails","Context","脈絡","Mixin","Data Context Interaction"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/01/11/the-ruby-mixin-can-describe-the-context/"
language: "zh-tw"
---


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

<!--more-->

## Mixin 的運作{#how-mixin-works}

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

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

class Actor
  include Attackable
end

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

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

## Ruby on Rails 的脈絡呈現{#the-context-of-rails}

若是要舉例，用 Ruby on Rails 會容易些，因為在框架的設計上就有很深入的考慮到 DCI 的概念，如果還不清楚可以參考[自然地在 Rails 中應用 Data Content Interaction](https://blog.aotoki.me/posts/2022/10/28/use-data-context-interaction-natively-in-rails/) 這篇文章的簡介。

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

```ruby
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 的描述，就可以變成類似這樣的敘述

```gherkin
#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 的使用案例{#use-case-of-mixin}

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

我使用的是 [Flipper](https://www.flippercloud.io/) 這個套件，他能讓我使用類似這樣的方式保護某個尚未釋出的功能。

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

  # ...
end
```

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

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

```ruby
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」而我們會自然地使用 `included` 和 `class_methods` 這兩個 DSL 來描述。

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

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

```ruby
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`）是否是在描述「前提」還是單純的想把過長的重複程式碼分離出去

