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 数据库,也不旨在处理跨越大量数据中心的数 TB 数据,正如 NoSQL 世界的巨头们经常声称的那样。Mnesia 更适合处理较少的数据量,在有限数量的节点上。虽然它可以在大量节点上使用,但大多数人发现他们的实际限制似乎集中在 10 个左右。当您知道它将在固定数量的节点上运行,并且了解它将需要多少数据,以及知道您主要需要以 ETS 和 DETS 在通常情况下允许您在 Erlang 中执行的方式访问您的数据时,您将需要使用 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 表格没有内置的类型约束,只要您遵守表格本身的元组结构即可。

无论如何,回到我们的黑手党应用程序。我们应该如何表示我们的朋友和服务信息?也许在一个表格中完成所有事情?

-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),但如果 64 位系统上有超过 4GB 的可用内存,则此限制会进一步提高。
disc_only_copies
此选项意味着数据仅存储在 DETS 中。仅在磁盘上,因此存储受 DETS 的 2GB 限制。
disc_copies
此选项意味着数据既存储在 ETS 中,也存储在磁盘上,即内存和硬盘上。disc_copies 表格不受 DETS 限制,因为 Mnesia 使用一个复杂的事务日志和检查点系统,允许创建内存中表格的磁盘备份。

对于我们当前的应用程序,我们将使用disc_copies。原因是我们至少需要将数据持久化到磁盘。我们与朋友建立的关系需要持久存在,因此能够持久存储数据是合理的。如果在断电后醒来,却发现所有辛苦建立的友谊都消失了,那将非常令人沮丧。你可能会问,为什么不直接使用disc_only_copies呢?嗯,当我们想执行一些比较复杂的查询和搜索时,在内存中保留副本通常是不错的选择,因为这些操作无需访问磁盘,而磁盘通常是任何计算机内存访问中最慢的部分,尤其是硬盘。

在将宝贵数据填充到数据库的路上,我们还有另一个障碍。由于 ETS 和 DETS 的工作方式,我们需要定义一个表类型。可用的类型与它们的 ETS 和 DETS 对应物具有相同的定义。选项是setbagordered_setordered_set特别地,不支持disc_only_copies表。如果你不记得这些类型的作用,我建议你在 ETS 章节中查找。

注意:duplicate_bag类型的表对于任何存储类型都不可用。没有明显的解释说明为什么会出现这种情况。

好消息是我们几乎已经决定好如何存储数据了。坏消息是,在真正开始之前,关于 Mnesia 还有更多需要了解的内容。

关于模式和 Mnesia

虽然 Mnesia 可以在孤立的节点上正常工作,但它确实支持将数据分布和复制到多个节点。为了知道如何在磁盘上存储表,如何加载它们以及它们应该与哪些其他节点同步,Mnesia 需要一个名为模式的东西,其中包含所有这些信息。默认情况下,Mnesia 在创建时直接在内存中创建一个模式。对于只需要驻留在 RAM 中的表来说,这很有效,但是当你的模式需要跨越多个 VM 重启,在 Mnesia 集群的所有节点上都存在时,事情就会变得更加复杂。

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

Mnesia 依赖于模式,但 Mnesia 也应该创建模式。这会导致一个奇怪的情况,即模式需要在没有先运行 Mnesia 的情况下由 Mnesia 创建!在实践中,这个问题很容易解决。我们只需要在启动 Mnesia 之前调用函数mnesia:create_schema(ListOfNodes)。它会在每个节点上创建一些文件,存储所有必要的表信息。调用该函数时,你不需要连接到其他节点,但它们需要在运行中;该函数会建立连接,并为你完成所有工作。

默认情况下,模式将在当前工作目录中创建,无论 Erlang 节点在何处运行。要更改此行为,Mnesia 应用程序有一个dir变量,可以设置为选择模式存储的位置。因此,你可以以erl -name SomeName -mnesia dir where/to/store/the/db的方式启动你的节点,或者使用application:set_env(mnesia, dir, "where/to/store/the/db").动态设置它。

注意:模式可能由于以下原因而无法创建:已经存在一个模式,Mnesia 正在模式应该在的某个节点上运行,你无法写入 Mnesia 要写入的目录等等。

创建完模式后,我们就可以启动 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 个组,每个组包含 10 个或更少元素的索引,对于它使用的资源来说,将更有用。请注意,你不需要在记录的第一个字段上建立索引,因为默认情况下会为你完成此操作。
{record_name, Atom}
如果你想创建一个与你的记录使用的名称不同的表,这将很有用。但是,这样做会迫使你使用与每个人通常使用的函数不同的函数来操作该表。我不建议使用这个选项,除非你真的知道自己想要使用它。
{type, Type}
Type 可以是setordered_setbag表。这与我在 从记录到表中之前解释的一样。
{local_content, true | false}
默认情况下,所有 Mnesia 表都将此选项设置为false。如果你想将表及其数据复制到模式中所有节点(以及在disc_copiesdisc_only_copiesram_copies选项中指定的节点)上,你应该保持这种设置。将此选项设置为true将创建所有节点上的所有表,但内容将仅为本地内容;不会共享任何内容。在这种情况下,Mnesia 成为在多个节点上初始化类似空表的引擎。

为了简短起见,以下是设置 Mnesia 模式和表时可能发生的一系列事件:

注意:还有第三种方法。只要你有一个正在运行的 Mnesia 节点,并且创建了一些你想移植到磁盘上的表,就可以调用函数mnesia:change_table_copy_type(Table, Node, NewType)将表移动到磁盘上。

更具体地说,如果你忘记在磁盘上创建模式,可以通过调用mnesia:change_table_copy_type(schema, node(), disc_copies),将你的 RAM 模式转换为磁盘模式。

我们现在对如何创建表和模式有了一个模糊的认识。这可能足以让我们开始。

真正创建表

我们将使用一些弱 TDD 风格的编程,使用 Common Test 来处理应用程序及其表的创建。现在你可能不喜欢 TDD 的想法,但请耐心等待,我们会以一种轻松的方式来完成它,仅仅作为一种指导设计的方式,而不是其他任何东西。没有那种“运行测试以确保它们失败”的业务(尽管如果你想这样做,你可以随意进行)。最终我们会有测试,这只是一个不错的副作用,而不是目的本身。我们主要关心的是定义mafiapp的行为和外观,而不要从 Erlang shell 中完成所有操作。测试甚至不会是分布式的,但这仍然是一个很好的机会,可以在学习 Mnesia 的同时,获得一些 Common Test 的实际应用。

为此,我们应该按照标准 OTP 结构,创建一个名为 mafiapp-1.0.0 的目录

ebin/
logs/
src/
test/

我们将从弄清楚如何安装数据库开始。因为第一次需要一个模式和初始化表,所以我们需要使用一个安装函数来设置所有测试,理想情况下,该函数会将东西安装到 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.

这个测试套件还没有测试,但它给了我们第一个关于如何完成事情的规范。我们首先选择将 Mnesia 模式和数据库文件放在哪里,方法是将dir变量设置为priv_dir的值。这将把每个模式和数据库实例放在用 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 就是你的选择。同步事务将在返回之前等待所有其他节点的最终确认,确保一切顺利进行。

一个有趣的用例是,如果你正在执行大量事务,足以使其他节点过载,那么切换到同步模式应该迫使事务以更慢的速度运行,减少积压的累积,并将过载问题推到应用程序中的更高层级。

async_dirty

async_dirty 活动上下文基本上绕过了所有事务协议和锁定活动(请注意,它将等待正在进行的事务完成,然后再继续)。但是,它将继续执行所有包括日志记录、复制等操作。async_dirty 活动上下文将尝试在本地执行所有操作,然后返回,让其他节点的复制异步进行。

sync_dirty

这个活动上下文与 async_dirty 的关系就像 sync_transactiontransaction 的关系一样。它将等待确认远程节点上的事情是否顺利进行,但仍将避免所有锁定或事务上下文。脏上下文通常比事务更快,但设计上风险更大。小心使用。

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.

我们要强调的用例是通过姓名查找朋友。虽然我们可以很好地只搜索服务,但在实践中我们可能希望通过姓名查找人员比通过行为查找人员更多。老板很少会问“谁又把那把吉他送到谁那里了?”不,他更有可能问“是谁把吉他送到我们朋友 Pete Cityshend 那里?”并尝试通过他的姓名查找他的历史记录以找到有关该服务的详细信息。

因此,下一个测试将是 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, "[email protected]"},
                             {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()).

此测试验证了我们可以插入一个朋友并查找他,但也验证了当我们不认识任何该名字的朋友时应该返回什么。我们将有一个元组结构返回各种详细信息,包括服务,这些服务现在我们不关心——我们主要想找到人,尽管复制信息会使测试更严格。

mafiapp:friend_by_name/1 的实现可以使用单个 Mnesia 读取完成。我们为 #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).

此匹配规范有两个子句:当 FromName 匹配时,我们将返回一个 {to, ToName, Date, Description} 元组。当 NameTo 相匹配时,该函数将返回一个 {from, FromName, Date, Description} 元组,允许我们有一个包含给定服务和接收服务的单个操作。

你会注意到 find_services/1 不会在任何事务中运行。这是因为该函数仅在 friend_by_name/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, "[email protected]"}],
                            [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(我们提供了一项服务,他们欠我们一项服务)。当 NameTo 匹配时,返回的值将为 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/

这两个节点启动后,您可以连接它们并安装应用程序

([email protected])1> net_kernel:connect_node('[email protected]').
true
([email protected])2> mafiapp:install([node()|nodes()]).
{[ok,ok],[]}
([email protected])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(),它将显示有关整个设置的状态信息

([email protected])2> mnesia:system_info().
===> System info in version "4.7", debug level = none <===
opt_disc. Directory "/Users/ferd/.../[email protected]" is used.
use fallback at restart = false
running db nodes   = ['[email protected]','[email protected]']
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

您可以看到两个节点都在运行的 DB 节点中,两个表和模式都被写入磁盘和 RAM 中(disc_copies)。我们可以开始从数据库中写入和读取数据。当然,将 Don 部分包含在 DB 中是一个良好的开始步骤

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

好了,朋友们是从 corleone 节点添加的。让我们尝试从 genco 节点添加一项服务

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

所有这些更改也可以反映回 corleone 节点

([email protected])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"}]}]

太好了!现在,如果您关闭其中一个节点并重新启动它,一切应该仍然正常

([email protected])7> init:stop().
ok

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

这不是很好吗?我们现在了解 Mnesia 了!

注意: 如果您最终处理的是一个系统,其中表开始变得混乱,或者只是对查看整个表感到好奇,请调用函数 observer:start()。它将启动一个带表格查看器选项卡的图形界面,让您可以通过视觉方式与表格进行交互,而不是通过代码进行交互。在 observer 应用程序尚未存在的旧版 Erlang 版本中,调用 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

节点启动后,我重新安装数据库。

([email protected])1> net_kernel:connect_node('[email protected]').
true
([email protected])2> mafiapp:install([node()|nodes()]).

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

启动应用程序并继续执行。

([email protected])1> application:start(mnesia), application:start(mafiapp).
ok
([email protected])3> application:start(mnesia), application:start(mafiapp).
ok
([email protected])4> mafiapp:add_enemy("Some Guy", "Disrespected his family").
ok
([email protected])5> mafiapp:find_enemy("Some Guy").
{"Some Guy","Disrespected his family"}
([email protected])2> mafiapp:find_enemy("Some Guy").
undefined

您可以看到,没有共享数据。删除条目也很简单。

([email protected])6> mafiapp:enemy_killed("Some Guy").
ok
([email protected])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 项是一件非常巧妙的事情——其他语言试图使用对象关系映射器(ORM)来实现这一点多年。

有趣的是,如果有人专心致志,他们很可能可以为 SQL 数据库或任何其他允许迭代的存储编写 QLC 选择器。

Mnesia 及其工具链在您未来的一些应用程序中具有很大的潜力。但是现在,我们将转向其他工具,以帮助您使用 Dialyzer 开发 Erlang 系统。