前提
アプリケーションの規模が大きくなりつつある段階で、アプリの中核ではない機能(今回はブログ記事管理機能)をプラグインとして切り出したいなーと思い、RailsのMountable Engine型プラグインを作ってみました。
「はじめての」なので、基礎的な内容になります。
これからやること
- Mountableエンジンを作成する
- Scaffoldで記事管理機能を作成する
- エンジンに親アプリとは別のassetを適用する
- テストにRSpecを使えるように設定する
- FactoryGirlをRSpec内で使う
サンプルアプリ
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に設定したエンジンのパスが表示されていれば正しくマウントできています。
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リンクから記事を作成できることを確認〜。
エンジン以下の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
を利用して
- 簡単なJavaScriptのコードを書いてみてる
-
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
デフォルトで書いてある //= 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リンク、ドロップダウンが下記のように表示されれば適用できています!
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
実行できたが結果の詳細が表示されない場合は、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内で使う
以下のステップに分けて設定を進めていきます。
-
- まず、コンソールでFactoryコマンドを実行できるようにする
-
- 次に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
そして、再度コンソールで実行すると...無事インスタンスを作成できた!
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等)を共通で使い、どれをエンジン側で自前で持つかの切り分けについて、ルール化しながら実装を進める必要があるのかなーと思いました。
参考情報
- わかりやすくMountableエンジンの始め方が載っています
http://blog.onk.ninja/2014/12/02/mountable_engine
Rails アプリ内に Rails アプリを入れ子で実現する仕組みです。
よく使われるのは RailsAdmin, ActiveAdmin といった管理画面の分離ですね。
なぜこういう作りにするのかと言うと
関心ごとを分離できる、標準化される、アプリを小さく保てるからです。
- 鉄板Railsガイド (ちょっと難しかったので部分的に参考にしました)
https://railsguides.jp/engines.html
アプリケーションは いかなる場合も エンジンよりも優先されます。
ある環境において、最終的な決定権を持つのはアプリケーション自身です。
エンジンはアプリケーションの動作を大幅に変更するものではなく、アプリケーションを単に拡張するものです。
- 書籍 パーフェクトRuby on Rails
Part4でさらっと書いてあります。作ってみた後に読むと更になるほどと。