混合存储(关系型+文档型)与 Entity Framework






4.60/5 (6投票s)
如何在 SQL Server 中混合关系型和文档型数据,并使用 Entity Framework 进行查询?包括潜在问题、限制和性能说明。
为什么?
关系型数据库仍然是大多数应用程序最流行的存储方式。它们有许多优点,包括良好的工具支持、长期的性能调优历史、通过强制执行数据结构、数据类型、一致性和参照完整性来保证数据质量、鼓励规范化、事务行为……以及许多开发者都熟悉它们。
文档数据库(存储 XML 或 JSON 文档原始内容或二进制形式)通常具有更宽松的模式验证。尽管它们通常支持验证、索引和转换,有时还将文档存储为二进制图,但可以将它们视为(B)LOB 存储,您可以在其中“转储”文档而无需关心数据库本身的结构(尽管这种比较是对更好的文档数据库的侮辱,抱歉)。
虽然我仍然是关系型数据库的坚定拥护者,因为它们具有严格的结构和一致性,但有时我也喜欢文档数据库的灵活性,在文档数据库中,我无需为每个多值属性创建一个新表,也无需为具有复杂结构但除了作为某个所有者对象的聚合之外没有其他关系的数据定义和维护复杂的表结构和关系。
以“产品”为例。在关系型数据库中定义一个产品意味着创建一个名为Product
的表,其中包含例如Id
、Name
、Price
等列。我现在可以在Order
表中注册order
,并让order
行通过其Id
引用product
。然后,我可以轻松查询在过去两周内哪个customer
以给定价格范围购买了product
。
在文档数据库中,存储客户、产品和订单更加简单:它们只是文档。但是“跨文档”查询和连接通常更加困难。
因此,对于经典的订购系统,我仍然会坚持使用关系型数据库。然而,产品可能还有更多用于对其进行文档化的属性(例如,在网上商店中):其规格(颜色、尺寸、功率……)、用户评论等,这些属性更适合文档格式。在关系型数据库中模拟这些属性将导致一个巨大的 ERD,其中包含许多小型表,从而掩盖了模型的核心概念和关系。
因此,混合模型就应运而生了:让我们在关系型数据库中存储关系型数据,并为文档型数据添加“document
”结构。两全其美!
它是如何工作的?
非常简单:对于所有您想应用混合存储的数据库表,添加一个“JsonData NVARCHAR(MAX)
”列。然后,当使用 Entity Framework 实现对象时,使用 JSON.NET 读取JsonData
并填充实体属性。稍后,在保存更改之前,请确保实体的JsonData
属性已更新以反映所有更改。
详细说明及示例
考虑以下类图
(暂时忽略IHybridDocument 接口
和DocumentData
属性。)
为了将其映射到关系型数据库,我们需要一个类似的数据库模式
Product
表中有“很多列”(好吧,这仍然是一个小例子,但想象一下product
的规格包含多个部件的测量值,那么我们将在Product
表中需要多个Length
、Height
、Width
,也许还有Unit
列;随着规格变得越来越复杂(例如,还要支持不同种类的product
),我们将需要更多的列)。
我们还有一些额外的表和关系,这里也是一样:产品定义越复杂,表和关系就越多。这使得我们的数据库更难管理和维护。并且可能使我们的 ERD 图变得庞大。
也许您需要这种复杂性。也许这些是您需要处理的核心实体,并且您的数据库应该反映这一点。那当然是可以的。但是,如果product
的价格、尺寸和颜色仅对非常有限的一组用例很重要,那么这种模式的复杂性就会阻碍其他用例,最好摆脱它。
解决方案?在Product
表中添加一个JsonData
列,并将您不需要在数据模型中结构化的所有产品信息存储在其中。这样可以使您的数据库模式保持精简高效。
现在,让您的Product
类实现IHybridDocument
接口,将其声明为混合文档。此接口包含一个DocumentData
属性,您应该将其映射到数据库中的JsonData
列。
[Column("JsonData")]
public virtual string DocumentData { get; set; }
然后,将我们希望被序列化到JsonData
列中的所有成员标记为[DataMember]
进行序列化,并标记为 Entity Framework 的[NotMapped]
。
[NotMapped, DataMember]
[AllowHtml, UIHint("Html")]
public string HtmlDescription { get; set; }
[NotMapped, DataMember(EmitDefaultValue = false)]
public Dictionary<string, decimal> Price { get; set; }
[NotMapped, DataMember(EmitDefaultValue = false)]
public ProductSpecifications Specifications { get; set; }
此外,我强烈建议添加一个 Json 扩展数据字段。
[JsonExtensionData]
private IDictionary<string, JToken> _additionalData;
这是IExtensibleDataObject
的 JSON.NET 等效项(遗憾的是 JSON.NET 不支持IExtensibleObject
,但就是这样,至少有一个等效项)。这可以确保在使用缺少一个或多个 JSON 映射属性的实体类进行往返(加载、更新和保存回数据库)记录时,您不会丢失 JSON 的部分。
接下来,我需要将Product
类标记为[DataContract]
。
[DataContract]
public class Product : IHybridDocument
{
...
}
最后,我们需要将ProductSpecifications
、ThreeDimensionalSize
和Color
类标记为[DataContract]
,并将其所有属性标记为[DataMember]
。如果希望将null
/空/0
值从 JSON 序列化中移除,请考虑在[DataMember]
属性上添加EmitDefaultValue = false
属性。
例如:
[DataContract]
public class ProductSpecifications
{
[DataMember(EmitDefaultValue = false)]
public List<Color> Colors { get; set; }
[DataMember(EmitDefaultValue = false)]
public ThreeDimensionalSize Size { get; set; }
}
到目前为止,这什么都做不了。我们需要额外的“魔法”!
在DbContext
子类的构造函数中,添加对UseHybridStorage()
扩展方法的调用。这是我们product
目录的完整上下文类。
public class CatalogContext : DbContext
{
public CatalogContext()
: base()
{
this.UseHybridStorage();
}
public CatalogContext(string nameOrConnectionString)
: base(nameOrConnectionString)
{
this.UseHybridStorage();
}
public virtual DbSet<Product> Products { get; set; }
public virtual DbSet<ProductCategory> ProductCategories { get; set; }
}
就这样!真的!
那么,背后的魔法是什么?
UseHybridStorage
扩展方法会向DbContext
的ObjectContext
的ObjectMaterialized
和SavingChanges
事件注册处理程序。得益于ObjectMaterialized
事件,我们可以在对象被实例化时拦截并反序列化 JSON 数据。
private static void OnObjectMaterialized(object sender, ObjectMaterializedEventArgs e)
{
var doc = e.Entity as IHybridDocument;
if (doc != null && doc.DocumentData != null)
{
JsonConvert.PopulateObject(doc.DocumentData, doc);
}
}
反之,当一个实体即将被保存时,我们将对象序列化,并将 JSON string
存储在DocumentData
属性中。
private static void OnSavingChanges(object sender, EventArgs e)
{
var context = new DbContext(sender as ObjectContext, false);
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
{
var doc = entry.Entity as IHybridDocument;
if (doc != null)
{
var newDocData = JsonConvert.SerializeObject(doc);
if (!newDocData.Equals(doc.DocumentData)) doc.DocumentData = newDocData;
}
}
}
}
没有更多了。这一切都非常直接,这要归功于 JSON.NET 的强大功能以及DataContract
/DataMember
属性的使用。
但是请注意,也有一些重要的问题。
实体状态处理问题
请注意,OnSavingChanges
处理程序仅查看状态为Added
或Modified
的实体。现在,如果我更改product
的Name
或Code
,Entity Framework 在使用代理时会检测到这些更改并将实体标记为Modified
。
但是,如果我们更改product
的价格或规格,由于这些属性是[NotMapped]
的,Entity Framework 不会更改实体的状态。
我们可以在OnSavingChanges
中处理所有实体,包括那些不是Added
或Modified
的实体,这可以解决问题。如果 JSON 序列化形式已更改,我们将更改DocumentData
属性,由于DocumentData
是一个映射的属性,这将把状态设置为Modified
。
但是,创建性能怪兽的风险非常大!这意味着我们将对上下文中加载的所有实体进行 JSON 序列化,只是为了查看其中一个是否可能已更改。序列化是一个昂贵的过程。影响可能非常大。
因此,如果我们希望保存 JSON 映射属性的更改,我们需要显式地将实体标记为“已更改”。为了方便起见,我在DbContext
中添加了一个Update
扩展方法,允许将实体标记为已修改。这是代码的简化版本。
public static void Update(this DbContext context, object entity)
{
if (context.Entry(entity).State != EntityState.Added)
{
context.Entry(entity).State = EntityState.Modified;
}
}
这样,我们就可以轻松地通过以下代码更改价格:
using (var context = new SampleContext())
{
var prod = context.Products.Find(3);
prod.Price["USD"] = 1999.95m;
context.Update(prod);
context.SaveChanges();
}
现在JsonData
列可能包含例如(此处为方便阅读而缩进):
{
"HtmlDescription": "<p>Great product!</p>",
"Price": {
"EUR": 1699.95,
"USD": 1999.95
},
"Specifications": {
"Colors": [
{
"HexCode": "FF0000",
"Label": "Red"
},
{
"HexCode": "00FF00",
"Label": "Green"
},
{
"HexCode": "0000FF",
"Label": "Blue"
}
],
"Size": {
"Unit": "inch",
"Length": 6.0,
"Height": 8.0,
"Width": 12.0
}
}
}
得益于 SQL Server 2016 中的原生 JSON 支持,您还可以使用 SQL 查询 JSON 序列化的属性来查询产品,例如:
SELECT Id, Code, Name, CAST(JSON_VALUE(JsonData, '$.Specifications.Size.Length') AS float) AS Length
FROM Products
WHERE CAST(JSON_VALUE(JsonData, '$.Price.USD') AS decimal(18,4)) > 1000
有关更多信息,请参阅例如:
https://msdn.microsoft.com/en-us/magazine/mt797647.aspx
Linq 查询问题
还有另一个重要限制。请记住,在 Entity Framework 中,您无法通过未映射的属性查询实体。尽管如上面的示例所示,您可以在 SQL 中通过某个价格查询产品,但在 Linq to Entities 中却不行!
如果您愿意,可以通过在Product
表中添加一个计算列,然后为其添加索引,再将该列映射到实体上的另一个属性,然后您就可以在 Linq 查询表达式中使用它。但这最终会导致同一个值有两个实体属性……
或者,您可以创建一个数据库视图(不一定公开 JsonData,但)公开计算列,并使用一个单独的Product
实体类映射到该视图来进行查询。
冗余存储
我们来总结一下。像往常一样,对于 Entity Framework,要使实体属性作为常规列值存储,只需定义该属性即可。如果您想覆盖默认映射或映射属性,请为其添加[Column]
属性。或者在OnModelCreating
中定义您的映射。
要使实体属性作为 JSON 值存储,请使用[NotMapped]
属性禁用其常规映射,并使用[DataMember]
属性启用 JSON 序列化。
您可以选择为属性添加[DataMember]
属性,而不添加[NotMapped]
属性。结果将是该值同时存储在数据库列和 JSON 序列化中。
如果您想在 SQL 中轻松访问该值,同时使 JSON 更具自包含性,并了解其记录的Id
、Name
、Code
等(根据我们的产品示例),这会很有用。这将允许您在某些情况下仅使用 JSON 格式,并且可能会派上用场。
我不会反对这种做法,但请注意,每当加载此实体时,OnObjectMaterialized
事件将用 JSON 序列化的值覆盖数据库映射的值。JSON 将优先。例如,如果您将Name
属性同时映射到列和 JSON,那么仅在数据库中更新该列值,下次使用 Entity Framework 加载行并调用SaveChanges
时,该更改将被撤销。
此外,我还没有尝试过,但我认为如果您将自动生成的 Id 同时映射到数据库(作为主键)和 JSON(以使 JSON 自包含)时,您将遇到严重的问题。问题在于,当您创建一个新实体时,它的Id = 0
。保存后,Id
仍然是0
,并且0
会被序列化到 JSON 中。一旦保存,Id
就会确定。但由于 JSON 包含值0
,并且 JSON 优先,因此每次尝试加载实体时,其Id
都会倾向于变回0
……
这个问题也有一个解决方案(https://stackoverflow.com/questions/31731320/serialize-property-but-do-not-deserialize-property-in-json-net),但这会使整个过程再次变得更加复杂。
性能
显然,在您只需要product
的Id
和Name
的情况下,通过网络传输整个 JSON string
并对其进行反序列化将产生性能开销。
另一方面,在您确实需要大部分或全部产品数据的情况下,加载单个记录将比急切加载和/或延迟加载整个数据库图更快。
因此,性能影响将取决于您选择将哪些属性序列化为 JSON,您可以通过不加载相关数据库行来获得性能提升的频率,以及 JSON 字符串的大小。
我的建议是仔细构建您的实体,选择将哪些属性映射到列,哪些属性映射到 JSON。同时考虑性能影响以及我在前一点描述的 Linq 查询限制。
然后,如果需要,可以添加一个或多个视图。例如,添加一个仅列出product
的Id
、Name
和Code
的视图。使用此视图,您将能够加载product
而无需在每行上反序列化 JSON 字符串的开销。由于此视图可更新,因此它可以成为您的主要Product
实体,而实际表可以映射到ProductWithDetails
实体,仅用于管理产品详细信息和规格。
为什么选择 JSON?
在我 2015 年的第一个实现中,我选择了 XML。XML 是一种更完整、更成熟的格式,具有强大的功能,如模式(以及在文档中混合多个模式的能力,例如,一个对象可以有两个同名的属性而不会相互干扰)和声明式转换。
更重要的是:自 SQL Server 2012 版本以来,数据库对 XML 具有相当好的原生支持。选择 XML 确保我仍然可以使用 SQL 查询检索单个值(我没有把自己限制为只能使用我的 .NET 实体检索单个值)。
由于混合存储概念意味着我们必须能够仅将某些属性(那些尚未映射到单独列的属性)序列化为 XML,因此我唯一的选择似乎是DataContractSerializer
,其中要序列化的属性可以标记为[DataMember]
。
但是序列化会创建“复杂”的 XML,具有不同的模式,尽管模式很酷且功能强大,但在这里我并不真正需要它们。它们使我的 XML 过分复杂、体积庞大且难以查询。而且我实际上无法拥有两个同名的属性(但具有不同的模式),因为我并不真正控制这些模式。
同样,反序列化不能直接在实体上完成,反序列化时我会得到另一个对象,并且需要将datamember
属性复制到实际实体。这需要相当多的代码行,涉及反射(和缓存)。
因此,尽管 XML 是一种强大的格式,并且在专注于真正的文档导向解决方案时,我可能会选择 XML 而不是 JSON,但在此案例中,我无法利用其优势,只能获得不便之处。
因此,我很高兴地发现 SQL Server 2016 引入了对 JSON 的原生支持。
现在我可以选择更简单的 JSON 格式,而无需担心被锁定。并且序列化和反序列化(使用[DataMember]
)都完美无缺!
而且,您可以立即开始,使用任何版本的 SQL Server,因为即使是 SQL Server 2016 也将 JSON 存储在简单的NVARCHAR(MAX)
字段中。因此,此解决方案适用于任何 SQL Server 版本,只有,要执行涉及 JSON 数据单个字段的 SQL 查询,您将需要 2016 版本(或找到替代解决方案)。
关于 .NET Core?
要使用 EF Core 实现相同的功能,我们需要等效于ObjectMaterialized
和SavingChanges
事件。据我目前所知,此功能尚未在 .NET 2.0 中实现,并计划在 .NET 2.1 中(如果实现的话)。
您今天可能可以通过处理DocumentData
和 JSON 映射属性的set
访问器中的内容来找到一种使用 EF Core 的方法,但那样您就无法使用自动属性了。
我找到了对 EF Core 已支持和未支持的功能的精彩概述。
代码
您可以在此处找到包含简单控制台示例的代码。
我还将此混合存储功能集成到了我的“通用”类库中,可以在以下位置找到:
您将在Arebis.Data.Entity
库中找到我的最新版本的混合存储。您也可以通过以下方式将此组件作为 Nuget 包获取: