Привет, Хабр! Меня зовут Леонид, я ведущий PHP-разработчик в НЛМК ИТ. Наша команда отвечает за сервисы HR-направления на едином корпоративном портале (далее – ЕКП), а также занимается развитием и поддержкой корпоративных сайтов.

В предыдущей статье мой коллега рассказывал про подход к использованию гридов на крупном корпоративном портале. Со временем, помимо возможности работать с данными непосредственно на ЕКП, у бизнеса появилась другая потребность – выгружать эти данные в файлы.

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

Первый подход к реализации файловых отчётов

В качестве первоначальной реализации пошли по пути единой точки входа – есть некий раздел на портале: попадая на него с определенными GET-параметрами, мы получаем в ответ отчет, сгенерированный согласно этим параметрам.

Что было сделано в рамках данной реализации:

Во-первых, был реализован класс ReportLink. Этот вспомогательный класс служит для формирования ссылок на отчет по заданным параметрам. Было принято соглашение формировать все ссылки через этот класс – в случае, если в будущем будет изменен формат ссылок, достаточно будет внести изменения только в методе ReportLink::get.

/**
 * Метод для генерации ссылки на отчёт.
 */
public static function get($type, array $params = []): string
{
    $link = '/';
    $type = strval($type);
    if (!empty($type)) {
        $link = self::getEndpointPath() . '?' . http_build_query(array_merge($params, ['type' => $type]));
    }
    return $link;
}

/**
 * Метод для получения точки входа для построения отчёта с учетом сайта
 */
public static function getEndpointPath(): string
{
    return SITE_DIR . self::ENDPOINT_PATH;
}

Для всех отчётов требуется передавать как минимум один параметр: type – символьный код типа отчёта. 

Во-вторых, были реализованы абстрактные классы для основных расширений файлов (форматов) отчетов и абстрактный класс BaseFormat, от которого обязаны наследоваться все классы форматов. Эти абстрактные классы отвечают за работу с конкретными форматами файлов и проверку доступа.

Класс BaseFormat является родительским классом для всех файловых отчётов. Он определяет механизм проверки прав доступа пользователей к отчетам (реализация скрыта), а также абстрактные методы generate и getFileName.

/**
 * Class BaseFormat
 */
abstract class BaseFormat
{
    /**
     * Массив параметров для построения отчёта
     */
    protected $params;

    /**
     * @param array $params
     */
    function __construct(array $params = [])
    {
        $this->params = $params;
        $this->processParams();
        $this->checkAccessRights();
    }

    /**
     * Метод обработки и проверки корректности входных параметров отчёта.
     * По умолчанию проверка не производится.
     */
    protected function processParams()
    {

    }

    /**
     * Метод проверки прав доступа авторизованного пользователя к генерации файла отчёта.
     */
    private function checkAccessRights()
    {
        /*реализация проверки доступа*/
    }   

    /**
     * Метод получения имени файла отчёта
     */
    abstract public function getFileName(): string;

    /**
     * Метод генерации отчёта
     */
    abstract public function generate();
}

Абстрактные классы форматов, в свою очередь, реализуют generate для конкретных форматов файлов. На примере XlsxReport, реализующего работу с xlsx файлами с помощью box/spout:

use \Box\Spout\Writer\Common\Creator\WriterEntityFactory;

/**
 * Class XlsxReport
 * Абстрактный класс для стандартных xlsx-отчётов
 */
abstract class XlsxReport extends BaseFormat
{
    /**
     * Метод получения данных для отчёта
     */
    abstract public function getData(): array;

    /**
     * Метод создания объекта writer
     */
    public function getWriter(): Writer
    {
        return WriterEntityFactory::createXLSXWriter();
    }

    /**
     * Метод генерации отчёта
     */
    public function generate()
    {
        $writer = $this->getWriter();
        $writer->openToFile('php://output');
        $rows = [];
        foreach ($this->getData() as $row) {
            $rows[] = WriterEntityFactory::createRowFromArray($row, $style);
        }
        $writer->addRows($rows);
        $writer->close();

        $this->setHeaders();
        die();
    }

    /**
     * Метод задания http-заголовков
     */
    protected function setHeaders()
    {
        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        header('Content-Disposition: attachment;filename="' . $this->getFileName() . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
    }
}

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

public function getData(): array
{
    $result = [];
    $params = $this->params;

    $candidates = Portfolio::exportWinners($params['contest-id']);

    $arTable = [
        [
            'fullName' => Loc::getMessage('HEADER_CANDIDATES_FULL_NAME'),
            'number' => Loc::getMessage('HEADER_CANDIDATES_NUMBER'),
            'position' => Loc::getMessage('HEADER_CANDIDATES_POSITION'),             
            'metrics' => Loc::getMessage('HEADER_CANDIDATES_METRICS'),
            'rating' => Loc::getMessage('HEADER_CANDIDATES_RATING'),
            'main_sense' => Loc::getMessage('HEADER_CANDIDATES_MAIN_SENSE'),
            'practice' => Loc::getMessage('HEADER_CANDIDATES_PRACTICE'),
            'example' =>  Loc::getMessage('HEADER_CANDIDATES_EXAMPLE'),
        ]
    ];

    foreach ($candidates as $candidate) {
        foreach ($candidate['PRACTICES'] as $practice) {
            $candidateInfo = [
                'fullName' => $candidate['FULL_NAME'],
                'number' => $candidate['PERSONAL_NUMBER'],
                'position' => $candidate['PROPERTY_POSITION_VALUE'],
                'metrics' => $candidate['PROPERTY_METRICS_VALUE'],
                'rating' => $candidate['PROPERTY_RATE_VALUE'],
                'main_sense' => $practice['MAIN_SENSE'],
                'practice' => $practice['PRACTICE_NAME'],
                'example' =>  $practice['PRACTICE_EXAMPLE'],
            ];
            $arTable[] = $candidateInfo;
        }
    }

    return $arTable;
}

Первый элемент в массиве представляет собой шапку xlsx таблицы и задается с помощью Loc::getMessage, последующие элементы уже содержат данные из грида. 

Ну и, в-третьих, была реализована единая точка входа, на которую генерирует ссылки ReportLink::get. 

NLMK\Report\ReportFactory::factory(\Bitrix\Main\Context::getCurrent()->getRequest()->toArray())->generate();

Внутри – фабрика, которая по передаваемому в $params параметру type, определяет нужный отчет и запускает генерацию методом generate.

Подключается на странице грида это все следующим образом:

<?php if ($arResult['DATA']['SHOW_EXPORT_WINNERS_LINK']) { ?>
	<a class="admin-btn"
		href="<?= ReportLink::get('contest-winners', ['contest-id' => $arParams['CONTEST_ID']]) ?>" target="_blank">
    	<?= Loc::getMessage('REPORT_WINNERS') ?>
    </a>
<?php } ?>

Вот так эта кнопка выглядит (на примере другого сервиса):

Итоги первого подхода

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

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

Второй подход к реализации файловых отчётов

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

Исходя из этих требований, был разработан класс FromGrid. Этот класс является универсальным для наших гридов через провайдеры и позволяет собрать данные для отчета в формате csv практически из любого грида. Класс ожидает в $params id грида, класс провайдера, класс фильтра и список полей для отображения:

class FromGrid extends CsvFormat
{
	...

	public function __construct(array $params = [])
    {
        parent::__construct($params);

        if (!$params['PROVIDER']) {
            throw new ArgumentException(Loc::getMessage(
                "CLASS_FROM_GRID_REQUIRED_PARAMETER",
                [
                    "#PARAMETER#" => 'PROVIDER',
                ]
            ));
        }

        if (!$params['GRID_ID']) {
            throw new ArgumentException(Loc::getMessage(
                "CLASS_FROM_GRID_REQUIRED_PARAMETER",
                [
                    "#PARAMETER#" => 'GRID_ID',
                ]
            ));
        }

		...

        $this->provider = new $params['PROVIDER']($params);
 

        if ($params['FILTER']) {
            $filter = new $params['FILTER']();
            $this->provider->setFilterInstance($filter);
        }

        $userOption = new GridOptions($params['GRID_ID_FILTER']);

        if ($viewFields = $userOption->getUsedColumns()) {
            $this->provider->setAvailableFields($viewFields);
        } elseif ($params['VIEW_FIELDS']) {
            $this->provider->setAvailableFields($params['VIEW_FIELDS']);
        }

        if ($sort = $userOption->getSorting()) {
            $this->provider->setSorting($sort['sort']);
        }

        $this->options = $userOption;
        $this->gridId = $params['GRID_ID'];
    }

	...
}

В переменной $userOption содержатся данные о пользовательских настройках грида – какие поля были выбраны для отображения, какая выбрана сортировка и т. д. 

А дальше данные для отчета получаются аналогично тому, как это сделано в компоненте гридов, через провайдер:

public function getData(): array
{
	...

    $rows = $this->provider->getRows();
    ...
}

Таким образом был решен вопрос универсальности и совместимости –данный класс может использоваться в той же фабрике, что и написанные ранее классы, с тем же способом генерации ссылки на отчет через ReportLink::get. Каким же образом был решен основной вопрос – генерация тяжелых отчетов? Здесь уже не получится запускаться напрямую по ссылке, которую мы получаем из метода ReportLink::get, ведь нам нужна обработка на агенте. Для этого, во-первых, в рамках класса компонента гридов была сделана кнопка-обертка для нашей ссылки, а во-вторых, была написана небольшая JS-библиотека, которая перехватывает событие нажатия на эту кнопку.

BX.ready(function() {
    if (typeof(window.NLMK.genReport) === 'function') {
        var report = new window.NLMK.genReport();
        report.init();
    }
});

Что же происходит внутри нашей библиотеки? Внутри идет ряд запросов BX.ajax.runAction к классу-контроллеру. Не буду подробно останавливаться на них, опишу механизм в общих чертах:

  • проверяем, есть ли у текущего пользователя запущенные процессы генерации отчетов

  • если такие есть, возвращаем либо статус генерации, либо ссылку на сгенерированный файл, если генерация завершилась

  • если запущенных генераций нет, запускаем новую, используя в качестве параметров наши params из ссылки

this.runAction('startExport', {'params': params})
  • запускаем обработчик, который раз в n секунд, пока пользователь находится на странице, проверяет статус генерации.

Все интересующее нас волшебство уже происходит в контроллере ExportActivity. 

/**
 * Класс-обертка для генерации отчетов на агентах
 *
 * Class ExportActivity
 * @package NLMK\Base\Controller
 */
class ExportActivity extends Controller
{
    const STATUS_WAIT = 0;
    const STATUS_READY = 1;
    const STATUS_COMPLETE = 100;
    const STATUS_DOWNLOADED = 200;

    /**
     * @inheritDoc
     * @return array
     */
    public function configureActions()
    {
        $defaultFilters = [
            new Authentication(),
            new HttpMethod([HttpMethod::METHOD_POST]),
            new Csrf(),
        ];

        return [
            'startExport' => [
                'prefilters' => $defaultFilters
            ],
			...
        ];
    }

	...

    /**
     * Метод запускает генерацию отчета
     * @param $params
     * @return array
     * @throws LoaderException
     */
    public function startExportAction($params): array
    {
        if (empty($params['report'])) {
            return [];
        }

		...

        $arRecords = GeneratorsActivity::getExportRecord($params['report'], [
            'PRIORITY', ['<=PRIORITY' => self::STATUS_COMPLETE]
        ]);

        if (count($arRecords) !== 0) {
            $arRecords = reset($arRecords);
            return $this->getResponseArray($arRecords['ID'], $arRecords['PRIORITY']);
        }

		...

        $exportActivity = new GeneratorsActivity($params['report']);
        
        if (!empty($params['params']['FILTER']) && !empty($params['params']['GRID_ID_FILTER'])) {
            $provider = new $params['params']['PROVIDER']($params['params']);
            $filter = new $params['params']['FILTER']();
            $provider->setFilterInstance($filter);
            $filter->setProvider($provider);
            if ($params['params']['VIEW_FIELDS']) {
                $filter->setFilterFields($params['params']['VIEW_FIELDS']);
            }

            $filter->setFilterOptions(new Options($params['params']['GRID_ID_FILTER']));

			if (empty($params['params']['SCOPE']) || !is_array($params['params']['SCOPE'])) {
                $params['params']['SCOPE'] = [];
            }
            $params['params']['SCOPE'] = array_merge(
                $filter->getGridFilterValues(),
                $params['params']['SCOPE']
            );
        }

		...

        $id = (int) $exportActivity->startExport(['params' => $params]);
        $arResult = [];
        if ($id > 0) {
            $exportActivity->addAgent($id);
            $arResult = $this->getResponseArray($id, self::STATUS_WAIT);
        }

        return $arResult;
    }

    ...
}

Остановимся здесь поподробнее. В методе startExportAction первым делом мы проверяем, не запущена ли генерация, с помощью вызова GeneratorsActivity::getExportRecord. По сути, это перестраховка, т. к. ранее это уже проверяется вызовом другого action в нашей JS-библиотеке, но лучше, как говорится, перебдеть. А что же происходит внутри нашего вызова GeneratorsActivity::getExportRecord? Внутри – сердце нашего подхода, обращение к таблице nlmk_storage

/**
 * Метод получает запись отчета из таблицы nlmk_storage
 *
 * @param null|string $type
 * @param array $arFilter
 * @return array
 */
public static function getExportRecord(string $type = null, array $arFilter = []): array
{
    $filter = [
        'USER_ID' => (new User())->getId(),
        '%DATA_TYPE' => $type,
    ];

    if (!empty($arFilter)) {
        $filter = array_merge($filter, $arFilter);
    }

    $arRecords = StorageTable::get([
        'filter' => $filter
    ], false);

    return $arRecords ?? [];
}

В таблице nlmk_storage мы храним данные для запуска генерации отчета (наши params из ссылки), id пользователя (инициатора отчёта) и тип отчета. Так нам достаточно знать id записи в этой таблице для запуска генерации, проверки статуса и т. д. Кроме того, гораздо удобнее при создании агента указать один единственный id, чем передавать все параметры для генерации.

Далее по коду нашего контроллера, после того как мы обработали все нужные параметры, мы выполняем $exportActivity->startExport(['params' => $params]).  Внутри мы как раз создаем с помощью класса StorageTable запись в нашей таблице и возвращаем ее id. А полученный id мы уже передаем в метод создания разового агента для генерации отчета.

/**
 * Добавим немедленный агент
 * @param $idExport int ID записи в StorageTable
 */
public function addAgent($idExport)
{
    if ($idExport > 0) {
        \CAgent::addAgent(
            '\NLMK\Agents\ExportReportsByAgent::generate(' . $idExport . ');',
            '',
            'Y',
            5,
            '',
            'Y',
            ConvertTimeStamp(
                time() + \CTimeZone::getOffset(),
                'FULL'
            )
        );
    }
}

Ну а далее всё просто. После того как мы создали объект GeneratorsActivity, получаем значения фильтра, заданного пользователем в гриде, и подставляем их в scope для обеспечения фильтрации на стороне агента. Затем вызовом ExportActivity::startExport создаем запись в нашей таблице и непериодический агент. На этом обработка в контроллере заканчивается, возвращаем на фронт сообщение о запуске генерации и ждем запуска агента.

Не забыли, что это было только во-первых? А вот и во-вторых – класс FromGridAgents, наследующийся от FromGrid. Его задача – решить вопросы с получением больших объемов данных из базы и работой в контексте агента. Собственно, в конструкторе мы создаем объект ExportActivity и задаем размер шага:

public function __construct(array $params = [])
{
    if (!empty($params['params']['params'])) {
        parent::__construct($params['params']['params']);
        $this->exportActivity = new ExportActivity(self::TYPE);
        $this->stepLength = (!empty($params['params']['limit'])) ? intval($params['params']['limit']) : self::DEFAULT_STEP_LENGTH;
        $this->arParams = $params;
    }
}

Затем в методе getData добавляем код для работы провайдера с пагинацией:

...
	$obNavigation = new PageNavigation($this->arParams['params']['params']['GRID_ID']);
    $obNavigation->setPageSize($this->stepLength);
    $this->provider->setNavigationInstance($obNavigation);
...

И таким образом наш $this->provider->getRows() начинает тянуть уже не все данные с базы, а порционно, снижая пиковую нагрузку на память. Добавим еще подсчет процентов готовности и обновление статуса импорта в таблице:

$totalCount = $this->provider->getRowsCount();

$iterationCount = ceil($totalCount / $this->stepLength);
if ($iterationCount <= 0) {
	$iterationCount = 1;
}

for ($iteration = 0; $iteration < $iterationCount; $iteration++) {

	...
	получаем тут порцию данных
	...

	$percent = ($iteration == ($iterationCount - 1))
    	? ExportActivityController::STATUS_COMPLETE
        : round(($iteration / $iterationCount) * 100);

    $this->exportActivity->updateStatus($this->arParams['current_report_id'], $percent);
    if ($iteration >= ($iterationCount - 1)) {
    	$this->exportActivity->finishExport($this->arParams['current_report_id'], $fileName);
    }
}

Ну и заполируем сверху методом для очистки мусора из таблицы nlmk_storage, старых файлов отчетов и агентов, которые по какой-то причине могли не удалиться естественным образом:

public static function clearOldReportData(string $period = self::CLEAR_INTERVAL)
{
    $dateTime = new DateTime();
    $dateTime->add($period);

    $arItems = StorageTable::get([
        'filter' => [
            '%DATA_TYPE' => self::DATA_TYPE,
            '<TIMESTAMP_X' => $dateTime,
        ]
    ]);

    foreach ($arItems as $arData) {
        if (!empty($arData['DATA']['FILE_NAME']) && file_exists(getProjectPublicPath($arData['DATA']['FILE_NAME']))) {
            unlink(getProjectPublicPath($arData['DATA']['FILE_NAME']));
        }
    }

    $arAgents = AgentTable::getList(
        [
            'filter' => [
                '%NAME' => '\NLMK\Agents\ExportReportsByAgent',
                '<NEXT_EXEC' => $dateTime,
            ],
            'select' => ['*']
        ]
    )->fetchAll();
    foreach ($arAgents as $agent) {
        if (!empty($agent['ID'])) {
            AgentTable::delete($agent['ID']);
        }
    }
}

Ну и на этом с классом FromGridAgents по большей части всё. Дальше нас ждет наше в-третьих – собственно наш агент:

public static function generate($id = 0)
{
    if ($id > 0) {
        $report = StorageTable::getRowById($id);
        if ($report !== null && (int)$report['PRIORITY'] === 0) {
            FromGridAgents::clearOldReportData();
            StorageTable::update($id, ['PRIORITY' => 1]);
            $report['DATA']['current_report_id'] = $id;
            ReportFactory::factory($report['DATA'])->generate();
        }
    }
}

Всё просто, получили запись, запустили очистку, сразу обновили запись, чтобы обозначить, что генерация в работе, да прокинули в уже знакомый нам ReportFactory данные из нашей записи в таблице.

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

$buttons = [
    [
        'text' => Loc::getMessage('EVENTS_BOOKING_REGISTRATIONS_GRID_EXPORT_BUTTON'),
        'link' => '#',
        'type' => 'generate-agent',
        'data' => [
            'nlmk-report-generator' => [
                'report' => 'from-grid-agents',
                'period' => 5000,
                'limit' => 10000,
            ]
        ],
    ]
];

$APPLICATION->IncludeComponent(
    'nlmk:element.grid',
    '',
    [
        'GRID_ID' => $gridId,
        'PROVIDER' => RegistrationsProvider::class,
        'FILTER' => RegistrationsFilter::class,
        ...
        'BUTTONS' => $buttons,
    ],
    false
);

где period – период проверки статуса генерации отчета в миллисекундах, а limit – лимит по числу записей, которые обрабатываются классом FromGridAgents за одну итерацию. По сути, даже эти значения можно не указывать, т.к. для них есть значения по умолчанию, достаточно будет указать текст для кнопки и тип generate-agent.   

Итоги второго подхода

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

Вывод

Получившееся решение очень хорошо себя зарекомендовало на практике – уже несколько лет оно активно используется как для старых гридов, так и для всех создаваемых новых. Подключить его "по образцу" может даже junior-разработчик: с вероятностью 99,99% оно заведется с первого раза как надо (а если нет, то служит лакмусовой бумажкой, показывая, что с нашим провайдером что-то не так) и настраивается в зависимости от "тяжести" данных по памяти практически на той же строчке, где и подключается.