一步一步DSL
12年的10月份,我准备为公司的彩票和话费充值等业务写一套DSL(Domain Specific Languages),顺路研究了一下Ruby特色的DSL技术,有些收获,于是写些东西作为资料备份。
Ruby中常见的DSL风格及其技术实现
我将通过一些例子来分析各个风格DSL的常见的技术实现,这些例子主要来自一些我们常用的gem的源代码,为了方便分析,我会对其中的代码做一些精简,后面 就不做一一说明了。
嵌套风格
嵌套风格的DSL给人的印象大概是这样,
something *args do |*items|
...
end
- yield
通过yield obj或者block.call(obj)抛出待操作的对象
我们看一个来自rails_admin的例子:
RailsAdmin.config do |config|
config.main_app_name = ["Cool app", "BackOffice"]
config.authorize_with :cancan
end
下面的代码来自RailsAdmin,
# 为了分析, 省略了一些代码
module RailsAdmin
def self.config(&block)
block.call(RailsAdmin::Config)
end
end
代码中block.call(RailsAdmin::Config)的作用和yield RailsAdmin::Config一样,统一称为yield。 可以看出, RailsAdmin.config抛出的config对象就是RailsAdmin::Config,
config.main_app_name = ["Cool app", "BackOffice"]
等同于
RailsAdmin::Config.main_app_name = ["Cool app", "BackOffice"]
-
instance_eval
将&block嵌入到待操作对象的上下文环境中
下面的代码来自Rack::Builder, 注意Rack::Builder#initialize方法的实现,
app = Rack::Builder.new do
use Rack::CommonLogger
use Rack::ShowExceptions
map "/lobster" do
use Rack::Lint
run Rack::Lobster.new
end
end
class Rack::Builder
def initialize(default_app = nil, &block)
@use, @map, @run = [], nil, default_app
instance_eval(&block) if block_given?
end
def use(middleware, *args, &block)
...
end
def map(path, &block)
...
end
end
instance_eval会将&block嵌入到方法调用者的上下文环境中,所以方法use 和 map 是在Rack::Builder.new创建的实例对象里执行的,效果类似于,
app = Rack::Builder.new
app.use Rack::CommonLogger
app.use Rack::ShowExceptions
app.map "/lobster" do
use Rack::Lint
run Rack::Lobster.new
end
-
转储&block
将&block转化为一个对象存起来,并在适当的时候使用。
我们看看来自rake的例子,
task :clobber => [:clean] do
rm_rf "html"
end
task 方法定义在dsl_definition.rb文件中,
module Rake
module DSL
def task(*args, &block)
Rake::Task.define_task(*args, &block)
end
end
end
self.extend Rake::DSL
Rake::Task.define_task(*args, &block)最终在Rake::Task#enhance里实现, 而Rake::Task#execute将触发&block执行。 self.extend Rake::DSL 为当前的main对象扩展出task方法,
module Rake
class Task
def enhance(deps=nil, &block)
@actions << block if block_given?
self
end
def execute(args=nil)
@actions.each do |act|
case act.arity
when 1
act.call(self)
else
act.call(self, args)
end
end
end
end
end
我们可以看到&block在Rake::Task#enhance方法里被转换为Proc对象后追加到了@actions这个数组的后面。 将&block转化为Proc对象存起来是件很容易的事情,&block去掉&就获得了一个Proc对象,接下来我们还可以看到使用其他手段转储&block。 我们熟悉的rspec中的describe和it等方法是通过使用module_eval转储&block来实现DSL的,
describe Bowling, "#score" do
it "returns 0 for all gutter game" do
bowling = Bowling.new
20.times { bowling.hit(0) }
bowling.score.should eq(0)
end
end
现在我们来分析下describe和it的大概实现过程。 describe方法是在Rspec::Core::DSL中定义的,然后通过extend RSpec::Core::DSL变成了main对象的一个方法:
module RSpec
module Core
module DSL
def describe(*args, &example_group_block)
RSpec::Core::ExampleGroup.describe(*args, &example_group_block).register
end
end
end
end
extend RSpec::Core::DSL
Module.send(:include, RSpec::Core::DSL)
&example_group_block这个block被传递到RSpec::Core::ExampleGroup.describe方法里,我们看看在RSpec::Core::ExampleGroup.describe方法里能否找到我们想要的东西,
module Rspec
module Core
class ExampleGroup
def self.describe(*args, &example_group_block)
child = const_set(
"Nested_#{@_subclass_count}",
subclass(self, args, &example_group_block)
)
children << child
child
end
def self.subclass(parent, args, &example_group_block)
subclass = Class.new(parent)
subclass.module_eval(&example_group_block) if example_group_block
subclass
end
end
end
end
在subclass方法中&example_group_block被转储到了Rspec::Core::ExampleGroup的一个匿名子类中去了。describe方法执行后将会生成一个 Rspec::Core::ExampleGroup的子类并返回,并且此子类包含&example_group_block。
再看看it的实现,
module Rspec
module Core
class ExampleGroup
class << self
def self.define_example_method(name, extra_options={})
module_eval(<<-END_RUBY, __FILE__, __LINE__)
def #{name}(desc=nil, *args, &block)
examples << RSpec::Core::Example.new(self, desc, options, block)
examples.last
end
END_RUBY
end
define_example_method :it
end
end
end
end
module Rspec
module Core
class Example
def initialize(example_group_class, description, metadata, example_block=nil)
@example_group_class, @options, @example_block = example_group_class, metadata, example_block
end
end
end
end
通过 define_example_method :it, it成为了Rspec::Core::ExampleGroup的一个实例方法,it方法执行后会生成一个Rsepc::Core::Example实例对象, it后面挂的&block会被转储到此实例对象里。
-
method_missing
调用不存在的方法其实不可怕
jbuilder是使用method_missing实现嵌套风格DSL的典型例子。 不过Jbuilder#method_missing是一个比较复杂的实现, 为了更容易的理解method_missing的作用机制, 我参照jbuilder的源码写了一个简化版: jbuilder_mini,
require 'multi_json'
class JbuilderMini
def initialize
@attributes = {}
yield self
end
def target!
::MultiJson.dump @attributes
end
private
def method_missing(key, value=nil, *args, &block)
result = if block
_scope { yield }
else
value
end
@attributes[key] = result
end
def _scope
parent_attributes = @attributes
@attributes = {}
yield
@attributes
ensure
@attributes = parent_attributes
end
end
jbuilder_mini只能实现一些很简单的json字符串,
json = JbuilderMini.new do |json|
json.author do
json.name 'David'
json.age 32
json.book do
json.name 'ruby'
json.price 100.0
end
end
end.target!
# {"author":{"name":"David","age":32,"book":{"name":"ruby","price":100.0}}}
JbuilderMini.new会抛出一个JbuilderMini的实例对象json,因为author不是JbuilderMini的实例方法, 所以json.author执行时会调用method_missing。 通过method_missing我们能够拿到未定义的方法名:key,方法的参数:value, *args和块: &block,然后在method_missing里实现我们想要的东西。
链式风格
链条,鞭子,蜡烛,你懂的
在rails2.x时代,写一个稍微复杂的查询是一件让人比较头疼的事情,
Person.find(:all, :conditions => [ "category IN (?) and name = ?", categories, parmas[:name]], :limit => 50, :order => "created_at DESC")
而如今我们可以优雅灵活地构造查询语句,
Person.where(category: categories, name: params[:name]).order('created_at DESC').limit(50)
这种便利正是链式风格的DSL带来的。
ActiveRecord::Base的子类使用的各种可以链式调用的方法比如where, select, group, order, limit, joins等都是通过delegate被代理到了ActiveRecord::Relation的实例上去了。我们做一个简化版的ActiveRecord::Relation,
module ActiveRecord
class Relation
def where(opts=:chain, *rest)
...
self
end
def select(*fields)
...
self
end
def group(*args)
...
self
end
def order(*args)
...
self
end
def limit(value)
...
self
end
def joins(*args)
...
self
end
end
end
可以看到这些能够链式调用的方法都有一个共同点就是它们最后都会返回self,返回self是构造链式DSL的一个比较方便自然的手段。
类宏风格
类里面不加self调用类方法
在Ruby中类宏风格的DSL常见且重要,比如我们经常用到的has_many, validate_presence_of, attr_reader等等就是类宏风格的DSL,
class Contest < ActivieRecord::Base
has_many :votes
has_many :entites
validate_presence_of :title
end
class Book
attr_reader :name
end
- ClassMethods 和 InstanceMethods
类宏风格DSL的经典实现是写一个module, 然后在这个module里定义两个module: ClassMethods, InstanceMethods, 以及一个included钩子,
module MaRo
def self.included(base)
base.extend ClassMethods
base.send(:include, InstanceMethods)
end
module ClassMethods
def act_as_something
...
end
end
module InstanceMethods
def im
...
end
end
end
class A
include MaRo
act_as_something
end
在ActiveRecord::Base中与associations这一块相关的DSL,比如has_many, has_one, belongs_to, has_and_belongs_to_many实现的方式和上面类似, 但是它引入了ActiveSupport::Concern机制从而使得这一过程简便和标准化了,
module ActiveRecord
module Associations
extend ActiveSupport::Concern
module ClassMethods
def has_many(name, scope = nil, options = {}, &extension)
Builder::HasMany.build(self, name, scope, options, &extension)
end
def has_one(name, scope = nil, options = {})
Builder::HasOne.build(self, name, scope, options)
end
def belongs_to(name, scope = nil, options = {})
Builder::BelongsTo.build(self, name, scope, options)
end
def has_and_belongs_to_many(name, scope = nil, options = {}, &extension)
Builder::HasAndBelongsToMany.build(self, name, scope, options, &extension)
end
end
end
end
module ActiveRecord
class Base
include Associations
end
end
- define_method
用 define_method 实现一个类似attr_reader的宏,
class A
def self.aattr_reader *attrs
attrs.each {|attr|
define_method attr do
instance_variable_get "@#{attr}"
end
}
end
end
class B < A
aattr_reader :foo, :bar
def initialize
@foo = "foo"
@bar = "bar"
end
end
b = B.new
b.foo #=> foo
b.bar #=> bar
补丁风格
子类通过特定的实例方法实现自己的特性
ActiveRecord::Migration是补丁风格DSL的一个很好的例子,
class CreateCategories < ActiveRecord::Migration
def change
create_table :categories do |t|
t.string :name
t.timestamps
end
end
end
class AddRoleToUsers < ActiveRecord::Migration
def change
add_column :users, :role, :string, default: 'user' # 账号角色, user 普通用户, admin 管理员, sponsor 赞助商
end
end
CreateCategories和AddRoleToUsers这个两个migration class继承自ActiveRecord::Migration,但是它们各自通过change
这个补丁实现了
不同的功能,CreateCategories可以创建和销毁categories表,AddRoleToUsers可以增加和移除users表的role字段。通过打补丁,每个子类都可以
拥有一份自留地,种白菜,种萝卜,种点草都行。现在我们分析下ActiveRecord::Migration中补丁风格DSL实现的大致手段,
module ActiveRecord
class Migration
# direction 为 :up或者:down, 其中:down表示回滚
def migrate(direction)
ActiveRecord::Base.connection_pool.with_connection do |conn|
exec_migration(conn, direction)
end
end
def exec_migration(conn, direction)
@connection = conn
if direction == :down
revert { change }
else
change
end
end
def connection
@connection || ActiveRecord::Base.connection
end
def method_missing(method, *arguments, &block)
connection.send(method, *arguments, &block)
end
end
end
change方法执行的入口在exec_migration这个方法中,在exec_migration方法里,如果direction是:down即回滚,则以revert的形式执行change,否则直接执行change。
我们要知道诸如create_table, add_column, change_column这些方法并没有直接定义在ActivieRecord::Migration中,而是定义在module ActivieRecord::ConnectionAdapeters::SchemaStatements中的, ActiveRecord::ConnectionAdapters::AbstractAdapter include了模块 ActivieRecord::ConnectionAdapeters::SchemaStatements,
module ActiveRecord
module ConnectionAdapters
module SchemaStatements
def create_table(table_name, options = {})
...
end
def add_column(table_name, options = {})
...
end
def change_cloumn(table_name, options = {})
...
end
end
end
end
module ActiveRecord
module ConnectionAdapters
class AbstractAdapter
include SchemaStatements
end
end
end
最后, ActiveRecord::Base.connection.class.supperclass 就是 ActiveRecord::ConnectionAdapters::AbstractAdapter, 所以我们不难明白为什么ActiveRecord::Migration的实例调用的create_table, add_column等方法是通过method_missing代理到 ActiveRecord::Base.connection这个实例上去的。
DSL实践
ActiveRecord::Base是一个非常大的class,它所包含的那一套DSL内容非常丰富,包括了表间关系,字段验证,回调,事务等很多东西,我们暂且把这种 体积大的DSL叫做Big DSL,接下来我们要来实践一种小DSL,即Small DSL。
-
(D)omain, 域
实现Small DSL首先应该确保Domain足够的准确和足够的具体,这要求Domain不能太大并且保有一定的复杂性。 拿彩票来说,彩票这个域有点大,我们可以对它进行细化,比如细化为注码的生成,注码的投注等等。 其中注码的生成这个域也很大,可以继续细化为双色球注码生成, 福彩3D注码生成等等, 注码的生成即使细化到了彩种可能还是不够,还可以针对渠道做进一步细化,比如说渠道A的双色球的注码生成, 此时这个域就比较具体,可以写代码实现了。
-
(S)pecific, 专
只做一件事情并且把这件事情做好。
-
(L)anguages, 言
有可以重复使用的语法和词汇。
Samll DSL的Domain在ruby中是什么样子?
- 是一个class
- 可以接收外部数据
根据上面的定义我写了一个叫Dun::Land的class,可以用Dun::Land对Domain进行封装。 Dun::Land来自于一个叫dun的gem,可以通过gem install dun安装。 dun的详细用法可以看看README。
class SomeDomain < Dun::Land
data_reader :image, :name
def call
puts "#{image} #{name}"
end
end
SomeDoamin image: 'fly.jpg', name: '秋天的味道'
data_reader 的作用是把外部数据变成域的实例方法,方便读取数据,
例如data_reader :image, :name
把image和name变成了SomeDomain的两个实例方法。
继承自Dun::Land的子类会自动拥有一个和类同名的全局方法,比如,
class Foo < Dun::Land
end
会生成一个叫Foo
的全局方法。
简单例子: 用户验证
用户登录时,我们需要判断用户的邮箱和密码是否匹配,如何实现这个功能呢? 传统的实现方法如下,
class User < ActiveRecord::Base
def self.authenticate(email, password)
user = find_by_email(email)
if user && user.password_hash == BCrypt::Engine.hash_secret(password, user.password_salt)
user
else
nil
end
end
end
user = User.authenticate "jim@126.com", "secret"
现在我们用另一种方式去实现这个功能,首先将用户验证抽象为一个域: AuthenticateUser,
class AuthenticateUser < Dun::Land
data_reader :email, :password
def call
user = User.find_by_email(email)
if user && user.password_hash == BCrypt::Engine.hash_secret(password, user.password_salt)
user
else
nil
end
end
end
user = AuthenticateUser email: 'jim@126.com',password: 'secret'
看起来和传统实现方法没有太大的区别。现在我们有了(D)omain,那么(L)anguages在哪呢? 语法和词汇在哪呢? 为了让这个例子能够进行下去,我们想像数据库里还有一个叫admins的表,admins表里存放着系统管理员的帐号, 同样当这些管理员登录后台时,我们需要对其进行验证,按传统的方法,我们又写了下面的代码,
class Admin < ActiveRecord::Base
def self.authenticate(name, password)
admin = find_by_name(name)
if admin && admin.password_hash == BCrypt::Engine.hash_secret(password, admin.password_salt)
admin
else
nil
end
end
end
admin = Admin.authenticate "admin01", "secret"
如果又出现其他的模型需要认证,那么我们需要按照同样的步骤去添加代码,这些代码不完全相同,但是逻辑和控制结构几乎完全相同, 我们应该怎样去消除代码在逻辑和结构上的重复呢?在传统的方法上面去解决这类问题比较麻烦,原因在于User, Admin这些类相对于验证这个域体积过大了。 那我们用第二种方法试试,依葫芦画瓢, 我们封装域: AuthenticateAdmin,
class AuthenticateAdmin < Dun::Land
data_reader :name, :password
def call
admin = Admin.find_by_name(name)
if admin && admin.password_hash == BCrypt::Engine.hash_secret(password, admin.password_salt)
admin
else
nil
end
end
end
admin = AuthenticateAdmin name: 'admin01', password: 'secret'
用户认证这个域很小,很快我们就成为了其中的砖家,我们开始为其造些语法和词汇吧,
class AuthenticateUser
auth :user, with: 'email'
end
class AuthenticateAdmin
auth :admin, with: 'name'
end
auth model_name, with: col_name
,读起来还算通顺。 现在有了语法和词汇,接下来我们开始写解释器! 由于只有一条语法,两个词(auth和with),这个解释器应该不会很复杂。
但是不管怎么说,我们要写的东西是一个解释器,好像很牛逼的样子,可是我对解释器一窍不通,有时连字节和位的关系都拈不清,
能写出解释器吗? 不过不要顾虑太多,因为我们马上要写的解释器是一个很普通,很简单的class。
定义Authenticate域,它将作为AuthenticateUser和AuthenticateAdmin的父类,对,Authenticate就是我们 要写的解释器。
class Authenticate < Dun::Land
data_reader :password
class << self
def auth model_name, opts = {}
col_name = opts[:with]
data_reader col_name
define_method :model do
@model ||= Object.const_get model_name.capitalize
end
define_method :auth_obj do
@auth_obj ||= model.send("find_by_#{col_name}", send(col_name))
end
end
end
def call
if auth_obj && password_match?
auth_obj
else
nil
end
end
private
def password_match?
auth_obj.password_hash == BCrypt::Engine.hash_secret(password, auth_obj.password_salt)
end
end
不超过30行代码,我们就拥有了一个用户验证方面的解释器,看来Small DSL不难实现嘛。虽然这个解释器只能解释一条语法,两个词汇,但是它功能强健, 可以想象,如果系统加入了一个新的模型Account需要验证,我们只需要增加一个AuthenticateAccount域就可以了,
class AuthenticateAccount < Authenticate
auth :account, with: 'login'
end
AuthenticateAccount login: 'liliy20', password: 'secret'
有点复杂的例子: 积分分配
这个例子来自一个自线上产品。为了鼓励用户踊跃创建比赛,我们的比赛系统需要实现一个奖励积分的功能, 也就是当比赛结束后,如果用户创建的比赛的浏览人数达到一定数值后, 系统会给创建者奖励积分。
比赛的浏览人数与系统奖励积分的关系如下所示:
非调查类比赛:
浏览数小于10,000不奖励积分;
浏览数大于10,000小于25,000,奖励50分;
浏览数大于25,000小于50,000,奖励100分;
浏览数大于50,000 奖励200分;
调查类比赛:
浏览数不到1000, 不奖励积分,
浏览数大于1000小于5000,奖励25分;
浏览数大于5000小于10,000,奖励50分;
浏览数大于10,000小于50,000,奖励100分;
浏览数大于50,000 奖励300分;
非调查类比赛,系统奖励的积分有多种分配方式:
1、比赛创建者得到10%积分,排名第一的图片的上传者得45%,第二得30%
2、比赛创建者得到10%积分,排名第一的图片的上传者得35%,第二得25%
3、比赛创建者得到10%积分,排名第一的图片的上传者得25%,第二得18%
4、最后各人得分比例之和为100%
首先确定两个域NoSuerveyContestCreditAllocate和SuerveyContestCreditAllocate。
先实现SuerveyContestCreditAllocate。一阵鼓捣之后,写出的代码大致如下:
class NonSurveyContestCreditAllocation < Dun::Land
data_reader :contest
def call
allocate_mapper.each {|mapper| allocate_credit mapper }
end
def allocate_mapper
... # 省略具体实现
end
def allocate_credit(mapper)
... # 省略具体实现
end
def credit
get_or_set :credit do
if view_count < 25_000
0
elsif view_count < 50_000 and view_count >= 25_000
100
elsif view_count >= 50_000
200
end
end
end
def rates
get_or_set :rates do
if view_count < 25_000
[0.10, 0.45, 0.30, 0.15]
elsif view_count < 50_000 and view_count >= 25_000
[0.10, 0.35, 0.25, 0.30]
elsif view_count >= 50_000
[0.10, 0.25, 0.18, 0.47]
end
end
end
end
end
在实现此功能的过程中,写了比较完善的单元测试。
然后又是一阵鼓捣,实现了SuerveyContestCreditAllocate,同样写了比较完善的单元测试,
class SurveyContestCreditAllocation < Dun::Land
data_reader :contest
def call
allocate_mapper.each {|mapper| allocate_credit mapper }
end
def allocate_mapper
... # 省略具体实现
end
def allocate_credit(mapper)
... # 省略具体实现
end
def credit
get_or_set :credit do
if view_count < 50_00
0
elsif view_count < 50_00 and view_count >= 10_000
50
elsif view_count >= 10_000 and view_count <= 50_000
200
elsif view_count >= 50_000 and view_count < 100_000
300
end
end
end
def rates
get_or_set :rates do
[1.0, 0.0]
end
end
end
end
通过前面的开发实践,我对积分分配这个问题理解清楚了,测试也达到了预期的效果,并且了解到NonSurveyContestCreditAllocation和SurveyContestCreditAllocation这两个域的区别就在于credit和rates这两个补丁方法上面,于是开始造语法和词汇,
class NonSurveyContestCreditAllocation
credit view_count_less_than: 10_000, then: 0
credit view_count_between: [10_000, 25_000], then: 50
credit view_count_between: [25_001, 50_000], then: 100
credit view_count_more_than: 50_000, then: 200
rate view_count_less_than: 25_000, then: [0.10, 0.45, 0.30, 0.15]
rate view_count_between: [25_000, 50_000], then: [0.10, 0.35, 0.25, 0.30]
rate view_count_more_than: 50_000, then: [0.10, 0.25, 0.18, 0.47]
end
class SurveyContestCreditAllocation
credit view_count_less_than: 1000, then: 0
credit view_count_between: [1000, 5000], then: 25
credit view_count_between: [5001, 10_000], then: 50
credit view_count_between: [10_001, 50_000], then: 100
credit view_count_more_than: 50_000, then: 300
rate default: [1.0, 0.0]
end
读起来还蛮通顺的,剩下的工作就是写解释器来实现自己刚才读到的语言。这个小语言有两条语法: credit 和 rate;七个词汇: credit, rate, view_count_less_than, view_count_between, view_count_more_than,then 和 default。 首先定义一个叫CreditAllocation的域,这个域就是我们要实现的解释器,同时CreditAllocation还可以作为上面两个域的namespace。接下来一阵鼓捣,感谢前面写的单元测试,我们的CreditAllocation解释器通过测试,顺利完成了,最终的代码如下,
class CreditAllocation
class NonSurveyContest < self
credit view_count_less_than: 10_000, then: 0
credit view_count_between: [10_000, 25_000], then: 50
credit view_count_between: [25_001, 50_000], then: 100
credit view_count_more_than: 50_000, then: 200
rate view_count_less_than: 25_000, then: [0.10, 0.45, 0.30, 0.15]
rate view_count_between: [25_000, 50_000], then: [0.10, 0.35, 0.25, 0.30]
rate view_count_more_than: 50_000, then: [0.10, 0.25, 0.18, 0.47]
end
end
CreditAllocation::NonSurveyContest contest: non_suervey_contest
class CreditAllocation
class SurveyContest < self
credit view_count_less_than: 1000, then: 0
credit view_count_between: [1000, 5000], then: 25
credit view_count_between: [5001, 10_000], then: 50
credit view_count_between: [10_001, 50_000], then: 100
credit view_count_more_than: 50_000, then: 300
rate default: [1.0, 0.0]
end
end
CreditAllocation::SurveyContest contest: suervey_contest
现在我们有积分奖励语言并且有健壮的CreditAllocation解释器(通过了完善的单元测试),那么我们会发现给系统增加一种新的,有不同积分奖励规则的比赛是一件令人愉快的事情。