「達人に学ぶDB設計徹底指南書」を読んだ
達人に学ぶDB設計 徹底指南書 初級者で終わりたくないあなたへ
- 作者: ミック
- 出版社/メーカー: 翔泳社
- 発売日: 2012/03/16
- メディア: 単行本(ソフトカバー)
- 購入: 21人 クリック: 316回
- この商品を含むブログ (23件) を見る
「達人に学ぶ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
が実行される(ここでのself
はActiveRecord::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 s
でRailsサーバーが起動するまでに何が起きているのかを紐解くことでRailsとは何なのかを理解していきたい。今回読んでいくソースコードのコミットは2d9b9fb5b5f6015e66d3ad5cb96bc1ba117fd626
だ。
TL;DR
bin/rails s
がユーザーによって実行される。
コマンドの実行
まずbin/railsを見る。bin/railsはrails 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_PATH
bin/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.groups
はRails.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::Application
がRails::Application
を継承するとき、以下のような実装によってRails::Application.inherited
が呼ばれ、Rails.app_class
がSampleApp::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::Server
はrails/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.application
はSampleApp::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_initializers
はRails::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
#initializers
はRails::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_for
はRails::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#run
でinstance_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
を結合して返している。
#railties
はRails::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
initializers
はInitializable::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
によってセットされる。block
はInitializer
が初期化される際に渡されたブロックだ。このblock
は#bind
によってセットされた@context
をレシーバとして実行される。- 今回の場合、
@context
はRails::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.application
がSampleApp::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)
を追記しているようだ。self
はRails.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
つまり:admin
がns
に入る。#namespace
はnamespaces[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_resource
はAA::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
delegate
はActiveSupportが拡張しているメソッドで、メソッドの呼び出しを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が定義される。
router
はRouter
インスタンスなので、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
このrouter
はActionDispatch::Routing::Mapper
であり、@application.namespaces.values
はAA::Namespace
インスタンスの配列だ。
ActiveAdmin.register
に特にoptionを指定しない場合、namespace.root?
はtrue
となる。namespace.root_to_options
とnamespace.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_router
はAA::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
initializer
はRails::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::DescendantsTracker
をextend
しているクラスを継承したタイミングで@@direct_descendants
に追加される。ActiveSupport::DescendantsTracker
は例えばActiveRecord::Base
でextend
されているため、ActiveRecord::Base
のサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendants
から取得できる。これを利用しているのが先述したActiveSupport::Callbacks
で、callbackをサブクラスから親クラスへ辿っていくときに利用されている。
ActiveSupport::Dependencies
def clear log_call loaded.clear loading.clear remove_unloadable_constants! end
loaded
とloading
はクラス変数@@loaded
および@@loading
へのアクセサでmattr_accessor
によって定義されている。そして、これらのクラス変数の実体はSet
オブジェクトだ。
次にloaded
とloading
にいつ何が追加されるのか調べると、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
loaded
とloading
に追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したら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_paths
はapp/models/user.rb
とapp/controllers/users_controller.rb
いうファイルがあれば"User"
と"UsersController"
という表す文字列を含む配列となる。parent_paths
はconst_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_missing
→load_missing_constant
→require_or_load
→load_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リクエストのみしかないので、今後追加していきたい。
ハッカソンについての所見
これまで何回かハッカソンに参加してきたけど、ハッカソンで作ったソフトウェアの開発をその後も続けた、ということはなかった。そのことがハッカソンに参加するたびに心にひっかかっていた。というのも、ハッカソンで開発するソフトウェアはなにかしらの問題を解決するために作っているはずで、その場かぎりの開発で解決できることはめったにないと思うからだ。ハッカソンは解決する価値のある問題を発見し、その解決策のプロトタイピングを短時間で行うイベントだと思う。だから、ハッカソンはきっかけであって解決に値する問題であればその後も継続的に開発していくべきなんだと思う。だから、開発しようと思った動機を大切にして、開発を継続していこうと思う。