Експорт даних з веб-застосунку: реалізація під ключ
Згідно зі Статтею 20 GDPR, кожен суб'єкт даних має право отримати свої персональні дані у структурованому, загальновживаному та машиночитаному форматі. Але на практиці вивантаження даних на 500 000 рядків часто завалює сервер: тайм-аут 30 секунд і 502 Bad Gateway. Причина — синхронна архітектура та ліміти PHP. Ми вирішуємо цю проблему на рівні інфраструктури: асинхронна генерація через черги, потоковий запис у S3 та тимчасові signed URLs. Вимоги GDPR (ст. 20) зобов'язують надати персональні дані користувача у машиночитаному форматі — ми реалізуємо експорт під ключ. Зв'яжіться з нами для оцінки вашого проєкту.
Проблеми, які вирішуємо
- N+1 запити при вивантаженні даних: при вивантаженні замовлень з позиціями кожен запит до БД плодить нові — для 10 000 замовлень це 10 001 запит. Використовуємо lazy loading через
lazy(200)та жадібне завантаження зв'язків. - Тайм-аути та падіння пам'яті при синхронному вивантаженні >100 000 рядків: PHP-скрипт впирається у
memory_limitтаmax_execution_time. Рішення — асинхронна обробка через черги з таймаутом 600 сек. - Гідратація невідповідності при SSR (hydration mismatch): якщо дані експорту вбудовуються на фронт, React/Nuxt можуть розсинхронізуватися. Експортуємо все через API з унікальними ID.
Чому асинхронний експорт кращий для великих даних?
Синхронний підхід підходить лише для малих обсягів (<10k рядків). При зростанні даних він веде до блокування інтерфейсу та скидання з'єднання. Асинхронна схема з чергою та повідомленнями знімає навантаження з HTTP-воркера та масштабується горизонтально. Коли один клієнт генерує звіт на 200k рядків, інші продовжують працювати без затримок.
Типовий приклад: один клієнт попросив вивантаження 150 000 замовлень у Excel. Спочатку розробник написав синхронний скрипт — на 40-й секунді PHP впав по memory_limit. Після переходу на чергу генерація зайняла 2 хвилини, користувач отримав листа з посиланням без блокувань.
| Критерій | Синхронний експорт | Асинхронний експорт |
|---|---|---|
| Макс. обсяг | до 10 000 рядків | без обмежень |
| Блокування воркера | так | ні |
| Тайм-аут | 30-60 сек | 600+ сек |
| Повідомлення користувача | ні (очікування) | email / websocket |
| Робота з S3 | ні | так (потокове завантаження) |
[User request] → [Create ExportJob record] → [Queue Worker] ↓ [Generate files] ↓ [Upload to S3 (zip)] ↓ [Email with download link] (signed URL, 7 days TTL) Як ми це робимо: Laravel-реалізація
Для серверної частини використовуємо Laravel з PHP 8.3+. Ключовий патерн — сервіс DataExportService, який ставить задачу в чергу. Воркер ExportUserDataJob генерує файли профілю (JSON), замовлень (CSV з BOM), повідомлень (JSON) та пакує їх у ZIP з подальшим завантаженням у S3.
class DataExportService { public function exportUserData(User $user): Export { $export = Export::create([ 'user_id' => $user->id, 'status' => 'pending', 'expires_at' => now()->addDays(7), ]); ExportUserDataJob::dispatch($user, $export); return $export; } } class ExportUserDataJob implements ShouldQueue { public int $timeout = 600; public function __construct(private User $user, private Export $export) {} public function handle(): void { $this->export->update(['status' => 'processing']); $tempDir = sys_get_temp_dir() . '/export_' . $this->export->id; mkdir($tempDir, 0777, true); try { // Профіль file_put_contents( "$tempDir/profile.json", json_encode($this->exportProfile(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); // Замовлення $this->exportOrders("$tempDir/orders.csv"); // Повідомлення $this->exportMessages("$tempDir/messages.json"); // Створити ZIP $zipPath = sys_get_temp_dir() . "/export_{$this->export->id}.zip"; $zip = new ZipArchive(); $zip->open($zipPath, ZipArchive::CREATE); foreach (glob("$tempDir/*") as $file) { $zip->addFile($file, basename($file)); } $zip->close(); // Завантажити в S3 $s3Key = "exports/{$this->user->id}/{$this->export->id}/data.zip"; Storage::disk('s3')->put($s3Key, file_get_contents($zipPath)); $this->export->update([ 'status' => 'ready', 's3_key' => $s3Key, ]); $this->user->notify(new ExportReadyNotification($this->export)); } finally { exec("rm -rf {$tempDir}"); if (file_exists($zipPath ?? '')) unlink($zipPath); } } private function exportProfile(): array { return [ 'id' => $this->user->id, 'name' => $this->user->name, 'email' => $this->user->email, 'created_at' => $this->user->created_at->toIso8601String(), 'profile' => $this->user->profile?->toArray(), ]; } private function exportOrders(string $path): void { $handle = fopen($path, 'w'); fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM fputcsv($handle, ['Номер', 'Дата', 'Сума', 'Статус', 'Позиції'], ';'); $this->user->orders()->with('items')->lazy(200)->each(function (Order $order) use ($handle) { $items = $order->items->map(fn($i) => "{$i->name} x{$i->quantity}")->join(', '); fputcsv($handle, [ $order->number, $order->created_at->format('d.m.Y H:i'), number_format($order->total, 2), $order->status, $items, ], ';'); }); fclose($handle); } private function exportMessages(string $path): void { $messages = $this->user->messages() ->select('id', 'subject', 'body', 'created_at') ->get() ->map(fn($m) => [ 'id' => $m->id, 'subject' => $m->subject, 'body' => strip_tags($m->body), 'date' => $m->created_at->toIso8601String(), ]); file_put_contents($path, json_encode($messages, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } public function failed(\Throwable $e): void { $this->export->update(['status' => 'failed']); Log::error('Export failed', ['export_id' => $this->export->id, 'error' => $e->getMessage()]); } } Як працює signed URL для завантаження?
Щоб не зберігати архіви вічно, використовуємо тимчасові посилання S3 з TTL 15 хвилин. Користувач отримує листа з унікальним посиланням, яке веде напряму в S3 — минаючи наш сервер.
public function download(Export $export): RedirectResponse { $this->authorize('download', $export); if ($export->status !== 'ready' || $export->expires_at->isPast()) { abort(410, 'Експорт недоступний або застарів'); } $url = Storage::disk('s3')->temporaryUrl($export->s3_key, now()->addMinutes(15)); return redirect()->away($url); } Як підібрати формат експорту?
Вибір формату залежить від мети: для API та GDPR-вивантаження підходить JSON, для таблиць та Excel — CSV з BOM, для бухгалтерії — XLSX з форматуванням, для B2B інтеграцій — XML. Якщо потрібен архів кількох файлів, використовуємо ZIP.
| Формат | Застосування | Інструмент |
|---|---|---|
| JSON | API, GDPR-вивантаження | PHP json_encode, Node JSON.stringify |
| CSV | Таблиці, Excel | fputcsv, csv-writer |
| XLSX | Бухгалтерія, аналітика | PhpSpreadsheet, ExcelJS |
| XML | B2B інтеграції | SimpleXML, xmlbuilder2 |
| ZIP | Архів кількох файлів | ZipArchive, archiver (Node) |
Процес роботи
- Аналітика — вивчаємо структуру даних, обсяги, вимоги до форматів.
- Проектування — обираємо стек (черга, сховище), проектуємо схему БД для експортів.
- Розробка — пишемо сервіс, воркери, контролери, тести.
- Тестування — навантажувальне тестування з реальними обсягами, перевірка тайм-аутів.
- Деплой — налаштовуємо черги (Redis, SQS), S3, cron для очищення.
- Документація — передаємо архітектурну документацію та інструкцію.
Що входить у роботу
- Архітектурна документація з описом потоків даних.
- Вихідний код з коментарями та строгою типізацією.
- Інструкція з деплою з прикладами конфігурації черг та S3.
- Навчання співробітників підтримки.
- Гарантія 3 місяці на безперебійну роботу.
Терміни та вартість
Базовий експорт даних користувача (JSON + CSV у ZIP): 2–3 дні. З чергою, S3 та email-повідомленням: 3–4 дні. GDPR-сумісний експорт всіх персональних даних: 4–5 днів. Вартість розраховується індивідуально в залежності від обсягів та кількості форматів. Досвід нашої команди — 5+ років та 50+ проєктів з експорту даних. Замовте експорт для вашого проєкту — отримайте консультацію та оцінку протягом 1 дня безкоштовно.
Зв'яжіться з нами, щоб обговорити ваш проєкт та отримати точну оцінку.







