Ruby clean code之block and instance eval
引子
自从来到ruby世界,我就被ruby那自由的语法、优雅的对象模型、漂亮的dsl深深地迷住了,了解更多的ruby特性,能够帮你实现更漂亮,流畅的api。在这篇文章中,我将以一个例子来演示如何使用ruby的block和instance_eval实现更具表现力的api。
需求
这个例子是一个来源于真实项目需求,为了演示方便,我对其做了一些简化。程序的输入是一个格式固定的json字符串,输出是从这个json中获取到一些属性值创建出来的一个给定类型的对象。然而,不同于以往的json和对象之间的序列化,反序列化,这里的从json字符串中的值与对象属性之间的对应关系有一定的逻辑。 json中的值和对象值对应关系有如下几种:
- json属性和对象属性直接对应。
- json属性和对象属性直接对应,当json中没有该属性时,使用给定默认值。
- 对象的属性的类型不是普通类型,当json中有对应属性的值时,需要根据json中的值创建一个对应的类型对象。
- 等等
我们先来看下最初的实现版本:
注:这里的json
不是一个字符串对象,而是经过JSON.parse
处理后得到一个嵌套的hash,下同。
class Post
attr_accessor :author_name, :date, :tags
def initialize(json)
init_author_name(json)
init_date(json)
init_tags(json)
#init_xxx...
end
# omit some code here...
# json中的title和对应上面的第一种情形
def init_author_name(json)
@author_name = json['author']['name']
end
# 对应上面的第二种情形
def init_date(json)
@date = json['date'].blank? ? "1970-01-01" : json['date']
end
# 对应上面的第三种情形
def init_tags(json)
@tags = []
unless json['tags'].nil?
json['tags'].each do |tag|
@tags.append(Tag.new(tag))
end
end
end
end
Bad smell
看到这样的代码,你发现什么bad smell了吗?重复代码?不像,但是那么多的init_xxx
方法看起来就是有那么点不自然。
在我看来,这份代码有两个问题:
第一,从json到Post对象的转换职责,不应该是Post类的职责,这份代码违反了单一职责原则。
第二,由于无法很好地将json中的值和对象值对应关系规则建模,导致我们不得不创建多个init_xxx
方法,然后在在initialize
方法中逐一调用这些方法。然而在这些init_xxx
方法之间,存在着结构化重复。
如何改进?
首先,要分离职责,把json到Post对象的转换职责放到一个新类PostBuilder
中。
其次,要对对应关系进行抽象。
改进
我们在来分析一下json中的值和对象值对应关系规则,还是有规律可循的,对应关系都由三部分组成:json属性,对象属性名,转换规则(默认没有转换规则)。其中,通过jsonpath
来标识json属性,通过block来表示转换规则, 我们可以建立一个MapingRule
类来对此关系进行建模。
由此我们得到如下代码:
class Post
attr_accessor :title, :date, :tags
end
class MappingRule
attr_accessor, :json_path, :attr_name, :converter
def apply(obj, json)
value = JSONPath.new(@json_path).on(json)
unless value.nil?
obj.send("#{@field_name}=", @converter.call(value))
end
end
end
class PostBuilder
def initialize
@rules = []
end
def rule(json_path, attr_name, converter)
@rules << MappingRule.new(json_path, attr_name, converter)
end
def build json
post = Post.new
@rules.each do |rule|
rule.apply(post, json)
end
end
end
# 创建builder
builder = PostBuilder.new
buider.rule("author name", :author_name)
buider.rule("date", :date, -> (date) { date.nil? ? "1970-01-01" : date} )
buider.rule("tags", :tags, -> (tags) { tags.map {|tag| Tag.new(tag)} })
# 使用builder从json创建对象
post = buidler.build({"date" => "2013-09-10", "tags" => ["music", "IT"] })
回顾
与最初版本相比,我们引入了jsonpath和block来对转换规则进行建模(创建了MappingRule类),在PostBuilder#build中循环应用各个rule完成对象的创建,消除了多个init_xxx
的重复。至此,代码已经达到一个令人满意的状态。然而,能否让我们的PostBuilder的接口更加漂亮些?
再改进,更具表达力的api
我们再来看下PostBuilder的使用场景:
- 创建一个PostBuilder对象。
- 给这个对象增加一些转换规则。
- 使用这个对象从json创建对象。
因此,可以说,在一个PostBuilder对象被添加规则之前,它是不完整的,是不可用的,即第一二步应该是一个原子操作,我们可以把initialize
变为private方法,增加一个config
类方法,这个方法可以接受一个block,在此block中对builder增加规则,在这个方法中创建一个builder实例,同时把这个实例传递给block完成buidler的创建。代码如下:
#增加一个config类方法
class PostBuilder
def self.config
builder = PostBuilder.new
yield(builder) if block_given?
builder
end
#...
private
def initialize
#...
end
end
#创建builder
builder = PostBuilder.config do |builder|
buider.rule("author name", :author_name)
buider.rule("date", :date, -> (date) { date.nil? ? "1970-01-01" : date} )
buider.rule("tags", :tags, -> (tags) { tags.map {|tag| Tag.new(tag)} })
end
#使用builder
post = buidler.build({"date" => "2013-09-10", "tags" => ["music", "IT"] })
再改进,更简洁的api
至此,这个PostBuilder提供的api已经非常干净了,然而,这个api还是有改进空间的。在block中builder
这个单词出现在每个增加规则的地方。有没有办法把这个重复也给消除掉呢?答案是可以的,instance_eval
隆重登场了。对PostBuilder.config
方法做如下修改:
def self.config(&block)
builder = PostBuilder.new
builder.instance_eval(block)
builder
end
那么,创建builder的代码就简化为:
builder = PostBuilder.config do
rule("author name", :author_name)
rule("date", :date, -> (date) { date.nil? ? "1970-01-01" : date} )
rule("tags", :tags, -> (tags) { tags.map {|tag| Tag.new(tag)} })
end
在PostBuilder.config
中使用instance_eval
对block进行evaluate,相当于在新创建的builder上执行block中的代码,同样能达到对builder增加规则的效果。
使用instance_eval能够使代码变得更加简洁,然而随之而来的风险是,你也给了你的api调用者一个在这个新建对象上执行任意代码的机会。因此,在简洁性和风险之间,你需要做一个权衡。
再抽象
再回头看看PostBuilder,只需些许改动,我们就能从json创建任意类型的对象,于是我们得到一个InstanceBuilder
类,如下:
post_builder = InstaneBuilder.config do
instane_class Post
rule("author name", :author_name)
rule("date", :date, -> (date) { date.nil? ? "1970-01-01" : date} )
rule("tags", :tags, -> (tags) { tags.map {|tag| Tag.new(tag)} })
end
你可以试着实现一个这个InstaneBuilder#instane_class
方法。
结语
通观上面的例子,我们通过使用ruby的block和instance_eval,把一个复杂丑陋的代码变得干净,层次清晰,同时,更加容易扩展。 在这里,我抛出自己对编写代码的一点想法,供各位参考:
- 在开始编写实现代码前,先考虑一下如何提供一套干净的,更具表达力的api,让api调用者喜欢使用你的api(sinatra做了一个很好的榜样)。
- 恰当地使用block,instance_eval 能够很容易的构建一个internal dsl。
Reference
想了解更多关于block
,instance_eval
, internal dsl
可以参考如下两篇文章: