【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

.NET 仓储模式高级用例

  • 2016-11-07
  • 本文字数:8989 字

    阅读完需:约 29 分钟

主要结论

  • 如果需要执行基本 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)至 ThreadLocal AsyncLocal 以绕过这一局限。

权限检查 – 表级

虽然可执行应用程序级别的权限检查,但同时强制进行仓储级的检查也能提供一定的好处。这种做法可以避免忘了对新创建的 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.Cache EFSecondLevelCache 。从列举的这些库可以知道,为 EF 增加二级缓存并没有什么标准的模式。

关于本文作者

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

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

2016-11-07 16:584768
用户头像

发布了 283 篇内容, 共 101.9 次阅读, 收获喜欢 61 次。

关注

评论

发布
暂无评论
发现更多内容

Java 多线程 —— 定时器,svnlinux使用教程

Java 程序员 后端

java 数据结构与算法之稀疏矩阵算法,BTAJ面试有关散列(哈希)表的面试题详解

Java 程序员 后端

Java-进阶:集合框架1,java三个技术平台

Java 程序员 后端

java8实战读书笔记:Lambda表达式语法与函数式编程接口

Java 程序员 后端

Jaeger知识点补充,java菜鸟教程面向对象

Java 程序员 后端

如何编写 Go 包

baiyutang

golang 11月日更

Java 世界里的垃圾回收规则你搞懂了吗?,springboot输出视频流

Java 程序员 后端

Java 虚拟机1:什么是 Java,太完整了

Java 程序员 后端

Java中使用Spring-security(一),java做视频直播

Java 程序员 后端

Java 虚拟机1:什么是 Java(1),DubboSPI及自适应扩展原理

Java 程序员 后端

java-集合-Map(双列)——迪迦重制版(1),关于线程池的五种实现方式

Java 程序员 后端

java-集合-Map(双列)——迪迦重制版,zookeeper面试

Java 程序员 后端

java8实战读书笔记:初识Stream、流的基本操作,nginx架构原理

Java 程序员 后端

JavaFx:窗口切换和ListView以及TableView的值绑定,docker面试题

Java 程序员 后端

JavaWeb Ajax详解,linux操作系统基础教程安俊秀课后答案

Java 程序员 后端

jackson学习之八:常用方法注解(1),java虚拟机实现原理

Java 程序员 后端

Java this关键字详解(3种用法),Java程序员最新职业规划

Java 程序员 后端

Java-进阶:集合框架1(1),java分布式系统面试题

Java 程序员 后端

JavaWeb学习笔记6——事务实例,我的支付宝3面+美团4面+拼多多四面

Java 程序员 后端

jackson学习之八:常用方法注解,复习指南

Java 程序员 后端

Java 里面的异常,java语言程序设计教程pdf

Java 程序员 后端

Java-Parallel GC介绍,springmvc面试题高级

Java 程序员 后端

Java 反射:框架设计的灵魂,springboot运行原理

Java 程序员 后端

JAVA 微信小程序 解密 用户信息encryptedData,linux系统架构与目录解析

Java 程序员 后端

JAVA 获取系统日期时间,java基础百度云

Java 程序员 后端

JavaWeb之Servlet技术(二),java基础程序设计题

Java 程序员 后端

James邮件服务器,高级java工程师简历模板

Java 程序员 后端

Java 低代码开发平台“光”发布 2,javapdf模板下载百度云

Java 程序员 后端

Java 高并发之设计模式,深入linux内核架构mobi

Java 程序员 后端

Java transient关键字的使用,java商城项目面试

Java 程序员 后端

Java 方法的使用(方法重载、形参和实参调用关系,java高级面试最新

Java 程序员 后端

.NET仓储模式高级用例_.NET_Jonathan Allen_InfoQ精选文章