时间对象

随时间变化的对象

2004年3月7日

这是我在2000年代中期进行的企业应用架构进一步开发写作的一部分。不幸的是,此后太多其他事情吸引了我的注意力,所以我没有时间进一步研究它们,在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿,我不会进行任何更正或更新,直到我能找到时间再次处理它。

有时你想把一个对象看作具有时间属性,但有时你想把对象本身看作是时间的。一个很好的例子是经过一系列修订的合同。你可以把每个修订看作本质上是一份具有新条款的新合同,但你也可以把它们看作是同一份合同的不同版本。

工作原理

这种模式非常常见,但以多种形式出现。为了帮助解决这些形式,我发现使用一些角色分析非常有帮助。该模式本质上具有两个角色:一个连续性和多个版本。

每个版本都捕获了对象在一段时间内的状态。只要对象任何属性的值发生变化,就会产生一个新版本。因此,你可以想象版本是一个对象列表,其中包含一个有效性来处理日期范围。

连续性代表经历这些版本变化的持续对象。当人们想到对象在其所有变化中的时候,他们所指的就是这个对象。

除了版本的时态属性之外,连续性要么没有数据,要么只有在整个连续性生命周期中真正不变的数据。但是它充当其他对象收集数据的点。当前值可以通过简单地调用委托给当前版本的获取方法来访问。历史值可以通过两种方式访问。连续性可以为版本的属性提供一个时态属性,或者连续性可以提供一个快照。你可以混合使用两者,例如为常用值提供时态属性接口,为其他值提供快照

如果你希望用户显式更改版本历史记录,你也可以提供对版本的直接访问权限以进行编辑。但是,通常情况下,你希望通过连续性来控制所有访问。

使这种模式难以处理的是,你有太多关于如何建模它的选择。

以我拥有的信用卡合同为例。我在1997年2月1日获得了这张信用卡,它附带了通常难以理解的合同协议,我将其归档在文件柜中,再也不见天日。1998年4月15日,一份修订后的协议通过邮件寄来,也得到了同样的待遇。因此,你有一张信用卡和两份(版本的)合同。

一种方法是将每份合同视为与信用卡绑定的独立合同。在许多方面,这根本不涉及时间对象。相反,你正在考虑显式地拥有一张信用卡,该信用卡具有时间属性的合同。

图 1:将每张信用卡视为独立合同

第二种选择是说信用卡有一份合同,该合同本身包含一个合同版本的集合。这种方法与将每份合同视为独立事物的区别在于企业如何看待其业务流程。一个优点是,如果一家公司有客户使用不同的合同,那么合同对象就是一个明确的地方来挂起这个概念。当然,一家公司可能还有其他概念来实现此目的,例如信用卡类型(用于金卡、白金卡和基本金属卡),并且每种类型只有一种合同。在这种情况下,合同对象的价值,至少在表示方面,会降低;但行为仍然有价值。

图 2:具有显式版本的合同

图 2是时间对象模式最明显的形式,因为模式中的两个角色都有一个显式类。然而,当一个对象同时扮演连续性和其中一个版本的角色时,事情会变得不那么明确。

图 3:具有修订的合同 - 类图。

一个很好的例子是 图 3这样的模型,其中我们有一个合同,该合同有一个修订,该修订也是一个合同,因此可以有自己的修订。在这种情况下,合同类同时扮演连续性和版本的角色,通常认为只有链中的第一个合同扮演连续性的角色。这样,任何引用原始合同的东西都有一个明确的参考点,但我们仍然可以使用版本来保存更改。

图 3的修订风格在合同很少被修订时很有用,因为这样只有一份合同,而 图 2的显式风格即使没有修订也总是需要至少两个对象。尽管如此,如今我倾向于始终使用显式形式,因为职责划分得更清楚。此外,显式形式更适合使用时间集合来实现,我发现这比遍历列表更容易。(虽然公平地说,你可以使用时间集合来表示修订,而不是通常使用的列表形式。)

需要注意的另一个方面是,连续性通常不以对象的形式表示,尤其是在不太面向对象的系统中。例如,在关系数据库中,你可能只对版本有一个表,而没有对连续性有一个表。在这样的关系模型中,连续性由一个字段实现,例如合同号。合同表的候选键将组合此合同号和有效性的一部分,例如开始日期。

另一个简单的例子是源代码控制系统。这里的连续性是版本化文件的文件名,每个版本都存储在源代码控制系统中,通常以增量的形式存储为一个单独的条目。

何时使用它

何时使用这种模式的最大问题是将其与使用时间属性进行比较。两者之间有很多重叠,实际上你可以看到连续性的接口通常是一组时间属性。实际上,就客户端而言,将每个属性都作为时间属性与使用时间对象之间的接口几乎相同。

一个明显的驱动因素是哪些属性是时间的。如果只有几个,那么使用时间属性,如果大多数是时间的,那么使用时间对象。当然,这仅仅意味着我将“几个”和“大多数”之间的判断留给你 - 这不是令人恼火吗?

另一个问题是业务人员如何看待信息。如果他们希望将联系人视为具有明确的修订,那么即使只有一个属性是时间的,也值得使用时间对象。一旦业务人员需要明确地引用版本,你就需要时间对象来提供他们可以引用的版本。

进一步阅读

安德森的Plop 论文在名称为自身历史的情况下讨论了这种模式。[Arnoldi 等人]在名称为版本历史的情况下描述了这种模式。

示例:显式连续性和版本(Java)

对于示例代码,我将遵循 图 2的显式形式。客户版本类包含你对客户期望的数据。

class CustomerVersion...

  private String address;
  private Money creditLimit;
  private String phone;

  String address() {return address;}
  Money creditLimit() {return creditLimit;}
  String phone() {return phone;}

  void setName(String arg) {_name = arg;}
  void setAddress(String arg) {address = arg;}
  void setCreditLimit(Money arg) {creditLimit = arg;}

客户类包含一个时间集合(有关其工作原理,请参阅时间属性)的客户版本,并且其简单的获取方法委托给最新版本。

class Customer...

  private TemporalCollection history = new SingleTemporalCollection();

  public String name() {return current().name();}
  public String address() {return current().address();}
  public Money creditLimit() {return current().creditLimit();}
  public String phone() {return current().phone();}
  
  private CustomerVersion current() {
    return (CustomerVersion)history.get();
  }

在更新客户时,你必须考虑你希望版本有多明确。如果你只需要一个简单的当前增量更新,你可以提供一个看起来很正常的设置方法。此设置方法获取版本的副本,更新副本,然后将副本添加到历史记录中。

class Customer...

  public void setAddress(String arg) {
    CustomerVersion workingCopy = getWorkingCopy();
    workingCopy.setAddress(arg);
    history.put(workingCopy);
  }
  public CustomerVersion getWorkingCopy() {
    return current().copy();
  }

class CustomerVersion...

  CustomerVersion copy() {
    return new CustomerVersion(_name, address, phone, creditLimit);
  }
  
  public CustomerVersion (String name, String address,
            String phone, Money creditLimit)
  {
    super(name);
    this.address = address;
    this.phone = phone;
    this.creditLimit = creditLimit;
  }

这使你能够使用简单的设置方法对时间记录进行更改。

class Tester...

  public void testSimple () {
    MfDate.setToday(new MfDate (1998, 8, 23));
    martin.setAddress(Damon15);
    martin.setCreditLimit(Money.dollars(100));
    MfDate.setToday(new MfDate (2000, 9,30));
    assertAddresses();
    assertCreditLimits();
  }
  private void assertCreditLimits() {
    assertEquals(Money.dollars(50), martin.creditLimit(new MfDate(1997, 12, 25)));
    assertEquals(Money.dollars(50), martin.creditLimit(new MfDate(1998, 8, 22)));
    assertEquals(Money.dollars(100), martin.creditLimit(new MfDate(1998, 8, 23)));
    assertEquals(Money.dollars(100), martin.creditLimit());
  }
  private void assertAddresses() {
    assertEquals(Franklin963, martin.address(new MfDate(1997, 12, 25)));
    assertEquals(Franklin963, martin.address(new MfDate(1998, 8, 22)));
    assertEquals(Damon15, martin.address(new MfDate(1998, 8, 23)));
    assertEquals(Damon15, martin.address());
  }

但是,对于追溯更新,客户的客户端需要了解版本。因此,追溯更新将由这样的客户端完成。

class Tester...

  public void testWorkingCopy() {
    MfDate.setToday(new MfDate (2000, 9,30));
    CustomerVersion workingCopy = martin.getWorkingCopy();
    workingCopy.setAddress(Damon15);
    workingCopy.setCreditLimit(Money.dollars(100));
    martin.addVersion(new MfDate (1998, 8, 23), workingCopy);
    MfDate.setToday(new MfDate (2000, 9,30));
    assertAddresses();
    assertCreditLimits();
  }