Тестовое задание Bitrix

Задача 3. Выгрузка пользователей в CSV

Реализовано как компонент Bitrix local:users.csv.export. Внутри - пошаговая выгрузка через AJAX пачками по 5 000 строк, со своим шаблоном (template.php, style.css) и обработчиком в классе компонента. Выгружаю всех активных пользователей, страница не перезагружается, файл скачивается сам. Под нагрузкой 500 000 пользователей - около минуты-двух.

Подготовка тестовых данных

Активных пользователей сейчас
540 000

Запустить выгрузку

Боевая точка входа - админ-страница /bitrix/admin/users_csv_export.php: она тонкая, проверяет права и просто включает компонент через $APPLICATION->IncludeComponent('local:users.csv.export', '.default', […]). Доступна только администратору.

Раздел «Выгрузка пользователей в CSV» в админке Битрикс
Так это выглядит в админке: пункт «Выгрузка пользователей в CSV» в разделе «Настройки» (добавляется через OnBuildGlobalMenu в local/php_interface/init.php) и сама страница с кнопкой запуска выгрузки.

Исходный код

Компонент: local:users.csv.export

local/components/local/users.csv.export/.description.php

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

$arComponentDescription = [
    'NAME'        => 'Выгрузка пользователей в CSV',
    'DESCRIPTION' => 'Пошаговая выгрузка всех активных пользователей в CSV-файл через AJAX (Фамилия, Имя, Телефон, E-mail).',
    'ICON'        => '/images/icon.gif',
    'COMPLEX'     => 'N',
    'PATH'        => [
        'ID'   => 'utility',
        'NAME' => 'Утилиты',
    ],
];

local/components/local/users.csv.export/.parameters.php

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

$arComponentParameters = [
    'PARAMETERS' => [
        'REQUIRE_ADMIN' => [
            'PARENT'  => 'BASE',
            'NAME'    => 'Только для администраторов',
            'TYPE'    => 'CHECKBOX',
            'DEFAULT' => 'Y',
        ],
        'CHUNK_LIMIT' => [
            'PARENT'  => 'BASE',
            'NAME'    => 'Размер чанка (записей за один шаг)',
            'TYPE'    => 'STRING',
            'DEFAULT' => '5000',
        ],
        'CACHE_TIME' => ['DEFAULT' => '0'],
    ],
];

local/components/local/users.csv.export/class.php

<?php

declare(strict_types=1);

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

use Bitrix\Main\Loader;
use Local\Export\UsersCsvExporter;

final class UsersCsvExportComponent extends CBitrixComponent
{
    public function onPrepareComponentParams($arParams): array
    {
        $arParams['REQUIRE_ADMIN'] = (($arParams['REQUIRE_ADMIN'] ?? 'Y') === 'Y') ? 'Y' : 'N';
        $chunk = (int)($arParams['CHUNK_LIMIT'] ?? UsersCsvExporter::DEFAULT_LIMIT);
        $arParams['CHUNK_LIMIT'] = max(100, min(50_000, $chunk));
        return $arParams;
    }

    public function executeComponent(): void
    {
        Loader::registerNamespace('Local', $_SERVER['DOCUMENT_ROOT'] . '/local/php_interface/lib');

        if (!$this->checkAccess()) {
            ShowError('Доступ запрещён');
            return;
        }

        $ajaxAction = (string)($_REQUEST['ajax_action'] ?? '');
        if ($ajaxAction !== '') {
            $this->handleAjax($ajaxAction);
            return;
        }

        $this->arResult['CHUNK_LIMIT'] = $this->arParams['CHUNK_LIMIT'];
        $this->arResult['SESSID']      = bitrix_sessid();
        $this->arResult['UNIQ']        = $this->randString();

        $this->includeComponentTemplate();
    }

    private function checkAccess(): bool
    {
        if ($this->arParams['REQUIRE_ADMIN'] !== 'Y') {
            return true;
        }

        global $USER;
        return $USER instanceof CUser && $USER->IsAdmin();
    }

    private function handleAjax(string $action): never
    {
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        if ($action === 'download') {
            UsersCsvExporter::download((string)($_REQUEST['token'] ?? ''));
            exit;
        }

        if (!check_bitrix_sessid()) {
            $this->respondJson(['error' => 'bad_sessid'], 403);
        }

        try {
            match ($action) {
                'start' => $this->respondJson(UsersCsvExporter::prepare()->toArray()),
                'step'  => $this->respondJson(
                    UsersCsvExporter::step(
                        token:  (string)($_REQUEST['token']  ?? ''),
                        lastId: (int)   ($_REQUEST['lastId'] ?? 0),
                        limit:  $this->arParams['CHUNK_LIMIT'],
                    )->toArray()
                ),
                default => $this->respondJson(['error' => 'unknown_action'], 400),
            };
        } catch (\Throwable $e) {
            $this->respondJson(['error' => $e->getMessage()], 500);
        }
    }

    private function respondJson(array $data, int $status = 200): never
    {
        http_response_code($status);
        header('Content-Type: application/json; charset=UTF-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }
}

Шаблон .default

templates/.default/template.php

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

$this->setFrameMode(false);

$uniq = $arResult['UNIQ'];
?>
<link rel="stylesheet" href="<?= $templateFolder ?>/style.css">
<div id="ucsv-<?= $uniq ?>" class="ucsv-wrap">
    <p>
        Запускаю пошаговую выгрузку всех активных пользователей в CSV
        (Фамилия, Имя, Телефон, E-mail). Запросы идут пачками по
        <?= (int)$arResult['CHUNK_LIMIT'] ?> записей через AJAX,
        страница не перезагружается.
    </p>

    <button type="button" class="adm-btn-save ucsv-start">Выгрузить пользователей</button>

    <div class="ucsv-progress" style="display:none">
        <div class="ucsv-bar-wrap"><div class="ucsv-bar"></div></div>
        <div class="ucsv-status">Подготовка…</div>
    </div>

    <div class="ucsv-done" style="display:none">
        <div>Готово. Скачивание должно начаться автоматически.</div>
        <div>Если вдруг нет - <a class="ucsv-link" href="#">забрать файл вручную</a>.</div>
    </div>

    <div class="ucsv-error" style="display:none"></div>
</div>

<script>
(function(){
    const root     = document.getElementById('ucsv-<?= $uniq ?>');
    const btn      = root.querySelector('.ucsv-start');
    const progress = root.querySelector('.ucsv-progress');
    const bar      = root.querySelector('.ucsv-bar');
    const statusEl = root.querySelector('.ucsv-status');
    const doneEl   = root.querySelector('.ucsv-done');
    const errorEl  = root.querySelector('.ucsv-error');
    const linkEl   = root.querySelector('.ucsv-link');

    const ENDPOINT = window.location.pathname + window.location.search.replace(/[?&]ajax_action=[^&]*/g, '');
    const SESSID   = <?= json_encode($arResult['SESSID']) ?>;

    btn.addEventListener('click', async () => {
        btn.disabled = true;
        doneEl.style.display = 'none';
        errorEl.style.display = 'none';
        progress.style.display = '';
        bar.style.width = '0%';
        statusEl.textContent = 'Подготовка…';

        try {
            const state = await ajax({ ajax_action: 'start', sessid: SESSID });
            statusEl.textContent = 'Найдено пользователей: ' + state.total.toLocaleString('ru');

            let lastId = 0;
            let processed = 0;
            while (true) {
                const res = await ajax({ ajax_action: 'step', sessid: SESSID, token: state.token, lastId });
                processed += res.processed;
                if (state.total > 0) {
                    const pct = Math.min(100, Math.round(processed / state.total * 100));
                    bar.style.width = pct + '%';
                    statusEl.textContent = 'Обработано ' + processed.toLocaleString('ru') + ' / ' + state.total.toLocaleString('ru') + ' (' + pct + '%)';
                } else {
                    statusEl.textContent = 'Обработано ' + processed.toLocaleString('ru');
                }
                if (res.done) {
                    finish(state.token);
                    break;
                }
                lastId = res.lastId;
            }
        } catch (err) {
            errorEl.textContent = 'Ошибка: ' + (err && err.message ? err.message : err);
            errorEl.style.display = '';
            btn.disabled = false;
        }
    });

    function finish(token) {
        bar.style.width = '100%';
        statusEl.textContent = 'Готово.';
        const sep = ENDPOINT.includes('?') ? '&' : '?';
        const url = ENDPOINT + sep + 'ajax_action=download&token=' + encodeURIComponent(token);
        linkEl.href = url;
        doneEl.style.display = '';
        btn.disabled = false;
        window.location.href = url;
    }

    async function ajax(params) {
        const body = new URLSearchParams(params).toString();
        const r = await fetch(ENDPOINT, {
            method: 'POST',
            credentials: 'same-origin',
            headers: { 'X-Requested-With': 'XMLHttpRequest' },
            body
        });
        const txt = await r.text();
        let data;
        try { data = JSON.parse(txt); }
        catch { throw new Error('Некорректный ответ: ' + txt.substring(0, 200)); }
        if (!r.ok || data.error) {
            throw new Error(data?.error ?? ('HTTP ' + r.status));
        }
        return data;
    }
})();
</script>

templates/.default/style.css

.ucsv-wrap { max-width: 680px; font-size: 14px; line-height: 1.5; }
.ucsv-wrap p { margin: 0 0 12px 0; }
.ucsv-wrap .ucsv-start { margin: 6px 0 14px 0; }
.ucsv-progress { margin: 14px 0; }
.ucsv-bar-wrap {
    background: #eee;
    border-radius: 4px;
    overflow: hidden;
    height: 20px;
    margin-bottom: 8px;
}
.ucsv-bar {
    height: 100%;
    width: 0;
    background: #3aafff;
    transition: width .25s;
}
.ucsv-status { color: #555; }
.ucsv-done {
    margin-top: 14px;
    padding: 12px;
    background: #eafff0;
    border: 1px solid #b7e6c6;
    border-radius: 4px;
}
.ucsv-error {
    margin-top: 14px;
    padding: 12px;
    background: #ffecec;
    border: 1px solid #f1b0b0;
    border-radius: 4px;
    color: #9c1f1f;
}

Логика выгрузки: Local\Export\UsersCsvExporter

<?php

declare(strict_types=1);

namespace Local\Export;

use Bitrix\Main\IO\Directory;
use Bitrix\Main\UserTable;
use RuntimeException;

final class UsersCsvExporter
{
    private const BOM         = "\xEF\xBB\xBF";
    private const DELIMITER   = ';';
    private const ENCLOSURE   = '"';
    private const ESCAPE      = "\\";
    private const STORAGE_DIR = '/upload/tmp/users_csv';
    private const TOKEN_RE    = '/^[a-f0-9]{32}$/';

    public const DEFAULT_LIMIT = 5000;

    private const HEADER = ['Фамилия', 'Имя', 'Телефон', 'E-mail'];

    public static function prepare(): PrepareResult
    {
        self::ensureStorage();

        $token = bin2hex(random_bytes(16));
        $path  = self::pathForToken($token);

        $fp = fopen($path, 'wb') ?: throw new RuntimeException("Не удалось создать файл {$path}");
        fwrite($fp, self::BOM);
        fputcsv($fp, self::HEADER, self::DELIMITER, self::ENCLOSURE, self::ESCAPE);
        fclose($fp);

        return new PrepareResult(
            token: $token,
            total: UserTable::getCount(['=ACTIVE' => 'Y']),
        );
    }

    public static function step(string $token, int $lastId, int $limit = self::DEFAULT_LIMIT): StepResult
    {
        $path = self::pathForToken($token);
        if (!is_file($path)) {
            throw new RuntimeException("Файл выгрузки {$token} не найден");
        }

        $limit = match (true) {
            $limit < 1, $limit > 50000 => self::DEFAULT_LIMIT,
            default                    => $limit,
        };

        $rows = UserTable::getList([
            'select' => ['ID', 'LAST_NAME', 'NAME', 'PERSONAL_PHONE', 'EMAIL'],
            'filter' => ['>ID' => $lastId, '=ACTIVE' => 'Y'],
            'order'  => ['ID' => 'ASC'],
            'limit'  => $limit,
        ])->fetchAll();

        $fp = fopen($path, 'ab') ?: throw new RuntimeException("Не удалось дописать в {$path}");

        $processed = 0;
        $newLastId = $lastId;
        foreach ($rows as $row) {
            fputcsv(
                $fp,
                [
                    (string)($row['LAST_NAME']      ?? ''),
                    (string)($row['NAME']           ?? ''),
                    (string)($row['PERSONAL_PHONE'] ?? ''),
                    (string)($row['EMAIL']          ?? ''),
                ],
                self::DELIMITER,
                self::ENCLOSURE,
                self::ESCAPE
            );
            $processed++;
            $newLastId = (int)$row['ID'];
        }
        fclose($fp);

        return new StepResult(
            processed: $processed,
            lastId: $newLastId,
            done: $processed < $limit,
        );
    }

    public static function download(string $token): void
    {
        $path = self::pathForToken($token);
        if (!is_file($path)) {
            http_response_code(404);
            echo 'Файл не найден';
            return;
        }

        $size = filesize($path);
        $filename = 'users_' . date('Y-m-d_H-i-s') . '.csv';

        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Content-Length: ' . $size);
        header('Cache-Control: no-cache, no-store, must-revalidate');
        header('Pragma: no-cache');
        header('Expires: 0');

        readfile($path);
    }

    public static function cleanup(string $token): void
    {
        $path = self::pathForToken($token);
        if (is_file($path)) {
            @unlink($path);
        }
    }

    private static function pathForToken(string $token): string
    {
        if (!preg_match(self::TOKEN_RE, $token)) {
            throw new RuntimeException('Недопустимый токен');
        }
        return self::storageDir() . '/' . $token . '.csv';
    }

    private static function storageDir(): string
    {
        return rtrim($_SERVER['DOCUMENT_ROOT'], '/') . self::STORAGE_DIR;
    }

    private static function ensureStorage(): void
    {
        $dir = self::storageDir();
        if (!is_dir($dir)) {
            class_exists(Directory::class)
                ? Directory::createDirectory($dir)
                : @mkdir($dir, 0755, true);
        }
        if (!is_writable($dir)) {
            throw new RuntimeException("Каталог {$dir} недоступен для записи");
        }

        $htaccess = $dir . '/.htaccess';
        if (!is_file($htaccess)) {
            file_put_contents($htaccess, "Order Deny,Allow\nDeny from all\n");
        }
    }
}
<?php

declare(strict_types=1);

namespace Local\Export;

final readonly class PrepareResult
{
    public function __construct(
        public string $token,
        public int    $total,
        public int    $lastId = 0,
    ) {
    }

    public function toArray(): array
    {
        return [
            'token'  => $this->token,
            'total'  => $this->total,
            'lastId' => $this->lastId,
        ];
    }
}
<?php

declare(strict_types=1);

namespace Local\Export;

final readonly class StepResult
{
    public function __construct(
        public int  $processed,
        public int  $lastId,
        public bool $done,
    ) {
    }

    public function toArray(): array
    {
        return [
            'processed' => $this->processed,
            'lastId'    => $this->lastId,
            'done'      => $this->done,
        ];
    }
}

Админ-страница

<?php

declare(strict_types=1);

require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_before.php';

if (!$USER->IsAdmin()) {
    $APPLICATION->AuthForm('Доступ запрещён');
}

if (!empty($_REQUEST['ajax_action'])) {
    $APPLICATION->IncludeComponent(
        'local:users.csv.export',
        '.default',
        ['REQUIRE_ADMIN' => 'Y']
    );
    exit;
}

$APPLICATION->SetTitle('Выгрузка пользователей в CSV');
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_after.php';

$APPLICATION->IncludeComponent(
    'local:users.csv.export',
    '.default',
    ['REQUIRE_ADMIN' => 'Y']
);

require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/epilog_admin.php';