До сих пор одной из самых лучших админ панелей для Symfony является SonataAdminBundle, и не зря. Простая установка, конфигурация, множество фич “из коробки” и большое сообщество.

Единственное, что в ней отсутствует, так это импорт файлов. Согласитесь, немаловажная функция.

В сети лежит множество реализаций импорта для Sonata, но везде есть небольшие недочеты — возможность импорта только текстовых полей, а не сущностей, не работает с коллекциями, проблематично грузить огромные базы, которые могут обрабатываться не один час…

Сегодня я хочу вам представить свою реализацию, которой успешно пользуюсь уже достаточно долго, но только сейчас руки дошли все это причесать и оформить в отдельный bundle.

image

Я не буду здесь описывать весь процесс установки и настройки. Темболее что в отличии от многих реализаций он крайне прост. Все это вы можете прочитать в 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)


  1. Gemorroj
    29.09.2017 15:35
    +1

    Отмечу все-таки EasyAdminBundle.
    Сам мигрировал на нее. Т.к. забодало воевать с "особенностями" сонаты.


    1. ghost404
      30.09.2017 10:41

      Спасибо за ссылку.
      Не знал про EasyAdminBundle.
      Но похоже EasyAdminBundle ещё больше завязан на антипаттерне Anemic model, чем SonataAdminBundle.
      И конфигурирование форм в yaml файлах попахивает чем-то.
      И не понятно как использовать свои FormType


      1. pbatanov
        30.09.2017 12:22

        Свои форм-тайпы там использовать так же просто — вместо встроенных типов указываете класс. Конфигурирование — да, чем то попахивает, с другой стороны проект расчитан именно на простые круд админки. Это прописано у них в фичах — круд, поиск и дизигн. Но для простых админок он гораздо удобней, чем соната. на мой взгляд.


        1. ghost404
          30.09.2017 19:44

          Ну если прям для савсем савсем простых CRUD проектов которые не планируют развивается, то да. Сгодится. Правда я с такими проектами не работаю.
          Мои проекты либо сразу сложнее, либо гарантированно будут усложняется и тогда внедрение сонаты только время сэкономит.


          1. pbatanov
            02.10.2017 12:33
            +1

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


            1. ghost404
              02.10.2017 20:23

              Тут я полностью поддерживаю. Под новые проекты стараюсь писать свои админки заточенные под конкретный проект.


  1. 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'
    );
    


    1. DOC_tr Автор
      29.09.2017 15:55

      Спасибо за замечание. Обязательно внесу его в проект.


  1. pbatanov
    29.09.2017 23:14

    Sonata получила поддержку symfony 3 (из-за FOS UB) только этим летом, что заставило нас отказаться от этого проекта в пользу easy admin в нескольких проектах. Ну и да, «особенностей» у нее полно.


  1. porn
    30.09.2017 10:42
    -3

    Пожалуйста, наберитесь опыта и не дискредитируйте сообщество.


    1. DOC_tr Автор
      30.09.2017 10:43
      +1

      Хотелось бы услышать более развернутый ответ, что именно не так, а не просто отписку типа «все плохо — выход там».