Mountable Engine だらけの Rails アプリ開発
はじめに
これはドリコムアドベントカレンダーの 2 日目です。
1 日目は id:sue445 さんによる ドリコムを支える中間ポイントシステム - くりにっき です。
お前誰よ
-
id
-
ドリコム歴
- 2006/12/01 中途入社
- 9年目に突入しました
-
仕事
- アプリケーションエンジニア
- 2009/04 から Rails アプリを触るように
- 主にサーバサイドを担当しています
今日の話
「普通に Rails アプリを作ると Mountable Engine を少なくとも 5 個は使う時代になったよね」って話をします。
目次
- Mountable Engine とは
- Mountable Engine の作り方
- Mountable Engine のテストの書き方
- Mountable Engine の設定を書きたい
- 管理画面付きの Mountable Engine を作る
- Mountable Engine を使うときの罠を幾つか
- ドリコム社内でよく使われている Mountable Engine を紹介
- なぜ今 Mountable Engine なのか
Mountable Engine とは
Rails アプリ内に Rails アプリを入れ子で実現する仕組みです。 よく使われるのは RailsAdmin, ActiveAdmin といった管理画面の分離ですね。
なぜこういう作りにするのかと言うと
- 関心ごとを分離できる
- 標準化される
- アプリを小さく保てる
からです。
「運営ブログ」機能をアプリケーションに追加するときを例に考えてみましょう。 簡単なブログとしても
- 一覧表示
- 1記事表示
- タグ別表示
- カテゴリ別表示
- 月別表示
- コメント投稿
- 投稿者向けの管理画面
等々は必要で、これらのためのコミットがアプリのリポジトリに増えていくのは アプリの本質ではないですよね。
標準化される
他のアプリに対しても運営ブログを作りたいときにコピペが蔓延していきます。
RubyKaigi2014で発表した - mitaku.log
gem にすることで、この流れに逆らって標準化していくことができます。
小さく保つ
1プロジェクトのモノリシックなアプリにしていると、数百 model に成長してしまいます。
ドリコム社内には .git が 6GB あるアプリもありましたが、
git status
を叩くたびにイライラしますし、テストの実施もままならず苦労しました……。
また、どんどん変更の影響範囲が読めなくなって、生産性も落ちていきます。 小さく保つことで、負債の絶対量に押しつぶされない健全な環境でフットワーク軽く開発ができます。
Mountable Engine の作り方
Rack on Rails App
Rails は Rack の仕組みで動いており、 config/routes.rb に 1 行追加するだけで Rack アプリをアプリ内に追加することができます。
Rails.application.routes.draw do
mount proc {|env| [200, {}, ["Hello, World!"]] }, at: "hello"
end
rake routes
するとこんな出力。
$ rake routes
Prefix Verb URI Pattern Controller#Action
/hello #<Proc:0x007fb10110c700@/path/to/main_app/config/routes.rb:2>
これで rails s
でアプリケーションサーバを起動して http://0.0.0.0:3000/hello
にアクセスするだけで "Hello, World!" と表示されます。
Sinatra on Rails App
同様に sinatra アプリも簡単に追加可能です。
(1) sintara app を作る
Gemfile
gem "sinatra"
lib/foo/app.rb
class Foo < Sinatra::Base
get "/" do
"Hello, Foo World!"
end
get "/bar" do
"Hello, FooBar World!"
end
end
(2) Rails 起動時に sintara app を読み込む
config/application.rb
require Rails.root.join("lib/foo/app")
(3) 適当な場所に mount する
config/routes.rb
mount Foo, at: "foo"
mount 結果
$ rake routes
Prefix Verb URI Pattern Controller#Action
/hello #<Proc:0x007fd827cb4858@/path/to/main_app/config/routes.rb:2>
foo /foo Foo
サーバを立ち上げて http://0.0.0.0:3000/foo
にアクセスすると Hello, Foo World!
と出力され
http://0.0.0.0:3000/foo/bar
にアクセスすると Hello, FooBar World!
と出力されます。
今回は紙面の都合で lib に配置して require
しましたが、通常は gem にしてしまうと良いでしょう。
Engine on Rails App
詳しくは
を読んでほしいのですが、以下の手順です。
(1) Mountable Engine を作る
$ rails plugin new my_engine --mountable
生成された engine の雛形に対して少し実装します。
$ cd my_engine
$ bundle install
$ bundle exec rails g scaffold users name
scaffold された画面が動くよう、app/assets/javascripts/my_engine/application.js に jquery, jquery_ujs を追加。
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
+//= require jquery
+//= require jquery_ujs
//= require_tree .
(2) Rails 起動時に MyEngine を読み込む
Gemfile
# 同じプロジェクト内に置いてしまったので path 指定で読み込みます。
gem "my_engine", path: "my_engine"
(3) engine を使う準備をする
engine 側で作った migration を親アプリにコピーします。
$ rake my_engine:install:migrations
コピーされた migration には db/migrate/xxxx_yyyy.my_engine.rb と、Engine 名の scope が付きます。
--- /dev/null
+++ b/db/migrate/20141201071044_create_my_engine_users.my_engine.rb
@@ -0,0 +1,10 @@
+# This migration comes from my_engine (originally 20141201070612)
+class CreateMyEngineUsers < ActiveRecord::Migration
+ def change
+ create_table :my_engine_users do |t|
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
あとはいつもの db:migrate
。
$ rake db:migrate
(4) 適当な場所に mount する
config/routes.rb
mount MyEngine::Engine, at: "my_engine"
サーバを立ち上げて http://0.0.0.0:3000/my_engine/users
にアクセスすると users の scaffold された画面が表示されます。
Mountable Engine のテストの書き方
概要
spec 内に dummy という名前の Rails アプリを置き、dummy app で mount することで mount された状態を作ります。 あとは普通にテストを書くと、この dummy app で動かしたことになります。
rspec でテストを書きたい!
TestUnit の場合は、普通に rails plugin new すると test/dummy に親となる Rails app が置かれます。
これを rspec にするには以下の 2 つのオプションを使います
- -T (--skip-test-unit)
- --dummy-path=spec/dummy
$ rails plugin new <gem_name> --mountable -T --dummy-path=spec/dummy
作った Engine の development_dependency に必要な gem を加えて bundle install
。
my_engine/my_engine.gemspec
@@ -15,10 +15,12 @@ Gem::Specification.new do |s|
s.license = "MIT"
s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
- s.test_files = Dir["test/**/*"]
+ s.test_files = Dir["spec/**/*"]
s.add_dependency "rails", "~> 4.1.8"
s.add_dependency "jquery-rails"
s.add_development_dependency "sqlite3"
+ s.add_development_dependency "rspec-rails"
+ s.add_development_dependency "factory_girl_rails"
end
my_engine/lib/my_engine/engine.rb にて以下の設定をしておくと良いでしょう。
module MyEngine
class Engine < ::Rails::Engine
isolate_namespace MyEngine
+
+ config.generators do |g|
+ g.test_framework :rspec, fixture: false
+ g.fixture_replacement :factory_girl, dir: "spec/factories"
+ end
end
end
spec/spec_helper.rb や spec/rails_helper.rb を作る generator は無いので適当な Rails アプリで rails g rspec:install
で作って持ってきます。
その際、rails_helepr を少し変更して dummy app を使えるように修正します。
rails_helper.rb
# This file is copied to spec/ when you run 'rails generate rspec:install'
ENV["RAILS_ENV"] ||= 'test'
require 'spec_helper'
-require File.expand_path("../../config/environment", __FILE__)
+require File.expand_path("../dummy/config/environment.rb", __FILE__)
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
+require 'factory_girl_rails'
+
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# require only the support files necessary.
#
-# Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
+# support を使いたい場合は以下のように
+# Dir["#{File.dirname(__FILE__)}/spec/support/**/*.rb"].each { |f| require f }
+
# Checks for pending migrations before tests are run.
# If you are not using ActiveRecord, you can remove this line.
spec の書き方
Controller Spec や Request Spec で普通に
get :index
とすると No route matches
と怒られるので、使う routes を指定します。
module MyEngine
RSpec.describe UsersController, :type => :controller do
+ routes { MyEngine::Engine.routes }
+
# This should return the minimal set of attributes required to create a valid
# User. As you add validations to User, be sure to
また、各リクエストで use_route
しても同じ効果が得られます。
面倒なので controller_spec や request_spec 丸ごと routes
指定する方が良いかな。
describe "GET /users" do
it "works! (now write some real specs)" do
- get :index
+ get :index, use_route: :my_engine
expect(response).to have_http_status(200)
end
end
Mountable Engine の設定を書きたい
ActiveSupport::Configurable を使います。
# my_engine.rb
require "my_engine/config"
module MyEngine
def self.config
@config ||= Config.new
end
def self.configure
yield config if block_given?
end
end
# my_engine/config.rb
require "active_support/configurable"
module MyEngine
class Config
include ActiveSupport::Configurable
config_accessor :hoge
configure do |config|
config.hoge = true
end
end
end
アプリからは initializers 辺りで変更します。 config/initializers/xxx.rb を生成する generator を用意してあげると優しいですね。
# config/initializers/my_engine.rb
MyEngine.configure do |config|
# config.hoge = true
end if defined?(MyEngine)
管理画面付きの Mountable Engine を作る
ユーザ向けの画面と管理画面とを別々の end_point に mount したい場合は Engine を分けて
# config/routes.rb
# /my_engine/xxx
mount MyEngine::Engine, at: "my_engine"
# /admin/my_engine/xxx
namespace :admin do
mount MyEngine::Admin::Engine, at: "my_engine"
end
のようにします。
別の Engine なんだけど、1つの gem で管理したいときは
Rails3 Recipe Book Gaiden // Speaker Deck
を参照ください。
また、管理画面には共通の認証機能を使いたいので
# config/routes.rb
authenticate :admin_user do
mount MyEngine::Engine, at: "my_engine"
end
と routes で認証したり、
MyEngine::Admin::ApplicationController < ::Admin::ApplicationController
と継承して親アプリの before_action での認証を使ったりすると扱いやすいです。
Mountable Engine を使うときの罠を幾つか
親アプリで拡張する
- model は「initializers でオープンクラスしようぜ」
- view は好きにやればいいんじゃないか
- controller は親アプリの ApplicationController を継承するかどうか次第
といった感じです。
model を拡張するときの罠
module MyEngine
class User < AR::Base
has_many :articles
end
class Article < AR::Base
belongs_to :user
end
end
という Engine の model があったとして、 親アプリでこの model を継承した場合に少し厄介なことになります。
class User < MyEngine::User
end
class Article < MyEngine::Article
end
User.first.articles.first.class
# => MyEngine::Article
# MyEngine::Article ではなく Article のインスタンスであってほしい!
has_many :articles
の関連も定義し直せば意図通りに動きますが、そもそも僕らが
Engine 化してきた目的は「各アプリでの独自実装を避けて共通化する」なので、
「model を変更したくなる場合はみんなで会話して落とし所を探そうね」という方針でやっています。
社内で複数アプリに展開しているだけでも、どうしても Engine 側に特定アプリ向けの実装が入り込んできます。 rubygems.org に公開して 広く使われる Engine gem を作るのは難易度が高いなぁと感じています。
Engine で指定した assets が見つからない
my_engine.gemspec で runtime_dependency に追加したはずなのに assets_path に見つからない場合は 大抵 require 漏れです。
Engine に限らず、Gemfile に慣れ切ってるとよくあるミスですね。
Engine の layout が他と違うのですげぇ違和感
Engine の layout を上書きするのはまだ試してないです('A`)
mount した URL を iframe で開いてしまうのが楽ですね。 sidekiq の事例 (sinatra app) ですが、GitLab でも採用されています。
https://github.com/gitlabhq/gitlabhq/blob/v7.5.1/app/views/admin/background_jobs/show.html.haml#L44
バージョニングされた API Endpoint に mount したい
- /api/v1/my_engine/xxx
- /api/v2/my_engine/xxx
みたいにバージョニングしつつ Mountable Engine を使いたい場合、同じ Engine
に対して複数回 mount
を呼ぶと
`add_route': Invalid route name, already in use: 'my_engine' (ArgumentError)
と怒られます。
まだ綺麗な解決はしていないのですが、
# config/routes.rb
scope :v1 do
mount MyEngine::Engine, at: "my_engine", as: "v1_my_engine"
end
scope :v2 do
mount MyEngine::Engine, at: "my_engine"
end
としています。
この状態で named_routes を使うと稀に罠に落ちるので注意してください。
ドリコム社内でよく使われている Mountable Engine を紹介
activeadmin
割と開発初期にしか使われず、ある程度開発が進むとアプリ専用の管理画面 Engine に置き換えられていきます。
管理画面はアプリとデザインが違っても許されるので Mountable Engine を使いやすいですね。
komachi_heartbeat
死活監視用のエンドポイントを提供する Mountable Engine。
mount 先にアクセスすると
- DB
- Redis
- Memcached
の各バックエンドにアクセス可能なことを確認し、アプリケーションの動作に問題がなければ
- Status: 200 OK
- body: ok
を返します。
carrier_pigeon (未公開)
APNS/GCM を用いた push 通知用の gem です
sidekiq-cron を使っての予約配信や、そのための model 等を扱います。
client_requirements (未公開)
アプリが必須バージョンを満たしているかを確認する gem です
アプリを久々に起動した時によくある
「最新バージョンを AppStore/Google Play からダウンロードしてください」
メッセージを出すのに使います。
他にも client version ごとの feature の ON/OFF を表現するのにも使っています。
dpoint_web (非公開)
dpoint の処理の流れを Mountable Engine にしました。
今では「1時間で実装が終わる課金システム」と評判で、少数の苦労でみんながリッチな状態を実現しています。
dpoint については ドリコムを支える中間ポイントシステム - くりにっき をどうぞ。
treasury (非公開)
オファーウォール広告の表示や、リワードアイテムの付与を扱う gem です。
各社の案件取得や、ポイントバック通知、付与の辺りのワークフローを共通化しています。
なぜ今 Mountable Engine なのか
時は 2014 年。
最近は JSON API が全盛を振るっていて、アプリケーションサーバは HTML を返す必要が無くなった。 assets やデザインを気にしなくて良くなり、Mountable Engine の弱みが弱みじゃなくなった。
今こそ Mountable Engine を全力で使うべき。
gem 化することで導入を簡単にして、共通化して
Engine にすることでワークフローを合わせて、
開発を加速させて、
「前にアプリを作った時はどうしたんだっけなぁ」と悩むことを減らしていきましょう。
ドリコムアドベントカレンダー の 3 日目
次は id:arihh さんです。