工作单元(Unit of Work)

公共连接和事务管理方法

在使用了数据库的应用中,连接和事务管理是最重要的概念之一。何时打开一个连接,何时开始一个事务,如何释放连接等等。

你可能已经知道,Net使用了连接池。因此,创建一个连接实际上是从连接池中获取一个连接,因为因为创建一个连接是有消耗的。如果在连接池中没有可用的连接,那么会创建一个新的连接,并将该连接加入连接池。当你释放连接时,实际上是将该连接发送回给连接池,并没有完全释放。这种机制是.Net提供的立即可用的功能。因此,在我们使用完一个连接后应该立即释放,在需要的时候才创建一个新的连接。总之,最佳实践记住这八个字足矣:尽晚打开,尽早释放

这里我推荐一篇关于数据库连接的文章,写得很浅显易懂:《细说数据库连接》

在一个应用中创建或者释放一个数据库连接,通常有2种方法。

第一种方法:当Web请求开始(在Global.asax的Application_BeginRequest事件中)的时候创建一个连接,在所有的数据库操作时使用相同的连接,并且在请求结束(Application_EndRequest)时关闭或者释放该连接。这种方法很简单但是不够高效。为啥呢?

事务的方式执行数据库操作已被认为是一种最佳实践。如果一个操作失败了,那么所有的操作都会回滚。因为一个事务可以锁定数据库中的一些行(甚至表),所以它必须是短暂存活的。

第二种方法:当需要时(仅在使用前)创建一个连接,使用后立即关闭。这是最有效的,但是到处创建或者释放连接是一项重复乏味的工作。

LCL中的连接和事务管理

LCL兼备了这两种方法并且提供了一个简单而又有效的模型。

仓储类

仓储式执行数据库操作主要的类。当进入一个仓储方法时,LCL会打开一个数据库连接(可能不是立即打开,但是在第一次使用数据库时肯定是打开的,取决于ORM提供者的实现)并开始一个事务。因此,在一个仓储方法中可以安全地使用连接。在方法的结束,事务被提交并且连接被释放。如果仓储方法抛出任何异常,那么事务都会回滚且连接被释放。这样一来,仓储方法就是原子的(一个工作单元)。LCL对于这些会自动处理。这里是一个简单的仓储:

public class ContentRepository : NhRepositoryBase<Content>, IContentRepository
{
    public List<Content> GetActiveContents(string searchCondition)
    {
        var query = from content in Session.Query<Content>()
                    where content.IsActive && !content.IsDeleted
                    select content;
        if (!string.IsNullOrEmpty(searchCondition))
        {
            query = query.Where(content => content.Text.Contains(searchCondition));
        }
        return query.ToList();
    }
}

这个例子使用了NHibernate作为ORM。正如上面演示的,没有编写数据库连接(在NHibernate中是Session)打开或者关闭的代码。

如果一个仓储方法调用了其他的仓储方法(一般而言,如果一个工作单元调用了其他的工作单元方法),那么它们共享相同的连接和事务。第一个进入的方法管理连接和事务,其他方法使用相同的连接和事务。

应用服务

一个应用服务也被认为是一个工作单元。假设我们有一个像下面的应用服务:

public class PersonAppService : IPersonAppService
{
    private readonly IPersonRepository _personRepository;
    private readonly IStatisticsRepository _statisticsRepository;
    public PersonAppService(IPersonRepository personRepository, IStatisticsRepository statisticsRepository)
    {
        _personRepository = personRepository;
        _statisticsRepository = statisticsRepository;
    }
    public void CreatePerson(CreatePersonInput input)
    {
        var person = new Person { Name = input.Name, EmailAddress = input.EmailAddress };
        _personRepository.Insert(person);
        _statisticsRepository.IncrementPeopleCount();
    }
}

在CreatePerson方法中,我们使用了person仓储插入了一个person,而且使用statistics仓储增加总人数。在这里例子中,这两个仓储共享相同的连接和事务,因为它们在一个应用服务方法中。LCL在进入CreatePerson方法时打开一个数据库连接并开始一个事务,如果没有抛出异常事务会在方法结尾时提交,如果有任何异常发生,将会回滚。这样一来,在CreatePerson方法中的所有数据库操作都成了原子的(工作单元)

工作单元

工作单元对于仓储和应用服务方法隐式有效。如果你想在其他地方控制数据库连接和事务,那么可以显式使用它。

UnitOfWork特性

最受人欢迎的方法是使用UnitOfWorkAttribute。例如:

[UnitOfWork]
public void CreatePerson(CreatePersonInput input)
{
    var person = new Person { Name = input.Name, EmailAddress = input.EmailAddress };
    _personRepository.Insert(person);
    _statisticsRepository.IncrementPeopleCount();
}

这样,CreatePerson方法变成了工作单元并且管理数据库连接和事务,两个仓储使用相同的工作单元,注意的是,如果这是一个应用服务方法,就不需要UnitOfWork特性。

IUnitOfWorkManager

第二种方法是使用IUnitOfWorkManager.Begin()方法,如下所示:

public class MyService
{
    private readonly IUnitOfWorkManager _unitOfWorkManager;
    private readonly IPersonRepository _personRepository;
    private readonly IStatisticsRepository _statisticsRepository;
    public MyService(IUnitOfWorkManager unitOfWorkManager, IPersonRepository personRepository, IStatisticsRepository statisticsRepository)
    {
        _unitOfWorkManager = unitOfWorkManager;
        _personRepository = personRepository;
        _statisticsRepository = statisticsRepository;
    }
    public void CreatePerson(CreatePersonInput input)
    {
        var person = new Person { Name = input.Name, EmailAddress = input.EmailAddress };
        using (var unitOfWork = _unitOfWorkManager.Begin())
        {
            _personRepository.Insert(person);
            _statisticsRepository.IncrementPeopleCount();
            unitOfWork.Complete();
        }
    }
}

你可以注入然后使用IUnitOfWork,正如这里演示的这样(如果你的应用继承自ApplicationService类,那么你可以直接使用CurrentUnitOfWork属性。如果没有,你要先注入IUnitOfWorkManager)。这样,你就可以创建更多的限制作用域的工作单元。用这种方法,你应该手动调用Complete方法。如果没有调用,事务就会回滚,改变就不会保存。

Begin方法有很多重载来设置工作单元选项

如果找不到一个很好的理由,建议还是使用UnitOfWork特性,因为代码越短越好。

工作单元详解

关闭工作单元

有时候你可能想关闭应用服务方法的工作单元(因为默认是开启的),此时,可以使用UnitOfWorkAttribute的IsDisabled属性。用法如下:

[UnitOfWork(IsDisabled = true)]
public virtual void RemoveFriendship(RemoveFriendshipInput input)
{
    _friendshipRepository.Delete(input.Id);
}

正常情况下,不需要关闭数据单元,因为应用服务方法应该是原子的且一般都会使用数据库。但也有些例外情况让你想要关闭应用服务方法的工作单元:

注意:如果一个工作单元方法调用了这个RemoveFriendship方法,那么后者的关闭工作单元的功能将会失效,并且也会使用和调用者方法相同的工作单元。因此,要小心使用工作单元的关闭功能。

非事务的工作单元

工作单元默认是事务的(本质如此)。因此,LCL会开始->提交->回滚一个显式的数据库级别的事务。在一些特殊场合,事务可能会造成问题,因为它可能会锁住数据库中的一些行或者表。在这种情况下,你可能想关闭数据库级别的事务。UnitOfWork特性可以在构造函数中获得一个布尔值,从而以非事务形式工作。用法如下:

[UnitOfWork(isTransactional: false)]
public GetTasksOutput GetTasks(GetTasksInput input)
{
    var tasks = _taskRepository.GetAllWithPeople(input.AssignedPersonId, input.State);
    return new GetTasksOutput
            {
                Tasks = Mapper.Map<List<TaskDto>>(tasks)
            };
}

建议使用[UnitOfWork(isTransactional: false)],因为它是更具可读性的,但你也可以使用[UnitOfWork(false)]。

注意ORM框架(如EF和NH)内部使用了一条单一命令来保存更改。假设你以非事务的UOW(工作单元)更新了一些实体的情景,甚至在这种情况下所有的更新都是在工作单元结束时以一个单一的数据库命令执行的。但是如果你直接执行一个SQL查询,它会立即执行。

非事务的UOW有一个限制。如果你已经处于一个事务的工作单元的作用域内,那么将isTransactional设置为false将会被忽略。

使用非事务的工作单元要小心,因为大多数时候对于数据的集成是事务的。如果你的方法只是读数据,不需要改变数据,当然该方法是可以为非事务的了。

工作单元方法调用其它

如果一个工作单元的方法(使用了UnitOfWork特性声明的方法)调用另一个工作单元的方法,那么它们共享相同的连接和事务。第一个方法管理连接,其他方法使用连接。这个对于运行在相同线程的方法是成立的(对于web应用则是相同的请求)。实际上,当一个工作单元作用域开始时,在同一线程执行的所有代码都共享同一个连接和事务,直到工作单元作用域结束。这对于UnitOfWork特性和UnitOfWorkScope类都是成立的。

工作单元作用域

在其他事务中可以创建一个不同而又隔离的事务,或者可以在一个事务中创建一个非事务的作用域。.Net中定义了TransactionScopeOption,你可以为工作单元设置作用域选项。

自动保存

当我们为一个方法使用了工作单元时,LCL会在该方法结束时自动保存所有的更改。假设我们有一个更新person的name的方法:

[UnitOfWork]
public void UpdateName(UpdateNameInput input)
{
    var person = _personRepository.Get(input.PersonId);
    person.Name = input.NewName;
}

你要做的就这么多,person的name就改变了。我们甚至不用调用_personRepository.Update方法。ORM框架会跟踪工作单元中实体的所有改变,并将改变反应给数据库。

注意没有必要为应用服务方法声明UnitOfWork特性,因为它们默认已经是工作单元了。

IRepository.GetAll()方法

当在一个仓储方法之外调用GetAll()时,必须存在一个打开的数据库连接,因为GetAll返回了IQueryable,而且IQueryable会延迟执行。直到调用ToList()方法或者在foreach循环中使用IQueryable,才会真正执行数据库查询。因此,调用ToList()方法时,数据库连接必须是活着的(alive)。

考虑一下下面的例子:

[UnitOfWork]
public SearchPeopleOutput SearchPeople(SearchPeopleInput input)
{
    //返回IQueryable<Person>
    var query = _personRepository.GetAll();
    //添加一些过滤
    if (!string.IsNullOrEmpty(input.SearchedName))
    {
        query = query.Where(person => person.Name.StartsWith(input.SearchedName));
    }
    if (input.IsActive.HasValue)
    {
        query = query.Where(person => person.IsActive == input.IsActive.Value);
    }
    //获得分页结果列表
    var people = query.Skip(input.SkipCount).Take(input.MaxResultCount).ToList();
    return new SearchPeopleOutput { People = Mapper.Map<List<PersonDto>>(people) };
}

这里,SearchPeople方法必须是工作单元,因为IQueryable的ToList()在方法体内调用了,当执行IQueryable.ToList()执行时,数据库连接必须是打开的状态。

就像GetAll()方法一样,如果在仓储之外需要数据库连接,那么必须使用工作单元。注意,应用服务方法默认是工作单元。

UnitOfWork特性的限制

UnitOfWork可以用于以下几个条件:

建议总是将方法声明为virtual,但是不能用于private方法。因为LCL为virtual方法私有了动态代理,private方法不能被派生的类访问到。如果你没有使用依赖注入且实例化类,那么UnitOfWork特性(和任何代理)就不能工作。

选项

有很多可以用于改变工作单元行为的选项。

首先,我们可以在启动配置中更改所有工作单元的默认值。这通常是在模块的PreInitialize方法中处理的。

public class SimpleTaskSystemCoreModule : AbpModule
{
    public override void PreInitialize()
    {
        Configuration.UnitOfWork.IsolationLevel = IsolationLevel.ReadCommitted;
        Configuration.UnitOfWork.Timeout = TimeSpan.FromMinutes(30);
    }
    //...其他模块方法
}

其次,我们可以为一个特定的工作单元重写默认值。比如,UnitOfWork特性的构造函数和IUnitOfWorkManager的Begin方法都有获得选项的重载。

方法

UnitOfWork系统无缝而不可见地工作。但是在某些场合,你需要调用它的方法。

SaveChanges

LCL会在工作单元结束时保存所有更改,我们根本不用做任何事情。但是有时候你可能想在工作单元操作的中间将更改保存到数据库中。在这种情况下,你可以注入IUnitOfWorkManager,然后调用IUnitOfWorkManager.Current.SaveChanges()方法。注意:如果当前的工作单元是事务的,那么如果有异常发生了,事务中的所有改变都会回滚,即使是已保存的改变。

事件

工作单元有Completed,Failed和Disposed事件。你可以注册这些事件,然后执行需要的操作。通过注入IUnitOfWorkManager然后使用IUnitOfWorkManager.Current属性来获得激活的工作单元,然后注册到它的事件。

在当前的工作单元成功完成时,你可能想运行一些代码,下面是一个例子:

public void CreateTask(CreateTaskInput input)
{
    var task = new Task { Description = input.Description };
    if (input.AssignedPersonId.HasValue)
    {
        task.AssignedPersonId = input.AssignedPersonId.Value;
        _unitOfWorkManager.Current.Completed += (sender, args) => { /* TODO: 给派发的人发送邮件*/ };
    }
    _taskRepository.Insert(task);
}