Rails Engine

Engine实际上是一个Rails Railtie。Railtie允许你在Rails的启动过程中添加自定义的文件等。

这样的话,就可以把一些具有完整功能的Rails应用,作为插件嵌入到你自己的应用中。

应用

Engine 可以想成是抽掉了某些功能的 Rails 应用程序: 微型的 Rails 应用程序 。可以安装到(mount)宿主(你想要扩展的那个应用程序),为宿主添加新功能。Rails 本身也是个 Engine,Rails 应用程序 Rails::Application 继承自 Rails::Engine,其实 Rails 不过就是个“强大的” Engine。

Rails 还有插件功能,插件跟 Engine 很像。两者都有 lib 目录结构,皆采用 rails plugin new 来产生 Engine 与插件。Engine 可以是插件;插件也可是 Engine。但还是不太一样,Engine 可以想成是“完整的插件”。

下面会用一个 blorgh Engine 的例子来讲解。这个 blorgh 给宿主提供了:新增文章(posts)、新增评论(comments),这两个功能。我们会先开发 Engine,再把 Engine 安装到应用程序。

假设路由里有 posts_path 这个 routing helper,宿主会提供这个功能、Engine 也会提供,这两者并不冲突。也就是说 Engine 可从宿主抽离出来。稍后会解释这是如何实现的。

记住!宿主的优先权最高,Engine 不过给宿主提供新功能。

以下皆是以 Rails Engine 实现的 RubyGems:

历经了多少无数的编程夜晚,本来与 Rails 错综复杂各种核心功能,在 Rails 3.1 起,全都被抽离出来,变成 Rails::Engine 了,甚至 Rails 本身也是个 Engine:

require 'rails'
Rails::Application.superclass
=> Rails::Engine

Rails 3.2 Engine 正式当家。

产生 Engine

用 plugin 产生器来产生 Engine(加上 --mountable 选项):

$ rails plugin new blorgh --mountable

看看产生出来的 Engine 的目录结构:

.
├── app
├── bin
├── blorgh.gemspec
├── config
├── lib
├── test
├── Gemfile
├── Gemfile.lock
├── MIT-LICENSE
├── README.rdoc
└── Rakefile

--help 可查看完整说明:

$ rails plugin --help

让我们看看 --full 选项跟 --mountable 的差异,--mountable 多加了下列文件:

  • Asset Manifest 文件(application.cssapplication.js)。
  • Controller application_controller.rb
  • Helper application_helper.rb
  • layout 的 view 模版: application.html.erb
  • 命名空间与 Rails 应用程序分离的 config/routes.rb

    • --full

      Rails.application.routes.draw do
      end
      
    • --mountable

      Blorgh::Engine.routes.draw do
      end
      
  • lib/blorgh/engine.rb

    • --full

      module Blorgh
        class Engine < ::Rails::Engine
        end
      end
      
    • --mountable

      module Blorgh
        class Engine < ::Rails::Engine
          isolate_namespace Blorgh
        end
      end
      

除了上述差异外,--mountable 还会把产生出来的 Engine 安装至 test/dummy 下的 Rails 应用程序,test/dummy/config/routes.rb:

mount Blorgh::Engine, at: "blorgh"

Engine 里面有什么

Engine 目录结构:

.
├── app
├── bin
├── blorgh.gemspec
├── config
├── db
├── lib
├── test
├── Gemfile
├── Gemfile.lock
├── MIT-LICENSE
├── README.rdoc
└── Rakefile

重要的文件

blorgh.gemspec

当 Engine 开发完毕时,安装到宿主的时候,需要在宿主的 Gemfile 添加:

gem 'blorgh', path: "vendor/engines/blorgh"

运行 bundle install 安装时,Bundler 会去解析 blorgh.gemspec,并安装其他相依的 Gems;同时,Bundler 会 require Engine lib 目录下的 lib/blorgh.rb,这个文件又 require 了 lib/blorgh/engine.rb,达到将 Engine 定义成 Module 的目的:

# lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engine
    isolate_namespace Blorgh
  end
end

lib/blorgh/engine.rb 可以放 Engine 的全局设定。

Engine 继承自 Rails::Engine,告诉 Rails 说:嘿!这个目录下有个 Engine 呢!Rails 便知道该如何安装这个 Engine,并把 Engine app 目录下的 model、mailers、controllers、views 加载到 Rails 应用程序的 load path 里。

isolate_namespace 方法非常重要!这把 Engine 的代码放到 Engine 的命名空间下,不与宿主冲突。

加了这行,在我们开发 Engine,产生 model 时 rails g model post 便会将 model 放在对的命名空间下:

$ rails g model Post
invoke  active_record
create    db/migrate/20130921084428_create_blorgh_posts.rb
create    app/models/blorgh/post.rb
invoke    test_unit
create      test/models/blorgh/post_test.rb
create      test/fixtures/blorgh/posts.yml

数据库的 table 名称也会更改成 blorgh_posts。Controller 与 view 同理,都会被放在命名空间下。

想了解更多可看看 isolate_namespace 的源码

app 目录

app 目录下有一般 Rails 应用程序里常见的 assetscontrollershelpersmailersmodelsviews

app/assets 目录

app/assets/
├── images
│   └── blorgh
├── javascripts
│   └── blorgh
│       └── application.js
└── stylesheets
    └── blorgh
        └── application.css

Engine 所需的 imagesjavascriptsstylesheets,皆放在 blorgh 下(命名空间分离):

app/controllers 目录

Engine controller 的功能放这里。

app/controllers/
└── blorgh
    └── application_controller.rb

注意到

module Blorgh
  class ApplicationController < ActionController::Base
  end
end

命名成 ApplicationController 的原因,是让你能够轻松的将现有的 Rails 应用程序,抽离成 Engine。

app/views 目录

app/views/
└── layouts
    └── blorgh
        └── application.html.erb

Engine 的 layout 放这里。Engine 单独使用的话,就可以在这里改 layout,而不用到 Rails 应用程序的 app/views/layouts/application.html.erb 下修改。

要是不想要使用 Engine 的 layout,删除这个文件,并在 Engine 的 controller 指定你要用的 layout。

bin 目录

bin
└── rails

这让 Engine 可以像原本的 Rails 应用程序一样,用 rails 相关的命令。

test 目录

test
├── blorgh_test.rb
├── dummy
│   ├── README.rdoc
│   ├── Rakefile
│   ├── app
│   ├── bin
│   ├── config
│   ├── config.ru
│   ├── db
│   ├── lib
│   ├── log
│   ├── public
│   └── tmp
├── fixtures
│   └── blorgh
├── integration
│   └── navigation_test.rb
├── models
│   └── blorgh
└── test_helper.rb

关于 Engine 的测试放这里。里面还附了一个 test/dummy Rails 应用程序,供你测试 Engine,这个 dummy 应用程序已经装好了你正在开发的 Engine:

Rails.application.routes.draw do
  mount Blorgh::Engine => "/blorgh"
end

test/integration

Engine 的整合测试(Integration test)放这里。其他相关的测试也可以放在这里,比如关于 controller 的测试(test/controller)、关于 model (test/model)的测试等。

4.4.2 配置 Engine

initializer、i18n、或是做其他的设定,在 Engine 里怎么做呢?Engine 其实就是个微型的 Rails 应用程序,所以可以像是在 Rails 里面那般设定。

要设定 initializer,在 Engine 目录 config/initializers 新增你的设定即可。关于 initializer 的更多说明请参考 Rails 官方文件的 Initalizers section

语系设定放在 Engine 目录下的 config/locales 即可。

就跟设定 Rails 应用程序一样。

Initalizer 例子:Devise devise_for

用过 Devise 的同学可能在 config/routes.rb 都看过 devise_for ,大概是怎么实现的呢?

以下代码仅做示意之用,并非实际 Devise 的代码:

# lib/devise/engine.rb
require 'devise/routing_extensions'

module Devise
  class Engine < ::Rails::Engines
    isolate_namespace Devise

    initializer 'devise.new_routes', after: 'action_dispatch.prepare_dispatcher' do |app|
      ActionDispatch::Routing::Mapper.send :include, Devise::RouteExtensions
    end
  end
end
# lib/devise/routing_extensions.rb
module Devise
  module RouteExtensions
    def devise_for
      mount Devise::Engine => "/user"
      get "sign_in", :to => "devise/sessions#new"
    end
  end
end

变更 Engine 默认的 ORM、模版引擎、测试框架

# lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engines
    isolate_namespace Blorgh
    config.generators.orm             :datamapper
    config.generators.template_engine :haml
    config.generators.test_framework  :rspec
  end
end

亦可:

# lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engines
    isolate_namespace Blorgh
    config.generators do |c|
      c.orm             :datamapper
      c.template_engine :haml
      c.test_framework  :rspec
    end
  end
end

Rails 3.1 以前请使用 config.generators

变更 Engine 的名称

Engine 的名称在两个地方会用到:

  • routes

mount MyEngine::Engine => '/myengine' 有默认的 default 选项 :as

默认的名称便是 as: 'engine_name'

  • 拷贝 migration 的 Rake task (如 myengine:install:migrations

如何变更?

module MyEngine
  class Engine < Rails::Engine
    engine_name "my_engine"
  end
end

添加 Middleware 到 Engine 的 Middleware stack

# lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engines
    isolate_namespace Blorgh
    middleware.use Rack::Cache,
      :verbose => true,
      :metastore   => 'file:/var/cache/rack/meta',
      :entitystore => 'file:/var/cache/rack/body'
  end
end

撰写 Engine 的 Generator

让使用者轻松安装你的 Engine,比如:

$ rake generate blorgh:install

Generator 该怎么写呢?(示意)

# lib/generators/blorgh/install_generator.rb
module Blorgh
  class InstallGenerator < Rails::Generator::Base
    def install
      run "bundle install"
      route "mount Blorgh::Engine" => '/blorgh'
      rake "blorgh:install:migrations"
      ...
    end
  end
end

路由优先权

将 Engine 安装至宿主之后,就会有 2 个 router。让我们看下面这个例子:

# host application
Rails.application.routes.draw do
  mount MyEngine::Engine => "/blog"
  get "/blog/omg" => "main#omg"
end

MyEngine 安装在 /blog/blog/omg 会指向宿主的 main controller 的 omg action。当有 /blog/omg 有 request 进来时,会先到 MyEngine,要是 MyEngine 没有定义这条路由,则会转发给宿主的 main#omg

改写成这样:

Rails.application.routes.draw do
  get "/blog/omg" => "main#omg"
  mount MyEngine::Engine => "/blog"
end

则 Engine 只会处理宿主没有处理的 request。

重新命名 Engine Routing Proxy 方法

有两个地方可换 Engine 名字:

  1. lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engine
    isolate_namespace Blorgh
    engine_name "blogger"
  end
end
  1. 在宿主或是使用 Engine 的(test/dummyconfig/routes.rb
Rails.application.routes.draw do
  mount Blorgh::Engine => "/blorgh", as: "blogger"
end