MogLog

日記 兼 備忘録

ActiveSupport::Concernが裏でやっていること

ActiveSupport::Concern を extend したモジュールは以下の機能が使えるようになる。

  • class_methods do .. end ブロックに定義したメソッドをクラスメソッドとして、include する側のクラスに取り込む
  • included do .. end ブロックに定義した処理を include する側のクラスのコンテキストで実行する

これらの機能を実現するために、Rails内部でどのようなことをしているかを調べた。

Railsのバージョン

サンプルコード

まずはサンプルコードでそれぞれの機能を使ったときの動作を確認しよう。

class_methods を使ったサンプルコード

app/models/concerns/sample.rb モジュールを作成し、class_methods ブロックの中にメソッドを1つ定義する。

# app/models/concerns/sample.rb
module Sample
  extend ActiveSupport::Concern

  class_methods do
    def sample_class_method
      'sample_class_method'
    end
  end
end

これをモデルのUserクラスの中で include する。

# app/models/user.rb
class User < ApplicationRecord
  include Sample
end

すると、次のようにクラスメソッドが使えるようになる。

[1] pry(main)> User.sample_class_method
=> "sample_class_method"

included を使ったサンプルコード

先ほど作成した app/models/concerns/sample.rb モジュールに対し、今度は included ブロックに validates の処理を書く。

# app/models/concerns/sample.rb
module Sample
  extend ActiveSupport::Concern

  included do
    validates :name, presence: true
  end
end

同じようにUserクラスの中で include する。

# app/models/user.rb
class User < ApplicationRecord
  include Sample
end

次のように、Userクラスに validates が効いた状態になっている

[1] pry(main)> user = User.new(name: nil)
=> #<User:0x00007fbda44d76a0 id: nil, name: nil, created_at: nil, updated_at: nil>
[2] pry(main)> user.valid?
=> false
[3] pry(main)> user.errors[:name]
=> ["can't be blank"]

class_methods の実装

それでは、#class_methods の実装を見てみよう。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L140-L146
def class_methods(&class_methods_module_definition)
  mod = const_defined?(:ClassMethods, false) ?
    const_get(:ClassMethods) :
    const_set(:ClassMethods, Module.new)

  mod.module_eval(&class_methods_module_definition)
end

最初に const_defined?(:ClassMethods, false) で、ClassMethods という定数(モジュール)が存在するかを確認し、あればそれを取得、なければモジュールとして新しく定義している。

その後、#module_eval をもって ClassMethods のコンテキストで引数の &class_methods_module_definition ブロックを実行している。

これが実行されると、先のサンプルコードであれば次の状態になったことと同義となる。

module Sample
  module ClassMethods
    def sample_class_method
      'sample_class_method'
    end
  end
end

included の実装

続いて #included の実装を見てみよう。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L126-L138
def included(base = nil, &block)
  if base.nil?
    if instance_variable_defined?(:@_included_block)
      if @_included_block.source_location != block.source_location
        raise MultipleIncludedBlocks
      end
    else
      @_included_block = block
    end
  else
    super
  end
end

included do .. end のようにブロック付きで実行される想定なので、引数 basenil となり最初の条件分岐は真となる。

続いて if instance_variable_defined?(:@_included_block) の条件分岐は最初の実行の場合は偽となるので @_included_block = block の部分だけが実行されることになる。

#append_features

さて、class_methodsincluded の実装を見たところだが、それぞれ次のことをやっているだけであった

  • class_methods はブロックの中の定義を ClassMethods モジュールに展開する
  • included はブロックの中の定義を @_included_block インスタンス変数に代入する

これだけでは include した側のクラスにクラスメソッドを定義したり、include する側のコンテキストでコードを実行することはできない。

ではどうしているか?この謎の答えは ActiveSupport::Concern に定義された #append_features というメソッドにある。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L113-L124
def append_features(base)
  if base.instance_variable_defined?(:@_dependencies)
    base.instance_variable_get(:@_dependencies) << self
    false
  else
    return false if base < self
    @_dependencies.each { |dep| base.include(dep) }
    super
    base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
    base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
  end
end

そもそも #append_featuresRuby の Moduleクラスにあるメソッドで、include の実体であるとドキュメントに書かれている。

モジュール(あるいはクラス)に self の機能を追加します。 このメソッドは Module#include の実体であり、...(略)

https://docs.ruby-lang.org/ja/latest/method/Module/i/append_features.html

つまり、モジュールを include したとき、内部では #append_features メソッドが実行されるようになっていて、ActiveSupport::Concern はこの #append_features をオーバーライドしているのだ。
このオーバライドした #append_features メソッドの最後の2行に注目してみよう。

base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)

base というのは include した側のクラスなので、サンプルコードで言えば Userクラスにあたる。

  • base.extend const_get(:ClassMethods)
    • Userクラスに対して ClassMethods モジュールを extend する
    • よって、ClassMethods に定義されたメソッドが、Userクラスにクラスメソッドとして定義される
  • base.class_eval(&@_included_block)
    • class_eval により Userクラスのコンテキストで @_included_block を実行する
    • サンプルコードで言えば、validates: :name, presence: true が実行されることになる

このようにして、2つの機能を実現していた。

コードを読むとき、Concernモジュール側の視点とConcernモジュールを include する側の視点とを切り替えなければならず、少し混乱した...。

Rails の Flash の機能はどのように実現されているか

Rails には Flash という機能がある。Flash の機能は次の通り。

flashはセッションの中の特殊な部分であり、リクエストごとにクリアされます。つまりflashは「直後のリクエスト」でのみ参照可能になるという特徴を持ち、エラーメッセージをビューに渡したりするのに便利です。 https://railsguides.jp/action_controller_overview.html#flash

この Flash について、内部でどのようなことをしているかを追ってみたのでまとめる。具体的には以下の2点について追いかけた。

  • FlashRails 内部でどのように管理されているか
  • Flash はどのようにして「直後のリクエスト」でだけ参照できるようにしているか

環境

事前準備

理由は後述するが、事前準備として以下2点の変更を加えておく。

  • activerecord-session-store という Gem を使ってセッションの管理を行うようにする
  • セッションデータの保存形式をJSONにする

application.rb に次の行を追加するだけ

# config/application.rb
module MyApp
  class Application < Rails::Application
    # ...
    config.session_store :active_record_store # 追加
    # ...
  end

  ActiveRecord::SessionStore::Session.serializer = :json # 追加
end

※ sessionsテーブルの作成手順は省略する

FlashRails 内部でどのように管理されているか

まず1つ目の調査テーマの解答となる。
最初に結論から書くが、Flash はセッションデータの一部として管理されている。 これは、Railsガイドにもしれっと書かれている。

flashはセッションの中の特殊な部分であり、リクエストごとにクリアされます。 https://railsguides.jp/action_controller_overview.html#flash

「事前準備」としてセッションの保存先をデフォルトの Cookie から ActiveRecord に変更し、保存形式をJSON にしたのはこの確認をしやすくするためだ。

実際に動かして確認する

では、実際に動かして確認してみよう。
まず、確認用のエンドポイントを作るため、ルーティングの設定を2つ追加する。
flash_addflash にデータを書き込み、flashes の方で flash からデータを参照して view に出力する。

# config/routes.rb
Rails.application.routes.draw do
  get 'flashes' => 'flashes#index', as: :flashes
  get 'flash_add' => 'flashes#add'
end

コントローラは #addmessage というキーに "Hello, flash!" という値をセットする。#index は view をレンダリングするのみ。

# app/controllers/flashes_controller.rb
class FlashesController < ApplicationController
  def index; end

  def add
    flash[:message] = 'Hello, flash!'
    render :index
  end
end

ビューでは、URL と flash を出力するだけ。

<!-- app/views/flashes/index.html.erb -->
URL: <%= request.url %>
<pre><%= debug flash %></pre>

それでは bundle exec rails s でサーバを起動して、http://localhost:3000/flash_add へアクセスしよう。

f:id:mogulla3:20190501212156p:plain
rails-flash-01

flashmessage がセットされていることを確認できた。 そして、このときに DB に保存されるセッションデータがどうなっているかも確認してみよう。

bundle exec rails db で DB のコンソールを起動して、sessions テーブルからデータを取得する(デフォルトなのでSQLite)。

sqlite> SELECT data FROM sessions;
{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM=","flash":{"discard":[],"flashes":{"message":"Hello, flash!"}}}}

セッションデータの一部として、flash キーが存在し、内部のデータも画面上に出力したデータと同等であることを確認できた。

Flash はどのようにして「直後のリクエスト」でだけ参照できるようにしているか

※ ここから先は一部確証が取れなかった部分もあるため、参考程度にしてください

2つ目の調査テーマ。こちらも最初に結論から書く。

  • (1) "直後のリクエスト" でセッションから Flash を取得する段階で、削除すべきデータについてフラグのようなものを立てる
  • (2) アプリケーション側の処理が完了した後、再びセッションに保存するための Hash へと変換する処理がある。このときに、先に削除フラグを立てたデータを Hash から削る
  • (3) 最後に activerecord-session-store が受け取った Hash でセッションデータの更新を行う。結果 Flash が消える

これだけではよくわからないと思うので、順を追って見ていこう。

事象の確認

寄り道となるが、まずは事象、つまり Flash が直後のリクエストでのみ参照できることをセッションデータと合わせて確認してみよう。

(1) Flashをセットするリクエスト(再掲)

まず最初に、Flash にデータを追加する http://localhost:3000/flash_add をリクエストする。ここでFlashがセットされる。

f:id:mogulla3:20190501212156p:plain
rails-flash-01

このとき、セッションデータは次の状態だ。

{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM=","flash":{"discard":[],"flashes":{"message":"Hello, flash!"}}}}

(2) Flashを参照するリクエス

次に Flash を表示する http://localhost:3000/flashes をリクエストする。

f:id:mogulla3:20190501213453p:plain
rails-flash-02

FlashHash オブジェクトの discard というインスタンス変数にデータが追加されている。これが先ほど「削除フラグ」と呼んだものの正体だ。またこのとき、DBのセッションデータからは すでに Flash のデータが削除されている

{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM="}}

(3) その次のリクエス

再度、Flash を表示するだけの http://localhost:3000/flashes をリクエストする。

f:id:mogulla3:20190501213704p:plain
rails-flash-03

ここでは View に出力される Flash も空になっている。DBの中も先と同様にFlashのデータは存在しない。

これで、ドキュメントにある通り直後のリクエストでだけ参照できることが確認できた。

ActionDispatch::Flash::FlashHash

ビューにダンプした結果からもわかるが、Flashは単なるHashではなく ActionDispatch::Flash::FlashHash というクラスのインスタンスになっている。

そしてこのクラスに Flash が直後のリクエストでのみ参照できるカラクリがある。 一旦詳細は置いておくが、FlashActionDispatch::Flash::FlashHashインスタンスとして Rails 内部で扱われていることだけ押さえて次に進もう。

Flashを取得するときに削除フラグを立てる

"直後のリクエスト" が飛んできたとき、処理の流れとしてはアプリケーション本体に到達する前にいくつかのミドルウェアを通るが、Flashに関しては「行き」のリクエストでは特に何も行われず、アプリケーション本体に処理が流れ着く。

今回のサンプルコードでは、アプリケーションの view で flash メソッドからデータを参照していた。このとき、内部では ActionDispatch::Flash:: RequestMethods#flash メソッドがコールされている。

# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L47-L51
def flash
  flash = flash_hash
  return flash if flash
  self.flash = Flash::FlashHash.from_session_value(session["flash"])
end

重要なのは3行目で、セッションに保存されたデータから Flash を取得するために Flash::FlashHash.from_session_value がコールされる。
このメソッドは Hash 形式の session["flash"] を FlashHash に変換する。実装を見てみよう。

# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L114-L131
def self.from_session_value(value)
  case value
  when FlashHash
    # 省略
  when Hash
    flashes = value["flashes"]
    if discard = value["discard"]
      flashes.except!(*discard)
    end
    new(flashes, flashes.keys)
  else
    # 省略
  end
end

引数 value はDBに保存されているセッションデータが Hash 化されたものだ。そのため、when Hash の条件に引っかかる。
DBに保存されたデータをもう一度見ればわかるが、discard キーの値は空配列だった。よって、discard = value["discard"] 式の結果も空の配列 [] となる。Rubyでは空の配列は真となるので、flashes.except!(*discard) は実行される。が、引数 discard は空配列なので特に何も変化しない(※ ここは本来的には実行されるべきではないのかもしれない...)。

最後に #new(flashes, flashes.keys) が実行されることになる。
#new の第2引数は discard となっており、ここで削除すべきデータをマークしている。

# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L141-L145
def initialize(flashes = {}, discard = [])
  @discard = Set.new(stringify_array(discard))
  @flashes = flashes.stringify_keys
  @now     = nil
end

つまり、Flash::FlashHash.from_session_value の返り値は FlashHashインスタンスであり、@flashes インスタンス変数には {"message":"Hello, flash!"} がセットされ、@discard インスタンス変数には Set.new(["message"]) がセットされている。

FlashHashを再度セッションに保存するための形式へ変換する

アプリケーション側ではこうして作られた FlashHashインスタンスを用いてHTMLの構築が行われる。
そしてHTMLの構築が終わった後に Flash::RequestMethods#commit_flash がコールされる。

# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_controller/metal.rb#L192
def commit_flash # :nodoc:
  session    = self.session || {}
  flash_hash = self.flash_hash

  if flash_hash && (flash_hash.present? || session.key?("flash"))
    session["flash"] = flash_hash.to_session_value
    self.flash = flash_hash.dup
  end

  if (!session.respond_to?(:loaded?) || session.loaded?) && # reset_session uses {}, which doesn't implement #loaded?
      session.key?("flash") && session["flash"].nil?
    session.delete("flash")
  end
end

ここで重要となるのは Flash::FlashHash#to_session_value の部分で、FlashHashインスタンスをセッションに保存するためのデータに変換している。

# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L135-L139
def to_session_value #:nodoc:
  flashes_to_keep = @flashes.except(*@discard)
  return nil if flashes_to_keep.empty?
  { "discard" => [], "flashes" => flashes_to_keep }
end

先に述べたように、@flashes の値は {"message":"Hello, flash!"} で、@discard の値はSet.new(['message']) なので @flashes.except(*@discard) の結果は [] になる。
よって、その後の if flashes_to_keep.empty? の結果は真となり、nil が返されることになる。

そして再び #commit_flash のコードに戻ってみよう。
#to_session_value の結果は session["flash"] に代入される。そして、その次の条件分岐は真となり、session.delete("flash") が実行されることになる。
つまり、ここで Hash 形式の セッションデータから flash のデータが消えることになる。

ActionDispatch::Session::ActiveRecordStore がセッションデータを更新する

ただし、この段階ではまだ DB の方は更新されていない。DBの更新はミドルウェアである ActionDispatch::Session::ActiveRecordStore が行うことになる。

ここから先の詳細は省略するが、ActionDispatch::Session::ActiveRecordStore は session の値を持って、データをUPDATEする動きとなるため、結果としてセッションデータから Flash の情報が消えることになる。

よって、この次以降のリクエストでは Flash に保存した { message: "Hello, flash!" } はもう参照できなくなる。

まとめ

RailsFlash の内部を追いかけてみた。

  • Flash はセッションの一部である
  • FlashRails 内部では ActionDispatch::Flash::FlashHashインスタンスである
  • "直後のリクエスト"でセッションから Flash を取得する段階で削除フラグがセットされる(=> discard インスタンス変数)
  • アプリケーション側の処理が終わった後に、FlashHash をセッション保存用に再変換する処理があり、そこで discard なデータが消される
  • 最後にセッションストアのミドルウェア(本記事では ActionDispatch::Session::ActiveRecordStore)がセッションを更新することで完全に削除される

ecs-deployを使ったAmazon ECSへのデプロイの裏側

ecs-deploy というデプロイツールがあり、これを使うと簡単に Amazon ECS へデプロイができる。

github.com

Dockerイメージを更新するだけであれば以下のように、コマンド一発でデプロイをいい感じにやってくれる。

$ ecs-deploy --cluster my-cluster --service-name my-service --image registry.gitlab.com/mogulla3/ecs-sample-app:xxe93fce82a4e787dc81427a4a469debaa07dbb4

ecs-deploy は内部的には AWS公式のCLI を使っているので、CLIを駆使してデプロイを実行していると想像できるが、具体的に何をやっているのかをこの記事では追ってみたい。

なおここでは ecs-deploy まわりについてしか触れていないが、一緒に検証をした mosuke5 先生も同じ作業内容で別視点の記事を書いているのでお供にどうぞ。 blog.mosuke.tech

前提

  • AWS ECS を Fargate 起動タイプを使って実行しているアプリケーションが対象
  • デプロイとして具体的にやりたいことはDockerイメージの更新のみ
  • デプロイタイプとして rolling update を選択
  • ecs-deploy は 2019/04/13時点のmasterブランチのバージョンを使用 (ハッシュ値 : 2470057351d205b8a59b1067cc23cb2243a7fae8

全体的な流れ

最初にコマンド実行時の全体的な流れをざっと確認した。大きく分けて、次の5ステップになっている。

  1. 現在のECSタスク定義を取得する
  2. 現在のECSタスク定義をベースに、新しいタスク定義を作成する
  3. 新タスク定義をAWS上に登録する
  4. 新タスク定義を使うよう、ECSを更新をする
  5. デプロイの最終確認をする

以下、それぞれのステップを細かく追っていく。

現在のECSタスク定義を取得する

現在のタスク定義を取得するために、最初に aws ecs describe-services を実行してタスク定義のARNを取得する

aws ecs describe-services --service <SERVICE_NAME> --cluster <CLUSTER_NAME> | jq -r .services[0].taskDefinition

ARNとはAmazonリソースネームの略で、AWS上のリソースを一意に識別するためのもの。詳細は こちら を参照されたし。
具体的には arn:aws:ecs:us-east-1:... のような単なる文字列だ。

タスク定義のARNを取得したら、実際の値、つまりJSONフォーマットで書かれた定義情報を aws ecs describe-task-definition を使って取得する。

aws ecs describe-task-definition --task-def <取得したタスク定義のARN>

現在のECSタスク定義をベースに、新しいタスク定義を作成する

次に、これからデプロイする新しいタスク定義を作成する。これは、現在のタスク定義をベースに一部を書き換えたり、不要な要素を削る形で作られる。

最初に、タスク定義の中に含まれるイメージ情報を sed を使って書き換える。変更後の値は ecs-deploy コマンド実行時に --image オプションに渡した値となる。

{
    "taskDefinition": {
        "containerDefinitions": [
            {
                // ここが書き換わる
                "image": "registry.gitlab.com/mogulla3/ecs-sample-app:xxe93fce82a4e787dc81427a4a469debaa07dbb4",
                ...
            },
            ...
        ],
        ...
    }
}

imageの部分を書き換えた後、必要となるデータだけを抽出する。 ここは起動タイプとしてFargateを選択しているかどうか等によって抽出する値が変わってくるが、自分の設定の場合は以下のようなタスク定義が作られていた。

{
    "family": "task-definition-name",
    "volumes": [],
    "containerDefinitions": [
        {
            "name": "custom",
            "image": "registry.gitlab.com/mogulla3/ecs-sample-app:xxe93fce82a4e787dc81427a4a469debaa07dbb4",
            "repositoryCredentials": {
                "credentialsParameter": "arn:aws:secretsmanager:.."
            },
            "cpu": 256,
            "links": [],
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "entryPoint": [],
            "command": [],
            "environment": [],
            "mountPoints": [],
            "volumesFrom": [],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "/ecs/task-definition-name",
                    "awslogs-region": "us-east-1",
                    "awslogs-stream-prefix": "ecs"
                }
            }
        }
    ],
    "placementConstraints": [],
    "networkMode": "awsvpc",
    "executionRoleArn": "arn:aws:iam::...",
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512"
}

新しく作成したタスク定義をAWS上に登録する

ここまでで新しいタスク定義が完成した。しかしこれをいきなり使うのではなく、最初にAWS上に登録する必要がある(らしい)。
よって、次のように aws ecs register-task-definition を使って登録する。JSONデータの新タスク定義をそのまま引数として渡して登録できる。

aws ecs register-task-definition --cli-output-json "新しく作成したタスク定義のJSON"

このコマンドの返り値として、新しいタスク定義のJSONデータが返ってくるので、ここからARNだけを変数に保持して次に進む。このステップはこれだけだ。

新タスク定義を使うよう、ECSを更新をする

ここまでで下準備が整った。いよいよイメージの更新を実行していく。これは aws ecs update-service を使って行われる。

aws ecs update-service --cluster my-cluster --service my-service --task-definition <新タスク定義のARN>

さらにここで終わらず、続けて aws ecs describe-services を使い desiredCount というパラメータを取得している。

aws ecs describe-services --cluster my-cluster --service my-service | jq '.services[]|.desiredCount

desiredCount とはタスク定義を反映したインスタンスの実行数のこと。
サービス定義パラメータ - Amazon Elastic Container Service

ecs-deployは気が利いていて、新しいタスク定義で作られた実行中インスタンスの数がこの値になるまでsleepをはさみつつ確認をしてくれる(ただしタイムアウト時間はある)。

このとき、aws ecs list-tasksaws ecs describe-tasks コマンドが使われている。

aws ecs list-tasks --cluster my-cluster --service-name my-service --desired-status RUNNING
aws ecs describe-tasks --cluster my-cluster --tasks RUNNING_TASKS

デプロイの最終確認をする

最後にデプロイの完了を確認を行う。具体的には以下コマンドで取得する deployments の数が1つになることを確認している。

aws ecs describe-services --services my-service--cluster my-cluster | jq "[.services[].deployments[]] | length"

ECSはデプロイした後、旧タスクと新タスクがしばらく混在した状態になる。実際にWebサーバのコンテナをデプロイしているときに、ブラウザからアクセスを繰り返すと旧タスクと新タスクのレスポンスがランダムで返ってくることを確認している。

おそらくロードバランサーなどの設定も絡んでくるところだが、要するに新旧タスクが混在した状態から新タスクだけが稼働している状態になるまでの確認をしてくれているわけだ。

自分が最初に試したときは90秒の待機時間を超えても2つの deployment が残った状態で確認処理がタイムアウトしてしまっていた。これはECSの前段に作られたロードバランサーが旧タスクの登録解除を行う際に300秒近く待機することが起因していたようで、ロードバランサーの設定を変えることで成功するようになった。

参考 : ECS運用のノウハウ - Qiita

まとめ

ecs-deploy を使ったECSアプリケーションのデプロイの裏側を追った。
コマンド一発でいい感じにやってくるその裏側では AWS CLI、jq、sed などのコマンドを使って結構色々なことをやってくれていた。

この記事では書ききれなかった処理やコマンドラインオプションも色々あったので、詳細が気になった方は読んでみるといいかもしれない。ShellScriptだけで書かれていて、かつ700行くらいしか無いので個人的には読みやすかった。

if-then-elseを使うかガード節を使うかの判断基準

最初に結論から

  • 一方が正常な振る舞い、もう一方は特殊な振る舞いと考えられる場合はガード節を使う
  • 双方とも同等に正常な振る舞いと考えられる場合は、if-then-else構文を使う

詳細

ガード節の有効性

プログラムを書いていると、様々な条件が考えられ、それらを考慮したコードを書かなければならないというケースが往々にして発生するだろう。
このような場合、単純にif-else ... と条件分岐を書きつらねていくとたちまち読みづらいコードになってしまう。

読みづらくなってしまう理由の1つとして条件がネストしてしまうということがあげられる。そういったときに ガード節(Guard Clauses) は有効に機能する。

# before
def some_method
  if @cond1
    'cond1 value'
  else
    if @cond2
      'cond2 value'
    else
      if @cond3
        'cond3 value'
      else
        'normal value'
      end
    end
  end
end
# after
def some_method
  return 'cond1 value' if @cond1
  return 'cond2 value' if @cond2
  return 'cond3 value' if @cond3
  'normal value'
end

条件分岐のネストが消え、副次的効果としてコード行数も削減できた。後者の方が読みやすいということにほとんど異論は無いと思う。

どちらがよりコードの意図を伝えられるか

もし条件がそこまで深くネストしていないような場合、ガード節を使うべきだろうか?

# ガード節を使わない
def some_method
  if @cond1
    'cond1 value'
  else
    'normal value'
  end
end

# ガード節を使う
def some_method
  return 'cond1 value' if @cond1
  'normal value'
end

コード行数という観点から見ると、やはりガード節を使ったほうが良さそうに思う。しかしコードの見た目上バランスが取れているのはガード節を使わない方に見えるという意見もありそうだ。

しかし、ここで本当に考えるべきはコードの見た目や行数ではなく、それぞれの処理が正常な振る舞いなのか特殊な振る舞いなのかということだ。より抽象的には、どちらがよりコードの意図を伝えられるか を考えるということでもある。

それぞれの使いどころ

『リファクタリング:Rubyエディション』 には次のように書かれている。

「条件分岐のネストからガード節へ」のポイントは、片方を強調することである。
if-then-else構文を使う場合、if/thenの分岐先と else の分岐先には同等のウェイトを置いている。これは、両方の分岐先が同じように実行され、重要だということを読者に伝える。
 
それに対し、ガード節は、「これはまれなケースで、発生した場合には何かちょっとしたことを行って外に出る」ということを表している。

まとめると次のようになる

  • if-then-else構文は、それぞれの処理に同等のウェイトを置く場合に使う
  • ガード節は、特殊な条件を前段で弾き、その後の処理こそが正常な振る舞いであることを示す場合に使う

先のサンプルコードで表すと、@cond1 を満たす場合も満たさない場合も同程度に正常な振る舞いと考えられるならば次のようにする。

def some_method
  if @cond1
    'cond1 value'
  else
    'normal value'
  end
end

逆に、 @cond1 は特殊な条件でのみ満たされ、満たさないケースこそが正常な振る舞いだとする場合は次のようにする。

def some_method
  return 'cond1 value' if @cond1
  'normal value'
end

※ サンプルコードなので納得感は薄いかもしれないが、現実的にはガード節の後はもっと処理量のあるコードになると思う。

最後に

この記事は、『リファクタリング:Rubyエディション』の第9章の内容の一部を噛み砕いて説明しようと試みたものです。 興味を持った方やより詳細に知りたい方は書籍を直接読んでみるのが良いと思います。

リファクタリング:Rubyエディション

リファクタリング:Rubyエディション

RubyのProcは `#call` 以外でも実行できる

Procオブジェクトは #call を使って実行することが多いと思う。

fn = Proc.new { |v| puts v }
fn.call("Hello, world!")
# => "Hello, world!" 

しかし、次のように [] を使っても実行できるのだ。

fn = Proc.new { |v| puts v }
fn["Hello, world!"]
# => "Hello, world!" 

引数が複数でも実行できる。

fn = Proc.new { |v, v2| puts "#{v}, #{v2}" }

fn.call("Hello", "world!")
# => "Hello, world!"

fn["Hello", "world!"]
# => "Hello, world!"

ドキュメントにも普通に書いてあるよ(僕は今日知りました)。
instance method Proc#=== (Ruby 2.6.0)