Недавно задумался о том, чем отличаются паттерны, позволяющие абстрагироваться от работы с хранилищем данных. Много раз поверхностно читал описания и различные реализации 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. Эрик Эванс дал точное описание в своей книге: «Respotory представляет собой все объекты определенного типа в виде концептуального множества. Его поведение похоже на поведение коллекции, за исключением более развитых возможностей для построения запросов».
Вернемся назад и спроектируем 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, мы бы использовали интерфейс HibernateSpicification, который генерирует 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 предоставляет размытое описание контракта. Используя его, выполучаете потенциально неверно используемые и раздутые реализации классов. Паттерн Репозиторий использует метафору коллекции, которая дает нам жесткий контракт и делает понимание вашего кода проще.

Комментарии (14)


  1. Throwable
    20.07.2015 11:19
    +14

    Когда я смотрю на заумные паттерны типа DAO и Repository мне представляется какой-то сферический конь в вакууме. При работе с базой данных основную роль играет не теоретическое обоснование выбранного паттерна и стройность архитектуры, а тупо быстродействие и оптимальность выполнения. Видел кучу проектов на Spring Repository. На тестах все хорошо, но когда проект обрастает данными, приходится делать оптимизации, которые рушат всю структуру этих паттернов. Вылазят проблемы типа N+1 запросов, проблемы выборки нескольких связанных сущностей, «проекции» сущностей, etc… Поэтому все паттерны очень условны и ограничены.

    Здесь же автор совершенно не убедил преимуществами Repository перед DAO.

    Сложнее создавать моки для интерфейса DAO во время юнит-тестирования. Необходимо было бы реализовывать больше методов DAO даже в тех тестовых сценариях, когда они не используются;

    Пусть реализует только те, которые используются в данном сценарии, в чем проблема? IDE не сгенерит заглушки? Или же пусть создает один универсальный осмысленный mock для DAO, реализующий все методы, который будет использоваться во всех тестах. Если DAO описывает интерфейс между логикой программы и хранилищем, то mock должен симулировать это хранилище.

    Интрфейс DAO становится все более привязанным к полям класса Account. Возникает необходимость в изменении интрфейса и его реализаций при изменении типов полей класса Account.

    А к чему еще он должен быть привязан? Это же DAO этой сущности! Нафига тогда он нужен, если он ничего ни знает о самой сущности? И как Repository нас избавляет от необходимого рефакторинга при изменении типа поля? В том, что типы заткнуты в Specification?

    List query(AccountSpecification specification);

    И как мокировать данный метод? Вы можете по этому методу сказать, какие запросы тут нужно мокировать? Если бы стоял простой findByUserName(), все было бы просто и понятно. Здесь же приходится писать дополнительную логику, узнающую specification, переданную в параметре.

    Более того, specification у нас зависима от платформы, а не какой-то там абстрактный критерий!!! Т.е. простой unit-test с repository мокнутым в коллекцию и простым поиском по имени без привлечения hibernate criteria уже не сделать, если в итоге мы хотим использовать hibernate.

    P.S. Риторический вопрос: какой человек в здравом уме и с лимитированным бюджетом будет писать для каждого запроса кучу рудиментарного по сути кода типа specification и прочей лабуды? Ну и апофеоз маразма — это юзать Repository с ORM, когда EntityManager решает абсолютно ВСЕ задачи, поставленные перед этим паттерном.
    Извиняюсь за резкость, но по-моему подобные теоретические изыскания топят Java, заставляя адептов делать сложно простые вещи.


    1. DrReiz
      20.07.2015 11:30
      -4

      > это юзать Repository с ORM, когда EntityManager решает абсолютно ВСЕ задачи

      ORM является одной из реализаций паттерна Repository.


    1. JCDenton Автор
      20.07.2015 12:40
      +1

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

      Пусть реализует только те, которые используются в данном сценарии, в чем проблема? IDE не сгенерит заглушки? Или же пусть создает один универсальный осмысленный mock для DAO, реализующий все методы, который будет использоваться во всех тестах. Если DAO описывает интерфейс между логикой программы и хранилищем, то mock должен симулировать это хранилище.

      Можно и так. Но для меня, например, чем меньше методов у мокнутого класса — тем проще работать с его моками.
      А к чему еще он должен быть привязан? Это же DAO этой сущности! Нафига тогда он нужен, если он ничего ни знает о самой сущности? И как Repository нас избавляет от необходимого рефакторинга при изменении типа поля? В том, что типы заткнуты в Specification?

      А почему он должен быть к ней привязан и что-то про нее знать? Чтобы что с этим делать? В описанном примере репозиторий знает только к какому бекэнду стучаться и все. ORM осуществляется так же каким-то другим классом, как я понимаю. Поэтому чем меньше репозиторий знает про модель — тем лучше, т.к. изменения в модели не тянут за собой изменений в логике репозитория.
      И как мокировать данный метод? Вы можете по этому методу сказать, какие запросы тут нужно мокировать? Если бы стоял простой findByUserName(), все было бы просто и понятно. Здесь же приходится писать дополнительную логику, узнающую specification, переданную в параметре.

      Какие вы будете выполнять запросы к БД — те и подставляйте в качестве моков. На каждый запрос отдельный мок. И, может, не один — чтобы проверить все возможные варианты со всеми возможными параметрами (корректными и некорректными).

      P.S. Мне кажется, вы слишком цеплятесь за конкретные вещи в статье, посвященной довольно абстрактной тематике. Ну, замените мысленно Hibernate на MongoDB, если оно вам так режет глаз.
      Спасибо за комментарий!


  1. vagra
    20.07.2015 13:01
    +2

    Есть и другой взгляд на репозитории и DAO. Например, уже достаточно старый тренд «Repository is dead».



  1. an24
    20.07.2015 16:54
    +3

    Пирамида абстракций. Над SQL надстроим ORM, потом HQL, над ним паттерн Repository, потом еще что-нибудь. Но это никак не снижает сложность программы и не улучшает ее читаемость. Гради Буч говорил, что самая полезная абстракция — абстракция сущности. Т.е. чем ближе к таблице(классу) тем полезнее.


  1. gurinderu
    20.07.2015 19:14
    +1

    у меня первым делом возникает сразу вопрос, а что делать если вам нужен запрос с join?)


    1. jrip
      20.07.2015 19:55

      Все просто — если нужен запрос с JOIN делаем запрос с JOIN :)
      Если же речь идет о выборке связанных списков из разных таблиц, то в зависимости от ситуации.


      1. gurinderu
        20.07.2015 20:32
        +2

        Ну допустим вам нужен left join от accounts к logs. Чтобы вы сделали в данной ситуации? Вопрос чисто из интереса)


        1. jrip
          21.07.2015 11:06

          Повторюсь :) Если вам нужен зачем-то join то делайте join.
          Как бы сделал я — отдельные DAO accounts, logs. Склеивание на уровне PHP.
          Почему так — App серверов можно поставить сколько угодно, простое горизонтальное масштабирование, в отличие от источника данных.
          Далее — логи, особенно сырые хранить в базе данных, возможно, очень быстро расхочется, тут как раз появляется один из главных плюсов DAO — берем и переносим логи куда угодно, переписыв всего один класс.

          Вообще запросы с join, делать при любом намеке на хайлоад не стоит, это может вызвать очень большие проблемы в будущем.
          Если зачем-то очень надо таки сделать join — ну пишем запрос тупо в DAO accounts, в практике я такое часто встречал, хоть и не одобряю.


          1. gurinderu
            21.07.2015 12:06

            Это просто пример таблиц. Так в какое DAO сувать запрос с join, в accounts или logs?
            При любом намеке на highload скорее всего и от реляционных БД придется отказаться.
            В целом inner join очень быстро работает.


            1. jrip
              21.07.2015 12:30

              >В целом inner join очень быстро работает.
              Дело не в скорости.
              Во первых самое простое масштабирование разнести таблицы по разным серверам.
              Далее можно заменить конкретный источник на другой тип базы данных, либо на что-то еще, на демон например.

              >При любом намеке на highload скорее всего и от реляционных БД придется отказаться.
              Это не так

              >Это просто пример таблиц. Так в какое DAO сувать запрос с join, в accounts или logs?
              Зачем вообще так необходим запрос с Join?
              Вообще логику можно учесть, вы выводите логи с указанием пользователя или логи пользователя.
              Впрочем как по мне — пофигу, одинаково фигово


            1. jrip
              21.07.2015 12:51

              >Это просто пример таблиц. Так в какое DAO сувать запрос с join, в accounts или logs?
              А ну да, еще можно какой-нибудь хитрый mapper намутить, но я это дело не люблю.
              Оно может показаться, что так потом проще понять кто с кем связан, но по-моему проще стандартно вынести названия таблиц в константы DAO а потом тупо поиском по ним в любой момент понять кто куда обращается.
              Большие проблемы обычно вызывает не понимание кто с кем связан, а сама связанность.


  1. summerwind
    21.07.2015 17:25

    В чем разница между Repository и EntityManager?