剑客
关注科技互联网

.NET仓储模式高级用例

主要结论

  • 如果需要执行基本CURD之外的其他操作,此时就有必要使用仓储(Repository)。
  • 为了促进测试工作并改善可靠性,应将仓储视作可重复使用的库(Library)。
  • 将安全和审计功能放入仓储中可减少Bug并简化应用程序。
  • 对ORM的选择不会限制仓储的用途,只会影响仓储承担的工作量。

在之前发布的文章 使用实体框架、Dapper和Chain的仓储模式实现策略 中,我们介绍了实现仓储所需的基本模式。很多情况下,这些模式只是围绕底层数据访问技术,本质上并非完全必要的薄层。然而通过构建这样的仓储将获得很多新的机会。

在设计仓储时,需要从“必须发生的事”这个角度来思考。例如,假设制订了一条规则,每当一条记录被更新后,其“LastModifiedBy”列必须设置为当前用户。但我们并不需要在每次保存前更新应用程序代码中的LastModifiedBy,可以直接将相关函数放在仓储中。

通过将数据访问层视作管理所有“必须发生的事情”细节的独立库,即可大幅减少实现过程中的错误数量。与此同时可以简化基于仓储构建的代码,因为已经不再需要考虑“记账”之类的任务。

注意:本文会尽量提供适用于 实体框架(Entity Framework)Dapper 和/或 Tortuga Chain 的代码范例,然而大部分仓储功能均可通过不依赖具体ORM的方式实现。

审计列

大部分应用程序最终需要追踪谁在什么时间更改了数据库。对于简单的数据库,这是通过审计列(Audit column)的形式实现的。虽然名称可能各不相同,但审计列通常主要承担下列四个角色:

  • 创建者的User Key
  • 创建日期/时间
  • 最后修改者的User Key
  • 最后修改日期/时间

取决于应用程序的安全需求,可能还存在其他审计列,例如:

  • 删除者的User Key
  • 删除日期/时间
  • [创建 | 最后修改 | 删除] 者的Application Key
  • [创建 | 最后修改 | 删除] 者的IP地址

从技术角度来看日期列很容易处理,但User Key的处理就需要费些功夫了,这里需要的是“可感知上下文的仓储”。

常规的仓储是无法感知上下文的,这意味着除了连接数据库时绝对必要的信息,仓储无法获知其他任何信息。如果能正确地设计,仓储可以是彻底无状态(Stateless)的,这样即可在整个应用程序中共享一个实例。

可感知上下文的仓储略微复杂。除非了解上下文,否则无法创建这种仓储,而上下文至少要包含当前活跃用户的ID和Key。对于某些应用程序这就够了,但对于其他应用程序,我们可能还需要传递整个用户对象和/或代表运行中应用程序的对象。

Chain

Chain通过一种名为审计规则(Audit rule)的功能为此提供了内建的支持。审计规则可供我们根据列名指定要覆盖(Override)的值。该功能包含了拆箱即用的基于日期的规则,以及从用户对象将属性复制到列的规则。范例:

dataSource = dataSource.WithRules(
    new UserDataRule("CreatedByKey", "UserKey", OperationType.Insert),
    new UserDataRule("UpdatedByKey", "UserKey", OperationType.InsertOrUpdate),
    new DateTimeRule("CreatedDate", DateTimeKind.Local, OperationType.Insert),
    new DateTimeRule("UpdatedDate", DateTimeKind.Local, OperationType.InsertOrUpdate)
    );

如上所述为了实现这一点我们需要一种可感知上下文的仓储。从下列构造函数中可以看到如何将上下文传递给不可变数据源,并使用必要信息新建数据源。

public EmployeeRepository(DataSource dataSource, User user)
{
    m_DataSource = dataSource.WithUser(user);
}

借此即可使用自行选择的DI框架针对每个请求自动创建并填写仓储。

实体框架

为了在实体框架中实现审计列的全局应用,我们需要利用ObjectStateManager并创建一个专用接口。该接口(如果愿意也可以称之为“基类(Base class)”)看起来类似这样:

public interface IAuditableEntity 
{
    DateTime CreatedDate {get; set;}
    DateTime UpdatedDate {get; set;}
    DateTime CreatedDate {get; set;}
    DateTime CreatedDate {get; set;}
}

随后该接口(或基类)会应用给数据库中与审计列匹配的每个实体。

随后需要通过下列方式对DataContext类的Save方法进行覆盖(Override)。

public override int SaveChanges()
{
    // Get added entries
    IEnumerable<ObjectStateEntry> addedEntryCollection = Context
        .ObjectContext
        .ObjectStateManager
        .GetObjectStateEntries(EntityState.Added)
        .Where(m => m != null && m.Entity != null);

    // Get modified entries
    IEnumerable<ObjectStateEntry> modifiedEntryCollection = Context
        .ObjectContext
        .ObjectStateManager
        .GetObjectStateEntries(EntityState.Modified)
        .Where(m => m != null && m.Entity != null);

    // Set audit fields of added entries
    foreach (ObjectStateEntry entry in addedEntryCollection)
    {                
        var addedEntity = entry.Entity as IAuditableEntity;
        if (addedEntity != null)
        {
            addedEntity.CreatedDate = DateTime.Now;
            addedEntity.CreatedByKey = m_User.UserKey;
            addedEntity.UpdatedDate = DateTime.Now;
            addedEntity.UpdatedByKey = m_User.UserKey;
        }

    }

    // Set audit fields of modified entries
    foreach (ObjectStateEntry entry in modifiedEntryCollection)
    {
        var modifiedEntity = entry.Entity as IAuditableEntity;
        if (modifiedEntity != null)
        {
            modifiedEntity.UpdatedDate = DateTime.Now;
            modifiedEntity.UpdatedByKey = m_User.UserKey;
        }
    }
    return SaveChanges();
}

如果需要大量使用实体框架(EF),则有必要非常熟悉ObjectStateManager及其能力。因为有关进行中事务的大部分有用元数据都包含在ObjectStateManager中。

最后还需要修改数据上下文(可能还有仓储)的构造函数以使其接受用户对象。

虽然看似这要编写大量代码,但每个EF数据上下文只需要编写一次。与上文的范例类似,数据上下文和仓储的实际创建工作可由DI框架负责进行。

历史表

很多地方性的法规和制度要求对记录的改动进行追踪,此外这样做也可以简化诊断工作。

对此的常规建议是直接让数据库自行处理。一些数据库内建包含了类似的功能,这类功能通常叫做 时间表(Temporal table) 。其他数据库则可使用触发器模拟出类似的功能。无论哪种情况,应用程序都不会发现额外的日志操作,因此这种技术出错的概率也得以大幅降低。

如果出于某些原因无法使用时间表或触发器,那么仓储需要能明确写入历史表。

无论将维持历史表的代码放在哪里,都有两个基本惯例需要遵循。一致性在这里真的很重要,如果一些表遵循一个惯例,其他表遵循另一个管理,最终只能造成混乱。

写入前复制:在这个惯例中,需要在实际执行更新或删除操作前将老的记录从活动(Live)表复制到历史表。这意味着历史表绝对不会包含当前记录。因此需要将活动表和历史表联接在一起才能看到完整的变更历史。

复制前写入:或者可以首先更新活动表,随后将该行复制到历史表。这种做法的优势在于历史表中包含完整的记录,无需进行上文提到的联接。不足之处在于,由于这种做法需要复制数据,因此会耗费更多空间。

无论哪种惯例,都可以使用软删除了解是谁实际删除了行。如果需要使用硬删除,也只能在执行软删除之后再进行硬删除。

软删除

使用仓储可获得的另一个优势在于可以在应用程序无法察觉的情况下从硬删除切换为软删除。软删除可用能被应用程序察觉的方式删除记录,但删除的记录可继续保留在数据库中,以便用于审计等用途。此外在必要时应用程序还可以恢复被软删除的记录。

为避免数据丢失,不应针对为软删除提供支持的表为应用程序分配DELETE特权。如果应用程序无意中试图执行硬删除,权限检查功能会显示错误信息,而不会直接删除行。

Chain

Chain通过自己的审计规则基础架构提供了隐式的软删除支持。在配置软删除规则后,按照习惯还需要配置匹配审计(Matching audit)列:

var dataSource = dataSource.WithRules(
    new SoftDeleteRule("DeletedFlag", true, OperationTypes.SelectOrDelete),
    new UserDataRule("DeletedByKey", "EmployeeKey", OperationTypes.Delete),
    new DateTimeRule("DeletedDate", DateTimeKind.Local, OperationTypes.Delete)
    );

在发现表包含软删除列(例如本例中的DeletedFlag)后,会自动发生两件事:

  • 所有查询的WHERE子句可暗中添加“AND DeletedFlag = 0”。
  • 所有对DataSource.Delete的调用将变成更新(Update)语句以设置 deleted flag。

实体框架

在实体框架中,可以在读取为软删除提供支持的表的每个查询中包含一个额外的Where子句。此外还需要将任何删除操作手工转换为更新操作,使用对象图(Object graph)时这一点可能较难办到。

另一种方法的过程较繁琐,但可能更不易出错。首先在DataContext.OnModelCreating覆盖中明确列出每个支持软删除的表。

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
   modelBuilder.Entity<Employee>().Map(m => m.Requires("IsDeleted").HasValue(false));
}

随后需要覆盖Save方法以确保删除操作可变成更新操作。 Stackoverflow上的Colin 提供了这种模式。

public override int SaveChanges()
{
   foreach (var entry in ChangeTracker.Entries()
             .Where(p => p.State == EntityState.Deleted 
             && p.Entity is ModelBase))
    SoftDelete(entry);
    return base.SaveChanges();
}

private void SoftDelete(DbEntityEntry entry)
{
    var e = (ModelBase)entry.Entity;
    string tableName = GetTableName(e.GetType());
    Database.ExecuteSqlCommand(
             String.Format("UPDATE {0} SET IsDeleted = 1 WHERE ID = @id", tableName)
             , new SqlParameter("id", e.ID));

    //Marking it Detached prevents the hard delete
    entry.State = EntityState.Detached;
}

建议阅读Colin回答中的剩余内容,这些回答解决了很多边界案例问题。

访问日志记录

虽然审计列、历史表,以及软删除均适用于写入操作场景,但有时候可能还要用日志记录读取操作。例如美国医疗健康行业中,医护人员需要能够在紧急情况下访问病患的医疗记录。但在正常业务中他们只有在为病患提供治疗的过程中可以合法访问这些记录。

由于记录不能彻底锁定,因此作为权宜之计只能追踪读取过每条记录的人的身份。在仓储层面上,只需要对每个涉及敏感数据的查询进行日志记录即可轻松实现。最简单的方法是在相关仓储方法的基础上手工实现。

性能日志

用户体验已成为一项功能,因此我们有必要了解每个查询到底要花费多长时间。单纯追踪每页面性能还不够,因为一个页面可能涉及多个查询。对于实体框架这一点尤为重要,因为延迟加载(Lazy-loading)可能会将数据库调用隐藏起来。

仓储中的显式日志记录

虽然很枯燥并且很容易漏掉某个查询,但可将每个查询封装到“即抛型”计时器中。具体模式如下:

public class OperationTimer : IDisposable
{
    readonly object m_Context;
    readonly Stopwatch m_Timer;

    public OperationTimer(object context)
    {
        m_Context = context;
        m_Timer = Stopwatch.StartNew();
    }
    public void Dispose()
    {
        //Write to log here using timer and context
    }
}

具体用法为:

using(new OperationTimer("Load employees"))
{
    //execute query here
}

Chain

Chain在数据源层面上暴露了一系列事件。本例需要的是 DataSource.ExecutionFinished 。范例如下:

static void DefaultDispatcher_ExecutionFinished(object sender, ExecutionEventArgs e)
{
    Debug.WriteLine($"Execution finished: {e.ExecutionDetails.OperationName}. Duration: {e.Duration.Value.TotalSeconds.ToString("N3")} sec. Rows affected: {(e.RowsAffected != null ? e.RowsAffected.Value.ToString("N0") : "<NULL>")}.");
}

此外还可将句柄附加到 DataSource.GlobalExecutionFinished ,借此侦听来自所有数据源的事件。

实体框架

实体框架内建的日志能力无法衡量每个查询所需的时间。为了消除这种局限,我们可以使用自定义的 IDbCommandInterceptor

public class EFLoggerForTesting : IDbCommandInterceptor
{
    static readonly ConcurrentDictionary<DbCommand, DateTime> m_StartTime = new ConcurrentDictionary<DbCommand, DateTime>();

    public void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
    {
        Log(command, interceptionContext);
    }

    public void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
        Log(command, interceptionContext);
    }

    public void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
        Log(command, interceptionContext);
    }

    private static void Log<T>(DbCommand command, DbCommandInterceptionContext<T> interceptionContext)
    {
        DateTime startTime;
        TimeSpan duration;

        m_StartTime.TryRemove(command, out startTime);
        if (startTime != default(DateTime))
        {
            duration = DateTime.Now - startTime;
        }
else
            duration = TimeSpan.Zero;

        string message;

        var parameters = new StringBuilder();
        foreach (DbParameter param in command.Parameters)
        {
            parameters.AppendLine(param.ParameterName + " " + param.DbType + " = " + param.Value);
        }

        if (interceptionContext.Exception == null)
        {
            message = string.Format("Database call took {0} sec. RequestId {1} /r/nCommand:/r/n{2}", duration.TotalSeconds.ToString("N3"), requestId, parameters.ToString() + command.CommandText);
        }
        else
        {
            message = string.Format("EF Database call failed after {0} sec. RequestId {1} /r/nCommand:/r/n{2}/r/nError:{3} ", duration.TotalSeconds.ToString("N3"), requestId, parameters.ToString() + command.CommandText, interceptionContext.Exception);
        }

        Debug.WriteLine(message);
    }


    public void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
        OnStart(command);
    }

    public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
    {
        OnStart(command);
    }

    public void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
        OnStart(command);
    }

    private static void OnStart(DbCommand command)
    {
        m_StartTime.TryAdd(command, DateTime.Now);
    }
}

虽然这种方式无法获取上下文数据,但可酌情将上下文推出(Shove)至 ThreadLocalAsyncLocal 以绕过这一局限。

权限检查 – 表级

虽然可执行应用程序级别的权限检查,但同时强制进行仓储级的检查也能提供一定的好处。这种做法可以避免忘了对新创建的Screen/页面进行权限检查。

仓储强制执行

实现这一切最简单的方法是在每个相关函数开始时执行角色检查。例如:

public int Insert(Employee employee)
        {
            if (!m_User.IsAdmin)
                throw new SecurityException("Only admins may add employees");

数据库强制执行

更成熟的做法是创建多个连接字符串。在创建仓储时,可根据用户角色选择连接字符串。在本例中非管理员用户的连接字符串针对employee表不具备INSERT特权。

由于复杂度和繁琐的维护,除非需要多层防御机制,对安全性要求极高的环境,否则不建议使用这种方法。就算在这种情况下,也需要通过大量的自动化测试确保每个连接字符串只包含自己需要的全部权限。

权限检查 – 列级

有时候可能需要进行列级的权限检查。例如我们可能需要防止用户为自己分配管理员特权,或可能希望阻止经理之外的其他用户查看员工的薪资数据。

Chain

Chain可以利用自带的审计规则功能实现列级权限检查。此时会将匿名函数与列名称,以及受限制操作列表一起传递至 RestrictColumn 构造函数。(并可选指定表名称。)

var IsAdminCheck = user => ((User)user).IsAdmin;

dataSource = dataSource.WithRules(
    new RestrictColumn("Users", "IsAdmin", OperationTypes.Insert|OperationTypes.Update, IsAdminCheck));

为防止读取受限制的列,可将其传递至 OperationTypes.Select flag

Dapper

在Dapper中实现这一目标的最简单方法是使用多个SQL语句。如果用户缺乏某一特权,只需要选择忽略对应列的SQL语句即可。

实体框架

查询可使用下列几个选项:

  1. 根据用户角色手工创建不同的投影(例如Select子句)。
  2. 正常执行查询,随后如果权限检查失败,对结果集进行循环,并将受限制的属性设置为null/0。

对于插入,按照上述方法将受限制属性留空即可。

更新操作较为复杂。当写入特定列的操作受限时将无法附加实体。此时需要重新获取原始记录,对允许的值进行复制并保存该对象,而不要保存应用程序代码传递来的对象。(基本上这就是上一篇文章提到的“新手”模式。)

将一个模型映射至多个表

数据架构方面有一个很重要的概念,即:无需在表和类之间创建一对一映射。为了让数据库的运转更高效或满足特定业务规则的需求,通常可能需要将一个类映射至多个表。

假设需要记录有关棒球队的数据,可能会用到这些表:

主键

Team

Team

TeamSeasonMap

TeamKey+SeasonKey

如果应用程序只能在有关赛季(Season)的上下文中理解团队(Team)的概念,那么可以用一个Team对象涵盖所有表。

Chain

Chain中的类和表之间不具备强关系,这意味着对于更新操作,应该这样写代码:

dataSource.Update("Team", myTeam).Execute();
dataSource.Update("TeamSeasonMap", myTeam).Execute();

代码运行时会判断哪些表适用哪些属性,并酌情生成SQL语句。

通过这种方式,即可从所有表的联接视图中获取Team对象。(Chain不支持直接联接,假设始终通过视图实现。)

实体框架

实体框架会认为映射至同一实体的多个表严格共享相同的主键。这意味着将无法支持该场景。

  • 对于读取操作,可以使用EF的常规LINQ语法执行联接和投影。
  • 对于更新操作,需要将每个表的模型复制到单独的实体中。

缓存

一般来说,仓储都需要考虑缓存问题。由于仓储知道数据的修改时间,因此可充当处理缓存失效问题的最佳方法。

Chain

Chain支持缓存,但必须通过Appender分别应用给每个查询。Appender可附加至实际执行之前的操作中,在本例中我们需要关注四个Appender:

  • .Cache(…)
  • .CacheAllItems(…)
  • .InvalidateCache(…)
  • .ReadOrCache(…)

也许通过仓储范例可以更好地说明这些Appender的作用。在下面的例子中可以看到对特定记录创建缓存,以及使用 CacheAllItems 对集合创建缓存这两种做法之间的相互作用。

public class EmployeeCachingRepository
{

    private const string TableName = "HR.Employee";
    private const string AllCacheKey = "HR.Employee ALL";

    public IClass1DataSource Source { get; private set; }
    public CachePolicy Policy { get; private set; }

    public EmployeeCachingRepository(IClass1DataSource source, CachePolicy policy = null)
    {
        Source = source;
        Policy = policy;
    }

    protected string CacheKey(int id)
    {
        return $"HR.Employee EmployeeKey={id}";
    }

    protected string CacheKey(Employee entity)
    {
        return CacheKey(entity.EmployeeKey.Value);
    }

    public Employee Get(int id)
    {
        return Source.GetByKey(TableName, id).ToObject<Employee>().ReadOrCache(CacheKey(id), policy: Policy).Execute();
    }

    public IList<Employee> GetAll()
    {
        return Source.From(TableName).ToCollection<Employee>().CacheAllItems((Employee x) => CacheKey(x), policy: Policy).ReadOrCache(AllCacheKey, policy: Policy).Execute();
    }

    public Employee Insert(Employee entity)
    {
        return Source.Insert(TableName, entity).ToObject<Employee>().InvalidateCache(AllCacheKey).Cache((Employee x) => CacheKey(x), policy: Policy).Execute();
    }

    public Employee Update(Employee entity)
    {
        return Source.Update(TableName, entity).ToObject<Employee>().Cache(CacheKey(entity)).InvalidateCache(AllCacheKey).Execute();
    }

    public void Delete(int id)
    {
        Source.DeleteByKey(TableName, id).InvalidateCache(CacheKey(id)).InvalidateCache(AllCacheKey).Execute();
    }
}

从这个例子中可以发现,Chain为失效逻辑提供了丰富的控制能力,但作为代价我们必须慎重地指定各种选项。

实体框架

实体框架提供了两种级别的缓存。第一级仅限数据上下文,主要可用于确保对象图不包含代表同一条物理数据库记录的重复实体。由于该缓存会与数据上下文一起销毁,因此大部分缓存场景并不使用这种缓存。

在EF的术语中,我们需要的是名为“二级缓存”的缓存。虽然该功能已包含在EF 5中,但第6版实体框架并未提供任何拆箱即用的缓存功能。因此我们需要使用第三方库,例如 EntityFramework.CacheEFSecondLevelCache 。从列举的这些库可以知道,为EF增加二级缓存并没有什么标准的模式。

关于本文作者

Jonathan Allen 的第一份工作是在九十年代末期为一家诊所开发MIS项目,借此帮助这家诊所逐渐由Access和Excel转向真正的企业级解决方案。在用五年时间为财政部开发自动化交易系统后,他开始担任各种项目的顾问,并从事了仓储机器人UI、癌症研究软件中间层,以及大型房地产保险企业大数据需求等各类项目。闲暇时他喜欢研究并撰文介绍16世纪的格斗术。

作者: Jonathan Allen阅读英文原文Advanced Use Cases for the Repository Pattern in .NET

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址