一次 SOLID DAL 的冒险






4.65/5 (16投票s)
尝试创建一个灵活且可重用的数据访问层。
引言
CodeProject 上有很多关于创建数据访问层 (DAL) 的文章。每篇文章似乎都只解决了某个特定问题,而没有着眼于解决整体的访问问题。我写这篇文章是为了看看我能否开始解决可重用性和灵活性问题,并利用 SOLID 原则。
更新: 最初,我没有计划讨论代码。我的初衷是,关于 SOLID 原则的优秀文章已经足够多,我不需要重复别人的文章,展示它们如何应用于代码。我本以为我会创建一些实用的代码来展示原则的应用,供大家自行研究,无需太多指导。我征求评论是为了引发关于大家喜欢或不喜欢代码的讨论。考虑到一些评论,我的初衷似乎有误,以下是纠正。
背景
多年来,我在不同公司工作时,大多数项目都有自己的内部 DAL。有些比其他的好,但都差强人意。我目前正在从事的项目,已经有大约十年的历史,最初是从 Classic ASP 开始的。
多年来,开发者来来往往,贡献了他们的想法,认为什么是可接受的或当时的标准。这种遗产的一部分是大约 4 种不同实现的数据访问层中的数千行代码。
现在我的孩子们暑期活动都结束了,一个主要的软件版本也完成了,我发现自己有空闲时间了。我决定花点时间看看我能完成什么,创建一个 DAL 框架,最终取代我们现有的代码。
项目
附加的源代码是我因时间有限而工作的成果。正如我在引言中所提到的,我想让代码更灵活、更可重用;为此,我尝试遵循 SOLID 原则。
如果您不熟悉 SOLID 原则,网上有很多关于该主题的文章。以下是该原则的要点:
- S – 单一职责原则 – 一个类应该只做一件事,并做好它。
- O – 开闭原则 – 软件应该对扩展开放,但对修改关闭。
- L – 里氏替换原则 – 函数应该接受基类,而不了解子类。
- I – 接口隔离原则 – 应该使用接口,并且接口只包含基本功能。
- D – 依赖倒置原则 – 应该使用容器来实现高层和低层类之间的松耦合。
我故意违反了最后一个原则:依赖倒置。目前,我将不实现容器。根据过去的经验,实现这样的东西总是给我带来比它价值更大的麻烦。我说的是同事。很多开发者都是朝九晚五,下班后就不再努力维护/更新他们的技能了。因此,我尽我所能去现代化,而不是在最后给自己增加更多的工作。
代码
我收到的一条评论是,我应该更多地针对 ADO.NET 接口进行编程,而不是为各个数据库创建特定的类。考虑到这一点,我重构了所有代码,现在 DbFactory.cs 文件中只有少数几个地方了解数据库类型。实现的最低限度的代码,仅出于必要而存在。
- 单一职责 – 如果您打开 QueryParameter.cs 文件,您会看到其中有属性和一些方法,这些方法通过流式编码风格来设置这些值,以及获取参数值的方法。这代表了单一职责的概念,只包含创建数据库参数所必需的内容。
- 开闭 – 我实现这一点的一种方式是使用 SqlParameterExtension.cs。该类允许对 DbParameter 子类 SqlParameter 执行额外的操作。将来可以创建一个 Oracle 版本来实现类似的行为,方法是按照 <className>Extension.cs 的约定命名文件并实现 SetAdditionalParameters 方法。在 DbQuery 中,如果定义了扩展方法,就会调用它。Executers 文件夹包含其他类,这些类封装了与数据库通信的功能。目前只有一个用于使用 SqlDataAdapter,但也可以创建其他类。
- 里氏替换 – DbQuery 再次展示了这一原则,它接受一个 DbCommand 对象,并在后续代码中不对子类类型进行任何引用。使用 switch 语句通常是违反此原则的好方法。
- 接口隔离 – QueryBuilder 接受一个 IDatabaseQuery 对象作为输入。具体类的具体类型并不真正重要,只要它实现了必需的项即可。接口本身是一个复合接口,只包含所需的最少项。
查询演练
让我们来演练一下物料清单查询。此查询将展示所有当前功能。我们从 Main() 开始。
static void Main()
{
IDbFactory factory = new DbFactory(DatabaseType.SqlServer);
// Stored Procedure - Parameters - Complex Object - Mapping
var obj = BillOfMaterialsQueries.GetByIdAndDate(factory, 893, new DateTime(2000, 06, 26));
}
我们首先获取数据库工厂。这将允许我们创建连接、创建查询对象并执行查询。虽然实际实现委托给了其他类,但这是请求执行某项操作的中心位置。我可以创建连接,然后从连接创建命令,从而消除了另一个需要了解数据库类型的地方。我认为这会限制创建查询并将其传递给任何开发者的原因。连接需要知道它连接到什么,所以我认为这对命令来说也不是一个糟糕的决定。我选择了灵活性而不是匿名功能。
工厂被传递给查询,以允许执行查询。查询包含了从数据库获取信息并返回对象列表所需的一切。我选择返回对象而不是 DataSet。这是一个个人决定,因为我认为你不应该返回任何东西,除了对象。这个框架并不排除你这样做。如果你需要,那就去做。
static public List GetByIdAndDate(IDbFactory factory, int startProductID, DateTime checkDate)
{
List list = null;
QueryCommand command = new QueryCommand
{
ConnectionString = connectionString,
QueryType = DbQueryType.StoredProcedure,
Command = "uspGetBillOfMaterials",
Parameters = new List
{
new DbInt32("StartProductID").SetValue(startProductID),
new DbDateTime("CheckDate").SetValue(checkDate)
}
};
var result = factory.ExecuteDataSetFacade(command);
if (result.WasSuccessful)
{
FieldColumnMap map = FieldColumnMap.GetMappings();
var parser = new DataTableParser(((DataSet)result.Data).Tables[0], map);
list = parser.ParseList();
}
return list;
}
该方法首先创建一个容器 (QueryCommand) 来保存运行数据库语句可能需要的所有信息:数据库连接键名、它是动态还是存储过程、命令本身以及可能需要的任何参数。
一旦容器设置好,就通过工厂上的 ExecuteDataSetFacade 调用执行命令。这是我另一个需要知道它正在使用哪个数据库的地方。由于类型存储在工厂内部,命令将切换到适当的实现。此时,人们可能会指出这违反了开闭原则,我会回应这篇文章:An Open and Closed Case
如果我必须添加创建数据库连接的功能,那么实现特定的数据库检索方法也是合乎逻辑的。未来能否更好地组织结构?当然可以,但目前我只有一个实现,SQL Server,我遵循不“过度设计”的方法论,并在出现模式时进行重构。
一旦数据库语句执行完毕,就会返回一个结构,该结构返回数据以及是否成功。成功后,返回的对象会被解析成适当的对象。这可能取决于将字段映射到特定的数据库列。该类可能定义一个静态方法 (GetFieldColumnMap) 来获取特定的映射。如果类没有定义映射,则使用属性名从解析器中获取值。
public class ProductBillOfMaterials : ICreator
{
static private FieldColumnMap _map;
public Product Product { get; set; }
public BillOfMaterials BillOfMaterials { get; set; }
//
// ICreator
//
public ProductBillOfMaterials Create(IParser parser, object record)
{
Product = new Product();
Product.Create(parser, record);
BillOfMaterials = new BillOfMaterials();
BillOfMaterials.Create(parser, record);
return this;
}
//
// Static Methods
//
static public FieldColumnMap GetFieldColumnMap()
{
if (_map == null)
{
FieldColumnMap productMap = FieldColumnMap.GetMappings();
productMap
.SetType()
.UpdateMapping("Name", "ComponentDesc")
.Done();
FieldColumnMap bomMap = FieldColumnMap.GetMappings();
bomMap
.SetType()
.UpdateMapping("PerAssemblyQty", "TotalQuantity")
.Done();
// Compose new map
_map = new FieldColumnMap()
.SetType()
.AddDictionary(productMap.GetMappings())
.Done()
.SetType()
.AddDictionary(bomMap.GetMappings())
.Done();
}
return (FieldColumnMap)_map.Clone();
}
}
public class BillOfMaterials : ICreator
{
//
// ICreator
//
public BillOfMaterials Create(IParser parser, object record)
{
var type = GetType();
BillOfMaterialsID = parser.GetValue(type, record, BillOfMaterialsID, "BillOfMaterialsID");
ProductAssemblyID = parser.GetValue(type, record, ProductAssemblyID, "ProductAssemblyID");
ComponentID = parser.GetValue(type, record, ComponentID, "ComponentID");
StartDate = parser.GetValue(type, record, StartDate, "StartDate");
EndDate = parser.GetValue(type, record, EndDate, "EndDate");
UnitMeasureCode = parser.GetValue(type, record, UnitMeasureCode, "UnitMeasureCode");
BOMLevel = parser.GetValue(type, record, BOMLevel, "BOMLevel");
PerAssemblyQty = parser.GetValue(type, record, PerAssemblyQty, "PerAssemblyQty");
ModifiedDate = parser.GetValue(type, record, ModifiedDate, "ModifiedDate");
return this;
}
}
public class DataTableParser : IParser
{
private readonly DataTable _dataTable;
public FieldColumnMap FieldColumnMap { get; private set; }
public DataTableParser(DataTable dataTable)
: this(dataTable, null)
{
}
public DataTableParser(DataTable dataTable, FieldColumnMap fieldColumnMap)
{
_dataTable = dataTable;
FieldColumnMap = fieldColumnMap;
}
//
// IParser
//
public T GetValue(Type type, object record, T variable, string columnName)
{
var overrides = (FieldColumnMap != null)
? FieldColumnMap.GetMappings(type)
: new Dictionary();
var mappedName = (overrides.ContainsKey(columnName))
? overrides[columnName]
: columnName;
DataRow row = (DataRow)record;
return row.GetValue(variable, mappedName);
}
public List ParseList() where T : ICreator, new()
{
List list = new List();
if (_dataTable == null)
return list;
foreach (DataRow row in _dataTable.Rows)
list.Add(ParseSingle(row));
return list;
}
public T ParseSingle(object record) where T : ICreator, new()
{
if (record == null)
return default(T);
DataRow row = (DataRow)record;
var obj = new T();
obj.Create(this, row);
return obj.Create(this, row);
}
}
构建演练
命令的执行隐藏在外观模式后面,以遵循 DRY 原则并提高框架的简洁性。
public ExecutionResult ExecuteDataSetFacade(QueryCommand command)
{
string name = command.ConnectionString;
IQueryBuilder builder = GetQueryBuilder(command.QueryType);
IDatabaseQuery query = builder.Build(command);
using (DbConnection connection = GetConnection(name))
{
var e = GetDataSetExecuter(connection, query);
return Execute(e);
}
}
此外观负责构造数据库特定的查询、打开连接、执行命令并返回请求的数据。此外观也可以实现其他连接类型(XML、DataReader 等)。
我们首先获取构建器。这是我们知道数据库类型并返回数据库特定命令对象并设置查询类型为动态或存储过程的最后一个地方。
构建器负责实际构造数据库语句,通过使用接口来实现,这些接口最终由 DbQuery 执行。
public class DbQuery : IDatabaseQuery
{
public DbCommand Command { get; protected set; }
public DbQuery(DbCommand command)
{
Command = command;
}
private MethodInfo GetExtensionClass()
{
var parm = Command.CreateParameter();
Type type = parm.GetType();
type = Type.GetType(string.Format("DalFramework.{0}Extension", type.Name));
return (type == null)
? null
: type.GetMethod("SetAdditionalParameters");
}
//
// IQuerySettings
//
public virtual void AddCommand(string command)
{
Command.CommandText = command;
}
public virtual void AddParameters(List parameters)
{
Command.Parameters.Clear();
if (parameters == null)
return;
var method = GetExtensionClass();
parameters.Any(q =>
{
var parm = Command.CreateParameter();
parm.Direction = q.Direction;
parm.IsNullable = q.IsNullable;
parm.ParameterName = q.ColumnName;
parm.Value = q.Value;
Command.Parameters.Add(parm);
// Set additional parameters if defined for this parm type
if (method != null)
method.Invoke(parm, new object[] { parm, q });
return false;
});
}
public virtual void AddTimeout(int seconds)
{
Command.CommandTimeout = seconds;
}
}
此类使用基类 DbCommand 调用现有方法和基属性来成功创建数据库语句。如果存在扩展方法,则可以执行其他功能。
执行演练
一旦数据库语句创建完成,数据库连接就会被创建,并通过 SqlDataSetExecuter 执行。然后将结果和状态传回调用者。
public class SqlDataSetExecuter : DataSetExecuter
{
public override ExecutionResult Execute()
{
ExecutionResult result = new ExecutionResult();
try
{
SqlCommand command = (SqlCommand)Query.Command;
command.Connection = (SqlConnection)Connection;
SqlDataAdapter da = new SqlDataAdapter(command);
da.Fill(DataSet, TableName);
result.Data = DataSet;
}
catch (Exception ex)
{
result.Error = ex.Message;
}
return result;
}
}
模板
我在项目中添加了一个文件夹,其中包含一个可执行文件以及一个模板 (CreateModel)。这超出了本文的范围,但包含在内,供有兴趣的人参考。该模板将根据请求的 SQL 代码生成业务对象模型,请参阅第 47-53 行的示例。它需要添加一个存储过程,该存储过程位于 SQL 文件夹中并已记录。
对评论的回复
- 为什么应该使用它?
让我告诉你为什么我会使用它,因为它填补了用最小的系统影响替换各种实现的需求,同时提供了类似的功能并隐藏了开发者的实现。
你使用它的原因和你使用任何其他框架的原因一样,因为它适合你的需求。我工作过的一些行业/公司不允许任何开源代码。这意味着自己动手解决。通过启动这个项目,我开始应用另一个原则,DRY(Don't Repeat Yourself),因为我从一个项目转移到另一个项目。
- 是什么让它比其他框架更好?
我把它留给读者。每个人对为什么某个东西比另一个东西更好都有自己的看法;这就是为什么我们有多个框架。对我来说,它是一个简单的框架,能够完成工作,并且不需要太多解释。我重视你关于喜欢或不喜欢什么的评论,所以请告诉我,我可以扩展代码。
哪些功能有效?
- 数据库
- SQL Server
- 查询
- 动态
- 存储过程
- 能够将数据库列映射到类字段。
- 通过以下方式检索数据:
- DataSet
- 对象创建
- 简单
- 复杂