| Сегодня я хочу наконец затронуть тему доступа к данным в Java и начать с самых основ - с использования JDBC и шаблона DAO. Описывать DAO я не стану - в интернете полно хороших описаний этого шаблона. Я хочу заострить внимание на его реализации. Статья построена таким образом, чтобы не давать готового решения, а попытаться шаг за шагом привести Вас к нему. Поэтому не торопитесь переживать по поводу заведомо не оптимальных решений в начале, скорее всего ваши замечания будут учтены по ходу статьи. |
То же самое на русском:
Пример для статьи.
Когда речь заходит о реализации, без конкретного примера трудно обойтись. В качестве такого примера я буду использовать простейшую ситуацию с двумя таблицами связанными отношением один ко многим:
Первая таблица описывает студенческие группы, их номер и название факультета; вторая таблица описывает студентов, их имя, фамилию, дату поступления и содержит внешний ключ на первую таблицу.
Обе таблицы содержат суррогатные первичные ключи, не смотря на то, что в случае с таблицей Group на роль первичного ключа идеально подходит номер группы. Вообще, особенной проблемы использовать номер группы в качестве первичного ключа нет. Но это не лучшая практика, тк ни что не вечно под луной и то, что нам кажется неизменным на этапе проектирования, может в конце концов измениться и принести нам много головной боли. Впрочем, этот вопрос не имеет никакого отношения к статье.
Вот пара простых sql скриптов для создания и наполнения базы данных:
Вот пара простых sql скриптов для создания и наполнения базы данных:
create_scheme.sql
generate_content.sql
Полигон для испытаний.
Когда мы разговариваем о данных, говорить хочется именно о способах получения и объектного представления данных, и не тратить силы на их визуальное представление. Но убедиться в корректности того, что делаем все таки стоит. Для этого я буду использовать модульные тесты на JUnit.
Вернемся к DAO.
Главная задача шаблона DAO - построить мост между реляционной и объектной моделями данных. Получив представление о реляционной модели, поговорим об объектном представлении.
В случае с Group - все достаточно просто:
Но когда мы начнем проектировать объект Student, перед нами обязательно встанет вопрос: "Как быть с ссылкой на группу?". Тут есть два варианта:
- Мы можем сделать целочисленное поле, содержащее значение ключа соответствующей группы.
- Или объявить поле group, как содержащее ссылку на полноценный объект, описывающий группу в которой учится студент.
Первый способ. Храним значение внешнего ключа.
Такое решение, вообще говоря, не далеко уводит нас от реляционного представления данных, но может оказаться вполне приемлемым в простых проектах, в силу своей ясности и простоты реализации.
Именно с этого решения мы начнем реализацию слоя DAO.
Библиотека DAO. Начало
Реализацию шаблона DAO начнем с проектирования и реализации интерфейсов.
DaoFactory.java
GroupDao.java
StudentDao.java
Не знаю как Вам, а мне всегда не терпится увидеть первый результат своей работы. Поэтому, вместо реализации всей функциональности этап за этапом, я сосредоточусь на том, чтобы написать все необходимое для выполнения простейшей операции - вывода списка всех студенческих групп.
В качестве базы данных я использую MySQL и предлагаю простейшую реализацию слоя DAO для работы с этой СУБД:
MySqlDaoFactory.java
MySqlGroupDao.java
Реализованных на данный момент методов достаточно, чтобы отобразить список всех студенческих групп и даже немножко больше.
Напишем простейший тест, чтобы убедиться в корректности реализации:
@Test
public void testGetAll() throws Exception {
DaoFactory daoFactory = new MySqlDaoFactory();
List<Group> list;
try (Connection con = daoFactory.getConnection()) {
GroupDao dao = daoFactory.getGroupDao(con);
list = dao.getAll();
}
Assert.assertNotNull(list);
Assert.assertTrue(list.size() > 0);
}
Минутка рефакторинга
Прежде чем бросаться реализовывать остальные методы и классы, предлагаю обратить внимание на сигнатуру интерфейсов Group и Student. Фактически, они отличаются только типом возвращаемых объектов, что наводит на мысль создания единого унифицированного интерфейса:
GenericDao.java
Теперь обратим внимание на методы read(int key) и getAll() у ранее приведенной реализации MySqlGroupDao:
@Override
public Group read(int key) throws SQLException {
String sql = "SELECT * FROM daotalk.Group WHERE id = ?;";
Group g = new Group();
try (PreparedStatement stm = connection.prepareStatement(sql)) {
stm.setInt(1, key);
ResultSet rs = stm.executeQuery();
rs.next();
g.setId(rs.getInt("id"));
g.setNumber(rs.getInt("number"));
g.setDepartment(rs.getString("department"));
}
return g;
}
@Override
public List<Group> getAll() throws SQLException {
String sql = "SELECT * FROM daotalk.Group;";
List<Group> list = new ArrayList<Group>();
try (PreparedStatement stm = connection.prepareStatement(sql)) {
ResultSet rs = stm.executeQuery();
while (rs.next()) {
Group g = new Group();
g.setId(rs.getInt("id"));
g.setNumber(rs.getInt("number"));
g.setDepartment(rs.getString("department"));
list.add(g);
}
}
return list;
}
@Override
public Student read(int key) throws SQLException {
String sql = "SELECT * FROM daotalk.Student WHERE id = ?;";
Student s = new Student();
try (PreparedStatement stm = connection.prepareStatement(sql)) {
stm.setInt(1, key);
ResultSet rs = stm.executeQuery();
rs.next();
s.setId(rs.getInt("id"));
s.setName(rs.getString("name"));
s.setSurname(rs.getString("surname"));
s.setEnrolmentDate(rs.getDate("enrolment_date"));
s.setGroupId(rs.getInt("group_id"));
}
return s;
}
@Override
public List<Student> getAll() throws SQLException {
String sql = "SELECT * FROM daotalk.Student;";
List<Student> list = new ArrayList<Student>();
try (PreparedStatement stm = connection.prepareStatement(sql)) {
ResultSet rs = stm.executeQuery();
while (rs.next()) {
Student s = new Student();
s.setId(rs.getInt("id"));
s.setName(rs.getString("name"));
s.setSurname(rs.getString("surname"));
s.setEnrolmentDate(rs.getDate("enrolment_date"));
s.setGroupId(rs.getInt("group_id"));
list.add(s);
}
}
return list;
}
/**
* Абстрактный класс предоставляющий базовую реализацию CRUD операций с использованием JDBC.
*
* @param <T> тип объекта персистенции
* @param <PK> тип первичного ключа
*/
public abstract class AbstractJDBCDao<T, PK extends Serializable> implements GenericDao<T, PK> {
private Connection connection;
/**
* Возвращает sql запрос для получения всех записей.
* <p/>
* SELECT * FROM [Table]
*/
public abstract String getSelectQuery();
/**
* Разбирает ResultSet и возвращает список объектов соответствующих содержимому ResultSet.
*/
protected abstract List<T> parseResultSet(ResultSet rs);
@Override
public T getByPK(int key) throws PersistException {
List<T> list;
String sql = getSelectQuery();
sql += " WHERE id = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, key);
ResultSet rs = statement.executeQuery();
list = parseResultSet(rs);
} catch (Exception e) {
throw new PersistException(e);
}
if (list == null || list.size() == 0) {
return null;
}
if (list.size() > 1) {
throw new PersistException("Received more than one record.");
}
return list.iterator().next();
}
@Override
public List<T> getAll() throws PersistException {
List<T> list;
String sql = getSelectQuery();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
ResultSet rs = statement.executeQuery();
list = parseResultSet(rs);
} catch (Exception e) {
throw new PersistException(e);
}
return list;
}
. . .
public AbstractJDBCDao(Connection connection) {
this.connection = connection;
}
}
Обратите внимание на то, как составляется запрос на получение записи по первичному ключу. В общем случае такая практика может оказаться не приемлемой, тогда соответствующий запрос нужно будет получать аналогично Select из специального метода.
Создание, удаление, обновление
Что на счет оставшихся методов создания, обновления и удаления записей? Аналогично методам получения данных, эти методы будут мало отличаться в реализации каждого DAO объекта. Во первых, это по прежнему будет синтаксис sql запросов, а во вторых процесс заполнения аргументов этих запросов.
@Override
public void update(T object) throws PersistException {
String sql = getUpdateQuery();
try (PreparedStatement statement = connection.prepareStatement(sql);) {
prepareStatementForUpdate(statement, object); // заполнение аргументов запроса оставим на совесть потомков
int count = statement.executeUpdate();
if (count != 1) {
throw new PersistException("On update modify more then 1 record: " + count);
}
} catch (Exception e) {
throw new PersistException(e);
}
}
@Override
public void delete(T object) throws PersistException {
String sql = getDeleteQuery();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
prepareStatementForDelete(statement, object); // заполнение аргументов запроса оставим на совесть потомков
int count = statement.executeUpdate();
if (count != 1) {
throw new PersistException("On delete modify more then 1 record: " + count);
}
statement.close();
} catch (Exception e) {
throw new PersistException(e);
}
}
@Override
public T persist(T object) throws PersistException {
if (object.getId() != null) {
throw new PersistException("Object is already persist.");
}
T persistInstance;
// Добавляем запись
String sql = getCreateQuery();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
prepareStatementForInsert(statement, object);
int count = statement.executeUpdate();
if (count != 1) {
throw new PersistException("On persist modify more then 1 record: " + count);
}
} catch (Exception e) {
throw new PersistException(e);
}
// Получаем только что вставленную запись
sql = getSelectQuery() + "WHERE id = last_insert_id();";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
ResultSet rs = statement.executeQuery();
List<T> list = parseResultSet(rs);
if ((list == null) || (list.size() != 1)) {
throw new PersistException("Exception on findByPK new persist data.");
}
persistInstance = list.iterator().next();
} catch (Exception e) {
throw new PersistException(e);
}
return persistInstance;
}
Полный код класса AbstractJDBCDao:
И так, давайте посмотрим на сколько просто стало создавать новые DAO классы. Унаследуем AbstractJDBCDao и реализуем GroupDao и StudentDao:
MySqlGroupDao.java
MySqlStudentDao.java
Не так уж и плохо! Однако метод prepareStatementForDelete постоянно дублируется в силу того, что мы не можем потребовать от наших объектов id в классе AbstractJDBCDao. Как вы и догадались, это легко решается с помощью интерфейса с единственным методом: getId(). Если все наши доменные объекты будут его реализовывать, то аргументы delete запроса можно будет устанавливать в методе delete абстрактного класса.
public abstract class AbstractJDBCDao<T extends Identified<PK>, PK extends Serializable> {
. . .
@Override
public void delete(T object) throws PersistException {
String sql = getDeleteQuery();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
try {
statement.setObject(1, object.getId());
} catch (Exception e) {
throw new PersistException(e);
}
int count = statement.executeUpdate();
if (count != 1) {
throw new PersistException("On delete modify more then 1 record: " + count);
}
statement.close();
} catch (Exception e) {
throw new PersistException(e);
}
}
. . .
}
Кто отвечает за идентификатор?
Если Вы обратили внимание на реализацию метода persist, то могли заметить, что задачу генерирования ключа я возлагаю на плечи СУБД. И в этом таится некоторая опасность. Дело в том, что наличие публичного метода set для идентификатора может рано или поздно привести к его противоправному изменению. Для решения этой проблемы я предлагаю следующее, возможно спорное, решение: сделать метод setId доступным только для Dao объекта. Реализовать это можно объявив setId protected методом и написав private наследника доменной сущности внутри DAO класса:
public class MySqlGroupDao extends AbstractJDBCDao<Group, Integer> implements GroupDao {
private class PersistGroup extends Group {
public void setId(int id) {
super.setId(id);
}
}
. . .
@Override
protected List<Group> parseResultSet(ResultSet rs) throws PersistException {
LinkedList<Group> result = new LinkedList<Group>();
try {
while (rs.next()) {
PersistGroup group = new PersistGroup(); // точка подмены реализации Group
group.setId(rs.getInt("id"));
group.setNumber(rs.getInt("number"));
group.setDepartment(rs.getString("department"));
result.add(group);
}
} catch (Exception e) {
throw new PersistException(e);
}
return result;
}
По сути, строка, где мы подменяем реализацию доменного объекта (в приведенном примере объекта Group) - это отправная точка его жизненного цикла, на протяжении которого значение идентификатора должно оставаться неизменным. Поэтому подменив реализацию объекта в этом месте, мы можем быть уверены, что обращение к объекту, как к экземпляру нового вложенного класса (PersistGroup), нам более нигде не понадобится.
Главным недостатком такого решения является необходимость изменения ссылки на объект после вызова метода persist:
Group g = persist(g);
С другой стороны мы можем легко определить соответствует ли объекту запись в базе данных или еще нет - достаточно проверить, определена ли ссылка на идентификатор у объекта.
Минутка рефакторинга затягивается
Мы проделали огромную работу, сократив количество кода, необходимого для реализации новых дао объектов. Осталось еще чуть-чуть - подумать, что можно сделать с фабрикой дао объектов.
Предложенный в начале статьи интерфейс фабрики предполагает написание отдельного метода для получения экземпляра дао. Это сильного ограничивает масштабируемость фабрики, вынуждая каждый раз изменять не только ее реализацию, но и интерфейс.
Удобнее было бы иметь метод, который для указанного класса доменного объекта возвращал дао объект:
public GenericDao getDao(Class dtoClass) throws PersistException;
public interface DaoCreator<Context> {
public GenericDao create(Context context);
}
public GenericDao getDao(Connection connection, Class dtoClass) throws PersistException {
DaoCreator creator = creators.get(dtoClass);
if (creator == null) {
throw new PersistException("Dao object for " + dtoClass + " not found.");
}
return creator.create(connection);
}
creators = new HashMap<Class, DaoCreator>();
creators.put(Group.class, new DaoCreator<Connection>() {
@Override
public GenericDao create(Connection connection) {
return new MySqlGroupDao(connection);
}
});
Прежде, чем я приведу полный код фабрики, я забегу вперед и объясню смысл PersistException и Context. Сам DAO паттерн подразумевает абстрагирование от способа хранения данных и не ограничивается использованием в этих целях реляционными базами данных. Поэтому, вообще говоря, использовать классы пакета java.sql в унифицированных классах и интерфейсах крайне не желательно. Использование собственного исключения PersistException позволяет не завязываться на SqlException. Context - сущность, описывающая сеанс взаимодействия с системой хранения данных, в случае с JDBC в качестве контекста выступает Connection.
![]() |
| Старый интерфейс |
![]() |
| Новый интерфейс |
MySqlDaoFactory.java
Тесты всему голова
После столь большой работы по рефакторингу кода, самое время проконтролировать его корректность. Для получающегося унифицированного решения нужен не менее унифицированный тест. Мы на самом деле можем для каждого дао объекта проконтролировать стандартные CRUD операции, за исключением операции обновления, тк в общем случае мы не знаем какие есть поля у соответствующего объекта доменной сущности.
GenericDaoTest.java
Пример реализации теста для MqSql фабрики:
MySqlDaoTest.java
Подытожим
Затронутая тема реализации шаблона DAO получается слишком большой, чтобы уместиться в рамках одной статьи. К настоящему моменту уже проделана достаточно большая работа: реализован унифицированные классы и интерфейсы паттерна, написана использующая их реализация управления персистентным состоянием объектов, функционал понаписанного проверен модульными тестами и вполне жизнеспособен. Исходный код к статье можно скачать здесь: https://bitbucket.org/dok/daotalk/get/daotalk1.zip
В следующей части статьи мы наконец вернемся к поднятому в самом начале вопросу: что делать с ссылкой на группу, в которой учится студент. Рассмотрим реализацию описанных выше решений, их плюсы и минусы.







