Java. Реализация шаблона DAO. Продолжение


В прошлой статье мы начали разговор о реализации паттерна DAO в Java. В качестве наглядного примера использовалась ситуация с двумя сущностями: группой и студентом. В самом начале нашей беседы мы столкнулись с дилеммой: как реализовывать связь студент-группа? Сохранять ли первичный ключ группы, в которой состоит студент, или хранить объект, эту группу описывающий?
Первый вариант мы уже рассмотрели. Пришла пора реализовать второй.

Второй способ. Храним ссылку на объект.

И так, наша цель абстрагироваться от реляционного представления и максимально обобщить код получившегося решения. Мы будем рассматривать простейшую связь многие-к-одному. В выбранном примере это означает, что в одной группе может учиться несколько студентов, но каждый студент может быть зачислен только в одну группу. Кроме того, у объектов описывающих студентов будет ссылка на группу, в то время как у объектов групп ссылки на студентов не будет.

Student.java

Что мы хотим получить? TDD

Ситуация, когда один хранимый объект имеет ссылку на другой хранимый объект, не столь прозрачна, как в случае с непосредственным отражением реляционной структуры.  Прежде всего, давайте определимся с тем, что мы хотим получить, а точнее с тем, как должны вести себя наши объекты в CRUD(Create, Read, Update, Delete) ситуациях. В этом нам поможет принцип разработки отталкивающийся от тестов, так называемый test-driven development. Вначале мы напишем тесты, которые описывают желаемое поведение, и в процессе статьи добьемся их выполнения.

Create

Начнем с операции создания объекта. Прежде всего, объект должен создаваться без ошибок и допускать изменение ссылки на группу:

@Test
public void testCreate() throws PersistException {
    Student student = (Student) factory.getDao(connection, Student.class).create();
    Assert.assertNull("Group is not null.", student.getGroup());

    Group group = new Group();
    student.setGroup(group);
    Assert.assertNotNull("Group is null.", student.getGroup());
}

Persist

При сохранении объекта в базе, мы будем сохранять и его зависимости. Напомню, что при создании записи об объекте (метод persist), мы создаем новый объект. Поэтому важно проконтролировать, что ссылка на группу не теряется. К тому же, здесь мы сталкиваемся с двоякой ситуацией: в одном случае объекту Group уже может соответствовать запись в базе данных, в другом нет. Нам необходимо проверить оба варианта:

@Test
public void testPersist() throws PersistException {
    Student student = new Student();
    Group group = (Group) factory.getDao(connection, Group.class).create();
    student.setGroup(group);
    group.setNumber(1234);
    student = (Student) factory.getDao(connection, Student.class).persist(student);
    Assert.assertNotNull("Group is null.", student.getGroup());
    Assert.assertEquals("Wrong group number.", 1234, student.getGroup().getNumber());
}

@Test
public void testPersistAll() throws PersistException {
    Student student = new Student();
    student.setGroup(new Group());
    student = (Student) factory.getDao(connection, Student.class).persist(student);
    Assert.assertNotNull("Group is null.", student.getGroup());
    Assert.assertNotNull("Group.id is null.", student.getGroup().getId());
}

Update

При обновлении записи об объекте тесты практически идентичны тем, что мы будем использовать для проверки метода persist:

@Test
public void testUpdate() throws PersistException {
    Student student = (Student) factory.getDao(connection, Student.class).create();
    student.setGroup(new Group());
    factory.getDao(connection, Student.class).update(student);
    Assert.assertNotNull("Group is null.", student.getGroup());
    Assert.assertNotNull("Group.id is null.", student.getGroup().getId());
}

@Test
public void testUpdateAll() throws PersistException {
    Student student = (Student) factory.getDao(connection, Student.class).create();
    Group group = (Group) factory.getDao(connection, Group.class).create();
    group.setNumber(1234);
    student.setGroup(group);
    factory.getDao(connection, Student.class).update(student);
    Assert.assertNotNull("Group is null.", student.getGroup());
    Assert.assertEquals("Wrong group number.", 1234, student.getGroup().getNumber());
}

Read

Пора позаботиться о том, чтобы объекты были доступны после записи их состояния в базу данных:

@Test
public void testRead() throws PersistException {
    Student student = (Student) factory.getDao(connection, Student.class).create();
    student.setGroup(new Group());
    factory.getDao(connection, Student.class).update(student);

    student = (Student) factory.getDao(connection, Student.class).getByPK(student.getId());
    Assert.assertNotNull("Student is null.", student);
    Assert.assertNotNull("Group is null.", student.getGroup());
}

Delete

И наконец удаление. И вот здесь кроется один очень важный нюанс. Если удаление студента логически не ведет за собой удаление группы,

@Test
public void testDelete() throws PersistException {
    Student student = (Student) factory.getDao(connection, Student.class).create();
    student.setGroup(new Group());
    factory.getDao(connection, Student.class).update(student);

    Group group = student.getGroup();

    factory.getDao(connection, Student.class).delete(student);
    group = (Group) factory.getDao(connection, Group.class).getByPK(group.getId());
    Assert.assertNotNull("Group not found.", group);
}
то обратное, вообще говоря, не верно. В некоторых ситуациях удаление записи об объекте должно повлечь за собой и удаление записей всех ссылающихся на него объектов. И если на уровне базы данных нам не составит труда выполнить это, то удаление непосредственно ссылок на объекты (в нашем примере - студентов) оказывается не возможным (или очень трудно выполнимым). Поэтому ситуацию, когда удаление группы должно повлечь за собой удаление студентов, мы рассматривать не будем.
RelationTest.java

Приступим

MySqlStudentDao

Прежде всего нам необходимо исправить методы parseResultSet, prepareStatementForInsert и prepareStatementForUpdate в классе MySqlStudentDao. В методах prepareStatementForInsert и prepareStatementForUpdate изменится процесс получения идентификатора группы:

@Override
protected void prepareStatementForUpdate(PreparedStatement statement, Student object) throws PersistException {
    try {
        int groupId = (object.getGroup() == null || object.getGroup().getId() == null) ? -1
                : object.getGroup().getId();
        ....

        statement.setInt(4, groupId);
    } catch (Exception e) {
        throw new PersistException(e);
    }
}

@Override
protected void prepareStatementForInsert(PreparedStatement statement, Student object) throws PersistException {
    try {
        int groupId = (object.getGroup() == null || object.getGroup().getId() == null) ? -1
                : object.getGroup().getId();

        .....
         
        statement.setInt(4, groupId);
    } catch (Exception e) {
        throw new PersistException(e);
    }
}
но с методом parseResultSet все оказывается не так просто. Для его реализации мы должны получиться ссылку на объект группы, соответствующий идентификатору, полученному в результате выполнения запроса. Первое, что приходит в голову - сохранить ссылку на фабрику и обращаться за объектом к ней. Но это не лучшее решение, тк работа с фабрикой на прямую слишком сильно развязывает нам руки и может привести к выполнению не желательных действий.

И все таки без ссылки на фабрику нам не обойтись. Мы сохраним ее, но сделаем это на уровне AbstractJDBCDao в приватном поле, а для получения необходимого объекта объявим protected метод, возвращающий объект указываемого класса, по значению указанного ключа:
protected Identified getDependence(Class<? extends Identified> dtoClass, Serializable pk);
в таком случае реализовать метод parseResultSet не составит труда:
@Override
protected List<Student> parseResultSet(ResultSet rs) throws PersistException {
    LinkedList<Student> result = new LinkedList<Student>();
    try {
        while (rs.next()) {
            ...
            student.setGroup((Group)getDependence(Group.class, rs.getInt("group_id")));
            ...
        }
    } catch (Exception e) {
        throw new PersistException(e);
    }
    return result;
}

AbstractJDBCDao

В нашем базовом дао-классе стало на одну зависимость больше. Теперь он требует при создании указывать создающую его фабрику (конструкторов наследующих классов это изменение тоже касается):
public abstract class AbstractJDBCDao<T extends Identified<PK>, PK extends Integer> implements GenericDao<T, PK> {

    private DaoFactory<Connection> parentFactory;
    ...
    public AbstractJDBCDao(DaoFactory<Connection> parentFactory, Connection connection) {
        this.parentFactory = parentFactory;
        this.connection = connection;
    }
 
    protected Identified getDependence(Class<? extends Identified> dtoClass, Serializable pk) throws PersistException {
        return parentFactory.getDao(connection, dtoClass).getByPK(pk);
    }
    ...
}

А теперь самое интересное. Чтобы выполнить метод persist или update мы должны сохранить состояние всех объектов на которые ссылаемся. При чем сделать это надо до сохранения самого объекта, иначе может сложиться ситуация, когда зависимый объект окажется только созданным и не сохраненным в базу, тогда его идентификатор не будет определен, что не позволит правильно сделать запись об объекте, который на него ссылается:

@Override
public T persist(T object) throws PersistException {
    if (object.getId() != null) {
        throw new PersistException("Object is already persist.");
    }
    // Сохраняем зависимости
    saveDependences(object);
    ...
}

@Override
public void update(T object) throws PersistException {
    // Сохраняем зависимости
    saveDependences(object);
    ...
}

Напомню, что методы persist и update реализованы в базовом абстрактном классе AbstractJDBCDao, который ничего не знает о полях объекта с зависимостями. И тем не менее, для реализации метода saveDependences нам необходимо перебрать все такие поля. Каким же образом нам лучше всего получить список таких полей? И как отличить их от прочих? Для этого мы создадим новый класс - ManyToOne, который будет отвечать за работу с полем, ссылающимся на хранимый объект. Интерфейс класса определит себя сам, в процессе реализации метода saveDependences:
private void saveDependences() throws PersistException {
    for (ManyToOne m : relations) {
        if (m.getDependence() == null) {
            continue;
        }
        if (m.getDependence().getId() == null) {
            Identified depend = m.persistDependence(connection);
            m.setDependence(depend);
        } else {
            m.updateDependence(connection);
        }
   }
}

ManyToOne

Первая версия интерфейса класса ManyToOne:
/**
 * Отвечает за реализацию связи многие-к-одному.
 *
 * @param <Owner>      класс объекта, чье поле ссылается на зависимый объект.
 * @param <Dependence> класс зависимого объекта.
 */
public class ManyToOne<Owner extends Identified, Dependence extends Identified> {

    public Dependence getDependence() {
        return null;
    }

    public void setDependence(Dependence dependence) {

    }

    public Dependence persistDependence(Connection connection) {
        return null;
    }

    public void updateDependence(Connection connection) {

    }
}

Для реализации такого интерфейса нам явно понадобятся дополнительные зависимости. В частности реализация методов getDependence и setDependence очевидно требуют ссылки на объект класса Owner и дополнительной информации непосредственно о том поле, которое ссылается на зависимый объект. Чтобы реализовать методы persistDependence и updateDependence необходима ссылка на фабрику дао-объектов и класс зависимого объекта:
/**
 * Отвечает за реализацию связи многие-к-одному.
 *
 * @param <Owner>      класс объекта, чье поле ссылается на зависимый объект.
 * @param <Dependence> класс зависимого объекта.
 */
public class ManyToOne<Owner extends Identified, Dependence extends Identified> {

    private DaoFactory<Connection> factory;

    private Field field;

    private String name;

    private int hash;

    public Dependence getDependence(Owner owner) throws IllegalAccessException {
        return (Dependence) field.get(owner);
    }

    public void setDependence(Owner owner, Dependence dependence) throws IllegalAccessException {
        field.set(owner, dependence);
    }

    public Identified persistDependence(Owner owner, Connection connection) throws IllegalAccessException, PersistException {
        return factory.getDao(connection, field.getType()).persist(getDependence(owner));
    }

    public void updateDependence(Owner owner, Connection connection) throws IllegalAccessException, PersistException {
        factory.getDao(connection, field.getType()).update(getDependence(owner));
    }

    @Override
    public String toString() {
        return name;
    }

    @Override
    public int hashCode() {
        return hash;
    }

    public ManyToOne(Class<Owner> ownerClass, DaoFactory<Connection> factory, String field) throws NoSuchFieldException {
        this.factory = factory;
        this.field = ownerClass.getDeclaredField(field);
        this.field.setAccessible(true);
        name = ownerClass.getSimpleName() + " to " + this.field.getType().getSimpleName();
        hash = ownerClass.hashCode() & field.hashCode();
    }
}
Мы близки к успеху! Осталось адаптировать метод saveDependences класса AbstractJDBCDao к новому интерфейсу ManyToOne и реализовать добавление новой связи в набор relations. В классе ManyToOne я заранее переопределил методы hashCode и toString чтобы хранить связи в HashSet и подробнее комментировать возможные ошибки:

...
private Set<ManyToOne> relations = new HashSet<ManyToOne>();
...
protected boolean addRelation(Class<? extends Identified> ownerClass, String field) {
    try {
        return relations.add(new ManyToOne(ownerClass, parentFactory, field));
    } catch (NoSuchFieldException e) {
        throw new RuntimeException(e);
    }
}

private void saveDependences(Identified owner) throws PersistException {
    for (ManyToOne m : relations) {
        try {
            if (m.getDependence(owner) == null) {
                continue;
            }
            if (m.getDependence(owner).getId() == null) {
                Identified depend = m.persistDependence(owner, connection);
                m.setDependence(owner, depend);
            } else {
                m.updateDependence(owner, connection);
            }
        } catch (Exception e) {
            throw new PersistException("Exception on save dependence in relation " + m + ".", e);
        }
    }
}

Регистрация связей должна происходить в конструкторе дао-объектов:

...
public MySqlStudentDao(DaoFactory<Connection> parentFactory, Connection connection) {
    super(parentFactory, connection);
    addRelation(Student.class, "group");
}
...

Вот и все. Исправив мелкие ошибки компиляции, связанные с изменение интерфейсов используемых ранее классов, мы можем запускать наши тесты и радоваться их успешному выполнению!
Исходный код можно получить здесь под тегом 2.0.

Подводя итоги

Использование JDBC для реализации DAO дает наиболее полное понимание того, как именно происходит взаимодействие с базой данных. Позволяет выполнять это взаимодействие максимально гибко и оптимально. 

Но давайте говорить честно, простое решение, представленное в предыдущей статье, мало пригодно для жизни, т.к. редко встречается ситуация, когда сущности не имеют связей, а при наличии этих связей идея хранения в полях объектов внешних ключей, приводит к необходимости нетривиального жонглирования сущностями и dao объектами. 

Идея возвращать в полях ссылки на зависимости выглядит более привлекательной для использования, но требует достаточно трудного решения. Мы рассмотрели только одну связь и при том не самую сложную, да еще и с упрощением (допустили существование каждой сущности раздельно), но код все равно получился большим и сложным.

Вынесение кода в AbstractJDBCDao позволило серьезно упростить реализацию дао-классов для новых сущностей, но эта задача так и осталась крайне ресурсоемкой.

Все это приводит к необходимости поиска альтернативных решений, которые могли бы избавить нас от большинства рутиной работы. Следующим шагом на этом пути должна стать попытка использования ORM решений, таких как Hibernate или MyBatis.





15 комментариев:

Анонимный комментирует...

В методе parseResultSet(ResultSet rs) любого dao класса с зависимостями, на разбор одного ResultSet, например на 1000 строк, будет сделано как минимум 1000 запросов к БД.

Vladimir Popov комментирует...

Боюсь вы правы. Но этих издержек можно избежать, кешируя объекты в фабрике и в методе getDependence пытаться получить объекты вначале из кеша. Правда это приводит к нетривиальной задаче поддержания кеша в актуальном состоянии.

Анонимный комментирует...

Также в этом же методе наблюдается косвенная рекурсия через метод T getByPK(Integer key). Допустим, в БД имеется "центральная" таблица, которая напрямую или косвенно связана со всеми таблицами БД, то вызов метода parseResultSet создаст все объекты сущностей БД, объем же БД чаще всего не ограничен.

Unknown комментирует...

Добрый день, подскажите пожалуйста почемы вылетают ошибки в вашем коде при добавлении студента? main -> http://pastebin.com/index/VxWc2Jda

лог:
ru.dokwork.daotalk.dao.PersistException: java.lang.ClassCastException: ru.dokwork.daotalk.domain.Student cannot be cast to ru.dokwork.daotalk.domain.Group
at ru.dokwork.daotalk.dao.AbstractJDBCDao.persist(AbstractJDBCDao.java:122)
at ru.dokwork.daotalk.mysql.MySqlDaoFactory.main(MySqlDaoFactory.java:85)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:606)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)
Caused by: java.lang.ClassCastException: ru.dokwork.daotalk.domain.Student cannot be cast to ru.dokwork.daotalk.domain.Group
at ru.dokwork.daotalk.mysql.MySqlGroupDao.prepareStatementForInsert(MySqlGroupDao.java:14)
at ru.dokwork.daotalk.dao.AbstractJDBCDao.persist(AbstractJDBCDao.java:116)
... 6 more

Vladimir Popov комментирует...

Вы создаете dao для группы, а затем пытаетесь с его помощью сохранить студента. Студенты должны сохраняться с помощью соответствующего дао.

Unknown комментирует...
Этот комментарий был удален автором.
Unknown комментирует...
Этот комментарий был удален автором.
Unknown комментирует...

Спасибо, помогло. Еще один вопросик: правильно ли я понимаю, что при добавлении 2х студентов в одну группу, у вас в бд создается 2 строки одной и той ж группы(number, department), но с разными id?

Unknown комментирует...

и подскажите пожалуйста как примерно делать, если у нас зависимость many to many ?

Unknown комментирует...

или может статьей поделитесь с объяснениями, если встречали.

Vladimir Popov комментирует...

по поводу двух студентов: сейчас уже не помню деталей, но если это действительно так, как Вы говорите, то это ошибка. По поводу many to many - это далеко не простая задача. Лучше много раз подумать, прежде чем пытаться ее унифицированно решать и взять готовое ORM решение :) Как справедливо заметили в первом комментарии к статье, предложенное решение имеет существенные недостатки, и имеет скорее фановый характер.

Анонимный комментирует...

Здравствуйте
Всегда интересовал вопрос представления данных, после того как мы получим их через DAO. Например, мы получили коллекцию объектов Student, из этой коллекции мне нужен массив вида id -> name, чтобы создать на форме выпадающий список(select). Можно делать это по разному, получать сразу из DAO готовый массив, либо каким-то образом преобразовывать уже снаружи нашу коллекцию. Что-то посоветуете?

Vladimir Popov комментирует...

Если вы коллекцию уже получили, то лучше ее трансформировать. Если вам нужны только два поля, ради них вытаскивать сущности целиком смысла нет. На сколько я понимаю, ситуации, когда нужны только часть полей объектов достаточно часто встречаются и каждый ORM с ними борется по своему, но суть всех решений сводится к одному: вытаскивать из базы минимум информации

Анонимный комментирует...

Мне интересен вопрос именно трансформации. Я сейчас создаю специальный объект-адаптер, который возвращает нужный мне массив на основе коллекции. Это мой подход не знаю на сколько это типично. Как делаете вы, интересно ваше мнение?

Vladimir Popov комментирует...

Скажем так, если бы мне надо было сформировать _только_ список имен студентов и их идентификаторов, целиком описание студентов из базы я бы не доставал и выполнял запрос только нужных мне полей. Возвращать массив идея спорная - просто, быстро, эффективно, но трудно поддерживать. Я бы завел отдельный объект с двумя полями.

Что до адаптера - с java 8 предпочтительнее делать фильтр коллекции с помощью stream api. Нагляднее на мой взгляд.