平行模型

允許應用程式的狀態有另一種表示方式,無論是在不同的時間點或是在假設狀態中。

2005 年 12 月 12 日

這是 進一步的企業應用程式架構開發 寫作的一部分,我在 2000 年代中期進行這項寫作。很遺憾,自那之後有太多其他事情吸引了我的注意力,所以我沒有時間進一步研究它們,我也看不到在可預見的未來有太多時間。因此,這份資料非常草稿化,而且在我找到時間再次研究它之前,我不會進行任何更正或更新。

企業應用程式是資訊系統,用於擷取世界狀態的資料。在許多情況下,當我們使用資訊系統時,我們希望它們反映我們對那個世界的當前最佳理解。有時我們也可能想要了解過去的世界,或探索未來可能發生的事情的後果。

大多數時候,我們透過在當前世界狀態中建置用於擷取這些替代狀態資料的功能,來處理這些世界的替代狀態。客戶過去地址的 時間屬性 就是一個很好的例子。

然而,有時我們想要更全面性的東西,真正捕捉過去或想像狀態中世界的所有細微差別。透過允許我們輕鬆地取得我們的整個資訊儲存,並將其表示為過去的狀態,或表示為在某些替代的過去、現在或未來中,平行模型 做到了這一點。

它的運作方式

當我們談論能夠查詢和操作任何過去狀態的資訊系統,或任何假設的過去、現在或未來狀態時,聽起來很像科幻小說。然而,任何半認真的軟體開發專案在我們使用版本控制系統時,都會一直這樣做。我們可以重新建立任何過去的程式碼狀態,我們可以建立替代現實(透過分支),而且我們確實可以同時保留多個替代現實(例如多個主動程式碼行)。

版本控制系統處理這種巨型精神分裂思考能力的核心在於它們使用事件溯源。對程式碼的每個變更都會被擷取並儲存。如果你在星期五詢問前一個星期三的程式碼狀態,版本控制系統在概念上會從無開始,並套用直到星期三發生的每個程式碼變更事件,然後提供結果。我說「在概念上」,因為它可能不會完全這麼做,但它確實會執行與此觀察結果等效的事情。

思考這件事的方式是,應用程式的目前狀態是從空白初始狀態開始並對其播放事件的結果。每個事件序列都會導致不同的狀態。星期三早上的狀態是套用星期三之前發生的所有事件的結果,現在的狀態是播放所有曾經發生的事件的結果。(如果你在星期三早上閱讀這篇文章,你必須放下書幾個小時,讓最後一句話有意義。)

將狀態視為套用事件的結果,設定了處理假設現實的方法。你想知道如果 Derek 沒有在上星期三進行那個白痴般的檢查會發生什麼事,你可以透過播放除了那個檢查之外的每個事件來做到這一點(在某種程度上)。這會給你一個替代現實(或可能是真實的,當我們思考這些事情時,我們會非常接近《銀河便車指南》)。

我們通常會考慮將平行模型套用於我們的程式碼,我們也可以將其套用於我們使用程式碼編寫的東西。如果我們對管理供應鏈的應用程式使用事件溯源,我們可以透過建立一堆採購事件並將它們引入系統的安全副本中,並觀察會發生什麼事,來探討非黏著膠在瘋狂聖誕節購物潮的後果。我們也可以透過為上週建立平行模型來查看上週那批無聲唱片的運送位置。

要建立平行模型,首先要檢查的是你是否有一個使用事件溯源的系統。如果系統在設計時考慮了事件溯源,那是最好的。如果不行,可能有一些方法可以從日誌中重建事件。

接下來你需要的是一種方法,可以在與一般現實分離的模型中處理事件。有兩種方法可以做到這一點。一種是建構一個平行系統,它是應用程式的獨立副本,有自己的資料庫和環境。另一種是允許單一系統能夠在嵌入式平行模型之間切換。

平行系統

平行系統最大的優點在於,您不需要對應用程式本身執行任何動作,就能建構平行模型。您取得一份應用程式的副本,將其使用的資源(例如持久性資料儲存)切換為使用副本。然後,您提供它需要處理的事件,並執行它。

平行系統的問題在於您如何處理結果。由於應用程式不了解平行性,因此它無法處理或顯示來自多個平行模型的結果。若要將平行模型處理的結果繫結到更廣泛的系統中,您需要使用整合技術,有效地將平行系統視為一個完全獨立的應用程式。

使用平行系統的其中一個好處是,您也可以變更平行系統的原始碼。對於嵌入在應用程式中的平行模型,任何行為變更都僅限於建構在被平行化的模型中的行為。如果您使用的是適應式物件模型,這可能會很可觀,但仍有其限制。使用平行系統,您可以對應用程式的原始碼進行任何您想要的變更,並查看這對事件串流的影響。

嵌入式平行模型

如果您想要將多個平行模型嵌入在應用程式中,您需要確保任何持久性儲存都能輕鬆地在多個實際資料來源之間切換。執行此操作的良好方式是使用儲存庫,並提供一種機制,讓應用程式能夠在執行階段切換儲存庫。暫時的儲存庫不需要是持久的,因為平行模型通常是暫時的,所以它們通常可以在記憶體中組裝。處理完事件並從平行模型取得您需要的資訊後,您可以捨棄儲存庫。

將系統切換為使用暫時儲存庫表示您必須先建立這個儲存庫,初始化它(通常使用架構和任何不可變的參考資料),然後在常規應用程式碼看到它的任何位置,將實際儲存庫替換為暫時儲存庫。這就是使用儲存庫的單一參考點如此重要的原因。

使用此類儲存庫的其中一個優點是,由於您處理記憶體中的所有內容,因此速度可能會快很多。

無論您如何執行平行處理,總是會產生一個效應,其中平行模型不知道備用實體的存在,但有一個外部系統知道平行世界。對於嵌入式模型,應用程式本身會分成平行感知和非感知區段,對於平行系統,有些系統不知道,而有些系統可以知道。您也可以有多個平行處理和合併層次。您可能需要一杯非常濃的茶來處理它們。

處理事件

若要建立您的平行模型,您需要決定哪些事件需要處理,這取決於平行模型的性質。如果是歷史狀態,則您需要該時間點之前的所有事件。這可以透過查詢主系統事件記錄來完成。事件記錄的優點在於它們是一系列不可變的物件,因此很容易複製(儘管速度可能很慢)。

如果是假設狀態,則您需要一種方法來定義和將備用事件注入到您想要處理的事件串流中。

然後,您處理所選事件的方式與處理即時系統事件的方式完全相同。如果您妥善安排好所有事情,則此時不應對任何系統進行任何變更 - 您只需執行常規作業即可。最後,您會得到應用程式狀態,並且可以使用常規機制再次查詢它。

使用平行模型的其中一個糾葛是,很容易陷入身分危機。通常,當您有一個模型時,您可以在模型中的實體與持久性儲存和真實世界之間建立明確的對應關係。模型中的 Gregor 物件對應於現實中的物件,而且您只有一個。當您有一個平行模型時,您可能會遇到一個情況,即您有多個 Gregor,每個平行模型一個:既可怕又令人困惑。因此,當您變換平行模型時,您必須非常小心您保留哪些物件,特別是如果您有嵌入式平行模型。最不令人困惑的做法是在切換儲存庫時放棄任何物件。另一個替代方案是讓每個實體保留它來自的儲存庫的參考。儘管平行系統在單一系統內避免了這個問題,但如果您將平行執行結果彙整在一起,您可能會遇到這個問題。

最佳化

從建立平行模型的概念大綱中,你可以看到建立它們在計算上相當昂貴。處理從時間開始的每個事件可能需要一段時間,特別是如果有很多事件。版本控制系統已經處理這段時間,並想出了一些方法來減輕這個負擔。

其中之一是你不必從時間開始。你可以擷取應用程式狀態在各個時間點的快照,並使用最新的合理快照作為平行模型的起點。最新的合理快照是在事件串流中執行不同操作之前的最後一個快照。對於歷史平行模型,這將是平行模型日期之前的最後一個快照。對於假設的平行模型,這將是第一個變異事件之前的最後一個快照。

因此,讓我們舉一些例子。我在 11 月 17 日,我想要一個 9 月 12 日的平行模型,並且我在每個月初擷取快照。我可以從 9 月 1 日快照開始,並處理從 9 月 1 日到 9 月 12 日發生的每個事件。

我在 11 月 17 日,我想在下週探索銷售熱潮。在這種情況下,我可以從我目前的應用程式狀態建立快照,並加入我的假設事件。

我在 11 月 17 日,並想探索如果我在 10 月份大幅減少銷售會發生什麼事。我從 10 月 1 日快照開始,並處理我的變異事件。

另一個最佳化是向前和向後工作。為了讓這個工作,我需要我的事件是可逆的。但是有了這個,我可以從 10 月 1 日快照開始,並將事件從 10 月 1 日反轉回 9 月 27 日,為 9 月 27 日建立一個歷史平行模型。通常這會更快,因為事件較少。

當你開始思考處理較少事件時,自然會想到選擇性重播。如果你想查看我的訂單的過去狀態,你或許可以忽略對其他人訂單執行的事件,前提是這些事件對我的訂單沒有影響。使用選擇性重播可以大幅減少你必須處理的事件數量,但困難之處在於確保事件之間沒有微妙的交互作用。不處理其他訂單的履行可能意味著系統錯誤地認為運輸上有空間,這會完全搞亂我的訂單歷史記錄。系統越複雜,就越難發現這些複雜的交互作用。你可以在正向和反向處理中使用選擇性重播,在每種情況下,優點和風險都是相同的。

了解最常見的查詢是什麼是一個好主意。Subversion 版本控制系統知道大多數請求都是針對最新版本的程式碼,因此它將其儲存為快照,並從那裡使用反向事件來確定過去狀態(它也會不時使用其他快照)。

所有這些最佳化的優點在於它們應該完全對系統使用者隱藏。你總是可以用從時間開始向前的方式來考慮建立平行模型。你也可以從這個簡單的實作開始,然後稍後新增快照。(反轉事件稍後新增會有點棘手,而且可能會影響模型。)

這提供了一種測試方法,你可以隨機產生事件序列,並使用未最佳化的方式和各種最佳化方式建構平行模型平行模型的應用程式狀態有任何差異,就表示有故障,你可以進一步調查。

外部查詢

事件溯源(Event Sourcing)的問題之一在於我們必須記住外部查詢的結果。對於平行模型,在考慮假設情況時,你會遇到另一個問題。如果我們正在處理一個未發生的事件,我們將不會有該事件的外部查詢結果記錄。

在這種情況下,我們需要修改閘道器,以便它們可以傳回我們認為合理的資料作為外部系統的回應。如果我們使用假設的記憶查詢對閘道器進行程式設計,我們可以將這些查詢作為設定假設情況的一部分來擷取。

何時使用

平行模型是處理歷史和替代狀態的一種方式。另一種替代方法是使用時間屬性時間物件效力等模式將這些資訊嵌入模型本身。

使用平行模型的一大優點是它消除了這些模式在模型中的複雜性。你可以專注於使模型成為一個簡單的快照模型,並完全忽略這些多重時間和觀點。另一個優點是將這些建構放入模型中的每個地方都是一項艱鉅的工作,而且每一部分都會增加複雜性。因此,你必須選擇放置它們的位置。如果你忽略在某個地方放置它們,你就會陷入困境。你必須稍後新增它們,或者你可能沒有機會,因為資料永遠遺失了。使用平行模型,你可以在任何地方獲得時間行為。你只需支付一次使用平行模型的成本,整個模型就能獲得好處。

具備這些優點的同時,也伴隨著缺點。第一個缺點是對 事件溯源 的先決需求,這會對模型施加其自身的約束和複雜性。第二個缺點是處理時間。對 平行模型 的每個查詢都需要事件處理。快照和其他最佳化只能做到這麼多。

與大多數模式一樣,這並非完全非此即彼的案例,因為很有可能使用 平行模型 作為一般機制,但在某些地方使用 時間屬性 來處理某些常見請求。您也不需要從一開始就具備 平行模型,儘管您確實需要具備 事件溯源。但是,如果您使用 事件溯源 建立系統,則可以在稍後時間在有用的地方輕鬆新增 平行模型

範例:運送時間查詢 (C#)

我以我為 事件溯源 開發的運送、貨物和港口的簡單範例為基礎。因此,您應該在深入探討這個範例之前熟悉該範例。網域邏輯程式碼幾乎相同。

圖 1:運送範例的網域物件。

由於 平行模型 的許多複雜性在於處理暫時的 平行模型 和活動資料庫,因此我在這個範例中引入了資料庫。我使用熱門的 NHibernate 物件關聯對應器對應到資料庫。我不會詳細說明對應的細節,這並不是很令人感興趣。相反,我將專注於它是如何由 儲存庫 包裝,以及如何在儲存庫和 平行模型 的儲存庫之間進行交換。

與基本範例一樣,網域模型的所有變更都由事件處理。當船隻抵達港口時,這將由抵達事件記錄下來。

class ArrivalEvent...

  string _port;
  int _ship;  
  internal ArrivalEvent (DateTime occurred, string port, int ship) : base (occurred) {
    this._port = port;
    this._ship = ship;
  } 
  internal Port Port {get {return Port.Find(_port);}}
  internal Ship Ship {get {return Ship.Find(_ship);}}
  
  internal override void Process() {
    Ship.HandleArrival(this);
  }

與基本範例不同的是,在這種情況下,港口和船隻由簡單值表示為識別碼 - 在這種情況下,對應於資料庫中的主鍵,儘管任何鍵都可以。但是,屬性使用實際網域物件。為了確保我們始終取得正確的網域物件,我們使用在網域物件類別上定義的尋找器方法從資料庫中提取它們。

class Port...

  public static Port Find(string s) {
    return (Port) Registry.DB.Find(typeof (Port), s);
  }

尋找器方法反過來委派給 儲存庫。在我們的基本案例中,這個儲存庫是一個由 NHibernate 對應的網域物件的資料庫。因此,儲存庫程式碼使用 NHibernate API 從資料庫(或 NHibernate 的快取)中提取物件。

class DB...

  public object Find(Type type, object key) {
    return _hibernateSession.Load(type, key);
  }

NHibernate,就像大多數基於 資料對應器 的資料來源一樣,使用 工作單元 來追蹤它所操作的物件。因此,您永遠不會告訴物件將其自身儲存到資料庫中,而是提交 工作單元,它會找出哪些物件在記憶體中發生變更,以及如何將它們寫出。

對於這個範例,我將在事件處理器中執行這個交易包裝,其處理器現在如下所示。

類別 EventProcessor...

  public void Process(DomainEvent e) {
    Registry.DB.BeginTransaction();
    try {
      e.Process();
      InsertToLog(e);
      Registry.DB.Commit();
    } catch (Exception ex) {
      Registry.DB.Rollback();
      LogFailedEvent(e, ex);
    }
  }

這絕不是包裝 工作單元 的最佳位置,這取決於應用程式的性質。但這對我的範例來說已經足夠,也提出了問題 - 我們如何輕鬆切換持久性儲存庫來處理暫時性查詢?

讓我們透過測試案例來了解這一點

類別 Tester...

  [Test] 
  public void TemporalQueryForShipsLocation() {
    eProc.Process(new ArrivalEvent(new DateTime(2005,11,2), la, kr));
    eProc.Process(new DepartureEvent(new DateTime(2005,11,5), la, kr ));
    eProc.Process(new ArrivalEvent(new DateTime(2005,11,6), sfo, kr));
    Assert.AreEqual(sfo, Ship.Find(kr).Port.Code);
    eProc.SetToEnd(new DateTime(2005,11,2));
    Assert.AreEqual(la, Ship.Find(kr).Port.Code);
  }

這裡的關鍵方法是 SetToEnd,它會變更我們的儲存庫以使用記憶體內儲存庫,並重新處理事件記錄,以便事件播放到當天最後一個事件。這會為 11 月 2 日建立我們的 平行模型

類別 EventProcessor...

  IList log;
  public void SetToEnd(DateTime date) {
    SynchronizeLog();
    IRepository temporalRepository = new MemoryDB();
    Registry.enableAlternateRepository(temporalRepository);
    foreach (DomainEvent e in log) {
      if (e.Occurred > date) return;
      e.Process();
    }
  }

為了執行暫時性查詢,處理器會完全從資料庫中分離。在這種情況下,處理器會保留自己的事件記錄副本。在離開資料庫之前,它會將其記錄與持久性記錄同步,以便完整記錄寫入資料庫的所有事件。

一旦記錄同步,我們就會建立一個全新的記憶體內儲存庫。這可以由嵌入式記憶體內資料庫支援,這將允許您繼續使用 SQL。它也可以是手寫的,只滿足儲存庫介面。由於這是一個簡單的範例,所以我只使用了一堆雜湊表。

類別 MemoryDB...

  public object Find(Type type, object key) {
    object result = this[type][key];
    if (null == result) 
      throw new ApplicationException ("unable to find: " + key.ToString());
    return result;
  }
  private IDictionary store = new Hashtable();
  private IDictionary this[Type index] {get {return (IDictionary)store[index];}}

記憶體內儲存庫需要初始化為在處理任何事件之前資料庫中的相同初始狀態 - 在這種情況下,持有港口和船舶的參考資料。

類別 MemoryDB...

  public void Initialize() {
    store[typeof(Ship)] = new Hashtable();
    store[typeof(Cargo)] = new Hashtable();
    store[typeof(Port)] = new Hashtable();
    Insert(new Port("SFO", "San Francisco", "US"));
    Insert(new Port("LAX", "Los Angeles", "US"));
    Insert(new Port("YVR", "Vancouver", "CA"));
    Insert (new Port("XXX", "out to sea", "XX"));
    Insert(new Ship("King Roy", 1));
  }

透過暫時儲存庫設定,我們會告訴註冊表開始使用它,而不是真正的儲存庫。

類別 Registry...

  internal static void enableAlternateRepository(IRepository arg) {
    instance._repository = arg;
    arg.Initialize();
  }

現在,網域模型中對註冊表的任何呼叫都會使用記憶體中註冊表。由於此設計將 NHibernate 放置在常規資料庫儲存庫中,這表示我們完全不使用 Hibernate 來執行 平行模型。物件會顯示在記憶體中,並由儲存庫儲存在那裡。

一旦設定記憶體內儲存庫,我們就會依序從記錄中處理在目標日期或之前發生的所有事件。完成後,我們就會有記憶體內儲存庫代表在請求日結束時的全球狀態。我們現在對網域物件執行的任何查詢都會反映該日期。

要回到正確的資料庫,我們只需將暫時的記憶體內儲存庫換成常規儲存庫連線即可。

類別 Registry...

  internal static void restoreBaseRepository() {
    instance._repository = instance._baseRepository;
  }

範例:與基本模型比較 (C#)

在許多 平行模型 用法中,我們一次只處理一個模型。一般的處理,包括更新,都是使用基礎模型完成的,而我們建立 平行模型 來處理歷史和假設查詢。在任何時候,我們只有一個 平行模型 在使用中。這個方案相對簡單,並避免了許多科幻小說情節中困擾平行宇宙身分的疑問。

然而,有時將兩者混合是有意義的。如果您還記得運送範例中的蹩腳笑話,您就會知道書籍發行商對加拿大有多大的恐懼,以及文字被「eh」污染的風險。讓我們想像一下,我們時不時會得到特別討厭的「eh」傳染病。停靠在有這種傳染病的港口的船隻中的任何貨物都應標記為高風險。當然,我們不會在當天發現這些事情,所以我們必須找出那天在港口裡的是什麼。

以下是一個用來表達這個問題的測試案例。

類別 Tester...

  [Test]
  public void HighRiskDayFlagsAllCargo() {
    eProc.Process(new RegisterCargoEvent(new DateTime(2005,1,1), "UML Distilled", "UML", "LAX" ));
    eProc.Process(new RegisterCargoEvent(new DateTime(2005,1,1), "Planning XP", "PXP", "LAX" ));
    eProc.Process(new RegisterCargoEvent(new DateTime(2005,1,1), "Analysis Patterns", "AP", "LAX" ));
    eProc.Process(new RegisterCargoEvent(new DateTime(2005,1,1), "P of EAA", "eaa", "LAX" ));
    eProc.Process(new ArrivalEvent(new DateTime(2005,11,2), la, kr));
    eProc.Process(new LoadEvent(new DateTime(2005,5,11),"PXP", 1));
    eProc.Process(new LoadEvent(new DateTime(2005,5,11),"AP", 1));
    eProc.Process(new ArrivalEvent(new DateTime(2005,11,9), yvr, kr));
    eProc.Process(new ArrivalEvent(new DateTime(2005,11,12), la, kr));
    eProc.Process(new ContagionEvent(new DateTime(2005,11,10), yvr));
    Assert.IsTrue(Cargo.Find("PXP").IsHighRisk, "PXP should be high risk");
    Assert.IsTrue(Cargo.Find("AP").IsHighRisk, "AP should be high risk");
    Assert.IsFalse(Cargo.Find("UML").IsHighRisk, "UML should NOT be high risk");
    Assert.IsFalse(Cargo.Find("eaa").IsHighRisk, "UML should NOT be high risk");
    Assert.IsFalse(Cargo.Find(refact).IsHighRisk, "UML should NOT be high risk");
  }

我用一個新事件來描述傳染病。

class ContagionEvent...

  internal class ContagionEvent : DomainEvent
  {
    string _portID;
    public ContagionEvent(DateTime occurred, string port) : base(occurred) {
      this._portID = port;
    }
    Port Port {get {return Port.Find(_portID);}}
internal override void Process() {
  Registry.EventProcessor.SetToEnd(Occurred);
  ArrayList infectedCargos = new ArrayList();
  foreach (Ship s in Port.Ships) infectedCargos.AddRange(s.Cargos);
  Registry.restoreBaseRepository();
  foreach (Cargo c in infectedCargos) {
    Cargo actualCargo = Cargo.Find(c.RegistrationCode);
    actualCargo.IsHighRisk = true;
  }
}

您會注意到,在這種情況下,我實際上在事件的處理方法中獲得了相當多的行為。這樣做的原因是我決定讓網域模型忽略 平行模型。因此,事件必須為傳染病的日期建立暫時的 平行模型,執行查詢以找出受影響的貨物,將世界還原為基礎模型,並傳遞更新。網域模型仍然在每個 平行模型 中執行邏輯,儘管它不多。

另一種我將認真考慮的做法是,建立新的事件來將貨物標記為高風險。在這種情況下,傳染病事件將在暫時的 平行模型 中找到受影響的貨物,然後建立一個事件來將這些貨物標記為高風險。這個第二個事件將在基礎狀態中執行。在我寫這篇文章時,我承認不確定我比較喜歡哪一種方法。