До сих пор одной из самых лучших админ панелей для Symfony является SonataAdminBundle, и не зря. Простая установка, конфигурация, множество фич “из коробки” и большое сообщество.
Единственное, что в ней отсутствует, так это импорт файлов. Согласитесь, немаловажная функция.
В сети лежит множество реализаций импорта для Sonata, но везде есть небольшие недочеты — возможность импорта только текстовых полей, а не сущностей, не работает с коллекциями, проблематично грузить огромные базы, которые могут обрабатываться не один час…
Сегодня я хочу вам представить свою реализацию, которой успешно пользуюсь уже достаточно долго, но только сейчас руки дошли все это причесать и оформить в отдельный bundle.
Я не буду здесь описывать весь процесс установки и настройки. Темболее что в отличии от многих реализаций он крайне прост. Все это вы можете прочитать в README.md и вики на github:
В данной статье я хочу описать лишь интересные моменты, с которыми я столкнулся во время его создания.
Впервые идея реализации этого бандла мне пришла в то время, когда требовалось перенести достаточно большую таблицу регионов, городов, поселков, площадей, и всего-всего-всего в базу заказчика (~3 млн строк). Осложняло все то, что доступов к его серверу у нас не было.
Я попробовал несколько готовых решений, но понял, что они рассчитаны на небольшие объемы, которые можно загрузить за время ожидания ответа от сервера.
Необходимо было это реализовать не через веб-сервер, а через php-cli. Благо в Symfony есть очень хорошие инструменты для работы с консольными командами.
Для вызова есть отличный класс Application:
Но данный способ также не подходит, потому что он работает через веб-сервер. Остается только одно: Symfony\Component\Process\Process, так как он напрямую работает с консолью. Создаем простую команду (спасибо oxidmod за более красивое и правильное решение):
Последняя строка для асинхронной работы. И запускаем все это в фоне.
Согласитесь, что ждать больше минуты, не понимая что именно происходит, тяжело. А если этот процесс растянется на час? Два?
Именно поэтому нам и нужен какой-то лог консольной команды. Обычно для логов я использую текстовые файлы, но в этот раз, из-за объема информации, я решил использовать базу данных.
За каждую строку отвечает сущность: Doctrs\SonataImportBundle\Entity\ImportLog.
Каждая запись соответствует строке из файла и в ней есть все, что нужно:
Именно по этим данным мы и будем в дальнейшем отслеживать процесс загрузки, и выводить окончательный подробный отчет.
Так как для разбора файла используется итератор, то процент выполнения вывести не получится. Выводим просто общее обработанное число записей.
К сожалению, отлавливать FatalError я так и не научился. Поэтому в случае, например,
команда упадет с FatalError.
Еще одно исключение, с которым я столкнулся — ORMException.
Что в нем такого интересного? Обычное исключение при попытке обработать запрос с неправильными данными.
Собственно именно для этого он и предназначается, правда после выбрасывания такого исключения EntityManager закрывает соединение, и на любые попытки запросов к БД отвечает:
EntityManager is closed
В моем бандле такое исключение бросается в 2х случаях. Первый — если неверно настроена валидация сущности (перед добавлением в базу сущности в обязательном порядке валидируются)
А второй связан с работой бандла с полями типа choice и entity. В случае, если у нас в сущности есть дочерняя сущность (например, у книги есть автор. Выбор автора происходит из базы), то при импорте книги мы можем указывать автора либо с помощью ID, либо с помощью названия. Если поле не числовое, то система пытается найти сущность по полю name. Если у сущности нет такого поля (например, имя автора хранится не в name, а в login или в username), то мы получаем ORMException.
В принципе, они было достаточно частыми, поэтому мне пришлось сделать небольшой хак по перезагрузке EntityManager, для того, что бы после выбрасывания исключения, система могла выставить файлу STATUS_ERROR, и успешно отобразить все это в интерфейсе:
По умолчанию Sonata экспортирует только простые поля (текст, дата, числа). Для того, чтобы она экспортировала вложенные сущности, их нужно явно задать в методе getExportFields. Помимо этого, у вложенных сущностей необходимо настроить метод __toString(); Представление сущности в виде строки как раз и будет экспортироваться.
ImportBundle также использует этот метод, чтобы только что импортированный файл можно было без изменений загрузить в базу. В случае, если вы заново создаете файл, то таблица с соответствием столбец-поле есть на странице импорта.
Мне никогда не нравилось то, что ради изменения пары строчек в бандле, необходимо делать (не то, чтобы сложную, но и не слишком удобную) надстройку с помощью easy-extends.
Поэтому все, что можно, я вынес в конфиги. Даже класс, с помощью которого происходит разбор файла. Так что, в случае чего, вы всегда сможете реализовать загрузку и XML и JSON и XLS.
Подробнее обо всех параметрах конфигурации можно прочитать в вики
В случае, если у вас есть нестандартные поля в базе данных (например, в моем случае, center_point — это координаты в базе данных), то необходимо объявить класс, который будет обрабатывать данные из файла, и приводить их к виду, в котором они будут заливаться в mysql.
Например: тип center_point это координата (MySql тип — point). При добавлении в базу и получении из базы, она является объектом класса Point. У объекта Point есть метод __toString.
С помощью него и происходит импорт, и в файле импорта мы получаем красивые координаты. Если мы попытаемся залить в базу те же x,y, то нас ждет ORMException. Именно для этого и предназначается массив mappings. В данном случае он просто берет сервис с id doctrs.form_format.point, который реализует интерфейс Doctrs\SonataImportBundle\Service\ImportAbstract, и на основе полученного значения возвращает нужный тип, который мы сможем залить в базу.
Вот код самого сервиса
Код сервиса doctrs.form_format.city_pa
Как видите, в параметре mappings мы указываем не названия классов, а id сервисов, что дает нам свободу действий. Например для преобразования типа city_autocomplete мне понадобился container.
Данным бандлом я пользовался в течении полугода (в то время он еще не был оформлен и я просто подтягивал его с bitbucket). Само собой были некоторые некритичные ошибки, но после регистрации на packagist.org я стараюсь все исправлять, чтобы не осталось вопросов и невнятных сообщений об ошибках.
Есть небольшие планы по улучшению этого бандла, но посмотрим, дойдут ли до них руки.
Любым комментариям и замечаниям буду рад.
Единственное, что в ней отсутствует, так это импорт файлов. Согласитесь, немаловажная функция.
В сети лежит множество реализаций импорта для Sonata, но везде есть небольшие недочеты — возможность импорта только текстовых полей, а не сущностей, не работает с коллекциями, проблематично грузить огромные базы, которые могут обрабатываться не один час…
Сегодня я хочу вам представить свою реализацию, которой успешно пользуюсь уже достаточно долго, но только сейчас руки дошли все это причесать и оформить в отдельный bundle.
Я не буду здесь описывать весь процесс установки и настройки. Темболее что в отличии от многих реализаций он крайне прост. Все это вы можете прочитать в README.md и вики на github:
В данной статье я хочу описать лишь интересные моменты, с которыми я столкнулся во время его создания.
Большие объемы данных
Впервые идея реализации этого бандла мне пришла в то время, когда требовалось перенести достаточно большую таблицу регионов, городов, поселков, площадей, и всего-всего-всего в базу заказчика (~3 млн строк). Осложняло все то, что доступов к его серверу у нас не было.
Я попробовал несколько готовых решений, но понял, что они рассчитаны на небольшие объемы, которые можно загрузить за время ожидания ответа от сервера.
Решение
Необходимо было это реализовать не через веб-сервер, а через php-cli. Благо в Symfony есть очень хорошие инструменты для работы с консольными командами.
Для вызова есть отличный класс Application:
$application = new Application();
// ... register commands
$application->run();
Но данный способ также не подходит, потому что он работает через веб-сервер. Остается только одно: Symfony\Component\Process\Process, так как он напрямую работает с консолью. Создаем простую команду (спасибо oxidmod за более красивое и правильное решение):
$command = sprintf(
'/usr/bin/php %s/console promoatlas:sonata:import %d "%s" "%s" > /dev/null 2>&1 &',
$this->get('kernel')->getRootDir(),
$fileEntity->getId(),
$this->admin->getCode(),
$fileEntity->getEncode() ? $fileEntity->getEncode() : 'utf8'
);
Последняя строка для асинхронной работы. И запускаем все это в фоне.
Отчетность
Согласитесь, что ждать больше минуты, не понимая что именно происходит, тяжело. А если этот процесс растянется на час? Два?
Именно поэтому нам и нужен какой-то лог консольной команды. Обычно для логов я использую текстовые файлы, но в этот раз, из-за объема информации, я решил использовать базу данных.
За каждую строку отвечает сущность: Doctrs\SonataImportBundle\Entity\ImportLog.
Каждая запись соответствует строке из файла и в ней есть все, что нужно:
- ts — таймштамп,
- status — что произошло со строкой,
- message — сообщения об ошибке,
- line — номер строки из файла,
- foreignId — ID сущности
Именно по этим данным мы и будем в дальнейшем отслеживать процесс загрузки, и выводить окончательный подробный отчет.
Так как для разбора файла используется итератор, то процент выполнения вывести не получится. Выводим просто общее обработанное число записей.
Ошибки
К сожалению, отлавливать FatalError я так и не научился. Поэтому в случае, например,
function setOwner(Owner $owner);
$owner = $em->findOwner(); // не найдено, вернет null
$entity->setOwner($owner);
команда упадет с FatalError.
Еще одно исключение, с которым я столкнулся — ORMException.
Что в нем такого интересного? Обычное исключение при попытке обработать запрос с неправильными данными.
Собственно именно для этого он и предназначается, правда после выбрасывания такого исключения EntityManager закрывает соединение, и на любые попытки запросов к БД отвечает:
EntityManager is closed
В моем бандле такое исключение бросается в 2х случаях. Первый — если неверно настроена валидация сущности (перед добавлением в базу сущности в обязательном порядке валидируются)
$validator = $this->getContainer()->get('validator');
$errors = $validator->validate($entity);
А второй связан с работой бандла с полями типа choice и entity. В случае, если у нас в сущности есть дочерняя сущность (например, у книги есть автор. Выбор автора происходит из базы), то при импорте книги мы можем указывать автора либо с помощью ID, либо с помощью названия. Если поле не числовое, то система пытается найти сущность по полю name. Если у сущности нет такого поля (например, имя автора хранится не в name, а в login или в username), то мы получаем ORMException.
В принципе, они было достаточно частыми, поэтому мне пришлось сделать небольшой хак по перезагрузке EntityManager, для того, что бы после выбрасывания исключения, система могла выставить файлу STATUS_ERROR, и успешно отобразить все это в интерфейсе:
if (!$this->em->isOpen()) {
$this->em = $this->em->create(
$this->em->getConnection(),
$this->em->getConfiguration()
);
}
Настройка Импорта/Экспорта
По умолчанию Sonata экспортирует только простые поля (текст, дата, числа). Для того, чтобы она экспортировала вложенные сущности, их нужно явно задать в методе getExportFields. Помимо этого, у вложенных сущностей необходимо настроить метод __toString(); Представление сущности в виде строки как раз и будет экспортироваться.
ImportBundle также использует этот метод, чтобы только что импортированный файл можно было без изменений загрузить в базу. В случае, если вы заново создаете файл, то таблица с соответствием столбец-поле есть на странице импорта.
Расширяемость
Мне никогда не нравилось то, что ради изменения пары строчек в бандле, необходимо делать (не то, чтобы сложную, но и не слишком удобную) надстройку с помощью easy-extends.
Поэтому все, что можно, я вынес в конфиги. Даже класс, с помощью которого происходит разбор файла. Так что, в случае чего, вы всегда сможете реализовать загрузку и XML и JSON и XLS.
doctrs_sonata_import:
mappings:
- { name: center_point, class: promaotlas.form_format.point}
- { name: city_autocomplete, class: promoatlas.form_format.city_pa}
upload_dir: %kernel.root_dir%/../web/uploads
class_loader: Doctrs\SonataImportBundle\Loaders\CsvFileLoader
encode:
default: utf8
list:
- cp1251
- utf8
- koir8
Подробнее обо всех параметрах конфигурации можно прочитать в вики
Нестандартные типы полей
В случае, если у вас есть нестандартные поля в базе данных (например, в моем случае, center_point — это координаты в базе данных), то необходимо объявить класс, который будет обрабатывать данные из файла, и приводить их к виду, в котором они будут заливаться в mysql.
Например: тип center_point это координата (MySql тип — point). При добавлении в базу и получении из базы, она является объектом класса Point. У объекта Point есть метод __toString.
public function __toString(){
retrun $this->x . ', ' . $this->y;
}
С помощью него и происходит импорт, и в файле импорта мы получаем красивые координаты. Если мы попытаемся залить в базу те же x,y, то нас ждет ORMException. Именно для этого и предназначается массив mappings. В данном случае он просто берет сервис с id doctrs.form_format.point, который реализует интерфейс Doctrs\SonataImportBundle\Service\ImportAbstract, и на основе полученного значения возвращает нужный тип, который мы сможем залить в базу.
Вот код самого сервиса
class Point implements ImportAbstract {
public function getFormatValue($value){
$value = explode(',', $value);
$point = new \PHPOpenGIS\MainBundle\Geometry\Point($value[0], $value[1] ?? 0);
return $point;
}
}
Код сервиса doctrs.form_format.city_pa
class CityPa implements ImportAbstract, ContainerAwareInterface {
private $container;
public function setContainer(ContainerInterface $container = null) {
$this->container = $container;
}
public function getFormatValue($value){
/** @var ContainerInterface $container */
$container = $this->container;
$city = $container->get('promoatlas.city_autocomplete')->byName($value);
return $city;
}
}
Как видите, в параметре mappings мы указываем не названия классов, а id сервисов, что дает нам свободу действий. Например для преобразования типа city_autocomplete мне понадобился container.
Заключение
Данным бандлом я пользовался в течении полугода (в то время он еще не был оформлен и я просто подтягивал его с bitbucket). Само собой были некоторые некритичные ошибки, но после регистрации на packagist.org я стараюсь все исправлять, чтобы не осталось вопросов и невнятных сообщений об ошибках.
Есть небольшие планы по улучшению этого бандла, но посмотрим, дойдут ли до них руки.
Любым комментариям и замечаниям буду рад.
Комментарии (11)
oxidmod
29.09.2017 15:52+2$command = '/usr/bin/php '; $command .= $this->get('kernel')->getRootDir() . '/console '; $command .= 'promoatlas:sonata:import '; $command .= $fileEntity->getId() . ' '; $command .= '"' . $this->admin->getCode() . '" '; $command .= '"' . ($fileEntity->getEncode() ? $fileEntity->getEncode() : 'utf8') . '" '; $command .= ' > /dev/null 2>&1 &';
$command = sprintf( '/usr/bin/php %s/console promoatlas:sonata:import %d "%s" "%s" > /dev/null 2>&1 &', $this->get('kernel')->getRootDir(), $fileEntity->getId(), $this->admin->getCode(), $fileEntity->getEncode() ? $fileEntity->getEncode() : 'utf8' );
pbatanov
29.09.2017 23:14Sonata получила поддержку symfony 3 (из-за FOS UB) только этим летом, что заставило нас отказаться от этого проекта в пользу easy admin в нескольких проектах. Ну и да, «особенностей» у нее полно.
Gemorroj
Отмечу все-таки EasyAdminBundle.
Сам мигрировал на нее. Т.к. забодало воевать с "особенностями" сонаты.
ghost404
Спасибо за ссылку.
Не знал про EasyAdminBundle.
Но похоже EasyAdminBundle ещё больше завязан на антипаттерне Anemic model, чем SonataAdminBundle.
И конфигурирование форм в yaml файлах попахивает чем-то.
И не понятно как использовать свои FormType
pbatanov
Свои форм-тайпы там использовать так же просто — вместо встроенных типов указываете класс. Конфигурирование — да, чем то попахивает, с другой стороны проект расчитан именно на простые круд админки. Это прописано у них в фичах — круд, поиск и дизигн. Но для простых админок он гораздо удобней, чем соната. на мой взгляд.
ghost404
Ну если прям для савсем савсем простых CRUD проектов которые не планируют развивается, то да. Сгодится. Правда я с такими проектами не работаю.
Мои проекты либо сразу сложнее, либо гарантированно будут усложняется и тогда внедрение сонаты только время сэкономит.
pbatanov
Не буду спорить, но у меня на сложных админках уже столько кастома, что проще впиливать полностью свои экраны, чем заморачиваться с кастомизацией что сонаты, что изиадмина.
ghost404
Тут я полностью поддерживаю. Под новые проекты стараюсь писать свои админки заточенные под конкретный проект.