وب اولیه بر پایه متن ساده بود. شما یک نام کاربری یا یک کامنت را در یک فرم تایپ میکردید، دکمه ارسال را میزدید و یک رشته کوتاه از طریق HTTP به سرور منتقل میشد. آن چرخه ساده درخواست-پاسخ، زیرساخت را تعریف میکرد. وقتی مردم خواستند عکس، سند و ویدیو به اشتراک بگذارند، مهندسان باید راهی پیدا میکردند تا دادههای باینری خام را از طریق سیستمی که کاملاً برای متنهای خوانا ساخته شده بود، منتقل کنند.
اگر سعی کنید یک JPEG را درون یک شیء JSON قرار دهید، به یک دیوار بنیادی برخورد میکنید. JSON یک پروتکل متنی است. این پروتکل انتظار کاراکترهای معتبر Unicode، کوتیشنها، آکولادها و رشتههای با اسکیپ (escape) صحیح را دارد. یک فایل باینری صرفاً دنبالهای طولانی از بایتهاست که بسیاری از آنها نمایش قابل چاپ ندارند. اگر این بایتها را به زور در یک رشته JSON جا دهید، پارسر (parser) از کار میافتد، توالیهای اسکیپ (escape sequences) محموله (payload) را فاسد میکنند و کل پیام در سمت دیگر غیرقابل خواندن میشود.
کدگذاری Base64 به عنوان یک راه حل جایگزین بدیهی پدیدار شد. این روش دادههای باینری را به مجموعه محدودی از ۶۴ کاراکتر قابل چاپ ASCII نگاشت میکند. هر سه بایت باینری به چهار کاراکتر متنی تبدیل میشود. حالا محموله یک JSON معتبر است، به این معنی که در هر API استانداردی بدون مشکل منتقل میشود. اما هزینه آن فوری است. این بازکدگذاری (re-encoding) حجم فایل را تقریباً ۳۳ درصد افزایش میدهد. یک تصویر سه مگابایتی در حین انتقال به چهار مگابایت تبدیل میشود. هم کلاینت و هم سرور چرخههای CPU اضافی را برای ترجمه رفت و برگشتی دادهها مصرف میکنند. مهمتر از آن، بسیاری از فریمورکهای سرور JSON کل بدنه را قبل از تجزیه (parsing) در حافظه میخوانند. تعداد کمی آپلود بزرگ همزمان میتواند یک سرور معمولی را از پا درآورد، زیرا هر کدام قبل از اینکه حتی در دیسک ذخیره شوند، به عنوان یک رشته متنی سنگین در RAM نگه داشته میشوند. Base64 در مواقع اضطراری کار میکند، اما هرگز برای حمل فایلهای سنگین در مقیاس تولید (production scale) طراحی نشده است.
پاسخ بهتر، multipart/form-data است. این فرمت با یک درخواست HTTP واحد به عنوان مجموعهای از بخشهای مجزا برخورد میکند که هر بخش با یک رشته مرزی (boundary string) منحصر به فرد از هم جدا شده است. یک بخش ممکن است یک فیلد متن ساده باشد. بخش بعدی ممکن است یک تصویر باینری خام باشد که با هدرهای Content-Type و Content-Disposition مخصوص به خود مشخص شده است. سرور جریان ورودی را به صورت متوالی میخواند، منتظر نشانگرهای مرزی میماند و هر بخش را بدون نیاز به برخورد با کل محموله به عنوان یک بلوک متنی واحد، به هندلر (handler) مناسب تحویل میدهد.
در Node.js، این تمایز بسیار مهم است. میانافزار express.json() میداند چگونه بدنه JSON را تجزیه کند، اما جریانهای فایل (file streams) را مدیریت نمیکند. برای پردازش آپلودهای multipart، به یک پارسر جریانی (streaming parser) مانند Multer یا Busboy نیاز دارید. این ابزارها به جریان درخواست خام متصل شده و آن را تکه به تکه (chunk by chunk) میخوانند. برای مثال، Multer به شما اجازه میدهد انتخاب کنید که فایلهای ورودی را در یک پوشه موقت روی دیسک بنویسید یا فایلهای کوچکتر را در حافظه نگه دارید. این تصمیم در پیکربندی اهمیت دارد. اگر همه چیز را در حافظه نگه دارید و اپلیکیشن شما ناگهان چندین فایل بزرگ را همزمان دریافت کند، ممکن است فضای Heap فرآیند شما تمام شده و کرش کند. نوشتن روی دیسک، عملکرد I/O را فدای پایداری میکند، اما سوالات مربوط به پاکسازی و امنیت مسیر (path security) را ایجاد میکند.
برای یک اپلیکیشن کوچک، ذخیره فایلها در یک پوشه محلی مانند ./uploads طبیعی و سریع به نظر میرسد. فایل روی همان ماشینی قرار میگیرد که کد شما را اجرا میکند و سرو کردن آن نیز فقط با اشاره به مسیر درست امکانپذیر است. این روش تا زمانی که کار میکند، خوب است، اما زمانی که کار نمیکند، مشکلساز میشود.
لحظهای که یک لود بالانسر (load balancer) را در مقابل سرور اپلیکیشن دوم قرار میدهید، ذخیرهسازی محلی به یک باگ تبدیل میشود. یک کاربر عکس پروفایل آپلود میکند. لود بالانسر درخواست را به سرور A هدایت میکند و فایل روی دیسک سرور A نوشته میشود. بعداً، همان کاربر میخواهد تصویر را مشاهده کند، اما لود بالانسر درخواست را به سرور B میفرستد. سرور B فایل سیستم خود را بررسی میکند و چیزی پیدا نمیکند. فایل عملاً مفقود شده است. میتوانید از sticky sessions برای متصل کردن یک کاربر به یک ماشین مشخص استفاده کنید، اما این یک راه حل شکننده است. اگر سرور A ریاستارت شود، دوباره مستقر (redeploy) شود یا با یک نمونه autoscaling جایگزین شود، دادهها از بین میروند. در محیطهای کانتینری، دیسکهای محلی حتی گذراتر (ephemeral) هستند. فایل سیستم یک کانتینر Docker برای دور ریختن طراحی شده است. برخورد با آن به عنوان ذخیرهسازی دائمی، راهی مطمئن برای از دست دادن دادههای کاربر است.
راه حل استاندارد، جدا کردن بخش محاسبات (compute) از ذخیرهسازی (storage) است. شما سرورهای اپلیکیشن خود را بدون وضعیت (stateless) نگه میدارید و فایلهای آپلود شده را به ذخیرهسازی اشیاء (object storage) اختصاصی مانند AWS S3 یا Google Cloud Storage میفرستید. این سرویسها برای پایداری، توزیع جغرافیایی و همزمانی (concurrency) عظیم ساخته شدهاند. سرور اپلیکیشن درخواست را مدیریت میکند، متادیتا را اعتبارسنجی میکند و سپس بایتها را به زیرساختی تحویل میدهد که مخصوص نگهداری آنها طراحی شده است.
با این حال، حتی این الگو نیز اگر با بیدقتی پیادهسازی شود، یک گلوگاه ایجاد میکند. بسیاری از تیمها با این روش شروع میکنند که مرورگر فایل را به بکاند آپلود کند و سپس بکاند تکتک بایتها را به فضای ذخیرهسازی اشیاء (object storage) ارسال کند. اگر کاربری یک ویدیوی ۵۰۰ مگابایتی را آپلود کند، سرور شما به یک واسطه تبدیل میشود. سرور پهنای باند را برای دریافت فایل مصرف میکند و سپس پهنای باند بیشتری را برای ارسال آن به S3 مصرف میکند. اتصال در تمام مدت انتقال باز میماند. آپلودهای کند از سوی کاربرانی با شرایط شبکه ضعیف میتواند برای چندین دقیقه اتصالات سرور را اشغال کند. اگر سرور جریان (stream) را بافر کند، میزان مصرف حافظه بالا میماند و اگر از میزبانی با نرخ مصرف (metered hosting) استفاده میکنید، بابت یک انتقال داده، دو بار هزینه پرداخت میکنید. مقیاسپذیری افقی (Horizontal scaling) این مشکل را حل نمیکند، زیرا هر سرور اضافهای که اضافه میکنید، همچنان درگیر جابهجایی بایتهایی میشود که نیازی به دیدن آنها ندارد.
سیستمهای مدرن این مشکل را با حذف کامل بکاند از مسیر دادهها حل میکنند. بکاند به جای دریافت فایل، فقط درخواستی برای اجازه آپلود را میپذیرد. جریان کار به این صورت است:
- مرورگر از بکاند میخواهد که یک آپلود را شروع کند، که معمولاً فقط نام فایل، نوع فایل و هدف مورد نظر را ارسال میکند.
- بکاند کاربر را احراز هویت کرده، درخواست را بر اساس قوانین کسبوکار اعتبارسنجی میکند و از یک SDK برای تولید یک presigned URL موقت از ارائهدهنده فضای ذخیرهسازی اشیاء استفاده میکند.
- بکاند آن URL را به مرورگر بازمیگرداند. این URL به یک bucket و key خاص محدود شده، برای بازه زمانی کوتاهی مانند ۵ یا ۱۵ دقیقه اعتبار دارد و با توکنی امضا شده است که فقط مجوزهای دقیق مورد نیاز را اعطا میکند.
- مرورگر فایل را مستقیماً با استفاده از یک PUT یا POST استاندارد به S3 یا GCS آپلود میکند. بایتها مستقیماً از دستگاه کاربر به
