返回

温故而知新,由 ADO.NET 与 Dapper 所联想到的

AI 摘要
这篇文章讨论了在维护一个"遗产项目"时遇到的困难,其中数据持久化层完全由 ADO.NET 手工构建,导致大量使用 DataTable 进行数据操作,与习惯使用 Entity Framework 的开发者产生不适。作者提到了 ADO.NET 的核心组件,介绍了 DbConnection、DbCommand、DbDataReader、DbDataAdapter、DataTable 和 DataSet 的作用。讨论了使用 ADO.NET 访问数据库的常用方法和设计理念。然后作者提到了希望改进这种情况,类似于 Dapper 的实现,引入了一个简化版的 DynamicRow 类来实现类似 DapperRow 的效果。最后,作者介绍了如何通过扩展方法实现类似 Dapper 的 Query 和 Execute 方法,以及简化版的参数化查询。整体文章展示了作者对 ADO.NET、Dapper 和 Dynamic 的思考,并提出了自己的改进方案。

这段时间在维护一个“遗产项目”,体验可以说是相当地难受,因为它的数据持久化层完全由 ADO.NET 纯手工打造,所以,你可以在项目中看到无所不在的 DataTable,不论是读操作还是写操作。这个 DataTable 让我这个习惯了 Entity Framework 的人感到非常别扭,我并不排斥写手写 SQL 语句,我只是拥有某种自觉并且清醒地知道,自己写的 SQL 语句未必就比 ORM 生成的 SQL 语句要好。可至少应该是像 Dapper 这种程度的封装啊,因为关系型数据库天生就和面向对象编程存在隔离,所以,频繁地使用 DataTable 无疑意味着你要写很多的转换的代码,当我看到DbConnectionDbCommandDbDataReaderDbDataAdapter这些熟悉的“底层”的时候,我意识到我可以结合着 Dapper 的实现,从中梳理出一点改善的思路,所以,这篇博客想聊一聊ADO.NETDapperDynamic这三者间交叉的部分,希望能给大家带来新的启发。

重温 ADO.NET

相信大家都知道,我这里提到的DbConnectionDbCommandDbDataReaderDbDataAdapte以及DataTableDataSet,实际上就是 ADO.NET 中核心的组成部分,譬如DbConnection负责管理数据库连接,DbCommand负责 SQL 语句的执行,DbDataReaderDbDataAdapter负责数据库结果集的读取。需要注意的是,这些类型都是抽象类,而各个数据库的具体实现,则是由对应的厂商来完成,即我们称之为“驱动”的部分,它们都遵循同一套接口规范,而DataTableDataSet则是“装”数据库结果集的容器。关于 ADO.NET 的设计理念,可以从下图中得到更清晰的答案:

ADO.NET架构
ADO.NET架构

在这种理念的指引,使用 ADO.NET 访问数据库通常会是下面的画风。博主相信,大家在各种各样的DbHelper或者DbUtils中都见过类似的代码片段,在更复杂的场景中,我们会使用DbParameter来辅助DbCommand,而这就是所谓的SQL 参数化查询

var fileName = Path.Combine(Directory.GetCurrentDirectory(), "Chinook.db");
using (var connection = new SQLiteConnection($"Data Source={fileName}"))
{
    if (connection.State != ConnectionState.Open) connection.Open();
    using (var command = connection.CreateCommand())
    {
        command.CommandText = "SELECT AlbumId, Title, ArtistId FROM [Album]";
        command.CommandType = CommandType.Text;

        //套路1:使用DbDataReader读取数据
        using (var reader = command.ExecuteReader())
        {
            while (reader.Read())
            {
                //各种眼花缭乱的写法:)
                Console.WriteLine($"AlbumId={reader.GetValue(0)}");
                Console.WriteLine($"Title={reader.GetFieldValue<string>("Title")}");
                Console.WriteLine($"ArtistId={reader.GetInt32("ArtistId")}");
            }
        }

        //套路2:使用DbDataAdapter读取数据
        using (var adapter = new SQLiteDataAdapter(command))
        {
            var dataTable = new DataTable();
            adapter.Fill(dataTable);
        }
    }
}

这里经常会引发的讨论是,DbDataReaderDbDataAdapter的区别以及各自的使用场景是什么?简单来说,前者是按需读取/只读,数据库连接会一直保持;而后者是一次读取,数据全部加载到内存,数据库连接用完就会关掉。从资源释放的角度,听起来后者更友好一点,可显然结果集越大占用的内存就会越多。而如果从易用性上来考虑,后者可以直接填充数据到DataSet或者DataTable,前者则需要费一点周折,你看这段代码是不是有点秀操作的意思:

//各种眼花缭乱的写法:)
Console.WriteLine($"AlbumId={reader.GetValue(0)}");
Console.WriteLine($"Title={reader.GetFieldValue<string>("Title")}");
Console.WriteLine($"ArtistId={reader.GetInt32("ArtistId")}");

在这个“遗产项目”中,DbDataReaderDbDataAdapter都有所涉猎,后者在结果集不大的情况下还是可以的,唯一的遗憾就是DataTableLINQ的违和感实在太强烈了,虽然可以勉强使用AsEnumerable()拯救一下,而前者就有一点魔幻了,你能看到各种GetValue(1)GetValue(2)这样的写法,这简直就是成心不想让后面维护的人好过,因为加字段的时候要小心翼翼地,确保字段顺序不会被修改。明明这个世界上有DapperSqlSugarSmartSql这样优秀的 ORM 存在,为什么就要如此执著地写这种代码呢?是觉得 MyBatis 在 XML 里写 SQL 语句很时尚吗?

所以,我开始尝试改进这些代码,我希望它可以像 Dapper 一样,提供Query<T>()Execute()两个方法足矣!如果要把结果集映射到一个具体的类型上,大家都能想到使用反射,我更想实现的是 Dapper 里的DapperRow,它可以通过“·”或者字典的形式来访问字段,现在的问题来了,你能实现类似 Dapper 里 DapperRow 的效果吗?因为想偷懒的时候,dynamic 不比 DataRow 更省事儿吗?那玩意儿光转换类型就要烦死人了,更不用说要映射到某个 DTO 啦!

实现 DynamicRow

通过阅读 Dapper 的源代码,我们知道,Dapper 中用DapperTableDapperRow替换掉了 DataTable 和 DataRow,可见这两个玩意儿有多不好用,果然,英雄所见略同啊,哈哈哈!其实,这背后的一切的功臣是IDynamicMetaObjectProvider,通过这个接口我们就能实现类似的功能,我们熟悉的ExpendoObject就是最好的例子:

dynamic person = new ExpandoObject(); 
person.FirstName = "Sherlock"; 
person.LastName = "Holmes";

//等价形式
(person as IDctionary<string, object>)["FirstName"] = "Sherlock";
(person as IDctionary<string, object>)["LastName"] = "Holmes";

这里,我们用一种简单的方式,让 DynamicRow 继承者 DynamicObject,下面一起来看具体的代码:

public class DynamicRow : DynamicObject
{
    private readonly IDataRecord _record;
    public DynamicRow(IDataRecord record)
    {
        _record = record;
    }

    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        var index = _record.GetOrdinal(binder.Name);
        result = index > 0 ? _record[binder.Name] : null;
        return index > 0;
    }
        
    //支持像字典一样使用
    public object this[string field] =>
       _record.GetOrdinal(field) > 0 ? _record[field] : null;
}

对于DynamicObject这个类型而言,里面最重要的两个方法其实是TryGetMember()TrySetMember(),因为这决定了这个动态对象的读和写两个操作。因为我们这里不需要反向地去操作数据库,所以,我们只需要关注TryGetMember()即可,一旦实现这个方法,我们就可以使用类似foo.bar这种形式访问字段,而提供一个索引器,则是为了提供类似foo["bar"]的访问方式,这一点同样是为了像 Dapper 看齐,无非是 Dapper 的 DynamicRow 本来就是一个字典!

现在,我们来着手实现一个简化版的 Dapper,给IDbConnection这个接口扩展出Query<T>()Execute()两个方法,我们注意到Query<T>()需要用到DbDataReader或者DbDataAdapter其一,对于DbDataAdapter而言,它的实现完全由具体的子类决定,所以,对于IDbConnection接口而言,它完全不知道对应的子类是什么,此时,我们只能通过判断IDbConnection的类型来返回对应的 DbDataAdapter。读过我之前博客的朋友,应该对 Dapper 里的数据库类型的字典有印象,不好意思,这里历史要再次上演啦!

public static IEnumerable<dynamic> Query(this IDbConnection connection, string sql, 
  object param = null, IDbTransaction trans = null)
{
    var reader = connection.CreateDataReader(sql);
    while (reader.Read())
        yield return new DynamicRow(reader as IDataRecord);
}

public static IEnumerable<T> Query<T>(this IDbConnection connection, string sql,
  object param = null, IDbTransaction trans = null) 
  where T : class, new()
{
    var reader = connection.CreateDataReader(sql);
    while (reader.Read())
        yield return (reader as IDataRecord).Cast<T>();
}

这里的CreateDataReader()Cast()都是博主自定义的扩展方法:

private static IDataReader CreateDataReader(this IDbConnection connection, string sql)
{
    var command = connection.CreateCommand();
    command.CommandText = sql;
    command.CommandType = CommandType.Text;
    return command.ExecuteReader();
}

private static T Cast<T>(this IDataRecord record) where T:class, new()
{
    var instance = new T();
    foreach(var property in typeof(T).GetProperties())
    {
        var index = record.GetOrdinal(property.Name);
        if (index < 0) continue;
        var propertyType = property.PropertyType;
        if (propertyType.IsGenericType && 
          propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
            propertyType = Nullable.GetUnderlyingType(propertyType);
        property.SetValue(instance, 
          Convert.ChangeType(record[property.Name], propertyType));
    } 

    return instance;  
}

Execute()方法则要简单的多,因为从IDbConnectionIDbCommand的这条线,可以直接通过CreateCommand()来实现:

public static int Execute(this IDbConnection connection, string sql, 
  object param = null, IDbTransaction trans = null)
{
    var command = connection.CreateCommand();
    command.CommandText = sql;
    command.CommandType = CommandType.Text;
    return command.ExecuteNonQuery();
}

实现参数化查询

大家可以注意到,我这里的参数 param 完全没有用上,这是因为IDbCommandParaneters属性显然是一个抽象类的集合。所以,从IDbConnection的角度来看这个问题的时候,它又不知道这个参数要如何来给了,而且像 Dapper 里的参数,涉及到集合类型会存在INNOT IN以及批量操作的问题,比普通的字符串替换还要稍微复杂一点。如果我们只考虑最简单的情况,它还是可以尝试一番的:

private static void SetDbParameter(this IDbCommand command, object param = null)
{
    if (param == null) return;
    if (param is IDictionary<string, object>)
    {
        //使用字典作为参数
        foreach (var arg in param as IDictionary<string, object>)
        {
              var newParam = command.CreateParameter();
              newParam.ParameterName = $"@{arg.Key}";
              newParam.Value = arg.Value;
              command.Parameters.Add(newParam);
        }
    }
    else 
    {
        //使用匿名对象作为参数
        foreach (var property in param.GetType().GetProperties())
        {
              var propVal = property.GetValue(param);
              if (propVal == null) continue;
              var newParam = command.CreateParameter();
              newParam.ParameterName = $"@{property.Name}";
              newParam.Value = propVal;
              command.Parameters.Add(newParam);
        }
    }
}

相应地,为了能在Query<T>()Execute()两个方法中使用参数,我们需要修改相关的方法:

public static int Execute(this IDbConnection connection, string sql, 
  object param = null, IDbTransaction trans = null)
{
    var command = connection.CreateCommand();
    command.CommandText = sql;
    command.CommandType = CommandType.Text;
    command.SetDbParameter(param);
    return command.ExecuteNonQuery();
}

private static IDataReader CreateDataReader(this IDbConnection connection, string sql, 
  object param = null)
{
    var command = connection.CreateCommand();
    command.CommandText = sql;
    command.CommandType = CommandType.Text;
    command.SetDbParameter(param);
    return command.ExecuteReader();
}

现在,唯一的问题就剩下DbType@啦,前者在不同的数据库中可能对应不同的类型,后者则要面临 Oracle 这朵奇葩的兼容性问题,相关内容可以参考在这篇博客:Dapper.Contrib 在 Oracle 环境下引发 ORA-00928 异常问题的解决。到这一步,我们基本上可以实现类似 Dapper 的效果。当然,我并不是为了重复制造轮子,只是像从 Dapper 这样一个结果反推出相关的技术细节,从而可以串联起整个 ASO.NET 甚至是 Entity Framework 的知识体系,工作中解决类似的问题非常简单,直接通过 NuGet 安装 Dapper 即可,可如果你想深入了解某一个事物,最好的方法就是亲自去探寻其中的原理。现在基础设施越来越完善了,可有时候我们再找不回编程的那种快乐,大概是我们内心深处放弃了什么……

考虑到,从微软的角度,它鼓励我们为每一家数据库去实现数据库驱动,所以,它定义了很多的抽象类。而从 ORM 的角度来考虑,它要抹平不同数据库的差异,Dapper 的做法是给IDbConnection写扩展方法,而针对每个数据库的“方言”,实际上不管什么 ORM 都要去做这部分“脏活儿”,以前是分给数据库厂商去做,现在是交给 ORM 设计者去做,我觉得 ADO.NET 里似乎缺少了一部分东西,它需要提供一个 IDbAdapterProvider 的接口,返回 IDbAdapter 接口,这样就可以不用关心它是被如何创建出来的。你看,同样是设计接口,可微软和 ServiceStack 俨然是两种不同的思路,这其中的差异,足可窥见一斑矣!实际上,Entity Framework 就是在以 ADO.NET 为基础发展而来的,在这个过程中,还是由厂商来实现对应的 Provider。此时此刻,你悟到了我所说的“温故而知新”了嘛?

本文小结

本文实则由针对 DataSet/DataTable 的吐槽而引出,在这个过程中,我们重新温习了 ADO.NET 中DbConnectionDbCommandDbDataReaderDbDataAdapter这些关键的组成部分,而为了解决 DataTable 在使用上的种种不变,我们想到了借鉴 Dapper 中的 DapperRow 来实现“动态查询”,由此引出了.NET 中实现 dynamic 最重要的一个接口:IDynamicMetaObjectProvide,这使得我们可以在查询数据库的时候返回一个 dynamic 的集合。而为了更接近 Dapper 一点,我们基于扩展方法的形式为IDbConnection编写了Query<T>()Execute()方法,在数据库读写层面上彻底终结了 DataSet/DataTable 的生命。最后,我们实现了一个简化版本的参数化查询,同样是借鉴 Dapper 的思路。这说明一件什么事情呢?当你在一个看似合理、结局固定的现状中无法摆脱的时候,“平躺”虽然能让你获得一丝喘息的机会,但与此同时,你永远失去了跳出这个层级去看待事物的机会,就像我以前吐槽同事天天用StringBuider拼接字符串一样,一味地吐槽是没有什么用的,重要的是你会选择怎么做,所以,后来我向大家推荐了Linquid2021 年已经来了,希望你不只是增长了年龄和皱纹,晚安!

Built with Hugo v0.126.1
Theme Stack designed by Jimmy
已创作 274 篇文章,共计 1038462 字