「達人に学ぶDB設計徹底指南書」を読んだ

達人に学ぶDB設計 徹底指南書 初級者で終わりたくないあなたへ

達人に学ぶDB設計 徹底指南書 初級者で終わりたくないあなたへ

「達人に学ぶDB設計徹底指南書」を読んだ。最近、Webアプリケーションを開発する中で設計に問題意識を感じるようになった。ただ、具体的にどこが問題なのかうまく言語化できなくて、というか設計という言葉が何を指しているのかイマイチ分かってなかったので、データベースの設計について本を読んでみた。

内容

  • データベースの設計は「論理設計」→「物理設計」の順に行う。論理設計はデータの要素やデータ間の関係を定める工程を指す。物理設計はデータを格納する物理的な領域や格納方法を定める工程を指す。
  • 論理設計は「エンティティの抽出」→「エンティティの定義」→「正規化」→「ER図の作成」の順に行う。エンティティというのは業務内容に現れるデータの集合体を指す。
  • 物理設計は「テーブル定義」→「インデックス定義」→「ハードウェアのサイジング」→「ストレージの冗長構成決定」→「ファイルの物理配置決定」の順に行う。
  • 正規化というのはデータの形式を正規形にすることであり、正規形というのはデータの冗長性が排除され一貫性と効率性が保持されるデータ形式のこと。異なるエンティティを異なるテーブルに分離することによって第3正規形までは実現できる。
  • 正規化の目的はデータの冗長性を排除して不整合な更新を避けることにある。ただし、正規化はテーブルを分離するため、SQLでjoinを使うことになる。joinはパフォーマンスが悪化するため、正規化とパフォーマンスはトレードオフの関係にある。
  • 「非正規化は最後の手段」というのが筆者の意見である。

感想

  • データベース設計で最初に行うのは「エンティティの抽出」であるということがとても重要なポイントに感じた。
  • いつもRailsでアプリケーションを開発するとき、正規化を意識することはない。Modelを定義することはテーブルを定義することと同義だ。なので、適切にエンティティを定義できていれば、意識せずとも第2正規形、第3正規形を実現できると思う。筆者の「非正規化は最後の手段」という立場にたつと、いつもどおりModelを定義することで正規化を行い、非正規化以外の手段でパフォーマンスを向上させていけばいいということになる。つまり、今まで通りでいいってことか。
  • 結局、「いかにしてエンティティを抽出・定義するか」という問題を考える必要がありそうだ。

ブランチごとにDB切り替えるヤツ作った

Gitのブランチ名をもとにActiveRecordが接続するDBを切り替えるRubygemを作った。

使い方

group :development do
  gem "brancher"
end

Gemfileに書いてbundle installするだけ。あとは自動的にブランチごとに別々のDBが使われるようになる。

config/database.ymlでdevelopment環境のDB名をsample_app_devと指定していた場合、masterブランチならsample_app_dev_masterが使われるし、some_featureブランチならsample_app_dev_some_featureが使われる。

問題意識

複数のブランチを移りながら開発していると、migrationを実行したブランチとしてないブランチでDBのスキーマが不整合になってエラーをおこすことがよくある。そのたびにrake db:migrateだったりrake db:resetだったりrake db:schema:loadしたりするのが非常に面倒だった。そういった問題を解決するためにブランチごとにDBを分けられるツールを作った。

どう実現しているか

やっていることはconfig/database.ymlをロードしたオブジェクトをいじっているだけ。これをいじるタイミングは2つある。Railsアプリケーションの初期化時とdb:load_configタスクだ。

Railsアプリケーションを初期化する際、ActiveRecord::Baseがロードされたあとにestablish_connectionが実行される。このメソッドはconfig/database.ymlに基いてDBとのコネクションを接続するものなので、これが実行される前にDB名をブランチ名に従っていじってあげる必要がある。実際に実行されているコードは以下の通りだ。

# lib/active_record/railtie.rb

initializer "active_record.initialize_database" do |app|
  ActiveSupport.on_load(:active_record) do
    self.configurations = Rails.application.config.database_configuration

    begin
      establish_connection
      # ...
    end
  end
end

Rails.application.config.database_configurationはconfig/database.ymlの中身をERBで展開してYAMLとしてロードしたHashオブジェクトだ。これがself.configurationsにセットされてestablish_connectionが実行される(ここでのselfActiveRecord::Base)。よって、この初期化処理が実行される前にRails.application.config.database_configurationをいじればいい。

初期化処理の一連の流れに割り込むにはRails::Initializable.initializerメソッドのオプションを使う。そして、その中でRails.application.config.database_configurationの中身を上書きする。

# lib/brancher/railtie.rb

initializer "brancher.rename_database", before: "active_record.initialize_database" do
  Rails::Application::Configuration.send(:prepend, DatabaseConfigurationRenaming)
end

次に、db:load_configタスク内でもconfig/database.ymlをいじる必要がある。なぜかというと、rake db:createなどの一部のRakeタスクは上述の初期化処理が実行されないからだ。environmentタスクに依存しているタスクであれば、environmentタスク内で初期化処理が行われるため問題ない。一方、db:load_configタスクは(おそらく)すべてのDBに関連するRakeタスクが依存しているため、ここでDB名をいじってあげればいい。

# lib/brancher/railtie.rb

rake_tasks do
  namespace :db do
    task :load_config do
      DatabaseRenameService.rename!(ActiveRecord::Base.configurations)
    end
  end
end

Rakeタスクは通常のメソッドとは異なり、同名のタスクを定義しても上書きされることはない。先に定義された順に同名のタスクが実行される。

所感

上のような初期化処理の仕組みやRakeタスクの追加などは以前のエントリなどでRailsの内部を読み理解を深めることによって実現することができた。ブラックボックスの中身が見えてくると、こういったRails自体に関わる便利ツールを簡単に作ることができてしまう。引き続きRailsソースコードリーディングは続けていきたい。

`rails s`読んだ

rails sRailsサーバーが起動するまでに何が起きているのかを紐解くことでRailsとは何なのかを理解していきたい。今回読んでいくソースコードのコミットは2d9b9fb5b5f6015e66d3ad5cb96bc1ba117fd626だ。

TL;DR

  • bin/rails sがユーザーによって実行される。
    • Gemfileで管理されるrubygemをrequireする。
    • Rails::CommandsTasks#serverを実行する。
      • config/application.rbをrequireする。
        • Railsを構成する各rubygemのrailtieをrequireする。
          • 各rubygemのinitializerが登録される。
      • config.ruが実行される。
        • 登録されたinitializerが実行される。
        • RailsアプリケーションがRackアプリケーションとして起動する。

コマンドの実行

まずbin/railsを見る。bin/railsrails newを実行したときに生成されるのだが、このひな形はrailties/lib/rails/generators/rails/app/templates/bin/railsにある。

# bin/rails

#!/usr/bin/env ruby
APP_PATH = File.expand_path('../../config/application', __FILE__)
require_relative '../config/boot'
require 'rails/commands'

config/boot.rbとrails/commands.rbを見る。

# config/boot.rb

ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)

require 'bundler/setup' # Set up gems listed in the Gemfile.
  • config/boot.rbはGemfileにあるgemをrequireするようだ。
# railties/lib/rails/commands.rb

ARGV << '--help' if ARGV.empty?

aliases = {
  "g"  => "generate",
  "d"  => "destroy",
  "c"  => "console",
  "s"  => "server",
  "db" => "dbconsole",
  "r"  => "runner"
}

command = ARGV.shift
command = aliases[command] || command

require 'rails/commands/commands_tasks'

Rails::CommandsTasks.new(ARGV).run_command!(command)
  • rails sと実行するとaliasesの中から"server"という文字列を取得してrails serverを実行することになる。

rails/commands/commands_tasksを見る。

# railsties/lib/rails/commands/commands_tasks.rb

module Rails
  class CommandsTasks
    # ...

    def initialize(argv)
      @argv = argv
    end

    def run_command!(command)
      command = parse_command(command)
      if COMMAND_WHITELIST.include?(command)
        send(command)
      else
        write_error_message(command)
      end
    end

    # ...
  end
end
  • #parse_command--version--helpをそれぞれ"version""help"というコマンドに変換するもの。それ以外はそのまま返す。
  • COMMAND_WHITELISTに含まれていれば実行、そうでなければエラーを出力する。
  • 今回は"server"commandに入るのでsend("server")が実行され、#serverが実行されることになる。
# railsties/lib/rails/commands/commands_tasks.rb

module Rails
  class CommandsTasks
    # ...

    def server
      set_application_directory!
      require_command!("server")

      Rails::Server.new.tap do |server|
        require APP_PATH
        Dir.chdir(Rails.application.root)
        server.start
      end
    end

    # ...
  end
end
  • #set_application_directory!はconfig.ruがないディレクトリからでもrails sを実行できるようにするためのものらしい。
  • APP_PATHbin/railsの中で代入されたconfig/application.rbなので、require "config/application"server.startの前に実行している。

設定の読み込み

# config/application.rb

require File.expand_path('../boot', __FILE__)

require 'rails/all'

Bundler.require(*Rails.groups)

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • 前述の通り、config/boot.rbはrequire "bundler/setup"を実行しておりGemfile.lockに記載されたバージョンのrubygemをrequireしている。

rails/all.rbを見る。

# railties/lib/rails/all.rb

require "rails"

%w(
  active_record
  action_controller
  action_view
  action_mailer
  active_job
  rails/test_unit
  sprockets
).each do |framework|
  begin
    require "#{framework}/railtie"
  rescue LoadError
  end
end
  • Railsを構成する各rubygemのrailtieをrequireしている。

rails.rbを見る。

# railsties/lib/rails.rb

module Rails
  # ...

  class << self
    # ...
  end
end
  • ここにはRails.application, Rails.configuration, Rails.envなどの重要なメソッドが定義されているため、登場次第また見ていくことにする。

rails/all.rbとrails.rbについて見たので、config/applicationに戻る。

# config/application.rb

# ...

Bundler.require(*Rails.groups)

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • Rails.groupsは上述したrails.rbで定義されているのでさっそく見る。
# railties/lib/rails.rb

module Rails
  class << self
    # ...

    def env
      @_env ||= ActiveSupport::StringInquirer.new(ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development")
    end

    # ...

    def groups(*groups)
      hash = groups.extract_options!
      env = Rails.env
      groups.unshift(:default, env)
      groups.concat ENV["RAILS_GROUPS"].to_s.split(",")
      groups.concat hash.map { |k, v| k if v.map(&:to_s).include?(env) }
      groups.compact!
      groups.uniq!
      groups
    end

    # ...
  end
end
  • Rails.groupsRails.envの値に合わせてBundlerが読みこむべきgroupを返す。
  • Rails.env環境変数"RAILS_ENV"または"RACK_ENV"から実行環境を返す。

config/application.rbに戻る。

# config/application.rb

# ...

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • SampleApp::ApplicationRails::Applicationを継承するとき、以下のような実装によってRails::Application.inheritedが呼ばれ、Rails.app_classSampleApp::Applicationとなる。
# railties/lib/rails/application.rb

module Rails
  class Application < Engine
    class << self
      def inherited(base)
        super
        Rails.app_class = base
        add_lib_to_load_path!(find_root(base.called_from))
      end
    end
  end
end

サーバーの起動

サーバー起動前にどういった設定を読み込んでいるか見たので、サーバーの起動について詳細に見ていく。

# railsties/lib/rails/commands/commands_tasks.rb

module Rails
  class CommandsTasks
    # ...

    def server
      set_application_directory!
      require_command!("server")

      Rails::Server.new.tap do |server|
        require APP_PATH
        Dir.chdir(Rails.application.root)
        server.start
      end
    end

    # ...
  end
end
  • #require_command!("server")require "rails/commands/server"をしている。

Rails::Serverrails/commands/server.rbで定義されているので見る。

# railsties/lib/rails/commands/server

module Rails
  class Server < ::Rack::Server
    # ...

    def initialize(*)
      super
      set_environment
    end

    # ...

    def start
      print_boot_information
      trap(:INT) { exit }
      create_tmp_directories
      log_to_stdout if options[:log_stdout]

      super
    ensure
      puts 'Exiting' unless @options && options[:daemonize]
    end

    # ...
  end
end
  • スーパークラス::Rack::Serverがサーバー起動において主な役割を果たしているようだ。
  • これ以降はRackのソースコードを追うことになるが本題から反れるので、結論だけ言うとconfig.ruが実行されることになる。

config.ruを見る。

# config.ru

require ::File.expand_path('../config/environment', __FILE__)
run Rails.application
  • config/environment.rbを読み込んでいる。
  • その後Rails.applicationをrackアプリケーションとして実行している。

とりあえずconfig/environment.rbを見る。

# config/environment.rb

require File.expand_path('../application', __FILE__)
Rails.application.initialize!
  • config/application.rbは既に読み込まれているはず。

Rails.application.initialize!について見ていくため、まずはRails.applicationの定義を見る。

# railties/lib/rails.rb

module Rails
  class << self
    def application
      @application ||= (app_class.instance if app_class)
    end
  end
end
  • app_classは、config/application.rbでRails::Applicationのサブクラスが定義されたときにそのサブクラスが代入される。
  • SampleApp::Application.instanceが呼ばれているが、これのメソッドRails::Applicationに定義されていると思われる。

Rails::Applicationを見る。

# railties/lib/rails/application.rb

module Rails
  class Application < Engine
    class << self
      def instance
        super.run_load_hooks!
      end
    end

    def run_load_hooks!
      return self if @ran_load_hooks
      @ran_load_hooks = true
      ActiveSupport.run_load_hooks(:before_configuration, self)

      @initial_variable_values.each do |variable_name, value|
        if INITIAL_VARIABLES.include?(variable_name)
          instance_variable_set("@#{variable_name}", value)
        end
      end

      instance_eval(&@block) if @block
      self
    end
  end
end
  • SampleApp::Application.instance内でsuper.run_load_hooks!が呼ばれている。このsuperスーパークラスで定義された.instanceを呼んでおり、スーパークラスをたどるとRails::Railtie.instanceが呼ばれていることがわかる。これはそのままnewを呼んでインスタンスを返すだけだ。なので、super.run_load_hooks!というのはSampleApp::Application#run_load_hooks!を指す。
  • SampleApp::Application#run_load_hooks!Rails::Applicationで上のように定義されており自分自身を返す。中でActiveSupport.run_load_hooks(:before_configuration, self)を呼んでおり、これによって:before_configurationをフックとして登録しておいた処理が実行される。
  • 結局、Rails.applicationSampleApp::Applicationインスタンスということになる。

initializerの実行

config/environment.rbに戻る。

# config/environment.rb

require File.expand_path('../application', __FILE__)
Rails.application.initialize!
  • Rails.application.initialize!はつまりSampleApp::Application#initialize!ということ。

Rails::Applicationを見る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def initialize!(group=:default) #:nodoc:
      raise "Application has been already initialized." if @initialized
      run_initializers(group, self)
      @initialized = true
      self
    end
  end
end
  • #run_initializersRails::Initializableで以下のように定義されている。
# railties/lib/rails/initializable.rb

module Rails
  module Initializable
    def run_initializers(group=:default, *args)
      return if instance_variable_defined?(:@ran)
      initializers.tsort_each do |initializer|
        initializer.run(*args) if initializer.belongs_to?(group)
      end
      @ran = true
    end
  end
end
  • #initializersRails::Applicationで以下のように定義されている。
# railties/lib/rails/application.rb

module Rails
  class Application
    def initializers
      Bootstrap.initializers_for(self) +
      railties_initializers(super) +
      Finisher.initializers_for(self)
    end
  end
end
  • Bootstrap.initializers_forとかFinisher.initializers_forRails::Initializableモジュールで以下のように定義されている。
# railties/lib/rails/initializable.rb

def initializers_for(binding)
  Collection.new(initializers_chain.map { |i| i.bind(binding) })
end
  • Initializable::Collectionを初期化しているようだ。
# railties/lib/rails/initializable.rb

class Collection < Array
  include TSort

  alias :tsort_each_node :each
  def tsort_each_child(initializer, &block)
    select { |i| i.before == initializer.name || i.name == initializer.after }.each(&block)
  end

  def +(other)
    Collection.new(to_a + other.to_a)
  end
end
  • TSortモジュールはRubyの標準モジュールで、依存関係を解決する順番に並び替える(=トポロジカルソート)実装を提供する。#tsort_each_node#tsort_each_childの2つを実装する必要がある。
  • #tsort_each_nodeはすべての要素を走査するメソッド#tsort_each_childは子要素を走査するメソッド
  • Collection#tsort_each_childでは与えられたinitializerの前後のinitlaizerに対してブロックを実行する。

Initializable.initializers_forに戻る。

# railties/lib/rails/initializable.rb

def initializers_chain
  initializers = Collection.new
  ancestors.reverse_each do |klass|
    next unless klass.respond_to?(:initializers)
    initializers = initializers + klass.initializers
  end
  initializers
end

def initializers_for(binding)
  Collection.new(initializers_chain.map { |i| i.bind(binding) })
end
  • 続いて.initializers_chainを見る。
  • .ancestorsスーパークラスincludeしているモジュールを直接の親から順に並べて配列で返す。
  • .ancestors.reverse_eachなので、最も遠いクラスまたはモジュールから順にinitializersを取得して一つのCollectionに連結させている。

initializers_chainの要素はおそらくInitializerインスタンスだと思われるので、Initializer#bindを見る。

# railties/lib/rails/Initializable.rb

class Initializer
  def bind(context)
    return self if @context
    Initializer.new(@name, context, @options, &block)
  end
end
  • Initializer#initializeの第2引数は、Initializer#runinstance_execのレシーバとして利用される。今回の場合、これはRails::Applicationインスタンスとなる。

Rails::Application#initializersに戻る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def initializers
      Bootstrap.initializers_for(self) +
      railties_initializers(super) +
      Finisher.initializers_for(self)
    end
  end
end

#railties_initializersがまだ残っているので見る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def ordered_railties
      @ordered_railties ||= begin
        order = config.railties_order.map do |railtie|
          # ...
        end

        all = (railties - order)
        all.push(self)   unless (all + order).include?(self)
        order.push(:all) unless order.include?(:all)

        index = order.index(:all)
        order[index] = all
        order
      end
    end

    def railties_initializers(current)
      initializers = []
      ordered_railties.reverse.flatten.each do |r|
        if r == self
          initializers += current
        else
          initializers += r.initializers
        end
      end
      initializers
    end
  end
end
  • config.railties_orderはデフォルトでは[:all]を返すようになっているが、ここを変更することで実行するRailtieのinitializerの順番を変更することもできるようだ。
  • #ordered_railtiesが返すのはall = (railties - order)の部分なので、あとで詳しく#railtiesについて見る。
  • ある順番でソートされた各Railtieのinitializersを結合して返している。

#railtiesRails::Engineから継承されたメソッドなので見る。

# railties/lib/rails/engine.rb

def railties
  @railties ||= Railties.new
end

Rails::Engine::Railtiesを見る。

# railties/lib/rails/engine/railties.rb

def initialize
  @_all ||= ::Rails::Railtie.subclasses.map(&:instance) +
    ::Rails::Engine.subclasses.map(&:instance)
end
  • ::Rails::RailtieまたはRails::Engineのサブクラスをすべて返している(!)

railties_initializersに戻る。

# railties/lib/rails/application.rb

def railties_initializers(current)
  initializers = []
  ordered_railties.reverse.flatten.each do |r|
    if r == self
      initializers += current
    else
      initializers += r.initializers
    end
  end
  initializers
end
  • ordered_railties::Rails::Railtieまたは::Rails::Engineのサブクラスすべてだということがわかった。
  • よって、それらのinitializersをすべて連結したものを返していることになる。

Rails::Application#initializersについて見たので、#run_initializersに戻る。

# railties/lib/rails/initializable.rb

def run_initializers(group=:default, *args)
  return if instance_variable_defined?(:@ran)
  initializers.tsort_each do |initializer|
    initializer.run(*args) if initializer.belongs_to?(group)
  end
  @ran = true
end
  • initializersInitializable::Collectionインスタンスなので、#tsort_eachによって依存関係を解決する順番で#eachを行う。

Initializer#runを見る。

# railties/lib/rails/initializable.rb

class Initializer
  def run(*args)
    @context.instance_exec(*args, &block)
  end
end
  • @context#bindによってセットされる。
  • blockInitializerが初期化される際に渡されたブロックだ。このblock#bindによってセットされた@contextをレシーバとして実行される。
  • 今回の場合、@contextRails::Applicationインスタンスをレシーバとしてblockが実行されることになる。

続いて、実行されるInitializerがどのように初期化されて登録されているのか見ていく。これはRails::Initializable::ClassMethods.initializerによって行われる。

# railties/lib/rails/initializable.rb

def initializer(name, opts = {}, &blk)
  raise ArgumentError, "A block must be passed when defining an initializer" unless blk
  opts[:after] ||= initializers.last.name unless initializers.empty? || initializers.find { |i| i.name == opts[:before] }
  initializers << Initializer.new(name, nil, opts, &blk)
end
  • opts[:after]Initializerインスタンス間の依存関係の解決に利用される。initializerで特に指定しなければ既存のinitializersの最後の要素がinitializer#afterになる。

Railsアプリケーションの起動

initializersの実行について一通り眺めたのでこれでconfig/environment.rbを読んだことになる。config.ruに戻る。

# config.ru

require ::File.expand_path('../config/environment', __FILE__)
run Rails.application
  • ようやくrun Rails.applicationでアプリケーションを起動できる。
  • Rails.applicationSampleApp::Applicationインスタンスであることは上述の通り。

参考

Qiita:TeamのテンプレートとJSONからテキストを組み立てるヤツ

あけましておめでとうございます。

プログラミング初めとしてnaoty/qiita-buildという簡単なrubygemを作りました。Qiita:Teamで下のようなテンプレートがあったときに

# 自己紹介
* 氏名: %{fullname}
* ニックネーム: %{nickname}
* 居住地: %{location}
* 生年月日: %{birthday}

標準入力でJSON文字列を渡すと、変数の中身をそれで展開するだけ。

$ echo '{"fullname": "Naoto Kaneko", "nickname": "naoty", "location": "Tokyo", "birthday": "1987/6/2"}' | qiita-build -t <TEAM> -a <ACCESS TOKEN> <TEMPLATE ID>
# 自己紹介
* 氏名: Naoto Kaneko
* ニックネーム: naoty
* 居住地: Tokyo
* 生年月日: 1987/6/2

とある用途で使いたくて作ったものの、Qiita:Team周りのワークフローを自動化するときに便利そうだなと思ったのでrubygemにしました。

本年も宜しくお願い致します。

activeadmin読んだ

activeadmin/activeadminを初めて使うことになったので、どういう仕組みになっているのか調べてみた。

TL;DR

  • rails g active_admin:installを実行するとlib/generators/active_admin/install_generator.rbが実行され、ActiveAdmin.routes(self)がconfig/routes.rbに追加される。
  • app/admin/以下にあるResource定義ファイル内で実行されるActiveAdmin.registerでは、ActiveAdmin::Resourceインスタンスが生成され、動的にResourceごとのcontrollerが生成される。それらはすべてActiveAdmin::ResourceControllerを継承している。
  • config/routes.rbに追加されたActiveAdmin.routes(self)は内部でapp/admin/以下のファイルをロードし(このタイミングで上述のActiveAdmin.registerが実行される)、ActiveAdmin::Resourceインスタンスから動的にroutingが定義される。

長いので、以下ActiveAdmin::AA::と略記する。

Generator

Active Adminをセットアップするにはまずrails g active_admin:installを実行する。

このとき、lib/generators/active_admin/install_generator.rbに定義されたRails::Generators::Baseのサブクラスにあるpublicメソッドが上から順番に実行される。Railsはlib/generators/**/*_generator.rbにマッチするファイルに定義されたRails::Generators::BaseのサブクラスをRails Generatorとして実行することができる。

# lib/generators/active_admin/install_generator.rb

module ActiveAdmin
  module Generators
    class InstallGenerator < ActiveRecord::Generators::Base
      # ...

      def setup_routes
        if options[:user]
          inject_into_file "config/routes.rb", "\n  ActiveAdmin.routes(self)", after: /devise_for .*, ActiveAdmin::Devise\.config/
        else
          route "ActiveAdmin.routes(self)"
        end
      end

      # ...
    end
  end
end

いくつかメソッドが定義されている中でsetup_routesを見ると、config/routes.rbにActiveAdmin.route(self)を追記しているようだ。selfRails.application.routes.draw do ... endのブロック内でのselfなのでActionDispatch::Routing::Mapperインスタンスを表している。

Register a resource

Generatorでファイルの追加と変更を行ったあとは、管理画面で管理するResourceを作成する。例えば、rails g active_admin:resource Postを実行すると以下のようなapp/admin/post.rbが生成される。

# app/admin/post.rb

ActiveAdmin.register Post do
end

このブロックの中にviewやcontrollerの設定を追加していくのだけど、まずActiveAdmin.registerの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  class << self
    # ...

    def application
      @application ||= ::ActiveAdmin::Application.new
    end

    # ...

    delegate :register, to: :application

    # ...
  end
end

ActiveSupportが拡張したメソッドdelegateによって、ActiveAdmin.registerの処理は実際にはAA::Application#registerが行っている。

# lib/active_admin/application.rb

def register(resource, options = {}, &block)
  ns = options.fetch(:namespace){ default_namespace }
  namespace(ns).register resource, options, &block
end

options[:namespace]がなければdefault_namespaceつまり:adminnsに入る。#namespacenamespaces[ns]があればそれを返し、なければAA::Namespaceインスタンスを初期化しnamespacesに追加した上で返す。よって、AA::Namespace#registerが処理が渡っている。

# lib/active_admin/namespace.rb

def register(resource_class, options = {}, &block)
  config = find_or_build_resource(resource_class, options)

  register_resource_controller(config)
  parse_registration_block(config, resource_class, &block) if block_given?
  reset_menu!

  ActiveAdmin::Event.dispatch ActiveAdmin::Resource::RegisterEvent, config

  config
end

find_or_build_resourceAA::Resourceインスタンスを返す。#register_resource_controllerは以下のように定義されており、Resourceインスタンスから動的にAA::ResourceControllerを継承するResourceごとのcontrollerを定義している。

# lib/active_admin/namespace.rb

def register_resource_controller
  eval "class ::#{config.controller_name} < ActiveAdmin::ResourceController; end"
  config.controller.active_admin_config = config
end

parse_registration_blockは上述の例のapp/admin/post.rbでActiveAdmin.registerに渡されていたブロックを評価する部分だと思う。ブロックの中身を独自のDSLとして評価してカスタマイズを行っていると思う。

Routing

Generatorによってconfig/routes.rbに追加されたActiveAdmin.routesの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  # ...

  def application
    @application ||= ::ActiveAdmin::Application.new
  end

  # ...

  delegate :routes, to: :application

  # ...
end

delegateActiveSupportが拡張しているメソッドで、メソッドの呼び出しをtoで指定したオブジェクトに委譲する。なので、ActiveAdmin.routesは実際にはAA::Application#routesを指している。

# lib/active_admin/application.rb

def routes(rails_router)
  load!
  router.apply(rails_router)
end

load!はapp/admin/**/*.rbをKernel.loadする。このとき上述したapp/admin/post.rbのようなResource定義ファイルがロードされる。そして、ActiveAdmin.registerが実行され各Resourceのcontrollerが定義される。

routerRouterインスタンスなので、Router#applyを調べる。

# lib/active_admin/router.rb

def apply(router)
  define_root_routes router
  define_resource_routes router
end

まずdefine_root_routesは以下のように定義されている。

# lib/active_admin/router.rb

def define_root_routes(router)
  router.instance_exec @application.namespaces.values do |namespaces|
    namespaces.each do |namespace|
      if namespace.root?
        root namespace.root_to_options.merge(to: namespace.root_to)
      else
        namespace namespace.name do
          root namespace.root_to_options.merge(to: namespace.root_to)
        end  
      end
    end
  end
end

このrouterActionDispatch::Routing::Mapperであり、@application.namespaces.valuesAA::Namespaceインスタンスの配列だ。

ActiveAdmin.registerに特にoptionを指定しない場合、namespace.root?trueとなる。namespace.root_to_optionsnamespace.root_toがどこで定義されているのか不明。。。なんだけど、AA::Application内にこれらが定義されており、root_to_options{}root_to"dashboard#index"となっている。どのようにしてAA::Namespaceにそれらが定義されるのか不明ではあるが、おそらくこれらの値が使われるのだと思う。よって、結局このメソッドroot to: "dashboard#index"としているだけだ。

# lib/active_admin/router.rb

def define_resource_routes(router)
  router.instance_exec @application.namespaces, self do |namespaces, aa_router|
    resources = namespaces.value.flat_map{ |n| n.resources.values }
    resources.each do |config|
      routes = aa_router.resource_routes(config)

      # ...

      instance_exec &routes
    end
  end
end

configは先述したAA::Resourceインスタンスだ。aa_routerAA::RouterインスタンスなのでAA::Router#resource_routesを見る。

# lib/active_admin/router.rb

def resource_routes(config)
  Prox.new do
    build_route = proc{ |verbs, *args|
      [*verbs].each{ |verb| send verb, *args }
    }
    build_action = proc{ |action|
      build_route.call(action.http_verb, action.name)
    }
    case config
    when ::ActiveAdmin::Resource
      resources config.resource_name.route_key, only: config.defined_actions do
        member do
          config.member_actions.each &build_action
        end

        collection do
          config.collection_actions.each &build_action
          post :batch_action if config.batch_action_enabled?
        end
      end
    when ::ActiveAdmin::Page
      # ...
    else
      # ...
    end
  end
end

このメソッドで返されるProcオブジェクトはActionDispatch::Routing::Mapperインスタンスのコンテキストでinstance_execされるため、要するにこのProcオブジェクト内の処理がそのままconfig/routes.rb内のroutingの設定となる。Resourceインスタンスの情報から動的にroutingを組み立てているようだ。

所感

軽く触ってみたけど、Resource定義ファイルに独自DSLでviewを書いていくのが非常にカスタマイズが大変だし覚えることが多そうだな、あまり筋がよくなさそうという印象を受けた。

で、調べてみた結果、Resource定義ファイルから動的にcontrollerとかroutingとかを定義していて、それらをカスタマイズするのに独自DSLを使うという構図になっていることが分かった。管理画面って、ビジネスサイドの要求によってどんどんカスタマイズが必要になるので、カスタマイズに独自のDSLを覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。

RailsのReloaderの仕組み

Resqueのworker上で実行されるコードが古いまま更新されないというような問題があって、意味もわからず書いたコードでなんとかその場を収めたんだけど、気持ち悪いのでRailsでいかにしてコードが更新されるのか調べてみることにした。

TL;DR

未定義の定数が参照されると、ActiveSupport::ModuleConstMissingによって拡張されたModule#const_missingが呼ばれる。命名規則に基いて定数名からファイル名が推測され、autoload_pathsに存在するファイルを見つける。そのファイルがロード済みであればそこで終了。ロード済みでなければKernel.loadでロードし、ロードされた定数は配列で管理される。

development環境では、Railsのmiddleware stackにActionDispatch::Reloaderというmiddlewareがあり、リクエストごとにファイルの最終更新日時を確認し、変更されていればActiveSupport::Dependencies.clearを呼ぶ。これによって、ロード済みのファイルが空っぽになり、ロードされた定数もすべて削除される(=未定義状態に戻る)。なので、ファイルが変更されるたびにconst_missingから始まる一連のフローが起こり、Kernel.loadによって最新のコードがロードされるようになっている。

以下は、上述の結論に至るまでのソースコードリーディングのメモです。分かりにくいかも。

ActionDispatch::Reloader

ActionDispatch::ReloaderはRackミドルウェアなので、#callの中で初期化時に受け取った他のRackミドルウェア#callを呼んでいる。その前後でリロードに関する処理を実行しているはずだ。

def call(env)
  @validated = @condition.call
  prepare!

  response = @app.call(env)
  response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! }

  response
rescue Exception
  cleanup!
  raise
end

def prepare!
  run_callbacks :prepare if validated?
end

def cleanup!
  run_callbacks :cleanup if validated?
ensure
  @validated = true
end

次のRackミドルウェアが処理を行う前後でprepare!cleanup!を呼んでいる。その中身はrun_callbacksを呼んでいる。これはActiveSupport::Callbacksで定義されているメソッドで、set_callbacksで登録されたcallbackを実行する。なので、:prepare:cleanupというイベントに対してどこかで登録されたcallbackがされている。このcallbackの登録を行うメソッドActionDispatch::Reloaderに含まれている。

def self.to_prepare(*args, &block)
  # ...
  set_callbacks(:prepare, *args, &block)
end

def self.to_cleanup(*args, &block)
  # ...
  set_callbacks(:cleanup, *args, &block)
end

この2つのメソッドを使ってcallbackの登録が行われている。これらを呼び出している箇所を探すと、Rails::Application::Finisherで呼ばれていることがわかる。

Rails::Application::Finisher

initializer :set_clear_dependencies_hook, group: :all do
  callback = lambda do
    ActiveSupport::DescendantsTracker.clear
    ActiveSupport::Dependencies.clear
  end

  if config.reload_classes_only_on_change
    reloader = config.file_watcher.new(*watchable_args, &callback)
    self.reloaders << reloader

    ActionDispatch::Reloader.to_prepare(prepend: true) do
      reloader.execute
    end
  else
    ActionDispatch::Reloader.to_cleanup(&callback)
  end
end

initializerRails::Initializableで定義されているメソッドRailsの初期化時に実行される処理を登録することができる。つまり、Railsの初期化時に:prepareまたは:cleanupのcallbackを登録しているということになる。

reload_classes_only_on_changeという設定はデフォルトでtrueになっていて、依存するファイルが変更されたときだけクラスを再読み込みするかどうかを制御する。file_watcherはデフォルトではActiveSupport::FileUpdateCheckerを指している。つまり、デフォルトでは、:prepareのときにActiveSupport::FileUpdateChecker#executeが実行されるように設定されていることになる。

ActiveSupport::FileUpdateCheckerは初期化時に渡されたファイルを配列として受け取り、またそれらが更新されたときに実行されるブロックを受け取る。#executeはファイルが更新されているかどうかに関わらずブロックを実行する。ここで実行されるのは、以下のブロックとなる。

callback = lambda do
  ActiveSupport::DescendantsTracker.clear
  ActiveSupport::Dependencies.clear
end

ここまでをまとめると、リクエストを受けるごとに上の2つのメソッドが実行されコードのリロードが行われるということになる。

ActiveSupport::DescendantsTracker

def clear
  if defined? ActiveSupport::Dependencies
    @@direct_descendants.each do |klass, descendants|
      if ActiveSupport::Dependencies.autoloaded?(klass)
        @@direct_descendants.delete(klass)
      else
        descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) }
      end
    end
  else
    @@direct_descendants.clear
  end
end

@@direct_descendantsの中身を消去しているようだ。これはHashであり、中身がキーがクラスで、値がそのクラスを継承したサブクラスの配列となっている。Class#inheritedをoverrideしており、ActiveSupport::DescendantsTrackerextendしているクラスを継承したタイミングで@@direct_descendantsに追加される。ActiveSupport::DescendantsTrackerは例えばActiveRecord::Baseextendされているため、ActiveRecord::Baseのサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendantsから取得できる。これを利用しているのが先述したActiveSupport::Callbacksで、callbackをサブクラスから親クラスへ辿っていくときに利用されている。

ActiveSupport::Dependencies

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

loadedloadingはクラス変数@@loadedおよび@@loadingへのアクセサでmattr_accessorによって定義されている。そして、これらのクラス変数の実体はSetオブジェクトだ。

次にloadedloadingにいつ何が追加されるのか調べると、ActiveSupport::Dependencies.require_or_loadというメソッドで呼ばれている。

def require_or_load(file_name, const_path = nil)
  # ...
  file_name = $` if file_name =~ /\.rb\z/
  expanded = File.expanded_path(file_name)
  return if loaded.include?(expanded)

  loaded << expanded
  loading << expanded

  begin
    if load?
      # ...

      load_args = ["#{file_name}.rb"]
      load_args << const_path unless const_path.nil?

      if !warnings_on_first_load or history.include?(expanded)
        result = load_file(*load_args)
      else
        enable_warnings { result = load_file(*load_args) }
      end      
    else
      # ...
    end
  rescue Exception
    loaded.delete expanded
    raise
  ensure
    loading.pop
  end

  history << expanded
  result
end

loadedloadingに追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したらloadingからは削除されるようだ。loadingは読み込み中の再読み込みを防ぐために一時的に利用される変数らしい。一方、loadedは既にロード済みかどうかをチェックして、ロード済みであればrequire_or_loadを中断させるために使われているようだ。実際のロードの処理はload_fileで行われるようだ。ActiveSupport::Dependencies.clearによってloadedが空になると、require_or_load内で再度load_fileを実行することになる。

def load_file(path, const_paths = loadable_constants_for_path(path))
  # ...
  const_paths = [const_paths].compact unless const_paths.is_a? Array
  parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object }

  result = nil
  newly_defined_paths = new_constants_in(*parent_paths) do
    result = Kernel.load path
  end

  autoloaded_constants.concat newly_defined_paths unless load_once_path?(path)
  autoloaded_constants.uniq!
  # ...
  result
end

const_pathsapp/models/user.rbapp/controllers/users_controller.rbいうファイルがあれば"User""UsersController"という表す文字列を含む配列となる。parent_pathsconst_pathsの中で"Admin::UsersController"のようなネストするものと"::Object"を抽出した配列となる。new_constants_inは渡したブロックを実行し、その中で新たにロードされた定数を返す。なので、Kernel.loadがとりあえず実行されるようだ。

話を少し戻してrequire_or_loadはどこで呼ばれているかを調べると、load_missing_constantで呼ばれており、さらにこのメソッドActiveSupport::ModuleConstMissing#const_missingで呼ばれている。

module ModuleConstMissing
  # ...

  def const_missing(const_name)
    from_mod = anonymous ? guess_for_anonymous(const_name) : self
    Dependencies.load_missing_constant(from_mod, const_name)
  end
end

そしてこのmoduleは以下のようにしてModuleクラスにincludeされるため、デフォルトのconst_missingの挙動をoverrideすることになる。

module ActiveSupport
  module Dependencies
    # ...

    def hook!
      Object.class_eval { include Loadable }
      Module.class_eval { include ModuleConstMissing }
      Exception.class_eval { include Blamable }
    end

    # ...
  end
end

ActiveSupport::Dependencies.hook!

つまり、定義されていない定数を参照する→const_missingload_missing_constantrequire_or_loadload_fileという順番で呼ばれることになる。

ここで、ActiveSupport::Dependencies.clearの定義に戻る。

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

見ていなかったremove_unloadable_constants!について見ていく。

def remove_unloadable_constants!
  autoloaded_constants.each { |const| remove_constant const }
  autoloaded_constants.clear
  Reference.clear!
  explicitly_unloadable_constants.each { |const| remove_constant const }
end

autoloaded_constantsは上述のload_fileでロードされた定数を含む配列だ。remove_constantはその名の通り定数を削除するメソッドで内部でModule#remove_constを呼んでいる。

Qiita API v2のSwiftクライアントを書き始めた

昨日、Qiita API v2 Hackathonに参加してきた。

僕はハッカソンでnaoty/qiita-swiftを作った。Qiita API v2のSwiftクライアントだ。動機としては、Swiftはすごく楽しいのだけどJSONのパースあたりがとてもつらいという問題があって、そこらへんの地味な実装を代わりに担うライブラリを書くことで、楽しいSwiftの開発をより楽しくできると思った。

使い方

let client = Qiita.Client(accessToken: "...")

let parameters = ["query": "user:naoty_k"]
client.getItems(parameters: parameters).onComplete({ items in
  // ...
}).onError({ error in
  // ...
}).resume()

Promiseっぽい感じで非同期処理をメソッドチェーンで記述することができる。対応しているAPIは、ハッカソンで数時間で作っただけだから、まだユーザーと投稿のGETリクエストのみしかないので、今後追加していきたい。

ハッカソンについての所見

これまで何回かハッカソンに参加してきたけど、ハッカソンで作ったソフトウェアの開発をその後も続けた、ということはなかった。そのことがハッカソンに参加するたびに心にひっかかっていた。というのも、ハッカソンで開発するソフトウェアはなにかしらの問題を解決するために作っているはずで、その場かぎりの開発で解決できることはめったにないと思うからだ。ハッカソンは解決する価値のある問題を発見し、その解決策のプロトタイピングを短時間で行うイベントだと思う。だから、ハッカソンはきっかけであって解決に値する問題であればその後も継続的に開発していくべきなんだと思う。だから、開発しようと思った動機を大切にして、開発を継続していこうと思う。