пятница, 19 июня 2009 г.

Как подружить Hibernate со Spring и обеспечить управление транзакциями через @ннотации


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

SpringFramework штука довольно сложная и интересная. В частности, в его состав входит package org.springframework.orm.hibernate3, который обеспечивает взаимодействие SpringFramework и Hibernate ORM.

Давайте создадим простое консольное приложение (чтобы не заморачиваться на определение сервлетов и прочего overhead'а), которое что-то пишет в БД.


Соответственно, прежде всего определим сущность, с которой будем работать. Назовем ее непритязательно: MyEntity.

Код сущности будет таким:

package ru.naumen.demo.entity;



import java.io.Serializable;



import javax.persistence.Column;

import javax.persistence.Entity;

import javax.persistence.GeneratedValue;

import javax.persistence.Id;



import org.hibernate.annotations.GenericGenerator;



@Entity

public class MyEntity implements Serializable

{

    private static final long serialVersionUID = 382157955767771714L;



    @Id

    @Column(name = "uuid")

    @GeneratedValue(generator = "system-uuid")

    @GenericGenerator(name = "system-uuid", strategy = "uuid")

    private String id;



    @Column(name = "name")

    private String name;



    public MyEntity()

    {

    }



    public MyEntity(String id, String name)

    {

        this.id = id;

        this.name = name;

    }



    public String getId()

    {

        return id;

    }



    public void setId(String id)

    {

        this.id = id;

    }



    public String getName()

    {

        return name;

    }



    public void setName(String name)

    {

        this.name = name;

    }

}

 


Напомню, что аннотации @Entity, @Id и т.д. относятся к JPA и заменяют собой Hibernate-mapping.

Работать с сущностью мы будем не напрямую, а через DAO. Использование DAO является одним из устоявшихся паттернов работы со SpringFramework. Определив бин, реализующий DAO можно легко и просто инъектировать его в бины, реализующие бизнес-логику приложения и тем самым полностью отделить бизнес-логику от работы с данными. DAO у нас будет реализовано следующим интерфейсом:

package ru.naumen.demo.dao;



import ru.naumen.demo.entity.MyEntity;



public interface IEntityDao

{

    public void save(MyEntity entity);

}

 


Для примера мы определим один метод - save, который будет сохранять сущность в БД. Реализация DAO довольно примитивна:

package ru.naumen.demo.dao;



import org.springframework.orm.hibernate3.support.HibernateDaoSupport;



import ru.naumen.demo.entity.MyEntity;



public class EntityDao extends HibernateDaoSupport implements IEntityDao

{

    @Override

    public void save(MyEntity entity)

    {

        getHibernateTemplate().save(entity);

    }

}

 


Мы наследуемся от класса HibernateDaoSupport, который инкапсулирует работу с Hibernate Session, Hibernate Session Factory и предоставляет нам простое API для взаимодействия с Hibernate. Рекомендую статью, в которой описано, как грамотно организовать слой DAO в своем приложении.

Теперь перейдем к классам, которые будут реализовывать бизнес-логику. В нашем случае бизнес-логика будет проста - мы будем просто брать и сохранять сущность.

Интерфейс IMyEntityService:

package ru.naumen.demo.services;



import ru.naumen.demo.entity.MyEntity;



public interface IMyEntityService

{

    public void saveEntity(MyEntity entity);

}

 


Реализация - класс MyEntityService:

package ru.naumen.demo.services;



import org.springframework.transaction.annotation.Propagation;

import org.springframework.transaction.annotation.Transactional;



import ru.naumen.demo.dao.IEntityDao;

import ru.naumen.demo.entity.MyEntity;



@Transactional(readOnly = true)

public class MyEntityService implements IMyEntityService

{

    private IEntityDao dao;



    public void setDao(IEntityDao dao)

    {

        this.dao = dao;

    }



    @Override

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)

    public void saveEntity(MyEntity entity)

    {

        dao.save(entity);

    }

}

 


Данный класс - самое интересное, что есть в нашей программе. Нам необходимо обернуть метод saveEntity в транзакцию. Для этого существует аннотация @Transactional, которой можно аннотировать методы или целый класс. Параметрами данной аннотации задается поведение транзакции. Основными параметрами являются readOnly, который указывает на возможность или невозможность менять состояние БД и propagation, который задает стратегию создания транзакции (не создавать транзакцию, создать новую, присоединиться к существующей и т.д.). Помимо этих параметров можно указывать таймаут, уровень изоляции, классы и типы исключений для которых надо и ненадо делать rollback.

Подробнее про параметры и их значения можно прочесть в официальном руководстве по SpringFramework.

Собственно, теперь надо рассмотреть конфигурацию Spring-контекста, которая будет храниться в файле applicationContext.xml. Файл будем рассматривать по частям, небольшими порциями. Прежде всего создадим "рыбу" файла:

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"

       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

       xmlns:aop="http://www.springframework.org/schema/aop"

       xmlns:tx="http://www.springframework.org/schema/tx"

       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.0.xsd

                           http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.0.xsd">




   

</beans>


Обратите внимание! Очень важно правильно прописать все namespaces и пути к схемам, иначе конфиг просто не будет парситься.

Итак, сначала добавим в контекст необходимые конфигурационные файлы, в нашем случае - jdbc.properties, в котором мы будем хранить параметры подключения к СУБД. Для работы с конфигурационными файлами SpringFramework содержит класс org.springframework.beans.factory.config.PropertyPlaceholderConfigurer. Разметка будет вот такой:

<bean id="propertyConfigurer"  class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">

    <property name="location" value="jdbc.properties" />

</bean>


Далее следует определить источник данных - мост между СУБД и Hibernate. Я предпочитаю использовать для этого замечательную библиотеку apache.commons.dbcp.

<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">

    <property name="driverClassName" value="${jdbc.driverClassName}" />

    <property name="url" value="${jdbc.url}" />

    <property name="username" value="${jdbc.username}" />

    <property name="password" value="${jdbc.password}" />

</bean>


После того, как определили источник данных, пришла пора описать фабрику, которая будет строить Hibernate-сессии. Для этого существует класс org.springframework.orm.hibernate3.LocalSessionFactoryBean. Мы опишем этот бин следующим образом:

<bean id="sessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean">

    <property name="dataSource" ref="dataSource" />

    <property name="configLocation" value="classpath:/hibernate.cfg.xml" />

    <property name="configurationClass" value="org.hibernate.cfg.AnnotationConfiguration" />

    <property name="hibernateProperties">

        <props>

            <prop key="hibernate.dialect">${hibernate.dialect}</prop>

        </props>

    </property>

</bean>


Все специфичные настройки Hibernate будем хранить в файле hibernate.cfg.xml, диалект - в файле jdbc.properties. Обратите внимание, что т.к. мы определяем мэппинг аннотациями, то работать с такой конфигурацией должен класс org.hibernate.cfg.AnnotationConfiguration.

С базой данных мы соединились и Hibernate-сессию создали. Пришла пора указать приложению на то, что нужно динамически управлять транзакциями. Что значит "динамически управлять транзакциями?" Это значит, что нам не нужно писать код, который создает/закрывает/откатывает транзакции и размещать его везде, где нужно. Нам достаточно лишь передать классу HibernateTransactionManager некие правила создания/завершения транзакций, а остальное он возьмет на себя.
Понятно, что все это счастье работает через AOP. Правило представляет собой соответствие между методом и типом создаваемой транзакции. Это обозначает, что когда мы входим в метод (перед самым началом выполнения кода метода) - необходимо создать транзакцию, а перед выходом из метода (после выполнения последней инструкции метода) транзакцию закоммитить. Ну и дополнительно можно описать при каких типах исключений должен быть выполнен откат транзакции.

Существует два основных способа определения правил: использование нотации Spring AOP в xml-конфигах Spring и использование аннотаций в Java-коде. Каждый метод имеет свои достоинства и недостатки, но это уже тема другой статьи. Мы же рассмотрим как управлять транзакциями с помощью аннотаций.

Для управления транзакциями в Spring существует пространство имен tx, в котором определена, в частности, директива tx:annotation-driven, включающая механизм управления транзакциями через аннотации. Про параметры этой директивы можно прочитать в секции 9.5.6. документа.

Мы определим менеджер транзакций следующим образом:

<tx:annotation-driven transaction-manager="txManager" />



<bean id="txManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager">

    <property name="sessionFactory" ref="sessionFactory" />

</bean>


Ну и остается определить бины для слоя DAO и слоя бизнес-логики:

<bean id="entityDAO" class="ru.naumen.demo.dao.EntityDao">

    <property name="sessionFactory" ref="sessionFactory" />

</bean>



<bean id="entityService" class="ru.naumen.demo.services.MyEntityService">

    <property name="dao" ref="entityDAO" />

</bean>


Напоследок приведу код класса Main, который запускает приложение:

package ru.naumen.demo;



import org.springframework.context.support.ClassPathXmlApplicationContext;



import ru.naumen.demo.entity.MyEntity;

import ru.naumen.demo.services.IMyEntityService;



public class Main

{

    public static void main(String[] args)

    {

        ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");

        IMyEntityService service = (IMyEntityService) ctx.getBean("entityService");



        MyEntity entity = new MyEntity();

        entity.setName("Pavel");



        service.saveEntity(entity);

    }

}

 


Код не сложный. Сначала мы загружаем контекст приложения, затем из контекста достаем нужный нам бин (в данном случае - "entityService". Ну а дальше используем бин по назначению - сохраняем с его помощью сущность в БД.

Вообще, я считаю, что конфигурация аннотациями проще, чем xml, да и читается лучше. В принципе, можно было бы и взаимодействие бинов сконфигурировать с помощью аннотаций, Spring это позволяет уже довольно давно. На тему конфигурирования Spring-бинов через аннотации можно почитать статьи на habrahabr: эту и эту.

Теперь вы знаете, как подключить к SpringFramework СУБД и Hibernate, опеспечить динамическое управление транзакциями, описать слой DAO и подключить DAO к бизнес-логике. Фактически, мы создали "рыбу" приложения и теперь можем неограниченно наращивать его функционал.

Если вам интересна тема интеграции Spring Framework и Hibernate, то возможно вам будет интересно почему-бы и не заплатить за ваш Spring Framework, а так же серия заметок об эффективном использовании данной ORM - Hibernate: это должен помнить каждый: типы каскадных операций, стандартные алгоритмы генерации идентификаторов и управление сессиями и привязка транзакции к сессии.

UPD 23.02.2011: Исходные коды примера (Maven-проект, GitHub).

Понравилось сообщение - подпишитесь на блог или читайте меня в twitter

43 комментария:

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

А почему для дао классов не используются дженерики?

Например,
интерфейс дао класса:
public interface MyEntityDao extends BaseDao < MyEntity > {}
класс дао:
public class MyEntityHibernateDao extends AbstractHibernateDao< MyEntity > implements MyEntityDao {}

Кстати в приведенном мной случае реализовывать метод save не требуется, все уже написано:)

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

Ну я про эту фишку знаю, тем более привел ссылку на соответствующую статью. Просто хотелось написать максимально простую демку. Основной упор сделать на управление транзакциями.

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

По моему скромному мнению учить надо использовать готовые средства. А дао на дженериках это уже стандартный подход в спринге. Я буквально неделю назад объяснял студенту про дао классы на дженериках. Он написал методы save, delete, update сам:)

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

Да, вы правы - учить надо прежде всего типовым решениям. Чтож, может быть есть смысл поправить статью :)

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

Просто и понятно. Пишите еще про Spring :)

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

Спасибо! Обязательно буду писать еще

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

Статью в закладки!
Сам на данный момент разбираюсь со Spring Security, не рассматриватете возможность написания статьи по этой тематике? :)

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

Рад, что вам понравилось. Конкретно про Spring Security знаю лишь то, что в девичестве оно называлось Acegi. Про него пишут вот здесь: http://nkoksharov.blogspot.com/search/label/spring%20security

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

Спасибо, пишите еще.
Статью в закладки!

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

Очень помогло, благодарю :)

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

Вот есть у меня похожий код... но транзакции не работают. БД Mysql, таблица типа InnoDB, исключение создается... но вот транзакции нет. В методе пишутся в БД две записи, одна заведомо правильная, вторая нет. Так вот после исключения, первая запись в БД присутствует... а ведь не должна.. в чем может быть проблема?

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

Я не помню поддерживает ли InnoDB транзакции. Hibernate он не волшебник, если СУБД не поддерживает транзакции, то он их создавать не сможет.

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

в том то и дело что поддерживает...

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

Тогда я затрудняюсь ответить. С MySQL из Hibernate работал очень мало. С PostgreSQL и Oracle таких проблем не было.

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

Тогда такой вопрос, если в jdbc конфигурации установить
hibernate.show_sql=true, тогда в этом sql-дампе, будут видны команды типа START TRANSACTION? COMMIT? или нет? так как я смотрю вот на дамп свой, а там только INSERTы, SELECTы и UPDATEы :) и никакого намека на START TRANSACTION... Хотя в консоли руками проверил, через mysql-клиент, то транзакции работают на ура.

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

Значит надо проверять реализуемую вами логику, а именно все аннотации. Ошибка локализована - транзакция не создается.

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

Здравствуйте. Прошу прощения за пространный комментарий.
Пытаюсь запустить Ваш пример. Вот что выходит из командной строки:


moradan@moradan-desktop:~/Downloads/SpringAnnDemo/bin$ ls ./ru/naumen/demo/
dao entity Main.class services
moradan@moradan-desktop:~/Downloads/SpringAnnDemo/bin$ ls ./../lib
c3p0-0.9.1.jar jta-1.1.jar
c3p0-oracle-thin-extras-0.9.1.jar log4j-1.2.11.jar
commons-dbcp-1.2.2.jar oscache-2.1.jar
commons-pool-1.4.jar postgresql-8.1-408.jdbc3.jar
hibernate3.jar spring.jar
hibernate-cglib-repack-2.1_3.jar sqlite-jdbc-3.6.17.3.jar
hibernate_patch.jar
moradan@moradan-desktop:~/Downloads/SpringAnnDemo/bin$ java -cp "./:./../lib" ru.naumen.demo.MainException in thread "main" java.lang.NoClassDefFoundError: org/springframework/context/support/ClassPathXmlApplicationContext
at ru.naumen.demo.Main.main(Main.java:19)
Caused by: java.lang.ClassNotFoundException: org.springframework.context.support.ClassPathXmlApplicationContext
at java.net.URLClassLoader$1.run(URLClassLoader.java:200)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:188)
at java.lang.ClassLoader.loadClass(ClassLoader.java:307)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:301)
at java.lang.ClassLoader.loadClass(ClassLoader.java:252)
at java.lang.ClassLoader.loadClassInternal(ClassLoader.java:320)
... 1 more

Добавил я только библиотеку sqlite-jdbc-3.6.17.3.jar и изменил jdbc.properties на

jdbc.driverClassName=org.sqlite.JDBC
jdbc.url=jdbc:sqlite:/moradan/home/SpringAnnDb.db
jdbc.username=
jdbc.password=
hibernate.dialect=

Базу такую создал и драйвер пускает на неё вроде без пароля. (Это версия драйвера от Xerial, если вдруг).

Попробовал импортировать этот код в Netbeans, как Java Project with Existing Sources, но он ругается, что не знает ничего о import javax.persistence.Column; и ругается на все аннотации. Если что также вот:
$ java -version
java version "1.6.0_15"
Java(TM) SE Runtime Environment (build 1.6.0_15-b03)
Java HotSpot(TM) Server VM (build 14.1-b02, mixed mode)

Не подскажите что я делаю не так?

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

Судя по логу, у вас в classpath отсутствует спринг. В эклипсе мало закинуть jar в каталог lib, нужно еще добавить его в classpath в настройках проекта.

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

Эклипсом я вообще не пытался это запускать. Чтобы он нашёл спринг действительно надо было добавить только "/*" в конце указания classpath. Теперь ошибка другая:
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/logging/LogFactory

Спасибо за ответ. А вы не подскажите минималистичный пример использования JPA, который было бы просто запустить на локальной машине? (В принципе MySql у меня стоит и вертеть я его немного умею, но хотелось бы как можно проще)

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

Вам надо добавить в classpath commons-logging.jar

По-моему, проще данного примера ковырять некуда, если вам не нравится mysql, то достаточно подсунуть jdbc-коннектор для своей СУБД и поменять конфиг.

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

возможно задам глупый вопрос, но не совсем понимаю смысл создания лишней ступеньки. Зачем делать Dao а потом сверху него ещё сервис для работы с сущностью, который по сути делает одно и то же? Нельзя спустить функционал сервиса (обработку транзакций) скинуть на уровень Dao?
Не пинайте сильно, возможно я не прав, просто разбираюсь с этим в данный момент и наличие лишней ступеньки мне как то не совсем понятна.

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

Хорошая статья.
Зря не включили в статью код класса MyEntityService.
Пока не скачаешь исходники, не ясно где привязана транзакция.

Alexander Tarankov комментирует...

Добрый день! Спасибо за статью, очень полезно для новичка. У меня тот же вопрос, что уже выше задавали - не могу для себя уяснить, что дает дополнительный уровень Service в данном примере? Задел на будущее расширение? Какой-то стандартный паттерн? Или что? Поясните пожалуйста?

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

Лишний уровень нужен для бизнес-логики. Т.е. ДАО - это просто операции с БД (CRUD, селекты и т.д.). Но в общем случае - бизнес-логика это последовательность таких операций. В данном примере все просто, но только в данном примере.

Можете рассматривать это как задел на будущее.

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

Очень интересно. Спасибо.
Небольшое улучшение: Т.к сущности отконфигурены с помощью аннотаций, для sessionFactory можно использовать класс org.springframework.orm.hibernate3.annotation.AnnotationSessionFactoryBean (незнаю есть ли он в Spring версии < 2.5)

И ещё вопрос: На сколько можно считать плохой практикой не использовать DAO уровень а наследовать MyEntityService от HibernateDaoSupport? Ведь hibernateTemplate обеспечивает всю функциональность DAO.

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

Поставил 6 жабу.( IDE - 9 Idea)
и уже 2 дня пытаюсь запустить проект.
проблема с библиотеками...

уже целый зверинец скачал по ClassNotFound exception.

Не могли бы Вы либо
1) Доложить все *.jar
2) дать линки на библиотеки. Или хоть перечислить - что и каких версий качать, чтоб все фабрики отработали... Спасибо.

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

запустил проект :
Ide Idea 9.0.1
mySql 5.1
Java 6

список библиотек(spring 2.5.6+ hibernate 3.2):

antlr-2.7.6.jar
asm.jar
cglib-2.1.jar
commons-collections-3.1.jar
commons-dbcp-1.2.2.jar
commons-logging.jar
commons-pool-1.4.jar
dom4j-1.6.1.jar
ehcache-1.2.3.jar
ejb3-persistence.jar
hibernate-annotations.jar
hibernate-commons-annotations.jar
hibernate3.jar
javaee.jar
javassist-3.4.GA.jar
jta-1.1.jar
log4j-1.2.15.jar
mysql-connector-java-5.1.12-bin.jar
mysqlJDBC-3.1.13.jar
slf4j-api-1.5.3.jar
slf4j-log4j12-1.5.3.jar
spring.jar

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

jdbc.properties :

jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/test_db
jdbc.username=test_usr
jdbc.password=test_passwd
hibernate.dialect=org.hibernate.dialect.MySQLDialect

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

Скачай Spring и Hibernate с зависимостями. Это архивы которые содержат ёще и все необходимые классы.

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

Позвольте дать совет тем, у кого есть проблемы с зависимостями.. Рано или поздно (а лучше рано) использовать какой-нибудь инструмент для сборки. Ant или maven. Последний особенно хорошо. Попробуйте,- не пожалеете. :)

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

почему при повторном запуске кода из таблицы MyEntity стираются ранее записанные туда значения?

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

Если Вы для создания таблицы используете hbm2ddl, то возможно он просто каждый раз пересоздает таблицы в БД.

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

спасибо за оперативный ответ.
hibernate.hbm2ddl.auto=update помогло

кстати у Вас отличный блог.

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

Привет,
спасибо за хорошую статью. Не хватает jUnit тестирования DAO и несколько сбивает с толку фраза "Понятно, что все это счастье работает через AOP.". Раз так, то что конфигурируется используя AOP?

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

Управление транзакциями и конфигурируется - tx:annotation-driven transaction-manager="txManager"

Суть в том, что вместо реальных классов будут создаваться прокси, в которой все аннотированные @Transactional методы будут обернуты кодом, который создает транзакцию, выполняет собственно написанный нами метод и коммитит транзакцию.

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

Лучше поздно, чем никогда.

По советам уважаемых подписчиков создал демонстрационный проект с использованием Maven. Проект выложен на GitHub: https://github.com/samolisov/spring-hibernate-annotations-demo

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

Скажите пожалуйста зачем используется класс типа Service, в который оборачивается функционал DAO, навесить транзакции можно и на DAO, но тем не менее, оборачивание DAO в Service я видел часто. Просветите пожалуйста в чем смысл.

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

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

Артём Малахов комментирует...

Спасибо большое за статью ) понравилось =)

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

Закоммичено 3-го июля 2011-го года. https://github.com/samolisov/samolisov-demo/commits/master/Spring/spring-hibernate-annotations/src/main/resources/hibernate.cfg.xml

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

Здравствуйте, спасибо за статью.
Делаю пример чуть побольше) и вот вопрос от новичка: если есть несколько entity, например, class YouEntity, привязан к MyEntity, то надо сделать по IYouEntityDao и IMyEntityDao; соответственно YouEntityDao и MyEntityDao. Затем обернуть это также IMyEntityService и IYouEntityService и MyEntityService, YouEntityService? или все проще?

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

Здравствуйте.

По вопросу организации DAO и сервисов сломано не мало копий. Все зависит от того, в какой взаимосвязи находятся ваши сущности YouEntity и MyEntity. Если, например, есть понятия "Абонент" и "Тип абонента" и нужно выбирать абонентов по типу, добавлять и изменять, то, на мой взгляд, можно обойтись одним сервисом. В общем, я хотел сказать, что распределение логики по сервисам - это творческая задача, здесь трудно дать однозначный ответ.

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

БЛАГОДАРЮ!!)) Здесь, нашел ответ на свой вопрос.

Отправить комментарий

Любой Ваш комментарий важен для меня, однако, помните, что действует предмодерация. Давайте уважать друг друга!