/ EC2

[Plupload] 支援 EC2/S3 的上傳筆記

Plupload 這支很有名的上傳工具,在前些陣子釋出的版本當中,在範例裡面有一個 s3.php,就是用來讓你可以直接透過他,把你的檔案上傳到 Amazon S3 上面去。正巧,我在工作上有這方面的需求,所以就稍微研究了一下。

不過,似乎還有些不足。

首先必須說明的,我在這裡使用了兩個比較輕巧的 framework,一個是 Slim,另一個是 Idiorm。

Slim is a PHP 5 Micro Framework
Idiorm


學習新的東西當然會有點阻礙,不過,由於 Slim 與 Idiorm 也都是開放原始碼的專案,所以學習曲線上並不會太過於困難。當然,你想要使用手邊慣用的工具也是可以的。會選用這兩種,其實一方面是因為 @pct 有用過(有保佑),另外一方面是,我不需要太複雜的東西,越是輕量化的東西越好。

或許你會說,那怎麼不乾脆用單純的 PHP 來做就好?嗯,問題來了,重刻輪子這件事情並不是每一次都是好事。

需求

我們比較特殊的需求是,

  • 圖片檔案上傳
  • 圖片檔案需要經過縮圖處理
  • 儲存到資料庫
  • 上傳到 Amazon S3

由於 Amazon S3 只是單純的儲存方案,如果需要做處理,還是得先把檔案放到 Amazon EC2 上面,去做完處理,並且儲存到資料庫之後,再行上傳到 Amazon S3 的動作。或許你有個疑問是,我從 EC2 上傳到 S3 的費用怎麼辦?關於這一點 Amazon 有個比較佛心的地方,就是同一個區域(Region),在任何的服務之間的傳輸是不會計算費用的。

因此,我們在 EC2 與 S3 之間的溝通就不需要太過於擔心。另外,EC2 到 S3 之間的速度非常快,我實際測試了幾個項目,

  • 由 EC2 上傳檔案到 S3,需要時間約 0.2 秒
  • 由 EC2 抓取(使用 cURL)Flickr 檔案,需要時間約 0.3 秒(以 10MB 的圖片測試
  • 由 EC2 抓取 Picasa 檔案,需要時間約 0.2 秒(以 Picasa 所提供之最大解析度圖片測試
  • 由 EC2 抓取 Facebook 檔案,需要時間約 0.4 秒(以 Facebook 所提供之原始圖檔測試

所以,我們不需要擔心上傳或是抓取第三方服務的時間差,起碼上述提到的那些當下火紅的服務來說,我們是不需要太過於擔心的。

潛在的問題

Plupload 這一套工具,我之前就有詬病過一些命名的問題。

請參閱舊文:[Plupload Note.] 微妙的錯誤


即是目前最新版本的 Plupload,一樣會有這種重複命名的問題存在。這算是非戰之罪了,畢竟上傳器本來就無法確切的,去避免前端使用者所傳送的資料。但是透過 Plupload 本身的 Event 也能巧妙的解決問題就是了。

附帶一提的是,Plupload 目前的版本,呼叫 Event 的方式與之前不同,所以上面那一篇舊文的寫法,用最新版本的 Plupload 可能會行不通。關於新版本的 Event 呼叫以及設定的方式,還是參考官方的說明會比較準確。

Plupload: Events


Amazon EC2/S3

S3 既然是一個儲存的解決方案,那麼需要做一些額外動作的時候,就得考慮在 EC2 上面自己開一台虛擬機器來解決。這也不是過於麻煩的事情,所以,我就在 EC2 上面開了一台 Ubuntu,然後在同一個區域內開了一個 S3,這樣準備工作就算是完成了。當然,你得準備一份 S3 的 PHP SDK。

Amazon S3 PHP SDK


開始實作

首先,我們先從簡單的目錄結構講起,

/static-s3/
  apps/
    s3.php <-- 跟 S3 有關的 App
    uploader.php <-- 跟 Plupload 有關的 App
  css/
  img/
  js/ <-- 所有的 Plupload 相關檔案都在這裡
    i18n/
    jquery.plupload.queue/
    jquery.ui.plupload/
    plupload.*
  Lib/
    common.php <-- 放一些共用的函式或是模組
    idiorm.php <-- Idiorm 的 Library 放這裡
  S3/ <-- Amazon S3 PHP SDK 放這裡
  Slim/ <-- Slim PHP5 Framework 放這裡
  templates/ <-- 這裡放樣板
    s3.php
    upload.php
    uploader.php
  app.php <-- 用來呼叫 /apps/ 底下的所有檔案
  config.inc.php <-- 設定檔
  index.php <-- 就等同於 bootstrap

這是大概的結構狀況,當然 templates 我是使用預設的。在 Slim 之中,也可以使用像是 Smarty 這一類的樣板引擎,不過我沒有採用的原因是,我想把整個結構盡量簡化,所以就僅使用預設的狀態。

啟動與預設值

設定檔案的內容很簡單,只是放一些關於應用程式的設定這樣,

<?php

if (!defined('DS')) define("DS", DIRECTORY_SEPARATOR);
if (!defined('ROOT')) define("ROOT", dirname(__FILE__));

$DB_NAME = 'your_database_name';
$DB_SERVER = 'your_database_server';
$DB_USER = 'your_database_account';
$DB_PASSWD = 'your_database_passwd';

$AWS_KEY = 'AWS_KEY';
$AWS_SECRET = 'AWS_SECRET';

$COOKIES_SECRET_KEY = 'Keyboard cat';

$APP_SETTINGS = array(
    'mode'               => 'development',
    'debug'              => true,
    'log.enable'         => false,
    'cookies.secret_key' => $COOKIES_SECRET_KEY
);

$APP_CONFIG = array(
    'ipAddressWhiteList' => array(
        '127.0.0.1'
    ),
    'allow_image_type' => array(
        'jpg','jpeg','gif','png'
    ),
    'thumbnail_size' => array(
        'm' => '640x640>',
        't' => '150x150>'
    ),
    'tmp_dir'       => '/tmp/uploader',
    'target_dir'    => '/tmp/uploaded',
    'convert'       => '/usr/bin/convert',
    'exiv2'         => '/usr/bin/exiv2'
);

其中 convertexiv2 是處理圖片需要用到的工具,所以也一併的放在設定檔裡面。而啟動呢,大抵上也沒什麼特別,

<?php

require_once 'Slim/Slim.php';
require_once 'S3/sdk.class.php';
require_once 'Lib/idiorm.php';
require_once 'config.inc.php';

$s3 = new AmazonS3(array(
    'key' => $AWS_KEY,
    'secret' => $AWS_SECRET
));

ORM::configure("mysql:dbname=$DB_NAME;host=$DB_SERVER");
ORM::configure('username', $DB_USER);
ORM::configure('password', $DB_PASSWD);
ORM::configure('driver_options', array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'));

$app = new Slim($APP_SETTINGS);
$app->config($APP_CONFIG);

require_once 'Lib/common.php';
require_once 'app.php';

// Home
$app->get('/', function() use ($app) {
    $app->response()->status(405);
    echo "Mathod Not Allow";
});

$app->run();
?>

為什麼要讓根目錄返回 405,這只是我個人習慣而已,如果你想要讓根目錄更有趣一點也無妨。

上傳器

我們先來看 uploader.php 到底做了什麼事情,

<?php

$app->get("/upload/:bucket", function($bucket = '') use ($app) {
    /* 當使用者造訪 /upload/ 的時候,吐出上傳介面 */
});

$app->post("/uploader", $checkDirectory(), function() use ($app, $uuid, $savePhotoData) {
    /* 這個地方是專門接收 Plupload 所傳送的資料用的 */
});

是的,我們的 uploader.php 只做這兩件事情,其中還有幾件事情他也會做,

  • checkDirectory() 檢查設定檔案中的資料夾是否存在
  • savePhotoData() 儲存所上傳的資料

這兩個函式我們放在共用函式的檔案裡面,所以這裡先不列出來了。

Amazon S3

我們接著看 s3.php 做了什麼事情,

<?php

$app->post("/s3/:bucket", $ipAddressWhiteList($_SERVER['REMOTE_ADDR']), $checkDirectory(), function($bucket) use ($app, $fetchAndSave) {
    /* 這裡會把使用者傳進來的 url 先抓回 EC2,處理完之後上傳 S3 */
});

$app->delete("/s3/:bucket", $ipAddressWhiteList($_SERVER['REMOTE_ADDR']), function($bucket) use ($app, $s3) {
    /* 這裡是用來刪除 S3 資料用的 */
});

其中有一個共用函式,一個叫做 fetchAndSave,我們後面會繼續介紹。

共用的核心

我實在很想寫 TL;DR,但是這樣好像太混了。所以我挑幾個來講,第一個是 uploadS3

<?php

$uploadS3 = function($bucket, $filename) use ($s3) {
    $app = Slim::getInstance();

    $target_dir = $app->config('target_dir');
    $extension = substr($filename, strrpos($filename, '.'));

    if (!$s3->if_bucket_exists($bucket)) {
        $response = $s3->create_bucket($bucket, AmazonS3::REGION_US_E1, AmazonS3::ACL_PUBLIC);
        if (!$response->isOK()) {
            return false;
        }
    }

    $files = array(
        $target_dir . DS . $filename,
        $target_dir . DS . str_replace($extension, '_o'.$extension, $filename),
        $target_dir . DS . str_replace($extension, '_m'.$extension, $filename),
        $target_dir . DS . str_replace($extension, '_t'.$extension, $filename)
    );

    foreach($files as $file) {
        $filename = explode(DS, $file);
        $filename = array_pop($filename);

        $s3->batch()->create_object($bucket, $filename, array(
            'fileUpload' => $file,
            'acl' => AmazonS3::ACL_PUBLIC
        ));
    }

    $upload_response = $s3->batch()->send();

    if ($upload_response->areOK()) {
        return true;
    } else {
        return false;
    }
};

其中 AmazonS3::REGION_US_E1, AmazonS3::ACL_PUBLIC 請不要照抄!

然後是 fetch 的部份,

<?php

$fetch = function($url, $filename = '') use ($uuid) {
    $app = Slim::getInstance();

    $filename = empty($filename) ? $uuid() : $filename;

    $extension = strtolower(substr($url, strrpos($url, '.') + 1));
    if (!in_array($extension, $app->config('allow_image_type'))) {
        $filename = $filename.'.jpg';
    } else {
        $filename = $filename.'.'.$extension;
    }
    $tmp_file = $app->config('tmp_dir'). DS . $filename;
    $target_file = $app->config('target_dir') . DS . $filename;

    $ch = curl_init();
    $fp = fopen($tmp_file, "w+");
    $ch_options = array(
        CURLOPT_URL => $url,
        CURLOPT_FILE => $fp,
        CURLOPT_HEADER => FALSE,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_FOLLOWLOCATION => TRUE
    );
    curl_setopt_array($ch, $ch_options);
    $result = curl_exec($ch);
    curl_close($ch);
    fclose($fp);

    if ($result) {
        if (copy($tmp_file, $target_file)) {
            unlink($tmp_file);
            return $filename;
        } else {
            unlink($tmp_file);
            return false;
        }
    } else {
        return false;
    }
};