При разработке приложений на Java сразу бросается в глаза ее многословность. Причем дело здесь не столько в самом языке, сколько в принятых подходах к разработке. Например, при разработке корпоративных приложений часто используют подход с разделением системы на слои. Само по себе разделение на слои это замечательный архитектурный шаблон, правда его реализация выглядит зачастую следующим образом: есть слой представления из которого вызывается слой бизнес-логики, представляющий собой набор сервисов (по другому это называется анемичная модель предметной области). Из слоя сервисов вызывается слой объектов доступа к данным (DAO), который может быть реализован с помощью некоторого фреймворка объектно-реляционного отображения. При этом, т.к. хочется независимости реализации слоя бизнес-логики от слоя доступа к данным, то каждый DAO реализует некий интерфейс. Обращение к самим сервисам также осуществляется через интерфейсы. В итоге имеем следующую структуру:
Рассмотрим процесс поддержки приведенной выше структуры. Чтобы добавить в сервис новую операцию, необходимо выполнить следующие действия:
1. Добавить метод в интерфейс сервиса.
2. Добавить метод в реализацию сервиса.
3. Добавить метод в интерфейс DAO.
4. Добавить метод в реализацию DAO, при этом иногда встречаются проекты с разными реализациями DAO для различных окружений, поэтому метод нужно добавить в каждую.
Понятно, что пункты 3 и 4 являются необязательными, иногда можно обойтись только расширением сервиса и его интерфейса, но мы рассматриваем предельный случай.
Существует подход, который называется "Обобщенный объект доступа к данным" (Generic DAO), суть которого заключается в том, что из системы удаляются классы-реализации DAO и остаются только интерфейсы, которые "реализуются" во время работы приложения с использованием, например динамических прокси. Данный подход подробно описан в ставшей уже классической статье на IBM developerWorks Не повторяйте DAO!. Стоит отметить, что применим он только если слой доступа к данным очень простой - каждой операции соответствует выборка каких-то объектов из базы данных с помощью именованного запроса. В случае же сложной логики работы с данными, например когда запрос генерируется динамически по каким-либо фильтрам, данный способ не подходит и мы снова возвращаемся к необходимости существования класса-реализации DAO.
Другим подходом является полный отказ от слоя DAO и использование в сервисах напрямую соединений с БД, возможно обернутых в сессию Hibernate или EntityManager. Такой способ позволяет избавиться от лишних уровней абстракции, но за это мы платим потерей возможности заменить способ работы с данными без переписывания кода. При этом использование JPA не является панацеей, т.к. развитие слоя доступа к данным не ограничивается только сменой JPA-провайдера, иногда требуется отказаться от БД и перейти на использование внешнего хранилища, доступ к которому реализован, например, через веб-сервисы.
Другой проблемой при поддержке приложения является анатомия самих сервисов. Как правило каждый метод сервиса реализует не только бизнес-логику, но еще и управляет транзакциями. Методы сервисов выглядят примерно следующим образом:
В приведенном коде сразу бросается в глаза, что на одну строку полезной работы - вызов personDao - приходится 15 строк служебного кода, предназначенного для управления транзакциями и обработки ошибок. А ведь так будет выглядеть каждый метод каждого сервиса. Как вы думаете они будут разрабатываться? Правильно, копированием какого-либо существующего метода, вставкой и последующим редактированием. Ну, если не забудем, конечно же.
В принципе, современные контейнеры, реализующие паттерн "инверсия управления", такие как Spring Framework или Guice, а так же EJB версии 3.0 и выше могут взять управление транзакциями на себя. От разработчика при этом необходимо лишь правильно расставить аннотации. Однако существует несколько соображений в пользу ручного управления транзакциями. Во-первых, такие фреймворки не всегда доступны, нет необходимости для небольшого приложения на 5-10 экранов и 7-8 бизнес-объектов подключать к зависимостям EJB, а уж тем более Spring. Во-вторых, не всегда бизнес-метод укладывается в одну транзакцию, иногда необходимо каждое изменение в рамках метода выполнить в отдельной транзакции. В-третьих, аннотации обеспечивают самую примитивную логику: откат изменений в БД. Возможны ситуации, когда при сбое требуется выполнить более сложные действия, например вызвать компенсирующий метод веб-сервиса. Собственно, не зря же в спецификации EJB до сих пор мирно уживаются как транзакции, управляемые контейнером (CMT), так и транзакции, управляемые компонентом (BMT).
Собственно вот мы и подошли к цели повествования. Почему нельзя разделить ответственности класса-сервиса: пусть транзакциями управляет один класс, а бизнес-логику реализует другой? Опять же принцип SRP нарушаться не будет. Идея заключается в следующем: для приложения создается только один сервис, реализующий самый простой интерфейс с тремя методами: загрузить изменения в хранилище в рамках транзакции, прочесть какие-то данные из хранилища в рамках транзакции, а так же прочесть данные без создания транзакции. На языке Java интерфейс обобщенного сервиса выглядит следующим образом:
Классы, реализующие интерфейсы UpdateRequest и LoadRequest<T> инкапсулируют в себе бизнес-логику. При этом каждый класс реализует только свой небольшой участок, тем самым имеет только одну ответственность и хорошо тестируется. Для взаимодействия с хранилищем данных в объекты классов, реализующих бизнес-логику, передается реестр DAO:
Реализация обобщенного сервиса специфична для приложения. Собственно сами реализации отличаются в основном способами взаимодействия с хранилищем данных и стратегиями их использования. Например, реализация обобщенного сервиса, использующая JPA и реализующая стратегию "EntityManager на транзакцию" может выглядеть следующим образом (на примере метода updateTransactionally):
Теперь рассмотрим пример использования обобщенного сервиса. Нам необходимо в обработчике нажатия на кнопку сохранить изменения в базе данных:
Загрузка данных из БД может выглядеть так:
В данных простых примерах бизнес-логика размещается в анонимных реализациях интерфейсов UpdateRequest и LoadRequest<T>. Если необходимо реализовать более сложную последовательность действий, то нужно вынести логику в конкретный класс:
Использование данного класса выглядит следующим образом:
Так как сервис является обобщенным, то можно иметь одну его реализацию на все приложение, нужно только обеспечить потокобезопасность методов. Таким образом легко построить пул EntityManager'ов для всего приложения или реализовать паттерн Open Session In View. При этом можно как использовать инверсию управления, так и обойтись без нее.
Я считаю, что каждый программист должен уметь писать код как с использованием контейнеров, реализующий паттерн "инверсия управления", так и без них. Зачастую использование контейнеров, реализующих инверсию управления, не добавляет к приложению никаких полезных свойств, кроме того, что создает иллюзию некоторой архитектуры. Ну и дань моде конечно же присутствует. Выбирать инструмент необходимо адекватно задаче. Если известно, что приложение будет небольшим или большим, но требовательным к ресурсам, будет использовать фреймворк, не имеющий интеграции с популярными контенерами или будет эксплуатироваться не на полноценном сервере приложений, а на контейнере сервлетов, в котором не реализована инъекция слоя хранения в слой бизнес-логики, то необходимо позаботиться о ручном управлении транзакциями и обеспечении целостности данных. Однако допускать разработки методом копирования/вставки не стоит и в этом случае. Паттерн "обобщенный сервис" может быть хорошей альтернативой тяжелым сервисам со множеством методов, написанных с применением "паттерна" копирование/вставка.
Предлагаю обсудить описанный паттерн в комментариях. Буду благодарен, если опишите используемые вами подходы.
Понравилось сообщение - подпишитесь на блог
Описание проблемы
Рассмотрим процесс поддержки приведенной выше структуры. Чтобы добавить в сервис новую операцию, необходимо выполнить следующие действия:
1. Добавить метод в интерфейс сервиса.
2. Добавить метод в реализацию сервиса.
3. Добавить метод в интерфейс DAO.
4. Добавить метод в реализацию DAO, при этом иногда встречаются проекты с разными реализациями DAO для различных окружений, поэтому метод нужно добавить в каждую.
Понятно, что пункты 3 и 4 являются необязательными, иногда можно обойтись только расширением сервиса и его интерфейса, но мы рассматриваем предельный случай.
Избавляемся от поддержки слоя доступа к данным
Существует подход, который называется "Обобщенный объект доступа к данным" (Generic DAO), суть которого заключается в том, что из системы удаляются классы-реализации DAO и остаются только интерфейсы, которые "реализуются" во время работы приложения с использованием, например динамических прокси. Данный подход подробно описан в ставшей уже классической статье на IBM developerWorks Не повторяйте DAO!. Стоит отметить, что применим он только если слой доступа к данным очень простой - каждой операции соответствует выборка каких-то объектов из базы данных с помощью именованного запроса. В случае же сложной логики работы с данными, например когда запрос генерируется динамически по каким-либо фильтрам, данный способ не подходит и мы снова возвращаемся к необходимости существования класса-реализации DAO.
Другим подходом является полный отказ от слоя DAO и использование в сервисах напрямую соединений с БД, возможно обернутых в сессию Hibernate или EntityManager. Такой способ позволяет избавиться от лишних уровней абстракции, но за это мы платим потерей возможности заменить способ работы с данными без переписывания кода. При этом использование JPA не является панацеей, т.к. развитие слоя доступа к данным не ограничивается только сменой JPA-провайдера, иногда требуется отказаться от БД и перейти на использование внешнего хранилища, доступ к которому реализован, например, через веб-сервисы.
Анатомия сервисов
Другой проблемой при поддержке приложения является анатомия самих сервисов. Как правило каждый метод сервиса реализует не только бизнес-логику, но еще и управляет транзакциями. Методы сервисов выглядят примерно следующим образом:
public void updatePerson(Person person) { EntityManager em = null; try { em = getEntityManager(); EntityTransaction tx = null; try { tx = em.getTransaction(); personDao.updatePerson(Person); tx.commit(); } finally { if (tx != null && tx.isActive()) tx.rollback(); } } finally { if (em != null) em.close(); } }
В приведенном коде сразу бросается в глаза, что на одну строку полезной работы - вызов personDao - приходится 15 строк служебного кода, предназначенного для управления транзакциями и обработки ошибок. А ведь так будет выглядеть каждый метод каждого сервиса. Как вы думаете они будут разрабатываться? Правильно, копированием какого-либо существующего метода, вставкой и последующим редактированием. Ну, если не забудем, конечно же.
В принципе, современные контейнеры, реализующие паттерн "инверсия управления", такие как Spring Framework или Guice, а так же EJB версии 3.0 и выше могут взять управление транзакциями на себя. От разработчика при этом необходимо лишь правильно расставить аннотации. Однако существует несколько соображений в пользу ручного управления транзакциями. Во-первых, такие фреймворки не всегда доступны, нет необходимости для небольшого приложения на 5-10 экранов и 7-8 бизнес-объектов подключать к зависимостям EJB, а уж тем более Spring. Во-вторых, не всегда бизнес-метод укладывается в одну транзакцию, иногда необходимо каждое изменение в рамках метода выполнить в отдельной транзакции. В-третьих, аннотации обеспечивают самую примитивную логику: откат изменений в БД. Возможны ситуации, когда при сбое требуется выполнить более сложные действия, например вызвать компенсирующий метод веб-сервиса. Собственно, не зря же в спецификации EJB до сих пор мирно уживаются как транзакции, управляемые контейнером (CMT), так и транзакции, управляемые компонентом (BMT).
Обобщенный сервис
Собственно вот мы и подошли к цели повествования. Почему нельзя разделить ответственности класса-сервиса: пусть транзакциями управляет один класс, а бизнес-логику реализует другой? Опять же принцип SRP нарушаться не будет. Идея заключается в следующем: для приложения создается только один сервис, реализующий самый простой интерфейс с тремя методами: загрузить изменения в хранилище в рамках транзакции, прочесть какие-то данные из хранилища в рамках транзакции, а так же прочесть данные без создания транзакции. На языке Java интерфейс обобщенного сервиса выглядит следующим образом:
package ru.rt.uip.router.data; /** * Интерфейс представляет паттерн "Обобщенный сервис". Предполагается, что * бизнес-логика будет описана в реализациях функторов {@link * ru.rt.uip.router.data.GenericService.UpdateRequest} и {@linkplain * ru.rt.uip.router.data.GenericService.LoadRequest}, а сам сервис предоставляет * только "обертку" над операциями, оборачивая их в транзакции при необходимости. * * Разные реализации обобщенного сервиса могут отличаться стратегиями управления * транзакциями и временем жизни соединения с источником данных. */ public interface GenericService { /** * Изменение состояния источника данных в рамках транзакции * @param request функтор, реализующий бизнес-логику транзакции */ public void updateTransactionally(UpdateRequest request) throws RequestExecutionException; /** * Загрузка данных из источника вне транзакции * @param request функтор, реализующий бизнес-логику выборки данных * @param <T> тип возвращаемых значений */ public <T> T load(LoadRequest<T> request) throws RequestExecutionException; /** * Загрузка данных из источника в рамках транзакции * @param request функтор, реализующий бизнес-логику выборки данных * @param <T> тип возвращаемых значений */ public <T> T loadTransactionally(LoadRequest<T> request) throws RequestExecutionException; /** * Функтор, осуществляющий команды по изменению данных в источнике. * В рамках UoW будет обернут в транзакцию. * * @author psamolisov */ public static interface UpdateRequest { public void update(DaoRegistry daos); }; /** * Функтор, осуществляющий выборку из источника объектов класса * <code>T</code>. * * @author psamolisov * @param <T> тип запрашиваемого значения */ public static interface LoadRequest<T> { public T load(DaoRegistry daos); }; }
Классы, реализующие интерфейсы UpdateRequest и LoadRequest<T> инкапсулируют в себе бизнес-логику. При этом каждый класс реализует только свой небольшой участок, тем самым имеет только одну ответственность и хорошо тестируется. Для взаимодействия с хранилищем данных в объекты классов, реализующих бизнес-логику, передается реестр DAO:
package ru.rt.uip.router.data; /** * Реестр объектов, реализующих слой доступа к данным (<i>DAO</i>). Объекты * могут строиться как явно, так и лениво при вызове метода {@linkplain * #get(java.lang.Class)} в зависимости от реализуемой стратегии использования * данных. * * @author psamolisov * */ public interface DaoRegistry { /** * Возвращает объект, реализующий слой доступа к данным (<i>DAO</i>) по * его классу. * @param clazz класс требуемого объекта * @return DAO */ public <T> T get(Class<T> clazz); }
Реализация обобщенного сервиса специфична для приложения. Собственно сами реализации отличаются в основном способами взаимодействия с хранилищем данных и стратегиями их использования. Например, реализация обобщенного сервиса, использующая JPA и реализующая стратегию "EntityManager на транзакцию" может выглядеть следующим образом (на примере метода updateTransactionally):
/** * Реализация паттерна "Обобщенный сервис" со стратегией " * {@link javax.persistence.EntityManager} на транзакцию". * Важно! Т.к. после выполнения бизнес-методов сервиса * {@link javax.persistence.EntityManager} закрыт, то данные методы должны * возвращать <b>полностью выбранные объекты</b>! * * @author psamolisov */ public class UipGenericService implements GenericService { private EntityManagerFactory emf; public UipGenericService(EntityManagerFactory emf) { this.emf = emf; } @Override public void updateTransactionally(UpdateRequest request) throws RequestExecutionException { EntityManager em = null; try { em = emf.createEntityManager(); EntityTransaction transaction = em.getTransaction(); try { transaction.begin(); request.update(registry(em)); transaction.commit(); } catch (Exception e) { try { if (transaction.isActive()) transaction.rollback(); } catch (Exception ee) { throw new RequestExecutionException("updateTransactionally " + "rollback fault", ee); } throw new RequestExecutionException("updateTransactionally fault", e); } } finally { if (em != null) em.close(); } }
Теперь рассмотрим пример использования обобщенного сервиса. Нам необходимо в обработчике нажатия на кнопку сохранить изменения в базе данных:
service.updateTransactionally(new UpdateRequest() { @Override public void update(DaoRegistry daos) { daos.get(UipDao.class).deleteBranches(forDelete); daos.get(UipDao.class).mergeBranches( getBranchesForMerge(forMerge)); daos.get(UipDao.class).createBranches( getBranchesForCreate(forCreate)); } });
Загрузка данных из БД может выглядеть так:
List<Filial> filials = service.load(new LoadRequest<List<Filial>>() { @Override public List<Filial> load(DaoRegistry daos) { return daos.get(UipDao.class).getBranches(); } });
В данных простых примерах бизнес-логика размещается в анонимных реализациях интерфейсов UpdateRequest и LoadRequest<T>. Если необходимо реализовать более сложную последовательность действий, то нужно вынести логику в конкретный класс:
import ru.rt.uip.router.data.GenericService.UpdateRequest; public class AcceptDocumentUpdateRequest implements UpdateRequest { private Document document; private Employee acceptor; public SaveDocumentUpdateRequest(Document document, Employee acceptor) { this.document = document; } @Override public void update(DaoRegistry daos) { // проверяем, что документ еще не утвержден if (document.isAccepted()) throw new IllegalStateException("Document is accepted"); // проверяем, что утверждающий документ человек наделен соответствующими // полномочиями if (!daos.get(Employee.class).hasPriviledge(acceptor, Priviledge.ACCEPT)) throw new IllegalArgumentException("Wrong Acceptor!"); // проверяем, что в документе заполнена дата начала действия if (document.getStartDate() == null) throw new IllegalStateException("Document must have a start date"); // проверяем еще что-то. ... } }
Использование данного класса выглядит следующим образом:
Employee acceptor = ...; Document document = ...; service.updateTransactionally(new SaveDocumentUpdateRequest( document, acceptor));
Так как сервис является обобщенным, то можно иметь одну его реализацию на все приложение, нужно только обеспечить потокобезопасность методов. Таким образом легко построить пул EntityManager'ов для всего приложения или реализовать паттерн Open Session In View. При этом можно как использовать инверсию управления, так и обойтись без нее.
Выводы
Я считаю, что каждый программист должен уметь писать код как с использованием контейнеров, реализующий паттерн "инверсия управления", так и без них. Зачастую использование контейнеров, реализующих инверсию управления, не добавляет к приложению никаких полезных свойств, кроме того, что создает иллюзию некоторой архитектуры. Ну и дань моде конечно же присутствует. Выбирать инструмент необходимо адекватно задаче. Если известно, что приложение будет небольшим или большим, но требовательным к ресурсам, будет использовать фреймворк, не имеющий интеграции с популярными контенерами или будет эксплуатироваться не на полноценном сервере приложений, а на контейнере сервлетов, в котором не реализована инъекция слоя хранения в слой бизнес-логики, то необходимо позаботиться о ручном управлении транзакциями и обеспечении целостности данных. Однако допускать разработки методом копирования/вставки не стоит и в этом случае. Паттерн "обобщенный сервис" может быть хорошей альтернативой тяжелым сервисам со множеством методов, написанных с применением "паттерна" копирование/вставка.
Предлагаю обсудить описанный паттерн в комментариях. Буду благодарен, если опишите используемые вами подходы.
Понравилось сообщение - подпишитесь на блог
5 комментариев:
Интересная статья, спасибо.
Предложу ещё третий вариант: использовать AspectJ, если хочется простого управления, но не хочется сложных зависимостей (типа Spring или EE). Основной минус -- сложная отладка, если не использовать proxy-based AOP.
Да, тоже неплохой вариант. По-сути обобщенный сервис делает нечто похожее - оборачивает участок бизнес-логики в транзакцию, правда делает это явно в отличие от AspectJ. Аналогично можно воспользоваться генерацией байткода через соответствующие библиотеки.
Спасибо за статью, ситуация с многослойностью систем знакомая многим, можно было еще упомянуть DTO. Обобщенный сервис имеет право на жизнь, но вместо
1. Добавить метод в интерфейс сервиса.
2. Добавить метод в реализацию сервиса.
3. Добавить метод в интерфейс DAO.
4. Добавить метод в реализацию DAO, при этом иногда встречаются проекты с разными реализациями DAO для различных окружений, поэтому метод нужно добавить в каждую.
может получиться:
1. Добавить метод в обобщейнный интерфейс
2. Перелопатить все реализации
ну и от DAO мы никуда не ушли.
3. Добавить метод в интерфейс DAO.
4. Добавить метод в реализацию DAO, при этом иногда встречаются проекты с разными реализациями DAO для различных окружений, поэтому метод нужно добавить в каждую.
Еще смущают такие конструкции в клиентских классах
List filials = service.load(new LoadRequest>() {
@Override
public List load(DaoRegistry daos) {
return daos.get(UipDao.class).getBranches();
}
});
И очень пугает "нужно только обеспечить потокобезопасность методов"
Прежде всего стоит отметить, что обобщенный сервис он на то и обобщенный, что методы в него добавлять не нужно, логика выносится в отдельные классы, возможно анонимные. DAO да, нужен, но я ведь перед тем как вводить понятие обобщенного сервиса перечислил способы борьбы с усложнением DAO, в общем случае от него можно вообще отказаться.
Потокобезопасность штука конечно коварная, но одним из самых надежных способов ее обеспечения является полная ликвидация общих, разделяемых потоками, объектов. В случае DAO и сервисов это прежде всего EntityManager. В моих наработках я использовал стратегию "EntityManager на транзакцию", т.е. каждый раз после входа в обобщенный метод создавал новый экземпляр EntityManager'а, выполнял работу, а затем его закрывал.
Хорошая статья!
Описан прямо таки идеальный случай - транзакциями управляет уровень сервисов, надо что-от новое из БД - напиши наследника DAO (сильно упрощенная версия). Несколько лет уже им пользуюсь в программировании бизнес-приложений.!
Отправить комментарий
Любой Ваш комментарий важен для меня, однако, помните, что действует предмодерация. Давайте уважать друг друга!