上次的文章有提到,如何讓 PHP 可以接受大檔案的上傳:

今天再來聊聊大檔案的分片儲存與加密。

背景

如果有一天我們心血來潮想要寫一個基於 Object Storage 的雲端檔案儲存系統,但是因為 Object Storage 通常由 Google Cloud Platform 或是 AWS S3 等服務提供,你沒有辦法保證你儲存的檔案不會接受這些服務的審查,所以我們就會需要將檔案加密,然而如果今天檔案非常巨大,如果將整個檔案透過 AES 加密就可能會寫出這樣一行程式:

function encrypt($key, $payload)
{
    $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
    $encrypted = openssl_encrypt($payload, 'aes-256-cbc', $key, 0, $iv);
    return base64_encode($encrypted . '::' . $iv);
}

encrypt("youwillneverknowthepassword", file_get_contents("/path/to/file"));

然而,file_get_contents這個函數會將整個檔案讀入記憶體,然後我們才能將檔案內容進行加密,想想你在 GCP 買的主機:1vCPU/2GB RAM/50GB SSD 然後你把 PHP 的 memory_limit 設定到了 512MB,你根本完全沒有加密並儲存處理一個 4GB 的檔案,有些人的作法可能會將檔案透過 zip 壓縮起來,可是每一次在做讀取的時候又要進行一次 unzip 相當耗費時間。

檔案分片並加密

你已經被標題劇透了,對,我們就是需要將檔案以 chucked 的方式讀取,然後分別存到多個檔案中,這就是所謂的切片,在今天的範例中我們將會以一片 4KB 來示範這個操作。

在這裡我們只做最簡易的示範,假設我們現在要接受一張 JPG 圖片,收到之後分片並加密,然後存到 image/ 資料夾中,我們就可以寫成:

<?php
// 加密函數
function encrypt($key, $payload)
{
    $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
    $encrypted = openssl_encrypt($payload, 'aes-256-cbc', $key, 0, $iv);
    return base64_encode($encrypted . '::' . $iv);
}

// 解密函數
function decrypt($key, $garble)
{
    list($encrypted_data, $iv) = explode('::', base64_decode($garble), 2);
    return openssl_decrypt($encrypted_data, 'aes-256-cbc', $key, 0, $iv);
}

// 接受上傳並打開上傳後佔置於 /tmp 中的檔案
$filename = $_FILES["file"]["tmp_name"];
$fileSize = $_FILES["file"]["size"];
$handle = fopen($filename, "r");
$chuckSize = (1 << 12); // 4KB
$position = 0;
$index = 1;

if ($handle) {
    while(!feof($handle)) {
        $chunk = fread($handle, $chuckSize);

        $data = encrypt("floatflower1029", $chunk);
        file_put_contents(__DIR__."/image/$index.chucked", $data);

        $index ++;
        $position += strlen($chunk);

        // 最後一個 chunk 時
        if($position + $chuckSize > $fileSize) $chuckSize = $fileSize - $position;

        // 處理完畢之後,chunkSize 為 0 時代表已經沒有資料需要再處理
        if($chuckSize === 0) break;

        fseek($handle, $position);
    }
    fclose($handle);
}

接著我們透過 POSTMAN 來上傳一張圖片,這是本文中用於示範的圖片:

上傳之後你就會發現目標資料夾中多了一堆 .chucked 文件,這些就是我們分片且加密後的檔案。

每一個檔案中都會有一串被加密過得字串,然後這個檔案經過加密及分片之後,總共產生了 20 個檔案,接下來我們就要來把這個檔案傳回給用戶。

分片並傳送給用戶

<?php
// 加密函數
function encrypt($key, $payload)
{
    $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
    $encrypted = openssl_encrypt($payload, 'aes-256-cbc', $key, 0, $iv);
    return base64_encode($encrypted . '::' . $iv);
}

// 解密函數
function decrypt($key, $garble)
{
    list($encrypted_data, $iv) = explode('::', base64_decode($garble), 2);
    return openssl_decrypt($encrypted_data, 'aes-256-cbc', $key, 0, $iv);
}

// 設定 Response Header
header("Content-Type: image/jpeg");
for($i = 1; $i <= 20; $i ++) {
    // 解密並將檔案寫回客戶端
    echo decrypt("floatflower1029", file_get_contents(__DIR__."/image/$i.chucked"));
    ob_flush();
    flush(); // 強制輸出,不然一直 echo 不輸出再大檔案的時候還是會塞爆 Buffer。
}

分片傳輸及解密的操作相當的容易,因為我們的 .chucked 文件名稱都是用序列號來命名的,所以我們只要把所有分片檔案依序讀取出來解密並傳送給客戶端就完成了。

後記

分片加密及傳輸這種手法在任何一種語言中都非常適合,而且這種儲存方法在自己寫的應用程式中,甚至可以作到平行下載及傳輸,如果做適當的改寫也可以把分片放在不同的伺服器上。但是這種儲存方法會相對於直接單檔儲存會耗費更多空間,並且使用產生大量的檔案,在 GoDaddy 這種僅限至十萬個 inode 的虛擬主機上面就盡量不要這樣寫,否則很快就會讓 inode 耗盡。

順帶一提,就是在 Block Storage 的服務中,通常會限制 putObject 操作的速度,但是這種作法在接受上傳時,會產生大量的寫入操作,這時候最好限制一下寫入速率,才不會超過 Block Storage 的寫入速度限制。


最後有關於檔案分片的效能測試可以參考以下文章: