Ghi chép về Rails Engine
Dự án mình mới tham gia gần đây xây dựng các components thành các engine riêng biệt thay vì phát triển trên cùng một Rails app truyền thống. Các ghi chép dưới đây tổng hợp lại các hiểu biết và trải nghiệm của mình khi làm việc với Rails engine. Bài viết sẽ được cập nhật khi cần thiết.
Các điều kiện môi trường sử dụng trong bài viết:
- Ubuntu 14.04, Ruby 2.3.0 và Rails 4.2.6
- Bài viết sẽ tập trung vào
mountable engine
với engine mẫu làFoo::Engine
nằm trong thư mụcvendor/foo
của parent app - Sử dụng Rspec làm test framework cho
Foo::Engine
Mục lục
- Giới thiệu
- Models và Migrations
- Testing
- Một số chú ý khác
- Tài liệu tham khảo
Giới thiệu
Rails engine là gì?
Rails engine giống như một ứng dụng thu nhỏ, cung cấp các tính năng của nó cho parent application (Rails app sử dụng engine đó). Một số gems thường dùng trong các Rails app là các engines, ví dụ như Devise là engine cung cấp tính năng xác thực cho parent application, Solidus cung cấp nền tảng e-commerce.
Engine có cấu trúc gần tương tự một Rails app thông thường. Thực tế, Rails app cũng là một engine đặc biệt (Rails::Application
kế thừa từ Rails::Engine
). Mặc khác, engine cũng được đóng gói giống như một gem.
Khi xây dựng một ứng dụng có nhiều components phức tạp, sử dụng engine là một giải pháp để tách rời các component đó vào trong từng engine, như vậy việc phát triển sẽ trở nên tách bạch và dễ kiểm soát.
Tạo một Engine
Tạo engine Foo
trong thư mục vendor
của main app, bỏ minitest và dùng mysql làm database engine:
rails plugin new vendor/foo --mountable -T -d mysql --dummy-path=spec/dummy
Có khá nhiều các tùy chọn khi tạo mới một engine, có thể xem đầy đủ bằng lệnh:
rails plugin --help
Engine được tạo ra là một mountable engine Foo::Engine
nằm trong thư mục vendor/foo
của parent app. Cấu trúc của engine này gần tương tự một Rails app với một số files quan trọng cần lưu ý:
app/
thư mục này tương tựapp/
của một Rails appconfig/routes.rb
dành cho việc thiết lập các routes trong enginefoo.gemspec
giống nhưgemspec
của một gem, thay thế choGemfile
lib/foo/engine.rb
nơi chưa các config liên quan đến engine
Vì engine được đóng gói như một gem nên để sử dụng engine với Rails app, chúng ta khai báo engine trong Gemfile
của app như sau:
gem "foo", path: "vendor/foo"
Engine file
Chúng ta sẽ bắt đầu bằng việc khảo sát file lib/foo/engine.rb
Isolate Namespace
module Foo
class Engine < ::Rails::Engine
isolate_namespace Foo
end
end
Khai báo isolate_namespace Foo
có nghĩa rằng mọi class trong app/models
, app/controllers
hay app/helpers
và cả routes
sẽ được wrap trong namespace Foo
. Điều này đảm bảo rằng Foo
engine sẽ chạy độc lập và không có các xung khắc về tên hay code với parent app. Ví dụ: parent app và engine đều có thể khai báo class User
, tuy nhiên của Foo
engine sẽ là Foo::User
với table name là foo_users
.
Một số khai báo cơ bản
Một số khai báo cơ bản về test framework, các asset, template engines sẽ sử dụng:
config.generators do |g|
g.test_framework :rspec, fixture: false
g.fixture_replacement :factory_girl, dir: "spec/factories"
g.template_engine = :slim
g.javascript_engine = :coffee
g.stylesheet_engine = :scss
end
Các sửa đổi khác liên quan đến engine.rb
sẽ được đề cập thêm trong các phần tiếp theo.
Routes và các vấn đề liên quan
Đây là file config/routes.rb
của Foo
engine:
Foo::Engine.routes.draw do
# routes definitions
end
Để ý rằng routes
của engine được wrap trong Foo::Engine.routes
thay vì Rails.application.routes
. Tuy nhiên về cách khai báo routes thì không có gì khác biệt.
Và mount một engine vào parent app trong config/routes.rb
của parent app:
Rails.application.routes.draw do
mount Foo::Engine, at: "foo"
end
Khi đó chúng ta có thể truy cập giao diện của engine foo thông qua địa chỉ localhost:3000/foo
trong development.
Có một vấn đề thú vị ở đây: Làm sao để chúng ta có thể link tới các routes của parent app từ trong engine và ngược lại?
Ví dụ, trong engine chúng ta muốn có một link tới trang chủ của parent app thay vì của engine, nếu làm như thông thường:
= link_to "Home", root_path
thì link Home
sẽ dẫn tới trang chủ của Foo
engine thay vì của parent app.
Chúng ta có 2 giải pháp cho việc này:
# use relative path
= link_to "Home", "/"
# use main_app helper provided by Rails
= link_to "Home", main_app.root_path
Tương tự đối với việc link từ parent app tới các routes trong engine:
# use relative path
= link_to "Foo Posts", "/foo/posts"
# use foo.path
= link_to "Home", foo.posts_path
Gem Dependencies
Engine có Gemfile
giống như Rails app thông thường, tuy nhiên chúng ta sẽ không dùng Gemfile
để khai báo gem dependencies vì engine có thể được cài đặt giống như một gem, và khi install một gem, các dependencies trong Gemfile
sẽ bị bỏ qua. Thay vào đó, chúng ta sẽ khai báo gem dependencies trong .gemspec
Gem::Specification.new do |s|
# Specify Ruby version
s.required_ruby_version = "2.3.0"
# Gems that are used for all envinronments
s.add_dependency "rails", "~> 4.2.6"
# Gems that are used only for development
s.add_development_dependency "pry"
end
Một số gem đòi hỏi được require trước khi engine được load, có thể khai báo require các gem này trong engine.rb
require "some_lib"
require "another_lib"
module Foo
class Engine < ::Rails::Engine
isolate_namespace Foo
end
end
mountable engine và full engine
Khi tạo engine, chúng ta có hai lựa chọn: tạo full engine hoặc tạo mountable engine
rails plugin new foo --mountable # create mountable engine
rails plugin new foo --full # create full engine
Điểm khác biệt lớn nhất giữa hai kiểu engine này là mountable engine hoạt động trong một namespace được cô lập, full engine thì không. Điều này sẽ dẫn tới những điểm khác nhau như sau:
- mountable engine giống như một ứng dụng chạy song song với parent app, còn full engine thì tích hợp vào trong parent app
- full engine chia sẻ models, views, controllers, helpers, và routes với parent app, mountable engine thì không
- mountable engine sử dụng layout, js, css của riêng mình, full engine dùng chung với parent app
- mountable engine cần được "mount" vào parent app thông qua
config/routes.rb
, full engine thì không
Phần lớn engine được dùng với mục đích tách rời các component để dễ maintain và tái sử dụng nên mountable engine là kiểu engine chủ yếu được sử dụng. Các bài viết trên Internet về engine cũng tập trung xoay quanh mountable engine.
Models và Migrations
Models
Việc tạo một ActiveRecord model trong engine giống như với Rails app
rails g model Post title:string content:text
Model được tạo sẽ được wrap trong module Foo
và table được tạo ra sẽ có tiền tố foo_
. Chúng ta có thể thay đổi tiền tố này bằng việc override hàm ::table_name_prefix
trong lib/foo.rb
module Foo
def self.table_name_prefix
"baz_"
end
end
Tuy nhiên Rails generator sẽ không quan tâm đến thay đổi này, các table do Rails generator tạo ra vẫn sẽ có tiền tố foo_
, bạn cần phải sửa các migration files bằng tay.
Vấn đề với migration files
Nếu trong engine có các file migrations, chúng ta có 2 lựa chọn:
- sao chép các file migrations từ engine ra parent app
- config để mỗi lần chạy
db:migrate
từ parent app, Rails sẽ tự động load các migrations từ engine
Với lựa chọn thứ nhất, mỗi lần có thay đổi về migrations trong engine chúng ta đều cần chạy lại rake task sau để cập nhật migrations từ engine qua parent app:
# rake engine_name:install:migrations
# only copy not-yet-present migrations from Foo engine
rake foo:install:migrations
hoặc
# copy not-yet-present migrations from all mountable engines
rake railties:install:migrations
Điều này khá là phiền phức nếu chúng ta đang phát triển engine đồng thời với parent app. Hơn nữa, nếu làm như vậy mỗi file migration sẽ được lưu ở 2 chỗ. Với cách 2, chúng ta sẽ không còn phải lo lắng về những chuyện này. Config để cho parent app tự động load migrations của engine như sau:
# engine.rb
module Foo
class Engine < ::Rails::Engine
isolate_namespace Foo
initializer :append_migrations do |app|
unless app.root.to_s.match root.to_s
config.paths["db/migrate"].expanded.each do |expanded_path|
app.config.paths["db/migrate"] << expanded_path
end
end
end
end
end
Testing
Dummy App
Trong câu lệnh tạo engine bên trên có 1 tùy chọn:
--dummy-path=spec/dummy
Tùy chọn này sẽ tạo ra một dummy app cho engine trong thư mục spec/dummy
của engine.
Dummy app trong engine được dùng để chạy test vì engine không có cấu trúc đầy đủ của một Rails app. Chúng ta có thể phải migrate database cho dummy app để chạy test
rake app:db:migrate
rake app:db:test:prepare
Hai lệnh trên sẽ sao chép schema.rb
từ parent app và migrate vào development
và test
databases của dummy app.
Database Cleaner
database_rewinder bị lỗi khi chạy trong engine, hãy dùng database_cleaner.
Config database_cleaner
trong spec/rails_helper
của engine:
RSpec.configure do |config|
config.use_transactional_fixtures = false
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Lưu ý: tùy chọn use_transactional_fixtures
phải được thiết lập là false
, nếu không database_cleaner
sẽ không hoạt động.
Controller Testing
Trong controller spec, cần chỉ định sử dụng routes của engine, nếu không controller tests sẽ bị lỗi:
routes { Foo::Engine.routes }
factory_girl
Việc sử dụng factory_girl
cho engine cũng tương tự như sử dụng cho Rails app bình thường. Tuy nhiên đôi khi chúng ta sẽ có nhu cầu sử dụng các factories
của engine trong parent app hay trong 1 engine khác. Thay vì phải copy factories
từ engine qua parent app và cập nhật mỗi khi có thay đổi, chúng ta có thể sử dụng thủ thuật để load factories
của engine vào parent app / engine khác như sau:
Trong engine gemspec, thêm "spec/factories/**/*"
vào s.files
Gem::Specification.new do |s|
s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile",
"README.md", "spec/factories/**/*"]
end
Trong engine config, thêm đoạn code sau để tự động load engine factories vào app require engine đó:
if Rails.env.test?
initializer "ukokkei_core.factories", after: "factory_girl.set_factory_paths" do
require "factory_girl_rails"
path = File.expand_path("../../../spec/factories", __FILE__)
FactoryGirl.definition_file_paths << path
end
end
Một số chú ý khác
Dùng annotate
gem với engine
Annotate
gem giúp generate schema của các model, tuy vậy annotate
không hỗ trợ engine.
https://github.com/ctran/annotate_models/issues/190
https://github.com/ctran/annotate_models/pull/292
Annotate
từ 2.7.0
trở lên hỗ trợ config multiple root_dir
giúp cho việc annotate các models trong engines của app dễ dàng hơn
gem 'annotate', '>= 2.7.0' # require rake ~> 10.4
# or
gem 'annotate', github: 'ctran/annotate_models' # for rake >= 10.4
Trong auto_annotate_models.rake
, chúng ta sẽ config 2 thuộc tính root_dir
và model_dir
như sau:
'model_dir' => ["app/models", "vendor/foo/app/models"],
'root_dir' => ["/", "vendor/foo"],
Dùng gem config
cho engine
http://stackoverflow.com/questions/20597501/how-to-integrate-rails-config-under-rails-engine
Assets path config
http://stackoverflow.com/questions/10224296/add-asset-path-in-rails-mountable-engine
module MyEngine
class Engine < ::Rails::Engine
config.assets.paths << File.expand_path("../../assets/stylesheets", __FILE__)
config.assets.paths << File.expand_path("../../assets/javascripts", __FILE__)
config.assets.precompile << %w[ my_engine.css ]
end
end
Load i18n files từ engine vào main app
Theo documentation, main app sẽ load locale files từ config/locales
của engine, tuy nhiên trong nhiều trường hợp main app sẽ không load đầy đủ. Chúng ta sẽ phải config cho engine để khi load engine, main app sẽ load toàn bộ locale files của engine:
module MyEngine
class MyEngine < Rails::Engine
config.before_initialize do
config.i18n.load_path += Dir["#{config.root}/config/locales/**/*.yml"]
end
end
end
# or
module MyEngine
class MyEngine < Rails::Engine
initializer 'MyEngine', before: :load_config_initializers do
Rails.application.config.i18n.load_path += Dir["#{config.root}/config/locales/**/*.yml"]
end
end
end
Xem thêm tại đây
Extend model
Giả sử một model được khai báo trong engine A, chúng ta muốn extend model đó trong main app hoặc trong một engine khác (giả sử engine B)
# app/models/engine_a/foo.rb
require_dependency EngineA::Engine.root.join('app', 'models', 'engine_a', 'foo').to_s
module EngineA
class Foo
# methods
end
end
Xem thêm tại đây