65.9K
CodeProject 正在变化。 阅读更多。
Home

业务应用程序的新方式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (5投票s)

2019年11月30日

CPOL

11分钟阅读

viewsIcon

9674

downloadIcon

249

开发 LOB 应用程序的新方法

引言

RDO.Net 是一个开源框架,采用 MIT 许可,用于处理 .NET 平台上的数据,并包含以下库和工具

RDO.Net 是一个端到端解决方案,从数据库到用户界面,特别关注

  • 最佳平衡的数据层和业务逻辑层,兼顾可编程性和性能。告别对象-关系阻抗失配
  • 将数据库操作测试作为头等大事。
  • 一个一体化、完全可定制的 MVP 框架,用于处理表示逻辑,包括布局、数据绑定和数据验证,所有这些都以简洁的 C#/VB.NET 代码(无需 XAML)实现。您不再需要复杂的控件,如 ListBoxTreeViewDataGrid

提供了示例应用程序 AdventureWorksLT

您可以在此处下载或在 github 上查看。有关示例应用程序的详细描述可以在此处找到。

数据和业务逻辑的新方式

每个企业应用程序都由持久化数据存储支持,通常是关系型数据库。另一方面,面向对象编程(OOP)是企业应用程序开发的主流。根据Martin Fowler 的文章,目前有 3 种开发业务逻辑的模式

每种模式都有其优缺点,基本上是在可编程性和性能之间进行权衡。大多数人为了更好的可编程性而选择内存代码方式,这需要一个对象-关系映射 (ORM, O/RM, and O/R mapping tool),例如 Entity Framework。为了调和这两者付出了巨大的努力,但这仍然是计算机科学的越南战争,原因在于对 SQL 和 OOP 的误解。

误解

SQL 已过时

SQL 的起源可以追溯到 20 世纪 70 年代。自那时以来,IT 世界发生了变化,项目变得更加复杂,但 SQL 保持不变——或多或少。它能工作,但对于如今的现代应用程序开发来说并不优雅。大多数 ORM 实现,如 Entity Framework,试图封装操作数据所需的代码,这样您就不再使用 SQL。不幸的是,这是错误的,最终会导致泄漏的抽象

正如 Joel Spolsky 所提出的,泄漏的抽象定律指出

所有非平凡的抽象,在某种程度上都是泄漏的。

显然,RDBMS 和 SQL,作为您应用程序的基础,远非平凡。您不能期望将其抽象掉——您必须与之共存。大多数 ORM 实现都提供了本机 SQL 执行,原因就在于此。

OOP/POCO 的狂热

另一方面,OOP 是现代的,也是应用程序开发的主流。它被开发者广泛采用,以至于许多开发者下意识地认为 OOP 可以解决所有问题。此外,许多框架作者坚信,任何不支持 POCO 的框架都不是一个好的框架。

事实上,像任何技术一样,OOP 也有其局限性。IMO,最大的局限性是:OOP 仅限于本地进程,它不便于序列化/反序列化。每一个对象都通过其引用(地址指针)进行访问,而引用以及类型元数据和编译后的字节码(进一步指向类型描述符、vtable 等)是本地进程私有的。这一点很容易理解。

本质上,任何序列化的数据都是值类型,这意味着

  1. 要序列化/反序列化一个对象,需要一个引用转换器,无论是隐式的还是显式的。ORM 可以被认为是对象和关系数据之间的转换器。
  2. 随着对象复杂性的增加,转换器的复杂性也随之增加。特别是,类型元数据和编译后的字节码(对象的行为,或逻辑)对于转换来说是困难的,甚至是不可能的——最终,您实际上需要整个类型运行时。这就是为什么如此多的应用程序从领域驱动设计开始,却以贫血领域模型告终。
  3. 另一方面,关系数据模型本质上非常复杂,与其他数据格式(如 JSON)相比。这增加了转换器的又一层复杂性。ORM,被认为是对象和关系数据之间的转换器,迟早会遇到瓶颈。

这就是对象-关系阻抗失配的真正问题,如果您想在任意对象(POCO)和关系数据之间进行映射。不幸的是,几乎所有的 ORM 实现都遵循这条路径,没有一个能幸免。

新方式

当您使用关系型数据库时,使用 SQL/存储过程实现业务逻辑是最快的路径,因此可以获得最佳性能。缺点在于 SQL 的代码可维护性。另一方面,将业务逻辑实现为内存代码,在代码可维护性方面有很多优点,但在某些情况下可能会出现性能问题,最重要的是,它将导致上述的对象-关系阻抗失配。我们如何兼顾两者?

RDO.Data 就是这个问题的答案。您可以使用 C#/VB.NET 以类似存储过程或内存代码的方式编写业务逻辑,而独立于您的物理数据库。为此,我们正在将关系模式和数据实现为一个全面而简单的对象模型

以下数据对象提供了丰富的属性、方法和事件

  • Model/Model<T>:定义模型的元数据,以及声明式业务逻辑,如数据约束、自动计算字段和验证,这些都可以用于数据库和本地内存代码。
  • DataSet<T>:在本地存储分层数据,并充当业务逻辑的领域模型。它可以方便地与关系型数据库进行基于集合的操作(CRUD),或通过 JSON 与外部系统进行交换。
  • Db:定义数据库会话,其中包含
    • DbTable<T>:用于数据存储的持久化数据库表;
    • Db 类的实例方法,用于实现过程式业务逻辑,使用 DataSet<T> 对象作为输入/输出。业务逻辑可以是简单的 CRUD 操作,也可以是复杂的计算,如 MRP 计算。
      • 您可以使用 DbQuery<T> 对象将数据封装为可重用的视图,以及/或使用临时的 DbTable<T> 对象来存储中间结果,以编写类似存储过程的、基于集合的操作(CRUD)业务逻辑。
      • 另一方面,DataSet<T> 对象除了可以用作过程式业务逻辑的输入/输出外,还可以用于编写内存代码以在本地实现业务逻辑。
      • 由于这些对象与数据库无关,您可以轻松地将业务逻辑移植到不同的关系型数据库。
  • DbMock<T>:轻松地在隔离、已知状态下模拟数据库进行测试。

以下是一个业务逻辑层实现的示例,用于处理 AdventureWorksLT 示例中的销售订单。请注意,为简化起见,此示例仅包含 CRUD 操作,RDO.Data 能够做的远不止于此。

public async Task<dataset<salesorderinfo>> GetSalesOrderInfoAsync(_Int32 salesOrderID,
    CancellationToken ct = default(CancellationToken))
{
    var result = CreateQuery((DbQueryBuilder builder, SalesOrderInfo _) =>
    {
        builder.From(SalesOrderHeader, out var o)
            .LeftJoin(Customer, o.FK_Customer, out var c)
            .LeftJoin(Address, o.FK_ShipToAddress, out var shipTo)
            .LeftJoin(Address, o.FK_BillToAddress, out var billTo)
            .AutoSelect()
            .AutoSelect(c, _.Customer)
            .AutoSelect(shipTo, _.ShipToAddress)
            .AutoSelect(billTo, _.BillToAddress)
            .Where(o.SalesOrderID == salesOrderID);
    });

    await result.CreateChildAsync(_ => _.SalesOrderDetails, (DbQueryBuilder builder,
        SalesOrderInfoDetail _) =>
    {
        builder.From(SalesOrderDetail, out var d)
            .LeftJoin(Product, d.FK_Product, out var p)
            .AutoSelect()
            .AutoSelect(p, _.Product)
            .OrderBy(d.SalesOrderDetailID);
    }, ct);

    return await result.ToDataSetAsync(ct);
}

public async Task<int?> CreateSalesOrderAsync(DataSet<SalesOrderInfo> salesOrders,
    CancellationToken ct)
{
    using (var transaction = BeginTransaction())
    {
        salesOrders._.ResetRowIdentifiers();
        await SalesOrderHeader.InsertAsync(salesOrders, true, ct);
        var salesOrderDetails = salesOrders.GetChild(_ => _.SalesOrderDetails);
        salesOrderDetails._.ResetRowIdentifiers();
        await SalesOrderDetail.InsertAsync(salesOrderDetails, ct);

        await transaction.CommitAsync(ct);
        return salesOrders.Count > 0 ? salesOrders._.SalesOrderID[0] : null;
    }
}

public async Task UpdateSalesOrderAsync(DataSet<SalesOrderInfo> salesOrders,
    CancellationToken ct)
{
    await EnsureConnectionOpenAsync(ct);
    using (var transaction = BeginTransaction())
    {
        salesOrders._.ResetRowIdentifiers();
        await SalesOrderHeader.UpdateAsync(salesOrders, ct);
        await SalesOrderDetail.DeleteAsync
              (salesOrders, (s, _) => s.Match(_.FK_SalesOrderHeader), ct);
        var salesOrderDetails = salesOrders.GetChild(_ => _.SalesOrderDetails);
        salesOrderDetails._.ResetRowIdentifiers();
        await SalesOrderDetail.InsertAsync(salesOrderDetails, ct);

        await transaction.CommitAsync(ct);
    }
}

public Task<int> DeleteSalesOrderAsync(DataSet<SalesOrderHeader.Key> dataSet,
    CancellationToken ct)
{
    return SalesOrderHeader.DeleteAsync(dataSet, (s, _) => s.Match(_), ct);
}

RDO.Data 的特性、优点和缺点

RDO.Data 的特性

  • 全面的分层数据支持
  • 丰富的声明式业务逻辑支持:约束、自动计算字段、验证等,同时支持服务器端和客户端
  • 全面的表间连接/查找支持
  • 通过 DbQuery<T> 对象实现的可重用视图
  • 通过临时 DbTable<T> 对象实现中间结果存储
  • 全面的 JSON 支持,由于无需反射,性能更佳
  • 完全可自定义的数据类型和用户定义函数
  • 内置数据库操作日志记录
  • 广泛的测试支持
  • 丰富的运行时工具支持
  • 还有更多...

优点

  • 所有场景的统一编程模型。您可以完全控制数据层和业务逻辑层,没有神秘的黑匣子。
  • 您的数据层和业务逻辑层在可编程性和性能之间达到了最佳平衡。提供了丰富的数据对象,消除了对象-关系阻抗失配。
  • 数据层和业务逻辑层测试是头等大事,可以轻松执行——您的应用程序可以更健壮,更能适应变化。
  • 易于使用。API 简洁直观,并提供丰富的运行时工具支持。
  • 功能丰富且轻量级。运行时 DevZest.Data.dll 的大小不到 500KB,而 DevZest.Data.SqlServer 仅为 108KB,没有任何第三方依赖。
  • 其他应用程序层(如表示层)可以方便地使用丰富的元数据。

缺点

  • 这是新的。虽然 API 设计简洁直观,但您或您的团队仍需要一些时间来熟悉框架。特别是,您的领域模型对象分为两部分:Model/Model<T> 对象和 DataSet<T> 对象。这并不复杂,但您或您的团队可能需要一些时间来适应。
  • 要最好地利用 RDO.Data,您的团队应该熟悉 SQL,至少达到中级水平。这是需要考虑团队构成的情况之一——人员会影响架构决策。
  • 虽然数据对象轻量级,但与 POCO 对象相比,会存在一些开销,尤其是在最简单的情况下。在性能方面,它可能接近原生存储过程,但无法超越。

表示层的新方式

表示逻辑很复杂——它负责弥合计算机与人之间的差距,这是计算机科学中一个巨大(也许是最大)的挑战。要将表示逻辑封装到单独的层中,我们需要从一开始就仔细规划。不幸的是,现有的 MVVM 实现(以及其他类似框架)都是事后才设计的。

反模式

POCO 的狂热,再次

当模型是任意对象(POCO)时,将模型隐藏在视图之外是根本不可能的。表示层对此无能为力,在很多情况下,它只是通过聚合暴露模型对象,而没有任何增值。

另一方面,数据模型不能是 100% POCO。例如,对于大多数数据模型,INotifyPropertyChanged 接口是必需的,如果您想绑定到自定义验证错误,则需要 IDataErrorInfo 接口。由于这些接口必须由所有数据模型实现,因此它们必须非常简单。

最终,您的表示层对数据模型无能为力。

复杂控件

复杂控件,如 DataGrid,具有非常复杂的表示逻辑。由于这些控件是在没有现有表示层的情况下构建的,因此这些逻辑自然地封装在控件本身——视图——中。这使得表示层处于尴尬的境地:对于没有复杂视图状态的简单控件(如 TextBlock),它几乎没有什么工作要做;对于复杂控件(如 DataGrid),控件已经完成了工作。

最终,您的表示层对视图也无能为力。

总而言之,如果表示层是事后才设计的,那么留给实现的房间就会很少,尤其是在抽象层面。

新方式

感谢 RDO.Data,它提供了丰富的数据对象和模型数据的分离,我们现在有了实现全面的模型-视图-演示器 (MVP) 模式的基础。以下是 RDO.WPF MVP 的架构

  • 模型包含数据值和数据逻辑,如计算和验证,存储在 DataSet<T> 对象中。DataSet<T> 对象包含 DataRow 对象和 Column 对象的集合,类似于二维数组。模型提供事件来通知数据更改,它完全不了解演示器的存在。
  • 视图包含直接与用户交互的 UI 组件。这些 UI 组件被设计得尽可能简单,所有表示逻辑都在演示器中实现。尽管有容器 UI 组件(如 DataViewBlockViewRowView),或者依赖于演示器中实现的表示逻辑的控件(如 ColumnHeader),但大多数 UI 元素都不知道演示器的存在。
  • 演示器是连接模型和视图的核心,它实现了以下表示逻辑
    • 选择、过滤和分层分组
    • UI 元素生命周期管理和数据绑定
    • 编辑和验证
    • 布局和 UI 虚拟化
  • 由于演示器广泛且独占地支持呈现数据集合,因此所有复杂的控件(派生自 System.Windows.Controls.ItemsControl 的控件,如 ListBoxDataGrid)都不再是必需的。通过使用 RDO.WPF,您的应用程序只需要处理简单的控件,如 TextBlockTextBox,通过数据绑定,以统一的方式。

由于演示器广泛且独占地支持呈现数据集合,因此所有复杂的控件(派生自 System.Windows.Controls.ItemsControl 的控件,如 ListBoxDataGrid)都不再是必需的。通过使用 RDO.WPF,您的应用程序只需要处理简单的控件,如 TextBlockTextBox,通过数据绑定,以统一的方式。

只需将您的数据演示器派生自 DataPresenter<T> 类(其中包含表示逻辑实现),并在视图中放置一个 DataView,您就可以立即获得所有表示逻辑,如过滤、排序、分组、选择、数据绑定、编辑和布局,而无需使用任何复杂控件。例如,以下代码

namespace DevZest.Samples.AdventureWorksLT
{
    partial class SalesOrderWindow
    {
        private class DetailPresenter : DataPresenter<SalesOrderInfoDetail>,
            ForeignKeyBox.ILookupService, DataView.IPasteAppendService
        {
            public DetailPresenter(Window ownerWindow)
            {
                _ownerWindow = ownerWindow;
            }

            private readonly Window _ownerWindow;

            protected override void BuildTemplate(TemplateBuilder builder)
            {
                var product = _.Product;
                builder.GridRows("Auto", "20")
                    .GridColumns("20", "*", "*", "Auto", "Auto", "Auto", "Auto")
                    .WithFrozenTop(1)
                    .GridLineX(new GridPoint(0, 2), 7)
                    .GridLineY(new GridPoint(2, 1), 1)
                    .GridLineY(new GridPoint(3, 1), 1)
                    .GridLineY(new GridPoint(4, 1), 1)
                    .GridLineY(new GridPoint(5, 1), 1)
                    .GridLineY(new GridPoint(6, 1), 1)
                    .GridLineY(new GridPoint(7, 1), 1)
                    .Layout(Orientation.Vertical)
                    .WithVirtualRowPlacement(VirtualRowPlacement.Tail)
                    .AllowDelete()
                    .AddBinding(0, 0, this.BindToGridHeader())
                    .AddBinding(1, 0, product.ProductNumber.BindToColumnHeader("Product No."))
                    .AddBinding(2, 0, product.Name.BindToColumnHeader("Product"))
                    .AddBinding(3, 0, _.UnitPrice.BindToColumnHeader("Unit Price"))
                    .AddBinding(4, 0, _.UnitPriceDiscount.BindToColumnHeader("Discount"))
                    .AddBinding(5, 0, _.OrderQty.BindToColumnHeader("Qty"))
                    .AddBinding(6, 0, _.LineTotal.BindToColumnHeader("Total"))
                    .AddBinding(0, 1, _.BindTo<rowheader>())
                    .AddBinding
                        (1, 1, _.FK_Product.BindToForeignKeyBox(product, GetProductNumber)
                        .MergeIntoGridCell(product.ProductNumber.BindToTextBlock())
                        .WithSerializableColumns(_.ProductID, product.ProductNumber))
                    .AddBinding(2, 1, product.Name.BindToTextBlock().AddToGridCell()
                        .WithSerializableColumns(product.Name))
                    .AddBinding(3, 1, _.UnitPrice.BindToTextBox().MergeIntoGridCell())
                    .AddBinding
                        (4, 1, _.UnitPriceDiscount.BindToTextBox(new PercentageConverter())
                        .MergeIntoGridCell(_.UnitPriceDiscount.BindToTextBlock("{0:P}")))
                    .AddBinding(5, 1, _.OrderQty.BindToTextBox().MergeIntoGridCell())
                    .AddBinding(6, 1, _.LineTotal.BindToTextBlock("{0:C}").AddToGridCell()
                        .WithSerializableColumns(_.LineTotal));
            }

            private static string GetProductNumber
                           (ColumnValueBag valueBag, Product.PK productKey,
                Product.Lookup productLookup)
            {
                return valueBag.GetValue(productLookup.ProductNumber);
            }

            bool ForeignKeyBox.ILookupService.CanLookup(CandidateKey foreignKey)
            {
                if (foreignKey == _.FK_Product)
                    return true;
                else
                    return false;
            }

            void ForeignKeyBox.ILookupService.BeginLookup(ForeignKeyBox foreignKeyBox)
            {
                if (foreignKeyBox.ForeignKey == _.FK_Product)
                {
                    var dialogWindow = new ProductLookupWindow();
                    dialogWindow.Show(_ownerWindow, foreignKeyBox,
                        CurrentRow.GetValue(_.ProductID));
                }
                else
                    throw new NotSupportedException();
            }

            protected override bool ConfirmDelete()
            {
                return MessageBox.Show(string.Format
                       ("Are you sure you want to delete selected {0} rows?", 
                       SelectedRows.Count), "Delete", 
                       MessageBoxButton.YesNo) == MessageBoxResult.Yes;
            }

            bool DataView.IPasteAppendService.Verify(IReadOnlyList<ColumnValueBag> data)
            {
                var foreignKeys = DataSet<Product.Ref>.Create();
                for (int i = 0; i < data.Count; i++)
                {
                    var valueBag = data[i];
                    var productId = valueBag.ContainsKey(_.ProductID)
                        ? valueBag[_.ProductID] : null;
                    foreignKeys.AddRow((_, dataRow) =>
                    {
                        _.ProductID.SetValue(dataRow, productId);
                    });
                }

                if (!App.Execute((db, ct) =>
                    db.LookupAsync(foreignKeys, ct), Window.GetWindow(View), out var lookup))
                    return false;

                Debug.Assert(lookup.Count == data.Count);
                var product = _.Product;
                for (int i = 0; i < lookup.Count; i++)
                {
                    data[i].SetValue(product.Name, lookup._.Name[i]);
                    data[i].SetValue(product.ProductNumber, lookup._.ProductNumber[i]);
                }
                return true;
            }
        }
    }
}

将生成以下可编辑的数据网格 UI,并实现外键查找和剪贴板支持

最终,您拥有 ALL 的表示逻辑,以 100% 强类型、高度可重用的简洁代码实现。

下一步

RDO.Net 是一个全面的框架,大约有 3800 个 API。不要被这个数字吓倒,这是因为它涵盖了许多特性。而且每个特性都非常简单!

从我们的循序渐进教程开始,了解它的易用性和趣味性!

历史

  • 2019 年 11 月 30 日:首次发布
© . All rights reserved.