Rubyのプラグイン開発に格好の教材「html5_validations」を分析する

# はじめに
- この記事の発端は銀座Rails#1のゲストスピーカーである、Railsコミッターの松田さんの発表でした（[スライド](https://speakerdeck.com/a_matsuda/ginza-rails-1?slide=67)）。
- この発表の中で、「**私が開発した[html5_validations](https://github.com/amatsuda/html5_validators)がプラグイン開発のいい教材になるよ！（意訳）**」という話がありまして。
- そこで私は考えました。「**プラグイン開発にそこまで興味はないけど、凄腕のRubistが手がけたプラグインには興味があるな‥‥。いい教材と言うことは私のようなアマチュアRubistには学ぶこと満載なんだろうし‥‥。よしやってみよう！**」とやる気になったので、自分なりにこのプラグインを分析してみました。

# html5_validationsはどのようなGemか？
- 一言でいえば「**モデルにバリデーションを設定すると、Railsの`form_helper`経由で作成したフォームに、バリデーションに応じたオプションが自動で付与されるよ！**」というもの。
- つまりはこういうこと。

```rb
# こうやって書いておくと
class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 30 }
end
```

```slim
= form_for(@user) do |f|
  f.text_field :name

/ デフォルトでrequiredとmaxlengthといったオプションがつく
/ => <input required="required" maxlength="30" size="30" type="text" name="user[name]" id="user_name" />
```

- 自分で逐一フォームに設定を書く必要がなくなるし、modelのバリデーションと連動できるので、齟齬がおきないから便利ですね！
- 他にも、`form_for`に`auto_html5_validation: false`を渡したり、コントローラー内の`around_action`で`Html5Validators.enabled = false`を設定することで、この**自動オプション追加機能をオフにする**ことができます。

```rb
= form_for(@user, auto_html5_validation: false) do |f|
  f.text_field :name

/ 元のフォームに戻る
/ => <input type="text" name="user[name]" id="user_name" />
```

```rb
# コントローラー内で以下を設定するだけでも、元のフォームに戻せる。
around_action do |controller, block|
  h5v_enabled_was = Html5Validators.enabled
  Html5Validators.enabled = false if params[:h5v] == 'disable'
  block.call
  Html5Validators.enabled = h5v_enabled_was
end
```

# 分析開始
- ではいよいよ本題。どのような仕組みでこのような機能を実装しているのかをみていきます。

## 起点となるファイル
### `lib/html5_validators.rb`
- 一番最初に呼び出されるファイルです。

```rb
require 'rails'

module Html5Validators
  @enabled = true

  def self.enabled
    @enabled
  end

  def self.enabled=(enable)
    @enabled = enable
  end

  class Railtie < ::Rails::Railtie #:nodoc:
    initializer 'html5_validators' do |app|
      ActiveSupport.on_load(:active_record) do
        require 'html5_validators/active_model/helper_methods'
        require 'html5_validators/active_model/validations'
        require 'html5_validators/active_record/base'
      end
      ActiveSupport.on_load(:action_view) do
        if ActionPack::VERSION::STRING >= '4'
          require 'html5_validators/action_view/form_helpers'
        else
          require 'html5_validators/action_view/form_helpers_rails3'
        end
      end
    end
  end
end
```

#### 理解するのに必要な知識
- [Railtie](https://api.rubyonrails.org/classes/Rails/Railtie.html)

> Railsの「基礎」を成しているクラス。
`initializer`メソッドを呼び出せば、渡したブロックをRailsの初期化時に読み込んでくれる。
つまりここで`ActiveSupport.on_load(:active_record)`などを使えば、**Rails初期化時に任意のライブラリに対して独自の機能拡張をすることができる**。|

#### ここからわかること
- `Html5Validators`モジュールは`enabled`というクラス変数を持っていて、**初期値は`true`に設定してある**と言うこと。
- 先ほど「*`Html5Validators.enabled = false`を設定することで、このプラグインをオフにできる*」という機能を説明したが、そのことからも**このクラス変数はこのプラグインのオンオフスイッチの役割をしていることの予測が立つ**。
- `ActiveRecord`と`ActionView`がロードされた時に、**それぞれモジュールを`require`している**。

## ActiveRecord内に読み込まれているファイル群
- 先ほど`Railtie`内でActiveModel内に読み込まれているファイルの分析です。

### `lib/html5_validators/active_model/helper_methods.rb`
```rb
# Refinementで使う用のモジュール
# バージョン5以前のActiveRecordには今回必要となるdefault_validation_contextメソッドが設定されていないため
# そしてそれ以外のObjectに対しても付与している。
module Html5Validators
  module DafaultValidationContext
    if defined?(::ActiveRecord::Base) && (::ActiveRecord::VERSION::MAJOR < 5)
      refine ::ActiveRecord::Base do
        def default_validation_context
          new_record? ? :create : :update
        end
      end
    end
    refine Object do
      def default_validation_context
        nil
      end
    end
  end
end

# 早速Refinement実行
using Html5Validators::DafaultValidationContext

module ActiveModel
  module Validations
    module HelperMethods
      # attributeにpresenceバリデーションが指定されているかどうかを判定
      def attribute_required?(attribute)
        self.class.validators.grep(PresenceValidator).any? do |v|
          if v.attributes.include?(attribute.to_sym) && (v.options.keys & [:if, :unless]).empty?
            !(on = v.options[:on]) || Array(on).include?(default_validation_context)
          end
        end
      end

      # attributeにmaxlengthバリデーションが指定されているかどうかを判定
      def attribute_maxlength(attribute)
        self.class.validators.grep(LengthValidator).select {|v|
          if v.attributes.include?(attribute.to_sym) && (v.options.keys & [:maximum, :is]).any? && (v.options.keys & [:if, :unless, :tokenizer]).empty?
            !(on = v.options[:on]) || Array(on).include?(default_validation_context)
          end
        }.map {|v| v.options.slice(:maximum, :is)}.map(&:values).flatten.max
      end

      # attributeにminlengthバリデーションが指定されているかどうかを判定
      def attribute_minlength(attribute)
        self.class.validators.grep(LengthValidator).select {|v|
          if v.attributes.include?(attribute.to_sym) && (v.options.keys & [:minimum, :is]).any? && (v.options.keys & [:if, :unless, :allow_nil, :allow_blank, :tokenizer]).empty?
            !(on = v.options[:on]) || Array(on).include?(default_validation_context)
          end
        }.map {|v| v.options.slice(:minimum, :is)}.map(&:values).flatten.min
      end

      # attributeにmaxバリデーション（数値のみ）が指定されているかどうか
      def attribute_max(attribute)
        self.class.validators.grep(NumericalityValidator).select {|v|
          if v.attributes.include?(attribute.to_sym) && (v.options.keys & [:less_than, :less_than_or_equal_to]).any? && (v.options.keys & [:if, :unless, :allow_nil, :allow_blank]).empty?
            !(on = v.options[:on]) || Array(on).include?(default_validation_context)
          end
        }.map {|v| v.options.slice(:less_than, :less_than_or_equal_to)}.map(&:values).flatten.max
      end

      # attributeにminバリデーション（数値のみ）が指定されているかどうか
      def attribute_min(attribute)
        self.class.validators.grep(NumericalityValidator).select {|v|
          if v.attributes.include?(attribute.to_sym) && (v.options.keys & [:greater_than, :greater_than_or_equal_to]).any? && (v.options.keys & [:if, :unless, :allow_nil, :allow_blank]).empty?
            !(on = v.options[:on]) || Array(on).include?(default_validation_context)
          end
        }.map {|v| v.options.slice(:greater_than, :greater_than_or_equal_to)}.map(&:values).flatten.min
      end
    end
  end
end
```

#### 理解するのに必要な知識
- [RubyのRefinement](https://docs.ruby-lang.org/ja/latest/method/Module/i/refine.html)

> 独自の機能拡張を、スコープを決めて設定できるようにするもの。
単純に拡張ファイルをRailsに読み込ませるよりも影響範囲を指定できるため、**玄人Rubistほど使いたがる傾向にある**。

#### 学べること
- `ActiveModel:: Validations`内に、**本来は存在しない独自の`HelperMethods`モジュールを追加して、独自のメソッドを追加している**こと。
- その独自メソッドの中で、`default_validation_context`を使っているのだが、**元々の`default_validation_context`だけでは問題があるため、解消するために`refinement`を使って回避している**こと。

### `lib/html5_validators/active_model/validations.rb`
```rb
# 独自のActiveModel拡張
module Html5Validators
  module ActiveModelExtension
    extend ActiveSupport::Concern

    included do
      cattr_accessor :auto_html5_validation, instance_accessor: false, instance_reader: false, instance_writer: false
    end
  end
end

module ActiveModel
  module Validations
    attr_accessor :auto_html5_validation
  end
end

ActiveModel::Validations.send(:include, Html5Validators::ActiveModelExtension)
```

#### 理解するのに必要な知識
- [cattr_accessor](https://apidock.com/rails/Class/cattr_accessor)

> クラス変数版の`attr_accessor`。~~そんなんあったんや‥‥~~
余談だけども、**インスタンス変数**と**クラスインスタンス変数**、**クラス変数**の違いがわからない人は調べておいた方がいいです。
**最初の方に出てきた`@enabled`はクラスインスタンス変数である**ということをパッと言えると、ワンランク上のRubistになれる~~気がする~~。

#### 　学べること
- `ActiveModel::Validations`のインスタンスメソッドとクラスメソッド両方に、`auto_html5_validation`を追加している。

### `lib/html5_validators/active_record/base.rb`
```rb
# 独自のActiveRecord拡張
module Html5Validators
  module ActiveRecordExtension
    extend ActiveSupport::Concern

    # Future subclasses will pick up the model extension
    module ClassMethods
      def inherited(kls)
        super
        class << kls
          attr_accessor :auto_html5_validation
        end
        kls.auto_html5_validation = true
      end
    end

    included do
      # Existing subclasses pick up the model extension as well
      self.descendants.each do |kls|
        class << kls
          attr_accessor :auto_html5_validation
        end
        kls.auto_html5_validation = true
      end
    end
  end
end

ActiveRecord::Base.send(:include, Html5Validators::ActiveRecordExtension)
```

#### ここからわかること
- `ActiveRecord`に対しても`ActiveModel`同様、クラスに対して`auto_html5_validation`アトリビュートを付与している。（ただしこちらでは初期値が`true`になっている。）

## ActionView内に読み込まれているファイル群
### `lib/html5_validators/action_view/form_helpers.rb`
```rb
# 独自ActionView拡張
# 全部でFormHelperとPresenceValidator、LengthValidator、NumericalityValidatorの四種類
module Html5Validators
  module ActionViewExtension
    # ActionView::Baseに読み込むもの
    module FormHelper
      def form_for(record, options = {}, &block)
        if record.respond_to?(:auto_html5_validation=)
          if !Html5Validators.enabled || (options[:auto_html5_validation] == false)
            record.auto_html5_validation = false
          end
        end
        super
      end
    end

    # これ以降３つのモジュールはActionView::Helpers::Tags内の各バリデーションフィールドモジュールにprependされる。
    module PresenceValidator
      # requiredオプションが与えられているか、バリデーションにpresenseバリデーションがかかっていれば、`required: true`にする。
      def render
        if object.is_a?(ActiveModel::Validations) && (object.auto_html5_validation != false) && (object.class.auto_html5_validation != false)
          @options["required"] ||= @options.fetch(:required) { object.attribute_required?(@method_name) }
        end
        super
      end
    end

    module LengthValidator
      def render
        if object.is_a?(ActiveModel::Validations) && (object.auto_html5_validation != false) && (object.class.auto_html5_validation != false)
          @options["maxlength"] ||= @options[:maxlength] || object.attribute_maxlength(@method_name)
          @options["minlength"] ||= @options[:minlength] || object.attribute_minlength(@method_name)
        end
        super
      end
    end

    module NumericalityValidator
      def render
        if object.is_a?(ActiveModel::Validations) && (object.auto_html5_validation != false) && (object.class.auto_html5_validation != false)
          @options["max"] ||= @options["max"] || @options[:max] || object.attribute_max(@method_name)
          @options["min"] ||= @options["min"] || @options[:min] || object.attribute_min(@method_name)
        end
        super
      end
    end
  end
end


module ActionView
  Base.send :prepend, Html5Validators::ActionViewExtension::FormHelper

  module Helpers
    module Tags
      class TextField
        prepend Html5Validators::ActionViewExtension::NumericalityValidator
        prepend Html5Validators::ActionViewExtension::LengthValidator
        prepend Html5Validators::ActionViewExtension::PresenceValidator
      end

      class TextArea
        prepend Html5Validators::ActionViewExtension::LengthValidator
        prepend Html5Validators::ActionViewExtension::PresenceValidator
      end

      #TODO probably I have to add some more classes here
      [RadioButton, CheckBox, Select, DateSelect, TimeZoneSelect].each do |kls|
        kls.send :prepend, Html5Validators::ActionViewExtension::PresenceValidator
      end
    end
  end
end
```

#### 理解するのに必要な知識
|名前|解説|
|:--|:--|
|`prepend`|`include`と違って、**読み込んだモジュールで元のメソッドを上書きできる**。|

#### ここからわかること
- `form_for`メソッドを拡張して、**オプション項目に`auto_html5_validation`を追加**した、`Html5Validators::ActionViewExtension`を作成している。
- `PresenceValidator（存在バリデーション）`と`LengthValidator（文字列長バリデーション）`、`NumericalityValidator（数値バリデーション）`の３つを拡張して、**フォームをレンダーする時に該当したバリデーションがフィールドにかかっていれば、自動でオプションを追加するようにする機能を追加した**`Html5Validators::ActionViewExtension`を作成している。
- 最後にこれら２つの独自拡張モジュールを該当するフォームモジュールに`prepend`している。

# おわりに
- 全体的にシンプルな構成になっている上、比較的コード量も少ないので、部分部分で知識を補えば、非常にわかりやすいプラグインでした。（~~わかったとは言えない~~）
- 実際に全体を俯瞰した時に、「**独自拡張を作って、元のコードを`prepend`や`include`で上書きしたファイルを作成**」して「**`Railtie`の`initializer`で、該当するライブラリに`require`する**」という流れが明確です。
- プラグイン作りの基本は「**独自拡張を作って元のRailsを壊さないよう上書きする**」ことなのでしょうが、コードリーディングした結果その方法の引き出しの数をいくらか増やすことができました。
- これをきっかけに、せっかくなので他のプラグインも覗いてみようと思えました。
- 非常に勉強になるコードでした！松田さん、ご紹介くださりありがとうございました！