总结过去的项目经验,客户反馈最多的就是“卡死了“。项目早期使用的是EF5,刚开始觉得确实方便,程序员使用起来也方便,特别是ling2db,省了很多写sql脚本的事情。可是,时间长了,就变味了,程序员把ef放到了业务层中使用,甚至为了省事,控制层都出现了EF的身影,业务层成了摆设,更不要说数据层。我相信不少企业的程序员或多或少有类似的情况出现,一旦更换程序员接手,维护难度是非常大的,关键是,不是每个程序员都有写详细注释的习惯。
复杂的查询,必然伴随着复杂的DB优化,从业务角度分析,数据库的应用总体分两大部分,一种是运行数据库,所谓的运行就是业务运行,日常业务流转。这部分影响的数据面很小,也非常具有明显的时间性;另一部分是分析数据库,这种往往是用于做查询或者做报表,甚至与第三方系统的数据交换的。这样就可以引入读写分离的概念。
读写分离数据库模式 运行数据库->同步工具->分析数据库,如果是使用mssql,同步就可以使用自带的SSIS,网上搜索一下一大把文章。如果是其他类型的数据库,可以用一些第三方的同步工具,搜索一下,也不少。读写数据库对于数据库的优化侧重点是不一样的,大家都知道,建立了索引,在插入数据的时候在一定程度上会影响索引顺序的,且在事务中,各种表锁都会互相排斥,使用读写分离后,数据库优化各做各的,即使读的数据库写入数据慢一点也无所谓,对日常业务运作没有任何影响,而写的数据,不会因为此刻正在进行大量的数据查询操作而卡顿,毕竟读写数据库是两台独立的数据库服务器。
分析数据库认为是只读数据库,这部分的数据库的优化是根据复杂查询而进行各种优化,例如索引优化,而且用于做分析的数据,可以在日常同步的过程中不断进行数据压缩和汇总,减少后续分析涉及到的数据量,就是说,读写数据库甚至连数据库结构都存在差异的,这个是根据具体业务需求来实现的。
运行数据库认为是写入数据库,由于日常的业务数据都是有限范围的查询,影响面很小,可以有针对性对数据库进行优化,甚至根据业务需求,可以适当进行数据分区。
好了,数据库大致也就这样的,再详细的,应该去找DBA了,呵呵。要解决业务层的后顾之忧,除了各种常用的工具类,最常接触的就是数据层了。一般我们开发的系统面向的都是写的数据库,例如查询表单及进度、打印单据报表、编辑审核表单等等,和日常业务是紧密相关的。
至于读的数据库,则建立另外的一个报表微服务来管理,可以参考通过配置文件实现快速构建分析性报表_kaka9的博客-CSDN博客,这是一个简化版的BI吧,集成了数据浏览、打印预览、导出这几个常用的功能,报表都是通过配置文件配置出来的。
程序员在开发系统时候,常因为读写数据库一下子不注意,或者是因为多人同时访问同一个表,导致一定程度上的出错,但是,往往程序员又没有做这样的容错和日志记录,导致后续查问题非常困难,调试很长时间,也未必能还原问题,毕竟有可能是多个用户操作互相影响的,特别是高密度操作数据的时候,例如生产系统中生产跟踪扫描的扫描记录,涉及到每个部件的每个工序扫描。从个人看来,框架设计和实现,数据访问层的难度是最大最需要细心的一个部分。
public Interface IBaseObject : IDisposable
{
//上下文对象
IContext context;
}
//上下文对象接口
public Interface IContext
{
string Token{get;set;}
string UserID{get;set;}
string RoleID{get;set;}
string EmployeeID{get;set;}
string TransactionKey{get;set;}
}
public interface IBaseDAL<T> : IBaseObject
where T:class
{
RValue<T> Insert(T item);
RList<T> Insert(List<T> items);
RValue<T> Update(T item);
RValue<T> Update(T item,string[] updateFields);
RList<T> Update(List<T> items);
RList<T> Update(List<T> items,string[] updateFields);
RValue<int> Update(Expression<Func<T,bool>> where,,Expression<Func<T,T>> updateFields);
RValue<T> Delete(T item);
RList<T> Delete(List<T> items);
RValue<int> Delete(Expression<Func<T,bool>> where);
//事务
void Commit();
void Rollback();
RList<T> GetList(Dictionary<string,ICondition> where,string orderBy="");
RPage<T> GetList(Dictionary<string,ICondition> where,int page,int size,string orderBy="");
RList<T> GetList(Expression<Func<T,bool>> where,string orderBy="");
RPage<T> GetList(Expression<Func<T,bool>> where,int page,int size,string orderBy="");
}
public abstract class BaseDAL<T> : BaseObject,IBaseDAL<T>
where T:class
{
IDBContext _dbContext;
protected string ConnectionString;
public BaseDAL(string constr)
{
ConnectionString = constr;
}
protected IDBContext dbContext{
{
get
{
if(_dbContext == null)
{
InitDBContext();
}
//获取或者开始事务
if(!string.IsNullOrEmpty(Context.TransactionKey) && !_dbContext.IsInTransaction)
{
if(TransCache.TryGetValue(_dbContext.ConnectionString,out ITransaction trans))
{
_dbContext.UseTransaction(trans);
}
else
{
trans = _dbContext.BeginTransaction();
TransCache[_dbContext.ConnectionString]=trans;
}
}
}
}
}
这是一个最基本的数据层接口定义,可以看到,事务部分是没有开始事务的方法,事务是根据上下文中的TransactionKey是否存在值而定的,因此,事务的开始,其实对于DAL来说,是自动的。而事务的结束时由业务层决定的,为什么这么说?事务的提交或者回滚,不仅仅取决于数据库操作的结果,另外,还有可能是受业务逻辑运算影响,如果业务处理过程中,得出的结果并不满足写入数据的条件,哪怕所有数据库操作都是成功的,都有可能引起回滚;另外,从分布式事务来说,事务的提交或者回滚,是取决于其他微服务的操作结果,控制器一层有提到过,分布式事务是通过WebApi实现的,如果A微服务发起事务,需要B微服务协助,那么,当A向B发出请求的时候,请求中的Header就会夹带这一个token,B微服务中的相关DAL就会根据这个夹带的token自动发起事务。这里后续需要大致说明一下怎么可以自动向B发起事务请求。
上面已经解析了事务在DAL中的一些逻辑处理代码,事务结束后,要把所有的痕迹抹除掉!接下来说说如果能够记录详细的日志。首先,ORM必须支持发送数据库执行请求的事件
privoid void InitDBContext()
{
_dbContext = CreateDBContext();
//这里是发送数据请求的事件委托对象
//ReaderExecuting,ReaderExecuted,NonQueryExecuting,NonQueryExecuted,ScalarExecuting,ScalarExecuted,分别是三种请求的开始和完成事件,通过这三组事件,可以记录下当时请求的SQL脚本和执行经过的时间
_dbContext.DbInterceptor = new DbCommandInterceptor();
}
//数据读写操作都必须带有异常获取和日志记录功能,例如
public RValue<T> Insert(T item)
{
try
{
//正常写入数据,日志由DbInterceptor事件负责记录日志
var r = beforeInsert(item);
if(!r)
return r;
//通过正则表达式校验数据的合法性
var rValid = DataValidator.TryValid(item);
if (!rValid.IsSuccess)
{
return string.Join(";", rValid.Results.Select(t => $"{t.PropertyName}:{t.ErrorMessage}"));
}
var _item = dbContext.Insert(item);
afterInsert(_item);
return _item;
}
catch(Exception ex)
{
cyb.Utility.Tools.LogHelper.Error("错误信息....",ex);
return ex;
}
}
其他数据操作就不一一举例,但是,封装的任务操作数据库的方法,都必须带有try...catch这样的代码,并且在出现异常的时候做日志处理和返回异常对象。
程序员在使用DAL的时候,并不需要关心是否会报错,这样DAL就会自动记录下详细的数据库操作日志,如果出现报错,很容易就定位到错误的位置以及执行相关的sql脚本;如果需要调优或者查卡顿的情况,执行事件中的执行时长很容易就找出每次请求所消耗的时间,无需程序员一步步调试,且还能准确还原出出错或者超时的情景。当然,DAL封住肯定不仅仅文中提到的几个操作方法,也不可能一一说的清楚。
DAL中的Dictionary<string,ICondition>查询参数,是一个技巧,通过这个参数,前端可以动态添加查询条件,而后端是无需修改任何一行代码的,当然,查询条件只能查当前表的。DAL建立了一个解析类,把Dictionary<string,ICondition>转换成了SQL脚本再执行,这里是一个很复杂的一个体系,文中也不一一说明,有兴趣的可以加QQ:2558863,标注请求的原因一起讨论。
DAL解决了所有异常问题和日志问题,其实已经解决了程序大部分的异常,往往程序员操作数据库的时候,都没有刻意去记录日志或者容错的措施,通过框架封装好的DAL基类,一般是不需要在派生的DAL中写任何代码,即
public class DALEmployee:BaseDAL<Employee>,IDALEmployee
{
public DALEmployee():base(SQLHelper.ConnteciontString)
{
}
}
一般要求程序员在每个项目中的每层,都要自己建立一个基类,哪怕没有任何代码,以便后续批量修改派生类的一些共性参数或者初始化环境,例如
public abstract class MyBaseDAL:BaseDAL<T>,IMyBaseDAL<T>
where T:class
{
public MyBaseDAL():base(SQLHelper.ConnteciontString)
{
}
}
public class DALEmployee:MyBaseDAL<Employee>,IDALEmployee
{
}
这里,已经初始化好连接字符串,无需每个DAL的派生类都要定义一个构造函数,如果不同派生DAL访问不同数据库,那么多定义几个基类就可以了,如果后续有什么变动,也不用每个派生DAL去修改连接字符串。后边的业务层同样是定义这么一个书写规则。
至此,数据层的事务、读写、日志、容错等主要功能都已经满足了,后续开发就可以完全不需要担心数据库操作问题,可以把精力重点放到业务层上面去了。