Aoite 系列(04) - 强劲的 CommandModel 开发模式(上篇)

Aoite 是一个适于任何 .Net Framework 4.0+ 项目的快速开发整体解决方案。Aoite.CommandModel 是一种开发模式,我把它成为“命令模型”,这是一种非常有意思的开发模式。

【Aoite 系列 目录】

赶紧加入 Aoite GitHub 的大家庭吧!!

1. 概述

CommandModel 的架构并不复杂,核心四大组件分别是:命令(Command)、执行器(Executor)、上下文(Context)和事件(Event)。

CommandModel 核心是剥离所有运行期的所有依赖,注入执行。它可以运用至传统的三层架构,也可以运用到 DDD(CQRS)架构。

不是只能应用到三层架构,只是以最传统最简单的三层架构作为比较。CommandModel 支持任何架构、模式,无论是 Web 和 Winform,亦或者 ASP.NET 和 MVC,亦或者三层架构或领域驱动。请看官不要纠结这些问题。

传统三层架构是这样的(实体层意义上包含 数据库实体视图模型 ):

传统三层架构

如果将 CommandModel 加入三层架构,那么它将变成以下架构:

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 中 BeginRequestEndRequest

事件可以做的事情非常多,它让 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.ContextAoite.Redis.RedisManager.Context,你应该在线程结束中调用 GA.ResetContexts。比如说,在 HTTP Application 中,每一个请求结束,都应当调用 GA.ResetContexts(如果你使用了 Aoite.Web 框架,则不需要手工调用)。

4.1 命令和执行器的映射

一个命令是如何与执行器进行映射的,其映射的优先级和规则如下:

  1. 命令包含了 BindingExecutorAttribute 特性。此特性可以指定执行器的数据类型(也可以是一个泛型)。
  2. 命令的嵌套类型,并且类型名称为“Executor”。这是推荐的用法
  3. 相同命名空间下,命令名称(若以 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.XControllerBaseSystem.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 一下 :)

点此下载本文的所有示例代码。

posted @ 2015-02-07 10:14 Treenew Lyn 阅读(...) 评论(...) 编辑 收藏