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

使用 .NET 和 IronPython 编写脚本业务规则,第一部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2015 年 5 月 29 日

CPOL

12分钟阅读

viewsIcon

19544

downloadIcon

407

介绍用于动态脚本的 Aim 框架

引言 - 什么是业务规则?

已添加 第二部分,其中包含所有单元测试代码以及一个完整的 WPF 应用程序。

你可能遇到过这种情况。你已经部署了新版本的系统,现在销售团队有一个重要的客户 Acme Corp。他们给你发了这样一封电子邮件:

“当用户创建新的产品库存记录时,是否可以自动将序列号设置为部门缩写,后跟一个连字符,然后是一个唯一的六位数字?哦,如果产品是船,他们还需要添加一个 -01 后缀。”

那么,现在怎么办?

如果只需浏览到你的 Web 应用程序,以系统管理员身份登录,然后编写类似以下内容的内容,会怎么样?

import clr
clr.AddReference('Sigma.Data.Helpers')
from Sigma.Data.Helpers import *

##
## Helper method
##
def assignSN(prod, prod_type):
    div = prod_type.CompanyDivision
    num = Atomic.GetNextNumber()
    suffix = ''
    if prod_type.Name.lower() == 'boat':
        suffix = '-01'
    sn = '{div}-{num}{suffix}'.format(
        div = div.Abbrev,
        num = str(num).zfill(6),
        suffix = suffix)
    prod.SerialNumber = sn

##
## Saving a product inventory record, need to set S/N
## according to Acme Corp. rules.
##
def onInform(sender, e):
    prod = sender
    if e.Info == 'Saving':
        sn = prod.SerialNumber
        prod_type = prod.ProductType
        if prod_type and not sn:
            assignSN(prod, prod_type)

是什么,而不是怎么做

如果你是大多数开发者,你会想:“我将如何做到这一点?序列号字段只是一个文本字符串——我不知道如何为所有可能的情况进行格式化。”

脚本业务规则可以介入处理“怎么做”。一旦你理解了这一点,这个概念就非常自由。你可以将精力集中在你的系统需要捕获的内容、需要触发的重要事件以及最重要的对象之间的关系上。

Linux 的创建者 Linus Torvalds 说过:“糟糕的程序员担心代码。好的程序员担心数据结构及其关系。”一旦你看到业务规则的动态力量,你就会明白这句话的智慧。更重要的是,你将拥有现成的工具来处理那些棘手的需求,这样你就可以专注于你的系统设计以及事物如何交互。

业务规则将数据与行为分离

序列号的分配过程是众所周知的:某人或某事创建了一个由数字和字符组成的字符串,我们需要存储它。这是序列号分配的数据录入部分。最有可能的是,这已经内置到你的代码中。

行为是不同的部分。当 Acme Corp 创建产品库存记录时,他们的行为规定序列号必须采用特定的格式,其中包含前缀和原子递增的数字。

业务规则允许你专注于已集成到代码中的基本流程。与行为的连接变得就像你的已集成、已编译的代码说“这是关于即将发生或刚刚发生的事情的一些信息”。

业务规则的正式定义

业务规则是对公司日常需求某个方面的声明。许多关于业务规则的书籍会非常学术地讨论约束、派生、事实、断言等。虽然这些概念很有用,但它们也相当无聊。我们宁愿绕过这些声明,然后开始编写代码!

一些关于业务规则的书籍过于宽泛。你会看到诸如“一个员工可以分配给多个团队”之类的“业务规则”示例。好吧——是的。但你在设计系统时已经涵盖了这一点。这实际上更像是我们所说的“已集成”规则:你不会为了 Acme Corp 而更改该需求,但会为 Widgets Inc. 保留它。

示例:通过需求收集发现的业务规则

当一个货物被标记为已接收时,我们必须将带有相应采购订单号的电子邮件发送给会计部门,以便他们将其输入到 MRP 中。

这里有几个“事物”和几个“动作”:货物、标记、接收、发送、电子邮件、采购订单、会计、输入、MRP。

业务规则声明可以帮助你发现流程中的差距。例如,如果你的系统目前没有跟踪货物的方法,它就需要该功能。

在这种情况下,还会出现其他内容:结尾有一个“为什么”的陈述:“以便他们将其输入到 MRP 中”。“为什么”的陈述对于识别可能的未来增强功能或让软件团队提出替代方案很重要。例如,开发团队可能会建议直接修改 MRP 系统,以便会计部门不必手动复制电子邮件中的数据。

我们的业务规则定义

所以这就是正式定义。非常枯燥乏味,对于我们只需要完成某事的情况并没有多大实际用处。为了本文系列的缘故,这是我们的定义:

业务规则是代码,它根据应用程序上下文的特定需求,实现某个工作单元或信息流的动态可变性

我们希望保持定义简短,但更具体和扩展的版本如下:“业务规则是脚本事件处理程序或由用户发起的命令触发的脚本,该脚本可以在运行时在系统本身内重新定义,以更改系统的行为。

背景 - 它是如何开发的

多年前,我开发了一个产品配置系统。该系统的变动量非常之大,几乎令人难以招架。它是一个在线工具,用于配置从船只到消防员服装再到医疗设备的各种产品。我的每个客户都有关于事物交互方式的大量规则:此选项仅适用于另外两个选项,此功能取决于该功能,等等。

我开发了一个相当健壮的“声明式规则系统”,可以处理大约 80% 的复杂性。但我们都知道 80/20 定律,对吧?是的——我花了太多时间试图克服那 20% 难以遵循规则的部分。

本系列中将展示的代码源于那 20%:处理它的唯一方法是编写特定于我每个客户的代码。与大多数子系统一样,这个子系统从处理特定需求发展到更通用的目的,因为我理解了我所发现的惊人力量。

我可以自信地报告,截至今天,它几乎可以用于任何基于 .NET 的系统,无论是桌面还是 Web。它可以用于多租户系统。而且,它已被完全重写,以消除其“产品配置”根源的每一个痕迹。

一个非常相似(但更早)的代码分支目前部署在几个 Web 应用程序中。完全相同的 DLL 也用于一个吞吐量极高的装配线光学检测系统。我开发所有这些系统的经验证明了像这样的系统具有广泛的适用性。

图片:在多租户 Web 应用程序中定义的通知维护业务规则。每个租户关于如何处理标记和删除通知的规则都不同。

图片:在高吞吐量 WPF 检测系统中定义的文件系统和数据库维护业务规则。客户分布在全球各地的每个地点都有关于在自动清理任务启动之前可以存储多少数据以及什么类型的数据的不同规则。

目标

该项目的目标很简单——使业务规则能够像你的数据存储中的其他数据模型类一样简单地定义,与产品、客户、销售等一起存储。它们像任何其他域实体一样被编辑、保存和检索。

工作原理

Aim.Scripting 库(包含在下载的 DLL 中)定义了名为 IRuntimeExecuter 的特殊接口的实现。该类 DefaultExecuter 处理两项任务:连接到标准的 .NET 事件,以及允许执行任意命令

从 C# 代码触发一个脚本连接的事件处理程序非常简单。它需要一行代码,如下所示:

public event EventHandler MyEvent;

// This is the script-connected handler signature for events that
// are directly defined in this class.
protected virtual void OnMyEvent(EventArgs e)
{
    e.FireWithScriptListeners(() => MyEvent, this);
}

或者像这样:

// Here we are overriding a handler. The MyEvent event is defined in
// the base class. Note the slightly different signature of the
// extension method call.
protected override void OnMyEvent(EventArgs e)
{
    e.FireWithScriptListeners(base.OnMyEvent, this);
}

你甚至不需要对 e 和事件执行空检查 - 扩展方法会为你完成。

处理此事件的 IronPython 脚本方法更简单:

def onMyEvent(sender, e):
    pass

这些 Python 脚本存储在一个实现 IScriptDefinition 接口的类中。不用担心——实现它非常容易,下载中的解决方案(以及下面的代码)确切地展示了如何做到这一点。IScriptDefinition 接口定义了模块的名称、脚本的文本以及一个“类型键”,该键指示脚本应该监听哪些类型的类型和事件。简而言之,所有这些都是标准的文本数据,可以非常简单地存储在你选择的任何数据存储中。

Python 签名始终是小写的 on,后跟事件名称,然后是 sender, e 作为参数列表。因此,一个名为 SomethingHappened 的 .NET 事件将连接到一个 Python 签名:

def onSomethingHappened(sender, e):
    ## put code here to handle the event
    pass

如果你需要 Python 入门教程,我在这里发布了一个:Python Primer官方 Python 文档非常出色且内容深入。你还想尽可能多地了解 .NET 和 Python 集成,这就是 IronPython 的全部内容。为此,请前往IronPython on CodePlex

这是 BizRuleDataModel 的完整定义,它是我们在本文附带的测试项目中的 IScriptDefinition 的实现。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Aim;
using Aim.Scripting;

namespace BizRules.DataModels
{
    /// <summary>
    /// The IScriptDefinition implementation for our domain. This is a
    /// data model class just like Product, Customer, etc.
    /// </summary>
    [
    Serializable()
    ]
    public partial class BizRuleDataModel : DomainBase, IScriptDefinition
    {
        private string m_Name;
        private string m_Scripts;
        private string m_TypeKey;
        private ICollection<BizRuleCmdDataModel> m_Commands =
            new List<BizRuleCmdDataModel>();

        /// <summary>
        /// Convenience method to find a script definition by its name.
        /// </summary>
        /// <param name="moduleName"></param>
        /// <returns></returns>
        public static BizRuleDataModel FindByModuleName( string moduleName )
        {
            return RepositoryProvider.Current
                .GetRepository<BizRuleDataModel>().GetAll()
                .FirstOrDefault( x => x.Name == moduleName );
        }

        /// <summary>
        /// The stored name. This is returned by GetModuleName().
        /// </summary>
        public string Name
        {
            get
            {
                return Get( m_Name );
            }
            set
            {
                Set( ref m_Name, value, "Name" );
            }
        }

        /// <summary>
        /// The stored Python scripts. This is returned by GetScripts().
        /// </summary>
        public string Scripts
        {
            get
            {
                return Get( m_Scripts );
            }
            set
            {
                Set( ref m_Scripts, value, "Scripts" );
            }
        }

        /// <summary>
        /// The stored type key. This is used to indicate what types this
        /// script definition should connect to.
        /// </summary>
        public string TypeKey
        {
            get
            {
                return Get( m_TypeKey );
            }
            set
            {
                Set( ref m_TypeKey, value, "TypeKey" );
            }
        }

        /// <summary>
        /// A collection of predefined commands that can map to functions
        /// defined in the scripts. These can be shown in a drop-down list
        /// for user selection. When the user selects a command, we can
        /// pop up a "parameter collector" window that collects argument
        /// information from the user, then when they press the "OK" or
        /// "Execute" button, we can call the Python script with the collected
        /// parameters.
        /// </summary>
        public ICollection<BizRuleCmdDataModel> Commands
        {
            get
            {
                return Get( m_Commands );
            }
            set
            {
                Set( ref m_Commands, value, "Commands" );
            }
        }

        /// <summary>
        /// Overridden. When this script definition changes, we need to
        /// notify the ScriptFactory.
        /// </summary>
        public override void Refresh()
        {
            ScriptFactory.Current.Invalidate( this );
        }

        #region IScriptDefinition Members...

        /// <summary>
        /// Returns the Name property.
        /// </summary>
        /// <returns></returns>
        public string GetModuleName()
        {
            return Name;
        }

        /// <summary>
        /// Returns the Scripts property.
        /// </summary>
        /// <returns></returns>
        public string GetScripts()
        {
            return Scripts;
        }

        /// <summary>
        /// Returns the TypeKey property.
        /// </summary>
        /// <returns></returns>
        public string GetTypeKey()
        {
            return TypeKey;
        }
        #endregion ...IScriptDefinition Members
    }
}

如你所见,实现 IScriptDefinition 仅仅是转发三个字符串属性的问题。

整个系统需要另外两样东西来实现基本功能。Aim.Scripting.ScriptFactory 需要知道在哪里可以找到它将使用的脚本定义。为此,我们需要继承 ScriptProviderBase 并重写一个方法:

using System;
using System.Collections.Generic;
using Aim.Scripting;

namespace BizRules.DataModels.Tests
{
    /// <summary>
    /// Our script provider implementation.
    /// </summary>
    public class ScriptProvider : ScriptProviderBase
    {
        /// <summary>
        /// This is the only method that must be overridden. The script factory must know
        /// where to find the script definitions that it will use.
        /// </summary>
        /// <returns></returns>
        public override IEnumerable<IScriptDefinition> GetAllDefinitions()
        {
            return RepositoryProvider.Current
                .GetRepository<BizRuleDataModel>().GetAll();
        }
    }
}

现在我们有了 ScriptProvider,我们需要将其注册到 ScriptFactory。这可以在你的“组合根”的任何地方完成(在我们的项目中,这是测试类的 Setup 方法)。

[TestInitialize]
public void Setup()
{
    //
    // Init script factory
    //
    ScriptFactory.Current.Initialize( () => new ScriptProvider() );
    
    // ... other init things
}

所以,总而言之,要使一切正常工作,我们需要:

  • 引发“脚本连接”事件的类,使用 FireWithScriptListeners 扩展方法之一。这些可以是任何 .NET 类,你可以定义它们或从中继承。
  • 一个实现 IScriptDefinition 接口的持久性(数据模型、域模型、实体、活动记录等)类,它将与其他数据一起存储在域中。
  • 一个派生自 ScriptProviderBase 的类,它重写了 GetAllDefinitions() 方法。此类应在你的组合根中或其附近定义;也就是说,它可能应该在你的解决方案的 UI 项目中定义,无论是控制台、Web、WPF、窗体还是其他。
  • 在你的“组合根”(测试设置方法、Web Global.asax、WPF 引导程序等)类中有一行代码,它会将你的脚本提供程序实现注册到 ScriptFactory
  • 当然,还有一些实际的 Python 代码存储在你实现的 IScriptDefinition 中。

使用代码

代码下载是一个包含三个项目的解决方案:

  • 一个定义“数据模型”和“存储库”的项目。该项目定义了 BizRuleDataModelProductDataModelCustomerDataModel 等——所有在单元测试项目中用作数据存储的类。
    • 仔细查看这里定义的 ListRepository<T> 类。这就是你将通过 RaiseInform(...) 方法代表其他对象引发事件的地方。
  • 一个带有虚假、无操作的 EmailingProvider 环境上下文实现的“服务”项目。
  • 最后,实际的单元测试项目。

这是 EventTests.cs 中的代码。

using System;
using System.Linq;
using Aim;
using Aim.Scripting;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace BizRules.DataModels.Tests
{
    /// <summary>
    /// These tests have to do with "script-connected events".
    /// </summary>
    [TestClass]
    public class EventTests
    {
        private const string PROMO_CODE = "JULY";

        /// <summary>
        /// Init the script factory, load the repos with some test data.
        /// </summary>
        [TestInitialize]
        public void Setup()
        {
            //
            // Init script factory
            //
            ScriptFactory.Current.Initialize( () => new ScriptProvider() );
            //
            // Save some stuff to the "database"
            //
            var rp = RepositoryProvider.Current;
            //
            // Create some products
            //
            var pRepo = rp.GetRepository<ProductDataModel>();
            var p = pRepo.Create();
            p.Name = "Marbles";
            p.Price = 1m;
            pRepo.Save( p );
            p = pRepo.Create();
            p.Name = "Jacks";
            p.Price = 10m;
            pRepo.Save( p );
            p = pRepo.Create();
            p.Name = "Spam";
            p.Price = 5m;
            pRepo.Save( p );
            p = pRepo.Create();
            p.Name = "Eggs";
            p.Price = 4.50m;
            pRepo.Save( p );
            //
            // Create some business rules
            //
            var brRepo = rp.GetRepository<BizRuleDataModel>();
            //------------------------
            var br = brRepo.Create();
            br.Name = "Forwarded Events";
            br.TypeKey = ScriptFactory.Current.CreateTypeKey( typeof( ForwardedEvents ) );
            br.Scripts =
@"

##
## Got an Inform event via ForwardedEvents. e.Item will have a reference
## to the forwarded object that we want to work with.
##
def onInform(sender, e):
    e.Item.Something = e.Info

";
            brRepo.Save( br );
            //------------------------
            br = brRepo.Create();
            br.Name = "Sale Events";
            br.TypeKey = ScriptFactory.Current.CreateTypeKey( typeof( SaleDataModel ) );
            br.Scripts =
@"

##
## Something happened. Set the Notes property so we know
## this was called.
##
def onSomeEvent(sender, e):
    sender.Notes = 'Hello'

##
## Give everyone the promo, we're feeling generous
##
def onPropertyChanged(sender, e):
    if e.PropertyName == 'SaleNumber':
        sender.PromoCode = 'JULY'

##
## Do the JULY promo, 10% off
##
def onInform(sender, e):
    if e.Info == 'Saving':
        if sender.PromoCode and sender.PromoCode.lower() == 'july':
            for item in sender.LineItems:
                product = item.Product
                if product:
                    item.Price = product.Price * 0.9
        else:
            for item in sender.LineItems:
                product = item.Product
                if product:
                    item.Price = product.Price

##
## Do something that is impossible without script handlers!
##
def onDeserialized(sender, e):
    sender.PromoCode = None

";
            brRepo.Save( br );
        }

        /// <summary>
        /// This test demonstrates the concept of "event forwarding". This is
        /// a pattern you can use when you need to raise events about something,
        /// but you don't have access to the code for the object, or you cannot
        /// derive from the class.
        /// </summary>
        [TestMethod]
        public void PocoCanHookToScriptEventsViaForwarding()
        {
            string info = "Hello";
            var p = new PocoDataModel();
            var events = new ForwardedEvents();
            events.RaiseInform( p, info );

            // =====
            // Event handler in biz rule should have set property on
            // the PocoDataModel.
            // =====
            Assert.IsNotNull( p.Something );

            // =====
            // Should have set the Something property on the PocoDataModel
            // to the Info string of the event args (Hello).
            // =====
            Assert.AreEqual( p.Something, info );
        }

        /// <summary>
        /// The FireWithScriptListeners extension methods should properly
        /// connect, call the scripted handler, and then disconnect the event,
        /// all in one line of code.
        /// </summary>
        [TestMethod]
        public void DynamicHandlersAreProperlyRemoved()
        {
            var sale = RepositoryProvider.Current.GetRepository<SaleDataModel>().Create();
            sale.RaiseSomeEvent();

            // =====
            // The script should have set the Notes property to a non-null value
            // =====
            Assert.IsNotNull( sale.Notes );

            // =====
            // Executer should have successfully disconnected after running event
            // =====
            Assert.AreEqual( sale.GetSomeEventHandlerCount(), 0 );
        }

        /// <summary>
        /// Can we write a script to handle the PropertyChanged event?
        /// </summary>
        [TestMethod]
        public void ScriptRecognizesPropertyChange()
        {
            var sale = RepositoryProvider.Current.GetRepository<SaleDataModel>().Create();
            sale.SaleNumber = "123";

            // =====
            // Script handler should have set the promo code
            // =====
            Assert.AreEqual( sale.PromoCode, PROMO_CODE );
        }

        /// <summary>
        /// Test something a little more complicated: Set pricing in a Sale
        /// object based on a promo code.
        /// </summary>
        [TestMethod]
        public void SavingSaleSetsPromoPricing()
        {
            var saleRepo = RepositoryProvider.Current.GetRepository<SaleDataModel>();
            var sale = saleRepo.Create();
            sale.SaleNumber = "456";

            // =====
            // Script handler should have set the promo code
            // =====
            Assert.AreEqual( sale.PromoCode, PROMO_CODE );

            //
            // Create a couple line items
            //
            var pRepo = RepositoryProvider.Current.GetRepository<ProductDataModel>();
            var marbles = pRepo.GetAll().FirstOrDefault( x => x.Name == "Marbles" );
            var jacks = pRepo.GetAll().FirstOrDefault( x => x.Name == "Jacks" );
            var spRepo = RepositoryProvider.Current.GetRepository<SaleProductDataModel>();
            var lineItem = spRepo.Create();
            lineItem.SaleId = sale.Id;
            lineItem.ProductId = marbles.Id;
            lineItem.Sequence = 0;
            sale.LineItems.Add( lineItem );
            lineItem = spRepo.Create();
            lineItem.SaleId = sale.Id;
            lineItem.ProductId = jacks.Id;
            lineItem.Sequence = 1;
            sale.LineItems.Add( lineItem );
            saleRepo.Save( sale );

            var productsPrice = marbles.Price + jacks.Price;

            // =====
            // Based on the promo code, the Inform saving event should have
            // set the price of each line item to 0.9 * product price.
            // =====
            Assert.AreEqual( productsPrice * 0.9m, sale.TotalPrice );

            sale.PromoCode = "NONE";
            saleRepo.Save( sale );

            // =====
            // Not a known promo code, should reset the price to the normal
            // product price.
            // =====
            Assert.AreEqual( productsPrice, sale.TotalPrice );
        }

        /// <summary>
        /// This is a fun one. Aim.NotifierBase defines a Deserialized
        /// event - which would normally be useless. But not with scripted
        /// handlers. Because scripted handlers connect, run, disconnect,
        /// they can be "connected" to objects that don't exist yet!
        /// </summary>
        [TestMethod]
        public void SomethingImpossibleLikeDeserializationEvents()
        {
            var saleRepo = RepositoryProvider.Current.GetRepository<SaleDataModel>();
            var sale = saleRepo.Create();
            sale.SaleNumber = "789";

            // =====
            // Script set the promo code, as per usual.
            // =====
            Assert.IsTrue( sale.PromoCode.IsNotNil() );

            var saleCopy = BinarySerializer.Copy( sale );

            // =====
            // We connected to an "impossible to connect to" event
            // handler that nulled out the promo code!
            // =====
            Assert.IsTrue( saleCopy.PromoCode.IsNil() );
        }
    }
}

解压解决方案(解压前不要忘记“解除阻止”zip 文件,然后将其复制到你的最终目标文件夹)。

打开 Aim.Scripting.Demo 解决方案。我使用 NuGet 的“包还原”,所以如果你遇到缺少 IronPython(唯一的外部依赖项)的错误,你需要从 Visual Studio 的程序包管理器控制台中运行:

PM> install-package ironpython

将“默认项目”设置为 BizRules.DataModels.Tests

代码是在 VS 2013 中开发的,但它以 .NET Framework 4.0 为目标,因此它应该可以在从 VS 2010 起的任何版本中打开。

要在打开解决方案后运行代码,请选择 Test --> Run --> All Tests

花时间查看测试中的代码——它有注释就是为了这个目的。尝试创建自己的数据模型、业务规则模型等。添加自己的测试类,并以提供的类为指导。创建自己的类和自己的事件,并实现所示的 FireWithScriptListeners。尽情发挥创意!

这些东西有什么用?

  • 自定义通信
    • 实现一个真正的 EmailingProvider。我推荐 Mandrill。Mandrill 服务 (mandrillapp.com) 非常棒,而这个包装器代码使其更容易使用。
  • 自定义日志记录
  • 自定义数据验证
  • 自定义数据格式化(如我们开头的序列号示例)
  • 自定义字段语义
    • 利用数据库中“自定义字段”的力量,使用业务规则赋予它们实际的含义。计算字段?没问题——只需查找几个不同的自定义字段的 ValueChanged 事件,然后在其中一个更改时设置计算字段的值。不再需要带有奇怪语法规则和难以理解的拖放操作员的愚蠢小编辑器,以及系统中到处都是“公式”。
  • 自定义、面向对象的安全性
  • 自定义维护
  • 自定义报表
    • 命令可以返回任何对象。因此,这包括 HTML、JSON、实体列表、数据集……
  • 自定义、基于事件的存档任务
    • def onBeforeDelete(sender, e) ...
  • 正向集成(基于事件)
    • 引用你客户的 ERP-供应商 API DLL(祝你好运……),并在每次事件表明你保存了新的销售、客户服务电话等时将数据推送到 ERP。或者更好的是,只需将其推送到你为该客户设置的自定义数据存储中,然后让他们处理提取。
  • 反向集成(基于命令)
    • 在你的网站产品页面上提供一系列命令,从你的 ERP 系统中提取某些信息(再次,上帝与你同在……)
  • 产品配置和事实基础(当然)
  • 全局或租户特定的设置

等等……

我们才刚开始

这是一个我充满热情的课题。我花了大约八年时间,断断续续地投入到我现在在这里分享的内容中。我发现它彻底改变了游戏规则。我现在思考的是我的 C# 代码中何处可能需要通知一个有趣的事情,而不是如何处理那个有趣的事情。一旦这个概念被你理解,希望它也能给你带来同样顿悟的时刻。

在未来的章节中,我们将更详细地介绍“有什么用”列表项。此外,我希望在第二部分中创建一个实际运行的应用程序,它真正地存储数据。目前还不确定是 Web 应用程序还是 WPF 应用程序。

历史

第一稿完成于 2015/05/29。

已添加 第二部分

© . All rights reserved.