Programming
Java
Industrial Programming
20 July 2015

Забудьте о DAO, используйте Repository

Недавно задумался о том, чем отличаются паттерны, позволяющие абстрагироваться от работы с хранилищем данных. Много раз поверхностно читал описания и различные реализации DAO и Repository, даже применял их в своих проектах, видимо, до конца не понимая концептуальных отличий. Решил разобраться, закопался в Google и нашел статью, которая для меня разъяснила все. Подумал, что неплохо было бы перевести ее на русский. Оригинал для англочитающих здесь. Остальным интересующимся добро пожаловать под кат.

Data Access Object (DAO) — широко распространенный паттерн для сохранения объектов бизнес-области в базе данных. В самом широком смысле, DAO — это класс, содержащий CRUD методы для конкретной сущности.
Предположим, что у нас имеется сущность Account, представленная следующим классом:
package com.thinkinginobjects.domainobject;
 
public class Account {
 
    private String userName;
    private String firstName;
    private String lastName;
    private String email;
    private int age;
 
    public boolean hasUseName(String desiredUserName) {
        return this.userName.equals(desiredUserName);
    }
 
    public boolean ageBetween(int minAge, int maxAge) {
        return age >= minAge && age <= maxAge;
    }
}

Создадим интерфейс DAO для данной сущности:
package com.thinkinginobjects.dao;
 
import com.thinkinginobjects.domainobject.Account;
 
public interface AccountDAO {
 
    Account get(String userName);
    void create(Account account);
    void update(Account account);
    void delete(String userName);
 
}

Интрефейс AccountDAO может иметь множество реализаций, которые могут использовать различные ORM фреймворки или прямые SQL-запросы к базе данных.
Паттерн имеет следующие преимущества:
  • Отделяет бизнес-логику, использующую данный паттерн, от механизмов сохранения данных и используемых ими API;
  • Сигнатуры методов интерфейса независимы от содержимого класса Account. Если вы добавите поле telephoneNumber в класс Account, не будет необходимости во внесении изменений в AccountDAO или использующих его классах.

Тем не менее, паттерн оставляет множество вопросов без ответа. Что если нам необходимо получить список аккаунтов с определенным lastName? Можно ли добавить метод, который обновляет только поле email для аккаунта? Что делать, если мы захотим использовать long id вместо userName в качестве идентификатора? Что именно является обязанностью DAO?
Проблема заключается в том, что обязанности DAO не описаны четко. Большая часть людей представляет DAO некими вратами к базе данных и добавляет в него методы как только находит новый способ, которым они хотели бы общаться с базой данных. Поэтому нередко можно увидеть DAO, раздутый как в следующем примере:
package com.thinkinginobjects.dao;
 
import java.util.List;
import com.thinkinginobjects.domainobject.Account;
 
public interface BloatAccountDAO {
 
    Account get(String userName);
    void create(Account account);
    void update(Account account);
    void delete(String userName);
 
    List getAccountByLastName(String lastName);
    List getAccountByAgeRange(int minAge, int maxAge);
    void updateEmailAddress(String userName, String newEmailAddress);
    void updateFullName(String userName, String firstName, String lastName);
 
}

В BloatAccountDAO мы добавили методы для поиска аккаунтов по различных параметрам. Если бы в классе Account было больше полей и больше различных способов построения запросов, мы могли бы получить еще более раздутый DAO. Следствием чего стало бы:
  • Сложнее создавать моки для интерфейса DAO во время юнит-тестирования. Необходимо было бы реализовывать больше методов DAO даже в тех тестовых сценариях, когда они не используются;
  • Интерфейс DAO становится все более привязанным к полям класса Account. Возникает необходимость в изменении интрфейса и его реализаций при изменении типов полей класса Account.

Чтобы сгустить краски еще сильнее, мы добавили дополнительные методы обновления в DAO. Они являются непосредственным результатом появления двух новых сценариев использования, которые обновляют различные наборы полей аккаунта. Они выглядят как невинная оптимизация и отлично укладываются в концепцию AccountDAO в том случае, если мы рассматриваем интрфейс как врата к хранилищу данных. Паттерн DAO и название класса AccountDAO определены слишком расплывчато чтобы отвратить нас от этого шага.
В итоге мы получили раздутый интерфейс DAO и, я уверен, мои коллеги добавят еще больше методов в будущем. Через год мы будем иметь класс с более чем 20 методами и проклинать себя за то, что выбрали этот паттерн.

Паттерн Repository


Лучшим решением будет использование паттерна Repository. Эрик Эванс дал точное описание в своей книге: «Repository представляет собой все объекты определенного типа в виде концептуального множества. Его поведение похоже на поведение коллекции, за исключением более развитых возможностей для построения запросов».
Вернемся назад и спроектируем AccountRepository в соответствии с данным определением:
package com.thinkinginobjects.repository;
 
import java.util.List;
import com.thinkinginobjects.domainobject.Account;
 
public interface AccountRepository {
 
    void addAccount(Account account);
    void removeAccount(Account account);
    void updateAccount(Account account); // Think it as replace for set
 
    List query(AccountSpecification specification);
 
}

Методы add и update выглядят идентично методам AccountDAO. Метод remove отличается от метода удаления, определенного в DAO тем, что принимает Account в качестве параметра вместо userName (идентификатора аккаунта). Представление репозитория как коллекции меняет его восприятие. Вы избегаете раскрытия типа идентификатора аккаунта репозиторию. Это сделает вашу жизнь легче в том случае, если вы захотите использовать long для идентрификации аккаунтов.
Если вы задумываетесь о контрактах методов add/remove/update, просто подумайте об абстрации коллекции. Если вы задумаетесь о добавлении еще одного метода update для репозитория, подумайте, имеет ли смысл добавлять еще один метод update для коллекции.
Однако, метод query является особенным. Я бы не ожидал увидеть такой метод в классе коллекции. Что он делает?
Репозиторий отличается от коллекции, если рассматривать возможности для построения запросов. Имея коллекцию объектов в памяти, довольно просто перебрать все ее элементы и найти интересующий нас экземпляр. Репозиторий работает с большим набором объектов, чаще всего, находящихся вне оперативной памяти в момент выполнения запроса. Нецелесообразно загружать все аккаунты в память, если нам необходим один конкретный пользователь. Вместо этого, мы передаем репозиторию критерий, с помощью которого он сможет найти один или несколько объектов. Репозиторий может сгенерировать SQL запрос в том случае, если он использует базу данных в качестве бекэнда, или он может найти необходимый объект перебором, если используется коллекция в памяти.
Одна из часто используемых реализаций критерия — паттерн Specification (далее спецификация). Спецификация — это простой предикат, который принимает объект бизнес-области и возвращает boolean:
package com.thinkinginobjects.repository;
 
import com.thinkinginobjects.domainobject.Account;
 
public interface AccountSpecification {
 
    boolean specified(Account account);
 
}

Итак, мы можем создавать реализации для каждого способа выполнения запросов к AccountRepository.
Обычная спецификация хорошо работает для репозитория в памяти, но не может быть использована с базой данных из-за неэффективности.
Для AccountRepository, работающего с SQL базой данных, спецификации необходимо реализовать интерфейс SqlSpecification:
package com.thinkinginobjects.repository;
 
public interface SqlSpecification {
 
    String toSqlClauses();
 
}

Репозиторий, использующий базу данных в качестве бекэнда, может использовать данный интерфейс для получения параметров SQL запроса. Если бы в качестве бекэнда для репозитория использовался Hibernate, мы бы использовали интерфейс HibernateSpecification, который генерирует Criteria.
SQL- и Hibernate-репозитории не используется метод specified. Тем не менее, мы находим наличие реализации данного метода во всех классах преимуществом, т.к. таким образом мы сможем использовать заглушку для AccountRepository в тестовых целях а также в кеширующей реализации репозитория перед тем, как запрос будет направлен непосредственно к бекэнду.
Мы даже можем сделать еще один шаг и использовать композицию Spicification с ConjunctionSpecification и DisjunctionSpecification для выполнения более сложных запросов. Нам кажется, что данный вопрос выходит за рамки статьи. Заинтересованный читатель может найти подробности и примеры в книге Эванса.
package com.thinkinginobjects.specification;
 
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.Restrictions;
import com.thinkinginobjects.domainobject.Account;
import com.thinkinginobjects.repository.AccountSpecification;
import com.thinkinginobjects.repository.HibernateSpecification;
 
public class AccountSpecificationByUserName implements AccountSpecification, HibernateSpecification {
 
    private String desiredUserName;
 
    public AccountSpecificationByUserName(String desiredUserName) {
        super();
        this.desiredUserName = desiredUserName;
    }
 
    @Override
    public boolean specified(Account account) {
        return account.hasUseName(desiredUserName);
    }
 
    @Override
    public Criterion toCriteria() {
        return Restrictions.eq("userName", desiredUserName);
    }
 
}


package com.thinkinginobjects.specification;
 
import com.thinkinginobjects.domainobject.Account;
import com.thinkinginobjects.repository.AccountSpecification;
import com.thinkinginobjects.repository.SqlSpecification;
 
public class AccountSpecificationByAgeRange implements AccountSpecification, SqlSpecification{
 
    private int minAge;
    private int maxAge;
 
    public AccountSpecificationByAgeRange(int minAge, int maxAge) {
        super();
        this.minAge = minAge;
        this.maxAge = maxAge;
    }
 
    @Override
    public boolean specified(Account account) {
        return account.ageBetween(minAge, maxAge);
    }
 
    @Override
    public String toSqlClauses() {
        return String.format("age between %s and %s", minAge, maxAge);
    }
 
}


Заключение


Паттерн DAO предоставляет размытое описание контракта. Используя его, выполучаете потенциально неверно используемые и раздутые реализации классов. Паттерн Репозиторий использует метафору коллекции, которая дает нам жесткий контракт и делает понимание вашего кода проще.

+18
111.5k 261
Comments 14