Mountable Engine だらけの Rails アプリ開発

はじめに

これはドリコムアドベントカレンダーの 2 日目です。

1 日目は id:sue445 さんによる ドリコムを支える中間ポイントシステム - くりにっき です。

お前誰よ

今日の話

「普通に Rails アプリを作ると Mountable Engine を少なくとも 5 個は使う時代になったよね」って話をします。

目次

Mountable Engine とは

Rails アプリ内に Rails アプリを入れ子で実現する仕組みです。 よく使われるのは RailsAdmin, ActiveAdmin といった管理画面の分離ですね。

なぜこういう作りにするのかと言うと

からです。

「運営ブログ」機能をアプリケーションに追加するときを例に考えてみましょう。 簡単なブログとしても

等々は必要で、これらのためのコミットがアプリのリポジトリに増えていくのは アプリの本質ではないですよね。

標準化される

他のアプリに対しても運営ブログを作りたいときにコピペが蔓延していきます。

cherry_pick1

cherry_pick2

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 つのオプションを使います

$ 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 を拡張するときの罠

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 でも採用されています。

gitlab_sidekiq_admin

https://github.com/gitlabhq/gitlabhq/blob/v7.5.1/app/views/admin/background_jobs/show.html.haml#L44

バージョニングされた API Endpoint に mount したい

みたいにバージョニングしつつ 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 先にアクセスすると

の各バックエンドにアクセス可能なことを確認し、アプリケーションの動作に問題がなければ

を返します。

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 さんです。

参考資料