使用 SqlConnection 实例的 'Unit of work' 和 'Repository' 设计模式的依赖注入管理

Dependency injection management with 'Unit of work' and 'Repository' design patterns using SqlConnection instance

很多时候,我遇到这样的情况,我想在不重新打开连接的情况下执行一些语句。

所以我目前的做法是创建一个 'unit of work' class,打开一个连接并将其传递给所有存储库。

这是我的代码示例:

public class BasicEmailUnitOfWork : IBasicEmailUnitOfWork
{
        private readonly IDNTConnectionFactory _conn; 

        public BasicEmailUnitOfWork(IDNTConnectionFactory connection)
        {
            _conn = connection; 
        }

        public (string, string, string, string, string) RenderEmailTemplate(string emailTemplateEventName, int userId)
        {
            string userPhone = String.Empty;
            string userEmail = String.Empty;
            string emailTitletranslatedContent = String.Empty;
            string emailBodytranslatedContent = String.Empty;
            string smstranslatedContent = String.Empty;

            try
            {
                IDbConnection connectionDb = _conn.GetConnection();

                IEmailSMSTemplateRepository _emailSMSTemplateRepository = new EmailSMSTemplateRepository(connectionDb);

                IUserInformationRepository _userInformationRepository = new UserInformationRepository(connectionDb);

                List<EmailSMSTemplateTDO> emailSmsesList = _emailSMSTemplateRepository.GetAllTemplates(emailTemplateEventName);

                UserInfoDTO userInfoDTO = _userInformationRepository.GetAllInformation(userId);
                userPhone = userInfoDTO.Phone;
                userEmail = userInfoDTO.Email;

                foreach (EmailSMSTemplateTDO emailTemplate in emailSmsesList)
                {
                    emailTitletranslatedContent = TranslateContent(connectionDb, userInfoDTO, emailTemplate.EmailTitle);

                    emailBodytranslatedContent = TranslateContent(connectionDb, userInfoDTO, emailTemplate.EmailBody);

                    if (emailTemplate.IsSMS)
                        smstranslatedContent = TranslateContent(connectionDb, userInfoDTO, emailTemplate.SMSBody);
                }
            }
            catch(Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            finally
            {
                _conn.CloseConnection();
            }

            return (userPhone, userEmail,emailTitletranslatedContent, emailBodytranslatedContent, smstranslatedContent);
    }

    private string TranslateContent(IDbConnection connectionDb, UserInfoDTO userInfoDTO, string content)
    {
            InterpreterContext intContext = new InterpreterContext();
            intContext.user = userInfoDTO;
            intContext.Content = content;

            IExpression emailSMSContentInterpreter = new EmailSMSContentInterpreter(connectionDb);
            emailSMSContentInterpreter.Interpret(intContext);
            return emailSMSContentInterpreter.BodyContentOutPut;
    }
}

尽管我可以毫无问题地对存储库进行单元测试,但函数中有 2 个依赖项:EmailSMSTemplateRepositoryUserInformationRepository

最佳做法是什么?还有什么其他方法可以共享连接,另请注意,我需要一些class来在处理对象或出现错误时处理连接。

顺便说一句,我在应用程序中使用了 Dapper,与用于构建此站点的微型 ORM 相同。

就个人而言,保持连接打开并重复使用它存在很多危险,尤其是在多个用户上下文中。插错机率很高

至于你说的“不重新打开连接”,不“重新打开”的目的是什么。我问这个是因为 SQL 连接在你处理它们时实际上并没有被处理掉。连接被清除并进入引擎盖下的池中。这意味着您不会招致任何开销“重新实例化”池中的对象。您的代码具有处置安全性,但没有开销。注意我假设对象创建开销的想法是可感知的问题。

这就是为什么 Microsoft 每次都让您关闭它们,但在引擎盖下创建了池。是的,池对象可以超时,但是你向它扔东西的速度足够快,它不会达到超时(默认 == 30 秒)。

注意:即使是 Dapper 也没有做任何特别的事情来真正破坏连接对象(即他们没有绕过 .NET 并重新发明轮子)。

根据上述内容,最佳做法是允许对象 Dispose 并返回到池中。我知道这似乎有点违反直觉,但一旦你意识到有一个内部池对象,它就有意义了。

如果您想要一个关于连续 运行 多个命令的思想实验,您可以考虑将命令创建为工作单元并快速喂养它们。您需要在回购协议中进行一些命令管理,但这比创建单个连接并等待直到您完成要处理的对象(因为处理失败,在某些情况下,可能是 BAAAAADDDD)更合理。

过去我通过使用连接工厂 -> 数据会话 -> 工作单元 -> 存储库模式来解决这个问题。

类似于 -

public interface IUnitOfWork : IDisposable
{
    Guid Id { get; }
    IDbConnection Connection { get; }
    IDbTransaction Transaction { get; }
    void Begin();
    void Commit();
    void Rollback();
}

public sealed class UnitOfWork : IUnitOfWork
{
    internal UnitOfWork(IDbConnection connection)
    {
        _id = Guid.NewGuid();
        _connection = connection;
    }

    private readonly IDbConnection _connection = null;
    private IDbTransaction _transaction = null;
    private readonly Guid _id;

    IDbConnection IUnitOfWork.Connection => _connection;
    IDbTransaction IUnitOfWork.Transaction => _transaction;
    Guid IUnitOfWork.Id => _id;

    public void Begin()
    {
        _transaction = _connection.BeginTransaction();
    }

    public void Commit()
    {
        _transaction.Commit();
        Dispose();
    }

    public void Rollback()
    {
        _transaction.Rollback();
        Dispose();
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _transaction = null;
    }
}

//define enum's that describe various databases you might want to connect to
//the db type/engine is an implementation detail handled by the connection factory
public enum DatabaseInstance
{
    YourDatabase,
    SomeOtherDatabase,
    AThirdDatabase
}

//abstracts the 'how do i connect to the database?' question
public interface IDbConnectionFactory
{
    IDbConnection GetConnection(DatabaseInstance database);
}

//for instance - 
public class SqlDbConnectionFactory : IDbConnectionFactory
{
    public IDbConnection GetConnection(DatabaseInstance database)
    {
        //return a IDbConnection for a given DatabaseInstance value
        //possible implementations could be storing a DatabaseInstance->Connectionstring map dictionary that is populated in the constructor
    }
}

public interface IDataAccessSession : IDisposable
{
    IUnitOfWork UnitOfWork { get; }

    void StartSession(DatabaseInstance databaseInstance);
}

//one possible implementation - use a connection factory and requested instance to create an IUnitOfWork for consumers to use
public sealed class DataAccessSession : IDataAccessSession
{
    private IDbConnection _connection = null;

    private readonly IDbConnectionFactory _dbConnectionFactory;

    public DalSession(IDbConnectionFactory dbConnectionFactory)
    {
        _dbConnectionFactory = dbConnectionFactory;
    }

    public void StartSession(DatabaseInstance databaseInstance)
    {
        _connection = _dbConnectionFactory.GetConnection(databaseInstance);
        _connection.Open();
        UnitOfWork = new UnitOfWork(_connection);
    }
    
    public IUnitOfWork UnitOfWork { get; private set; }

    public void Dispose()
    {
        UnitOfWork.Dispose();
        _connection.Dispose();
    }
}

//then there's an extension method (in practice a helper overload per DatabaseInstance as well
//starts a session, passes the unit of work to the consumer task, commit/rollsback, and then disposes
public static class DataAccessSessionExtensions
{
    public static async Task<T> RunTransactionAsync<T>(this IDataAccessSession dataAccessSession, DatabaseInstance instance, Func<IUnitOfWork, Task<T>> functionToRun, bool rollbackOnException=false)
    {
        dataAccessSession.StartSession(instance);
        dataAccessSession.UnitOfWork.Begin();
        try
        {

            var result = await functionToRun(dataAccessSession.UnitOfWork);
            dataAccessSession.UnitOfWork.Commit();

            return result;
        }
        catch (SqlException)
        {
            if(rollbackOnException)
                dataAccessSession.UnitOfWork.Rollback();
            else
                dataAccessSession.UnitOfWork.Commit();
            throw;
        }
    }
}

//then in your dependency injection container (asp.net core here)
....
//if  you needed to say connect to mongo for one and sql for another this would need a bit further refinement as the MS DI Api doesn't handle that well, you'd need to do something like pass collections of connection factory/instance->connections instead as one possible solution
//this can be a singleton as it doesn't store live connections, just how to map requests to new connections
services.AddSingleton<IDbConnectionFactory>(i => new SqlDbConnectionFactory(/*pass in a values for connection strings per data base instance defined*/)); 
//should be scoped so that a new instance is created/disposed per DI request         
services.AddScoped<IDalSession, DalSession>();
....

//finally usage would be like so
public class SomeService : ISomeService
{
    private readonly IDataAccessSession _dataAccessSession;
    private readonly ISomeRepo _someRepo;
    Private readonly ISomeOtherRepo _someOtherRepo

    public SomeService (IDataAccessSession dataAccessSession, ISomeRepo someRepo, ISomeOtherRepo someOtherRepo)
    {
        _dataAccessSession = dataAccessSession;
        _someRepo = someRepo;
        _someOtherRepo = someOtherRepo;
    }

    // at this point all needed repo's are handed a single UnitOfWork that represents the connection and transaction and a session that will clean up afterwards 
    public async Task<SomeResult> DoSomeThing(string param1, int param2)
    {
        var result = await _dataAccessSession.RunTransactionAsync(DatabaseInstance.Whatever, async uow =>
        {
            //critical - forget this and things will go boom
            _someRepo.UnitOfWork = uow;
            _someOtherRepo.UnitOfWork = uow;

           var repo1Value = await _someRepo.SomeAction(param1);
           var repo2Value = await _someOtherRepo.GetAThing(param2);
           
           return new SomeResult(repo1Value, repo2Value);
        });
        return Task.FromResult(result);
    }
}

一个弱点是,除了要求他们实现接收 属性 然后在使用前填充它之外,我从来没有找到更好的方法将工作单元传递给 repo 层。这意味着开发人员很容易忘记该步骤并 运行 出错。

public interface IHasUnitOfWork 
{
    IUnitOfWork UnitOfWork { get; set; }
}

public interface IDataRepo : IHasUnitOfWork
{
}

public interface ISomeRepo : IDataRepo
{
    Task UpdateSomethingAsync();
}

//at the time of implementing the repo the promise is that UnitOfWork will be non-null with a valid open connection 
//that is ready to exec against and that if a sql exception is thrown a rollback will be initiated (if flag is set)
//though note that if multiple repos are sharing this unit of work and a different one throws, this repo's calls will also rollback as it considers all actions to be under a single transaction scope per IDataAccessSession
public SomeRepo : ISomeRepo 
{ 
    public IUnitOfWork UnitOfWork { get; set; }

    public async Task UpdateSomethingAsync()
    {
        await UnitOfWork.Connection.ExecuteAsync("sproc", .....);
    }
}