ארכיטקטורת העלאה ישירה למדיה באפליקציות מובייל: S3, GCS, R2
העלאת תמונות וסרטונים מאפליקציית מובייל היא משימה שקל לזלזל בה. בקשה אחת של סרטון 50 MB דרך פרוקסי backend יוצרת עומס עצום על השרת, מגדילה את זמן התגובה ומכפילה את השימוש בתעבורה. משתמשים נוטשים העלאות אם הן נמשכות יותר משלוש שניות. אנו מציעים ארכיטקטורה מוכחת: presigned URL → העלאה ישירה מהלקוח ל-S3 או GCS. ה-backend רק מנפיק טוקן ומקבל אישור—העבודה הכבדה מטופלת על ידי תשתית הענן. גישה זו מוכנה לשימוש מפחיתה את עומס השרת בעד 70%, מאיצה את אספקת התוכן פי 3 ומבטיחה אבטחה. הניסיון שלנו: למעלה מ-5 שנים בשילוב אחסון ענן באפליקציות iOS ו-Android, עם למעלה מ-50 פרויקטים מוצלחים.
למה להעלות מדיה ישירות ל-S3?
העלאה ישירה דרך presigned URL פותרת בעיות ביצועים ואמינות. השוו עם שיטת הפרוקסי:
| היבט | העלאה ישירה (Presigned URL) | דרך פרוקסי backend |
|---|---|---|
| עומס שרת | מינימלי (הנפקת טוקן בלבד) | גבוה (כל התעבורה דרך השרת) |
| מהירות משתמש | גבוהה (CDN edge) — עד פי 10 מהר יותר | בינונית (תלויה בשרת) |
| קנה מידה | אוטומטי (ענן) | דורש משאבים |
| אבטחה | טוקן מוגבל בזמן | מפתחות על השרת |
אנו ממליצים בחום על העלאה ישירה למדיה—היא מפחיתה עלויות תשתית ומשפרת את ההיענות של האפליקציה. תיעוד רשמי של AWS: Presigned URLs.
השוואת ספקי אחסון ענן
| ספק | SDK | יתרונות |
|---|---|---|
| AWS S3 | aws-sdk-swift, aws-sdk-kotlin, Amplify | בוגר, עשיר בתכונות, multipart מובנה |
| Google Cloud Storage | google-cloud-storage (Java/Kotlin), GCS REST API | אינטגרציה טובה עם Firebase/GCP |
| Cloudflare R2 | API תואם S3 | ללא דמי egress — זול ב-90% מ-S3 בתעבורה יוצאת |
| Backblaze B2 | API תואם S3 | אחסון האובייקטים הזול ביותר ($0.006/GB/חודש) |
R2 ו-B2 תואמים ל-API של S3—אותו קוד לקוח, endpoint שונה. בחירת הספק תלויה באזור התעבורה ובתקציב. לפרויקטים עם תעבורה יוצאת גבוהה, Cloudflare R2 יכול להפחית את עלויות ה-egress לאפס.
כיצד אנו מיישמים העלאה עם התקדמות
משתמשים רואים פס התקדמות—זה לא אופציונלי עבור סרטונים. שלבי היישום:
- האפליקציה מבקשת presigned URL מה-backend, ומעבירה סוג קובץ וגודל.
- ה-backend מייצר טוקן זמני עם הרשאות מוגבלות (למשל, PUT על אובייקט ספציפי).
- הלקוח מעלה את הקובץ ישירות לאחסון הענן באמצעות ה-URL שהתקבל ומנגנוני מעקב התקדמות סטנדרטיים.
iOS באמצעות URLSession.uploadTask עם delegate:
class MediaUploader: NSObject, URLSessionTaskDelegate {
private lazy var session: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
func upload(fileURL: URL, presignedURL: URL, progress: @escaping (Double) -> Void, completion: @escaping (Result<Void, Error>) -> Void) {
var request = URLRequest(url: presignedURL)
request.httpMethod = "PUT"
request.setValue(fileURL.mimeType, forHTTPHeaderField: "Content-Type")
let task = session.uploadTask(with: request, fromFile: fileURL)
task.taskDescription = fileURL.lastPathComponent
uploadCompletionHandlers[task.taskIdentifier] = completion
uploadProgressHandlers[task.taskIdentifier] = progress
task.resume()
}
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
uploadProgressHandlers[task.taskIdentifier]?(progress)
}
}ב-Android באמצעות OkHttp עם class MediaUploader: NSObject, URLSessionTaskDelegate { private lazy var session: URLSession = { URLSession(configuration: .default, delegate: self, delegateQueue: nil) }() func upload(fileURL: URL, presignedURL: URL, progress: @escaping (Double) -> Void, completion: @escaping (Result<Void, Error>) -> Void) { var request = URLRequest(url: presignedURL) request.httpMethod = "PUT" request.setValue(fileURL.mimeType, forHTTPHeaderField: "Content-Type") let task = session.uploadTask(with: request, fromFile: fileURL) task.taskDescription = fileURL.lastPathComponent uploadCompletionHandlers[task.taskIdentifier] = completion uploadProgressHandlers[task.taskIdentifier] = progress task.resume() } func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend) uploadProgressHandlers[task.taskIdentifier]?(progress) } } מותאם אישית:
class ProgressRequestBody(
private val file: File,
private val contentType: MediaType,
private val onProgress: (Int) -> Unit
) : RequestBody() {
override fun contentType() = contentType
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
val buffer = ByteArray(8192)
var uploaded = 0L
file.inputStream().use { input ->
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
uploaded += bytesRead
val progress = (uploaded * 100 / file.length()).toInt()
onProgress(progress)
}
}
}
} מה לעשות עם קבצי וידאו גדולים?
S3 דורש multipart עבור קבצים גדולים מ-5 MB (מומלץ מ-100 MB). יתרונות: העלאה מקבילית של חלקים, יכולת חידוש לאחר הפרעה.
class S3MultipartUploader(private val s3Client: S3Client) {
suspend fun upload(bucketName: String, key: String, file: File): String {
// 1. Инициализируем multipart upload
val createResponse = s3Client.createMultipartUpload {
bucket = bucketName
this.key = key
contentType = file.detectMimeType()
}
val uploadId = createResponse.uploadId!!
val partSize = 10 * 1024 * 1024L // 10 МБ на часть
val parts = mutableListOf<CompletedPart>()
try {
file.inputStream().use { stream ->
var partNumber = 1
val buffer = ByteArray(partSize.toInt())
var bytesRead: Int
while (stream.read(buffer).also { bytesRead = it } != -1) {
val partData = buffer.copyOf(bytesRead)
val uploadPartResponse = s3Client.uploadPart {
bucket = bucketName
this.key = key
this.uploadId = uploadId
this.partNumber = partNumber
body = ByteStream.fromBytes(partData)
}
parts.add(CompletedPart {
this.partNumber = partNumber
eTag = uploadPartResponse.eTag
})
partNumber++
}
}
// 3. Завершаем multipart upload
s3Client.completeMultipartUpload {
bucket = bucketName
this.key = key
this.uploadId = uploadId
multipartUpload = CompletedMultipartUpload {
this.parts = parts
}
}
return "https://$bucketName.s3.amazonaws.com/$key"
} catch (e: Exception) {
// Очищаем незавершённый upload — иначе оплачивается
s3Client.abortMultipartUpload {
bucket = bucketName
this.key = key
this.uploadId = uploadId
}
throw e
}
}
}RequestBody בשגיאה חשוב. העלאות multipart לא שלמות ממשיכות לחייב בעלויות ב-AWS. הוסיפו Lifecycle Rule למחיקת העלאות לא שלמות לאחר 7 ימים כרשת ביטחון.
עיבוד מדיה לפני העלאה
וידאו 4K בגודל 200 MB המועלה ישירות ל-S3 הוא rarely נחוץ. בלקוח לפני העלאה:
- תמונות: דחיסה באמצעות
class ProgressRequestBody( private val file: File, private val contentType: MediaType, private val onProgress: (Int) -> Unit ) : RequestBody() { override fun contentType() = contentType override fun contentLength() = file.length() override fun writeTo(sink: BufferedSink) { val buffer = ByteArray(8192) var uploaded = 0L file.inputStream().use { input -> var bytesRead: Int while (input.read(buffer).also { bytesRead = it } != -1) { sink.write(buffer, 0, bytesRead) uploaded += bytesRead val progress = (uploaded * 100 / file.length()).toInt() onProgress(progress) } } } }(iOS) אוclass S3MultipartUploader(private val s3Client: S3Client) { suspend fun upload(bucketName: String, key: String, file: File): String { // 1. Инициализируем multipart upload val createResponse = s3Client.createMultipartUpload { bucket = bucketName this.key = key contentType = file.detectMimeType() } val uploadId = createResponse.uploadId!! val partSize = 10 * 1024 * 1024L // 10 МБ на часть val parts = mutableListOf<CompletedPart>() try { file.inputStream().use { stream -> var partNumber = 1 val buffer = ByteArray(partSize.toInt()) var bytesRead: Int while (stream.read(buffer).also { bytesRead = it } != -1) { val partData = buffer.copyOf(bytesRead) val uploadPartResponse = s3Client.uploadPart { bucket = bucketName this.key = key this.uploadId = uploadId this.partNumber = partNumber body = ByteStream.fromBytes(partData) } parts.add(CompletedPart { this.partNumber = partNumber eTag = uploadPartResponse.eTag }) partNumber++ } } // 3. Завершаем multipart upload s3Client.completeMultipartUpload { bucket = bucketName this.key = key this.uploadId = uploadId multipartUpload = CompletedMultipartUpload { this.parts = parts } } return "https://$bucketName.s3.amazonaws.com/$key" } catch (e: Exception) { // Очищаем незавершённый upload — иначе оплачивается s3Client.abortMultipartUpload { bucket = bucketName this.key = key this.uploadId = uploadId } throw e } } }(Android). WebP במקום JPEG נותן יחס איכות/גודל טוב יותר (עד 30% קטן יותר). - סרטונים: transcoding באמצעות AVAssetExportSession (iOS) או MediaCodec/Transformer (Android) ל-1080p/720p. עבור React Native—
abortMultipartUpload.
// iOS: сжатие видео перед загрузкой
let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1280x720)!
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
exporter.shouldOptimizeForNetworkUse = true
await exporter.export()
// После экспорта — загружаем outputURL в S3דחיסה בצד הלקוח היא פשרה: פחות תעבורה, העלאה מהירה יותר, אבל עומס CPU על המכשיר. זה חוסך באחסון ובתעבורה יוצאת (במיוחד בקנה מידה גדול). דחיסה יכולה לקצר את זמן ההעלאה ב-50%.
CDN לאספקה
S3 ישירות מיועד רק לקבצים פרטיים. מדיה ציבורית (avatars, תוכן) צריכה לעבור דרך CloudFront או Cloudflare CDN. זה מאחסן תוכן ב-edge nodes ברחבי העולם ומספק HTTPS ללא הגדרה נוספת. חיבור CDN יכול להפחית את זמן האחזור בעד 50 ms עבור משתמשים באזורים מרוחקים.
פרטים טכניים להגדרת CORS
להעלאה ישירה מדפדפן או אפליקציית מובייל, יש להגדיר מדיניות CORS על ה-bucket. דוגמת הגדרה עבור S3:{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"]
}
]
}עבור Cloudflare R2, הגדרת CORS נעשית דרך לוח הבקרה או ה-API.
מה כלול בהקמת אחסון מדיה?
אנו מספקים פתרון מקיף:
- דיאגרמת ארכיטקטורה של אינטגרציית presigned URL
- endpoint שרת להנפקת טוקן (בחירה בין API Gateway + Lambda או controller מותאם)
- אינטגרציית SDK בצד הלקוח (iOS Swift/Kotlin/Flutter)
- יישום multipart upload עבור סרטונים
- הגדרת CDN ומדיניות lifecycle
- בדיקות עומס
- תיעוד תפעולי
- תמיכה ל-30 יום לאחר ההשקה
עם ניסיון של למעלה מ-5 שנים ו-50+ אינטגרציות ענן מוצלחות, אנו מבטיחים אחסון מדיה אמין ומאובטח.
לוח זמנים ותמחור
פרויקטים טיפוסיים אורכים 1 עד 3 שבועות בהתאם למורכבות ולמחסנית. אנו מעריכים כל פרויקט באופן אישי—צרו קשר לייעוץ והערכה ראשונית. חיסכון בתעבורת egress יכול להגיע עד 90% באמצעות Cloudflare R2, והפחתת עומס השרת בעד 70% באמצעות העלאה ישירה. עלויות ההקמה נעות בדרך כלל בין $2,000 ל-$5,000, עם פוטנציאל חיסכון חודשי של $1,000+ ברוחב פס.
צרו קשר כדי לדון בפרויקט שלכם ולקבל ייעוץ. הניסיון של הצוות שלנו: למעלה מ-5 שנים בפיתוח אפליקציות מובייל ו-50+ אינטגרציות אחסון ענן מוצלחות. אנו מבטיחים אמינות ואבטחה לנתוני המדיה שלכם.







