เว็บในยุคแรกทำงานด้วยข้อความธรรมดา (plain text) คุณพิมพ์ชื่อผู้ใช้หรือความคิดเห็นลงในฟอร์ม กดส่ง แล้วสตริงสั้นๆ ก็จะเดินทางไปยังเซิร์ฟเวอร์ผ่าน HTTP วงจร request-response ที่เรียบง่ายนั้นคือสิ่งที่กำหนดโครงสร้างพื้นฐาน เมื่อผู้คนเริ่มต้องการแชร์รูปภาพ เอกสาร และวิดีโอ วิศวกรจึงต้องหาวิธีเคลื่อนย้ายข้อมูลไบนารีดิบ (raw binary data) ผ่านระบบที่ถูกสร้างขึ้นมาเพื่อรองรับข้อความที่อ่านออกเท่านั้น

หากคุณพยายามใส่ไฟล์ JPEG ลงในออบเจกต์ JSON คุณจะพบกับกำแพงสำคัญ JSON เป็นโปรโตคอลแบบข้อความ ซึ่งคาดหวังอักขระ Unicode ที่ถูกต้อง เครื่องหมายคำพูด ปีกกา และสตริงที่มีการ escape อย่างเหมาะสม แต่ไฟล์ไบนารีเป็นเพียงลำดับของไบต์ที่ยาวเหยียด ซึ่งหลายไบต์ไม่มีรูปแบบที่สามารถพิมพ์ออกมาได้ (printable representation) หากคุณยัดไบต์เหล่านั้นลงในสตริง JSON ตัว parser จะพัง ลำดับการ escape จะทำให้ข้อมูลเสียหาย (corrupt) และข้อความทั้งหมดจะกลายเป็นสิ่งที่อ่านไม่ออกเมื่อไปถึงปลายทาง

การเข้ารหัสแบบ Base64 จึงกลายเป็นทางออกที่เห็นได้ชัด มันจะเปลี่ยนข้อมูลไบนารีให้กลายเป็นชุดอักขระ ASCII ที่พิมพ์ได้จำนวน 64 ตัว ทุกๆ 3 ไบต์ของไบนารีจะกลายเป็นข้อความ 4 ตัวอักษร ตอนนี้ payload จะกลายเป็น JSON ที่ถูกต้องตามมาตรฐาน ซึ่งหมายความว่ามันจะสามารถเดินทางผ่าน API มาตรฐานใดๆ ก็ได้ แต่ก็มีราคาที่ต้องจ่ายทันที การเข้ารหัสใหม่นี้จะทำให้ขนาดไฟล์เพิ่มขึ้นประมาณ 33% รูปภาพขนาด 3 เมกะไบต์จะกลายเป็น 4 เมกะไบต์เมื่อส่งผ่านเครือข่าย ทั้งฝั่ง client และ server ต้องใช้รอบการทำงานของ CPU เพิ่มขึ้นในการแปลงข้อมูลกลับไปกลับมา ที่สำคัญกว่านั้น เฟรมเวิร์กของเซิร์ฟเวอร์ JSON หลายตัวจะอ่าน body ทั้งหมดลงในหน่วยความจำก่อนที่จะทำการ parse การอัปโหลดไฟล์ขนาดใหญ่พร้อมกันเพียงไม่กี่ไฟล์ก็อาจทำให้เซิร์ฟเวอร์ขนาดกลางรับไม่ไหว เพราะแต่ละไฟล์จะถูกถือไว้ใน RAM ในรูปแบบของสตริงข้อความที่มีขนาดใหญ่เกินจริงก่อนที่จะถูกบันทึกลงดิสก์ Base64 ใช้แก้ขัดได้ในยามคับขัน แต่มันไม่ได้ถูกออกแบบมาเพื่อรองรับไฟล์ขนาดใหญ่ในระดับการใช้งานจริง (production scale)

คำตอบที่ดีกว่าคือ multipart/form-data รูปแบบนี้จะปฏิบัติกับ HTTP request หนึ่งรายการเสมือนเป็นกลุ่มของส่วนประกอบที่แยกจากกัน โดยแต่ละส่วนจะถูกแบ่งด้วยสตริง boundary ที่ไม่ซ้ำกัน ส่วนหนึ่งอาจเก็บฟิลด์ข้อความธรรมดา ส่วนถัดไปอาจเก็บรูปภาพไบนารีดิบ ซึ่งระบุด้วย Content-Type และ Content-Disposition headers ของตัวเอง เซิร์ฟเวอร์จะอ่าน stream ที่เข้ามาตามลำดับ โดยคอยตรวจจับเครื่องหมาย boundary และส่งแต่ละส่วนไปยัง handler ที่เหมาะสม โดยไม่จำเป็นต้องปฏิบัติกับ payload ทั้งหมดเป็นบล็อกข้อความเดียว

ใน Node.js ความแตกต่างนี้มีความสำคัญอย่างยิ่ง middleware อย่าง express.json() รู้วิธีการ parse JSON bodies แต่ไม่สามารถจัดการ file streams ได้ ในการประมวลผล multipart uploads คุณจำเป็นต้องใช้ streaming parser อย่าง Multer หรือ Busboy เครื่องมือเหล่านี้จะเชื่อมต่อกับ request stream ดิบและอ่านข้อมูลแบบ chunk by chunk ตัวอย่างเช่น Multer ช่วยให้คุณเลือกได้ว่าจะเขียนไฟล์ที่เข้ามาลงในโฟลเดอร์ชั่วคราวบนดิสก์ หรือจะเก็บไฟล์ขนาดเล็กไว้ในหน่วยความจำ การตัดสินใจเรื่องการกำหนดค่านี้มีความสำคัญมาก หากคุณเก็บทุกอย่างไว้ในหน่วยความจำและแอปพลิเคชันของคุณได้รับไฟล์ขนาดใหญ่หลายไฟล์พร้อมกัน กระบวนการ (process) ของคุณอาจใช้พื้นที่ heap จนหมดและแครชได้ การเขียนลงดิสก์เป็นการแลก I/O เพื่อความเสถียร แต่ก็มาพร้อมกับคำถามเรื่องการล้างข้อมูล (cleanup) และความปลอดภัยของเส้นทางไฟล์ (path security)

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

ทันทีที่คุณวาง load balancer ไว้หน้า application server เครื่องที่สอง การเก็บข้อมูลแบบ local storage จะกลายเป็นบั๊กทันที ผู้ใช้คนหนึ่งอัปโหลดรูปโปรไฟล์ Load balancer ส่ง request ไปยัง Server A และไฟล์ถูกเขียนลงในดิสก์ของ Server A ต่อมา ผู้ใช้คนเดิมต้องการดูรูปภาพ แต่ load balancer ส่ง request ไปยัง Server B ซึ่ง Server B ตรวจสอบระบบไฟล์ของตัวเองแล้วไม่พบอะไรเลย ไฟล์นั้นจึงหายไปโดยปริยาย คุณอาจใช้ sticky sessions เพื่อล็อกผู้ใช้ไว้กับเครื่องเดิม แต่การแก้ปัญหานี้ก็เปราะบาง หาก Server A เริ่มทำงานใหม่ (restart), ถูก deploy ใหม่ หรือถูกแทนที่ด้วย instance จากการทำ autoscaling ข้อมูลก็จะหายไป ในสภาพแวดล้อมแบบ containerized ดิสก์ท้องถิ่นยิ่งมีความไม่แน่นอนสูงขึ้นไปอีก เพราะระบบไฟล์ของ Docker container ถูกออกแบบมาให้ทิ้งได้ (disposable) การปฏิบัติกับมันเสมือนเป็นที่เก็บข้อมูลถาวรจึงเป็นวิธีที่รับประกันได้เลยว่าจะทำให้ข้อมูลผู้ใช้สูญหาย

วิธีแก้ปัญหามาตรฐานคือการแยกส่วนประมวลผล (compute) ออกจากส่วนจัดเก็บข้อมูล (storage) คุณควรทำให้ application server ของคุณเป็นแบบ stateless และส่งไฟล์ที่อัปโหลดไปยัง object storage โดยเฉพาะ เช่น AWS S3 หรือ Google Cloud Storage บริการเหล่านี้ถูกสร้างขึ้นเพื่อความทนทาน (durability), การกระจายตัวทางภูมิศาสตร์ และการรองรับการทำงานพร้อมกันจำนวนมหาศาล Application server จะทำหน้าที่จัดการ request, ตรวจสอบ metadata และส่งต่อไบต์เหล่านั้นไปยังโครงสร้างพื้นฐานที่ออกแบบมาเพื่อจัดเก็บข้อมูลโดยเฉพาะ

อย่างไรก็ตาม รูปแบบนี้ยังคงสร้างคอขวดหากคุณนำไปใช้งานอย่างไม่ระมัดระวัง หลายทีมเริ่มต้นด้วยการให้เบราว์เซอร์อัปโหลดไฟล์ไปยัง backend จากนั้นจึงให้ backend ส่งต่อทุกไบต์ไปยัง object storage หากผู้ใช้อัปโหลดวิดีโอขนาด 500 เมกะไบต์ เซิร์ฟเวอร์ของคุณจะกลายเป็นตัวกลาง มันใช้แบนด์วิดท์ในการดึงไฟล์เข้ามา จากนั้นก็ใช้แบนด์วิดท์เพิ่มขึ้นในการส่งไฟล์ออกไปยัง S3 การเชื่อมต่อจะเปิดค้างไว้ตลอดระยะเวลาการถ่ายโอนข้อมูล การอัปโหลดที่ล่าช้าจากผู้ใช้ที่มีสภาพเครือข่ายไม่ดีสามารถยึดครองการเชื่อมต่อของเซิร์ฟเวอร์ได้นานหลายนาที การใช้งานหน่วยความจำจะยังคงสูงอยู่หากเซิร์ฟเวอร์ทำการบัฟเฟอร์สตรีม และหากคุณใช้งานบนโฮสติ้งแบบคิดค่าบริการตามการใช้งาน (metered hosting) คุณจะต้องจ่ายเงินสองเท่าสำหรับการถ่ายโอนข้อมูลชุดเดียวกัน การทำ Horizontal scaling ไม่สามารถแก้ปัญหานี้ได้ เพราะทุกเซิร์ฟเวอร์ที่คุณเพิ่มเข้ามายังคงต้องติดอยู่กับการขนส่งไบต์ที่มันไม่จำเป็นต้องเห็นอยู่ดี

ระบบสมัยใหม่แก้ปัญหานี้โดยการนำ backend ออกจากเส้นทางการรับส่งข้อมูล (data path) โดยสิ้นเชิง แทนที่จะรับไฟล์ backend จะรับเพียงคำขอเพื่อขออนุญาตในการอัปโหลดเท่านั้น ขั้นตอนการทำงานเป็นดังนี้:

  • เบราว์เซอร์ขอให้ backend เริ่มต้นการอัปโหลด โดยปกติจะส่งเพียงชื่อไฟล์ ประเภทไฟล์ และวัตถุประสงค์ที่ต้องการเท่านั้น
  • backend ทำการยืนยันตัวตนผู้ใช้ ตรวจสอบคำขอตามกฎทางธุรกิจ และใช้ SDK เพื่อสร้าง presigned URL ชั่วคราวจากผู้ให้บริการ object storage
  • backend ส่ง URL นั้นกลับไปยังเบราว์เซอร์ โดย URL จะถูกจำกัดขอบเขตไว้ที่ bucket และ key ที่เฉพาะเจาะจง มีอายุการใช้งานสั้นๆ เช่น 5 หรือ 15 นาที และลงลายมือชื่อด้วย token ที่ให้สิทธิ์เฉพาะที่จำเป็นเท่านั้น
  • เบราว์เซอร์อัปโหลดไฟล์ไปยัง S3 หรือ GCS โดยตรงโดยใช้ PUT หรือ POST มาตรฐาน ไบต์จะเดินทางตรงจากอุปกรณ์ของผู้ใช้ไปยัง