Управление внедрением зависимостей с помощью шаблонов проектирования «Единица работы» и «Репозиторий» с использованием экземпляра SqlConnection

Много раз я сталкиваюсь с ситуацией, когда я хочу выполнить ряд операторов без повторного открытия соединения.

Итак, моя практика до сих пор заключается в создании класса «единицы работы», открытии соединения и передаче его во все репозитории.

Вот пример кода, который у меня есть:

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 зависимости в функции: EmailSMSTemplateRepository и UserInformationRepository.

Какова наилучшая практика? Какой другой способ поделиться соединением, обратите внимание, что мне нужен какой-то класс для удаления соединения, когда объект удаляется или если есть ошибка.

Кстати, я использую Dapper для приложения, тот же микро-ORM, который использовался для создания этого сайта.

Почему RenderEmailTemplate внутри UnitOfWork? Единица работы имеет 2 основные обязанности: Gathering несколько репозиториев для выполнения задачи и begin/commit transactions при необходимости. Почему бы не создать метод EmailService с помощью RenderEmailTemplate и внедрить BasicEmailUnitOfWork в его конструктор? И еще: удаление объекта является обязанностью класса, который его создал. Здесь IDNTConnectionFactory вводится в ваш класс из elsewhere. Следовательно, в другом месте следует беспокоиться о его утилизации. не BasicEmailUnitOfWork. Если вы используете Dependecy Injection, это обязанность самого фреймворка.

Efe 11.12.2020 00:16
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
1
1 663
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Лично мне очень опасно держать соединение открытым и повторно использовать его, особенно в нескольких пользовательских контекстах. Вероятность того, что вы вставите неправильно, очень высока.

Что касается вашего утверждения «без повторного открытия соединения», в чем цель не «повторного открытия». Я спрашиваю об этом, поскольку SQL-соединения на самом деле не удаляются, когда вы их удаляете. Соединение очищается и уходит в лужу под капотом. Это означает, что вы не несете никаких накладных расходов, «повторно создавая экземпляр» объекта из пула. Ваш код безопасен при утилизации, но нет накладных расходов. ПРИМЕЧАНИЕ. Я предполагаю, что идея наличия накладных расходов на создание объекта является предполагаемой проблемой.

Вот почему Microsoft каждый раз закрывает их, но создает пул под капотом. Да, у объектов пула может истечь время ожидания, но вы бросаете в него вещи достаточно быстро, и время ожидания не истечет (по умолчанию == 30 секунд).

ПРИМЕЧАНИЕ. Даже Dapper не делает ничего особенного, чтобы действительно уничтожить объект подключения (т.е. они не обходят .NET и не изобретают велосипед).

Следуя вышеизложенному, рекомендуется разрешить объектам удаляться и возвращаться в пул. Я знаю, что это кажется немного нелогичным, но как только вы осознаете, что существует внутренний объект пула, это обретает смысл.

Если вы хотите провести мысленный эксперимент с запуском нескольких команд подряд, вы можете создать команды как единицы работы и быстро передать их. Вам потребуется немного управления командами в вашем репо, но это более разумно, чем создание одного соединения и ожидание, пока вы не закончите работу с объектом для удаления (поскольку сбой в удалении в некоторых случаях может быть BAAAAADDDD).

Но если я создам новое соединение в каждом репозитории, это займет больше времени, чем использование того же соединения. Что, если бы я хотел реализовать одну транзакцию. Я не могу открыть несколько подключений и откатить или зафиксировать их все, пока каждая партия операторов принадлежит другому репо или классу.

Dan Friedman 10.12.2020 21:18

@LastGrip «требуется больше времени на обработку». Откуда вы знаете? Вы измерили?

Mark Seemann 10.12.2020 21:29

@LastGrip «Я не могу открыть несколько подключений и откатить или зафиксировать их все, пока каждый пакет операторов принадлежит другому репо или классу» Да, вы можете. Окружите все операции знаком TransactionScope.

Mark Seemann 10.12.2020 21:31

@LastGrip - Что вызывает дополнительное время обработки? Создание экземпляра исключается, что может составлять не более миллисекунд. Извлечение из пула может занять микросекунду, но если ваше решение требует такого уровня производительности, почему бы вам не использовать что-то еще более быстрое, например хранимые процедуры. Кроме того, если вы выполняете транзакции, то цепочка работает, но хранимая процедура еще более эффективна, поскольку вся транзакция выполняется в базе данных.

Gregory A Beamer 11.12.2020 20:10
Ответ принят как подходящий

В прошлом я подходил к этому с помощью шаблона Фабрика соединений -> Сеанс данных -> Единица работы -> Репозиторий.

Что-то вроде -

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);
    }
}

Одним из слабых мест было то, что я так и не нашел лучшего способа передать единицу работы на уровень репо, кроме как потребовать от них реализовать свойство получения, а затем заполнить его перед использованием. Это означает, что разработчик может легко забыть об этом шаге и столкнуться с ошибками.

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", .....);
    }
}

Я вижу, вы передаете единицу работы в репозитории через свойство, а не через конструктор.

Dan Friedman 11.12.2020 14:26

Правильно, потому что объекты репо предоставляются контейнером DI через интерфейсы. Потребляющий код отделен от любых типов реализации. Хотя я считаю это довольно большим слабым местом. Возможно, уровень репо должен был принять объект единицы работы в качестве параметра для каждого вызова или что-то в этом роде, поскольку это своего рода «яма неудачи», а не «яма успеха».

asawyer 11.12.2020 17:18

Другие вопросы по теме