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

Artisan.Orm 或 如何重新发明轮子

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (40投票s)

2016年11月15日

CPOL

11分钟阅读

viewsIcon

64303

downloadIcon

620

ADO.NET 微型 ORM 写入 SQL Server

本文是对文章中提出的主题的延续和发展

下一篇文章

引言

您是否读过文章“不要写自己的 ORM”?

我读过。读完后我感到非常沮丧。

但这种沮丧并没有持续多久,直到看到文章的第一个评论

程序员:如果你想写 ORM,请尽管写!

Nicholas de Lioncourt,你是我灵感的源泉和英雄!

于是,我写了属于自己的 ORM!又一次发明了轮子!

当然,它不是像 EF 那样完整而复杂的 ORM,它只是一个微型的 ORM,仅能与 SQL Server 配合使用,并具备基本的数据存储和读取功能。但至少它能做到并且做得很好!

本文将讲述我为何以及如何这样做。

为什么?

主要是因为我没有找到一种优雅的方式,能够使用现有的 ORM 框架来完成我想做的事情。*我承认我可能遗漏了某些方面。*

我对 ORM 的*主要要求*是性能。我听过一种观点,认为开发速度比系统性能略有下降更重要。而我也看到了后果:“我们的系统已经成熟,变得缓慢”。因此,在确保达到最佳性能之前,我无法安心。即使这会增加我的一些工作量,但之后我就可以安枕无忧了。

**其次**,无论是*数据库优先*方法还是*代码优先*方法都不够理想 — 第二种通常会受到影响。最好的解决方案是将应用程序领域模型留在 OOP 世界,数据库留在关系型世界,让 ORM 来弥合两者之间的差异。

**第三**,我仍然相信应用程序应该通过存储过程与数据库进行交互。*是的,我知道现在是 2016 年末了,但老派依然很酷!* 这有很多原因:性能、安全、可维护性。*请注意,我没有提及在 SP 中保留业务逻辑。* 还有一个原因是,存储过程是在*一次性保存和检索对象图*的有效方式

**第四**,接续上一段,我认为将*CRUD*命令以 SQL 文本形式写在应用程序代码中是邪恶的。当然不是绝对的邪恶,但至少是一种可怕的罪过。查询数据库的正确位置是存储库。即使在那里 — 也应该调用存储过程!*我猜这对我来说已经够自我鞭挞了。*

**第五**,现有的 ORM 通常假定应用程序领域模型和数据库从一开始就同步开发,因此对象对应表,属性名称和类型对应列名称和类型。但在我所处的环境中,情况并非总是如此。我经常发现自己处于这样的境地:应用程序领域模型已经创建,具有复杂的 OO 结构,需要为其创建持久化存储。或者另一种情况是,为非常老的遗留数据库创建新的 UI。在这种情况下,我希望有一个工具能够帮助我更轻松地集成这两个不同的部分。

上述观点是我的看法,当然不是最终真理。
您有权持有不同意见,甚至是相反的意见!:)

长话短说,这是我的初始目标

  • 最佳性能
  • 完全控制映射器中的数据转换
  • 最好是存储库模式
  • 数据库交互主要通过存储过程
  • 方便的多结果集读取
  • 轻松创建表值参数
  • 整体代码减少

走向我自己的 ORM

于是我从头开始,我指的是纯 ADO.NET。

例如,这是使用 ADO.NET 通过*Id*获取*User*的方法

public static User GetUserById(int id)
{
    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        using (var cmd = connection.CreateCommand())
        {
            cmd.CommandType = CommandType.Text;
            cmd.CommandText = "select Id, Login, Name, Email from dbo.Users where Id = @Id";
        
            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@Id",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Int,
                Value         = id
            });

            User user = null;

            connection.Open();

            using (var dr = cmd.ExecuteReader())
            {
                if (dr.Read())
                {
                    user = new User
                    {
                        Id    = (int)dr["Id"],
                        Login = (string)dr["Login"],
                        Name  = (string)dr["Name"],
                        Email = (string)dr["Email"]
                    };                
                }
            }

            connection.Close();
                    
            return user;
        }
    }
}

对于如此简单的示例,代码太多了。而保存整个对象图的代码会是怎样的呢!

数据到对象的映射

几乎所有的代码都可以重构为 `SqlConnection`、`SqlCommand` 和 `SqlDataReader` 类的扩展方法,除了 `using..ExecuteReader` 块内的部分,我指的是这部分

using (var dr = cmd.ExecuteReader())
{
    if (dr.Read())
    {
        user = new User
        {
            Id    = (int)dr["Id"],
            Login = (string)dr["Login"],
            Name  = (string)dr["Name"],
            Email = (string)dr["Email"]
        };                
    }
}

如果我们稍微重构一下,并将这个 `Func` 委托提取出来呢?

Func<SqlDataReader, User> createUser = (dr) => {
    return new User
    {
        Id    = (int)dr["Id"],
        Login = (string)dr["Login"],
        Name  = (string)drr["Name"],
        Email = (string)dr["Email"]
    };
}

那么 `using..ExecuteReader` 块的简化代码就变成了

using (var dr = cmd.ExecuteReader())
{
    if (dr.Read())
        createUser(dr);                
}

为什么这部分很重要?因为 ORM 通常基于如何创建和使用这个 `Func` 委托来创建 `` 对象。

这里有 “三猴子和狼的故事” 这篇文章,它展示了三种主要的方法来完成这项任务。

例如,Dapper 使用 `DynamicMethods` 生成这个 `Func` 委托并将其缓存起来。这使得性能与手动编写的 `Func` 委托几乎相同。我认为这是自动映射的最佳解决方案。

但是,对于数据这种关键事物,我对于黑盒有一种偏执。我希望对数据转换方式有更多的控制权。

因此,对于我的 ORM,我更喜欢手动编写的 `Func` 委托。这意味着我准备好并将数据到对象的转换代码保存在某个地方,而其他一些 ORM 则会自动生成它。

另外,对于 `SqlDataReader`,我更喜欢按序访问而不是按列名访问

Func<SqlDataReader, User> createUser = (dr) => {
    var i = 0;

    return new User
    {
        Id    = dr.GetInt32  (i)   ,
        Login = dr.GetString (++i) ,
        Name  = dr.GetString (++i) ,
        Email = dr.GetString (++i)
    };
} 

为什么按序访问更好?因为它更快。而且在这种情况下,列名甚至都不重要。

当然,总有混合顺序的风险,但为了安全起见,您可以创建一个视图,例如

create view dbo.vwUsers
as
(
    select
        Id		,
        [Login]	,
        Name	,
        Email
	from
		dbo.Users
);

并在获取 `User` 时始终使用 `select * from vwUsers`。
是的,我知道 `select * from` 是坏的实践,但伙计,我们在这里是为了打破规则的。

对象到数据的映射

除了数据到对象的映射方向,我还需要反向的,即对象到数据。

对象图保存的技术意味着为每个需要持久化的应用程序域类型创建用户定义的数据类型。

因此,对于一个域类型,我有了两个额外的映射委托:一个用于创建 `DataTable`,另一个用于将对象转换为 `DataRow`。

静态映射器类

这些 `Func` 委托应该存放在哪里?我的解决方案是创建一个带有*静态*方法的*静态*类,并用自定义的 `MapperFor` 属性来装饰此类,如下所示。

[MapperFor(typeof(User)]
public static class UserMapper 
{
    public static User CreateObject(SqlDataReader dr)
    {
        var i = 0;

        return new User 
        {
            Id     =  dr.GetInt32  (i)   ,
            Login  =  dr.GetString (++i) ,
            Name   =  dr.GetString (++i) ,
            Email  =  dr.GetString (++i)
        };
    }
    
    public static DataTable CreateDataTable()
    {
        return new DataTable("UserTableType")
            
            .AddColumn< Int32  >( "Id"     )
            .AddColumn< String >( "Login"  )
            .AddColumn< String >( "Name"   )
            .AddColumn< String >( "Email"  );
    }

    public static object[] CreateDataRow(User obj)
    {
        return new object[]
        {
            obj.Id     ,
            obj.Login  ,
            obj.Name   ,
            obj.Email
        };
    }
}

当应用程序启动时,一个特殊的 `MappingManager` 会迭代 `MapperFor` 属性,将*静态*方法转换为 `Func` 委托,并将其缓存到字典中,例如 `Dictionary>`。

扩展方法

一旦应用程序中有了包含 `Func` 委托的缓存,并且可以通过类型获取它,那么所有例行的 `SqlCommand` 或 `SqlDataReader` 执行调用都可以重构为泛型扩展方法,例如

cmd.ReadTo<User>();

cmd.ReadToList<User>();

cmd.AddTableParam("@Records", records);

dr.ReadTo<string>()

dr.ReadToArray<int>()

dr.ReadToDictionary<int, User>()

自动映射

当对象的属性/列名、数量和类型与数据库查询匹配时,就可以安全地使用自动映射而无需编写静态映射器。我的 ORM 提供了几种用于这些情况的扩展方法。

cmd.ReadAs<User>();

cmd.ReadAsList<User>();

dr.ReadAsArray<Record>()

`ReadAs` 方法使用基于表达式树编译的自动映射。

  • 当 `SqlDataReader` 从结果集中读取第一行时,它会调用*表达式树*生成器 ,该生成器使用 API。
  • 表达式树生成器会在 `SqlDataReader` 中查找列名,并将它们与目标对象的属性进行匹配。
  • 然后,表达式树会被编译成 `Func` 映射委托。
  • 此映射委托被缓存到一个具有复合字符串键的字典中。
  • 复合键由 `SqlCommand` 文本和目标对象的完全限定类型名称组成。
  • 因此,每次后续的 `SqlDataReader` 读取都会使用已编译并缓存的映射委托。

所以自动映射的性能几乎与手动编写的映射器相同。
该死的,我成了黑盒的创造者。我是在滑向黑暗面吗?

读取方法

与数据库最常见的交互是数据获取。因此,映射器所需和自动映射的 `Read` 方法集合成为了我 ORM 的主要部分。

Read-method 描述
ReadTo<T> 使用现有映射器读取单个值或对象
ReadToAsync<T> 使用现有映射器异步读取单个值或对象
ReadAs<T> 使用自动映射读取单个对象
ReadAsAsync<T> 使用自动映射异步读取单个对象
ReadToList<T> 使用现有映射器读取值或对象的列表
ReadToListAsync<T> 使用现有映射器异步读取值或对象的列表
ReadAsList<T> 使用自动映射读取对象列表
ReadAsListAsync<T> 使用自动映射异步读取对象列表
ReadToArray<T> 使用现有映射器读取值或对象的数组
ReadToArrayAsync<T> 使用现有映射器异步读取值或对象的数组列表
ReadAsArray<T> 使用自动映射读取对象数组列表
ReadAsArrayAsync<T> 使用自动映射异步读取对象数组列表
ReadToObjectRow<T> 使用现有映射器读取单个*ObjectRow*
ReadToObjectRowAsync<T> 使用现有映射器异步读取单个 `ObjectRow`
ReadAsObjectRows 使用自动映射读取 `ObjectRows`
ReadAsObjectRowsAsync 使用自动映射异步读取 `ObjectRows`
ReadToDictionary<TKey, TValue> 使用现有映射器读取一个以第一列为键的对象字典
ReadToDictionaryAsync<TKey, TValue> 使用现有映射器异步读取一个以第一列为键的对象字典
ReadAsDictionary<TKey, TValue> 使用自动映射读取一个以第一列为键的对象字典
ReadAsDictionaryAsync<TKey, TValue> 使用自动映射异步读取一个以第一列为键的对象字典
ReadToEnumerable<T> 使用现有映射器读取对象的 `IEnumerable`(仅同步方法)
ReadAsEnumerable<T> 使用自动映射读取对象的 `IEnumerable`(仅同步方法)

了解更多关于

内联映射器

上面所有的 `ReatTo` 扩展方法都接受 `Func` 作为参数,因此除了单独的映射器和自动映射之外,这些方法还可以与所谓的*内联映射器*一起使用。

var user = cmd.ReadTo(dr => new User 
{
    Id     =  dr.GetInt32  (0) ,
    Login  =  dr.GetString (1) ,
    Name   =  dr.GetString (2) ,
    Email  =  dr.GetString (3)
});

存储库基类

由于我决定使用存储库模式,所以我为所有自定义存储库创建了一个基类。这个 `RepositoryBase` 类可以负责连接、命令创建和事务(如果需要)。一个存储库一次可以有一个连接、一个事务,并创建多个命令。

为了封装所有释放昂贵资源(关闭连接和释放命令)的逻辑,`RepositoryBase` 类提供了创建 `SqlCommand` 并将其作为参数传递给 `Func` 或 `Action` 参数的方法。

public <T> GetByCommand<T>(Func<SqlCommand, T> func)

public void RunCommand(Action<SqlCommand> action)

public int ExecuteCommand(Action<SqlCommand> action)

存储库方法

继承自 `RepositoryBase` 类、手动编写的映射器以及 `SqlCommand` 和 `SqlDataReader` 的扩展方法,使得存储库方法更加简洁,代码更易读且自成一体。

点击 `ORM` 和 `ADO.NET` 选项卡,查看差异。

public User GetUserById(int id)
{
    return ReadTo<User>("dbo.GetUserById", new SqlParameter("Id", id));
}
public static User GetUserById(int id)
{
    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        using (var cmd = connection.CreateCommand())
        {
            cmd.CommandType = CommandType.StoredProcedure;
            cmd.CommandText = "dbo.GetUserById";
        
            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@UserId",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Int,
                Value         = id
            });

            User user = null;

            connection.Open();

            using (var dr = cmd.ExecuteReader())
            {
                if (dr.Read())
                {
                    user = new User
                    {
                        Id    = (int)dr["Id"],
                        Login = (string)dr["Login"],
                        Name  = (string)dr["Name"],
                        Email = (string)dr["Email"]
                    };                
                }
            }

            connection.Close();
                    
            return user;
        }
    }
}

这是保存 `User` 并将其读回的方法。

public static User SaveUser(User user)
{
    return GetByCommand(cmd =>
    {
        cmd.UseProcedure("dbo.SaveUser");

        cmd.AddTableRowParam("@User", user);

        return cmd.ReadTo<User>;
    });
}
public static User SaveUser(User user)
{
    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        using (var cmd = connection.CreateCommand())
        {
            cmd.CommandType = CommandType.StoredProcedure;
            cmd.CommandText = "dbo.SaveUser";
        
            var userTable = new DataTable("UserTableType");

            userTable.Columns.Add( "Id"     , typeof( Int32  ));
            userTable.Columns.Add( "Login"  , typeof( String ));
            userTable.Columns.Add( "Name"   , typeof( String ));
            userTable.Columns.Add( "Email"  , typeof( String ));

            userTable.Rows.Add(new object[] {
                user.Id, 
                user.Login, 
                user.Name,
                user.Email
            });

            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@User",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Structured,
                TypeName      = userTable.TableName,
                Value         = userTable
            });

            User user = null;

            connection.Open();

            using (var dr = cmd.ExecuteReader())
            {
                if (dr.Read())
                {
                    user = new User
                    {
                        Id    = (int)dr["Id"],
                        Login = (string)dr["Login"],
                        Name  = (string)dr["Name"],
                        Email = (string)dr["Email"]
                    };                
                }
            }

            connection.Close();
                    
            return user;
        }
    }
}

这是关于*图保存*的文章中的示例 ,已重写为 ORM 用法。

public static IList<GrandRecord> SaveGrandRecords(IList<GrandRecord> grandRecords)
{
    var records = grandRecords.SelectMany(gr => gr.Records);
    var childRecords = records.SelectMany(r => r.ChildRecords);

    return GetByCommand(cmd =>
    {
        cmd.UseProcedure("dbo.SaveGrandRecords");

        cmd.AddTableParam("@GrandRecords", grandRecords);

        cmd.AddTableParam("@Records", records);

        cmd.AddTableParam("@ChildRecords", childRecords);

        return cmd.GetByReader(dr => {

            var grandRecords = dr.ReadToList<GrandRecord>();

            var records = dr.ReadToList<Record>();

            var childRecords = dr.ReadToList<ChildRecord>();

            dr.Close();

            grandRecords.MergeJoin(
                records, 
                (gr, r) => gr.Id == r.GrandRecordId,
                (gr, r) => { r.GrandRecord = gr; gr.Records.Add(r); },

                childRecords,
                (r, cr) => r.Id == cr.RecordId,
                (r, cr) => { cr.Record = r; r.ChildRecords.Add(cr); }
            );

            return grandRecords;
        });
    });
}
public static IList<GrandRecord> SaveGrandRecords(IList<GrandRecord> grandRecords)
{
    var id = int.MinValue;

    foreach (var grandRecord in grandRecords)
    {
        if (grandRecord.Id == 0)
            grandRecord.Id = id++;

        foreach (var record in grandRecord.Records)
        {
            if (record.Id == 0)
                record.Id = id++;

            record.GrandRecordId = grandRecord.Id;

            foreach (var childRecord in record.ChildRecords)
            {
                if (childRecord.Id == 0)
                    childRecord.Id = id++;

                childRecord.RecordId = record.Id;
            }
        }
    }

    var connectionString = 
        @"Data Source=.\SQLEXPRESS;Initial Catalog=ObjectGraphs;Integrated Security=True;"

    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        using (var cmd = connection.CreateCommand())
        {
            cmd.CommandType = CommandType.StoredProcedure;
            cmd.CommandText = "dbo.SaveGrandRecords";


            var grandRecordTable = new DataTable("GrandRecordTableType");

            grandRecordTable.Columns.Add( "Id"   , typeof( Int32  ));
            grandRecordTable.Columns.Add( "Name" , typeof( String ));

            foreach(var grandRecord in grandRecords) 
            {
                grandRecordTable.Rows.Add(new object[] {
                    grandRecord.Id, 
                    grandRecord.Name
                });
            }

            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@GrandRecords",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Structured,
                TypeName      = grandRecordTable.TableName,
                Value         = grandRecordTable
            });



            var recordTable = new DataTable("RecordTableType");

            recordTable.Columns.Add( "Id"            , typeof( Int32  ));
            recordTable.Columns.Add( "GrandRecordId" , typeof( Int32  ));
            recordTable.Columns.Add( "Name"          , typeof( String ));

            var records = grandRecords.SelectMany(gr => gr.Records);

            foreach(var record in records) 
            {
                recordTable.Rows.Add(new object[] {
                    record.Id, 
                    record.GrandRecordId, 
                    record.Name
                });
            }

            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@Records",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Structured,
                TypeName      = recordTable.TableName,
                Value         = recordTable
            });


            var childRecordTable = new DataTable("ChildRecordTableType");

            childRecordTable.Columns.Add( "Id"       , typeof( Int32  ));
            childRecordTable.Columns.Add( "RecordId" , typeof( Int32  ));
            childRecordTable.Columns.Add( "Name"     , typeof( String ));

            var childRecords = records.SelectMany(r => r.ChildRecords);

            foreach(var childRecord in childRecords) 
            {
                childRecordTable.Rows.Add(new object[] {
                    childRecord.Id, 
                    childRecord.RecordId, 
                    childRecord.Name
                });
            }

            cmd.Parameters.Add( new SqlParameter
            { 
                ParameterName = "@ChildRecords",
                Direction     = ParameterDirection.Input,
                SqlDbType     = SqlDbType.Structured,
                TypeName      = childRecordTable.TableName,
                Value         = childRecordTable
            });

                    
            var savedGrandRecords = new List<GrandRecord>();
            var savedRecords = new List<Record>();
            var savedChildRecords = new List<ChildRecord>();

            connection.Open();

            using (var dr = cmd.ExecuteReader())
            {
                while (dr.Read())
                {
                    savedGrandRecords.Add(
                        new GrandRecord
                        {
                            Id      = dr.GetInt32(0),
                            Name    = dr.GetString(1),
                            Records = new List<Record>()
                        }
                    );
                }

                dr.NextResult();

                while (dr.Read())
                {
                    savedRecords.Add(
                        new Record
                        {
                            Id            = dr.GetInt32(0),
                            GrandRecordId = dr.GetInt32(1),
                            Name          = dr.GetString(2),
                            ChildRecords  = new List<ChildRecord>()
                        }
                    );
                }

                dr.NextResult();

                while (dr.Read())
                {
                    savedChildRecords.Add(
                        new ChildRecord
                        {
                            Id       = dr.GetInt32(0),
                            RecordId = dr.GetInt32(1),
                            Name     = dr.GetString(2)
                        }
                    );
                }
            }

            connection.Close();
                    
            var recordEnumerator = records.GetEnumerator();
            var record = recordEnumerator.MoveNext() 
                       ? recordEnumerator.Current 
                       : null;

            var childRecordEnumerator = childRecords.GetEnumerator();
            var childRecord = childRecordEnumerator.MoveNext() 
                            ? childRecordEnumerator.Current 
                            : null;


            foreach (var grandRecord in grandRecords)
            {
                grandRecord.Records = new List<Record>();

                while (record != null && record.GrandRecordId == grandRecord.Id)
                {
                    record.ChildRecords = new List<ChildRecord>();

                    while (childRecord != null && childRecord.RecordId == record.Id)
                    {
                        record.ChildRecords.Add(childRecord);

                        childRecord = childRecordEnumerator.MoveNext() 
                                    ? childRecordEnumerator.Current 
                                    : null;
                    }

                    grandRecord.Records.Add(record);

                    record = recordEnumerator.MoveNext() 
                           ? recordEnumerator.Current 
                           : null;
                }
            }

            return savedGrandRecords;
        }
    }
}

关于 Artisan.Orm

上述方法已在多个项目中得到测试,并证明了其效率。因此,我决定创建一个独立的库。

我将我的项目命名为 `Artisan.Orm`,因为它允许您精确而细致地调整*数据访问层*(DAL)
福特的流水线很棒,但法拉利是手工组装的!:)

如果您对该项目感兴趣,请访问 `Artisan.Orm` 的 GitHub 页面 及其 文档 Wiki

GitHub + Wiki

`Artisan.Orm` 也可通过 NuGet 包 获取。

NuGet

关于源代码

附带的压缩包包含 Visual Studio 2015 创建的 GitHub 解决方案副本,该解决方案由三个项目组成:

  • `Artisan.Orm` - 包含 `Artisan.Orm` 类的 DLL 项目。
  • `Database` - SSDT 项目,用于创建 SQL Server 2016 数据库以提供测试数据。
  • `Tests` - 包含代码使用示例的测试项目。

要安装数据库并运行测试,请在 `Artisan.publish.xml` 文件和 `App.config` 文件中将连接字符串更改为您自己的。

多重异常输出

当单个*insert*/*update*/*delete*语句发生错误或异常时,足以*引发错误*并*抛出异常*,只包含一个错误代码或消息。

当需要捕获保存整个对象图时可能发生的所有问题,抛出多个异常并将这些异常信息传递给应用程序客户端时,情况就完全不同了。

`Artisan.Orm` 提供了自己解决此问题的方法。

阅读下一篇文章:Artisan 的数据回复方式

历史记录

  • 2016年11月16日
    • 初次发表
  • 2016年11月23日
    • 添加了关于通过表达式树进行自动映射的章节。
    • 源代码已更新至版本 1.0.5。
  • 2016年12月30日
    • 添加了关于 Read-methods 的章节。
    • 源代码已更新至版本 1.0.7。
  • 2017年1月31日
    • 添加了 `ReadToDictionary` 和 `ReadAsDictionary` 扩展方法。
    • 源代码已更新至版本 1.0.8。
  • 2017年6月13日
    • 添加了关于内联映射器的段落。
    • 源代码已更新至版本 1.1.0。
    • 添加了指向文章“Artisan 的数据回复方式”的链接。
    • 添加了关于多重异常输出的段落。
  • 2018年2月4日
    • 添加了用于处理层级结构的方法(阅读更多)。
    • .NET Framework 源代码已更新至版本 1.1.3。
    • 已实现为 .NET Standard 2.0 库。
    • 添加了 .NET Standard 源代码版本 2.0.0(含 .NET Core 测试)。
© . All rights reserved.