Паттерн repository и смена ORM

101
28 сентября 2021, 20:50

Знаю, что подобных вопросов было много, но ответа в них для себя, к сожалению, найти не смог. Вероятно буду писать глупости, не судите строго, я еще учусь)

Если использовать GenericRepository с Entity Framework в связке с UnitOfWork, то для того, что бы делать сложные запросы с несколькими join нам придется делать тип возвращаемого значения IQuerable а не IEnumerable, что в свою очередь подходит только для тех ORM, которые поддерживают Query Builder.

А Если например делать реализацию для Dapper, то я вообще не понимаю, как можно делать сложные запросы, если считать что Repository это in-memory коллекция доменных объектов. Т.е. я имею ввиду, что либо придется сначала загружать всю коллекцию объектов из базы в память, либо изобретать свою версию Query Builder для ОРМ, с помощью каких нибудь спецификаций. Вообще реально ли использовать Repository с Dapper или чистым SQL, учитывая, что существует постоянная необходимость джойнить несколько таблиц?

Хочу уточнить, что задаю этот вопрос именно в рамках паттерна Repository а не DAO.

Answer 1

Первое и главное, что нужно понять про Репозиторий — он возвращает сущности предметной области, даже более того — не просто сущности, а агрегаты.

Для примера рассмотрим сайт с зарегистрированными пользователями. У пользователей есть идентификатор, логин и пароль.

class User
{
    public int Id { get; set; }
    public string Login { get; set; }
    public string Password { get; set; }
}

Замечательный класс сущности, который легко отобразить на таблицу в БД. К сожалению, такая лобовая реализация нарушает все принципы ООП. В частности, пароль пользователя не может быть изменён, если пользователь не знает старый пароль. Точно также идентификатор пользователя не должен меняться вообще, а смена логина может потребовать серьёзного сценария с поиском дубликата в БД.

Класс-сущность должен выглядеть так:

class User
{
    public int Id { get; }
    public string Login { get; }
    private string _password;
    public void ChangePassword(string oldPassword, string newPassword)
    {
        if (_password != oldPassword)
            throw new Exception();
        _password = newPassword;
    }
    internal User(int id, string login, string password)
    {
        Id = id;
        Login = login;
        _password = password;
    }
}

Такой класс реализует бизнес-логику. Это сущность предметной области. Его можно было бы отобразить на таблицу в БД «как есть», если не думать о безопасности. Рекомендации OWASP предлагают в частности, никогда не хранить пароли в открытом виде, а хранить их хеши, более того, для вычисления хешей использовать случайную затравку.

Для хранения в базе нам потребуется другой класс с отличной структурой. Поскольку он используется для связи объекта в БД, мы будем считать его объектом переноса данных (data transfer object — DTO).

class UserDto
{
    public int Id { get; set; }
    public string Login { get; set; }
    public byte[] PasswordHash { get; set; }
    public byte[] Salt { get; set; }
}

Теперь у нас есть класс предметной области User и объект переноса данных UserDto, с помощью которого мы можем складывать пользователей в базу. Обратите внимания, мы не накладываем сейчас никаких особых ограничений на то, где именно будут храниться пользователи. UserDto определяет только, какие поля должны в конце концов сохраняться.

Класс User можно переписать с учётом существования UserDto:

class User
{
    private UserDto _dto;
    private User(UserDto dto)
    {
        _dto = dto;
    }
    public int Id => _dto.Id;
    public string Login => _dto.Login;
    public void ChangePassword(string oldPassword, string newPassword)
    {
        var hash = CalculateHash(oldPassword, _dto.Salt);
        if (!hash.SequenseEqual(_dto.PasswordHash))
            throw new Exception();
        _dto.PasswordHash = CalculateHash(newPassword, _dto.Salt);
    }
}

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

На уровне предметной области — то есть физически в том же проекте, где объявлены User и UserDto мы описываем только интерфейс репозитория.

interface IUserRepository
{
    User Create(string login, string password);
    User GetById(int id);
    User GetByLogin(string login);
    User UpdateLogin(int id, string login);
}

Этот интерфейс может быть реализован с помощью ADO, Dapper, EF или любым другим способом. Вы можете хранить пользователей в SQL Server, Postres, MongoDB и даже в XML-файле, если их не очень много.

Создаём новый проект, скажем, для репозиториев ADO.

class AdoUserRepository : IUserRepository
{
    private Func<SqlConnection> _connectionFactory;
    public AdoUserRepository(Func<SqlConnection> connectionFactory)
    {
        _connectionFactory = connectionFactory;
    }
    User GetById(int id)
    {
        using (var connection = _connectionFactory())
        using (var command = connection.CreateCommand())
        {
            command.CommandText = "SELECT * FROM [Users] WHERE Id = @Id";
            command.Parameters.Add("@Id", id);
            . . .
        }
    }
}

Построение запросов и отображение данных из запросов в UserDto решаются на уровне конкретных репозиториев. Если у вас EF, то репозитории получаются простые.

У нас остался важный практический вопрос: если мы заполнили UserDto, кто превратит этот объект в доменную сущность User? В книге про паттерны GoF описан паттерн Memento, который как раз занимается сохранением и восстановлением объектов.

Логика Memento отличается от того, что нужно нам, но не кардинально, поэтому свою реализацию класса для сохранения и восстановления мы можем называть Mememto. Это будет отдельный класс, чтобы не нагружать User второй ответственностью. И, поскольку он должен иметь доступ к приватным членам класса User, он будет вложенным.

public class User
{
    private UserDto _dto;
    public static class Memento
    {
        public static UserDto Serialize(User domain) => domain._dto;
        public static User Deserialize(UserDto dto) => new User(dto);
    }
}

Теперь реализации репозиториев, например AdoUserRepository может вызывать User.Memento.Deserialize, чтобы создать User из UserDto.

User GetById(int id)
{
    using (var connection = _connectionFactory())
    using (var command = connection.CreateCommand())
    {
        command.CommandText = "SELECT * FROM [Users] WHERE Id = @Id";
        command.Parameters.Add("@Id", id);
        var reader = command.ExecuteReader();
        if (!reader.Read())
            throw new Exception();
        return User.Memento.Deserialize(new UserDto
        {
            Id = reader.GetInt32(0),
            Login = reader.GetString(1),
            PasswordHash = reader.GetBytes(),
            Salt = reader.GetBytes(),
        });
    }
}

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

Представим, что у наших пользователей есть профиль. Это набор параметров ключ-значение, которых не очень много. Мы могли бы всегда подгружать эти параметры, при загрузке пользователя. Если мы делаем так, мы прячем от бизнес-логики такие детали, как таблицы, связи, внешние ключи. Конечно, все они остаются на уровне реализации, но наверх не проникают.

Вот так можно загрузить агрегат вместе с дочерними параметрами с помощью энергичной загрузки EF (метод Include):

User GetById(int id)
{
    using (var context = _contextFactory())
    {
        var dto = context.Users
                         .Include(x => x.Parameters)
                         .Single(x => x.Id == id);
        return User.Memento.Deserialize(dto);
    }
}

Как видим, реализация может быть посложнее и попроще. EF пошлёт в базу один запрос и сумеет корректно отобразить значения полей на свойства UserDto. Мы сами могли бы послать два запроса в базу и реализовать простой мапинг, если бы использовали ADO.

В любом случае, это деталь реализации, про которую мы можем не думать на уровне предметной области.

Answer 2

Вообще реально ли использовать Repository с Dapper или чистым SQL, учитывая, что существует постоянная необходимость джойнить несколько таблиц?

Паттерн репозиторий можно использовать с любым источником данных. Даже если это сетевой ресурс или файл.

Более того, репозиторий не должен знать ничего о физической структуре данных и способе их хранения. Для этого есть источник данных и их объектная модель. Задача репозитория предоставить фасадный интерфейс к данным.

READ ALSO
Изменить условие цикла при выполнении

Изменить условие цикла при выполнении

Не страшно же, если во время работы цикла я изменю его условие?

153
Работа с object[] и List object

Работа с object[] и List object

Я добавляю object[] в List objectКак я могу удалить этот object[]?

254
C# асинхронная загрузка файла

C# асинхронная загрузка файла

Целиком задача заключается в загрузке трех картинок для трех PictureBoxПо условию задачи пользователь может загружать их в любой момент, поэтому...

91
C# перенос кода в функцию [закрыт]

C# перенос кода в функцию [закрыт]

Хотите улучшить этот вопрос? Добавьте больше подробностей и уточните проблему, отредактировав это сообщение

299