Help us understand the problem. What is going on with this article?

[Rails4/5]はじめてのMountable Engine

More than 1 year has passed since last update.

前提

アプリケーションの規模が大きくなりつつある段階で、アプリの中核ではない機能(今回はブログ記事管理機能)をプラグインとして切り出したいなーと思い、RailsのMountable Engine型プラグインを作ってみました。
「はじめての」なので、基礎的な内容になります。

これからやること

  • Mountableエンジンを作成する
  • Scaffoldで記事管理機能を作成する
  • エンジンに親アプリとは別のassetを適用する
  • テストにRSpecを使えるように設定する
  • FactoryGirlをRSpec内で使う

サンプルアプリ

https://github.com/kaorina/engine-rails

Mountableエンジンとは?

Railsアプリケーション内に存在するが、完全に本体アプリ(親)から独立した小さなアプリケーションのこと。
Model、View、Controller、ルーティング、Gemfile、assetなど全てにおいて親アプリとは別の世界で管理している。
そして、親アプリのそれらを利用することもできる。

例えば、もし親が膨大なアプリに成長してしまった際に、親に実装進めて更に太らせていくのではなく、独立できる機能はMountableエンジンとして別階層に切り出して実装するという策をとると、親がカオスに陥ることがなくHappyなことが多いらしい。何の機能をエンジン化するかはじっくり検討し設計が必要そうだけど。
(コンテンツの管理機能や、お問い合わせ機能なども管理方法によっては対象にできそう。)

1. Mountableエンジンを作成する

今回ブログ記事の管理ツールを親アプリケーションから切り離しエンジン化するという想定で、blog_engineという名前のMountableエンジンを作成する。
エンジン名は命名ルールとして小文字かつアンダースコアを使うこと。

blog_engineというプラグインをGemとして作成し、親アプリ側でそのGemをインストール、ルーティングの設定でガシャーンとmountすることで、使えるようになるというイメージです!

作成コマンドを実行

親アプリのディレクトリに移動し、plugin作成コマンドを実行

$ ./bin/rails plugin new blog_engine --mountable

テストにRSpecを使いたい場合はこの段階で、オプション付きで作成しておくのがベター

$ ./bin/rails plugin new blog_engine --mountable -T --dummy-path=spec/dummy_app

実行すると、appディレクトリと同列にblog_engineディレクトリが作成される
中身を見てみると、appを始めとして見慣れたディレクトリが並んでいるのがわかる。

Gemfileをみると

gemspec

と記述があり、同列にある blog_engine.gemspecを参照しているようだ。

gemspecを編集する

[TO DO] となっている箇所、email, homepageをアプリに沿った内容に変更する
下記のilovebeerはサンプルの親アプリ名なのでお好きなように設定。

blog_engine/blog_engine.gemspec

$:.push File.expand_path("../lib", __FILE__)

# Maintain your gem's version:
require "blog_engine/version"

# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
  s.name        = "blog_engine"
  s.version     = BlogEngine::VERSION
  s.authors     = ["ilovebeer"]                  
  s.email       = ["info@ilovebeer.com"]
  s.homepage    = "http://ilovebeer.com"
  s.summary     = "ilovebeer: Summary of BlogEngine."
  s.description = "ilovebeer: Description of BlogEngine."
  s.license     = "MIT"

  s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
  s.test_files = Dir["test/**/*"]

  s.add_dependency "rails", "~> 4.2.0"

  s.add_development_dependency "sqlite3"
end

blog_engine以下へ移動し、bundle installを実行

$ cd blog_engine
$ bundle install

親アプリにGemをインストールする

エンジンを使えるように親アプリ側に設定します。これをやらないと作ったエンジンが使えません!
親アプリ側のGemfileにgem blog_engine を読み込ませる

Gemfile

gem 'blog_engine', path: 'blog_engine'
(他記述は省略)

今回はアプリ内に作成したのでpathは上記のようになっているが、作る場所によって異なる。
また、gitHub上に公開して、gitのURLを指定する場合が一般的のようです。

$ cd ../
$ bundle install

エンジンをmountする

親アプリ側のルートへ追加。

config/routes.rb

require 'sidekiq/web'
Rails.application.routes.draw do
  mount BlogEngine::Engine, at: '/blog'
(他記述は省略)
end

at はどのURLでアクセスするかを設定。今回は http://localhost:3000/blog でエンジンにアクセスできるように設定しました。
ただし、この時点ではサーバーを起動しアクセスしてもRouting Errorになってしまいます。
理由としてエンジン側のroutes.rbの記述が必要ですが、それについては「2. Scaffoldで記事管理機能を作成する」で行います。

エンジンが正しくmountされたことを確認

サーバーを起動し、エンジンのマウント先URLにアクセスするとRouting Errorが出ることを確認。
その際、ブラウザに表示されているRoutesに設定したエンジンのパスが表示されていれば正しくマウントできています。

RoutingError画面.png

2. Scaffoldで記事管理機能を作成する

エンジンで利用するModel, View, Controllerを作成していきます。

Scaffoldを実行

Articleという記事管理用のモデルを作成し、Articleテーブルには titiel:string型, body:text型のカラムをもたせます。

$ cd blog_engine
$ bundle exec rails g scaffold articles title body:text

Migrationファイルを親アプリ側へコピー

scaffoldコマンドで作られたblog_engine/db/migrate/以下のマイグレーションファイルを親アプリへコピーします。

$ cd ../
$ rake blog_engine:install:migrations

これでアプリ側に下記ファイルができる
db/migrate/xxxx_create_blog_engine_articles.blog_engine.rb
お尻にエンジン名のscopeがついているのがポイント。

Migrationを実行し、コンソールで確認

$ bin/rake db:migrate

これでエンジン以下にArticleテーブルが作成されたので、コンソールで確認してみましょう。

$ rails c

# これはアプリ側のArticleテーブルを呼ぶのでエンジンではない
pry(main)> Article

# 頭にエンジン名をつけて呼び出す
pry(main)> BlogEngine::Article

pry(main)> BlogEngine::Article.first
  BlogEngine::Article Load (0.4ms)  SELECT  `blog_engine_articles`.* FROM `blog_engine_articles`  ORDER BY `blog_engine_articles`.`id` ASC LIMIT 1
=> nil

参照できました!

サーバーを起動し、ブラウザで確認

ルーティングを確認し、/blog/articles でindexのページにアクセスしてみる。

New Articleリンクから記事を作成できることを確認〜。
scaffold_article.png

エンジン以下のapplication.jsを修正

jquery, jquery_ujs を呼び出す記述を追加

blog_engine/app/assets/javascripts/blog_engine/application.js
(※先頭が +の行は追加、 -の行は削除を意味しています)

+ //= require jquery
+ //= require jquery_ujs
//= require_tree .

これを書かないとScaffoldが正しく動かない(Destroyでdialogが立ち上がらず削除できない)
また、この記述だとエンジン側でなく親アプリ側のassetパスからjQuery達を参照している。
アプリ側にもjQuery関連のGemがインストールされているので問題なく動いているが、ない場合はエラーになるので、エンジン側のassetパスを参照するようにする必要がある。
その方法は次のステップで行います。
問題なくブラウザ上でAarticleの各アクションが動くことを確認できればOKです。

3. エンジンに親アプリとは別のassetを適用する

今回はサンプルお題として、エンジン側にBootStrapを適用します。

要件として

  • 親アプリ側のasset(CSS, JavaScript)を呼び出すのではなく、エンジン側で独自のassetを適用したい場合を想定して進める。

  • 今回BootStrapの適用方法については、Gemを利用する方式でなく、公式サイトからダウンロードしたソースファイルを使って実装する。

つまり、エンジン側のasset配下に置いたCSS, JavaScriptファイルを読み込む方法を説明します。

ダウンロードしたソースをエンジンのassetへ設置

Bootstrap4のページからsource filesをダウンロードし、ZIPを解凍。

scssフォルダの中身をごっそり下記ディレクトリへ置く
blog_engine/app/assets/stylesheets/blog_engine
(cssファイルでも良いが、今回はscssを利用)

dist/jsフォルダのbootstrap.min.js を下記ディレクトリに置く
blog_engine/app/assets/javascripts/blog_engine

stylesheets, javascriptsディレクトリ以下にblog_engine用のディレクトリが切られているので、その配下に置くのがポイント!

また、Bootstrap4のjsファイルを使う場合、単品だとChromeのコンソールにエラーが出てしまう。StackOverFlowより

解決にはTetherというライブラリが必要とのことなので、「Download ZIP」からダウンロードし、tether.min.jsをjsファイルと同じ場所に設置する。

設置したassetファイルを読み込むように追記 [ハマりどころ]

scss
blog_engine/app/assets/stylesheets/blog_engine/application.scss
(↑拡張子はcssのままでも、scssに変えてもどちらでも良いぽい)

+ *= require blog_engine/bootstrap
- *= require_tree .
*= require_self

JavaScript
blog_engine/app/assets/javascripts/blog_engine/application.js

//= require jquery
//= require jquery_ujs
+ //= require blog_engine/tether.min
+ //= require blog_engine/bootstrap.min
- //= require_tree .

両方とも、ポイントは blog_engine/を明記すること!
ファイル名だけを書いた場合、親アプリ側のassetパスを指していることになります!
知ってたらふーんという感じですが、知らなかったので地味にハマりました。。

参考までに、どうパスを書けば読み込まれるか?の検証をする場合、いきなりBootstrapのファイルを適用する前にもっとライトなやり方として
scaffold実行時にできた blog_engine/app/assets/javascripts/blog_engine/article.jsを利用して
1. 簡単なJavaScriptのコードを書いてみてる
2. application.js にどう書けば読み込まれるか試行錯誤しながらブラウザで確認してみる〜というステップを踏みました。

結果的にこの書き方でうまくいくかと。
blog_engine/app/assets/javascripts/blog_engine/articles.js

console.log("Hi")

blog_engine/app/assets/javascripts/blog_engine/application.js

+ //= require blog_engine/articles

表示された!!
index_bootstrap_2.png

デフォルトで書いてある //= require_tree . については、この場合親アプリ側のassetsパス配下にあるcss, jsファイル全てを読み込むという意味になってしまいます。
この記述を残していると、(明確な理由はわかりませんが、おそらくアプリ側とエンジン側のcss, jsファイルの中身の定義がバッティングしているとかで)Sass::SyntaxError になってしまったので削除しました。

BootStrapが適用されているかどうかViewを修正し確認

index用のviewを修正
blog_engine/app/views/blog_engine/articles/index.html.erb

scssの適用を確認するために、Showリンクにクラスを追加してみる

<%= link_to 'Show', article, class: "btn btn-primary" %>

JavaScriptの適用を確認するために、ドロップダウンを設置してみる

<div class="dropdown">
    <button id="dLabel" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
      Dropdown sample
      <span class="caret"></span>
    </button>
    <ul class="dropdown-menu" aria-labelledby="dLabel">
      <li>Menu A</li>
      <li>Menu B</li>
      <li>Menu C</li>
    </ul>
  </div>

Showリンク、ドロップダウンが下記のように表示されれば適用できています!

index_bootstrap.png

4. RSpecを使えるように設定する

MountableエンジンのテストはデフォルトがRSpecではないので、設定が必要。

テストをRSpecに指定しエンジンを作成

「1. Mountableエンジンを作成する」 の作成時に、テストをRSpecにするオプション付きのコマンドを実行する。

すでにオプション無しでエンジンを作成してしまった場合

オプションをつけずにエンジンを作成した場合は、blog_engineとは別のエンジンを新規作成し、
新規作成したエンジンのディレクトリ以下から必要なものをblog_engine側にコピーする策をとる。

親アプリのディレクトリへ移動し、下記実行
例としてblog_engine2というエンジンを作成

$ ./bin/rails plugin new blog_engine2 --mountable -T --dummy-path=spec/dummy_app

オプションについて

  • -T (--skip-test-unit)
  • --dummy-path テストを実行するダミーのアプリを作成する。 エンジンの場合、テストを実行するためにspec以下にダミーのアプリを作成し、それをmountすることで、テスト可能なエンジンの環境を作る。

ディレクトリをコピペして、ファイルを修正

  • blog_engine2/spec/dummy_appディレクトリをコピーし、blog_engine/spec/dummy_appを作成。
  • dummy_app以下でblog_engine2と記載がある箇所をblog_engineへ修正
  • 不要になったので、blog_engine/test ディレクトリを削除

gemspecファイルを修正

必要なGemを追加し、bundle installを実行

blog_engine/blog_engine.gemspec

$:.push File.expand_path("../lib", __FILE__)
(中略)
-  s.test_files = Dir["test/**/*"]
+ s.test_files = Dir["spec/**/*"]
s.add_dependency "rails", "~> 4.2.0"
s.add_development_dependency "sqlite3"
+ s.add_development_dependency "rspec-rails"
+ s.add_development_dependency "factory_girl_rails"
$ cd blog_engine/
$ bundle install

engine.rb に設定を追加

blog_engine/lib/blog_engine/engine.rb

module BlogEngine
  class Engine < ::Rails::Engine
    isolate_namespace BlogEngine

    + config.generators do |g|
    +   g.test_framework :rspec, fixture: false
    +   g.fixture_replacement :factory_girl, dir: "spec/factories"
    + end
  end
end

blog_engineへRSpecをインストール

下記コマンドでRSpec実行に必要なファイル等を作成

$ rails g rspec:install

rails_helper.rb を修正

blog_engine/spec/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__)

# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?

ActiveRecord::Migration.maintain_test_schema!

RSpec.configure do |config|
  - config.fixture_path = "#{::Rails.root}/spec/fixtures"
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!
end

spec_helper.rb を修正

blog_engine/spec/spec_helper.rb

ENV['RAILS_ENV'] ||= 'test'

+ require File.expand_path("../dummy_app/config/environment.rb", __FILE__)
require 'rspec/rails'
+ require 'factory_girl_rails'

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end

  config.shared_context_metadata_behavior = :apply_to_host_groups
end

簡単なspecを作成してみる

blog_engine/spec/controllers/articles_controller_spec.rb

describe BlogEngine::ArticlesController, type: :controller do
  routes { BlogEngine::Engine.routes }
  describe "GET /articles" do
    it "works! (now write some real specs)" do
      get :index
      expect(response).to have_http_status(200)
    end
  end
end

実行できるとこんな画面になります。
spec.png

実行できたが結果の詳細が表示されない場合は、RSpecの結果をドキュメント形式で出力する設定を加えてください。

blog_engine/.rspec

+ --format documentation



specファイルについてのポイントは、

  • describeの部分はBlogEngine::ArticlesController とエンジン名のscopeをControllerの先頭につける
  • 通常のController specの場合 get :index のみで良いが、エンジンの場合は routes { BlogEngine::Engine.routes } で先にルーティングを読み込ませる記述を書いておかないとエラーになってしまう。

また、設定ファイル rails_helper.rb, spec_helper.rbついて

2つあるけどどちらに何を書けばいい?
私はよく分かってなかったのでハマりました〜。

迷いなく rails_helper.rbにRSpecの設定を全て書いて、テストを実行したけど動かない〜という状況に陥っていました。
RSpec実行時にspec_helper.rbが先に読み込まれるので、spec_helperにはRSpecの設定を全て書いておく必要があるとのこと!

区別的に下記の認識がわかりやすいです。(参考記事より)

spec/rails_helper.rb にはRails特有の設定
spec/spec_helper.rbにはRSpecの全体的な設定 を記載する

試しに、rails_helper.rbの記述をspec_helper.rbにコピーし、rails_helper.rbの中身を空にしてテストを実行してみたりしました。
この場合テストはクリアしましたが、逆の場合は( spec_helper.rbを空にしてみると)エラーになりました。ふむふむ。

5. FactoryGirlをRSpec内で使う

以下のステップに分けて設定を進めていきます。

  • 1. まず、コンソールでFactoryコマンドを実行できるようにする
  • 2. 次にRSpec内でFactoryGirlを使ってみる

1. コンソールでFactoryコマンドを実行できるようにする

親アプリ側にFactoryGirlのGemをインストールしておく

Gemfile

+ group :development, :test do
+  gem 'factory_girl_rails'
+ end

Factoryを作成

ArticleテーブルについてFactoryを定義するファイルを作成し、Factoryを作成。

blog_engine/spec/factories/blog_engine_articles.rb

FactoryGirl.define do
  factory :blog_engine_article, class: BlogEngine::Article do
    sequence(:title) { |n| "記事のタイトル#{n}" }
    body "Boddddddy"
  end
end

ポイントは

  • factory名 親アプリ、エンジンを通して一意である必要があるので、既に使っているfactory名を避けて名前をつける
  • class名をつける コンソールでモデルの参照を行ったときと同様にエンジン名のscopeをつけてモデル名を書く

この状態でコンソールで

FactoryGirl.build(:blog_engine_article)

が通ると思ったが、通らず。。
更に調べてみると、
gmespec, engine.rbへのfactoryのパス追加が必要だった〜。

gemspecを修正

spec/factoriesへのパスを追加

blog_engine/blog_engine.gemspec

-  s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
+  s.files = Dir["{app,config,db,lib}/**/*", "spec/factories/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
-  s.test_files = Dir["test/**/*"]
+  s.test_files = Dir["spec/**/*"]

engine.rbを修正

blog_engine/lib/blog_engine/engine.rb

+    initializer "model_core.factories", :after => "factory_girl.set_factory_paths" do
+      FactoryGirl.definition_file_paths << File.expand_path('../../../spec/factories', __FILE__) if defined?(FactoryGirl)
+    end

そして、再度コンソールで実行すると...無事インスタンスを作成できた!
console_FG.png

2. RSpec内でFactoryGirlを使ってみる [ハマりどころ]

articles_controllerのspecにFactoryGirlの記述を追加

blog_engine/spec/controllers/articles_controller_spec.rb

describe BlogEngine::ArticlesController, type: :controller do
  routes { BlogEngine::Engine.routes }
  describe "GET /articles" do
    it "works! (now write some real specs)" do
      get :index
      + FactoryGirl.create(:blog_engine_article)
      expect(response).to have_http_status(200)
    end
  end
end

この状態でspecを実行するとまだエラーになってしまう。
この時点でなぜテストが通らないかでハマった〜>_<

Rspecでハマったときは...
binding.pry をspecファイルや、実際のコード(この場合articles_controller.rb)に書いてみて原因を探るのが良いと教わったので、試行錯誤してみる。
ただし、この状態ではpryは使えないのでGemfileに下記を追加し、エンジン以下でbundle installが必要

blog_engine/Gemfile

+ group :development, :test do
  + gem 'pry-rails'
  + gem 'rb-readline' #環境によってはこちらも必要
+ end

これでbinding.pryが使えるようになった。

そしてRSpec内でFactoryGirlを使うには結論として下記が必要だった!

  • spec_helperにfactory_girl_railsを記載していることを確認
  • Rakefileを確認
  • テスト用DBの作成、migration実行

spec_helper.rbを確認

factory_girl_railsが参照されていることを確認。記載していなければ追加。
ついでにFactoryGirlの呼び出しを短く書ける記述(FactoryGirl.xxxの頭の”FactoryGirl”を省略する)も追加しておく。

+ require 'factory_girl_rails'
RSpec.configure do |config|
  + config.include FactoryGirl::Syntax::Methods
end 

Rakefileを修正

「1. Mountableエンジンを作成する」 でのエンジン作成の際、RSpecを使うオプション付きで作成した場合は修正不要。
既に下記の内容になっていると思います。

blog_engine/Rakefile

begin
  require 'bundler/setup'
rescue LoadError
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
end

require 'rdoc/task'

RDoc::Task.new(:rdoc) do |rdoc|
  rdoc.rdoc_dir = 'rdoc'
  rdoc.title    = 'BlogEngine'
  rdoc.options << '--line-numbers'
  rdoc.rdoc_files.include('README.md')
  rdoc.rdoc_files.include('lib/**/*.rb')
end

APP_RAKEFILE = File.expand_path("../spec/dummy_app/Rakefile", __FILE__)
load 'rails/tasks/engine.rake'
load 'rails/tasks/statistics.rake'
require 'bundler/gem_tasks'

テスト用DBの作成、migration実行

$ cd blog_engine
$ RAILS_ENV=test rake db:create
$ RAILS_ENV=test rake db:migrate

テスト実行するとパスできました!やった!

$ bundle exec rspec spec/controllers/

ちなみにspec内のFactoryGirl.createの記述は下記でOK。

create(:blog_engine_article)

まとめ: 実際に作ってみて

思ったより簡単だった所

Mountableエンジンを作成し、動かすとこまではスムーズにできた。デフォルトの使い方はわかりやすいなと思いました。
エンジン以下のディレクトリ構成などは見慣れたものが多く、構成は理解しやすかった。

イマイチ概念がわからずモヤモヤしたところ

  • テスト環境としてspec以下にダミーのアプリを持つという概念が、エンジン特有で理解し難かった。
  • 親アプリに極力依存せず、親とは別のものを使いたいとなった時のカスタマイズがわかりずらかった。(assetの参照など)

  • (想像だが)更に実装を進めていくと、どの程度親側のもの(Model等)を共通で使い、どれをエンジン側で自前で持つかの切り分けについて、ルール化しながら実装を進める必要があるのかなーと思いました。

参考情報

Rails アプリ内に Rails アプリを入れ子で実現する仕組みです。
よく使われるのは RailsAdmin, ActiveAdmin といった管理画面の分離ですね。
なぜこういう作りにするのかと言うと
関心ごとを分離できる、標準化される、アプリを小さく保てるからです。

アプリケーションは いかなる場合も エンジンよりも優先されます。
ある環境において、最終的な決定権を持つのはアプリケーション自身です。
エンジンはアプリケーションの動作を大幅に変更するものではなく、アプリケーションを単に拡張するものです。

  • 書籍 パーフェクトRuby on Rails
    Part4でさらっと書いてあります。作ってみた後に読むと更になるほどと。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした