嵌入式文档

2013年6月4日

如今,我越来越常看到通过服务器流动的 JSON 数据结构。JSON 文档可以直接持久化,方法是使用 AggregateOrientedDatabase 或关系数据库中的 serialized LOB。JSON 文档也可以直接提供给 Web 浏览器,或用于将数据传输到服务器端页面渲染器。当以这种方式使用 JSON 时,我听到人们说使用面向对象的语言会妨碍工作,因为 JSON 需要转换为对象,然后再重新渲染出来,这是一种浪费编程工作量 [1]。我同意关于浪费的观点,但我认为这不是对象的问题,而是对封装的理解不足。

假设我们要将订单存储为 JSON 文档,并通过少量服务器端处理将其提供,同样以 JSON 格式提供。示例文档可能如下所示。

{ "id": 1234,
  "customer": "martin",
  "items": [
    {"product": "talisker", "quantity": 500},
    {"product": "macallan", "quantity": 800},
    {"product": "ledaig",   "quantity": 1100}
  ],
  "deliveries": [
    { "id": 7722,
      "shipDate": "2013-04-19",
      "items": [
        {"product": "talisker", "quantity": 300},
        {"product": "ledaig",   "quantity": 500}
      ]
    },
    { "id": 6533,
      "shipDate": "2013-04-18",
      "items": [
        {"product": "talisker", "quantity": 200},
        {"product": "ledaig",   "quantity": 300},
        {"product": "macallan", "quantity": 300}
      ]
    }
  ]
}

假设我们没有太多服务器端处理要做,但确实有一些。我们还假设我们使用的是面向对象的语言。一种天真的方法可能是读取 JSON 文档,将数据转换为适当的对象图(包含订单、行项目和交付),应用任何处理,然后将对象图序列化为 JSON 以供客户端使用。

在许多这样的情况下,更好的做法是将数据保留在类似 JSON 的形式中,但仍然用对象将其包装起来以协调操作。大多数编程环境都提供通用库,这些库可以获取文档并将其反序列化为通用数据结构。因此,JSON 文档将反序列化为列表和字典的结构,XML 文档将反序列化为 XML 节点的树。然后,我们可以获取此通用数据结构并将其放入订单对象的字段中,以下是用 Ruby 和 JSON 的示例。

class Order...

  def initialize jsonDocument
    @data = JSON.parse(jsonDocument)
  end

当我们想要操作数据时,可以像往常一样在对象上定义方法,并通过访问此数据结构来实现它们。

class Order...

  def customer
    @data['customer']
  end
  def quantity_for aProduct
    item = @data['items'].detect{|i| aProduct == i['product']}
    return item ? item['quantity'] : 0
  end

这包括具有更复杂逻辑的情况。 [2]

class Order...

  def outstanding_delivery_for aProduct
    delivered_amount = @data['deliveries'].
      map{|d| d['items']}.
      flatten.
      select{|d| aProduct == d['product']}.
      inject(0){|res, d| res += d['quantity']}
    return quantity_for(aProduct) - delivered_amount
  end

可以在将嵌入式文档发送到客户端之前对其进行丰富。

class Order...

  def enrich
    @data['earliestShipDate'] = 
      @data['deliveries'].
      map{|d| Date.parse(d['shipDate'])}.
      min.
      to_s
  end

如果需要,可以在嵌入式文档的子树上形成类似的对象。

class Order...

  def deliveries
    @data['deliveries'].map{|d| Delivery.new(d)}
  end

class Delivery

  def initialize hash
    @data = hash
  end
  def ship_date
    Date.parse(@data['shipDate'])
  end

这里需要注意的一点是,这样的对象包装器与普通对象并不完全相同。上述代码片段中返回的交付对象没有与更常见结构中排列的对象相同的相等语义。

尽管嵌入式文档相对罕见,但它与面向对象非常契合。封装数据的目的是隐藏数据结构,以便对象的用户不知道或不关心订单的内部结构。

熟悉函数式编程的人会认识到将通用数据结构通过一系列函数流动的风格——你可以将对象视为用于操作通用数据结构的命名空间。

嵌入式文档的最佳应用场景是,当您以从数据存储中获取的相同形式提供文档时,但仍然希望对该数据进行一些操作。如果您不需要访问 JSON 文档的内容,那么甚至不需要将其反序列化为通用数据结构。订单对象只需要一个构造函数和一个方法来返回其 JSON 表示。另一方面,随着您对数据进行更多工作——更多服务器端逻辑,转换为不同的表示——那么值得考虑是否将数据转换为对象图更容易。

笔记

1: 有人可能会争辩说这也是浪费计算资源——尽管我怀疑它是否会很显著。我当然不会接受反对转换为对象图的性能论据,除非它伴随着测量结果——就像 任何性能论据 一样。

2: 注意此方法中 集合管道 的链接。我个人最讨厌的一件事是听到一些函数式编程爱好者说这种代码风格不是面向对象的。虽然它可能对那些有 C++/Java 背景的人来说很陌生,但这种风格对 Smalltalk 程序员来说是完全自然的。