Ghi chép về Rails Engine

by Giang, last updated 19 Jan 2018

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ục vendor/foo của parent app
  • Sử dụng Rspec làm test framework cho Foo::Engine

Mục lục

  1. Giới thiệu
  2. Models và Migrations
  3. Testing
  4. Một số chú ý khác
  5. 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 app
  • config/routes.rb dành cho việc thiết lập các routes trong engine
  • foo.gemspec giống như gemspec của một gem, thay thế cho Gemfile
  • 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:

  1. sao chép các file migrations từ engine ra parent app
  2. 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 developmenttest 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

Xem thêm ở đâyở đây

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_dirgiú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_dirmodel_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

Tài liệu tham khảo