Mnesia 與記憶的藝術

您是個有許多朋友的人最親近的朋友。他們中的許多人,就像您一樣,已經認識很久了。他們來自世界各地,從西西里島到紐約都有。朋友們關心您和您的朋友,您們也關心他們。

A parody of 'The Godfather' logo instead saying 'The Codefather'

在特殊情況下,他們會請您幫忙,因為您們是有權力、值得信賴的人。他們是您的好朋友,所以您會幫忙。然而,友誼是有代價的。每一項實現的恩惠都會被記錄下來,而在未來的某個時間點,您可能會或可能不會要求回報。

您總是信守諾言,是可靠的支柱。這就是為什麼他們稱呼您的朋友為老大,稱呼您為顧問,以及為什麼您領導著最受尊敬的黑手黨家族之一。

然而,記住您所有的友誼變得非常麻煩,而且隨著您的影響範圍在全球擴大,越來越難以追蹤哪些朋友欠您,以及您欠哪些朋友。

因為您是一位樂於助人的顧問,您決定將傳統的系統從秘密保存在各處的筆記,升級為使用 Erlang 的系統。

起初,您認為使用 ETS 和 DETS 表格會很完美。然而,當您出差在外,遠離老大時,要保持同步變得有些困難。

您可以在您的 ETS 和 DETS 表格之上編寫一個複雜的層級,以保持一切井然有序。您可以這麼做,但身為人,您知道自己會犯錯並編寫有錯誤的軟體。當友誼如此重要時,應該避免這些錯誤,因此您上網尋找如何確保您的系統正常運作的方法。

這就是您開始閱讀本章的時候,本章將解釋 Mnesia,一個為了解決這些問題而建構的 Erlang 分散式資料庫。

什麼是 Mnesia

Mnesia 是在 ETS 和 DETS 之上建構的一個層級,為這兩個資料庫添加了許多功能。它主要包含許多開發人員如果大量使用它們,最終可能會自己編寫的功能。這些功能包括自動寫入 ETS 和 DETS 的能力、同時擁有 DETS 的持久性和 ETS 的效能,或自動將資料庫複製到許多不同的 Erlang 節點的可能性。

我們發現有用的另一個功能是交易。交易基本上意味著您將能夠在一個或多個表格上執行多個操作,就好像執行這些操作的過程是唯一可以存取這些表格的過程一樣。當我們需要將混合讀取和寫入的並行操作作為單一單元的一部分時,這將被證明至關重要。一個例子是讀取資料庫以查看使用者名稱是否已被使用,然後在使用者名稱可用時建立使用者。如果沒有交易,在表格中查找值,然後註冊它會被視為兩個不同的操作,它們可能會相互干擾——在適當的時機下,一次有多個程序可能認為它有權建立唯一的使用者,這將導致很多混亂。交易通過允許多個操作充當一個單一單元來解決這個問題。

Mnesia 的好處是,它幾乎是您唯一擁有的功能齊全的資料庫,它可以原生儲存並返回任何 Erlang 項 (在撰寫本文時)。缺點是,它將繼承 DETS 表格在某些模式下的所有限制,例如無法在磁碟上為單一表格儲存超過 2GB 的資料 (實際上可以使用一個稱為 分段 的功能來繞過此限制)。

如果我們參考 CAP 定理,Mnesia 位於 CP 端,而不是 AP 端,這表示它不會進行最終一致性,在某些情況下會對網路分裂反應相當糟糕,但如果您期望網路可靠,它會為您提供強烈的一致性保證 (而且有時您不應該期望網路可靠)。

請注意,Mnesia 並不是要取代您的標準 SQL 資料庫,它也不是要像 NoSQL 世界的巨頭經常宣稱的那樣,處理跨大量資料中心的 TB 級資料。Mnesia 而是為了較小量的資料,在有限數量的節點上使用。雖然可以在大量節點上使用它,但大多數人發現其實際限制似乎集中在 10 個左右。當您知道它將在固定數量的節點上運行、了解它需要多少資料,並且知道您主要需要從 Erlang 中以 ETS 和 DETS 在通常情況下允許您執行的方式存取您的資料時,您會想要使用 Mnesia。

它與 Erlang 的關係有多密切?Mnesia 以使用記錄來定義表格結構的概念為中心。因此,每個表格都可以儲存一堆相似的記錄,而記錄中的任何內容都可以儲存在 Mnesia 表格中,包括原子、pid、參考等等。

商店應該儲存什麼

a Best Friends Forever necklace

使用 Mnesia 的第一步是找出我們為黑手黨朋友追蹤應用程式 (我決定將其命名為 mafiapp) 需要哪種表格結構。我們可能想要儲存的與朋友相關的資訊將是

然後我們必須考慮我們朋友和我們之間的服務。我們想知道它們的哪些資訊?以下是我可以想到的一些事項列表

  1. 誰提供了服務。也許是您,顧問。也許是教父。也許是朋友的朋友,代表您。也許是後來成為您朋友的人。我們需要知道。
  2. 誰接受了服務。與前一個相同,但在接收端。
  3. 服務何時提供。通常能夠刷新某人的記憶會很有用,尤其是在要求回報時。
  4. 與前一點相關,如果能夠儲存有關服務的詳細資訊,那就太好了。除了日期之外,記住我們提供的服務的每一個細節會更好 (也更令人敬畏)。

正如我在上一節中提到的,Mnesia 是基於記錄和表格 (ETS 和 DETS) 的。確切地說,您可以定義一個 Erlang 記錄,並告訴 Mnesia 將其定義轉換為表格。基本上,如果我們決定讓我們的記錄採用以下形式

-record(recipe, {name, ingredients=[], instructions=[]}).

然後我們可以告訴 Mnesia 建立一個 recipe 表格,該表格會將任意數量的 #recipe{} 記錄儲存為表格行。因此,我可能會有一份披薩食譜,如下所示

#recipe{name=pizza,
        ingredients=[sauce,tomatoes,meat,dough],
        instructions=["order by phone"]}

以及一份湯食譜,如下所示

#recipe{name=soup,
        ingredients=["who knows"],
        instructions=["open unlabeled can, hope for the best"]}

然後我可以將這兩者都插入 recipe 表格中。然後,我可以從表格中提取完全相同的記錄,並像使用其他記錄一樣使用它們。

主鍵,也就是在表格中查找資料最快的欄位,將是食譜名稱。那是因為 name#recipe{} 記錄定義中的第一個項目。您還會注意到,在披薩食譜中,我使用原子作為成分,而在湯食譜中,我使用字串。與 SQL 表格不同,Mnesia 表格沒有內建的類型約束,只要您遵守表格本身的 tuple 結構即可。

無論如何,回到我們的黑手黨應用程式。我們應該如何表示我們的朋友和服務資訊?也許作為一個表格來執行所有操作?

-record(friends, {name,
                  contact=[],
                  info=[],
                  expertise,
                  service=[]}). % {To, From, Date, Description} for services?

這不是最好的選擇。將服務的資料巢狀在與朋友相關的資料中,表示新增或修改與服務相關的資訊將需要我們同時更改朋友。這可能會很麻煩,尤其是因為服務至少涉及兩個人。對於每項服務,我們都需要提取兩位朋友的記錄並更新它們,即使沒有需要修改的與朋友相關的特定資訊。

一個更靈活的模型將為我們需要儲存的每一種資料使用一個表格

-record(mafiapp_friends, {name,
                          contact=[],
                          info=[],
                          expertise}).
-record(mafiapp_services, {from,
                           to,
                           date,
                           description}).

擁有兩個表格應該可以為我們提供搜尋資訊、修改資訊所需的所有彈性,而且開銷很小。在開始處理所有這些寶貴的資訊之前,我們必須初始化表格。

不要喝太多酷飲料
您會注意到我在 friendsservices 記錄前面都加上了 mafiapp_ 前綴。這樣做的原因是,雖然記錄是在我們的模組內局部定義的,但 Mnesia 表格對於將成為其叢集一部分的所有節點都是全域的。這表示如果您不小心,可能會發生名稱衝突。因此,手動為表格命名空間是個好主意。

從記錄到表格

現在我們知道我們想要儲存什麼,接下來的邏輯步驟是決定我們將如何儲存它。請記住,Mnesia 是使用 ETS 和 DETS 表格建構的。這為我們提供了兩種儲存方式:在磁碟上或在記憶體中。我們必須選擇一種策略!以下是選項

ram_copies
此選項會使所有資料完全儲存在 ETS 中,因此僅儲存在記憶體中。對於在 32 位元編譯的虛擬機器,記憶體應該限制在理論上的 4GB (實際上約為 3GB),但假設有超過 4GB 的可用記憶體,此限制在 64 位元虛擬機器上會被進一步推移。
disc_only_copies
此選項表示資料僅儲存在 DETS 中。僅限磁碟,因此儲存空間限制在 DETS 的 2GB 限制內。
disc_copies
這個選項表示資料會同時儲存在 ETS 和磁碟上,也就是記憶體和硬碟。disc_copies 表格受限於 DETS 的限制,因為 Mnesia 使用一套複雜的交易日誌和檢查點系統,可以建立記憶體中表格的磁碟備份。

對於我們目前的應用程式,我們將使用 disc_copies。原因在於我們至少需要將資料持久化到磁碟上。我們與朋友建立的關係需要長久維持,因此能夠持久儲存資料是有意義的。如果在停電後醒來,卻發現自己辛苦建立的所有友誼都消失了,那會相當惱人。你可能會問,為什麼不直接使用 disc_only_copies?嗯,當我們想執行比較複雜的查詢和搜尋時,通常在記憶體中有副本會比較好,因為這樣不需要存取磁碟,而磁碟通常是任何電腦記憶體存取中最慢的部分,尤其是硬碟。

在我們將珍貴資料填入資料庫的路上,還有另一個障礙。由於 ETS 和 DETS 的運作方式,我們需要定義表格類型。可用的類型與它們在 ETS 和 DETS 中對應的定義相同。選項有 setbagordered_set。特別的是,ordered_set 不支援 disc_only_copies 表格。如果你不記得這些類型是做什麼的,我建議你查閱 ETS 章節

注意: duplicate_bag 類型的表格不適用於任何儲存類型。目前沒有明顯的解釋說明原因。

好消息是,我們在決定如何儲存資料方面已經差不多完成了。壞消息是,在真正開始之前,關於 Mnesia 還有更多需要理解的地方。

關於 Schema 和 Mnesia

雖然 Mnesia 可以在隔離的節點上正常運作,但它確實支援分佈和複製到多個節點。為了知道如何在磁碟上儲存表格、如何載入它們,以及應該與哪些其他節點同步,Mnesia 需要一個稱為 schema 的東西,其中包含所有這些資訊。預設情況下,Mnesia 會在建立時直接在記憶體中建立一個 schema。這對於只需要存在於 RAM 中的表格來說是可行的,但當你的 schema 需要在 Mnesia 集群中所有節點的多次 VM 重新啟動中存活時,事情會變得比較複雜。

A chicken and an egg with arrows pointing both ways to denotate the chicken and egg problem

Mnesia 依賴於 schema,但 Mnesia 也應該建立 schema。這產生了一個奇怪的情況,schema 需要在沒有先執行 Mnesia 的情況下由 Mnesia 建立!實際上,這個問題很容易解決。我們只需要在啟動 Mnesia 之前呼叫函式 mnesia:create_schema(ListOfNodes)。它會在每個節點上建立一堆檔案,儲存所有需要的表格資訊。呼叫它時不需要連線到其他節點,但它們需要處於執行狀態;該函式會設定連線並讓一切正常運作。

預設情況下,schema 會在 Erlang 節點執行的目前工作目錄中建立。若要變更此設定,Mnesia 應用程式有一個 dir 變數,可以設定來選擇儲存 schema 的位置。因此,你可以將節點啟動為 erl -name SomeName -mnesia dir where/to/store/the/db,或使用 application:set_env(mnesia, dir, "where/to/store/the/db"). 動態設定。

注意: schema 可能因以下原因而建立失敗:已經存在一個 schema、Mnesia 正在 schema 應該存在的其中一個節點上執行、你無法寫入 Mnesia 想要寫入的目錄等等。

一旦建立 schema,我們就可以啟動 Mnesia 並開始建立表格。我們需要使用的函式是 mnesia:create_table/2。它接受兩個參數:表格名稱和選項清單,其中一些選項如下所述。

{attributes, List}
這是表格中所有項目的清單。預設情況下,它採用 [key, value] 的形式,表示你需要一個 -record(TableName, {key,val}). 形式的記錄才能運作。幾乎每個人都會稍微作弊一下,並使用一種特殊的結構(實際上是一種編譯器支援的巨集),從記錄中提取元素名稱。這個結構看起來像函式呼叫。若要使用我們的朋友記錄執行此操作,我們會將其作為 {attributes, record_info(fields, mafiapp_friends)} 傳遞。
{disc_copies, NodeList},
{disc_only_copies, NodeList},
{ram_copies, NodeList}
這是你指定如何儲存表格的地方,如從記錄到表格中所述。請注意,你可以同時存在許多這些選項。例如,我可以定義一個表格 X,透過使用所有三個選項,將其儲存在我的主節點的磁碟和 RAM 中、僅儲存在所有從節點的 RAM 中,以及僅儲存在專用備份節點的磁碟上。
{index, ListOfIntegers}
Mnesia 表格允許你在基本 ETS 和 DETS 功能之上建立索引。當你計劃在主要鍵以外的記錄欄位上建立搜尋時,這很有用。例如,我們的朋友表格將需要一個用於專業知識欄位的索引。我們可以將此索引宣告為 {index, [#mafiapp_friends.expertise]}。一般而言,對於許多資料庫來說,你只會想在大多數條目之間差異不太大的欄位上建立索引。在一個包含數十萬個條目的表格中,如果你的索引最多只能將表格分割成兩個要排序的群組,那麼索引將會佔用大量空間,卻沒有太多好處。例如,如果一個索引將同一個表格分割成 N 個包含十個或更少元素的群組,對於它使用的資源來說會更有用。請注意,你不需要在記錄的第一個欄位上放置索引,因為預設情況下會為你完成此操作。
{record_name, Atom}
如果你想要讓表格的名稱與記錄使用的名稱不同,這會很有用。但是,這樣做會迫使你使用不同的函式來操作表格,而不是大家常用的函式。除非你真的知道自己想要這麼做,否則我不建議使用此選項。
{type, Type}
Type 可以是 setordered_setbag 表格。這與我在從記錄到表格中先前解釋的相同。
{local_content, true | false}
預設情況下,所有 Mnesia 表格的此選項都設定為 false。如果你希望表格及其資料複製到 schema 中所有節點(以及在 disc_copiesdisc_only_copiesram_copies 選項中指定的節點),你就會想要保持這種方式。將此選項設定為 true 會在所有節點上建立所有表格,但內容只會是本機內容;不會共享任何內容。在這種情況下,Mnesia 成為一個在許多節點上初始化類似空表格的引擎。

簡而言之,以下是在設定 Mnesia schema 和表格時可能發生的事件順序

注意: 還有第三種方式可以做到。每當你有一個 Mnesia 節點正在執行且已建立你想要移植到磁碟的表格時,可以呼叫函式 mnesia:change_table_copy_type(Table, Node, NewType) 將表格移動到磁碟。

更特別的是,如果你忘記在磁碟上建立 schema,你可以呼叫 mnesia:change_table_copy_type(schema, node(), disc_copies),將你的 RAM schema 轉換為磁碟 schema。

我們現在對如何建立表格和 schema 有了一個模糊的概念。這可能足以讓我們開始。

實際建立表格

我們將使用一些弱 TDD 風格的程式設計,透過 Common Test 來處理應用程式及其表格的建立。現在你可能不喜歡 TDD 的想法,但請繼續看下去,我們將以輕鬆的方式進行,只是一種引導我們設計的方式,而不是其他任何目的。沒有「執行測試以確保它們失敗」的事情(儘管如果你想,你可以隨意這樣做)。我們最終會有測試只是一個不錯的副作用,而不是最終目的。我們主要關心的是定義 mafiapp 應該如何運作和看起來的介面,而不會全部從 Erlang shell 執行。測試甚至不會分佈,但這仍然是一個透過學習 Mnesia 同時實際使用 Common Test 的好機會。

為此,我們應該按照標準 OTP 結構啟動一個名為 mafiapp-1.0.0 的目錄

ebin/
logs/
src/
test/

我們將從找出我們想要如何安裝資料庫開始。由於需要一個 schema 和第一次初始化表格,我們將需要使用一個安裝函式來設定所有測試,該函式最好將東西安裝到 Common Test 的 priv_dir 目錄中。讓我們從一個基本的測試套件開始,mafiapp_SUITE,儲存在 test/ 目錄下

-module(mafiapp_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([init_per_suite/1, end_per_suite/1,
         all/0]).
all() -> [].

init_per_suite(Config) ->
    Priv = ?config(priv_dir, Config),
    application:set_env(mnesia, dir, Priv),
    mafiapp:install([node()]),
    application:start(mnesia),
    application:start(mafiapp),
    Config.

end_per_suite(_Config) ->
    application:stop(mnesia),
    ok.

這個測試套件目前還沒有測試項目,但它給了我們第一個關於事情應該如何完成的規範。首先,我們透過將 dir 變數設定為 priv_dir 的值,來選擇 Mnesia 綱要和資料庫檔案的存放位置。這會將每個綱要和資料庫的實例放置在使用 Common Test 生成的私有目錄中,保證我們不會有來自先前測試執行的問題和衝突。您也可以看到我決定將安裝函數命名為 install,並給它一個要安裝的節點列表。通常,這種列表方式比在 install 函數中硬編碼更好,因為它更具彈性。完成此操作後,應該啟動 Mnesia 和 mafiapp。

現在我們可以進入 src/mafiapp.erl,開始了解安裝函數應該如何運作。首先,我們需要將之前擁有的記錄定義重新帶入。

-module(mafiapp).
-export([install/1]).

-record(mafiapp_friends, {name,
                          contact=[],
                          info=[],
                          expertise}).
-record(mafiapp_services, {from,
                           to,
                           date,
                           description}).

看起來夠好了。這是 install/1 函數:

install(Nodes) ->
    ok = mnesia:create_schema(Nodes),
    application:start(mnesia),
    mnesia:create_table(mafiapp_friends,
                        [{attributes, record_info(fields, mafiapp_friends)},
                         {index, [#mafiapp_friends.expertise]},
                         {disc_copies, Nodes}]),
    mnesia:create_table(mafiapp_services,
                        [{attributes, record_info(fields, mafiapp_services)},
                         {index, [#mafiapp_services.to]},
                         {disc_copies, Nodes},
                         {type, bag}]),
    application:stop(mnesia).

首先,我們在 Nodes 列表中指定的節點上建立綱要。然後,我們啟動 Mnesia,這是建立表格的必要步驟。我們建立兩個表格,分別以記錄 #mafiapp_friends{}#mafiapp_services{} 命名。我們在專業知識上建立索引,因為我們預計在需要時會依專業知識搜尋朋友,如先前所述。

A bag of money with a big dollar sign on it

您也會看到服務表格的類型是 bag。這是因為有可能有多個具有相同發送者和接收者的服務。使用 set 表格,我們只能處理唯一的發送者,但 bag 表格可以很好地處理這個問題。然後您會注意到表格的 to 欄位上有索引。這是因為我們預計會依據接收者或發送者來查找服務,而索引可以讓任何欄位的搜尋速度更快。

最後要注意的是,我會在建立表格後停止 Mnesia。這只是為了符合我在測試中寫的行為。測試中的內容是我期望使用程式碼的方式,所以我最好讓程式碼符合這個想法。不過,在安裝後讓 Mnesia 繼續執行並沒有任何問題。

現在,如果我們的 Common Test 套件中有成功的測試案例,則初始化階段將會使用此安裝函數成功完成。然而,嘗試使用許多節點會將失敗訊息帶到我們的 Erlang shell。知道為什麼嗎?這看起來會像這樣:

Node A                     Node B
------                     ------
create_schema -----------> create_schema
start Mnesia
creating table ----------> ???
creating table ----------> ???
stop Mnesia

為了在所有節點上建立表格,Mnesia 需要在所有節點上執行。為了建立綱要,Mnesia 需要在沒有節點上執行。理想情況下,我們可以遠端啟動和停止 Mnesia。好消息是我們可以做到。還記得來自 Distribunomicon 的 RPC 模組嗎?我們有 rpc:multicall(Nodes, Module, Function, Args) 函數可以為我們做到。讓我們將 install/1 函數定義更改為這個:

install(Nodes) ->
    ok = mnesia:create_schema(Nodes),
    rpc:multicall(Nodes, application, start, [mnesia]),
    mnesia:create_table(mafiapp_friends,
                        [{attributes, record_info(fields, mafiapp_friends)},
                         {index, [#mafiapp_friends.expertise]},
                         {disc_copies, Nodes}]),
    mnesia:create_table(mafiapp_services,
                        [{attributes, record_info(fields, mafiapp_services)},
                         {index, [#mafiapp_services.to]},
                         {disc_copies, Nodes},
                         {type, bag}]),
    rpc:multicall(Nodes, application, stop, [mnesia]).

使用 RPC 允許我們在所有節點上執行 Mnesia 操作。現在的方案看起來像這樣:

Node A                     Node B
------                     ------
create_schema -----------> create_schema
start Mnesia ------------> start Mnesia
creating table ----------> replicating table
creating table ----------> replicating table
stop Mnesia -------------> stop Mnesia

很好,非常好。

我們必須處理的 init_per_suite/1 函數的下一個部分是啟動 mafiapp。嚴格來說,沒有必要這樣做,因為我們的整個應用程式都依賴 Mnesia:啟動 Mnesia 就是啟動我們的應用程式。然而,從 Mnesia 啟動到它完成從磁碟載入所有表格之間,可能會有一段明顯的延遲,尤其是在表格很大的情況下。在這種情況下,即使我們在正常操作中完全不需要任何程序,像 mafiappstart/2 這樣的函數也可能是執行這種等待的理想場所。

我們將讓 mafiapp.erl 實現應用程式行為(-behaviour(application).),並在檔案中加入以下兩個回呼(記得匯出它們):

start(normal, []) ->
    mnesia:wait_for_tables([mafiapp_friends,
                            mafiapp_services], 5000),
    mafiapp_sup:start_link().

stop(_) -> ok.

秘密在於 mnesia:wait_for_tables(TableList, TimeOut) 函數。這個函數會等待最多 5 秒(一個任意數字,您可以根據您的資料調整)或直到表格可用為止。

這並沒有告訴我們有關監督者應該做什麼的太多資訊,那是因為 mafiapp_sup 沒有太多事情要做。

-module(mafiapp_sup).
-behaviour(supervisor).
-export([start_link/0]).
-export([init/1]).

start_link() ->
    supervisor:start_link(?MODULE, []).

%% This does absolutely nothing, only there to
%% allow to wait for tables.
init([]) ->
    {ok, {{one_for_one, 1, 1}, []}}.

監督者沒有做任何事,但是由於 OTP 應用程式的啟動是同步的,所以它實際上是放置這種同步點的最佳位置之一。

最後,在 ebin/ 目錄中加入以下 mafiapp.app 檔案,以確保可以啟動應用程式:

{application, mafiapp,
 [{description, "Help the boss keep track of his friends"},
  {vsn, "1.0.0"},
  {modules, [mafiapp, mafiapp_sup]},
  {applications, [stdlib, kernel, mnesia]}]}.

現在我們準備好編寫實際的測試並實現我們的應用程式了。還是說我們還沒準備好?

存取和上下文

在開始實作我們的應用程式之前,了解如何使用 Mnesia 來處理表格可能會很有價值。

對資料庫表格的所有修改甚至讀取操作都需要在稱為活動存取上下文的內容中完成。這些是不同類型的交易或執行查詢的「方式」。以下是一些選項:

transaction

Mnesia 交易允許將一系列資料庫操作作為單一功能區塊執行。整個區塊將在所有節點上執行,或在沒有任何節點上執行;它要么完全成功,要么完全失敗。當交易返回時,我們保證表格處於一致的狀態,並且即使它們嘗試操作相同的資料,不同的交易也不會互相干擾。

這種活動上下文類型是部分非同步的:它對於本機節點上的操作將是同步的,但它只會等待其他節點確認它們提交交易,而不是它們已經提交。根據 Mnesia 的運作方式,如果交易在本機運作正常,而且其他所有人都同意執行,那麼它應該在其他地方也正常運作。如果沒有,可能是由於網路或硬體故障,交易將在稍後的時間點被還原;該協定為了提高效率而容忍這種情況,但可能會在稍後回滾時,向您確認交易成功。

sync_transaction

此活動上下文與 transaction 非常相似,但它是同步的。如果 transaction 的保證對您來說不夠,因為您不喜歡交易告訴您它成功了,但實際上可能因為奇怪的錯誤而失敗,尤其是在您想要執行與交易成功相關的副作用(例如通知外部服務、產生程序等)時,那麼您可以使用 sync_transaction。同步交易將等待所有其他節點的最終確認後再返回,以確保一切都 100% 正常運作。

一個有趣的用例是,如果您正在執行大量的交易,足以使其他節點超載,那麼切換到同步模式應該會強制事情以較慢的速度進行,並減少積壓,將超載問題提升到應用程式中的一個更高層級。

async_dirty

async_dirty 活動上下文基本上會繞過所有交易協定和鎖定活動(請注意,它會在繼續之前等待活動交易完成)。但是,它將繼續執行包括記錄、複製等在內的所有操作。async_dirty 活動上下文會嘗試在本機執行所有操作,然後返回,讓其他節點的複製操作以非同步方式進行。

sync_dirty

此活動上下文對於 async_dirty 來說,就像 sync_transaction 對於 transaction 一樣。它會等待遠端節點確認事情進展順利,但仍會保持在所有鎖定或交易上下文之外。Dirty 上下文通常比交易更快,但從設計上來說絕對風險更高。請謹慎處理。

ets

最後一個可能的活動上下文是 ets。這基本上是一種繞過 Mnesia 所做的一切,並對底層 ETS 表格(如果有的話)執行一系列原始操作的方法。不會進行複製。ets 活動上下文並不是您通常需要使用的東西,因此您不應該想要使用它。這又是另一個「如果不確定,就不要使用它,當您需要它時,您就會知道」的例子。

這些是執行常見 Mnesia 操作的所有上下文。這些操作本身要包裝在 fun 中,並透過呼叫 mnesia:activity(Context, Fun). 來執行。fun 可以包含任何 Erlang 函數呼叫,但請注意,在發生故障或被其他交易中斷的情況下,交易可能會被執行多次。

這表示如果從表格中讀取值的交易,在將某些內容寫回之前也傳送了訊息,則訊息完全有可能被傳送數十次。因此,不應在交易中包含這類副作用

a pen writing 'sign my guestbook'

讀取、寫入等等

我已經多次提到這些表格修改函數,現在是時候定義它們了。它們中的大多數都與 ETS 和 DETS 提供給我們的相似,這並不令人意外。

write

透過呼叫 mnesia:write(Record),其中記錄的名稱是表格的名稱,我們可以將 Record 插入表格中。如果表格的類型為 setordered_set,並且主要索引鍵(記錄的第二個欄位,而不是其名稱,以元組形式表示),則元素將被取代。對於 bag 表格,整個記錄必須相似。

如果寫入操作成功,write/1 將返回 ok。否則,它會拋出一個異常,該異常將中止交易。拋出此類異常的情況應該不常見。它主要發生在 Mnesia 沒有執行、找不到表格或記錄無效時。

delete

該函數被呼叫為 mnesia:delete(TableName, Key)。具有此索引鍵的記錄將從表格中移除。它要么返回 ok,要么拋出異常,語意與 mnesia:write/1 類似。

read

此函數被呼叫為 mnesia:read({TableName, Key}),將返回一個記錄列表,其主要索引鍵與 Key 匹配。與 ets:lookup/2 非常相似,它總是會返回一個列表,即使對於永遠不可能有多個結果與索引鍵匹配的 set 類型表格也是如此。如果沒有記錄匹配,則返回空列表。與刪除和寫入操作的方式非常相似,如果發生故障,則會拋出異常。

match_object

此函數類似於 ETS 的 match_object 函數。它使用在 Meeting Your Match 中描述的模式,從資料庫表格中返回整個記錄。例如,快速尋找具有特定專業知識的朋友的方法可以使用 mnesia:match_object(#mafiapp_friends{_ = '_', expertise = given}) 來完成。然後,它將返回表格中所有匹配項的列表。再一次,失敗會導致拋出異常。

select

這與 ETS 的 select 函式類似。它使用匹配規格或 ets:fun2ms 作為執行查詢的方式。如果您不記得它是如何運作的,我建議您回顧You Have Been Selected,複習您的匹配技巧。該函式可以呼叫為 mnesia:select(TableName, MatchSpec),它將返回符合匹配規格的所有項目列表。同樣,如果失敗,將會拋出例外。

其他操作

Mnesia 表格還有許多其他操作可用。然而,之前解釋的那些構成了我們前進的堅實基礎。如果您對其他操作感興趣,可以前往Mnesia 參考手冊,尋找諸如 firstlastnextprev 用於個別迭代,foldlfoldr 用於對整個表格進行摺疊,或者其他用於操作表格本身的函式,例如 transform_table (特別適用於向記錄和表格新增或移除欄位)或 add_table_index

這使得函式數量相當多。為了了解如何實際使用它們,我們將稍微推進測試。

實作第一個請求

為了實作這些請求,我們首先要編寫一個簡單的測試,展示我們希望應用程式表現出的行為。這個測試將關於新增服務,但將包含對更多功能的隱式測試。

[...]
-export([init_per_suite/1, end_per_suite/1,
         init_per_testcase/2, end_per_testcase/2,
         all/0]).
-export([add_service/1]).

all() -> [add_service].
[...]

init_per_testcase(add_service, Config) ->
    Config.

end_per_testcase(_, _Config) ->
    ok.

這是我們在大多數 CT 套件中需要新增的標準初始化程式碼。現在是測試本身。

%% services can go both way: from a friend to the boss, or
%% from the boss to a friend! A boss friend is required!
add_service(_Config) ->
    {error, unknown_friend} = mafiapp:add_service("from name",
                                                  "to name",
                                                  {1946,5,23},
                                                  "a fake service"),
    ok = mafiapp:add_friend("Don Corleone", [], [boss], boss),
    ok = mafiapp:add_friend("Alan Parsons",
                            [{twitter,"@ArtScienceSound"}],
                            [{born, {1948,12,20}},
                             musician, 'audio engineer',
                             producer, "has projects"],
                            mixing),
    ok = mafiapp:add_service("Alan Parsons", "Don Corleone",
                             {1973,3,1},
                             "Helped release a Pink Floyd album").

因為我們要新增服務,我們應該新增將參與交換的雙方朋友。函式 mafiapp:add_friend(Name, Contact, Info, Expertise) 將會用於此目的。一旦朋友被新增,我們實際上就可以新增服務。

注意: 如果您曾經閱讀過其他 Mnesia 教學,您會發現有些人非常熱衷於直接在函式中使用記錄(例如 mafiapp:add_friend(#mafiapp_friend{name=...}))。這是本指南試圖積極避免的事情,因為記錄通常最好保持私有。實作中的變更可能會破壞底層記錄表示。這本身不是問題,但每當您變更記錄定義時,您都需要重新編譯,並且如果可能,請以原子方式更新所有使用該記錄的模組,以便它們可以在執行中的應用程式中繼續運作。

簡單地將所有東西包裝在函式中會提供一個稍微乾淨的介面,它不會要求任何使用您的資料庫或應用程式的模組通過 .hrl 檔案包含記錄,這坦白說很煩人。

您會注意到我們剛剛定義的測試實際上沒有尋找服務。這是因為我實際上計劃對應用程式執行的操作是在查找使用者時搜尋它們。目前,我們可以嘗試使用 Mnesia 交易實作上述測試所需的功能。要新增到 mafiapp.erl 的第一個函式將用於將使用者新增到資料庫。

add_friend(Name, Contact, Info, Expertise) ->
    F = fun() ->
        mnesia:write(#mafiapp_friends{name=Name,
                                      contact=Contact,
                                      info=Info,
                                      expertise=Expertise})
    end,
    mnesia:activity(transaction, F).

我們定義一個寫入記錄 #mafiapp_friends{} 的單一函式。這是一個相當簡單的交易。add_services/4 應該會稍微複雜一些。

add_service(From, To, Date, Description) ->
    F = fun() ->
            case mnesia:read({mafiapp_friends, From}) =:= [] orelse
                 mnesia:read({mafiapp_friends, To}) =:= [] of
                true ->
                    {error, unknown_friend};
                false ->
                    mnesia:write(#mafiapp_services{from=From,
                                                   to=To,
                                                   date=Date,
                                                   description=Description})
            end
    end,
    mnesia:activity(transaction,F).

您可以看到在交易中,我首先執行一或兩個讀取,嘗試查看我們試圖新增的朋友是否在資料庫中找到。如果其中一個朋友不存在,則根據測試規範返回元組 {error, unknown_friend}。如果找到交易的兩個成員,我們將改為將服務寫入資料庫。

注意: 驗證輸入由您自行決定。這樣做只需要編寫自訂的 Erlang 程式碼,就像您使用該語言編寫的其他任何程式碼一樣。如果可能,在交易上下文之外盡可能多地進行驗證是一個好主意。交易中的程式碼可能會多次執行並競爭資料庫資源。在那裡盡可能少做事情始終是個好主意。

基於此,我們應該能夠執行第一批測試。為此,我正在使用以下測試規範,mafiapp.spec (放置在專案的根目錄)。

{alias, root, "./test/"}.
{logdir, "./logs/"}.
{suites, root, all}.

以及以下 Emakefile(也在根目錄)。

{["src/*", "test/*"],
 [{i,"include"}, {outdir, "ebin"}]}.

然後,我們可以執行測試。

$ erl -make
Recompile: src/mafiapp_sup
Recompile: src/mafiapp
$ ct_run -pa ebin/ -spec mafiapp.spec
...
Common Test: Running make in test directories...
Recompile: mafiapp_SUITE
...
Testing learn-you-some-erlang.wiptests: Starting test, 1 test cases
...
Testing learn-you-some-erlang.wiptests: TEST COMPLETE, 1 ok, 0 failed of 1 test cases
...

好的,它通過了。這很好。接下來進行下一個測試。

注意: 在執行 CT 套件時,您可能會收到錯誤,指出某些目錄未找到。解決方案是使用 ct_run -pa ebin/ 或使用 erl -name ct -pa `pwd`/ebin (或完整路徑)。雖然啟動 Erlang shell 會使目前的工作目錄成為節點的目前工作目錄,但呼叫 ct:run_test/1 會將目前的工作目錄變更為新的工作目錄。這會中斷相對路徑,例如 ./ebin/。使用絕對路徑可以解決問題。

add_service/1 測試讓我們可以新增朋友和服務。下一個測試應該專注於使查找事物成為可能。為了簡單起見,我們將老闆新增到所有可能的未來測試案例中。

init_per_testcase(add_service, Config) ->
    Config;
init_per_testcase(_, Config) ->
    ok = mafiapp:add_friend("Don Corleone", [], [boss], boss),
    Config.

我們要強調的使用案例是通過朋友的名字查找他們。雖然我們完全可以只搜尋服務,但在實務中,我們可能更想通過名字查找人員,而不是行為。老闆很少會問「誰又把那把吉他送到誰那裡了?」,他更可能問「是誰把吉他送到我們的朋友皮特·湯森那裡的?」,並試圖通過他的名字查找他的歷史記錄以找到有關該服務的詳細資訊。

因此,下一個測試將是 friend_by_name/1

-export([add_service/1, friend_by_name/1]).

all() -> [add_service, friend_by_name].
...
friend_by_name(_Config) ->
    ok = mafiapp:add_friend("Pete Cityshend",
                            [{phone, "418-542-3000"},
                             {email, "quadrophonia@example.org"},
                             {other, "yell real loud"}],
                            [{born, {1945,5,19}},
                             musician, popular],
                            music),
    {"Pete Cityshend",
     _Contact, _Info, music,
     _Services} = mafiapp:friend_by_name("Pete Cityshend"),
    undefined = mafiapp:friend_by_name(make_ref()).

此測試驗證我們可以插入一個朋友並查找他,以及當我們知道沒有同名的朋友時應該返回什麼。我們將有一個返回各種詳細資訊的元組結構,包括我們目前不關心的服務—我們主要想找到人,儘管複製資訊會使測試更嚴格。

可以使用單一的 Mnesia 讀取來完成 mafiapp:friend_by_name/1 的實作。我們為 #mafiapp_friends{} 的記錄定義將朋友名稱作為表格的主要鍵(記錄中定義的第一個)。通過使用 mnesia:read({Table, Key}),我們可以輕鬆地使事情順利進行,並以最少的包裝使其符合測試。

friend_by_name(Name) ->
    F = fun() ->
        case mnesia:read({mafiapp_friends, Name}) of
            [#mafiapp_friends{contact=C, info=I, expertise=E}] ->
                {Name,C,I,E,find_services(Name)};
            [] ->
                undefined
        end
    end,
    mnesia:activity(transaction, F).

只要您記得匯出它,單單這個函式就足以讓測試通過。我們現在不關心 find_services(Name),所以我們將直接替換它。

%%% PRIVATE FUNCTIONS
find_services(_Name) -> undefined.

完成之後,新測試也應該通過。

$ erl -make
...
$ ct_run -pa ebin/ -spec mafiapp.spec
...
Testing learn-you-some-erlang.wiptests: TEST COMPLETE, 2 ok, 0 failed of 2 test cases
...

最好在請求的服務區域中放入更多詳細資訊。以下是要執行的測試。

-export([add_service/1, friend_by_name/1, friend_with_services/1]).

all() -> [add_service, friend_by_name, friend_with_services].
...
friend_with_services(_Config) ->
    ok = mafiapp:add_friend("Someone", [{other, "at the fruit stand"}],
                            [weird, mysterious], shadiness),
    ok = mafiapp:add_service("Don Corleone", "Someone",
                             {1949,2,14}, "Increased business"),
    ok = mafiapp:add_service("Someone", "Don Corleone",
                             {1949,12,25}, "Gave a Christmas gift"),
    %% We don't care about the order. The test was made to fit
    %% whatever the functions returned.
    {"Someone",
     _Contact, _Info, shadiness,
     [{to, "Don Corleone", {1949,12,25}, "Gave a Christmas gift"},
      {from, "Don Corleone", {1949,2,14}, "Increased business"}]} =
    mafiapp:friend_by_name("Someone").

在此測試中,唐·柯里昂幫助一位水果攤的不良人士發展他的業務。水果攤上的不良人士後來給了老闆一份聖誕禮物,老闆永遠不會忘記它。

您可以看到我們仍然使用 friend_by_name/1 來搜尋條目。儘管該測試過於通用且不夠完整,但我們可能可以弄清楚我們想做什麼。幸運的是,由於完全沒有可維護性要求,所以做一些不完整的事情是可以接受的。

find_service/1 的實作將需要比先前的實作更複雜一些。雖然 friend_by_name/1 可以僅通過查詢主要鍵來工作,但服務中的朋友名稱僅在 from 欄位中搜尋時才是主要鍵。我們仍然需要處理 to 欄位。有很多方法可以處理此問題,例如多次使用 match_object 或讀取整個表格並手動篩選事物。我選擇使用匹配規格和 ets:fun2ms/1 解析轉換。

-include_lib("stdlib/include/ms_transform.hrl").
...
find_services(Name) ->
    Match = ets:fun2ms(
            fun(#mafiapp_services{from=From, to=To, date=D, description=Desc})
                when From =:= Name ->
                    {to, To, D, Desc};
               (#mafiapp_services{from=From, to=To, date=D, description=Desc})
                when To =:= Name ->
                    {from, From, D, Desc}
            end
    ),
    mnesia:select(mafiapp_services, Match).

此匹配規格有兩個子句:每當 From 匹配 Name 時,我們返回一個 {to, ToName, Date, Description} 元組。每當 Name 匹配 To 時,該函式會改為返回 {from, FromName, Date, Description} 形式的元組,這讓我們可以進行包括已提供和已接收服務的單一操作。

您會注意到 find_services/1 不在任何交易中執行。這是因為該函式僅在已經在交易中執行的 friend_by_name/1 中呼叫。Mnesia 實際上可以執行巢狀交易,但我選擇避免它,因為在這種情況下這樣做是沒用的。

再次執行測試應該會顯示所有三個測試實際上都有效。

我們計劃的最後一個使用案例是通過他們的專業知識搜尋朋友的想法。例如,以下測試案例說明當我們需要一些攀岩專家執行某些任務時,我們如何找到我們的朋友小熊貓。

-export([add_service/1, friend_by_name/1, friend_with_services/1,
         friend_by_expertise/1]).

all() -> [add_service, friend_by_name, friend_with_services,
          friend_by_expertise].
...
friend_by_expertise(_Config) ->
    ok = mafiapp:add_friend("A Red Panda",
                            [{location, "in a zoo"}],
                            [animal,cute],
                            climbing),
    [{"A Red Panda",
      _Contact, _Info, climbing,
     _Services}] = mafiapp:friend_by_expertise(climbing),
    [] = mafiapp:friend_by_expertise(make_ref()).

為了實作它,我們需要讀取主要鍵以外的內容。我們可以為此使用匹配規格,但我們已經這樣做了。此外,我們只需要匹配一個欄位。mnesia:match_object/1 函式非常適合此用途。

friend_by_expertise(Expertise) ->
    Pattern = #mafiapp_friends{_ = '_',
                               expertise = Expertise},
    F = fun() ->
            Res = mnesia:match_object(Pattern),
            [{Name,C,I,Expertise,find_services(Name)} ||
                #mafiapp_friends{name=Name,
                                 contact=C,
                                 info=I} <- Res]
    end,
    mnesia:activity(transaction, F).

在這一個中,我們首先宣告模式。我們需要使用 _ = '_' 將所有未定義的值宣告為全匹配規格 ('_')。否則,match_object/1 函式將僅查找除了專業知識以外的所有內容都是原子 undefined 的條目。

一旦獲得結果,我們將記錄格式化為元組,以符合測試。再次編譯並執行測試將顯示此實作有效。萬歲,我們實作了整個規範!

帳戶和新需求

沒有任何軟體專案真的完成。使用系統的使用者會提出新的需求或以意想不到的方式破壞它。老闆甚至在我們使用全新的軟體產品之前,就決定他想要一個功能,讓我們可以快速瀏覽我們所有的朋友,看看我們欠誰東西,以及實際上誰欠我們東西。

這是該功能的測試。

...
init_per_testcase(accounts, Config) ->
    ok = mafiapp:add_friend("Consigliere", [], [you], consigliere),
    Config;
...
accounts(_Config) ->
    ok = mafiapp:add_friend("Gill Bates", [{email, "ceo@macrohard.com"}],
                            [clever,rich], computers),
    ok = mafiapp:add_service("Consigliere", "Gill Bates",
                             {1985,11,20}, "Bought 15 copies of software"),
    ok = mafiapp:add_service("Gill Bates", "Consigliere",
                             {1986,8,17}, "Made computer faster"),
    ok = mafiapp:add_friend("Pierre Gauthier", [{other, "city arena"}],
                            [{job, "sports team GM"}], sports),
    ok = mafiapp:add_service("Pierre Gauthier", "Consigliere", {2009,6,30},
                             "Took on a huge, bad contract"),
    ok = mafiapp:add_friend("Wayne Gretzky", [{other, "Canada"}],
                            [{born, {1961,1,26}}, "hockey legend"],
                            hockey),
    ok = mafiapp:add_service("Consigliere", "Wayne Gretzky", {1964,1,26},
                             "Gave first pair of ice skates"),
    %% Wayne Gretzky owes us something so the debt is negative
    %% Gill Bates are equal
    %% Gauthier is owed a service.
    [{-1,"Wayne Gretzky"},
     {0,"Gill Bates"},
     {1,"Pierre Gauthier"}] = mafiapp:debts("Consigliere"),
    [{1, "Consigliere"}] = mafiapp:debts("Wayne Gretzky").

我們以吉爾·蓋茨、皮埃爾·高蒂爾和冰球名人堂成員韋恩·格雷茨基的身份新增三位測試朋友。在他們每個人和您,顧問之間都有服務交換(我們沒有為此測試選擇老闆,因為其他測試正在使用他,這會弄亂結果!)。

mafiapp:debts(Name) 函式會查找名稱,並計算名稱所涉及的所有服務。當有人欠我們東西時,該值為負數。當我們持平時,該值為 0,而當我們欠某人東西時,該值為 1。因此,我們可以說 debt/1 函式返回欠不同人數的服務數量。

該函式的實作將會稍微複雜一些。

-export([install/1, add_friend/4, add_service/4, friend_by_name/1,
         friend_by_expertise/1, debts/1]).
...
debts(Name) ->
    Match = ets:fun2ms(
            fun(#mafiapp_services{from=From, to=To}) when From =:= Name ->
                {To,-1};
                (#mafiapp_services{from=From, to=To}) when To =:= Name ->
                {From,1}
            end),
    F = fun() -> mnesia:select(mafiapp_services, Match) end,
    Dict = lists:foldl(fun({Person,N}, Dict) ->
                        dict:update(Person, fun(X) -> X + N end, N, Dict)
                       end,
                       dict:new(),
                       mnesia:activity(transaction, F)),
    lists:sort([{V,K} || {K,V} <- dict:to_list(Dict)]).

每當 Mnesia 查詢變得複雜時,匹配規格通常會成為您解決方案的一部分。它們讓您可以執行基本的 Erlang 函式,因此在產生特定結果時它們被證明是無價的。在上面的函式中,匹配規格用於查找每當提供的服務來自 Name 時,其值為 -1 (我們提供了一項服務,他們欠我們一項服務)。當 Name 匹配 To 時,返回的值將為 1 (我們收到了一項服務,我們欠一項服務)。在兩種情況下,該值都與包含名稱的元組配對。

A sheet of paper with 'I.O.U. 1 horse head -Fred' written on it

為了進行計算的第二步驟,包含名稱是必要的,在第二步驟中,我們會嘗試計算每個人所提供的所有服務,並給予一個唯一的累計值。同樣地,有很多方法可以做到。我選擇了一種方法,讓我盡可能在交易中停留最少的時間,以允許我的大部分程式碼與資料庫分離。這對於 mafiapp 來說是沒用的,但在高效能的情況下,這可以大幅減少資源的競爭。

總之,我選擇的解決方案是取得所有值,將它們放入字典中,並使用字典的 dict:update(Key, Operation) 函數,根據移動是為了我們還是來自我們來增加或減少該值。透過將此放入 Mnesia 給出的結果的摺疊操作中,我們可以獲得所有需要的值的列表。

最後一步是將值翻轉(從 {Key,Debt}{Debt, Key}),並基於此進行排序。這將給出想要的結果。

認識老大

我們的軟體產品至少應該在生產環境中嘗試一次。我們將透過設定老大將使用的節點,然後設定您的節點來完成此操作。

$ erl -name corleone -pa ebin/
$ erl -name genco -pa ebin/

一旦兩個節點都啟動,您就可以連接它們並安裝應用程式

(corleone@ferdmbp.local)1> net_kernel:connect_node('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> mafiapp:install([node()|nodes()]).
{[ok,ok],[]}
(corleone@ferdmbp.local)3> 
=INFO REPORT==== 8-Apr-2012::20:02:26 ===
    application: mnesia
    exited: stopped
    type: temporary

然後,您可以透過呼叫 application:start(mnesia), application:start(mafiapp) 在兩個節點上開始執行 Mnesia 和 Mafiapp。完成後,您可以嘗試呼叫 mnesia:system_info() 來查看一切是否正常運行,這將顯示有關您整個設定的狀態資訊。

(genco@ferdmbp.local)2> mnesia:system_info().
===> System info in version "4.7", debug level = none <===
opt_disc. Directory "/Users/ferd/.../Mnesia.genco@ferdmbp.local" is used.
use fallback at restart = false
running db nodes   = ['corleone@ferdmbp.local','genco@ferdmbp.local']
stopped db nodes   = [] 
master node tables = []
remote             = []
ram_copies         = []
disc_copies        = [mafiapp_friends,mafiapp_services,schema]
disc_only_copies   = []
[{'corleone@...',disc_copies},{'genco@...',disc_copies}] = [schema,
                                                            mafiapp_friends,
                                                            mafiapp_services]
 5 transactions committed, 0 aborted, 0 restarted, 2 logged to disc
 0 held locks, 0 in queue; 0 local transactions, 0 remote
 0 transactions waits for other nodes: []
yes

您可以看到兩個節點都在運行的資料庫節點中,兩個表格和結構描述都寫入磁碟和 RAM 中 (disc_copies)。我們可以開始從資料庫寫入和讀取資料。當然,將老大角色放入資料庫中是個好的開始步驟

(corleone@ferdmbp.local)4> ok = mafiapp:add_friend("Don Corleone", [], [boss], boss).
ok
(corleone@ferdmbp.local)5> mafiapp:add_friend(
(corleone@ferdmbp.local)5>    "Albert Einstein",
(corleone@ferdmbp.local)5>    [{city, "Princeton, New Jersey, USA"}],
(corleone@ferdmbp.local)5>    [physicist, savant,
(corleone@ferdmbp.local)5>        [{awards, [{1921, "Nobel Prize"}]}]],
(corleone@ferdmbp.local)5>    physicist).
ok

好的,所以朋友是從 corleone 節點添加的。讓我們嘗試從 genco 節點添加一項服務

(genco@ferdmbp.local)3> mafiapp:add_service("Don Corleone",
(genco@ferdmbp.local)3>                     "Albert Einstein",
(genco@ferdmbp.local)3>                     {1905, '?', '?'},
(genco@ferdmbp.local)3>                     "Added the square to E = MC").
ok
(genco@ferdmbp.local)4> mafiapp:debts("Albert Einstein").
[{1,"Don Corleone"}]

所有這些變更也可以反映回 corleone 節點

(corleone@ferdmbp.local)6> mafiapp:friend_by_expertise(physicist).
[{"Albert Einstein",
  [{city,"Princeton, New Jersey, USA"}],
  [physicist,savant,[{awards,[{1921,"Nobel Prize"}]}]],
  physicist,
  [{from,"Don Corleone",
         {1905,'?','?'},
         "Added the square to E = MC"}]}]

太棒了!現在,如果您關閉其中一個節點並重新啟動它,一切應該仍然正常

(corleone@ferdmbp.local)7> init:stop().
ok

$ erl -name corleone -pa ebin
...
(corleone@ferdmbp.local)1> net_kernel:connect_node('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)3> mafiapp:friend_by_expertise(physicist).
[{"Albert Einstein",
  ...
         "Added the square to E = MC"}]}]

是不是很棒?我們現在對 Mnesia 有所了解了!

注意:如果您最終在一個表格開始混亂的系統上工作,或者只是好奇想查看整個表格,請呼叫函數 observer:start()。它將啟動一個圖形介面,其中包含表格檢視器標籤,讓您透過視覺方式與表格互動,而不是透過程式碼。在較舊的 Erlang 版本中,observer 應用程式尚未存在,呼叫 tv:start() 將啟動它的前身。

示範刪除資料

等等。我們是不是完全跳過了從資料庫刪除記錄?喔不!讓我們為此新增一個表格。

我們將為您和老大創建一個小功能來完成它,讓您可以基於個人原因儲存您自己的私人敵人

-record(mafiapp_enemies, {name,
                          info=[]}).

由於這將是私人敵人,我們需要使用稍微不同的表格設定來安裝表格,在安裝表格時使用 local_content 作為選項。這將讓表格成為每個節點的私有表格,這樣就沒有人可以意外讀取其他人的私人敵人(儘管 RPC 可以輕鬆規避)。

這是新的安裝函數,前面是 mafiapp 的 start/2 函數,已為新表格而變更

start(normal, []) ->
    mnesia:wait_for_tables([mafiapp_friends,
                            mafiapp_services,
                            mafiapp_enemies], 5000),
    mafiapp_sup:start_link().
...
install(Nodes) ->
    ok = mnesia:create_schema(Nodes),
    application:start(mnesia),
    mnesia:create_table(mafiapp_friends,
                        [{attributes, record_info(fields, mafiapp_friends)},
                         {index, [#mafiapp_friends.expertise]},
                         {disc_copies, Nodes}]),
    mnesia:create_table(mafiapp_services,
                        [{attributes, record_info(fields, mafiapp_services)},
                         {index, [#mafiapp_services.to]},
                         {disc_copies, Nodes},
                         {type, bag}]),
    mnesia:create_table(mafiapp_enemies,
                        [{attributes, record_info(fields, mafiapp_enemies)},
                         {disc_copies, Nodes},
                         {local_content, true}]),
    application:stop(mnesia).

現在,start/2 函數透過監管者發送 mafiapp_enemies,以保持運作。 install/1 函數對於測試和全新安裝非常有用,但如果您在生產環境中執行操作,可以直接在生產環境中呼叫 mnesia:create_table/2 來新增表格。根據您系統的負載和您擁有的節點數量,您可能需要在預演環境中先進行幾次練習。

無論如何,完成此操作後,我們可以編寫一個簡單的測試來使用我們的資料庫並查看它的執行情況,仍然在 mafiapp_SUITE

...
-export([add_service/1, friend_by_name/1, friend_by_expertise/1,
         friend_with_services/1, accounts/1, enemies/1]).

all() -> [add_service, friend_by_name, friend_by_expertise,
          friend_with_services, accounts, enemies].
...
enemies(_Config) ->
    undefined = mafiapp:find_enemy("Edward"),
    ok = mafiapp:add_enemy("Edward", [{bio, "Vampire"},
                                  {comment, "He sucks (blood)"}]),
    {"Edward", [{bio, "Vampire"},
                {comment, "He sucks (blood)"}]} =
       mafiapp:find_enemy("Edward"),
    ok = mafiapp:enemy_killed("Edward"),
    undefined = mafiapp:find_enemy("Edward").

這將與之前針對 add_enemy/2find_enemy/1 的執行類似。我們需要做的只是對前者進行基本插入,以及基於後者的主索引鍵的 mnesia:read/1 操作

add_enemy(Name, Info) ->
    F = fun() -> mnesia:write(#mafiapp_enemies{name=Name, info=Info}) end,
    mnesia:activity(transaction, F).

find_enemy(Name) ->
    F = fun() -> mnesia:read({mafiapp_enemies, Name}) end,
    case mnesia:activity(transaction, F) of
        [] -> undefined;
        [#mafiapp_enemies{name=N, info=I}] -> {N,I}
    end.

enemy_killed/1 函數是稍微不同的函數

enemy_killed(Name) ->
    F = fun() -> mnesia:delete({mafiapp_enemies, Name}) end,
    mnesia:activity(transaction, F).

這就是基本刪除的全部內容。您可以匯出函數,執行測試套件,所有測試都應該仍然通過。

在兩個節點上嘗試時(刪除先前的結構描述後,或可能只是呼叫 create_table 函數),我們應該能夠看到表格之間的資料沒有共享

$ erl -name corleone -pa ebin
$ erl -name genco -pa ebin

啟動節點後,我重新安裝資料庫

(corleone@ferdmbp.local)1> net_kernel:connect_node('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> mafiapp:install([node()|nodes()]).

=INFO REPORT==== 8-Apr-2012::21:21:47 ===
...
{[ok,ok],[]}

啟動應用程式並開始運作

(genco@ferdmbp.local)1> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)3> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)4> mafiapp:add_enemy("Some Guy", "Disrespected his family").
ok
(corleone@ferdmbp.local)5> mafiapp:find_enemy("Some Guy").
{"Some Guy","Disrespected his family"}
(genco@ferdmbp.local)2> mafiapp:find_enemy("Some Guy").
undefined

您可以看到,沒有共享任何資料。刪除條目也很簡單

(corleone@ferdmbp.local)6> mafiapp:enemy_killed("Some Guy").
ok
(corleone@ferdmbp.local)7> mafiapp:find_enemy("Some Guy").
undefined

終於!

查詢列表解析式

如果您默默地跟隨本章(或者更糟的是,直接跳到這一部分!),並想著「該死,我不喜歡 Mnesia 的外觀」,您可能會喜歡這一節。如果您喜歡 Mnesia 的外觀,您也可能會喜歡這一節。然後,如果您喜歡列表解析式,您肯定也會喜歡這一節。

查詢列表解析式基本上是一個編譯器技巧,使用解析轉換來讓您將列表解析式用於可以搜尋和迭代的任何資料結構。它們針對 Mnesia、DETS 和 ETS 實作,但也可以針對 gb_trees 之類的東西實作。

一旦您將 -include_lib("stdlib/include/qlc.hrl"). 新增至您的模組,您就可以開始使用列表解析式,並將名為查詢句柄的東西作為產生器。查詢句柄允許任何可迭代的資料結構與 QLC 一起使用。就 Mnesia 而言,您可以做的是使用 mnesia:table(TableName) 作為列表解析式產生器,然後從那時起,您可以透過將它們包裝在對 qlc:q(...) 的呼叫中來使用列表解析式查詢任何資料庫表格。

這將反過來傳回一個修改後的查詢句柄,其中包含比表格傳回的更多詳細資訊。這個最新的查詢句柄可以透過使用 qlc:sort/1-2 之類的函數進行更多修改,並且可以使用 qlc:eval/1qlc:fold/1 進行評估。

讓我們直接開始練習。我們將重寫幾個 mafiapp 函數。您可以建立 mafiapp-1.0.0 的副本,並將其命名為 mafiapp-1.0.1(不要忘記在 .app 檔案中增加版本)。

要重新調整的第一個函數將是 friend_by_expertise。目前使用 mnesia:match_object/1 來實作。以下是使用 QLC 的版本

friend_by_expertise(Expertise) ->
    F = fun() ->
        qlc:eval(qlc:q(
            [{Name,C,I,E,find_services(Name)} ||
             #mafiapp_friends{name=Name,
                              contact=C,
                              info=I,
                              expertise=E} <- mnesia:table(mafiapp_friends),
             E =:= Expertise]))
    end,
    mnesia:activity(transaction, F).

您可以看到,除了我們呼叫 qlc:eval/1qlc:q/1 的部分外,這是一個正常的列表解析式。您在 {Name,C,I,E,find_services(Name)} 中有最終運算式,在 #mafiapp{...} <- mnesia:table(...) 中有產生器,最後,有 E =:= Expertise 的條件。現在,以更自然的方式(就 Erlang 而言)搜尋資料庫表格。

這就是查詢列表解析式的全部內容。真的。但是,我認為我們應該嘗試一個稍微複雜一點的範例。讓我們看看 debts/1 函數。它是使用比對規格實作,然後再摺疊到字典中。以下是使用 QLC 的樣子

debts(Name) ->
    F = fun() ->
        QH = qlc:q(
            [if Name =:= To -> {From,1};
                Name =:= From -> {To,-1}
             end || #mafiapp_services{from=From, to=To} <-
                      mnesia:table(mafiapp_services),
                    Name =:= To orelse Name =:= From]),
        qlc:fold(fun({Person,N}, Dict) ->
                  dict:update(Person, fun(X) -> X + N end, N, Dict)
                 end,
                 dict:new(),
                 QH)
    end,
    lists:sort([{V,K} || {K,V} <- dict:to_list(mnesia:activity(transaction, F))]).

不再需要比對規格。列表解析式(儲存到 QH 查詢句柄)執行該部分。摺疊已移至交易中,並用作評估查詢句柄的方式。結果產生的字典與先前由 lists:foldl/3 傳回的字典相同。最後一部分(排序)是在交易之外處理,方法是取得 mnesia:activity/1 傳回的任何字典並將其轉換為列表。

您看。如果您在 mafiapp-1.0.1 應用程式中編寫這些函數並執行測試套件,所有 6 個測試應該仍然通過。

a chalk outline of a dead body

記住 Mnesia

這就是 Mnesia 的全部內容。它是一個相當複雜的資料庫,而我們僅看到它可以執行的一切的中等部分。要進一步推進,您需要閱讀 Erlang 手冊並深入研究程式碼。在大型、可擴展且運行多年的系統中具有 Mnesia 真實生產經驗的程式設計師相當罕見。您可以在郵件列表中找到其中一些人,他們有時會回答一些問題,但他們通常都很忙。

否則,Mnesia 始終是較小應用程式的一個非常好的工具,在這些應用程式中,您會發現選擇儲存層非常惱人,甚至對於較大的應用程式也是如此,在這些應用程式中,您將擁有已知的節點數量(如前所述)。能夠直接儲存和複製 Erlang 項是非常棒的一件事 — 這是其他語言多年來嘗試使用物件關聯對應器編寫的東西。

有趣的是,一個專注於此的人可能會為 SQL 資料庫或任何其他允許迭代的儲存類型編寫 QLC 選取器。

Mnesia 及其工具鏈在您未來的一些應用程式中具有很大的潛力。但是現在,我們將轉向其他工具,以使用 Dialyzer 協助您開發 Erlang 系統。