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。
開始實作
首先,我們先從簡單的目錄結構講起,
/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'
);
其中 convert
與 exiv2
是處理圖片需要用到的工具,所以也一併的放在設定檔裡面。而啟動呢,大抵上也沒什麼特別,
<?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;
}
};