F#类型提供程序 vs C#接口 + 实体框架

14

这个问题非常技术化,它深深地涉及到F# / C#之间的差异。很可能我会漏掉一些东西。如果您发现了概念错误,请评论,我将更新这个问题。

让我们从C#世界开始。假设我有一个简单的业务对象,叫做Person(但请记住,在我们所处理的业务领域中,有100多个比它复杂得多的对象):

public class Person : IPerson
{
    public int PersonId { get; set; }
    public string Name { get; set; }
    public string LastName { get; set; }
}

我使用依赖注入/控制反转,以便我不必直接传递Person。相反,我总是使用一个接口(如上所述),称之为IPerson

public interface IPerson
{
    int PersonId { get; set; }
    string Name { get; set; }
    string LastName { get; set; }
}

业务需求是将人员信息能够序列化到/从数据库反序列化。比如说我选择使用Entity Framework,但具体的实现对于这个问题似乎不重要。此时,我有一个选项可以引入与“数据库”相关的类,例如EFPerson

public class EFPerson : IPerson
{
    public int PersonId { get; set; }
    public string Name { get; set; }
    public string LastName { get; set; }
}

除了相关的数据库相关属性和代码,出于简洁起见我会跳过它们,然后使用反射在PersonEFPerson之间复制IPerson接口的属性,或直接使用EFPerson(作为IPerson)或做其他事情。这相当不相关,因为消费者始终会看到IPerson,所以实现可以随时更改,而消费者不会知道任何有关其的信息。

如果我需要添加属性,那么我会首先更新接口IPerson (假设我添加了一个属性 DateTime DateOfBirth { get; set; }),然后编译器会告诉我要修复什么。但是,如果我从接口中删除属性(比如我不再需要LastName),那么编译器就无法帮助我。但是,我可以编写一个基于反射的测试,以确保IPersonPersonEFPerson等的属性是相同的。这并不是真正必需的,但是可以完成,然后它将像魔术一样工作(是的,我们确实有这样的测试,它们确实像魔术一样工作)。

现在,让我们进入F#世界。在这里,我们有类型提供程序,它们完全消除了在代码中创建数据库对象的需求:类型提供程序会自动创建它们!

很酷!但是真的吗?

首先,总得有人创建/更新数据库对象,如果有不止一个开发人员参与,那么自然会在不同的分支中进行升级/降级。就我个人的经验而言,当涉及到F#类型提供程序时,这是极其痛苦的一件事情。即使使用C# EF Code First来处理迁移,仍需要进行一些“广泛的萨满舞蹈”才能让F#类型提供程序“高兴”。

其次,在F#世界中,默认情况下所有内容都是不可变的(除非我们将其设置为可变),因此我们显然不想将可变数据库对象传递给上游。这意味着一旦我们从数据库中加载了可变行,我们希望尽快将其转换为“本地”F#不可变结构,以便只在上游使用纯函数。毕竟,使用纯函数可以将所需测试数减少5-50倍,这取决于域。

让我们回到我们的Person。暂且不考虑任何可能的重新映射(例如将数据库整数转换为F# DU case等类似内容)。因此,我们的F#Person看起来像这样:

type Person =
    {
        personId : int
        name : string
        lastName : string
    }
如果“明天”我需要在这个类型中添加dateOfBirth:DateTime,那么编译器将告诉我需要修复的所有地方。这很棒,因为除了数据库之外,C#编译器不会告诉我需要添加出生日期的地方。F#编译器也不会告诉我需要去表格Person添加数据库列。但是,在C#中,由于我必须先更新接口,编译器将告诉我必须修复哪些对象,包括数据库对象。

显然,我希望在F#中获得最佳效果。虽然可以使用接口来实现这一点,但这并不符合F#的方式。毕竟,在F#中,DI / IOC的类比方式与传递函数而不是接口通常有所不同。

因此,这里有两个问题。

  1. 如何在F#世界中轻松管理数据库升级/降级迁移?并且,从何开始实际上在F#世界中进行数据库迁移时,涉及多个开发人员的情况下应该采用什么样的适当方式?
  2. 如何通过F#的方式实现“C#世界中的最佳方式”,如上所述:当我更新F#类型Person并修复需要添加/删除记录属性的所有位置时,如果我没有更新数据库以匹配业务对象,则最合适的F#方式是“失败”要么在编译时,要么至少在测试时?
Note: As per the given instructions, I have only provided the translated text and retained the HTML tags.

3
我认为两个问题的答案都是在 C# 库项目中使用 EF。对于小型数据库和项目,F# 类型提供程序非常好用。然而,目前 F# 类型提供程序的工具还不足以处理“100多个更复杂”的对象模式迁移,并且需要团队协作完成。 - Wallace Kelly
4
我个人也在学习这个,所以无法提供完整或良好的答案,但我现在从我的资源中得到的建议是:“如果你必须使用EF,我建议为你的实体模型创建一个C#层,并从F#访问它。”- Isaac Abraham。另外,问题非常好,写得很棒! - ChiefTwoPencils
4
这种方法完全消除了在代码中创建数据库对象的需要:它们由类型提供程序自动创建。但事实上,这只是一个承诺,ORM也曾经做出过同样的承诺,但从未成功。这就是为什么人们谈论“对象关系阻抗不匹配”的原因。无论是对象还是记录,应用程序的模式/类型系统很少适合作为数据库模式,反之亦然。 - Panagiotis Kanavos
3
数据库并不是应用程序的实现细节,事实上,通常情况下,数据库会比应用程序存在更久。大多数情况下,数据库为多个应用程序提供服务。即使在同一个应用程序中,数据库也会为多个看似不同的DbContexts和映射提供服务。在这种情况下,EF Code First和数据库迁移本身将失败。 - Panagiotis Kanavos
3
F#类型提供程序是ORM的轻量级版本,类似于像Dapper这样的微型ORM。没有模式和迁移,只有自动映射到对象的查询语句。任何人都可以将返回的记录视为只读,微型ORM不会跟踪更改。 - Panagiotis Kanavos
正如@PanagiotisKanavos所提到的,您正在遭受阻抗不匹配的困扰。您无法解决这种不匹配,而是应该接受它。如果您将DB升级视为单独的操作,则可以考虑使用TPs。将模式升级作为第一个步骤,域升级作为第二个步骤。 - Devon Burriss
1个回答

7
我该如何在F#中轻松管理数据库的上下迁移?并且,从何处开始,在多个开发人员参与的情况下,实际进行数据库迁移的正确方法是什么?
最自然的管理数据库迁移的方式是使用本地于数据库的工具,即纯SQL。在我们的团队中,我们使用dbup包,为每个解决方案创建一个小型控制台项目,在开发和部署期间升级数据库迁移。消费者应用程序既可以使用F#(类型提供程序),也可以使用C#(EF),有时使用相同的数据库。非常好用。
你提到了EF Code First。由于F# SQL提供程序都是根据外部数据源(数据库)生成类型,而不是相反的,因此它们在本质上都是“Db First”。我认为混合两种方法并不是一个好主意。事实上,我不建议任何人使用EF Code First来管理迁移:纯SQL更简单,不需要“全面的巫术舞蹈”,具有无限的灵活性,并且被更多的人所理解。 如果您不熟悉手动SQL脚本编写,并且仅考虑使用EF Code First自动生成迁移脚本,则甚至可以使用MS SQL Server Management Studio designer为您生成迁移脚本。

如何使用F#方式实现上述“C#世界中最好”的内容:当我更新F#类型Person并修复所有需要添加/删除属性以匹配记录的位置时,最适合的F#方式是在编译时或测试时“失败”,如果我没有更新数据库以匹配业务对象会怎样?

我的建议如下:

  • 不要使用接口,就像你说的那样 :)

接口,它只是不符合F#的风格

  • 不要让类型提供程序自动生成的类型泄漏到薄的数据库访问层之外。它们不是业务对象,实际上连EF实体也不是。
  • 相反,声明F#记录和/或辨别联合作为您的领域对象。按您的意愿对其进行建模,不要受到数据库架构的限制。
  • 在数据库访问层中,从自动生成的数据库类型映射到您的领域F#类型。 每次使用类型提供程序自动生成的类型都始于此,终于此。 是的,这意味着您必须手动编写映射,并在此引入人为因素,例如您可能会意外地将FirstName映射到LastName。实际上,这只是一个微小的开销,解耦的好处远远超过了它。
  • 如何确保您不会忘记映射某些属性?这是不可能的,如果记录没有完全初始化,F#编译器会发出错误。
  • 如何添加新属性而不会忘记初始化它?从F#代码开始:向领域记录/记录添加新属性,F#编译器将指导您到所有记录实例(通常只有一个),并强制您使用某些内容进行初始化(您将不得不添加迁移脚本/升级数据库架构)。
  • 如何删除属性并确保将所有内容清除到数据库架构。从另一端开始:从数据库中删除列。类型提供程序类型和领域F#记录之间的所有映射都将中断,并突出显示变得多余的属性(更重要的是,它将迫使您仔细检查它们是否真的不需要,并重新考虑您的决定)。
  • 实际上,在某些情况下,您可能希望保留数据库列(例如,用于历史/审计目的),只需从F#代码中删除属性即可。这只是许多方便将领域模型与数据库架构解耦的情况之一(而且相当罕见)。

简而言之

  • 通过纯SQL进行迁移
  • 手动声明F#记录作为域类型
  • 从类型提供程序手动映射到F#域类型

更简单的说法

坚持单一职责原则并享受其好处。


离题一下,如果你的数据库是 MS SQL,我建议看看这个类型提供程序:https://fsprojects.github.io/FSharp.Data.SqlClient/。它只适用于 MS SQL,并需要手动编写 SQL 查询或存储过程,即它不是真正的 ORM,但编译时安全级别是我以前从未感受到的。当它编译成功时,通常都能正常工作,有时我甚至不必在继续之前运行它。 - KolA
谢谢,我已经使用它并在单个项目中手动映射类型提供程序类型到F#类型。这在F#中是自然而然的,因为我们绝对不希望这些可变的、可空的结构逃逸到纯函数的世界中 :) - Konstantin Konstantinov

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