我记得在座位边缘观看 2005 年的 PDC(专业开发者大会)视频,那是我第一次看到 LINQ(语言集成查询)。我想要 LINQ:它几乎提供了我所希望的一切,使数据处理变得轻松。
将查询构建到语言中的推动力非常简单;它是一种始终都在使用的东西;统一查询模型的承诺是足够好的,甚至在你添加所有加在我们身上的语言特性之前就足够好了。能够在 C# 中编写代码,并让数据库神奇地理解我在做什么?太棒了!从 Visual Studio 获取编译错误,而不是在测试(或更糟糕的,生产环境)时出现运行时错误?太好了!摆脱大多数 SQL 注入问题?太神奇了!
然后 .NET 3.5 推出了,我们有了 LINQ,我发现自己陷入了困境。实际上,LINQ 的故事有两面:消费者端和实现者端。对于消费者来说,一切都阳光明媚、鲜花盛开,但对于实现者来说,却是厄运、阴郁,只有一丝绝望。即使对于消费者来说,在最初的一见钟情阶段之后,也存在需要处理的问题。首先让我关注消费者端。
尽管表面看起来如此,但 LINQ 实际上并不是 C#(或 VB.NET)中的 SQL。它只是从一组众所周知的关键字到众所周知的方法的语法转换。Select, Where和GroupBy的使用使其尝起来非常类似于 SQL,但 LINQ 的概念工作方式与统治 SQL 数据库的基于集合的关系逻辑之间存在巨大差异。
让我们检查一个简单的 LINQ 查询
from user in users where user.IsActive select user;
我们不知道users变量的类型,但一般来说,它将是IEnumerable<T>或IQueryable<T>。 IEnumerable<T>是内存枚举和迭代中一切事物的基本接口。但是IQueryable<T>以及编译器完成的语法转换,则完全是另一回事。它允许IQueryable<T>的实现者访问查询的表达式树(编译器抽象语法树)。
让我们看看这个查询实际上是如何转换的。表 1 显示了取决于users变量类型的转换。
因为IQueryable<T>实现者被调用时会收到查询的表达式树,他们有机会根据该表达式树来决定实际如何执行该查询。在许多情况下,IQueryable<T>实现将表达式树转换为底层数据存储的查询媒介(例如,SQL、XPath 等),而不是直接执行代码。
LINQ to Objects(针对IEnumerable<T>的查询)行为被认为是 LINQ 查询应如何表现的权威实现。该实现实际上是在内存中执行代码,因此可以访问整个基类库,并可以执行它希望的任何操作。其他 LINQ 实现并非如此,因为是IQueryable<T>实现决定了哪些操作受支持,哪些操作不受支持。然而,问题是,除了最简单的查询之外,实际上很难将用户的意图映射到在目标数据存储上有意义的查询。当所有数据都可以在主内存中访问时,完美有意义的操作,当数据分布在不同的数据库表甚至不同的服务器中时,就会成为糟糕的选择。
例如,考虑以下非平凡的 LINQ 查询,它查找他们拥有的项目上所有未完成的、延期的、活动用户的任务
from user in users where user.IsActive from task in user.Tasks where task.Project.Owners.Contains(user) && task.DueDate < DateTime.Now && task.Completed == false select new { task.Title, task.Status, user.UserName };
这是表达意图的一种非常清晰的方式,并且读起来非常流畅。当在内存中的小型数据集上运行时,它的性能也非常出色。问题是,在大多数情况下,我们不是在小型数据集或内存中运行。
即使在内存中的大型数据集上,也请考虑此类查询的效果。对于 LINQ to Objects,大多数原始操作都具有 O(N) 的复杂度。在之前的查询中,我们有相互馈送的复合调用。
这导致即使当我们谈论纯内存模型时,大型数据集也需要我们采取优化的方法。我们可以构建一个IQueryable<T>实现,它将使用内存索引来执行此查询。然而,在大多数情况下,我们不是针对内存表示编写此类查询;我们是针对数据库编写此类查询。数据库可以轻松处理大型数据集;这才是它的用途。
使用 LINQ to Entities 框架,之前的查询示例被转换为以下 SQL
SELECT [Extent1].[Id] AS [Id], [Filter1].[Title] AS [Title], [Filter1].[Status] AS [Status], [Extent1].[UserName] AS [UserName] FROM [dbo].[Users] AS [Extent1] INNER JOIN (SELECT [Extent2].[UserId] AS [UserId], [Extent3].[Title] AS [Title], [Extent3].[Status] AS [Status], [Extent3].[ProjectId] AS [ProjectId] FROM [dbo].[UsersTasks] AS [Extent2] INNER JOIN [dbo].[Tasks] AS [Extent3] ON [Extent3].[Id] = [Extent2].[TaskId] WHERE ( [Extent3].[DueDate] < CAST(sysdatetime() AS DATETIME2) ) AND ( 0 = [Extent3].[Completed] )) AS [Filter1] ON [Extent1].[Id] = [Filter1].[UserId] WHERE ( [Extent1].[IsActive] = 1 ) AND ( EXISTS (SELECT 1 AS [C1] FROM [dbo].[ProjectOwners] AS [Extent4] WHERE ( [Filter1].[ProjectId] = [Extent4].[ProjectId] ) AND ( [Extent4].[UserId] = [Extent1].[Id] )) )
到目前为止还不错,但是很容易编写任何提供程序都无法转换的 LINQ 查询,仅仅是因为你想要在查询中执行的操作与底层数据库提供的操作之间没有映射。例如,考虑像
from user in users where user.UserName[5] == 'A' select user;
这样简单的东西。对于 LINQ to Objects,此查询会按预期运行,但是使用 LINQ to Entities 尝试此查询会抛出错误。该错误只会发生在运行时,因为代码完全没问题,并且编译器无法知道特定的IQueryable<T>实现是否支持此操作。或者,更糟糕的是,提供程序将能够转换它,但会转换为性能很差的查询。
从技术上讲,LINQ to Entities 没有理由不支持对字符串索引的查询,但请考虑从数据库的角度来看这意味着什么。那将被翻译成类似SUBSTRING(UserName, 5,1) = 'A'之类的东西,那将意味着强制进行表扫描来回答这个查询。
问题,一如既往,是当你有一个抽象层时,它很容易抽象掉重要的细节。结果是,虽然理论上你可以将查询从一个数据存储移动到另一个数据存储,但在实践中,你必须充分了解 LINQ 提供程序和底层数据库的实际行为。
并不是 LINQ 生成了低效的查询,甚至不是它允许生成低效的查询。这仅仅是一个抽象隐藏了真正发生的事情的问题。LINQ 给用户很大的自由度,并且很容易创建底层数据存储根本无法有效执行的查询。这主要是因为内存集合(LINQ 使用的抽象)中的查询语义与关系型和非关系型数据库等数据存储的查询之间存在很大的差距。
作为 LINQ 的消费者,我绝对喜欢它,尽管存在一些刚才讨论的问题。但是使用 LINQ 的部分是阳光明媚、鲜花盛开的。实现 LINQ 提供程序的任务是艰巨的,并且需要大量使用肥料、汗水和辛勤工作。
自从 .NET 3.5 发布以来,我不得不实现 LINQ 提供程序好几次。我为 NHibernate 实现了第一个,我还实现了一些不针对关系数据库的,其中最重要的是 RavenDB 的 LINQ 提供程序 (http://ravendb.net)。
在每一种情况下,这个过程都非常痛苦。要构建 LINQ 提供程序,你首先必须了解编译器如何看待语言,因为这就是 LINQ 提供程序的全部内容。你得到一个表达式树,它只是代码片段的编译器视图,并被告知:“从中做出一些东西,并给我一个 T 类型的结果。”
问题是,编译器交给你的东西与用户实际编写的东西之间实际上几乎没有关系。代码的编译器视图通常与你期望的截然不同。
考虑这个简单的 LINQ 查询
from user in users where user.Id == 1 select user;
虽然这可能是最简单的 LINQ 查询,但编译器将其转换为看起来截然不同且相当复杂的东西,如清单 1 所示。这是编写 LINQ 提供程序更成问题的一个方面。你得到的东西看起来与用户提供的非常不同。
LISTING 1 The expression tree from a simple query
1. MethodCallExpression a. Method : MethodInfo : "Where" b. Arguments : ReadOnlyCollection i. ConstantExpression 1. Value : Object : "LINQConsoleApplication1.User[]" 2. NodeType : ExpressionType : "Constant" 3. Type : Type : "EnumerableQuery" ii. UnaryExpression 1. Operand : ExpressionLambda a. Expression> i. Body : ExpressionEqual 1. BinaryExpression a. Left : ExpressionMemberAccess i. MemberExpression 2. Expression : ExpressionParameter a. ParameterExpression i. Name : String : "user" ii. NodeType : ExpressionType : "Parameter" iii. Type : Type : "User" 3. Member : MemberInfo : "Int32 Id" 4. NodeType : ExpressionType : "MemberAccess" 5. Type : Type : "Int32" a. Right : ExpressionConstant i. ConstantExpression 6. Value : Object : "1" 7. NodeType : ExpressionType : "Constant" 8. Type : Type : "Int32" a. Method : MethodInfo : null b. Conversion : LambdaExpression : null c. IsLifted : Boolean : "False" d. IsLiftedToNull : Boolean : "False" e. NodeType : ExpressionType : "Equal" f. Type : Type : "Boolean" i. Parameters : ReadOnlyCollection 1. ParameterExpression a. Name : String : "user" b. NodeType : ExpressionType : "Parameter" c. Type : Type : "User" ii. NodeType : ExpressionType : "Lambda" iii. Type : Type : "Func" 1. Method : MethodInfo : null 2. IsLifted : Boolean : "False" 3. IsLiftedToNull : Boolean : "False" 4. NodeType : ExpressionType : "Quote" 5. Type : Type : "Expression>" a. NodeType : ExpressionType : "Call" b. Type : Type : "IQueryable"
如果你习惯于编写编译器,那么这一切都很有道理。但是,当你遇到更复杂的查询时,即使是表面上看起来没有太大差异的查询,结果也会截然不同且更加复杂。以下查询生成一个长达五页的表达式树
from user in users where user.IsActive from task in user.Tasks where task.Project.Owners.Contains(user) && task.DueDate < DateTime.Now && task.Completed == false select new { task.Title, task.Status, user.UserName };
第一个查询是三行,其表达式树大约有一页。第二个查询是五行,其表达式树长达五页。你可以在以下文件中查看这两个查询的完整表达式树:https://queue.org.cn/downloads/2011/Trivial_Query.html 和 https://queue.org.cn/downloads/2011/Non_Trivial_Query.html。对于编译器编写者来说,这很合理。对于不精通编译器理论的开发人员来说,这是一个很大的绊脚石。
大多数成熟的 LINQ 提供程序实际上都使用两阶段过程。在该过程的第一部分,提供程序遍历表达式树,编译其自身从表达式树中提取的查询的内存表示形式。第二部分是执行用户实际想要执行的操作。
虽然这可能不会让你感到惊讶,但几乎所有的艰苦工作都涉及将编译器给我们的表达式树转换为实际非常接近用户最初提供的形式。
在 re-linq 框架 (http://relinq.codeplex.com) 中可以看到一种更好的方法的开始,该框架负责处理从内部编译器表示形式到用户意图的大部分转换工作。就我个人而言,我本应该从一开始就构建一个模仿实际查询语义的 AST(抽象语法树),而不是提供IQueryable<T>原始编译器输出。清单 2 是使用 NRefactory 项目 (http://wiki.sharpdevelop.net/NRefactory.ashx) 处理后,简单查询 AST 的外观示例。使用这样的东西比使用编译器输出要简单得多,因为我们现在有了相关的含义。就目前而言,我们必须自己提取含义,这并非易事。
LISTING 2 Trivial query AST, as generated by NRefactory
• FromClause=QueryExpressionFromClause o Identifier=user o InExpression ▪ IdentifierExpression Identifier=users o MiddleClauses ▪ QueryExpressionWhereClause Condition ▪ BinaryOperatorExpression ▪ Left ▪ MemberReferenceExpression ▪ TargetObject Identifier=user ▪ MemberName=Id ▪ Op=Equality ▪ Right ▪ PrimitiveExpression ▪ Value=1 o SelectOrGroupClause ▪ QueryExpressionSelectClause Projection ▪ IdentifierExpression Identifier=user
从中可以吸取两个教训。第一个教训是,从需要在这些基础上构建 LINQ 提供程序的开发人员的角度来看,将原始编译器输出作为表达式树提供是一个相当糟糕的选择。即使是相当少的工作(如清单 2 中所示)也会使创建 LINQ 提供程序变得非常容易。我并不是说你可以让它在所有情况下都有效;我相信在某些情况下,你仍然会遇到这种复杂性,仅仅是因为我们需要支持任意复杂的输入。
然而,绝大多数情况都属于相当可预测的模式,在编译器/框架级别解决这个问题将使复杂的表达式树的情况变得罕见,而不是每天都发生。这就是为什么我强烈鼓励使用第三方库,例如 re-linq 项目,这些库承担了将常见转换转换为更合理格式的大量工作。
在实现正确的 LINQ 提供程序时,还存在其他问题
• 对于相同的逻辑代码,不同的语言提供不同的表达式树。例如,在 C# 和 VB 中,相等性的处理方式不同,具体取决于你是比较字符串还是其他内容。
• 相同查询的不同编译器版本可以发出不同的表达式树。
• 绝对没有关于表达式树的文档可以帮助构建 LINQ 提供程序。
• 你的输入基本上是 C# 或 VB.Net 中的任何合法表达式,这意味着你必须处理的选项空间是巨大的。当然,如果你不理解查询,可以抛出异常,但这意味着用户只有在运行时才会发现不受支持的场景,从而大大削弱了将查询集成到语言中的意义。
更糟糕的是,用户经常提出最复杂的查询,并期望它们能够工作。由于上述原因,实现 LINQ 提供程序主要是一次实现一个查询的问题。你必须以这种方式构建 LINQ 提供程序的原因是,你无法真正将许多操作视为黑盒,给定 X 输出 Y。例如,考虑Count()操作的情况。它可以应用于全局查询、查询中的子查询、作为投影一部分或处理查询的集合属性,或者在 group by 子句中。每种情况都需要区别对待。此外,用户通常会提出可能需要重新架构你的做事方式的查询。
到目前为止,我主要讨论了实现任何 LINQ 提供程序中固有的问题,但本文特别关注为 NoSQL 数据存储构建 LINQ 提供程序。
构建此类 LINQ 提供程序的问题之一是,很多 LINQ API 在关系数据库之外根本没有意义。例如,你如何在文档数据库中处理分组?或键值存储中的排序?你当然可以做任何一个(可能效率不高,但你可以)。LINQ API 使它看起来好像与在内存中处理一样容易。有一些方法可以将 API 的某些部分标记为对特定提供程序不可用,但这是一种选择退出的方法,并且需要相当多的重复编码。
现在,鉴于构建 LINQ 的意图(允许查询任何内容),实际上没有很好的方法不遇到这个问题。不同的数据存储具有不同的语义,并且在通用 API 中考虑所有这些语义相当困难。提供了这样做的方法,如果说令人恼火,那也足够了。
在我们开始将 LINQ API 的某些部分标记为超出我们提供程序范围之前,我们仍然必须处理首要任务:你如何处理查询?我们将在下一节中讨论这个主题。
正如我提到的,我有机会构建几个 LINQ 提供程序。其中,RavenDB 和 RavenLight 是最近的两个实现,它们提供了两种非常相似但截然不同的实现 LINQ 提供程序的方法。
RavenLight 是一种嵌入式文档数据库,旨在在 Silverlight 应用程序内部运行。换句话说,它完全在客户端运行,没有任何服务器端组件(它可以复制到 RavenDB 实例和从 RavenDB 实例复制,但这与本文无关)。图 1 显示了一个典型的使用 RavenLight 的 Silverlight 应用程序。
在构建 RavenLight 的 LINQ 提供程序时,我们做出了以下假设
• 所有数据都在本地可用。
• 数据存储中的数据总量可能很小(最多数万项,而不是数百万或更多)。
• 大多数 Silverlight 查询将在响应用户操作时完成。
我还将添加一个同义反复的陈述:对 LINQ API 的支持越广泛,就越好。
对于 RavenLight,我们实际上决定跳过实现 LINQ 提供程序的麻烦,而只是使用 LINQ to Objects。
这是如何工作的?因为所有数据都在本地可用,并且存储的数据量很小,所以我们实际上将直接针对数据存储运行查询。这在实践中意味着我们可以这样做
public class RavenLight { // other stuff public IQueryableQuery () { return ReadAll ().AsQueryable(); } private IEnumerable ReadAll () { var fullName = typeof(T).FullName; foreach (var item in datastore["ByType"] .SkipTo(fullName) .TakeWhile(x=> x.Type == fullName)) { yield return item.Deserialize (); } } }
正如你所看到的,我们实际上并没有在这里实现任何东西。ReadAll<T>()方法只是根据请求的类型执行第一级过滤,仅此而已。显然,我大大简化了实现以说明这一点,但这正是它在概念层面上如何工作的。瞧,整个 LINQ API 都得到了支持,因为我们实际上返回了IEnumerable<T>在底层。
这种方法有一些限制,但我们实际上保留了以后坐下来实现一个不总是执行线性扫描操作的 LINQ 提供程序的选项。也就是说,当 N 很小时,O(N) 方法实际上是相当合理和高效的。
就此而言,如果我们想要获得快速的速度提升,我们可能会这样做
public IQueryableQuery () { return ReadAll ().AsQueryable().AsParallel(); }
就开发人员时间和实际使用而言,此选项都是高效的。请记住,在 Silverlight 应用程序中,我们实际上是在客户端桌面运行。除了确保我们可以在短于人类响应时间的时间内响应查询之外,我们不必过多担心性能。这至少给了我们数十毫秒的时间,而这段时间意味着我们可以“浪费”大量的计算能力。
然而,只有当你实际上能够满足我指定的所有要求时,这种方法才适用。在大多数情况下,我认为你会发现情况并非如此。
更常见的是,我看到人们转向以下两种选择之一:LINQ 序列化和成熟的 LINQ 提供程序。通过网络序列化 LINQ 查询假设你在另一端有可以有效处理它们的某些东西。通常,情况并非如此,即使情况如此,真实世界的数据集大小也会使这成为一个非常低效的选择。
关系数据库允许你进行几乎任何你想要的查询。NoSQL 数据存储通常会限制你的查询能力。例如,你可能只能在特定字段(预定)上或以特定方式查询。
对于 NoSQL 解决方案,例如键值存储(Memcached、Redis 等),你只能按 ID 查询。对于 BigTable 数据存储,你的查询选项仅限于按键或键范围查询等。
在许多情况下,尝试在高度受限的查询功能之上提供 LINQ 提供程序抽象实际上是不值得的。如果我们使用像 Redis(键值存储,唯一的查询选项是按 ID)这样的东西,那么尝试创建 LINQ 提供程序实际上没有多大意义。它只会产生我们可以支持其他查询操作的错觉。
对于 RavenDB,查询功能允许我们执行大多数查询(属性等于值,属性上的范围查询等),而不会遇到太多麻烦,这意味着支持 LINQ 提供程序是很有意义的。也就是说,实际上有很多我们无法支持的操作。例如,GroupBy操作通常不是可以即时定义的东西。
为 RavenDB 构建 LINQ 提供程序并非易事;事实上,我们为它预算的时间比构建实际数据库的时间还要多。是的,构建数据库和查询机制所花费的时间比为同一个数据库构建 LINQ 提供程序所花费的时间要少。
RavenDB 允许你发出如表 2 所示的查询。
你可以想象,LINQ 提供程序的任务是获取 LINQ 查询表达式并发出 RavenDB 语法。这或多或少是我们所做的。构建一个简单的 LINQ 查询提供程序是困难的,但并没有太难。
一旦你有了支持它的框架,处理单个表达式实际上非常容易。对于复合表达式,它会变得有点困难,但仍然是可管理的。
当你开始考虑如何(甚至是否应该)支持底层数据存储实际上未提供的操作时,问题才真正开始出现。例如,连接和分组是 RavenDB 不做的两个操作,但它们是 LINQ 的原生部分(实际上是语言的一部分)。还有更简单的例子。RavenDB 的一个特点是它在查询期间绝对不执行任何计算;查询的所有数据都已准备好。这意味着我们可以实现非常好的查询速度。
问题是用户可以指定类似
from user in users where user.Id % 2 == 0 select user.Name;
这样的语句。同样,这主要是确定你将支持多少的问题。显然,我们无法将该查询发送到 RavenDB,RavenDB 在查询期间没有内置的数学运算支持。当然,你可以将该支持添加到 API 的客户端部分,但这意味着你的提供程序实现必须变得越来越复杂,并且它已经花费了比仅仅构建数据库更多的时间。
由于这些问题,我们决定我们不会尝试支持 LINQ API 中所有可用的选项。相反,我们将定义一组我们将支持的常用操作,任何超出该范围的操作都将无法工作。
例如,考虑查询特定角色中的用户
var q = from user in users from role in user.Roles where role == "Admins" select user;
我们无法真正支持此查询;它太复杂了,我们无法轻松理解(并且它开启了我们必须支持的很多可能性)。弄清楚role来自user实际上非常复杂,因为这是在内部处理的方式。你必须跟踪别名、透明标识符、重命名、处理多个嵌套语句等。
然而,我们仍然希望支持这一点,因此我们提供了另一种方法
var q = from user in users where user.Roles.Any(role => role == "Admin") select user;
这比之前的查询容易得多的原因很简单。我们直接在节点中拥有处理表达式的所有信息。在之前的查询中,我们必须花费大量精力才能弄清楚我们从哪里得到了role。 我们将得到的实际上是所谓的透明标识符,并且我们必须追溯它的来源。
从用户的角度来看,没有太大的区别,但是理解这个语句与之前的语句相比,所涉及的复杂程度却有很大的不同。
我们在其他地方也做出了类似的决定,并且我们已经彻底记录了它们。用户可以使用 LINQ,并获得与之相关的所有好处(最重要的是强类型和智能提示),但我们不必解决尝试支持任何可以编写为 LINQ 查询的内容所涉及的全部复杂性。
如前所述,为 RavenDB 构建 LINQ 提供程序实际上比构建 RavenDB 本身花费了更多的时间。实际上,即使现在,RavenDB 中最复杂的代码片段也是 LINQ 提供程序实现。
然而,当谈到 .NET 框架时,如果你正在创建数据库或访问数据库的库,那么你几乎别无选择,只能提供 LINQ 实现。即使这样说,我还是建议仔细评估你是否真的需要 LINQ 提供程序,或者更准确地说,LINQ 提供程序实现是否值得。
如果你连接到键值存储或其他查询选项有限的东西,你可能不应该为此烦恼。拥有可用的 LINQ 查询只会暗示你可以做但实际上不能做的事情。最好不要拥有一个,而不是解释为什么不支持这个或那个操作。
如果你确实决定针对 NoSQL 数据库实现 LINQ 提供程序,我强烈建议你选择一种传统方式来定义每个受支持的操作。如果用户的输入可以遵循特定模式,你将会好得多。
另一种选择是为你的 LINQ 提供程序选择性地禁用 LINQ API 的某些部分(通过创建你自己的 LINQ 方法并使用 [Obsolete] 属性标记它们)。
最后,有一些库(例如 re-linq (http://relinq.codeplex.com/))可以帮助构建 LINQ 提供程序。特别是 Re-linq 负责完成从 LINQ 表达式树到更易于处理的东西的大部分工作。
正如我在本文开头所说,我真的很喜欢 LINQ,但仅从消费者的角度来看。编写 LINQ 提供程序不是一项适合胆小者的任务,除非你知道你实际上在做什么,否则不应尝试。
你可能最好使用有限的 LINQ 实现。考虑创建一个仅支持极少操作的提供程序,甚至是一个仅支持相等性测试的提供程序。这可能令人印象深刻,而且并不难做到。
当你想要支持更多时,复杂性开始堆积起来,因此请仔细考虑你究竟想要做什么,与每个选项相关的成本以及将获得的收益。
毕竟,用户喜欢 LINQ……
问
喜欢还是讨厌?请告诉我们
Oren Eini 在开发领域拥有超过 15 年的经验,专注于 Microsoft 和 .NET 生态系统。他的主要重点是架构和最佳实践,以促进高质量的软件和零摩擦开发。Eini 使用他的笔名 Ayende Rahien,经常在 http://www.ayende.com/Blog/ 上撰写博客。他是 Hibernating Rhinos LTD 的创始人,这是一家位于以色列的公司,提供无摩擦数据管理解决方案。
© 2011 1542-7730/11/0600 $10.00
最初发表于 Queue vol. 9, no. 7—
在 数字图书馆 中评论本文
Craig Russell - 弥合对象-关系鸿沟
现代应用程序是使用两种截然不同的技术构建的:用于业务逻辑的面向对象编程;以及用于数据存储的关系数据库。面向对象编程是实现复杂系统的关键技术,提供了可重用性、健壮性和可维护性的好处。关系数据库是持久数据的存储库。ORM(对象-关系映射)是两者之间的桥梁,允许应用程序以面向对象的方式访问关系数据。