Madogiwa Blog

主に技術系の学習メモに使っていきます。

`ActiveRecord::Relation#explain`を拡張してJSON形式のEXPLAIN結果を取得出来るgemを作った💎

前に下記の記事で紹介したActiveRecord::Relation#explainJSON形式を取得出来るモンキーパッチを紹介したのですが、 今回はそれをGemとして公開したのでそれについて書きます💎✨

madogiwa0124.hatenablog.com

Gemはactive_record_json_explainとしてRubyGems.orgに公開してます!

公開したGemのurlは下記です🙌

rubygems.org

コードはこちらです🐙

github.com

導入方法

導入方法は簡単で下記のようにGemfileに追加して、

gem 'active_record_json_explain', require: false

有効化したい箇所でrequireしてください🙋

require 'active_record_json_explain'

require後からgemで定義しているモンキーパッチが有効になります🐵
※既存のexplainには影響しない実装にしています。

使い方

出来ることは前回のブログにも記載していますが、ActiveRecord::Relation#explainの引数にjson: trueを渡すをJSON形式でEXPLAINの結果を取得出来ます👍

以下が実際にMySQLを使っている場合の結果になります🐬

$ require 'active_record_json_explain'  # モンキーパッチの有効化
=> true
$ Sample.with_title.explain(json: true)
=> EXPLAIN for: SELECT `samples`.* FROM `samples` WHERE `samples`.`title` = 'hoge'
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "0.35"
    },
    "table": {
      "table_name": "samples",
      "access_type": "ALL",
      "rows_examined_per_scan": 1,
      "rows_produced_per_join": 1,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "0.25",
        "eval_cost": "0.10",
        "prefix_cost": "0.35",
        "data_read_per_join": "1K"
      },
      "used_columns": [
        "id",
        "category",
        "title",
        "body"
      ],
      "attached_condition": "(`sample`.`samples`.`title` = 'hoge')"
    }
  }
} |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

PostgreSQLにも対応していて下記のような形で取得出来ます 🐘

$ require 'active_record_json_explain'  # モンキーパッチの有効化
=> true
Sample.with_title.explain(json: true)
=> EXPLAIN for: SELECT "samples".* FROM "samples" WHERE "samples"."title" = $1 [["title", "hoge"]]
                                                                                                                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
[
  {
    "Plan": {
      "Node Type": "Seq Scan",
      "Parallel Aware": false,
      "Relation Name": "samples",
      "Alias": "samples",
      "Startup Cost": 0.00,
      "Total Cost": 11.62,
      "Plan Rows": 1,
      "Plan Width": 556,
      "Filter": "((title)::text = 'hoge'::text)"
    }
  }
]
(1 row)

おわりに

前回の記事で書いた通りJSON形式のEXPLAINは特にMySQLだとオプティマイザが判断したクエリのコストが表示されリファクタリング時に有用な情報を取得出来るので便利かなと思います👍
クエリチューニングを行うとき等に使っていただければ🙌

ActiveRecordにモンキーパッチを当ててJSON形式のEXPLAINを取得出来るようにする🐵

ActiveRecord::Relation#explainを使うと、実行するSQLのEXPLAINの結果を取得出来ます。ActiveRecord::Relation#explainではデフォルト設定でしか結果を取得出来なのですが、EXPLAINにはオプションが設定できます。

特にMySQLではオプティマイザが評価したSQLのコスト等の詳細な情報はJSON形式のEXPLAINでしか取得できません😢

dev.mysql.com

実際の結果の違いを見るとJSON形式のほうが有用な情報が多いことが分かると思います👀

EXPLAIN SELECT `samples`.* FROM `samples` WHERE `samples`.`title` = 'hoge'
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | samples | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |    100.0 | Using where |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set (0.00 sec)

EXPLAIN FORMAT=JSON SELECT `samples`.* FROM `samples` WHERE `samples`.`title` = 'hoge'
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "0.35"
    },
    "table": {
      "table_name": "samples",
      "access_type": "ALL",
      "rows_examined_per_scan": 1,
      "rows_produced_per_join": 1,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "0.25",
        "eval_cost": "0.10",
        "prefix_cost": "0.35",
        "data_read_per_join": "1K"
      },
      "used_columns": [
        "id",
        "category",
        "title",
        "body"
      ],
      "attached_condition": "(`sample`.`samples`.`title` = 'hoge')"
    }
  }
} |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

というわけで今回はActiveRecordにモンキーパッチを当てて下記のような形でJSON形式で取得出来るようにしてみました。

最終的な使い方はこちら

reuqire 'monkey_path.rb' # モンキーパッチのコードをrequire
Mysql::Sample.with_title.explain
# => 通常のEXPLAINの結果
Mysql::Sample.with_title.explain(json: true)
# => JSON形式のEXPLAINの結果

実際のモンキーパッチのコードはこちら🐵

ActiveRecord::Relation#explainに引数json: falseを追加して、最終的に実行されるDatabaseStatements#explainまで引数を引き継いでいって、その引数の値を元にFORMATを指定する文言を作成してSQLに設定しています👩‍🏭(もう少しいいやり方ありそう・・・🤔)

# frozen_string_literal: true

require 'active_support'
require 'active_record'

# NOTE:
# This monkey patch add argument(json) for ActiveRecord::Relation#explain.
# Argument json is default false. Therefore, it does not affect the default behavior.
# Only MySql and Postgresql are supported.

module ActiveRecord
  class Relation
    # NOTE: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation.rb#L239-L241
    def explain(json: false) # NOTE: add arg json
      exec_explain(collecting_queries_for_explain { exec_queries }, json: json) # NOTE: add arg json
    end
  end

  module Explain
    # NOTE: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/explain.rb#L19-L36
    def exec_explain(queries, json: false) # NOTE: add arg json
      str = queries.map do |sql, binds|
        msg = +"EXPLAIN for: #{sql}"
        unless binds.empty?
          msg << " "
          msg << binds.map { |attr| render_bind(attr) }.inspect
        end
        msg << "\n"
        msg << connection.explain(sql, binds, json: json) # NOTE: add arg json
      end.join("\n")

      # Overriding inspect to be more human readable, especially in the console.
      def str.inspect
        self
      end

      str
    end
  end

  module ConnectionAdapters
    module PostgreSQL
      module DatabaseStatements
        # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb#L31-L38
        def explain(arel, binds = [], json: false) # NOTE: add arg json
          format_option = "(FORMAT JSON)" if json # NOTE: get format option
          sql = "EXPLAIN #{format_option} #{to_sql(arel, binds)}" # NOTE: set format option
          PostgreSQL::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", binds))
        end
      end
    end

    module MySQL
      module DatabaseStatements
        # NOTE: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L7-L10
        def explain(arel, binds = [], json: false) # NOTE: add arg json
          format_option = "FORMAT=JSON" if json # NOTE: get format option
          sql     = "EXPLAIN #{format_option} #{to_sql(arel, binds)}" # NOTE: set format option
          start   = Concurrent.monotonic_time
          result  = exec_query(sql, "EXPLAIN", binds)
          elapsed = Concurrent.monotonic_time - start

          MySQL::ExplainPrettyPrinter.new.pp(result, elapsed)
        end
      end
    end
  end
end

MySQLだけじゃなくてPostgresqlにも対応してみました🙌(SQLiteJSON形式のEXPLAINに対応していないようだったので対応していません)

JSON形式のEXPLAINでオプティマイザのコストとかまで見れるとSQLの調整等でいい感じに使えそうですね👍

RubyonRails: ActiveJobのperform_laterが実行されてadapterがenqueueするまでの挙動を追ってみた。

最近Sidekiqを扱うことが割とあり、ActiveJob経由で使ったときの挙動をいまいち把握出来てなかったのでコードを読んでみて、内容とかをざっくりメモしておきます📝

ちなみに読んだのはrailsの6-stableブランチのコードです。

rails/activejob at 6-0-stable · rails/rails · GitHub

perform_laterの実行

まずperform_laterの定義されているところを見ていきます。job_or_instantiateで引数を元に自分自身(Job)をオブジェクト化して、enqueueを呼び出している。

module ActiveJob
  module Enqueuing
    extend ActiveSupport::Concern

    module ClassMethods
      def perform_later(*args)
        job_or_instantiate(*args).enqueue
      end

https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/enqueuing.rb#L21

enqueuequeue_adapter.enqueue selfを実行して、自分自身(Jobのオブジェクト)を引数にadapterenqueueを呼び出している👀

module ActiveJob
  module Enqueuing
    extend ActiveSupport::Concern
    module ClassMethods

    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      successfully_enqueued = false

      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end

        successfully_enqueued = true
      end

      if successfully_enqueued
        self
      else
        if self.class.return_false_on_aborted_enqueue
          false
        else
          ActiveSupport::Deprecation.warn(
            "Rails 6.1 will return false when the enqueuing is aborted. Make sure your code doesn't depend on it" \
            " returning the instance of the job and set `config.active_job.return_false_on_aborted_enqueue = true`" \
            " to remove the deprecations."
          )

          self
        end
      end
    end

https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/enqueuing.rb#L48

adapterによるenqueueの実行

adapterは下記のように色々あるが、

https://github.com/rails/rails/tree/6-0-stable/activejob/lib/active_job/queue_adapters

今回はsidekiqの場合を読んでいく。ActiveJobを使っても結局はSidekiq::Client.pushが実行されてRedisへのenqueueされている。引数に関しては、そのまま渡すのではなくserializeを呼び出してシリアライズをかけている。

module ActiveJob
  module QueueAdapters
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end

https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb#L20

Sidekiq::Client.pushの挙動を知りたいかたはこちら

madogiwa0124.hatenablog.com

引数のシリアライズ

serialize内では引数で渡す値を色々シリアライズをかけている。気になるargumentsシリアライズArguments.serializeを呼び出している。

module ActiveJob
  module Core
    extend ActiveSupport::Concern
    def serialize
      {
        "job_class"  => self.class.name,
        "job_id"     => job_id,
        "provider_job_id" => provider_job_id,
        "queue_name" => queue_name,
        "priority"   => priority,
        "arguments"  => serialize_arguments_if_needed(arguments),
        "executions" => executions,
        "exception_executions" => exception_executions,
        "locale"     => I18n.locale.to_s,
        "timezone"   => Time.zone&.name,
        "enqueued_at" => Time.now.utc.iso8601
      }
    end

    private
      def serialize_arguments_if_needed(arguments)
        if arguments_serialized?
          @serialized_arguments
        else
          serialize_arguments(arguments)
        end
      end

      def serialize_arguments(arguments)
        Arguments.serialize(arguments)
      end

https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/core.rb#L93

Arguments.serializeの中で引数をserializerでいろいろなシリアライズをかけている。だからActiveJobを使うとオブジェクトを引数に渡してもよしなにやってくれるんですね👀

  module Arguments
    extend self
    def serialize(arguments)
      arguments.map { |argument| serialize_argument(argument) }
    end

    private

      def serialize_argument(argument)
        case argument
        when *PERMITTED_TYPES
          argument
        when GlobalID::Identification
          convert_to_global_id_hash(argument)
        when Array
          argument.map { |arg| serialize_argument(arg) }
        when ActiveSupport::HashWithIndifferentAccess
          serialize_indifferent_hash(argument)
        when Hash
          symbol_keys = argument.each_key.grep(Symbol).map!(&:to_s)
          aj_hash_key = if Hash.ruby2_keywords_hash?(argument)
            RUBY2_KEYWORDS_KEY
          else
            SYMBOL_KEYS_KEY
          end
          result = serialize_hash(argument)
          result[aj_hash_key] = symbol_keys
          result
        when -> (arg) { arg.respond_to?(:permitted?) }
          serialize_indifferent_hash(argument.to_h)
        else
          Serializers.serialize(argument)
        end
      end

https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/arguments.rb#L33

※適用されるserializerは下記にまとめられている。 https://github.com/rails/rails/tree/6-0-stable/activejob/lib/active_job/serializers

おわりに

今回は、ActiveJobのperform_laterがどのようにadapter(Sidekiq等)を使ってenqueueしているのかを見てみました。 流れとしては、引数のシリアライズを行って、adapterを使ってenqueueしているようか動きなんですね。(Sidekiqの場合はSidekiq::Client.pushを呼び出している。

Sidekiqだと通常、シンボルやオブジェクトといった値を引数として指定できないのですが、ActiveJobを使っていると事前にシリアライズ処理が走るのでよしなにやってくれて上手くいくようになっているのは便利ですねー✨

GitHub ActionsでGitHub Pagesに自動デプロイするワークフローを作った

今までCIといえばCircleCIかTravis CIを使うことが多くGitHub Actionsを使ったことがなかったので、 今回はGitHub Pagesで運用している自分のポートフォリオサイトをGiHub ActionでCIを回して 自動的にデプロイ(gh-pagesブランチにpush)するワークフローを作ってみたのでGitHub Actionsの基本的な使い方からメモしておきます📝

GitHub Actionsとは?

GitHub Actionsで、ソフトウェア開発ワークフローをリポジトリの中で自動化、カスタマイズ実行しましょう。 CI/CDを含む好きなジョブを実行してくれるアクションを、見つけたり、作成したり、共有したり、完全にカスタマイズされたワークフロー中でアクションを組み合わせたりできます。 https://help.github.com/ja/actions

GitHub ActionsはGitHub上で実行出来るCIサービスです。設定するとGitHubリポジトリの下記で結果等を確認出来ます。

登録不要でGitHubで完結してCIを実行出来るのは便利ですね✨

GitHub Actionsの基本的な使い方

基本的な使い方としては下記のページで記載されているようにYAML構文でワークフローを定義していきます。

help.github.com

下記のような感じで書くとmasterブランチにpushまたはPull Requestが作成されたときにubuntu環境でHello Sample Job!を表示させるようなJobを実行させるワークフローです。

name: sample action
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - master

jobs:
  my_first_job:
    name: sample job
    runs-on: ubuntu-latest
    steps:
      - name: print hello
        run: echo Hello Sample Job!

さらにissueが作成されたタイミングにhookして何かを実行する等、GitHub独自っぽいものもあるのが良いですね、CI/CD以外にも色々使えそうな気がしています👀

help.github.com

コミュニティが提供しているActionを使う

GitHub Actionsではコミュニティで開発されているActionを使ってjobを定義することも出来ます✨

help.github.com

例えばactions/checkout@v2を使ってリポジトリをcheckoutさせる場合は下記のような感じでusesを使うと使用することが出来ます。

name: sample checkout action
on:
  push:
    branches:
      - master
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

github.com

提供されているActionはマーケットプレイスで確認出来るようです👀

help.github.com

GitHub ActionsでGitHub Pagesに自動デプロイする

今回は自分のポートフォリオサイトを今まではmasterブランチに手動でnpm run buildしてpushしていたのをGitHub Actionsで実行するようにしました。

流れとしては、masterブランチにpushされたときに下記を実行します。

  1. リポジトリのチェックアウト
  2. nodejs(12系)のセットアップ
  3. 依存モジュールのインストール
  4. 静的解析(eslint)の実行
  5. ビルドの実行
  6. ビルドで作成された./dist配下のファイルをgh-pagesブランチへpush
name: github pages

on:
  push:
    branches:
      - master

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [12.x]

    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - name: install deps
        run: npm ci
      - name: lint
        run: npm run lint
      - name: build
        run: npm run build --if-present
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          personal_token: ${{ secrets.ACTIONS_DEPLOY_KEY }}
          publish_dir: ./dist

secrets.ACTIONS_DEPLOY_KEYは、リポジトリのSecretsに設定したPersonal access tokenを読み込んでします。

おわりに

GitHub Actions今まで使ったことなかったのですが結構簡単に設定出来る + GitHub内で完結できるので良さそうに思いました。 今まで個人の小さいものはTravis CIを使うことが多かったのですが、GitHub Actionsを使っても良さそうだなと・・・!

またissue等のイベントにhookして出来るのもCI/CD以外にも色々できそうで良さそうですね✨

参考

qiita.com

RailsでIDの可逆難読化する方法のメモ📝

最近、IDの可逆な方式で難読化したいけどfriendly_idみたいなmigrationが伴うものは、ちょっと避けたかったので色々と方法を考えたり人に教えてもらったりしたので、いろいろ方法とかをメモしておきます。

github.com

やりたいこと

今回は「ユーザーにコンテンツ紹介メールを送信して、そのメールに記載されたURLをクリックしたときにブックマークさせる」 という機能で考えてみます。

普通に考えると下記のような実装になるかなと思うのですが、この実装には問題点があります。

# メール文面
# このコンテンツをブックマークしませんか?
# https://example.com/page/:id/bookmark

Rails.application.routes.draw do 
  resouces :pages do
    member { get 'bookmark' }
  end
end

class User; has_many :bookmarks; end
class Page; has_many :bookmarks; end
class Bookmark
  belongs_to :user
  belongs_to :page
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

それは、idが予測可能なためユーザーがURLを予測して独自に拡散等を行うと他ユーザーに不正に任意のページをブックマークさせるようなことが出来てしまいます。

これに対応するためにはidの難読化させて予測不能にする対応が考えられます。

ActiveRecordでUUIDを使う

まず、ActiveRecordのUUIDサポートを使うことが考えられます。 実装も特に工夫する必要もなくIDを予測不能にする必要が最初から検討できていれば良い方法かなと思います。

railsguides.jp

しかし、すでにidをintで作ってしまっていることや、Posgresqlでしか使えないのでMySqlでは使うことは出来ないといった問題点があります。

ActiveSupport::MessageEncryptorを使う

ActiveSupport::MessageEncryptorを使って可逆暗号化してIDを難読化する方法も考えられます。

api.rubyonrails.org

実装は下記のような感じになるかなと思います。

class Page
  ENCRYPT_CIPHER = "aes-256-cbc"
  ENCRYPT_SECRET = '01234567890123456789012345678901'
  has_many :bookmarks

  def self.find_with_encrypted_id(encrypted_id)
    crypt = ActiveSupport::MessageEncryptor.new(ENCRYPT_SECRET, cipher: ENCRYPT_CIPHER)
    decrypted_id = crypt.decrypt_and_verify(decrypt_and_verify)
    find(decrypted_id)
  end

  def encrypted_id
    crypt = ActiveSupport::MessageEncryptor.new(ENCRYPT_SECRET, cipher: ENCRYPT_CIPHER)
    crypt.encrypt_and_sign(id)
  end
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find_with_encrypted_id(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

bookmark_page_url(id: page.encrypted_id)のような形でhttps://example.com/page/NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh.../bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

しかしActiveSupport::MessageEncryptorで暗号化した文字列を生成すると同じ値を暗号化しても毎回違う値が生成されます。(※復号結果は変わらない) なので同一リソースを表すURLが毎回変わってしまうのはうーんという感じがしますね・・・。

scatter_swapを使う

scatter_swapというgemを使うと数字を10桁の数字に難読化することが出来ます。

github.com

このgemを使うを下記のような形でIDの難読化と復号を行う事ができます、シンプル✨

class Page
  has_many :bookmarks

  def self.find_with_encrypted_id(encrypted_id)
    decrypted_id = ScatterSwap.reverse_hash(encrypted_id).to_i
    find(decrypted_id)
  end

  def encrypted_id
    ScatterSwap.hash(id).to_i
  end
end

bookmark_page_url(id: page.encrypted_id)のような形でhttps://example.com/page/4517239960/bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

しかしidが数字だということはバレてしまう + 最終更新が7年前とメンテされる気配があやしそう・・・😭

hashid-railsを使う

hashid-railsを使っても同様のことが出来ますし、かつかなりシンプルに実装出来ます(これがいいのでは?)

github.com

class Page
  include Hashid::Rails
  has_many :bookmarks
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

bookmark_page_url(id: page.hashid)のような形でhttps://example.com/page/yLA6m0oM/bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

英語なので数字よりも元のIDを予測しずらく安全性も高そうですね✨そこそこメンテナンスもされてそうです🙌

結論

今回のケースだとhashid-railsが個人的には良さそうな気がしました🙌
イケてるgemをしっておくとこういうときに便利なので色々調べておきたいなと思いました🙇‍♂️

自作フレームワークで動的URLに対応したときの方針とか考えたこと📝

github.com

/hoge/1/fuga/2にGETでアクセスしたときに下記のロジックを実行させたいときにどうするかちょっと悩んだので考えたこととかメモしておく。

router.get '/hoge/:hoge_id/fuga/:fuga_id' do
  request.params['id']
end

今までは完全一致でパスの検証をしていたので、idみたいな動的に変更されるURLだとid単位にroutingを用意しないと行けない感じでした😭

動的項目を表すプレフィックスをどうするか

今回はsinatrarailsに倣って:を動的項目を表すプレフィックスにしました。

get '/hello/:name' do
  # "GET /hello/foo" と "GET /hello/bar" にマッチ
  # params['name'] は 'foo' か 'bar'
  "Hello #{params['name']}!"
end

http://sinatrarb.com/intro-ja.html

get '/patients/:id', to: 'patients#show'

Rails のルーティング - Railsガイド

URLの中の動的項目をどう判定するか

今回は/でsplitして動的項目だけリクエストのときにきたURLに書き換えて一致しているか判断するような方針をとりました。

例えば/hoge/1/fuga/2/hoge/:hoge_id/fuga/:fuga_idをチェックするさいにhoge, 1, fuga, 2hoge, :hoge_id, fuga, :fuga_idで動的項目の値を書き換えて一致していることを確認するようにしました。(これでいけるはずと思ってますけど、なんか漏れとかありそう・・・)

      def dynamic_indx
        @dynamic_indx ||= path.split('/').map.with_index do |val, i|
          i if val.include?(DYNAMIC_PREFIX)
        end.compact
      end

      attr_reader :path, :process, :method
      def path_match?(path)
        request_path, route_path = [path, self.path].map(&->(array) { array.split('/') })
        request_path.each.with_index do |val, i|
          route_path[i] = val if dynamic_indx.include?(i)
        end
        route_path == request_path
      end

※今は出来てないけど、この辺ちょっと動的項目の一意性を担保(hoge/:hoge_id/fuga/:fuga_idはNG)するのと、2回連続で動的項目を設定不可(hoge/:hoge_id/:fuga_id)とかはチェックするようにしたほうが良いかもですね・・・!

ちなみにsinatraだとMustermannという正規表現プレフィックスを元に一致チェックと行えるやつを使ってるみたいですね👀

github.com

if '/foo/bar' =~ Mustermann.new('/foo/*')
  puts 'it works!'
end

case 'something.png'
when Mustermann.new('foo/*') then puts "prefixed with foo"
when Mustermann.new('*.pdf') then puts "it's a PDF"
when Mustermann.new('*.png') then puts "it's an image"
end

pattern = Mustermann.new('/:prefix/*.*')
pattern.params('/a/b.c') # => { "prefix" => "a", splat => ["b", "c"] }

railsはどうやってるのかは見てないですが、後で時間があるときに見てみようかと思います。

動的項目をrequestのparamとして受け取る

これはrouteにurl_argsというインスタンス変数を追加して、{ 動的項目 => 値 }の要はhashを設定するようにしました。 そしてそれをrequestparamsにmergeするようにしました。

module Makanai
  class Application
    def execute_route
      route = router.bind!(url: request.url, method: request.method)
      # NOTE: merge dynamic url params (ex. /resources/1 -> { 'id' => '1' })
      @request.params.merge!(route.url_args)
      route.process.call(request)


module Makanai
  class Router
      def build_url_args(path:)
        request_path, route_path = [path, self.path].map(&->(array) { array.split('/') })
        dynamic_indx.each do |i|
          @url_args.merge!({ route_path[i].gsub(DYNAMIC_PREFIX, '') => request_path[i] })
        end
      end

でもrequestのオブジェクトをrouteのインスタンス変数で上書きするのは、ちょっとイマイチな気もしていたりするけど、railsとかsinatraに合わせておいたほうがいいかなと。。。

おわりに

今回はオレオレWebフレームワークを動的URLに対応させるときに個人的に考えていたことをメモしました。 DSL周りの考え方とか、paramsのような跨って使うようなobjectをどこで修正してどうやって処理に引き継がせるかとか、判断基準とか難しいけど、勉強になりますね💪

オレオレフレームワークの全体像が気になる方はこちらからコードをすべて読むことが出来ます。

github.com

Rubyのmethod_missingの使い方MEMO🔯

最近Rubyのメタプロ本を再読して、method_missingを使った実装を試してみたので使い方とかメモしておきます📝

method_missingとは?

呼びだされたメソッドが定義されていなかった時、Rubyインタプリタがこのメソッドを呼び出します。 https://docs.ruby-lang.org/ja/latest/method/BasicObject/i/method_missing.html

method_missingとはRubyリファレンスマニュアルに記載してあるとおり、呼び出したメソッドが定義されていなかったときに自動的に呼び出されるメソッドです。

class Hoge
 def method_missing(name, *args)
   puts "method #{name} is missing... :("
 end
end

irb(main):040:0> Hoge.new.fuga
method fuga is missing... :(
=> nil

このような感じでメソッドが見つからなかったときの挙動を定義できます。 これを使うと実行時に定義するメソッドを動的に決めたり色々なことができます。

method_missingを使ってみる

引数で渡された複数のClientの挙動を引き継いで独自の実装を追加するようなClassが必要となったときmethod_missingが役に立ちます。 例えば複数のDBMSに対して同一の処理を実行するために、それぞれのDBMSのクライアントの挙動を引き継ぎながら共通の処理を追加するWrapperなどです。

※Clientの挙動の引き継ぐやり方として、継承を使うことも考えられますが複数のClientに対応することはできません。 ※またRuby標準のSimpleDelegatorを使ったやり方では複数のClientの挙動を委譲することはできません。

class ClientsWrapper
  def initialize(clients:)
    @clients = clients
  end

  def method_missing(name, *args)
    @clients.each { |client| client.send(name, *args) }
  end
end

class Mysql
  def self.exec
    puts "Mysql"
  end
end

class Sqlite
  def self.exec
    puts "Slite"
  end
end

irb(main):016:0> ClientWrapper.new(client: Client).exec
Mysql
Slite

このような形で複数のClientに対して対してメソッドを実行することができました!

method_missingを使ってもrespond_to?をtrueにしたい

method_missingを使うと動的にメソッドを定義出来る一方、respond_to?を使って挙動を制御するといったことはできなくなってしまいます😢

irb(main):082:0> ClientsWrapper.new(clients: [Mysql,Sqlite]).respond_to?(:exec)
=> false

この問題を解決してくれるのがrespond_to_missing?です。

自身が symbol で表されるメソッドに対し BasicObject#method_missing で反応するつもりならば真を返します。 Object#respond_to? はメソッドが定義されていない場合、デフォルトでこのメソッドを呼びだし問合せます。 https://docs.ruby-lang.org/ja/latest/method/Object/i/respond_to_missing=3f.html

このメソッドを使うとrespond_to?の挙動を制御してmethod_missingによる応答の場合でもrespond_to?をtrueにするといったことができます。

先程のClientsWrapperclientsに定義されているメソッドだったら実行可能なので、respond_to?の結果がtrueになるように修正してみます。

class ClientsWrapper
  def initialize(clients:)
    @clients = clients
  end

  def method_missing(name, *args)
    @clients.each { |client| client.send(name, *args) }
  end

  def respond_to_missing?(sym, include_private)
    # 各clientのpublic_methodsに呼び出したメソッドが含まれていればtrueを返す。
    @clients.map(&:public_methods).flatten.include?(sym) ? true : super
  end
end

respond_to?(:exec)を実行したときにtrueが返却されています🙌

irb(main):109:0> ClientsWrapper.new(clients: [Mysql,Sqlite]).respond_to?(:exec)
=> true

おわりに

今回はrubymethod_missingについて記載してみました。method_missingを使うと柔軟にメソッドを定義することができますが、

「With great power comes great responsibility.(大いなる力には、大いなる責任が伴う)」 スパイダーマン - Wikipedia

ということで使い所には注意していきたいですね😅

メタプログラミングRuby 第2版

メタプログラミングRuby 第2版