Недавно задумался о том, чем отличаются паттерны, позволяющие абстрагироваться от работы с хранилищем данных. Много раз поверхностно читал описания и различные реализации DAO и Repository, даже применял их в своих проектах, видимо, до конца не понимая концептуальных отличий. Решил разобраться, закопался в Google и нашел статью, которая для меня разъяснила все. Подумал, что неплохо было бы перевести ее на русский. Оригинал для англочитающих здесь. Остальным интересующимся добро пожаловать под кат.
Data Access Object (DAO) — широко распространенный паттерн для сохранения объектов бизнес-области в базе данных. В самом широком смысле, DAO — это класс, содержащий CRUD методы для конкретной сущности.
Предположим, что у нас имеется сущность Account, представленная следующим классом:
Создадим интерфейс DAO для данной сущности:
Интрефейс AccountDAO может иметь множество реализаций, которые могут использовать различные ORM фреймворки или прямые SQL-запросы к базе данных.
Паттерн имеет следующие преимущества:
Тем не менее, паттерн оставляет множество вопросов без ответа. Что если нам необходимо получить список аккаунтов с определенным lastName? Можно ли добавить метод, который обновляет только поле email для аккаунта? Что делать, если мы захотим использовать long id вместо userName в качестве идентификатора? Что именно является обязанностью DAO?
Проблема заключается в том, что обязанности DAO не описаны четко. Большая часть людей представляет DAO некими вратами к базе данных и добавляет в него методы как только находит новый способ, которым они хотели бы общаться с базой данных. Поэтому нередко можно увидеть DAO, раздутый как в следующем примере:
В BloatAccountDAO мы добавили методы для поиска аккаунтов по различных параметрам. Если бы в классе Account было больше полей и больше различных способов построения запросов, мы могли бы получить еще более раздутый DAO. Следствием чего стало бы:
Чтобы сгустить краски еще сильнее, мы добавили дополнительные методы обновления в DAO. Они являются непосредственным результатом появления двух новых сценариев использования, которые обновляют различные наборы полей аккаунта. Они выглядят как невинная оптимизация и отлично укладываются в концепцию AccountDAO в том случае, если мы рассматриваем интрфейс как врата к хранилищу данных. Паттерн DAO и название класса AccountDAO определены слишком расплывчато чтобы отвратить нас от этого шага.
В итоге мы получили раздутый интерфейс DAO и, я уверен, мои коллеги добавят еще больше методов в будущем. Через год мы будем иметь класс с более чем 20 методами и проклинать себя за то, что выбрали этот паттерн.
Лучшим решением будет использование паттерна Repository. Эрик Эванс дал точное описание в своей книге: «Repository представляет собой все объекты определенного типа в виде концептуального множества. Его поведение похоже на поведение коллекции, за исключением более развитых возможностей для построения запросов».
Вернемся назад и спроектируем AccountRepository в соответствии с данным определением:
Методы add и update выглядят идентично методам AccountDAO. Метод remove отличается от метода удаления, определенного в DAO тем, что принимает Account в качестве параметра вместо userName (идентификатора аккаунта). Представление репозитория как коллекции меняет его восприятие. Вы избегаете раскрытия типа идентификатора аккаунта репозиторию. Это сделает вашу жизнь легче в том случае, если вы захотите использовать long для идентрификации аккаунтов.
Если вы задумываетесь о контрактах методов add/remove/update, просто подумайте об абстрации коллекции. Если вы задумаетесь о добавлении еще одного метода update для репозитория, подумайте, имеет ли смысл добавлять еще один метод update для коллекции.
Однако, метод query является особенным. Я бы не ожидал увидеть такой метод в классе коллекции. Что он делает?
Репозиторий отличается от коллекции, если рассматривать возможности для построения запросов. Имея коллекцию объектов в памяти, довольно просто перебрать все ее элементы и найти интересующий нас экземпляр. Репозиторий работает с большим набором объектов, чаще всего, находящихся вне оперативной памяти в момент выполнения запроса. Нецелесообразно загружать все аккаунты в память, если нам необходим один конкретный пользователь. Вместо этого, мы передаем репозиторию критерий, с помощью которого он сможет найти один или несколько объектов. Репозиторий может сгенерировать SQL запрос в том случае, если он использует базу данных в качестве бекэнда, или он может найти необходимый объект перебором, если используется коллекция в памяти.
Одна из часто используемых реализаций критерия — паттерн Specification (далее спецификация). Спецификация — это простой предикат, который принимает объект бизнес-области и возвращает boolean:
Итак, мы можем создавать реализации для каждого способа выполнения запросов к AccountRepository.
Обычная спецификация хорошо работает для репозитория в памяти, но не может быть использована с базой данных из-за неэффективности.
Для AccountRepository, работающего с SQL базой данных, спецификации необходимо реализовать интерфейс SqlSpecification:
Репозиторий, использующий базу данных в качестве бекэнда, может использовать данный интерфейс для получения параметров SQL запроса. Если бы в качестве бекэнда для репозитория использовался Hibernate, мы бы использовали интерфейс HibernateSpecification, который генерирует Criteria.
SQL- и Hibernate-репозитории не используется метод specified. Тем не менее, мы находим наличие реализации данного метода во всех классах преимуществом, т.к. таким образом мы сможем использовать заглушку для AccountRepository в тестовых целях а также в кеширующей реализации репозитория перед тем, как запрос будет направлен непосредственно к бекэнду.
Мы даже можем сделать еще один шаг и использовать композицию Spicification с ConjunctionSpecification и DisjunctionSpecification для выполнения более сложных запросов. Нам кажется, что данный вопрос выходит за рамки статьи. Заинтересованный читатель может найти подробности и примеры в книге Эванса.
Паттерн DAO предоставляет размытое описание контракта. Используя его, выполучаете потенциально неверно используемые и раздутые реализации классов. Паттерн Репозиторий использует метафору коллекции, которая дает нам жесткий контракт и делает понимание вашего кода проще.
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 предоставляет размытое описание контракта. Используя его, выполучаете потенциально неверно используемые и раздутые реализации классов. Паттерн Репозиторий использует метафору коллекции, которая дает нам жесткий контракт и делает понимание вашего кода проще.