时间属性

随时间变化的属性

2004 年 3 月 7 日

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

通常,当我们在类上看到属性时,它们代表我们可以对对象提出的问题现在。但是,有时我们不仅想询问对象属性现在的问题,还想询问过去某个时间点的问题,那时情况可能已经发生了变化。

工作原理

这种模式的关键是为处理随时间变化的对象属性提供一个规律且可预测的接口。这方面最重要的部分在于访问器函数。您将始终看到一个访问器函数,它接受一个时间点作为参数:这使您可以询问“1998 年 2 月 2 日 Fowler 先生的地址是什么?”。此外,您通常会看到一个不带参数的访问器,这是一个关于某个默认地址的问题,通常是今天。

除了访问信息之外,您通常还需要更新信息。最简单的修改形式是增量更新。您可以将增量更新视为在时间线末尾添加另一条信息的更新。增量更新可以通过一个接受时间点和新值的 put 方法来处理。因此,这将表示“从 1998 年 8 月 23 日起,将 Fowler 先生的地址更改为 15 Damon Ave”。提供的日期可以是过去,表示追溯更改;现在,表示当前更改;或将来,反映计划更改。同样,您可能会发现使用不带时间点的 put 方法很有用,该方法使用今天(或类似的默认值)作为更改的生效日期。这使得进行当前更改变得容易。

另一种更新是插入更新。这种形式是您想要说“1998 年 1 月 Fowler 先生搬进了 963 Franklin St,但我们需要将其更改为 1997 年 12 月”。重点是,这修改了我们当前拥有的时间信息的一部分,而不是在末尾添加一个。为此,您需要一个引用来访问您当前拥有的值的,然后将其调整到一个新的范围。仅提供值是不够的,因为该值可能在不同的范围内有效:我可以搬出去,然后返回到同一个地址。

举个例子,假设我从 85 年 9 月到 87 年 1 月住在 154 Norwood Rd,然后搬到 Brighton 住了六个月,然后又回来住了一段时间。如果我需要将第一次住宿的离开时间从 1 月调整到 2 月,那么获取第一次住宿而不是第二次住宿非常重要。因此,我需要地址和一些标识第一次住宿的东西——实际上是第一次住宿范围内的任何日期。

如果您持有的值是值对象,您实际上可以使用增量更新,尽管插入更新可能更容易使用。

有两种方法可以实现时间属性。一种是使用有效性拥有一个对象集合,然后操作这个集合。但是,一旦您发现自己不止一次这样做,您就会意识到最好创建一个提供此行为的特殊集合类:时间集合。这样的类很容易编写,并且可以在您需要时间属性时使用。

何时使用它

当您有一个类,该类具有一些显示时间行为的属性,并且您希望轻松访问这些时间值时,您应该使用时间属性

这方面的第一点是关于轻松访问。记录时间变化的最简单方法是使用审计日志审计日志的缺点是您需要额外的工作来处理日志。因此,您需要了解的第一件事是在什么情况下人们需要该属性的历史记录。请记住,将普通属性重构为时间属性并不困难。(您可以用时间集合替换目标字段,并轻松维护现有接口。)

第二点是考虑有多少属性是时间属性。如果类的大多数属性都是时间属性,那么您需要使用时间对象

进一步阅读

我首先在[fowler-ap]中以历史映射的名称描述了时间属性。然后,我在与 Andy Carlson 和 Sharon Estepp 合作准备[PLoPD 4]中的时间模式论文时,完善了我的想法。Francis Anderson 的plop 论文也以关联历史的名称描述了这种模式。

示例:使用时间集合(Java)

时间集合是实现时间属性的简单方法。时间集合的基本表示和接口类似于映射:提供一个使用日期作为索引的 get 和 put 操作。实际上,映射是它的一个很好的支持集合。

class TemporalCollection...

  private Map contents = new HashMap();
  public Object get(MfDate when) {
    /** returns the value that was effective on the given date */
    Iterator it = milestones().iterator();
    while (it.hasNext()) {
      MfDate thisDate = (MfDate) it.next();
      if (thisDate.before(when) || thisDate.equals(when)) return contents.get(thisDate);
    }
    throw new IllegalArgumentException("no records that early");
  }
  public void put(MfDate at, Object item) {
    /** the item is valid from the supplied date onwards */
    contents.put(at,item);
    clearMilestoneCache();
  }

映射包含以它们生效的开始日期为索引的值。milestones 方法以相反的顺序返回这些键。然后,get 方法遍历这些里程碑以找到正确的键。当您更有可能请求最新值时,此算法最有效。

如果您访问时间集合的频率高于更新它的频率,则可能值得缓存里程碑集合。

class TemporalCollection...

  private List _milestoneCache;
  private List milestones() {
    /** a list of all the dates where the value changed, returned in order
    latest first */
    if (_milestoneCache == null)
      calculateMilestones();
    return _milestoneCache;
  }
  private void calculateMilestones() {
    _milestoneCache = new ArrayList(contents.size());
    _milestoneCache.addAll(contents.keySet());
    Collections.sort(_milestoneCache, Collections.reverseOrder());
  }
  private void clearMilestoneCache() {
    _milestoneCache = null;
  }

当您编写了一个时间集合后,就可以轻松地为客户制作一个时间地址集合。

class Customer...

  private TemporalCollection addresses = new SingleTemporalCollection();
  public Address getAddress(MfDate date) {
    return (Address) addresses.get(date);
  }
  public Address getAddress() {
    return getAddress(MfDate.today());
  }
  public void putAddress(MfDate date, Address value) {
    addresses.put(date, value);
  }

使用时间集合的最大问题之一是,如果您必须将集合持久化到关系数据库中——映射到表的做法并不完全直观。本质上,关系数据库需要使用有效性。这通常意味着您需要创建一个交叉表作为日期范围的存放位置。其中一些代码可以泛化到时间集合类,但需要一些显式的映射代码。

示例:实现双时间属性(Java)

在考虑双时间属性的工作原理之前,值得考虑它必须做什么。本质上,双时间属性允许我们存储随时间推移的历史信息,并在两个维度上保留完整的历史记录。因此,我们构建了一个这样的历史记录。

class Tester...

  private Customer martin;
  private Address franklin = new Address ("961 Franklin St");
  private Address worcester = new Address ("88 Worcester St");
  public void setUp () {
    MfDate.setToday(new MfDate(1996,1,1));
    martin = new Customer ("Martin");
    martin.putAddress(new MfDate(1994, 3, 1), worcester);
    MfDate.setToday(new MfDate(1996,8,10));
    martin.putAddress(new MfDate(1996, 7, 4), franklin);
    MfDate.setToday(new MfDate(2000,9,11));
  }

注意更新的节奏。当我们存储双时间历史记录时,记录日期始终是今天。因此,在我们的测试中,我们首先更改当前日期,然后将信息存储在历史记录中。当我们将信息放入历史记录时,我们提供实际日期。

最终的历史记录如下所示。

class Tester...

  private MfDate jul1 = new MfDate(1996, 7, 1);
  private MfDate jul15 = new MfDate(1996, 7, 15);
  private MfDate aug1 = new MfDate(1996, 8, 1);
  private MfDate aug10 = new MfDate(1996, 8, 10);
  public void testSimpleBitemporal () {
    assertEquals("jul1 as at aug 1", worcester, martin.getAddress(jul1, aug1));
    assertEquals("jul1 as at aug 10",worcester, martin.getAddress(jul1, aug10));
    assertEquals("jul1 as at now",worcester, martin.getAddress(jul1));

    assertEquals("jul15 as at aug 1", worcester, martin.getAddress(jul15, aug1));
    assertEquals("jul15 as at aug 10",franklin, martin.getAddress(jul15, aug10));
    assertEquals("jul15 as at now",franklin, martin.getAddress(jul15));
  }

正如您可以通过使用时间集合来实现时间属性的大部分复杂性一样,类似地,您可以定义一个双时间集合来处理双时间属性的大部分复杂性。

本质上,双时间集合是一个时间集合,其元素是时间集合。每个时间集合都是记录历史的图片。

我们先来看看如何从集合中获取信息。最新的时间集合代表实际历史记录,其记录日期为现在。因此,只有实际日期的获取方法使用此当前实际历史记录。

class BitemporalCollection...

  private SingleTemporalCollection contents = new SingleTemporalCollection();
  public BitemporalCollection() {
    contents.put(MfDate.today(), new SingleTemporalCollection());
  }
  public Object get(MfDate when) {
    return currentValidHistory().get(when);
  }
  private SingleTemporalCollection currentValidHistory() {
    return (SingleTemporalCollection) contents.get();
  }

(类SingleTemporalCollection只是我上面讨论的普通时间集合,我将在后面解释这两个类之间的关系。)

要获取真正的双时间值,我们使用一个同时具有实际日期和记录日期的获取器。

class BitemporalCollection...

  public Object get(MfDate validDate, MfDate transactionDate) {
    return validHistoryAt(transactionDate).get(validDate);
  }
  private TemporalCollection validHistoryAt(MfDate transactionDate) {
    return (TemporalCollection) contents.get(transactionDate);
  }

然后,我们可以在域类中使用双时间集合。

class Customer...

  BitemporalCollection addresses = new BitemporalCollection();
  public Address getAddress(MfDate actualDate) {
    return (Address) addresses.get(actualDate);
  }
  public Address getAddress(MfDate actualDate, MfDate recordDate) {
    return (Address) addresses.get(actualDate, recordDate);
  }
  public Address getAddress() {
    return (Address) addresses.get();
  }

每次更新集合时,都需要保留实际历史记录的旧副本。

class BitemporalCollection...

  public void put(MfDate validDate, Object item) {
    contents.put(MfDate.today(), currentValidHistory().copy());
    currentValidHistory().put(validDate,item);
  }
  public void put(Object item) {
    put(MfDate.today(),item);
  }

双时间集合支持与一维时间集合非常相似的接口。因此,我们可以为时间集合创建一个接口,并为一维和二维集合提供单独的实现。这允许双时间集合可以替换一维时间集合,这使得将一维时间属性重构为双时间属性变得容易。

在本例中,我使用时间点,其日期粒度用于实际日期和记录日期。虽然这使示例更容易编写,但对于记录时间使用更精细的粒度可能更好。这里的问题是,如果您在下午 2 点修改记录,在下午 3 点运行计费流程,然后在下午 4 点再次修改记录。使用当前实现,适当的值虽然没有完全丢失,但不容易获取。另一种方法,通常在业务中使用,是在一个工作日结束时进行计费等操作,并且在计费完成后不允许当天进行任何进一步更新。计费后的更新将被赋予计费后的下一天的记录日期。同样,这种功能可以轻松地添加到适当的时间集合类中。