核心数据模型设计

5
假设我有一个关于烹饪食谱的应用程序,具有两个基本功能:
1. 第一个涉及我正在准备的当前食谱。 2. 第二个存储我决定保存的食谱。
标准情况下,我的当前食谱是“芝士蛋糕”,在RecipeDetailViewController中,我可以看到我为此食谱添加的当前配料:糖、牛奶、黄油等等。现在,假设我对最终结果感到满意,并决定保存(记录)我刚刚准备的食谱。点击“保存”按钮后,该食谱现已保存(已记录),并且在RecipesHistoryViewController中,我可以看到类似以下内容:
- 2013年11月15日 - 芝士蛋糕 - 2013年11月11日 - 布朗尼 - 等等。
现在,如果我想要在历史记录中编辑该食谱并将牛奶更改为豆浆等,问题在于在历史记录中编辑食谱不应编辑当前食谱(及其配料),反之亦然。如果我编辑当前食谱并将黄油替换为花生酱,则不得编辑历史记录中的任何一个食谱。这就是问题所在。
这种情况意味着什么?这意味着,为了满足这些功能,我每次用户单击“保存食谱”按钮时都会复制该食谱及其每个子关系(配料)。它可以工作,但我感觉还有其他更干净的方法。使用此实现,我会拥有许多不同的重复Core Data对象(sqlite行),例如:
- 对象#1,名称:黄油,食谱:1 - 对象#2,名称:黄油,食谱:4 - 对象#3,名称:黄油,食谱:3 - 等等。
有什么好的想法吗?如何优化此模型结构?
编辑1:我已经考虑创建任何RecipeHistory对象,并带有一个NSString属性,可以在其中存储json字典,但我不知道这是否更好。
编辑2:当前RecipeHistory对象包含以下内容:
+-- RecipeHistory --+
|                   |
| attributes:       |
| - date            |
+-------------------+
| relationships:    |
| - recipes         |
+-------------------+

+----- Recipe ------+
| relationships:    |
| - recipeInfo      |
| - recipeshistory  |
| - ingredients     |
+-------------------+

+-- RecipeInfo  ----+
|                   |
| attributes:       |
| - name            |
+-------------------+

+--- Ingredient ----+
|                   |
| attributes:       |
| - name            |
+-------------------+
| relationships:    |
| - recipe          |
+-------------------+

当paulrehkugler说在创建RecipeHistory时复制每个Recipe对象(以及其关系RecipeInfo和Ingredients)是正确的,这将填充数据库大量的数据,但我找不到另一种解决方案,可以让我未来更加灵活。也许在未来,我会创建有关食谱和历史记录的统计信息,并且拥有Core Data对象可能会证明有用。你怎么想?我认为这是许多存储历史记录并允许编辑历史记录项目的应用程序中常见的情况。

重要更新

我已经阅读了一些用户的答案,并希望更好地解释一下情况。上面提到的示例只是一个示例,我的应用程序没有涉及烹饪/食谱参数,但我使用食谱,因为我认为它非常适合我的实际情况。

说了这些,我想解释一下应用程序需要两个部分: - 第一部分:在其中可以查看当前食谱及相关成分 - 第二部分:在其中可以查看我通过点击第一部分中的“保存食谱”按钮决定保存的食谱

第一部分中找到的当前食谱和第二部分中找到的X食谱没有任何共同点。但是,用户可以编辑“历史”部分中保存的任何食谱(他可以编辑名称、成分或其他任何内容,他可以完全编辑有关在历史记录部分中找到的食谱的所有内容)。

这就是为什么我会想到复制所有NSManagedObject。然而,以这种方式,数据库将像疯狂增长一样,因为每次用户保存当前食谱时,代表食谱的对象(Recipe)都会被复制,还有食谱所拥有的关系(成分)。因此,将有大量名为“Butter”的成分。你可以问我:为什么你需要有大量的“Butter”对象?嗯,我需要它,因为成分例如具有“数量”属性,因此每个食谱都有不同数量的成分。

无论如何,我不喜欢这种方法,即使它似乎是唯一的方法。问我任何问题,我会尝试解释每一个细节。

PS:对我的基础英语表示抱歉。

编辑

enter image description here


1
你对持久化策略的重大变更有多开放? - Sergey Kalinichenko
该应用程序尚未发布。然而,在提交您的假设答案之前,我建议您阅读我即将发布的帖子更新,我将添加许多重要细节。 - Fred Collins
你的真实成分实体有多少属性?那个实体真正扮演什么角色?捏造的问题不可避免地忽略了实际上与适当解决方案相关的细节... - Wain
我同意你的观点,但对于这个例子来说非常相似。做了一个快速更新。 - Fred Collins
在更新部分:如果我们有成分、食谱、成分-食谱表,为什么会有大量的“Butter”对象?我们只会有一个“Butter”,但会有大量指向Butter的“成分-食谱”行。这正确吗? - Mohsen Heydari
显示剩余2条评论
10个回答

7

由于您必须处理历史记录,并且事件是由终端用户手动生成的,请考虑更改方法:不要存储模型实体(即食谱、配料和它们之间的连接)的当前视图,而是存储用户发起的单个事件。这被称为事件溯源

这个想法是记录用户所做的事情,而不是记录用户行动后的新状态。当您需要获取当前状态时,“回放”事件,将更改应用到内存结构中。除了让您实现即时需求外,这还可以通过“回放”事件来恢复特定日期的状态。这有助于审核。

您可以通过定义以下事件来完成:

  • CreateIngredient - 添加新的食材,并分配一个唯一的ID。
  • UpdateIngredient - 修改现有食材的属性。
  • DeleteIngredient - 从当前状态中删除食材。删除食材会将其从所有食谱和食谱历史记录中删除。
  • CreateRecipe - 添加新的食谱,并分配一个唯一的ID。
  • UpdateRecipeAttribute - 修改现有食谱的属性。
  • AddIngredientToRecipe - 将食材添加到现有食谱中。
  • DeleteIngredientFromRecipe - 从现有食谱中删除食材。
  • DeleteRecipe - 删除食谱。
  • CreateRecipeHistory - 从特定的食谱创建新的食谱历史记录,并分配一个新的ID。
  • UpdateRecipeHistoryAttribute - 更新特定食谱历史记录的属性。
  • AddIngredientToRecipeHistory - 在食谱历史记录中添加食材。
  • DeleteIngredientFromRecipeHistory - 从食谱历史记录中删除食材。
您可以使用Core Data API将单个事件存储在一个表中。添加一个处理事件顺序并创建模型当前状态的类。事件来自两个地方-由Core Data支持的事件存储和用户界面。这将允许您保持单个事件处理程序以及具有当前菜谱、配料和菜谱历史详情的单个模型。

只有当用户查看历史记录时才应重放事件,对吗?

不是这样的:启动时您会将整个历史记录读入当前"视图",然后将新事件发送到视图和DB以进行持久化。
当用户需要查看历史记录(特别是当他们需要查找过去某个日期的模型外观时),您需要部分重播事件,直到感兴趣的日期为止。
由于事件是手动生成的,所以不会有太多事件:我估计至多有数千个-这是针对具有每个10个成分的100道食谱列表而言。在现代硬件上处理事件应该在微秒级别,因此读取和重新播放整个事件日志应该在毫秒级别完成。
此外,您知道有没有任何链接展示如何在Core Data应用程序中使用事件溯源的示例?例如,我需要摆脱RecipeHistory NSManagedObject吗?
我不知道iOS上事件溯源的好的参考实现。这与在其他系统上实现它并没有太大的区别。您需要摆脱目前所有的表格,并用一个看起来像这样的单个表格替换它:

Event log entry

以下是属性列表:
  • EventId - 此事件的唯一ID。它在插入时自动分配,从不更改。
  • EntityId - 此事件创建或修改的实体的唯一ID。此ID由Create...处理器自动分配,从不更改。
  • EventType - 表示此事件类型名称的短字符串。
  • EventTime - 事件发生的时间。
  • EventData - 事件的序列化表示形式 - 这可以是二进制或文本。
最后一个项目可以替换为代表上述12种事件类型使用的属性的超集的“去规范化”列组。这完全取决于您 - 这个表只是存储事件的一种可能方式。它不必是核心数据 - 实际上,它甚至不需要在数据库中(尽管这使事情变得稍微容易些)。

我对你的回答感到有些震惊。那有什么优点呢?然而,我从未使用过类似的东西,实际上我甚至不知道该如何依赖于Core Data中当前的实现。 - Fred Collins
@FredCollins 我已经实现了使用你所使用的写入复制方法和我在这个答案中描述的事件源方法来实现时间和双时间模型。使用事件源方法明显更容易正确实现,生成的数据模型也更易于维护。最重要的是,写入复制方法会产生大量的冗余,并且在尝试审核记录以查看当出现错误时会发生什么时会引发问题。 - Sergey Kalinichenko
重放事件应该只在用户查看历史记录时发生,对吧?此外,您知道有没有任何链接展示如何在Core Data应用程序中使用事件溯源的示例?这将非常感激,因为我从未使用过它。例如,我是否需要摆脱RecipeHistory NSManagedObject - Fred Collins
虽然我认为我不会在这个应用程序中实现这种答案,但我想说一声感谢和+1,因为你向我介绍了事件溯源,它似乎很有趣,我很快就会尝试!Fred - Fred Collins

4

我认为当选择修改RecipesHistoryViewController中的一行时,我们可以通过两个选项来优化保存流程:

  • 让用户选择是保存新行还是更新现有行。
    添加Save New按钮以在Recipe中创建新行和Update按钮以更新当前选择的行。
  • 为了跟踪食谱的更改(当更新发生时),我将尝试仅记录食谱的更改情况。
    使用EAV模式将是一个选项。

    enter image description here]![enter image description here]![enter image description here 提示:
    在将行插入到RecipeHistory表中时,可以使用逗号分隔的成分名称作为旧值和新值,示例可能会有所帮助。

关于BIG UPDATE:
假设实际应用程序具有用于持久操作的数据库,则以下建议可能会有所帮助。

第一部分中找到的当前食谱和在“历史”部分中找到的X食谱没有任何共同点

这导致CurrentIn-History食谱之间不存在关系,因此试图创建关系将是徒劳的。没有关系,设计将不符合正常形式,冗余将不可避免。
按照这种方法,将会有很多记录,在这种情况下

  • 我们可以限制用户保存的食谱数量。

  • 优化食谱表性能的另一种解决方案是基于创建日期字段对表进行范围 划分(需要参与数据库管理员)。

  • 另一个建议是拥有一个单独的成分概念表。
    拥有ingredientreciperecipe-ingredient 表将减少冗余。
    enter image description here

使用NoSql
如果关系不是应用程序逻辑的重要部分,也就是说,如果您不会遇到像“哪些成分在少于总Y成分且牛奶不是其中之一的食谱中已经被使用超过X次”这样的复杂查询或分析程序,那么请看看NoSql数据库以及它们之间的比较

它们提供非关系型、分布式、开源、无模式、易于复制支持、简单API、大量数据和横向可扩展性。

一个基本的文档数据库示例:
在我的本地机器上安装了couchdb(端口号5984),通过发送标准的HTTP请求(使用curl)来创建couchdb上的recipe数据库(表),如下所示:

curl -X PUT http://127.0.0.1:5984/recipe 

删除配方表:

curl -X DELETE http://127.0.0.1:5984/recipe 

添加一个食谱:

curl -X PUT http://127.0.0.1:5984/recipe/myFirstRecipe -d 
'{"name":"Cheese Cake","description":"i am using couchDB for my recipes",
"ingredients": [ 
"Milk", 
"Sugar" 
],}'

获取myFirstRecipe记录(文档)

curl -X GET http://127.0.0.1:5984/recipe/myFirstRecipe 

不需要像对象关系映射、数据库驱动程序等传统的服务器端过程。
顺便提一句,使用Nosql会有一些需要考虑的缺点,请参考这里这里


嗨Mohsen Heydari,感谢您的回答。我刚刚对问题进行了大量更新。我无法应用您的第一个建议,因为当用户尝试“保存/记录”当前食谱时,一定会保存一个新的食谱,这将是独一无二的。 - Fred Collins
没有关系的话,设计就不会处于正常形式,冗余是不可避免的。这就是我结束的地方。顺便说一下,我对“如果在创建日期上有一个分区表对于食谱实体不适用,是否有帮助(让DBA参与)”有些疑问,这是什么意思,DBA是什么?另外,通过“食谱-配料”,您的意思是我需要在“食谱”中放置一个“配料”,并且每个“配料”都与“配料信息”相关联吗?如果可以减少冗余,那就比没有好。谢谢。 - Fred Collins
此外,我将检查您关于NoSql数据库的链接,但首先:您是否看到在这种情况下使用其中一个的任何优势?(另外,CoreData支持NoSql吗?) - Fred Collins
是的,但我发布的图片显示了食谱和成分之间存在关系,对于你的“没有关系”的说法我有点困惑。然而,我认为这个答案更适合我的情况。 - Fred Collins
这并不是关于实体之间的有限关系,NOSql确实支持集合,但记录及其集合与其他记录或实体没有依赖关系。我进行了更新,希望能有所帮助。 - Mohsen Heydari
显示剩余2条评论

2

我认为,你的问题更多地是概念性的,而不是模型结构相关的。
我的建议是:

+*******+
食谱
-----------------

-----------------
属性:
-----------------
- isDraft - 布尔类型
- name - 字符串类型
- creationDate - 日期类型
-----------------

-----------------
关系:
-----------------
- ingredients - 与原料的一对多关系
-----------------
+*******+

+*******+
原料
-----------------

-----------------
属性:
-----------------
- name - 字符串类型
-----------------

-----------------
关系:
-----------------
- recipes - 与食谱的一对多关系
-----------------

+*******+

现在,我们把你的“当前”食谱称为草稿(一个用户可能有多个草稿)。
正如你所看到的,现在你可以使用单个获取结果控制器(FRC)来显示你的食谱。
获取请求将如下所示:

NSFetchRequest* r = [NSFetchRequest fetchRequestWithEntityName:@"Recipe"];
[r setFetchBatchSize:25];

NSSortDescriptor* sortCreationDate = [NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO];
[r setSortDescriptors:@[sortCreationDate]];

您可以按 isDraft 属性对数据进行分组:

NSFetchedResultsController* frc = [[NSFetchedResultsController alloc] initWithFetchRequest:r
                                                                      managedObjectContext:context
                                                                        sectionNameKeyPath:@"isDraft"
                                                                                 cacheName:nil];

记得给你的部分添加适当的标题,以免混淆用户。
现在,你只需要添加一些特定的功能,比如:
  • 创建新食谱
    • 保存
    • 保存草稿
  • 编辑食谱(草稿或非草稿)
    • 如果是草稿,请提供保存为完整食谱的选项
    • 否则,保存实际食谱
    • 如果您愿意,可以添加“另存为”选项
  • 创建副本(用户知道如果他多次保存相同的食谱可能会导致冗余数据)
无论如何,用户体验应该保持一致。
这意味着:
当用户正在编辑/添加对象时,此对象不应在他的操作下发生变化。
如果用户正在添加新食谱,则可能希望将其保存为草稿或完整食谱。
在任一情况下,当用户保存时,他可能仍然希望继续编辑它。因此,不需要创建新对象。
如果您想为您的食谱添加版本控制,您需要添加一个类似于 RecipeHistory 的实体,与单个食谱相关联。此实体将记录每次提交更改的完整食谱对象上的更改(使用 NSManagedObject changedValues 或针对现有/提交的值进行检查)。
您可以根据需要对数据进行序列化和存储。
因此,你可以看到,这更多是一个概念问题(如何访问你的数据),而不是建模问题。

2
有几个问题需要回答:
  1. 对于食谱中的“历史项目”,是否有数量限制,或者真的需要保留食谱的所有版本?
  2. 何时修改只是对现有食谱的更改,何时更改会导致新食谱的诞生?例如,用户是否可以通过完全替换每种成分和标题,将“芝士蛋糕”食谱更改为“肉馅糕”食谱?

这些问题的答案在规划数据模型时非常重要。例如,如果您的应用程序可以使用以下用例:用户创建一个包含糖、面粉和鸡蛋的“基础蛋糕”食谱。用户现在想要以此“基础蛋糕”食谱作为模板创建“芝士蛋糕”、“磅蛋糕”和“胡萝卜蛋糕”食谱。这是一个有效的用例吗?

如果是这样,每次保存食谱时,它基本上会创建完全独立的新食谱,因为用户可以改变一切事物,从而将芝士蛋糕变成肉馅糕。

然而,我认为这对用户来说是意料之外的行为。在我看来,用户创建了一个“芝士蛋糕”食谱,然后可能想要跟踪该食谱的更改,而不是将其完全变成其他东西。

以下是我的建议:

  1. 将您的数据模型更改为,Recipes 拥有多个 RecipeVersions,而不是 RecipeHistory 拥有 Recipes。这样,用户可以明确地创建新食谱,并跟踪该食谱的更改。此外,用户将无法直接编辑 RecipeVersion,而是可以“还原”其食谱到特定版本,然后进行编辑。
  2. 使 Ingredients唯一:数据库中的 “Butter”、“Milk”和“Flour”仅存在一次,只被不同的食谱引用。这样,您的数据库中不会有重复项,并且仅保存参考将占用比保存成分名称再次和再次占用更少的磁盘空间。
  3. 允许用户基于现有的 Recipe(Version)创建新食谱。通过这种方式,您为用户提供了在现有应用程序和数据模型上“基础”新食谱的能力。

这是我建议的数据模型:

+----- Recipe ------+
| attributes:       |
| - name            |
| relationships:    |
| - recipeVersions  |
+-------------------+

+-- RecipeVersion  ----+
| attributes:          |
| - timestamp          |
+----------------------+
| relationships:       |
| - recipe             | 
| - ingredients        |
+----------------------+

+--- Ingredient ----+
| attributes:       |
| - name            |
+-------------------+
| relationships:    |
| - recipeVersions  |
+-------------------+

享受。


谢谢回复,顺便说一下,我已经更新了问题并添加了一些细节。 - Fred Collins

2
您不需要复制所有的食材对象。相反,只需更改关系,使得食谱有多个配料,而配料可以出现在多个食谱中。然后当您创建副本食谱时,只需连接到现有的配料即可。
这样做也会更容易列出使用某种(或某些组合)配料的食谱。
您还应该考虑界面设计和用户体验 - 应该是完全的复制?或者应该允许用户在每个食谱中创建'替代品'(仅列出一组替换配料)。

嗨Wain,感谢您的回答。我刚刚对问题进行了大更新。 - Fred Collins

1
我不确定您试图解决的问题是否清晰,但我会从建模食谱和配料开始,并将它们与实际混合物和方法分开,因为这些可能会随着厨师的实验而改变。通过一些智能应用逻辑,您可以仅跟踪每个版本中的更改,而不是创建新副本。例如,如果用户决定尝试食谱的新版本,则默认显示以前的版本(或允许用户选择版本),并且如果进行任何更改,则将这些更改保存为与RecipeVersion相关联的新Method和RecipeIngredient。
此方法将使用更少的存储空间,但需要更复杂的应用程序逻辑,例如交换成分将设置要替换的成分的数量为0,并为新成分添加新记录。简单地复制以前(或用户选择的)版本不会占用太多空间,这些是小记录,并且实现起来会更加简单。

enter image description here


这个模型仍然可以满足您的要求。您可以通过使用版本号将事物分区为两个部分,或者只需在mixVersion上包含一个标志,指示它是否为当前版本。所有其他版本都会出现在历史部分中。 - Duncan Groenewald
实际上,在真实场景中,拥有对象及其多个“修订/版本”并不是很有意义,因为每个对象都是独一无二的。 - Fred Collins
好的,那我对你所说的“历史”是什么意思感到困惑了,因为历史意味着历史对象与当前对象之间存在某种关系(通常是随时间变化而当前对象成为首选版本,用于任何目的)。它们仍然是独立的对象,可以独立复制、保存、编辑、删除等。当然,如果您开始编辑一个版本,那么这是否需要进入自己的历史记录,或者它会成为当前版本,还是其他什么? - Duncan Groenewald
无论如何,请记住您可能有两个芝士蛋糕的食谱,同样地,您可能在多个食谱中使用芝士(一种成分),因此您的模型(食谱<-->成分)强制您为每个食谱创建一个新的芝士成分。在上面的模型中,您只需要创建一个名为Cheese的成分,对于您在其中使用Cheese的每个食谱,您都会创建一个仅定义要使用的数量的记录,这可能是该食谱所特有的。 - Duncan Groenewald
尝试阅读这本书,其中包含大量建模对象的示例:http://www.amazon.com/Java-Modeling-Color-With-UML/dp/013011510X - Duncan Groenewald

1

这是存储大小和检索时间之间的权衡。

如果每次用户点击“保存食谱”按钮时都复制每个食谱,那么会在数据库中复制大量数据。

如果创建一个RecipeHistory对象,其中包含一个Recipe和一系列更改列表,则检索数据并填充您的视图控制器需要更长的时间,因为您必须在内存中重构完整的Recipe。

我不确定哪种方法更容易 - 适合您的用例的方法可能是最好的。


请查看我的更新,以了解“RecipeHistory”对象包含的内容。 - Fred Collins

1
我认为最好定义成分表具有成分ID和成分显示名称,在食谱历史表中存储食谱ID、历史日期和成分数组。
如果在成分表中, id:1 是黄油 id:2 是牛奶 id:3 是奶酪 id:4 是糖 id 5 是豆浆
那么在历史表中, 对于食谱1:芝士蛋糕,日期11月15日,成分数组:{1,2,3,4} 如果在11月16日芝士蛋糕改用豆浆而不是牛奶,则在该日期的成分数组为{1,2,3,5}。许多数据库都具有数组列选项或可以是逗号分隔的字符串或Json文档。最好将成分列表保存在内存中,以便快速查找并从列表中获取成分名称。

1
也许我没有理解你的问题,但是你需要通过编辑更改黄油的名称吗?为什么不只是从那个配方中删除黄油并添加花生酱呢?这样你就不会将黄油更改为花生酱,适用于所有与之相关联的其他配方。对于新配方,你可以选择花生酱或黄油。

是的,但最大的问题是在保存“食谱”时需要复制每个对象及其关系。 - Fred Collins

0

明确一下,我们是在谈论前端吗?

首先,像Mohsen Heydari建议的那样,在SQL rdbms上,您应该创建一个表来处理多对多的连接,以便提高性能,将其转化为两个一对多。

所以您想要一个历史记录

+-- RecipeHistory --+
|                   |
| attributes:       |
| - id              |
| - date            |
| - new name?       |
| - notes ??        |
| - recipe-id       |
+-------------------+
| relationships:    |
| - recipes         |
+-------------------+

+----- Recipe ------+
| attributes:       |
| - id              |
| - name            |
| - discription     |
| - date            |
| - notes           | #may be useful?
| - Modifiable      | #this field is false if in history, else true,
+-------------------+
| relationships:    |
| recipe-ingredient |
+-------------------+

+-Recipe-ingridient-+
| attributes:       |
| id                |
| recipe-id         |
| ingridient-id     |
| quantity          |
+-------------------+

+--- Ingredient ----+
|                   |
| attributes:       |
| - id              |
| - name            |
+-------------------+
| relationships:    |
| -recipe-ingredient|
+-------------------+

现在,如果食谱上的可修改字段为True,则它属于主页面。

如果为false,则它属于历史页面。

找到所需的食谱后,您可以使用Recipe-Ingredient表按其recipe-id查询配料,或者以相同的方式按成分查询食谱。

另一个占用空间较少的选项是创建食谱历史记录,并创建修改后的食谱表 -> 它需要一个基本食谱ID,

并将其映射到 -> 主要食谱ID,废弃的配料和新配料,如果您想了解此解决方案,请询问。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接