翻译 解读 Rails - 适配器模式

martin91 · 2015年05月26日 · 最后由 chanshunli 回复于 2015年05月26日 · 3454 次阅读

看到最近翻译的文章比较热,我也来贡献一篇去年翻译的吧,本文翻译自Reading Rails - The Adapter Pattern,限于本人水平有限,翻译不当之处,敬请指教!

今天我们暂时先放下具体的代码片段,我们将要对 Rails 中所实现的一个比较常见的设计模式进行一番探索,这个模式就是适配器模式(Adapter Pattern)。从一定的意义上来说,这次的探索并不全面,但是我希望能够突出一些实际的例子。

为了跟随本文的步骤,请使用qwandry打开相关的代码库,或者直接在Github上查看这些代码。

适配器模式

适配器模式可以用于对不同的接口进行包装以及提供统一的接口,或者是让某一个对象看起来像是另一个类型的对象。在静态类型的编程语言里,我们经常使用它去满足类型系统的特点,但是在类似 Ruby 这样的弱类型编程语言里,我们并不需要这么做。尽管如此,它对于我们来说还是有很多意义的。

当使用第三方类或者库的时候,我们经常从这个例子开始(start out fine):

def find_nearest_restaurant(locator)
  locator.nearest(:restaurant, self.lat, self.lon)
end

我们假设有一个针对locator的接口,但是如果我们想要find_nearest_restaurant能够支持另一个库呢?这个时候我们可能就会去尝试添加新的特殊的场景的处理:

def find_nearest_restaurant(locator)
  if locator.is_a? GeoFish
    locator.nearest(:restaurant, self.lat, self.lon)
  elsif locator.is_a? ActsAsFound
    locator.find_food(:lat => self.lat, :lon => self.lon)
  else
    raise NotImplementedError, "#{locator.class.name} is not supported."
  end
end

这是一个比较务实的解决方案。或许我们也不再需要考虑去支持另一个库了。也或许find_nearest_restaurant就是我们使用locator的唯一场景。

那假如你真的需要去支持一个新的locator,那又会是怎么样的呢?那就是你有三个特定的场景。再假如你需要实现find_nearest_hospital方法呢?这样你就需要在维护这三种特定的场景时去兼顾两个不同的地方。当你觉得这种解决方案不再可行的时候,你就需要考虑适配器模式了。

在这个例子中,我们可以为GeoFish以及ActsAsFound编写适配器,这样的话,在我们的其他代码中,我们就不需要了解我们当前正在使用的是哪个库了:

def find_nearest_hospital(locator)
  locator.find :type => :hospital,
               :lat => self.lat,
               :lon => self.lon
end

locator = GeoFishAdapter.new(geo_fish_locator)
find_nearest_hospital(locator)

特意假设的例子就到此为止,接下来让我们看看真实的代码。

MultiJSON

ActiveSupport在做 JSON 格式的解码时,用到的是MultiJSON,这是一个针对 JSON 库的适配器。每一个库都能够解析 JSON,但是做法却不尽相同。让我们分别看看针对ojyajl的适配器。 (提示: 可在命令行中输入qw multi_json查看源码。)

module MultiJson
  module Adapters
    class Oj < Adapter
      #...
      def load(string, options={})
        options[:symbol_keys] = options.delete(:symbolize_keys)
        ::Oj.load(string, options)
      end
      #...

Oj 的适配器修改了options哈希表,使用Hash#delete:symbolize_keys项转换为 Oj 的:symbol_keys项:

options = {:symbolize_keys => true}
options[:symbol_keys] = options.delete(:symbolize_keys) # => true
options                                                 # => {:symbol_keys=>true}

接下来 MultiJSON 调用了::Oj.load(string, options)。MultiJSON 适配后的 API 跟 Oj 原有的 API 非常相似,在此不必赘述。不过你是否注意到,Oj 是如何引用的呢?::Oj引用了顶层的Oj类,而不是MultiJson::Adapters::Oj

现在让我们看看 MultiJSON 又是如何适配 Yajl 库的:

module MultiJson
  module Adapters
    class Yajl < Adapter
      #...
      def load(string, options={})
        ::Yajl::Parser.new(:symbolize_keys => options[:symbolize_keys]).parse(string)
      end
      #...

这个适配器从不同的方式实现了load方法。Yajl 的方式是先创建一个解析器的实力,然后将传入的字符串string作为参数调用Yajl::Parser#parse方法。在options哈希表上的处理也略有不同。只有:symbolize_keys项被传递给了 Yajl。

这些 JSON 的适配器看似微不足道,但是他们却可以让你随心所欲地在不同的库之间进行切换,而不需要在每一个解析 JSON 的地方更新代码。

ActiveRecord

很多 JSON 库往往都遵从相似的模式,这让适配工作变得相当轻松。但是如果你是在处理一些更加复杂的情况时,结果会是怎样?ActiveRecord 包含了针对不同数据库的适配器。尽管 PostgreSQL 和 MySQL 都是 SQL 数据库,但是他们之间还是有很多不同之处,而 ActiveRecord 通过使用适配器模式屏蔽了这些不同。(提示: 命令行中输入qw activerecord查看 ActiveRecord 的代码)

打开 ActiveRecord 代码库中的lib/connection_adapters目录,里边会有针对 PostgreSQL,MySQL 以及 SQLite 的适配器。除此之外,还有一个名为AbstractAdapter的适配器,它作为每一个具体的适配器的基类。AbstractAdapter实现了在大部分数据库中常见的功能,这些功能在其子类比如PostgreSQLAdapter以及AbstractMysqlAdapter中被重新定制,而其中AbstractMysqlAdapter则是另外两个不同的 MySQL 适配器——MysqlAdapter 以及 Mysql2Adapter——的父类。让我们通过一些真实世界中的例子来看看他们是如何一起工作的。

PostgreSQL 和 MySQL 在 SQL 方言的实现稍有不同。查询语句SELECT * FROM users在这两个数据库都可以正常执行,但是它们在一些类型的处理上会稍显不同。在 MySQL 和 PostgreSQL 中,时间格式就不尽相同。其中,PostgreSQL 支持微秒级别的时间,而 MySQL 只是到了最近的一个稳定发布的版本中才支持。那这两个适配器又是如何处理这种差异的呢?

ActiveRecord 通过被混入到AbstractAdapterActiveRecord::ConnectionAdapters::Quoting中的quoted_date引用日期。而AbstractAdapter中的实现仅仅只是格式化了日期:

def quoted_date(value)
  #...
  value.to_s(:db)
end

Rails 中的 ActiveSupport 扩展了Time#to_s,使其能够接收一个代表格式名的符号类型参数。:db所代表的格式就是%Y-%m-%d %H:%M:%S

# Examples of common formats:
Time.now.to_s(:db)      #=> "2014-02-19 06:08:13"
Time.now.to_s(:short)   #=> "19 Feb 06:08"
Time.now.to_s(:rfc822)  #=> "Wed, 19 Feb 2014 06:08:13 +0000"

MySQL 的适配器都没有重写quoted_date方法,它们自然会继承这种行为。另一边,PostgreSQLAdapter则对日期的处理做了两个修改:

def quoted_date(value)
  result = super
  if value.acts_like?(:time) && value.respond_to?(:usec)
    result = "#{result}.#{sprintf("%06d", value.usec)}"
  end

  if value.year < 0
    result = result.sub(/^-/, "") + " BC"
  end
  result
end

它在一开始便调用super方法,所以它也会得到一个类似 MySQL 中格式化后的日期。接下来,它检测value是否像是一个具体时间。这是一个 ActiveSupport 中扩展的方法,当一个对象类似Time类型的实例时,它会返回true。这让它更容易表明各种对象已被假设为类似Time的对象。(提示: 对acts_like?方法感兴趣?请在命令行中执行qw activesupport,然后阅读core_ext/object/acts_like.rb

第二部分的条件检查value是否有用于返回毫秒的usec方法。如果可以求得毫秒数,那么它将通过sprintf方法被追加到result字符串的末尾。跟很多时间格式一样,sprintf也有很多不同的方式用于格式化数字:

sprintf("%06d", 32) #=> "000032"
sprintf("%6d",  32) #=> "    32"
sprintf("%d",   32) #=> "32"
sprintf("%.2f", 32) #=> "32.00"

最后,假如日期是一个负数,PostgreSQLAdapter就会通过加上"BC"去重新格式化日期,这是 PostgreSQL 数据库的实际要求:

SELECT '2000-01-20'::timestamp;
-- 2000-01-20 00:00:00
SELECT '2000-01-20 BC'::timestamp;
-- 2000-01-20 00:00:00 BC
SELECT '-2000-01-20'::timestamp;
-- ERROR:  time zone displacement out of range: "-2000-01-20"

这只是 ActiveRecord 适配多个 API 时的一个极小的方式,但它却能帮助你免除由于不同数据库的细节所带来的差异和烦恼。

另一个体现 SQL 数据库的不同点是数据库表被创建的方式。MySQL 以及 PostgreSQL 中对主键的处理各不相同:

# AbstractMysqlAdapter
NATIVE_DATABASE_TYPES = {
  :primary_key => "int(11) DEFAULT NULL auto_increment PRIMARY KEY",
  #...
}

# PostgreSQLAdapter
NATIVE_DATABASE_TYPES = {
  primary_key: "serial primary key",
  #...
}

这两种适配器都能够明白 ActiveRecord 中的主键的表示方式,但是它们会在创建新表的时候将此翻译为不同的 SQL 语句。当你下次在编写一个 migration 或者执行一个查询的时候,思考一下 ActiveRecord 的适配器以及它们为你做的所有微小的事情。

DateTime 和 Time

当 MultiJson 以及 ActiveRecord 实现了传统的适配器的时候,Ruby 的灵活性使得另一种解决方案成为可能。DateTime以及Time都用于表示时间,但是它们在内部的处理上是不同的。虽然有着这些细微的差异,但是它们所暴露出来的 API 却是极其类似的(提示:命令行中执行qw activesupport查看此处相关代码):

t = Time.now
t.day     #=> 19         (Day of month)
t.wday    #=> 3          (Day of week)
t.usec    #=> 371552     (Microseconds)
t.to_i    #=> 1392871392 (Epoch secconds)

d = DateTime.now
d.day     #=> 19         (Day of month)
d.wday    #=> 3          (Day of week)
d.usec    #=> NoMethodError: undefined method `usec'
d.to_i    #=> NoMethodError: undefined method `to_i'

ActiveSupport 通过添加缺失的方法来直接修改DateTimeTime,进而抹平了两者之间的差异。从实例上看,这里就有一个例子演示了 ActiveSupport 如何定义DateTime#to_i

class DateTime
  def to_i
    seconds_since_unix_epoch.to_i
  end

  def seconds_since_unix_epoch
    (jd - 2440588) * 86400 - offset_in_seconds + seconds_since_midnight
  end

  def offset_in_seconds
    (offset * 86400).to_i
  end

  def seconds_since_midnight
    sec + (min * 60) + (hour * 3600)
  end
end

每一个用于支持的方法,seconds_since_unix_epochoffset_in_seconds,以及seconds_since_midnight都使用或者扩展了DateTime中已经存在的 API 去定义与Time中匹配的方法。

假如说我们前面所看到的适配器是相对于被适配对象的外部适配器,那么我们现在所看到的这个就可以被称之为内部适配器。与外部适配器不同的是,这种方法受限于已有的 API,并且可能导致一些麻烦的矛盾问题。举例来说,DateTimeTime在一些特殊的场景下就有可能出现不一样的行为:

datetime == time #=> true
datetime + 1     #=> 2014-02-26 07:32:39
time + 1         #=> 2014-02-25 07:32:40

当加上 1 的时候,DateTime加上了一天,而Time则是加上了一秒。当你需要使用它们的时候,你要记住 ActiveSupport 基于这些不同,提供了诸如changeDuration等保证一致行为的方法或类。

这是一个好的模式吗?它理所当然是方便的,但是如你刚才所见,你仍旧需要注意其中的一些不同之处。

总结

设计模式不是只有 Java 才需要的。Rails 通过使用设计模式以提供用于 JSON 解析以及数据库维护的统一接口。由于 Ruby 的灵活性,类似DateTime以及Time这样的类可以被直接地修改而提供相似的接口。Rails 的源码就是一个可以让你挖掘真实世界中不同设计模式实例的天堂。

在这次的实践中,我们同时也发掘了一些有趣的代码:

  • hash[:foo] = hash.delete(:bar)是一个用于重命名哈希表中某一项的巧妙方法。
  • 调用::ClassName会调用顶层的类。
  • ActiveSupport 为TimeDate以及其他的类添加了一个可选的代表格式的参数format
  • sprintf可以用于格式化数字。

想要探索更多的知识?回去看看 MultiJson 是如何处理以及解析格式的。仔细阅读你在你的数据库中所使用到的 ActiveRecord 的适配器的代码。浏览 ActiveSupport 中用于 xml 适配器的XmlMini,它跟 MultiJson 中的 JSON 适配器是类似的。在这些里面还会有很多可以学习的。

喜欢这篇文章? 阅读另外 8 篇“解读 Rails”中的文章。

匿名 #1 2015年05月26日

good job :) :plus1:

需要 登录 后方可回复, 如果你还没有账号请 注册新账号