当出现同一个功能模块需要被多个 Rails 项目使用时, Rails 引擎(engine) 就是一个很好的选择。
关于 Rails Engine, 官方文档已经写的很详细了。
这里我记录一下开发一个引擎时各部分的写法。
初始化
rails plugin new foo --mountable
这会创建一个引擎的基本骨架,这里 foo
就是引擎的名字,由于使用了 --mountable
选项。
Foo
这个单词会作为命名空间的存在,非常重要。
目录结构
一个引擎的目录简单来说就是一个加了命名空间的 rails 项目。其 controller model view 包括 assets layout 都要加命名空间(rails 中命名空间就对应文件目录)。
一个常规的 rails 项目本身也是一个引擎,宿主和引擎之间没什么本质的区别,存在的差异只是因为职责的不同而已。
由于引擎是依附于宿主 rails 应用的,因此引擎有自己的 gemspec
(e.g foo.gemspec
),引擎以 gem 的形式存在。
只不过相比于普通的 gem,它更加全面,mvc 三层都被覆盖到了。
不过引擎一般没有自己的持久化配置,因为引擎是要被宿主调用的,因此数据库配置一般都是由宿主决定的,引擎只提供逻辑代码。
另外引擎可以有自己的 migration,宿主可以使用命令 rake foo:install:migrations
来把存在于引擎 foo
中的 migration 复制到自己的 db/migrate
目录下,便于执行。
与宿主的交互
在编写引擎的逻辑时和一般的 rails 应用没什么区别,重点就是如何和宿主应用进行交互。
路由
一个最简单的引擎只需要被宿主挂载就可以了,只要在宿主的路由中加入一行:
mount Foo::Engine => "/foo"
, 意为引擎 Foo
的 base url
就是 /foo
。
如果不用这个 base url
把路由区分开来的话,就有可能出现路由被覆盖的情况。
注意这里的 base url
和引擎的命名空间完全没有关系, 因此引擎当中的所有内部跳转 必须 全部使用
具名路由来表示,这样才不会被 base url
所影响。
assets
引擎的 assets 是完全独立的,要设定 precompile 的话需要在 lib/foo/engine.rb
中定义:
initializer "foo.assets.precompile" do |app|
app.config.assets.precompile += %w(foo/admin.css foo/admin.js)
end
assets 的路径(包含 css,js,图片等)都要使用 rails 提供的 helper 方法(assets_path 等)来指定。否则会出现和路由一样的问题。
view
view 层是最不通用的一层,用过 devise 的都知道, view 层是几乎一定会被 overwrite 的。
所以写引擎的时候尽量把 view 层写的简洁一些,不要有复杂逻辑,这样 overwrite 的时候比较方便。
注意 layout 文件的位置是 layouts/foo/xxx
而不是 foo/layouts/xxx
哦。
model
引擎中的模型都是带有命名空间的(它们对应的表名也有命名空间的前缀), 在调用时最好加上命名空间(不然可能出现找不到定义的情况)。这点可以参考 Shoppe,包括在定义模型的关系时,也最好指定带有命名控件的 Class Name。
controller
这是有点麻烦的一层,跟宿主的耦合比较严重,比如说如果我想要复用宿主应用中的权限管理(在宿主的 app/controller/admin/base_controller.rb
中),那么我不得不让引擎的 base_controller
都继承 ::Admin::BaseController
才行,这是非常严重的耦合。
rails guide 是这么写的:
An easy way to provide this access is to change the engine's scoped ApplicationController to inherit from the main application's ApplicationController.
意思是说哪怕是非常简单的获得 current_user
的方法,我也要定义在宿主应用的 application_controller
中才能让引擎调用到,我写在 base_controller
中就不行了。
目前看起来这个问题无解。。。好在自己开发引擎的情况绝大多数都是给内部应用使用的,所以这也不算很大的问题。
tips
独立数据源
如果引擎需要自己的独立数据库,可以让引擎中的 model 都继承一个 base_model, 在其中配置数据源:
module Foo
class BaseModel < ActiveRecord::Base
self.abstract_class = true
establish_connection "foo_#{Rails.env}".to_sym
end
end
ps: 如果宿主使用的不是 active_record
,而引擎是的话,这种情况可以不指定 establish_conecton
, 一样可以做到引擎有独立的数据源, 因为框架还是会读取 config/database.yml
,然后根据使用的持久化框架来自动指定对应的数据库。
元编程
在引擎中难免需要和宿主中的类和对象进行交互,这样就需要动态的修改宿主程序。
这就要用到元编程了,在 ruby 语言中,使用元编程更是家常便饭了。
在引擎中一般都在 lib/foo.rb
中个引擎同名文件中进行一些初始化工作,当然也包含元编程。
这里需要引入 decorators 这个 gem,然后就可以开始对宿主的类和对象开刀啦。
要使用 decorators
,要先在 lib/foo/engine.rb
中初始化:
class << self
attr_accessor :root
def root
@root ||= Pathname.new(File.expand_path('../../../', __FILE__))
end
end
config.to_prepare do
Decorators.register! Engine.root, Rails.root
end
常见的例子就是给宿主的 User 类添加一些和引擎的逻辑相关的方法。
require 'decorators'
module Foo
mattr_accessor :user_class
class << self
def decorate_user_class!
Foo.user_class.class_eval do
def say_hello
puts "hello from foo"
end
end
end
def user_class
Object.const_get(@@user_class)
end
end
end
顺便说一句,这里的 User(用户)
的类名是可以在宿主里指定的,只要在宿主初始化的时候定义诸如:
Foo.user_class = "Student"
这样的一行代码就行了。