事件溯源與流水賬的結賬模式

banq發表於2024-02-20


沒有人知道事件溯源Event Sourcing是誰發明的。我無意中聽說是漢謨拉比發明的。為什麼?因為他規範了第一套會計規則。

事件溯源Event Sourcing(活動事件源)就像記賬一樣,我們記錄每項業務活動(Event)作為一條新的條目(流水賬)。毫不奇怪,會計模式同樣適用於事件溯源。

例如,我們可以使用 "結賬 "模式對生命週期流程進行有效建模。它的名稱來自會計領域。所有財務資料都要進行彙總和核實,並在每個週期(如月和年)結束時建立最終報告。這些資料將作為下一週期的基礎。

對於會計計算而言,不需要將整個歷史資料進行結轉;只需總結需要結轉的關鍵部分,如期初餘額等即可。

同樣的模式也可用於時間建模。

結賬(Closing the Books pattern)
結賬事件溯源建模的本質。

正因為如此,我們可以保持簡短,從而有效地執行我們的系統。我們對流程的生命週期進行切片,使用事件標記生命週期的開始和結束。

商店的收銀員
我們可以嘗試透過將特定收銀機的所有交易保持在同一流上來對此進行建模,但如果我們正在為更大的百貨商店構建一個系統,那麼這種情況可能會迅速升級。我們最終可能會得到一個包含數千個事件的流。這很快就會使我們的處理效率低下且難以管理。

然而,如果我們與領域專家交談,我們可能會意識到這不是我們業務的運作方式。所有付款均由特定收銀員登記,收銀員只關心本班發生的情況。他們不需要知道整個交易歷史,只需要知道上一個班次留下的抽屜中的起始現金金額(稱為浮動)。

讓我們嘗試在簡化的事件模型中反映這一點:

public abstract record CashierShiftEvent
{
    public record ShiftOpened(
        CashierShiftId CashierShiftId,
        string CashierId,
        decimal Float,
        DateTimeOffset StartedAt
    ): CashierShiftEvent;

    public record TransactionRegistered(
        CashierShiftId CashierShiftId,
        string TransactionId,
        decimal Amount,
        DateTimeOffset RegisteredAt
    ): CashierShiftEvent;

    public record ShiftClosed(
        CashierShiftId CashierShiftId,
        decimal DeclaredTender,
        decimal OverageAmount,
        decimal ShortageAmount,
        decimal FinalFloat,
        DateTimeOffset ClosedAt
    ): CashierShiftEvent;

    private CashierShiftEvent(){}
}


因此,我們有ShiftOpened和ShiftClosed事件標記收銀班次的生命週期,並有TransactionRegistered事件來註冊付款。當然,在輪班開始和結束之間可能會發生更多型別的事件,但讓我們保持簡單。

假設TransactionRegistered是您在整個輪班生命週期中可能遇到的事件的一個示例。

您可能會注意到CashierShiftId是一個類,而不是原始值。這是強型別鍵的示例:

public record CashierShiftId(string CashRegisterId, int ShiftNumber)
{
    public static implicit operator string(CashierShiftId id) => id.ToString();

    public override string ToString() => $<font>"urn:cashier_shift:{CashRegisterId}:{ShiftNumber}";
}

我們的收銀員班次CashierShiftId由兩個元件組成:

  • 收銀機 ID、
  • 班次。

我們使用統一資源名稱結構 ( URN )
urn:cashier_shift:{CashRegisterId}:{ShiftNumber}

您可以使用任何格式,但 URN 是處理具有有意義段的 id 的標準化方法。如果我們有標準,為什麼要重新發明輪子呢?我們的 URN 以字首和流型別開頭。然後,我們有收銀機 ID 和班次號段。

如果我們想按時間方面對它進行切片,我們可以新增更多元件,例如日期。因此,每天都會重置輪班號碼。最重要的是,我們正在定義收銀輪班的明確生命週期。

預設情況下,使用Guid作為流鍵,但它允許使用 string。為此,我們需要在配置中進行更改:

services.AddMarten(options =>
    {
        options.Events.StreamIdentity = StreamIdentity.AsString;

        <font>// (...)<i>
    });

接下來,我們的收銀班次可以建模如下:

public record CashierShift
{
    public record NonExisting: CashierShift;

    public record Opened(
        CashierShiftId ShiftId,
        decimal Float
    ): CashierShift;

    public record Closed(
        CashierShiftId ShiftId,
        decimal FinalFloat
    ): CashierShift;

    private CashierShift() { }

    public string Id { get; init; } = default!;
}

當沒有班次、開放或關閉時,它要麼不存在。它被修剪為僅包含決策所需的資訊

我們可以根據事件構建它:

public record CashierShift
{
    <font>// (...)<i>

    public CashierShift Apply(CashierShiftEvent @event) =>
        (this, @event) switch
        {
            (_, ShiftOpened shiftOpened) =>
                new Opened(shiftOpened.CashierShiftId, shiftOpened.Float),

            (Opened state, TransactionRegistered transactionRegistered) =>
                state with { Float = state.Float + transactionRegistered.Amount },

            (Opened state, ShiftClosed shiftClosed) =>
                new Closed(state.ShiftId, shiftClosed.FinalFloat),

            _ => this
        };
}

讓我們新增最後的構建塊:收銀機設定。我們需要收銀機來進行收銀輪班:

public record CashRegister(string Id)
{
    public static CashRegister Create(CashRegisterInitialized @event) =>
        new(@event.CashRegisterId);
}

public record CashRegisterInitialized(
    string CashRegisterId,
    DateTimeOffset InitializedAt
);

public record InitializeCashRegister(
    string CashRegisterId,
    DateTimeOffset Now
);

public static class CashRegisterDecider
{
    public static object[] Decide(InitializeCashRegister command) =>
        [new CashRegisterInitialized(command.CashRegisterId, command.Now)];
}


現在讓我們定義流程中允許的一組方法。因此,對於事件來說,它將是:OpenShift、RegisterTransaction、CloseShift:

public abstract record CashierShiftCommand
{
    public record OpenShift(
        string CashRegisterId,
        string CashierId,
        DateTimeOffset Now
    ): CashierShiftCommand;

    public record RegisterTransaction(
        CashierShiftId CashierShiftId,
        string TransactionId,
        decimal Amount,
        DateTimeOffset Now
    ): CashierShiftCommand;

    public record CloseShift(
        CashierShiftId CashierShiftId,
        decimal DeclaredTender,
        DateTimeOffset Now
    ): CashierShiftCommand;

    private CashierShiftCommand(){}
}

我們的決策器如下:

public static class CashierShiftDecider
{
    public static object[] Decide(CashierShiftCommand command, CashierShift state) =>
        (command, state) switch
        {
            (OpenShift open, NonExisting) =>
            [
                new ShiftOpened(
                    new CashierShiftId(open.CashRegisterId, 1),
                    open.CashierId,
                    0,
                    open.Now
                )
            ],

            (OpenShift open, Closed closed) =>
            [

                new ShiftOpened(
                    new CashierShiftId(open.CashRegisterId, closed.ShiftId.ShiftNumber + 1),
                    open.CashierId,
                    closed.FinalFloat,
                    open.Now
                )
            ],

            (OpenShift, Opened) => [],

            (RegisterTransaction registerTransaction, Opened openShift) =>
            [
                new TransactionRegistered(
                    openShift.ShiftId,
                    registerTransaction.TransactionId,
                    registerTransaction.Amount,
                    registerTransaction.Now
                )
            ],

            (CloseShift close, Opened openShift) =>
            [
                new ShiftClosed(
                    openShift.ShiftId,
                    close.DeclaredTender,
                    close.DeclaredTender - openShift.Float,
                    openShift.Float - close.DeclaredTender,
                    openShift.Float,
                    close.Now
                )
            ],
            (CloseShift, Closed) => [],

            _ => throw new InvalidOperationException($<font>"Cannot run {command.GetType().Name} on {state.GetType().Name}")
        };
}

在這裡使用了 C# 中新的模式匹配功能。如果您還不太感興趣,我將傳遞狀態和命令,並根據其型別,執行特定的業務邏輯。

  1. 根據OpenShift命令,當沒有狀態或增加最後一個班次編號時,我將返回ShiftOpened事件,班次編號等於0 ,
  2. 如果班次已經開啟,我不會返回任何事件,使其變得無關緊要,因此我不會進行任何更改,也不會引發任何異常。
  3. 註冊交易非常簡單:新增一個新事件,
  4. 當嘗試關閉一個已經關閉的班次時,我將其設定為冪等,就像開啟一個已經開啟的班次一樣。
  5. 如果命令和狀態的組合無效或意外,我只會丟擲InvalidOperationException。模式匹配允許我節省一些 if 語句。

基本場景發生在這裡:

(OpenShift open, Closed closed) =>
[

    new ShiftOpened(
        new CashierShiftId(open.CashRegisterId, closed.ShiftId.ShiftNumber + 1),
        open.CashierId,
        closed.FinalFloat,
        open.Now
    )
],

閉的班次與我們將開啟的班次是不同的流。我們將其設定為:

new CashierShiftId(
    open.CashRegisterId, 
    closed.ShiftId.ShiftNumber + 1
);

提醒一下,這也反映在我們的 id 結構中:

urn:cashier_shift:{CashRegisterId}:{ShiftNumber}


關閉和開啟班次作為一項操作

using CommandResult = (CashierShiftId StreamId, CashierShiftEvent[] Events);

public record CloseAndOpenCommand(
    CashierShiftId CashierShiftId,
    string CashierId,
    decimal DeclaredTender,
    DateTimeOffset Now
);

public static class CloseAndOpenShift
{
    public static (CommandResult, CommandResult) Handle(CloseAndOpenCommand command, CashierShift currentShift)
    {
        <font>// close current shift<i>
        var (currentShiftId, cashierId, declaredTender, now) = command;
        var closingResult = Decide(new CloseShift(currentShiftId, declaredTender, now), currentShift);

       
// get new current shift state by applying the result event(s)<i>
        currentShift = closingResult.Aggregate(currentShift, (current, @event) => current.Apply(@event));

       
// open the next shift<i>
        var openResult = Decide(new OpenShift(currentShiftId, cashierId, now), currentShift);

       
// 仔細檢查是否真的開啟過<i>
        var opened = openResult.OfType<ShiftOpened>().SingleOrDefault();
        if (opened == null)
            throw new InvalidOperationException(
"Cannot open new shift!");

       
// 用各自的流 id 返回兩個結果<i>
        return ((currentShiftId, closingResult), (opened.CashierShiftId, openResult));
    }
}

我們按順序執行兩個命令,返回兩個操作的結果以及 id。

如果我們使用受益於 PostgreSQL 事務功能和Marten 內建工作單元

public static class CloseAndOpenShift
{
    public static async Task<CashierShiftId> CloseAndOpenCashierShift(
        this IDocumentSession documentSession,
        CloseAndOpenCommand command,
        int version,
        CancellationToken ct
    )
    {
        var currentShift =
            await documentSession.Events.AggregateStreamAsync<CashierShift>(command.CashierShiftId, token: ct) ??
            new CashierShift.NonExisting();

        var (closingResult, openResult) = Handle(command, currentShift);

        <font>// Append Closing result to the old stream<i>
        if (closingResult.Events.Length > 0)
            documentSession.Events.Append(closingResult.StreamId, version, closingResult.Events.AsEnumerable());

        if (openResult.Events.Length > 0)
            documentSession.Events.StartStream<CashierShift>(openResult.StreamId, openResult.Events.AsEnumerable());

        await documentSession.SaveChangesAsync(ct);

        return openResult.StreamId;
    }
}

由於 id 的可預測結構和呼叫StartStream方法,我們可以確保如果新班次已經開啟,那麼我們的操作將被拒絕。我們不會有任何重複的輪班。
我們可以在端點中使用此程式碼:

app.MapPost(<font>"/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber:int}/close-and-open",
    async (
        IDocumentSession documentSession,
        string cashRegisterId,
        int shiftNumber,
        CloseAndOpenShiftRequest body,
        [FromIfMatchHeader] string eTag,
        CancellationToken ct
    ) =>
    {
        var command = new CloseAndOpenCommand(
            new CashierShiftId(cashRegisterId, shiftNumber),
            body.CashierId,
            body.DeclaredTender,
            Now
        );

        var openedCashierId = await documentSession.CloseAndOpenCashierShift(command, ToExpectedVersion(eTag), ct);

        return Created(
            $
"/api/cash-registers/{cashRegisterId}/cashier-shifts/{openedCashierId.ShiftNumber}",
            cashRegisterId
        );
    }
);

正如您所看到的,我們極大地受益於事件溯源和決策者模式的可重複性和可組合性以及 Marten 的事務功能。

我們還使用ETag 的樂觀併發來確保我們不會遇到併發問題。因此,我們會知道我們正在根據預期狀態做出決策。

關閉和開放輪班作為單獨的操作
如果我們的生命週期是連續的,那麼將關閉和開啟作為一項操作的模式可能是合適的。然而,通常情況下,它們並非如此。收銀員佔用收銀機的情況取決於客流量。有時可能沒有人在特定的收銀機上工作。怎麼處理呢?
讓我們從結尾開始,從結束開始,一步步解決這個問題。現在這將是一個簡單的操作:

app.MapPost(<font>"/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber:int}/close",
    (
        IDocumentSession documentSession,
        string cashRegisterId,
        int shiftNumber,
        CloseShiftRequest body,
        [FromIfMatchHeader] string eTag,
        CancellationToken ct
    ) =>
    {
        var cashierShiftId = new CashierShiftId(cashRegisterId, shiftNumber);

        return documentSession.GetAndUpdate<CashierShift, CashierShiftEvent>(cashierShiftId, ToExpectedVersion(eTag),
            state => Decide(new CloseShift(cashierShiftId, body.DeclaredTender, Now), state), ct);
    }
);

我在這裡使用一個簡單的包裝器,它將從流中載入事件,從中構建狀態,執行業務邏輯並附加事件結果事件(如果有):

public static class DocumentSessionExtensions
{
    public static Task GetAndUpdate<T, TEvent>(
        this IDocumentSession documentSession,
        string id,
        int version,
        Func<T, TEvent[]> handle,
        CancellationToken ct
    ) where T : class =>
        documentSession.Events.WriteToAggregate<T>(id, version, stream =>
        {
            var result = handle(stream.Aggregate);
            if (result.Length != 0)
                stream.AppendMany(result.Cast<object>().ToArray());
        }, ct);
}

開啟班次會比較複雜
我們需要獲取上次關閉的班次編號和資料(例如float,即上次班次後抽屜中現金的狀態)。當然,收銀員可以提供之前的班次號碼,但這很容易出錯並且存在潛在的漏洞。如果我們自己跟蹤的話會更好。最好的方法是構建一個可以快取資訊的模型。我們可以根據註冊的事件定義更新的投影。

這樣的模型應該包含什麼?它有可能快取開啟新班次所需的所有資訊,例如:

public record CashierShiftTracker(
    string Id, <font>// Cash Register Id<i>
    string LastClosedShiftNumber,
    string LastClosedShiftFloat,
   
// (...) etc.<i>
);

看起來不錯,因為我們可以從查詢它並從中獲取所需的資訊開始。然而,在我看來,該解決方案不可擴充套件,並且在維護時可能會帶來一些問題。我們的流程可能會發生變化,並且每次影響關閉或開啟時我們都需要更新此預測。這可能會在模型版本控制、更新等方面產生問題。

結賬模式總結

  • 儲存審計和下一個生命週期所需的已關閉流的摘要,
  • 開啟時,從關閉的生命週期中的最後一個事件獲取資料,並使用它來啟動新的週期和流。

保持流簡短是事件溯源中最重要的建模實踐。結賬模式是實現這一目標的最大推動者。

請參閱我的示例儲存庫中的完整程式碼。

相關文章