Aoite 系列(04) - 强劲的 CommandModel 开发模式(上篇)
Aoite 是一个适于任何 .Net Framework 4.0+ 项目的快速开发整体解决方案。Aoite.CommandModel 是一种开发模式,我把它成为“命令模型”,这是一种非常有意思的开发模式。
赶紧加入 Aoite GitHub 的大家庭吧!!
1. 概述
CommandModel 的架构并不复杂,核心四大组件分别是:命令(Command)、执行器(Executor)、上下文(Context)和事件(Event)。
CommandModel 核心是剥离所有运行期的所有依赖,注入执行。它可以运用至传统的三层架构,也可以运用到 DDD(CQRS)架构。
不是只能应用到三层架构,只是以最传统最简单的三层架构作为比较。CommandModel 支持任何架构、模式,无论是 Web 和 Winform,亦或者 ASP.NET 和 MVC,亦或者三层架构或领域驱动。请看官不要纠结这些问题。
传统三层架构是这样的(实体层意义上包含 数据库实体 和 视图模型 ):
如果将 CommandModel 加入三层架构,那么它将变成以下架构:
注入 CommandModel 模式以后,原本的数据访问层不见了,变成了命令层,而命令层是由一个或多个命令(以及对应的一个或多个执行器)组成的集合。
也就是说,CommandModel 其实是将数据访问层进行粒度分解。
CommandModel 的优点:
- 简化单元测试工作量。传统三层架构(或延伸的各种结构),在单元测试模拟时,往往需要实现整个接口。通过 CommandModel 可以实现非常细粒度的单元测试。
- 基于服务容器的依赖注入。可以针对每个命令的执行前和执行后进行拦截处理。
- 支持命令级的缓存。例如:获取积分排行前十的用户列表。
- 支持命令集级的事务。
1.1 命令(Command)###
命令是一个符合单一职责的设计原则。通过命令的名称(Name)、参数(Properties)和返回结果(Result),它应该非常直观的表达出命令的目的。比如“查询用户编号为?的用户信息”,这就是一个典型的命令。
以下代码则是一个典型的命令(命令的名称可以以 Command 结尾,也可以不以 Command 结尾,这并非强制性的规则,并且两种方式都支持):
public class FindUserById : ICommand<User>
{
//- 输入参数
public long Id { get; set; }
//- 输出参数
public User ResultValue { get; set; }
}
具有返回值的命令实现 ICommand<TResultValue>
接口,没有返回值则直接实现 ICommand
接口
1.2 执行器(Executor)###
如果把命令比作一个方法签名,显然执行器对应的则是方法实现。从这个角度来看,命令(Command)和执行器(Executor)是相互依赖的。
执行器是单例模式。一个命令若执行了无数次,执行器只会初始化一次。
比如对应 1.1 节代码的执行器应该是这样:
public class FindUserByIdExecutor : IExecutor<FindUserById>
{
public void Execute(IContext context, FindUserById command)
{
//- 业务代码, context 和 command 参数永不为 null 值
}
}
每一个执行器都必须实现 IExecutor<TCommand>
接口。
如果该命令具有返回值,方法实现内部应该有 command.ResultValue = ...
的代码。
关于命令和执行器是如何绑定关系,请往下查看第 2 节的内容。
1.3 上下文(Context)
上下文在每一次命令的执行都会产生新的实例。其接口的定义如下所示:
// 摘要:
// 定义一个执行命令模型的上下文。
public interface IContext : IContainerProvider
{
// 摘要:
// 获取正在执行的命令模型。
ICommand Command { get; }
//
// 摘要:
// 获取执行命令模型的其他参数,参数名称若为字符串则不区分大小写的序号字符串比较。
HybridDictionary Data { get; }
//
// 摘要:
// 获取上下文中的 System.IDbEngine 实例。该实例应不为 null 值,且线程唯一。
// * 不应在执行器中开启事务。
IDbEngine Engine { get; }
//
// 摘要:
// 获取执行命令模型的用户。该属性可能返回 null 值。
[Dynamic]
dynamic User { get; }
// 摘要:
// 获取或设置键的值。
//
// 参数:
// key:
// 键。
//
// 返回结果:
// 返回一个值。
object this[object key] { get; set; }
}
- Command:上下文中的抽象命令。
- Data:临时数据存储的字典,生命周期仅限命令执行期间。
- Engine:在当前命令模型上下文中的线程上下文的引擎上下文。简单的说,就是在当前线程中唯一的数据库操作引擎。
- User:在整个运行环境中,假设用户已登录授权,这里存储的便是已授权的用户信息。若想实现此功能,必须实现
IUserFactory
接口。
上下文(Context)在整个 CommandModel 中具有非常特殊的意义。比如通过事件(Event)提前定义特殊数据存储在 Context.Data
,执行器再根据不同的特殊数据处理不同的业务逻辑。亦或者,它允许了在同一线程里执行若干个命令,而不会重复、多余打开数据库连接;也可以将定义一个事务范围,控制所有的命令执行有效性。
1.4 事件(Event)
事件可以让每一个命令的执行得到有效控制,其的意义类似 HTTP 中 BeginRequest
和 EndRequest
。
事件可以做的事情非常多,它让 CommandModel 具备无限扩展的可能。比如常见的命令拦截执行、修改命令参数、命令缓存和日志管理等等……
2. 快速入门
上面说了很多概念性的东西,现在让我们实际操作一下,看看 CommandModel 是如何运用的。
2.1 普通命令
业务上定义了一个目的:查询用户编号为?的用户信息。完整代码如下所示:
public class User
{
public string Username { get; set; }
public string Password { get; set; }
}
public class FindUserById : ICommand<User>
{
public long Id { get; set; }
public User ResultValue { get; set; }
class Executor : IExecutor<FindUserById>
{
public void Execute(IContext context, FindUserById command)
{
if(command.Id == 1)
{
command.ResultValue = new User() { Username = "admin", Password = "123456" };
}
}
}
}
我们通过控制台来试着执行这个命令:
var container = new IocContainer();
var bus = new CommandBus(container);
var result = bus.Execute(new FindUserById { Id = 1 }).ResultValue;
Console.WriteLine("{0}\t{1}", result.Username, result.Password);
以上代码最终输出
admin 123456
2.2 泛型命令
泛型命令是一个具有非常大扩展性的功能。我们来定义几个实体:
public interface IPerson
{
string Name { get; set; }
}
public class Student : IPerson
{
public string Name { get; set; }
}
public class Teacher : IPerson
{
public string Name { get; set; }
}
创建命令:
class PersonModify<T> : ICommand where T : IPerson
{
public T Person { get; set; }
class Executor : IExecutor<PersonModify<T>>
{
public void Execute(IContext context, PersonModify<T> command)
{
if(command.Person is Teacher)
{
command.Person.Name = command.Person.Name + "老师";
}
else if(command.Person is Student)
{
command.Person.Name = command.Person.Name + "学生";
}
}
}
}
测试代码:
var container = new IocContainer();
var bus = new CommandBus(container);
var person = new Student { Name = "张三" };
bus.Execute(new PersonModify<Student> { Person = person });
Console.WriteLine(person.Name);
最终输出结果便是:张三学生
。
3 缓存
CommandModel 默认实现了缓存的功能,支持内存缓存(容器范围内)和 Redis 缓存。由于缓存的示例代码较多,并且其十分重要,所以我单独拿出一个篇章描述缓存。
使用缓存需要知道的三个重要内容“
CacheAttribute
:命令必须包含此特性,表示这是具有缓存功能的命令。它还要求使用者提供一个关键参数group
,这是一个不能为空的参数。它的作用是用于区分key
。比如根据部门编号进行缓存,那么group
则是Dept
,而key
则是Id
。ICommandCache
:命令必须实现此接口,此接口有三个作用:获取缓存策略、设置缓存值和获取缓存值。ICommandCacheStrategy
:缓存策略,在实现接口ICommandCache
接口的CreateStrategy(IContext context)
方法返回值。默认接口实现CommandCacheStrategy
,其特点是:支持绝对间隔过期方式、支持滑动间隔过期方式、支持基于内存的缓存、支持 Redis 的缓存。可以继承这个类,来进行更多的扩展。
3.1 创建具有缓存效果的命令
[Cache("User")]
public class GetDate : ICommand<DateTime>, ICommandCache
{
//- 根据传入的用户编号,获取一个时间
public long UserId { get; set; }
public DateTime ResultValue { get; set; }
class Executor : IExecutor<GetDate>
{
public void Execute(IContext context, GetDate command)
{
command.ResultValue = DateTime.Now.AddDays(command.UserId); //- 当前时间加上 UserId 值的天数
}
}
//- 缓存策略,弹性 3 秒内缓存
ICommandCacheStrategy ICommandCache.CreateStrategy(IContext context)
{
return new CommandCacheStrategy(UserId.ToString(), TimeSpan.FromSeconds(3), this, context);
}
//- 返回需缓存的内容
object ICommandCache.GetCacheValue()
{
return this.ResultValue;
}
//- 设置缓存值,若值不合法必须返回 false,否则执行器永不会执行
bool ICommandCache.SetCacheValue(object value)
{
if(value is DateTime)
{
this.ResultValue = (DateTime)value;
return true;
}
return false;
}
3.2 缓存测试代码
var container = new IocContainer();
var bus = new CommandBus(container);
for(int i = 0; i < 6; i++)
{
//- 0、1、2
Console.WriteLine("{0} -> {1}", i % 3, bus.Execute(new GetDate() { UserId = i % 3 }).ResultValue);
}
Console.WriteLine("开始休眠 3 秒...");
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(3));
Console.WriteLine("结束休眠 3 秒...");
for(int i = 0; i < 6; i++)
{
//- 0、1、2
Console.WriteLine("{0} -> {1}", i % 3, bus.Execute(new GetDate() { UserId = i % 3 }).ResultValue);
}
Console.WriteLine("测试 5 次,每次间隔 2 秒...");
for(int i = 0; i < 5; i++)
{
Console.WriteLine("{0} -> {1}", 99, bus.Execute(new GetDate() { UserId = 99 }).ResultValue);
Console.WriteLine("开始休眠 2 秒,避免缓冲过期...");
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(2));
}
最终输出结果:
0 -> 2015/2/6 16:40:46
1 -> 2015/2/7 16:40:46
2 -> 2015/2/8 16:40:46
0 -> 2015/2/6 16:40:46
1 -> 2015/2/7 16:40:46
2 -> 2015/2/8 16:40:46
开始休眠 3 秒...
结束休眠 3 秒...
0 -> 2015/2/6 16:40:49
1 -> 2015/2/7 16:40:49
2 -> 2015/2/8 16:40:49
0 -> 2015/2/6 16:40:49
1 -> 2015/2/7 16:40:49
2 -> 2015/2/8 16:40:49
测试 5 次,每次间隔 2 秒...
99 -> 2015/5/16 16:40:49
开始休眠 2 秒,避免缓冲过期...
99 -> 2015/5/16 16:40:49
开始休眠 2 秒,避免缓冲过期...
99 -> 2015/5/16 16:40:49
开始休眠 2 秒,避免缓冲过期...
99 -> 2015/5/16 16:40:49
开始休眠 2 秒,避免缓冲过期...
99 -> 2015/5/16 16:40:49
开始休眠 2 秒,避免缓冲过期...
3.2 使用 Redis 作为缓存提供程序
非常简单,只要往 Container(服务容器)添加 IRedisProvider
,即刻支持 Redis!默认实现的 RedisProvider
取得是 Aoite.Redis.RedisManager.Context
。
4. 进阶内容
进阶内容包含了更多关于 CommandModel 的内容。
提醒:在多线程中使用了 System.Db.Context
或 Aoite.Redis.RedisManager.Context
,你应该在线程结束中调用 GA.ResetContexts
。比如说,在 HTTP Application 中,每一个请求结束,都应当调用 GA.ResetContexts
(如果你使用了 Aoite.Web 框架,则不需要手工调用)。
4.1 命令和执行器的映射
一个命令是如何与执行器进行映射的,其映射的优先级和规则如下:
- 命令包含了
BindingExecutorAttribute
特性。此特性可以指定执行器的数据类型(也可以是一个泛型)。 - 命令的嵌套类型,并且类型名称为“Executor”。这是推荐的用法。
- 相同命名空间下,命令名称(若以 Command 为后缀则会去掉 Command)加上“Executor”。
示例1:命令以 Command 结尾。
class Simple1Command : ICommand {}
class Simple1Executor : IExecutor<Simple1Command> {}
示例2:命令不以 Command 结尾。
class Simple2 : ICommand {}
class Simple2Executor : IExecutor<Simple2> {}
示例3:泛型+嵌套执行器。
class Simple3<T1, T2> : ICommand
{
//....
class Executor : IExecutor<Simple3<T1, T2>>
{
//....
}
}
示例5:特性+泛型,可以看出执行器的名称是“不符合”规则的。
[BindingExecutor(typeof(TestSimple4<,>))]
class Simple4<T1, T2> : ICommand { }
class TestSimple4<T1, T2> : IExecutor<Simple4<T1, T2>>{}
4.2 用户工厂(UserFactory)
表示当前用户的方式有两种:第一种是通过命令参数(将当前用户信息作为参数);第二种则是通过执行器的 context.User
属性获取用户信息。本节要讲解的就是如何利用 context.User
获取上下文中的用户。
假设我们定义了以下命令。
public class GetUsername : ICommand<string>
{
//-目的:获取当前用户的账号。
public string ResultValue { get; set; }
class Executor : IExecutor<GetUsername>
{
public void Execute(IContext context, GetUsername command)
{
//- 模拟:编号为 1 返回 admin,否则返回 user
if(context.User.Id == 1) command.ResultValue = "admin";
else command.ResultValue = "user";
}
}
}
然后添加测试代码:
var container = new IocContainer();
object user = new { Id = 1 };
container.AddService<IUserFactory>(new UserFactory(c => user));
var bus = new CommandBus(container);
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
user = new { Id = 2 };
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
以上代码的输出内容是
admin
user
4.3 事件(Event)
事件由两个部分组成,分别是:事件仓库(EventStore)和事件(Event)。事件仓库负责全局的事件(比如你想对所有命令进行执行前捕获和执行后捕获),事件则针对固定命令类型进行捕获。如果你要全局事件,在程序运行开始就应该手工注册 IEventStore
类型,并继承 EventStore
或实现 IEventStore
。
var container = new IocContainer();
object user = new SimpleUser { Id = 1 };
container.AddService<IUserFactory>(new UserFactory(c => user));
container.GetService<IEventStore>().Register<GetUsername>(new MockEvent<GetUsername>((context, command) =>
{
if(context.User.Id == 1) context.User.Id = 2;
else if(context.User.Id == 2) context.User.Id = 1;
return true;
}, (context, command, exception) =>
{
Console.WriteLine("执行后结果 {0}", command.ResultValue);
}));
var bus = new CommandBus(container);
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
user = new SimpleUser { Id = 2 };
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
经过事件的干扰以后,输出内容变成
执行后结果 user
user
执行后结果 admin
admin
从执行器可以看出,预期输出的第一个选项应该是 admin,第二个才是 user。通过事件的拦截和处理,CommandModel 可以有效的对数据进行校验、捕获和处理等工作。
4.4 命令的事务机制
本节的事务更多指的是 ADO.NET 的事务。 ADO.NET 的事务实现方式有两种方式:第一种是利用 System.Data.Common.DbTransaction
的派生类,第二种则是利用 System.Transactions.TransactionScope
实现事务机制。
结合 Db.Context
数据库上下文,CommandModel 巧妙的运用第二种方式进行事务的控制,具体代码请看下篇内容。
5.结束
下篇内容主要利用命令模型服务(CommandModelServiceBase)做一个完整的示例(含数据库和单元测试)。Aoite.CommandModel.CommandModelServiceBase
是一个默认 CommandModel 服务(业务逻辑层)的实现(若采用 Aoite.Web 框架,可以通过继承 System.Web.Mvc.XControllerBase
和 System.Web.Mvc.XWebViewPageBase
)。
命令模型服务(CommandModelServiceBase)的主要成员:
ICommandBus Bus { get; }
:命令总线。IIocContainer Container { get; set; }
:服务容器。dynamic User { get; }
:执行命令模型的用户。IDisposable AcquireLock(key, timeout = null)
:一个全局锁的功能,如果获取锁超时将会抛出异常。long Increment(key, increment )
:获取指定键的原子递增序列。ITransaction BeginTransaction()
:开始事务模式。TCommand Execute<TCommand>(command, executing, executed)
:执行一个命令模型。Task<TCommand> ExecuteAsync<TCommand>(command, executing, executed)
:以异步的方式执行一个命令模型。
关于 Aoite.CommandModel 的上篇内容,就到此结束了,如果你喜欢这个框架,不妨点个推荐吧!如果你非常喜欢这个框架,那请顺便到Aoite GitHub Star 一下 :)
点此下载本文的所有示例代码。