Mork 的單支影片上限是 500 MB,這個數字一開始就決定了上傳不能照一般的方式做。
一般的作法是檔案先送到應用伺服器,伺服器收完再轉存到儲存空間,那樣每一個位元組都會經過應用伺服器,而應用伺服器通常是按執行時間計費、有請求逾時上限,同一個執行個體還要同時服務別人的請求,一支 500 MB 的影片傳半小時,就等於把一個工作單元佔住半小時。
所以我們把它拆開了,檔案直接進儲存空間,位元組完全不經過應用伺服器。
拆成兩次對話
流程變成跟伺服器講兩次話,中間夾著一次不經過伺服器的傳輸。
第一次是 prepare,瀏覽器報上檔名、大小、型別,伺服器回一組有簽章的上傳網址,那個簽章有時效,而且綁定了檔案位置跟 Content-Type,拿到它的人只能往那一個位置放那一種型別的東西,不能拿去傳別的。
第二次是 PUT,瀏覽器把檔案直接送到儲存空間,伺服器不在這條路上。
第三次是 commit,瀏覽器說「傳完了」,伺服器這時候才去確認、才建立短連結。
好處是 AP 的營運成本降低了,架構也變簡單了。
伺服器沒看過那個檔案
這是整件事的核心取捨,傳統作法裡檔案經過伺服器,所以伺服器知道它多大、是什麼、內容長什麼樣;改成直傳之後,這些全部不知道了。
伺服器唯一知道的,是使用者在 prepare 階段宣稱的那些。而使用者可以說謊。
所以 commit 不能只是寫一筆資料庫,它得回頭去問儲存空間:
head = head_object(key=file_key)
if head['ContentLength'] > max_mb * 1048576:
delete_object(key=file_key)
raise UploadError('檔案大小超過上限 %d MB。' % max_mb)
這個查詢只要中繼資料,不下載內容,所以確認一支 500 MB 的影片跟確認一張 20 KB 的圖一樣快。
有三件事只有在這裡才驗得到。
一是檔案真的存在,不存在就代表還沒傳完,或是根本沒傳,回 409 請對方重試。
二是真正的大小,位元組是直接進儲存空間的,這是唯一量得到的地方;超標的直接刪掉,不刪的話儲存空間會被拿來當免費空間用。
三是型別跟宣告的一致,簽章已經綁了 Content-Type,理論上到不了這裡,但這是最後一道,寧可多擋,空的型別也算不符——沒有型別的物件,瀏覽器嗅探出什麼都有可能。
短網址代碼要在最後才產生
一個容易寫錯的細節,是短網址代碼要在哪一步產生。
放在 prepare 最直覺,先給網址,使用者可以先複製起來,但那樣中途放棄的上傳會把代碼佔掉,而那些代碼永遠不會有人用,短網址代碼是 4 到 7 碼,空間有限,沒有理由拿去養一堆空號。
所以短網址代碼是在 commit、驗證通過之後才產生的,使用者得等檔案傳完才拿得到網址,這樣是對的,在檔案還沒到位之前給出的網址是壞的。
有效期限也一樣
「30 分鐘後到期」要從哪一刻起算?如果從 prepare 起算,一支傳了半小時的大檔案,使用者拿到網址的時候它已經快過期了,等於白白吃掉他半小時。
# 到期時間從 commit 當下起算,而不是 prepare。
expire_at = datetime.now() + timedelta(minutes=int(post_data['expire_minutes']))
待確認的狀態放在快取
prepare 到 commit 之間,伺服器要記住這次上傳有哪些檔案,那份資料我們是寫在快取裡,帶著存活時間,不是寫進資料庫。
理由是大部分的上傳其實會失敗或被放棄,網路斷了、使用者關掉分頁、檔案太大按了取消,如果每一次 prepare 都在資料庫留一筆,就得另外寫一支清掃程式去收拾那些永遠不會 commit 的紀錄;放在快取的話,時間到就自己不見了。
順便把 commit 綁在同一個工作階段上:
if pending['identifier'] != request.session.get('identifier'):
raise UploadError('上傳工作階段不符,請重新上傳。', status=403)
沒有這一行,別人猜到 upload id 就能替你收單、替你建立連結。
值不值得
如果上限是 5 MB,這個複雜度不值得;是 500 MB 就值得。