0

Tài liệu mô tả kiến trúc của Export Core

Tài liệu mô tả kiến trúc của Export Core — hạ tầng dùng chung để sinh file export (XLSX / CSV) chạy ngầm qua queue, lưu lên S3, rồi gửi link tải về cho người dùng qua Chat. Hiện tại đang phục vụ tính năng xuất excel service config, nhưng được thiết kế để mọi module khác tái sử dụng chỉ bằng cách thêm một Exporter mới.

Mục tiêu thiết kế

  • Tách business logic khỏi cách ghi file. Mỗi loại export chỉ cần khai báo lấy dữ liệu gì (sheets + headers + generator); việc ghi XLSX/CSV và upload S3 do core lo.
  • Memory phẳng (flat) với dataset lớn. Toàn bộ pipeline dùng PHP \Generator
    • lazyById(), ghi ra temp file theo stream, rồi stream-upload lên S3 — không bao giờ nạp toàn bộ dataset vào RAM. Chạy được dataset 100k+ dòng.
  • Chạy ngầm, không block request. Người dùng bấm nút → tạo bản ghi ExportRequest (PENDING) → đẩy GenericExportJob vào queue export → trả về ngay. Khi xong, link tải được gửi qua Chat.
  • Mở rộng bằng cách thêm class, không sửa core. Thêm 1 enum case + 1 class Exporter + 1 nhánh trong factory là đủ.

Sơ đồ luồng (end-to-end)

vào https://mermaid.live/

image.png

flowchart TD
    Blade["Blade: &lt;x-admin-base::buttons.export-ajax&gt;<br/>nút 'Xuất dữ liệu'"]
    Ctrl["Controller::exportAdministrativeFormat<br/>modules/Admin/Utm/.../UtmServiceConfigController"]
    DomainSvc["UtmServiceConfigService::exportAdministrativeFormat<br/>app(ExportService)->export($auth, ['component', 'filters'], $isSync)"]
    ExportSvc["Export\\Services\\ExportService::export<br/>tạo ExportRequest (PENDING)"]
    Job["Export\\Jobs\\GenericExportJob (queue: 'export')<br/>status → PROCESSING<br/>ExportServiceFactory::make(component)"]
    Exporter["Exporter extends BaseExportService::export<br/>getSheets() → FastExcelSheet[] (mỗi tỉnh = 1 sheet)"]
    Writer{"Chọn writer<br/>theo export_format"}
    Csv["NativeCsvExport<br/>(csv)"]
    FastXlsx["FastExcelXlsxExport<br/>(xlsx + FastExcelSheet)"]
    Legacy["Maatwebsite/Excel<br/>(xlsx legacy, GenericSheetExportV2)"]
    Storage["StorageService: temp file → stream put() lên S3<br/>(visibility: private)<br/>status → COMPLETED, files=[path], completed_at"]
    Notify["notifyUser: gửi pre-signed URL (hết hạn 24h)<br/>qua send_chat()"]

    Blade -->|"AJAX POST (params từ URL filter + export_format + _token)"| Ctrl
    Ctrl -->|"gọi service domain"| DomainSvc
    DomainSvc --> ExportSvc
    ExportSvc -->|"dispatch / dispatchSync"| Job
    Job --> Exporter
    Exporter --> Writer
    Writer -->|csv| Csv
    Writer -->|xlsx| FastXlsx
    Writer -->|xlsx legacy| Legacy
    Csv --> Storage
    FastXlsx --> Storage
    Legacy --> Storage
    Storage --> Notify

Với chế độ đồng bộ (isSync = true), dispatchSync chạy ngay trong request và controller trả thẳng files_url về cho front-end (front-end tự window.open từng link). Với chế độ bất đồng bộ (mặc định cho UTM), controller trả message "Quá trình tải về sẽ mất vài phút… file sẽ được gửi về Chat của bạn."

Thành phần

Services/ExportService.php — điểm vào (entry point)

API public mà các module domain gọi:

app(ExportService::class)->export($auth, [
    'component' => ExportRequestComponent::UTM_SERVICE_CONFIG_ADMINISTRATIVE,
    'filters'   => [...],          // params lọc, gồm cả 'export_format'
], $isSync = false);
  • Tạo bản ghi ExportRequest (status PENDING) qua ExportRequestInterface (repository), gắn causer polymorphic là user đang đăng nhập.
  • isSync = trueGenericExportJob::dispatchSync() rồi trả về ExportRequest đã refresh().
  • isSync = falseGenericExportJob::dispatch() trả về PendingDispatch.

Controller dựa vào kiểu trả về (PendingDispatch vs ExportRequest) để quyết định trả message "chạy ngầm" hay trả thẳng files_url.

Jobs/GenericExportJob.php — worker chạy ngầm

  • Queue cố định: export. tries = 1 (không retry tự động), timeout = 0 + set_time_limit(0) để job dài không bị kill; nếu lỡ bị requeue thì retryAfter = 7200s.
  • handle(): bỏ qua nếu request không tồn tại hoặc đã COMPLETED (idempotent); set PROCESSING + started_at; lấy Exporter từ factory rồi gọi export().
  • failed(): set FAILED + ghi error_reason (gồm request context từ LogContext, message/file/line của exception) + log lỗi. Cũng bỏ qua nếu đã COMPLETED.

Services/ExportServiceFactory.php — bộ định tuyến

match từ ExportRequestComponent sang class Exporter cụ thể. Đăng ký singleton trong ExportServiceProvider. Đây là nơi duy nhất phải sửa khi thêm loại export mới (ngoài việc tạo class Exporter và thêm enum case).

match ($component) {
    UTM_SERVICE_CONFIG_ADMINISTRATIVE => app(UtmServiceConfigAdministrativeExport::class),
    UTM_SERVICE_CONFIG_UTM            => app(UtmServiceConfigUtmExport::class),
    default => throw new \Exception(...),
};

Services/Contracts/ExportServiceInterface.php + BaseExportService.php

BaseExportServicetemplate method chứa toàn bộ logic ghi file & thông báo; mỗi Exporter con chỉ override 3 hook:

Hook abstract Trách nhiệm
getSheets($request) Trả về FastExcelSheet[] (hoặc GenericSheetExportV2[] legacy)
getFilePrefix() Tiền tố tên file, vd cau_hinh_shop_theo_dia_gioi_hanh_chinh
getNotificationTitle() Tiêu đề thông báo Chat

export() của base làm các việc:

  1. Đọc export_format từ filters (mặc định csv).
  2. Suy ra extension (csv/xlsx) và writer phù hợp.
  3. Sinh tên file: {prefix}_{timestamp}_{uniqid}.{ext}; object path exports/{prefix}/{Y-m-d} qua StorageService::generateStorageMeta().
  4. Dispatch tới đúng writer:
    • CSVNativeCsvExport (nhận cả FastExcelSheet lẫn GenericSheetExportV2).
    • XLSX + FastExcelSheetFastExcelXlsxExport (đường nhanh, streaming).
    • XLSX + sheet legacyMaatwebsite\Excel qua MultiSheetExport (GenericSheetExportV2, có styling nhưng nặng RAM/CPU).
  5. Nếu lưu thành công: cập nhật COMPLETED, files, completed_at rồi gọi notifyUser(). Nếu thất bại: ném exception (job sẽ vào failed()).

notifyUser() lấy pre-signed URL với hạn 24h (withFilesUrlExpiration), gom thành message dạng [link]...[/link] và gửi qua send_chat(). Lưu ý: ngoài production thì email nhận luôn là chuongtd@hasaki.vn (hard-code để test) — chỉ production mới gửi đúng $user->email.

Các writer

Class Format Cơ chế Khi dùng
NativeCsvExport CSV fputcsv thuần + BOM UTF-8, ghi temp file rồi stream lên S3 format csv. Nhanh 10–50× so với PHPSpreadsheet CSV
FastExcelXlsxExport XLSX rap2hpoutre/fast-excel (OpenSpout) — ZIP streaming, không style format xlsx với FastExcelSheet. Nhanh 5–20× với 100k+ dòng
Maatwebsite\Excel XLSX PHPSpreadsheet qua MultiSheetExport (legacy) XLSX với GenericSheetExportV2 — khi cần styling

Cả ba đều stream temp file → S3 với visibility: private, và luôn unlink temp file trong finally. CSV chỉ ghi 1 sheet (CSV không có khái niệm multi-sheet); XLSX gom nhiều sheet, mỗi sheet 1 tab (key của SheetCollection = tên tab).

Services/FastExcelSheet.php — DTO mô tả 1 sheet

Thay thế GenericSheetExportV2 (vốn mang theo concern của PHPSpreadsheet) cho pipeline FastExcel, để không đụng tới GenericSheetExportV2 của các consumer cũ.

new FastExcelSheet(
    title:   $sheetTitle,        // ≤ 31 ký tự (giới hạn tab name của XLSX)
    headers: ['Nhãn cột' => 'row_key', ...],  // map nhãn hiển thị → key dữ liệu
    data:    $generator,         // \Generator, mỗi yield = mảng assoc theo row_key
);
  • headings() → mảng nhãn cột (hàng tiêu đề).
  • mappedGenerator() → yield row đã map đúng thứ tự cột theo headers (dùng cho CSV để đảm bảo cột khớp tiêu đề).
  • FastExcelXlsxExport tự build header row bằng nhãn cột làm key của row — KHÔNG inject thêm một hàng heading riêng (xem ghi chú trong rowsWithHeading()).

Exporter mẫu (Services/Exporter/*)

  • UtmServiceConfigAdministrativeExport — xuất cấu hình theo địa giới hành chính. Mỗi tỉnh = 1 sheet. Duyệt cây tỉnh → quận/huyện → phường/xã bằng lazyById(1000), tính trạng thái bật/tắt từng service type và kế thừa disable từ cấp cha (calculateStatuses).
  • UtmServiceConfigUtmExport — xuất cấu hình theo toạ độ UTM (grid cell). Có thêm cột UTM/toạ độ/shop; chỉ tạo sheet cho các tỉnh thực sự có dữ liệu grid-cell (1 query join grid_cells_ward → wards → districts thay cho vòng lặp EXISTS N+1 per-tỉnh).

Cả hai dùng PickupLocationServiceType::allNow() để sinh động các cột service.

Front-end: nút export AJAX

Component dùng chung: modules/Admin/Base/.../components/buttons/export-ajax.blade.php. Cách dùng (xem modules/Admin/Utm/.../service-configs/index-administrative-format.blade.php):

<x-admin-base::buttons.export-ajax
    url="{{ route('admin.utm.service-config.export-administrative-format') }}"
    permission="now-utm-service-config-administrative-export"
    exportFormat="xlsx"
/>

Props chính: url, method (mặc định POST), permission (disable nút nếu user thiếu quyền), buttonText, exportFormat (xlsx/csv), dataCallback (tên hàm JS trả về payload), exportData (payload tĩnh).

Hành vi JS (đăng ký @once, dùng event-delegation trên .btn-ajax-export-action):

  1. Thu thập params theo thứ tự ưu tiên: dataCallback()exportData tĩnh → mặc định: copy toàn bộ query-string của URL hiện tại (giữ nguyên bộ lọc đang xem; tự gom key foo[] thành mảng).
  2. Gắn thêm export_format (từ prop) và _token (CSRF) cho POST/PUT/DELETE.
  3. Block UI, gọi $.ajax.
  4. Xử lý response chuẩn của hệ thống (status.error_code === 0):
    • Nếu data.files_url (mảng) hoặc data.file_url (legacy) có giá trị → window.open từng link (giãn 500ms để trình duyệt không chặn popup) — đây là nhánh đồng bộ, file có sẵn ngay.
    • Nếu không có URL → báo "Tiến trình xuất dữ liệu đang chạy ngầm thành công!" — nhánh bất đồng bộ, link sẽ về qua Chat.
    • error_code === 403 → "Bạn không có quyền…".

Khớp với contract back-end: controller trả files_url khi sync, hoặc chỉ trả message khi async.

Dữ liệu & trạng thái: ExportRequest

Bảng export_requests (model Modules\Entity\Models\ExportRequest):

Cột Ý nghĩa
causer_type/id Polymorphic — ai yêu cầu export
component enum ExportRequestComponent (loại export → factory map)
status enum ExportRequestStatus: PENDING(1) → PROCESSING(2) → COMPLETED(4) hoặc FAILED(3)
filters JSON params lọc, gồm export_format
files mảng đường dẫn file đã lưu trên S3
error_reason JSON chi tiết lỗi (khi FAILED)
started_at / completed_at mốc thời gian
  • files_url (accessor) sinh pre-signed URL từ files qua url_media(); hạn mặc định StorageService::URL_EXPIRATION_REPORT.
  • withFilesUrlExpiration($sec) đặt hạn một lần cho lần đọc files_url kế tiếp (tự reset sau 1 lần, không lộ qua toArray()/toJson()).

Thêm một loại export mới — checklist

  1. Thêm case vào enum Modules\Entity\Enums\ExportRequest\ExportRequestComponent.
  2. Tạo class Modules\Export\Services\Exporter\{Name}Export extends BaseExportService, override getSheets() / getFilePrefix() / getNotificationTitle(). Trong getSheets() ưu tiên trả FastExcelSheet[] với generator + lazyById().
  3. Thêm nhánh match trong ExportServiceFactory::make().
  4. Ở service domain tương ứng, gọi app(ExportService::class)->export($auth, [...]).
  5. Thêm route + controller action trả về tuỳ kiểu (PendingDispatch → message, ExportRequestfiles_url).
  6. Đặt nút <x-admin-base::buttons.export-ajax> trên view với permissionexportFormat phù hợp.

Lưu ý đặt sheet title ≤ 31 ký tự (giới hạn XLSX) và giữ nguyên chuỗi tiếng Việt trong header/label theo đúng yêu cầu nghiệp vụ.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí