
相信大家都知道,我这里提到的DbConnection、DbCommand、DbDataReader、DbDataAdapte以及DataTable、DataSet,实际上就是ADO.NET中核心的组成部分,譬如DbConnection负责管理数据库连接,DbCommand负责SQL语句的执行,DbDataReader和DbDataAdapter负责数据库结果集的读取。需要注意的是,这些类型都是抽象类,而各个数据库的具体实现,则是由对应的厂商来完成,即我们称之为“驱动”的部分,它们都遵循同一套接口规范,而DataTable和DataSet则是“装”数据库结果集的容器。关于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("Title")}");
Console.WriteLine($"ArtistId={reader.GetInt32("ArtistId")}");
}
}
//套路2:使用DbDataAdapter读取数据
using (var adapter = new SQLiteDataAdapter(command))
{
var dataTable = new DataTable();
adapter.Fill(dataTable);
}
}
}
这里经常会引发的讨论是,DbDataReader和DbDataAdapter的区别以及各自的使用场景是什么?简单来说,前者是按需读取/只读,数据库连接会一直保持;而后者是一次读取,数据全部加载到内存,数据库连接用完就会关掉。从资源释放的角度,听起来后者更友好一点,可显然结果集越大占用的内存就会越多。而如果从易用性上来考虑,后者可以直接填充数据到DataSet或者DataTable,前者则需要费一点周折,你看这段代码是不是有点秀操作的意思:
//各种眼花缭乱的写法:)
Console.WriteLine($"AlbumId={reader.GetValue(0)}");
Console.WriteLine($"Title={reader.GetFieldValue("Title")}");
Console.WriteLine($"ArtistId={reader.GetInt32("ArtistId")}");
在这个“遗产项目”中,DbDataReader和DbDataAdapter都有所涉猎,后者在结果集不大的情况下还是可以的,唯一的遗憾就是DataTable和LINQ的违和感实在太强烈了,虽然可以勉强使用AsEnumerable()拯救一下,而前者就有一点魔幻了,你能看到各种GetValue(1)、GetValue(2)这样的写法,这简直就是成心不想让后面维护的人好过,因为加字段的时候要小心翼翼地,确保字段顺序不会被修改。明明这个世界上有Dapper、SqlSugar、SmartSql这样优秀的ORM存在,为什么就要如此执著地写这种代码呢?是觉得MyBatis在XML里写SQL语句很时尚吗?
所以,我开始尝试改进这些代码,我希望它可以像Dapper一样,提供Query
通过阅读Dapper的源代码,我们知道,Dapper中用DapperTable和DapperRow替换掉了DataTable和DataRow,可见这两个玩意儿有多不好用,果然,英雄所见略同啊,哈哈哈!其实,这背后的一切的功臣是IDynamicMetaObjectProvider,通过这个接口我们就能实现类似的功能,我们熟悉的ExpendoObject就是最好的例子:
dynamic person = new ExpandoObject(); person.FirstName = "Sherlock"; person.LastName = "Holmes"; //等价形式 (person as IDctionary)["FirstName"] = "Sherlock"; (person as IDctionary )["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
public static IEnumerableQuery(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 Query (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 (); }
这里的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(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()方法则要简单的多,因为从IDbConnection到IDbCommand的这条线,可以直接通过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完全没有用上,这是因为IDbCommand的Paraneters属性显然是一个抽象类的集合。所以,从IDbConnection的角度来看这个问题的时候,它又不知道这个参数要如何来给了,而且像Dapper里的参数,涉及到集合类型会存在IN和NOT IN以及批量操作的问题,比普通的字符串替换还要稍微复杂一点。如果我们只考虑最简单的情况,它还是可以尝试一番的:
private static void SetDbParameter(this IDbCommand command, object param = null)
{
if (param == null) return;
if (param is IDictionary)
{
//使用字典作为参数
foreach (var arg in param as IDictionary)
{
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
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中DbConnection、DbCommand、DbDataReader、DbDataAdapter这些关键的组成部分,而为了解决DataTable在使用上的种种不变,我们想到了借鉴Dapper中的DapperRow来实现“动态查询”,由此引出了.NET中实现dynamic最重要的一个接口:IDynamicMetaObjectProvide,这使得我们可以在查询数据库的时候返回一个dynamic的集合。而为了更接近Dapper一点,我们基于扩展方法的形式为IDbConnection编写了Query