用資料結構解釋事件溯源 – {4Comprehension}

banq發表於2020-06-06

在本系列中,我們將透過實現假設資料結構的PoC(基於事件的列表),重新審視事件源的概念,然後在後續文章中透過使其併發且對記憶體友好的方式進一步改進事件源的概念。

事件溯源
多年來,我們已經習慣了這樣一個事實,即大多數業務應用程式將狀態儲存在某些外部儲存中,這在確保可審計性或重建過去的狀態時通常會產生額外的工作。但是,如果我們放棄了需要儲存狀態的假設前提,該怎麼辦?
事件溯源是一個概念,在該概念中,我們儲存狀態更改事件而不是狀態,並僅在需要時匯出實際狀態。在可稽核性和獲取新資訊方面,這提供了很多可能性!
例如,想象一下檢索使用者的帳戶餘額。使用事件源,您不僅可以訪問原始運算元,還可以訪問整個操作歷史記錄。您可以跟蹤所有過去的事件,過去的狀態,這些事件導致導致表示當前狀態的特定值。奇妙!
知道這一點後,讓我們看看是否可以將此想法應用到通用列表中。

將事件源應用於列表
讓我們開始為實現定義:

public class ESList<T> implements List<T> { ... }


就像上面建立的一樣,為了實現事件源資料結構,我們需要儲存狀態改變事件/操作而不是狀態本身,然後針對當場重新建立的狀態執行方法。
為了實現這一目標,我們需要:
  • 為我們的事件定義合同
  • 為我們的歷史事件定義一個儲存容器
  • 實現事件處理邏輯
  • 實現事件重播邏輯

內部事件日誌是我們實施的核心。這是應用於資料結構的所有修改的歷史,可以表示為簡單列表:

private final List<ListOp<T>> opLog = new ArrayList<>();


一個操作實際上只是一個函式,它接受一些列表並返回該操作的結果:

interface ListOp<R> {
    Object apply(List<R> list);
}


因此,代表加法的操作可以實現為:

class AddOp<T> implements ListOp<T> {
    private final T elem;

    AddOp(T elem) {
        this.elem = elem;
    }

    @Override
    public Object apply(List<T> list) {
        return list.add(elem);
    }

    @Override
    public String toString() {
        return String.format("add(element = %s)", elem);
    }
}


現在,無論何時實現任何狀態更改方法,我們都只需要建立該操作的表示形式,將其儲存並應用於重新建立的狀態,以便我們可以返回該操作的結果:

@Override 
public boolean add(T t){ 
    returnboolean)handle(new AddOp <>(t)); 
}

現在,如果我們想在任何時間點重新建立列表的狀態,則需要從時間開始重新執行所有操作。
最好返回包裝在Optional例項中的結果:

public Optional<List<T>> snapshot(int version) {
    if (version > opLog.size()) {
        return Optional.empty();
    }

    var snapshot = new ArrayList<T>();
    for (int i = 0; i <= version; i++) {
        try {
            opLog.get(i).apply(snapshot);
        } catch (Exception ignored) { }
    }
    return Optional.of(snapshot);
}

public List<T> snapshot() {
    return snapshot(opLog.size())
        .orElseThrow(IllegalStateException::new);
}

空的catch塊可能看起來有爭議,但是對於跟蹤故障至關重要。稍後我們將新增一些日誌記錄。
現在我們可以完成事件處理邏輯。注意,在將操作新增到日誌之前,我們需要首先重新建立當前狀態:

private Object handle(ListOp<T> op) {
    List<T> snapshot = snapshot();
    opLog.add(op);
    return op.apply(snapshot);
}

現在,所有List介面的查詢方法都需要首先重新建立狀態:

@Override
public int indexOf(Object o) {
    return snapshot().indexOf(o);
}

@Override
public int lastIndexOf(Object o) {
    return snapshot().lastIndexOf(o);
}

@Override
public ListIterator<T> listIterator() {
    return snapshot().listIterator();
}

// ...


擁有一種通知我們資料結構存在多少版本的方法也將很方便:

public int version() {
    return opLog.size();
}

另外,為了方便我們觀察更改,讓我們新增一種額外的方法來顯示所有操作的歷史記錄:

public void displayLog() {
    for (int i = 0; i < opLog.size(); i++) {
        System.out.printf("v%d :: %s%n", i, opLog.get(i).toString());
    }
}


現在,讓我們透過執行一些修改並最終清除列表來了解它的作用:

public static void main(String[] args) {
    ESList<Integer> objects = ESList.newInstance();

    objects.add(1);
    objects.add(2);
    objects.add(3);
    objects.addAll(List.of(4, 5));
    objects.remove(Integer.valueOf(1));
    objects.clear();
    objects.displayLog();

    System.out.println();

    for (int i = 0; i < objects.version(); i++) {
        System.out.println("v" + i + " :" + objects.snapshot(i).get());
    }
}


儘管透過清除列表回到了第一位,我們可以看到整個歷史記錄得以保留,並且我們設法重新建立了資料結構的所有現有版本:

v0 :: init[]
v1 :: add(element = 1)
v2 :: add(element = 2)
v3 :: add(element = 3)
v4 :: addAll([4, 5])
v5 :: remove(1)
v6 :: clear()

v0 :[]
v1 :[1]
v2 :[1, 2]
v3 :[1, 2, 3]
v4 :[1, 2, 3, 4, 5]
v5 :[2, 3, 4, 5]
v6 :[]


我們到了!這是事件源的基本思想。
自然,此實現有多個缺點,而且肯定還沒有準備好投入生產。它不僅不是執行緒安全的,而且內部事件日誌是記憶體洩漏的根源。
甚至沒有提到這樣一個事實,即在每次操作之前重播事件效率低下,但可以很好地為我們提供教育材料。

可以在GitHub上找到程式碼段
 

相關文章