Erken dönem web, düz metin üzerine kuruluydu. Bir forma kullanıcı adı veya yorum yazar, gönder butonuna basar ve HTTP üzerinden sunucuya kısa bir dize gönderirdiniz. Bu basit istek-yanıt döngüsü altyapıyı tanımlıyordu. İnsanlar fotoğraf, belge ve video paylaşmak istemeye başladığında, mühendislerin tamamen okunabilir metin için inşa edilmiş bir sistem aracılığıyla ham ikili (binary) veriyi nasıl taşıyacaklarını bulmaları gerekiyordu.
Bir JPEG dosyasını bir JSON nesnesinin içine yerleştirmeye çalışırsanız, temel bir engelle karşılaşırsınız. JSON bir metin protokolüdür. Geçerli Unicode karakterleri, tırnaklar, parantezler ve düzgün şekilde kaçış dizileriyle (escaped strings) işlenmiş dizeler bekler. İkili bir dosya, birçoğunun yazdırılabilir bir temsili olmayan uzun bir bayt dizisinden ibarettir. Bu baytları bir JSON dizesinin içine tıkıştırırsanız ayrıştırıcı (parser) bozulur, kaçış dizileri veri yükünü (payload) bozar ve tüm mesaj karşı tarafta okunamaz hale gelir.
Base64 kodlaması bariz bir çözüm yolu olarak ortaya çıktı. İkili veriyi altmış dört adet yazdırılabilir ASCII karakterinden oluşan sınırlı bir kümeye yeniden eşler. Her üç baytlık ikili veri, dört karakterlik bir metne dönüşür. Veri yükü artık yasal bir JSON'dır, yani herhangi bir standart API üzerinden sorunsuz geçecektir. Ancak bunun maliyeti anında hissedilir. Bu yeniden kodlama, dosya boyutunu yaklaşık yüzde otuz üç oranında artırır. Üç megabaytlık bir görüntü, hat üzerinde dört megabayta çıkar. Hem istemci hem de sunucu, veriyi ileri geri çevirmek için fazladan CPU döngüsü harcar. Daha da önemlisi, birçok JSON sunucu çerçevesi (framework), ayrıştırmadan önce tüm gövdeyi belleğe okur. Birkaç eşzamanlı büyük yükleme, mütevazı bir sunucuyu felç edebilir; çünkü her biri diske kaydedilmeden önce bellekte (RAM) aşırı büyük bir metin dizesi olarak tutulur. Base64 zor anlarda işe yarar ancak hiçbir zaman üretim ölçeğinde ağır dosyaları taşımak için tasarlanmamıştır.
Daha iyi cevap multipart/form-data formatıdır. Bu format, tek bir HTTP isteğini, her biri benzersiz bir sınır (boundary) dizesiyle ayrılmış ayrı parçalar koleksiyonu olarak ele alır. Bir parça düz metin alanı içerebilir. Bir sonraki parça, kendi Content-Type ve Content-Disposition başlıklarıyla işaretlenmiş ham bir ikili görüntü içerebilir. Sunucu, gelen akışı (stream) sınır işaretçilerini izleyerek sırayla okur ve tüm veri yükünü tek bir metin bloğu olarak ele almaya gerek duymadan her bölümü ilgili işleyiciye (handler) devreder.
Node.js'de bu ayrım özellikle önemlidir. express.json() ara yazılımı (middleware) JSON gövdelerini ayrıştırmayı bilir ancak dosya akışlarını (file streams) yönetmez. Çok parçalı (multipart) yüklemeleri işlemek için Multer veya Busboy gibi bir akış ayrıştırıcısına (streaming parser) ihtiyacınız vardır. Bu araçlar ham istek akışına bağlanır ve onu parça parça (chunk by chunk) okur. Örneğin Multer, gelen dosyaları diskteki geçici bir klasöre mi yazacağınızı yoksa daha küçük olanları bellekte mi tutacağınızı seçmenize olanak tanır. Bu yapılandırma kararı önemlidir. Eğer her şeyi bellekte tutarsanız ve uygulamanız aniden birkaç büyük dosya alırsa, işleminiz yığın alanı (heap space) yetersizliğinden dolayı çökebilir. Diske yazmak, kararlılık için I/O'dan (giriş/çıkış) feragat etmek demektir, ancak kendi temizleme ve yol güvenliği (path security) sorularını da beraberinde getirir.
Küçük bir uygulama için dosyaları ./uploads gibi yerel bir klasöre kaydetmek doğal ve hızlı hissettirir. Dosya, kodunuzu çalıştıran aynı makineye iner ve onu geri sunmak sadece doğru yolu göstermekten ibarettir. Bu yöntem, sorun çıkana kadar sorunsuz çalışır.
İkinci bir uygulama sunucusunun önüne bir yük dengeleyici (load balancer) yerleştirdiğiniz anda, yerel depolama bir hata (bug) haline gelir. Bir kullanıcı profil resmi yükler. Yük dengeleyici isteği Sunucu A'ya yönlendirir ve dosya Sunucu A'nın diskine yazılır. Daha sonra kullanıcı görüntüyü görüntülemek ister ancak yük dengeleyici isteği Sunucu B'ye gönderir. Sunucu B kendi dosya sistemini kontrol eder ve hiçbir şey bulamaz. Dosya aslında kayıptır. Bir kullanıcıyı aynı makineye sabitlemek için "sticky sessions" (yapışkan oturumlar) uygulayabilirsiniz ancak bu kırılgan bir çözümdür. Eğer Sunucu A yeniden başlatılırsa, yeniden dağıtılırsa veya bir otomatik ölçeklendirme örneğiyle değiştirilirse veriler yok olur. Konteynırlaştırılmış ortamlarda yerel diskler daha da geçicidir. Bir Docker konteynırının dosya sistemi tek kullanımlık olması için tasarlanmıştır. Onu kalıcı depolama olarak kullanmak, kullanıcı verilerini kaybetmenin güvenilir bir yoludur.
Standart çözüm, hesaplama (compute) ile depolamayı (storage) ayırmaktır. Uygulama sunucularınızı durumsuz (stateless) tutarsınız ve yüklenen dosyaları AWS S3 veya Google Cloud Storage gibi özel nesne depolama (object storage) servislerine gönderirsiniz. Bu servisler dayanıklılık, coğrafi dağılım ve devasa eşzamanlılık için inşa edilmiştir. Uygulama sunucusu isteği işler, meta verileri doğrular ve ardından baytları onları tutmak için özel olarak tasarlanmış altyapıya devreder.
Ancak bu desen bile, dikkatsizce uygulanırsa bir darboğaz oluşturur. Birçok ekip, tarayıcının dosyayı backend'e yüklemesi ve ardından backend'in her bir baytı nesne depolama birimine iletmesiyle işe başlar. Eğer bir kullanıcı beş yüz megabaytlık bir video yüklerse, sunucunuz bir aracı haline gelir. Dosyayı içeri çekerken bant genişliği tüketir, ardından onu S3'e gönderirken daha fazla bant genişliği tüketir. Bağlantı, transfer süresinin tamamı boyunca açık kalır. Kötü ağ koşullarına sahip kullanıcılardan gelen yavaş yüklemeler, sunucu bağlantılarını dakikalarca meşgul edebilir. Eğer sunucu akışı tamponlarsa bellek kullanımı yüksek kalır ve eğer ölçümlü bir barındırma hizmeti kullanıyorsanız, aynı veri transferi için iki kez ödeme yaparsınız. Yatay ölçeklendirme bunu çözmez, çünkü eklediğiniz her yeni sunucu yine de görmesine gerek olmayan baytları taşımakla uğraşmak zorunda kalır.
Modern sistemler, backend'i veri yolundan tamamen çıkararak bu sorunu çözer. Dosyayı kabul etmek yerine, backend yalnızca yükleme izni için bir istek kabul eder. Akış şu şekildedir:
- Tarayıcı, genellikle yalnızca dosya adını, dosya türünü ve kullanım amacını göndererek backend'den bir yükleme başlatmasını ister.
- Backend kullanıcıyı doğrular, isteği iş kurallarına göre geçerli kılar ve nesne depolama sağlayıcısından geçici bir önceden imzalanmış (presigned) URL oluşturmak için bir SDK kullanır.
- Backend bu URL'yi tarayıcıya döndürür. URL, belirli bir bucket ve key ile sınırlandırılmıştır, beş veya on beş dakika gibi kısa bir süre için geçerlidir ve yalnızca gereken kesin izinleri veren bir token ile imzalanmıştır.
- Tarayıcı, standart bir PUT veya POST kullanarak dosyayı doğrudan S3 veya GCS'ye yükler. Baytlar doğrudan kullanıcının cihazından ...'ya
