วิธีส่งไฟล์ Excel ให้ผู้ใช้ใน Laravel ตามขนาดของรายงาน

รายงานเล็กดาวน์โหลดใน request ได้เลย รายงานใหญ่ที่เขียนแบบ streaming ใช้ streamDownload ส่วนงานที่ใช้เวลาหลายนาทีให้ส่งเข้าคิวแล้วแจ้งผู้ใช้เมื่อไฟล์พร้อม พร้อมตั้ง timeout ให้เรียงกันและตั้งชื่อไฟล์ให้ถูก

เผยแพร่
เวลาอ่าน
6 นาที
ในโพสต์นี้ 9 หัวข้อ
  1. สามทางที่ไฟล์เดินไปถึงผู้ใช้
  2. หน้าเว็บรอได้ไม่นานอย่างที่คิด
  3. streamDownload ส่งไฟล์ออกไประหว่างเขียน
  4. ส่งเข้าคิวแล้วแจ้งผู้ใช้เมื่อไฟล์พร้อม
  5. ตั้ง timeout ของคิวให้เรียงกัน
  6. memory ของ job และงานที่ตายกลางทาง
  7. ตั้งชื่อไฟล์ให้เปิดได้ทุกเครื่อง
  8. ลบไฟล์เก่าตามรอบ
  9. ปุ่ม export ที่พังเมื่อระบบโตขึ้น

ปุ่ม Export ที่ใช้ได้ดีตอนข้อมูลยังน้อย มักเริ่มมีปัญหาเมื่อระบบใช้ไปสักปี กดแล้วหน้าเว็บหมุนนาน สุดท้ายได้หน้า 504 หรือไฟล์ที่เปิดไม่ขึ้น ส่วนใหญ่ไม่ได้มาจากตัว package ที่ใช้เขียนไฟล์ แต่มาจากทางที่ไฟล์เดินไปถึงผู้ใช้ ซึ่งมีให้เลือกอยู่สามทาง

สามทางที่ไฟล์เดินไปถึงผู้ใช้

รายงานแบบไหน ทางที่เหมาะ ผู้ใช้เห็นอะไร
ไม่กี่พันแถว เสร็จในไม่กี่วินาที ดาวน์โหลดใน request (Excel::download, response()->download) กดแล้วได้ไฟล์ทันที
หลายหมื่นแถว แต่เขียนทีละแถวได้ และเสร็จก่อนหน้าเว็บหมดเวลา streamDownload ส่งออกไปตอนเขียน ไฟล์เริ่มดาวน์โหลดเร็ว แล้วค่อย ๆ โหลดจนจบ
ใช้เวลาหลายนาที ดึงข้อมูลจากระบบอื่น หรือรายงานประจำที่ตั้งเวลาไว้ ส่งเข้าคิว แล้วแจ้งเมื่อเสร็จ ได้ข้อความว่ากำลังทำ แล้วได้ลิงก์ อีเมล หรือไฟล์ในโฟลเดอร์กลาง

ทางแรกง่ายสุดและพอสำหรับรายงานส่วนใหญ่ ปัญหาจะเริ่มเมื่อเวลาที่ใช้สร้างไฟล์ยาวกว่าที่หน้าเว็บรอได้

หน้าเว็บรอได้ไม่นานอย่างที่คิด

request หนึ่งครั้งผ่านหลายชั้นกว่าจะถึง PHP และทุกชั้นมีเวลารอของตัวเอง ชั้นที่สั้นที่สุดคือชั้นที่ตัดก่อน

  • Cloudflare ถ้าเว็บอยู่หลัง Cloudflare จะรอคำตอบจากเซิร์ฟเวอร์ประมาณ 100 วินาที เกินนั้นผู้ใช้จะได้ error 524
  • nginx รอ PHP-FPM ตาม fastcgi_read_timeout ซึ่งค่าเริ่มต้นคือ 60 วินาที
  • PHP หยุดสคริปต์ตาม max_execution_time และหยุดเมื่อใช้ memory เกิน memory_limit

ที่ผ่านมาเราก็มักแก้ด้วยการเพิ่ม set_time_limit(0) กับ ini_set('memory_limit', -1) ไว้ในปุ่ม Export ก็ใช้ได้นะ แต่แค่ย้ายปัญหาไปที่ชั้นอื่น nginx หรือ Cloudflare จะตัดไปก่อนอยู่ดี และ -1 เปิดให้ request เดียวกิน memory จนเครื่องช้าทั้งเครื่อง เมื่อรายงานเริ่มใช้เวลาเกินครึ่งนาที ควรเปลี่ยนเป็นทางที่สองหรือสาม

streamDownload ส่งไฟล์ออกไประหว่างเขียน

streamDownload ของ Laravel ให้เราเขียนข้อมูลลง output ไปเรื่อย ๆ แทนการสร้างไฟล์ทั้งก้อนให้เสร็จก่อน (เอกสาร Laravel) เบราว์เซอร์เริ่มรับไฟล์ตั้งแต่แถวแรก ทางนี้ใช้ได้กับ writer ที่เขียนทีละแถว เช่น fputcsv, league/csv หรือ OpenSpout ส่วน PhpSpreadsheet ต้องสร้างทั้งไฟล์ใน memory ก่อนเขียนออก จึงได้ประโยชน์จากทางนี้น้อย (เรื่อง memory ของแต่ละ package อยู่ใน โพสต์เรื่อง memory ตอน export)

return response()->streamDownload(function () use ($query) {
    $writer = new \OpenSpout\Writer\XLSX\Writer();
    $writer->openToFile('php://output');

    foreach ($query->lazy() as $order) {
        $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
            $order->number, $order->customer_name, $order->total,
        ]));
    }

    $writer->close();
}, 'ยอดขาย-'.now()->format('Y-m-d-Hi').'.xlsx');

จุดที่ต้องรู้คือ streaming ช่วยเรื่อง memory แต่ไม่ได้ทำให้เวลารวมสั้นลง ถ้าทั้งไฟล์ใช้สามนาที Cloudflare ก็ยังตัดที่ 100 วินาที ผู้ใช้จะได้ไฟล์ที่ขาดกลางทางและเปิดไม่ขึ้น ใช้ทางนี้กับรายงานที่ใหญ่แต่ยังเร็ว ส่วน วิธีเขียนด้วย OpenSpout อยู่อีกโพสต์

ส่งเข้าคิวแล้วแจ้งผู้ใช้เมื่อไฟล์พร้อม

ส่ง export เข้าคิว แล้วแจ้งผู้ใช้เมื่อไฟล์เสร็จ

ปุ่ม Export แค่ส่งงานเข้าคิว แล้ว job เป็นคนเขียนไฟล์และแจ้งผู้ใช้

ปุ่ม Export จะสร้าง job แล้วตอบผู้ใช้ทันทีว่ากำลังทำรายงาน ส่วน worker ในคิวค่อยดึงข้อมูลและเขียนไฟล์ จะใช้เวลากี่นาทีก็ไม่เกี่ยวกับหน้าเว็บอีกแล้ว Laravel Excel มีให้ทั้ง Excel::queue() และ Excel::store() ใน job ของเราเอง (เอกสาร Laravel Excel)

พอไฟล์เสร็จ ทางแจ้งผู้ใช้ที่เราใช้บ่อยมีสี่แบบ

  • ลิงก์ดาวน์โหลดที่หมดอายุเอง สร้างด้วย URL::temporarySignedRoute() หรือ Storage::temporaryUrl() (signed URL) ลิงก์ส่งต่อให้คนนอกไม่ได้นานเกินกำหนด
  • แนบไฟล์ไปกับอีเมล เหมาะกับไฟล์ไม่กี่ MB ไฟล์ใหญ่ส่งเป็นลิงก์ดีกว่า เพราะ mail server ส่วนมากจำกัดขนาดไฟล์แนบ
  • วางไฟล์ในโฟลเดอร์กลาง เช่น SharePoint หรือแชร์ไดรฟ์ของบริษัท เหมาะกับรายงานประจำที่หลายฝ่ายเปิดดู
  • บอกในหน้าเว็บว่าไฟล์พร้อมแล้ว ให้หน้าเว็บ poll ถามสถานะทุกไม่กี่วินาที (Livewire มี wire:poll และ Inertia มี polling) พอเสร็จก็เริ่มดาวน์โหลดให้เลย

อย่างระบบรายงานจาก SAP ที่เราทำให้ลูกค้า รายงานส่วนใหญ่รันตามเวลาที่แต่ละฝ่ายตั้งไว้ แล้ววางไฟล์ลง SharePoint พร้อมส่งอีเมล ส่วนรอบทดสอบที่ผู้ใช้กดเอง หน้าเว็บจะถามสถานะทุก 2 วินาทีแล้วดาวน์โหลดให้เมื่อเสร็จ ถ้าอยากเห็นภาพรวมของระบบ ลองอ่านต่อที่ ระบบรายงานจาก SAP Business One ได้ครับ

ตั้ง timeout ของคิวให้เรียงกัน

พอย้ายงานเข้าคิว เวลาที่ต้องดูก็ย้ายมาอยู่ฝั่ง worker แทน ค่าที่เกี่ยวข้องมีสี่ชั้น และต้องเรียงจากสั้นไปยาว

ชั้น ตั้งที่ หน้าที่
HTTP call ไปยังระบบที่ดึงข้อมูล Http::timeout() ตัดเมื่อระบบปลายทางตอบช้า
job $timeout ใน job ตัด job ที่ทำนานเกิน
worker --timeout หรือค่าใน Horizon ค่าเริ่มต้นของทุก job ใน worker นั้น
connection ของคิว retry_after ใน config/queue.php เวลาที่คิวถือว่า job หายไปแล้วปล่อยให้ worker อื่นหยิบซ้ำ

เอกสาร Laravel เตือนว่า --timeout ต้องสั้นกว่า retry_after อย่างน้อยหลายวินาที (Job Expirations and Timeouts) ถ้ากลับกัน job ที่ยังทำไม่เสร็จจะถูกหยิบไปทำซ้ำอีกรอบ รายงานเดียวกันออกมาสองไฟล์ หรือส่งอีเมลซ้ำ และ $timeout ของ job จะแทนค่าของ worker เสมอ งานที่รู้ว่านานควรตั้งไว้ที่ตัว job เลย

memory ของ job และงานที่ตายกลางทาง

รายงานแต่ละตัวใช้ memory ไม่เท่ากัน เราจึงตั้ง memory_limit ราย job ใน handle() แทนการเพิ่มค่าทั้งเครื่อง เช่น ค่าเริ่มต้น 512M แล้วค่อยเพิ่มเป็น 1G หรือ 2G เฉพาะรายงานที่วัดแล้วว่าต้องใช้ ข้อดีคือรายงานที่ผิดปกติจะชนเพดานของตัวเองแล้วหยุดพร้อม error ที่อ่านออก แทนที่จะกิน memory จนระบบปฏิบัติการสั่งปิด process

ระวังกรณีที่ process ถูกปิดจากข้างนอก เช่น ระบบปฏิบัติการปิดเพราะ memory เต็ม หรือ worker ถูกรีสตาร์ตกลางงาน กรณีนี้ Laravel ไม่มีโอกาสเรียก failed() ถ้าเราเก็บสถานะรายงานไว้ในฐานข้อมูลว่า "กำลังทำ" สถานะนั้นจะค้างไปตลอด ทางแก้คือตั้ง scheduled command เล็ก ๆ ที่ไล่หารายการที่ "กำลังทำ" นานเกิน timeout ของ job แล้วเปลี่ยนเป็นล้มเหลว ผู้ใช้จะได้รู้ว่าต้องกดใหม่

รายงานประจำที่ตั้งเวลาไว้ควรใส่ middleware WithoutOverlapping ด้วย (Preventing Job Overlaps) ถ้ารอบก่อนยังไม่เสร็จ รอบใหม่จะรอแทนที่จะเขียนไฟล์เดียวกันซ้อนกัน

ตั้งชื่อไฟล์ให้เปิดได้ทุกเครื่อง

ชื่อไฟล์ภาษาไทยใช้ได้ Laravel ใส่ให้ทั้ง filename แบบ ASCII ไว้ให้โปรแกรมรุ่นเก่า และ filename* แบบ UTF-8 ตาม RFC 6266 ให้เบราว์เซอร์ปัจจุบัน ตอนลองกับ streamDownload ชื่อ "รายงานยอดขาย ต.ค. 2569.xlsx" ได้ header แบบนี้

attachment; filename=" .. 2569.xlsx"; filename*=utf-8''%E0%B8%A3%E0%B8%B2...2569.xlsx

เบราว์เซอร์ใช้ filename* จึงเห็นชื่อไทยครบ แต่ส่วน ASCII ตัดภาษาไทยทิ้งหมด ถ้าไฟล์ต้องผ่านโปรแกรมอื่นที่อ่านแค่ filename ให้ใส่รหัสหรือวันที่ภาษาอังกฤษไว้ในชื่อด้วย อีกสามเรื่องที่เจอบ่อยมีดังนี้

  • อักขระที่ Windows ไม่ยอม ได้แก่ \ / : * ? " < > | เช่น ชื่อรายงานที่มี "ยอด > 1 ล้าน" จะเซฟลงเครื่องไม่ได้ ให้แทนด้วยคำอื่นก่อนตั้งชื่อ
  • h กับ H ในรูปแบบเวลา h คือนาฬิกา 12 ชั่วโมง ไฟล์ที่ออกตอน 09:00 กับ 21:00 จึงชื่อเหมือนกันและเรียงผิด ใช้ H เสมอ
  • เติมนามสกุลด้วย Str::finish() อย่าใช้ rtrim($name, '.xlsx') เพราะ rtrim ตัดตัวอักษร . x l s ทุกตัวที่อยู่ท้ายชื่อ ไม่ใช่ตัดคำว่า .xlsx ชื่อที่ลงท้ายด้วย s จะหายไปหนึ่งตัวโดยไม่มีใครสังเกต Str::of($name)->finish('.xlsx') เติมให้เฉพาะเมื่อยังไม่มี

ลบไฟล์เก่าตามรอบ

ไฟล์รายงานที่เก็บไว้ให้ดาวน์โหลดสะสมเร็วกว่าที่คิด ถ้าไม่มีใครลบ ดิสก์จะเต็มในวันที่ไม่มีใครนึกถึง ตั้ง scheduled command ลบไฟล์ที่เก่ากว่ากำหนด เช่น 7 หรือ 30 วัน ตามที่ผู้ใช้ต้องย้อนดู ถ้าเก็บประวัติรายงานไว้ในตาราง ให้ลบไฟล์พร้อมแถวนั้นด้วย model:prune ส่วนไฟล์ที่ดาวน์โหลดทันทีจากทางแรก ใช้ ->deleteFileAfterSend() ให้ Laravel ลบหลังส่งเสร็จ

ปุ่ม export ที่พังเมื่อระบบโตขึ้น

  • รายงานที่เคยเร็วเริ่มช้าเมื่อข้อมูลโต ปุ่มเดิมที่ดาวน์โหลดตรงเคยใช้ได้ วันหนึ่งผู้ใช้เลือกช่วงวันที่ย้อนหลังหลายปีแล้วได้ 504 ทางเลือกคือจำกัดช่วงวันที่ที่เลือกได้ หรือให้ช่วงยาวต้องส่งทางอีเมลแทน
  • สองทางของปุ่มเดียวกันใช้ค่าไม่ตรงกัน ปุ่มที่มีทั้ง "ดาวน์โหลดเลย" และ "ส่งเข้าอีเมล" ควรส่งค่าจากฟอร์มไปให้ export ตัวเดียวกัน ถ้าแยกเขียนสองที่ วันหนึ่งทางใดทางหนึ่งจะลืมใช้ช่วงวันที่ที่ผู้ใช้เลือก
  • รายงานสองตัวเขียนชื่อไฟล์ชั่วคราวเดียวกัน ถ้ารายงานคนละตัวใช้ pattern ชื่อไฟล์เดียวกันแล้วรันพร้อมกัน ไฟล์ของตัวหนึ่งจะทับหรือถูกย้ายไปก่อนอีกตัวเขียนเสร็จ ใส่ชื่อรายงานหรือ id ของรอบที่รันไว้ในชื่อไฟล์ที่ทำงานอยู่ด้วย

ตอนถัดไปของซีรีส์ Export Excel และ CSV จาก Laravel

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →