Задача 3. Выгрузка пользователей в CSV
Реализовано как компонент Bitrix local:users.csv.export.
Внутри - пошаговая выгрузка через AJAX пачками по 5 000 строк, со
своим шаблоном (template.php, style.css) и
обработчиком в классе компонента. Выгружаю всех активных пользователей,
страница не перезагружается, файл скачивается сам. Под нагрузкой
500 000 пользователей - около минуты-двух.
Подготовка тестовых данных
Запустить выгрузку
Боевая точка входа - админ-страница
/bitrix/admin/users_csv_export.php:
она тонкая, проверяет права и просто включает компонент через
$APPLICATION->IncludeComponent('local:users.csv.export', '.default', […]).
Доступна только администратору.
Исходный код
Компонент: 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';