
こんにちは制作の奥田です。
先日30歳を迎えました。年齢を聞かれ、答えた自分が「あ、自分30歳になったんや・・・」と少し戸惑ってしまうほど中身はまだまだですw
さて、ちょっとしたアプリケーションを作る際に画像のアップロードは欠かせませんよね?
そんな時、非同期で複数のチャンクに分割してアップロードしてくれる「flow.js」を使うとストレスなくファイルをアップロードできます。
今回はLaravelを使ってファイルのアップロード、取得、削除までを解説いたします。
flow-php-serverとImageのインストール
今回解説するLaravelのバージョンは5.2ですが、Laravel4系でも同じように実装可能だと思います。(一部コードの変更が必要です。)
まずどんな完成かイメージできない方のために、完成イメージはこんな感じです。

でははじめに必要なパッケージをインストールします。
composer require flowjs/flow-php-server composer require intervention/image
Imageはconfig/image.phpが必要なので以下のコマンドで作成します。
artisan vendor:publish --provider="Intervention\Image\ImageServiceProviderLaravel5"
インストールが完了したら、Imageを使えるようにconfig/app.phpを編集します。
// $providersのarray内に追加 Intervention\Image\ImageServiceProvider::class, // $aliasesのarray内に追加 'Image' => Intervention\Image\Facades\Image::class
データベースマイグレーション
ではfilesのデータベースを作成します。
以下のコマンドでマイグレーションファイルを作成します。
php artisan make:migration create_files_table
database/migrationsの中の2016_**_**_******_create_files_table.phpを開きそれぞれup()とdown()の中身を以下のようにします。
public function up()
{
Schema::create('files', function(Blueprint $table)
{
$table->increments('id');
$table->string('mime', 255);
$table->string('filename', 255);
$table->bigInteger('size')->unsigned();
$table->string('storage_path');
$table->string('thumbnail_path');
$table->string('disk', 10);
$table->boolean('status');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('files');
}
マイグレーションします。
php artisan migrate
モデル・コントローラーを作成
必要なモデル・コントローラーを作成します。
php artisan make:model File php artisan make:controller FilesController php artisan make:controller UploadController
モデルは以下のようにします。
static::deletingで削除時にファイルも一緒に削除するようになっています。
{{-- app/Http/File.php --}}
<?php
namespace App;
use Storage;
use Illuminate\Database\Eloquent\Model;
class File extends Model
{
protected $fillable = ['mime', 'storage_path','thumbnail_path', 'filename', 'size', 'disk'];
public static function boot()
{
parent::boot();
static::deleting(function($model)
{
if(file_exists(public_path() . $model->storage_path))
unlink(public_path() . $model->storage_path);
if(file_exists(public_path() . $model->thumbnail_path))
unlink(public_path() . $model->thumbnail_path);
});
}
}
UploadController.phpはファイルの一覧を取得してViewに渡すだけです。
{{-- app/Http/Controllers/UploadController.php --}}
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Requests;
use App\Http\Controllers\Controller;
use App\File;
class UploadController extends Controller
{
public function index()
{
$files = File::all();
return view('upload.index',[
'files' =>$files
]);
}
}
FilesController.phpでファイルの操作をします。
{{-- app/Http/Controllers/FilesController.php --}}
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Requests;
use App\Http\Controllers\Controller;
use App\File;
use Image;
use Flow\Config as FlowConfig;
use Flow\Request as FlowRequest;
use Flow\ConfigInterface;
use Flow\RequestInterface;
class FilesController extends Controller
{
public function postUpload()
{
$config = new FlowConfig();
$config->setTempDir(storage_path() . '/tmp');
$config->setDeleteChunksOnSave(false);
$file = new \Flow\File($config);
$request = new FlowRequest();
$totalSize = $request->getTotalSize();
if ($totalSize && $totalSize > (1024 * 1024 * 10))
{
return \Response::json(['error'=>'ファイルサイズが大きすぎます。アップロード可能なサイズは10MBまでです。'],400);
}
$uploadFile = $request->getFile();
if ($file->validateChunk()) {
$file->saveChunk();
} else {
// Indicate that we are not done with all the chunks.
return \Response::json(['error'=>'アップロードに失敗しました。'], 204);
}
$filedir = '/upload/';
if(!file_exists( public_path() . $filedir)){
mkdir( public_path() . $filedir, $mode = 0777, true);
}
$thumbnail_dir = '/upload/thumbnail/' ;
if(!file_exists( public_path() . $thumbnail_dir)){
mkdir( public_path() . $thumbnail_dir, $mode = 0777, true);
}
$identifier = md5($uploadFile['name']).'-' . time() ;
$p = pathinfo($uploadFile['name']);
/* hashファイル名と拡張子を結合 */
$identifier .= "." . $p['extension'];
/* アップロードパス */
$path = $filedir . $identifier;
/* サムネイルのアップロードパス */
$thumbnail_path = $thumbnail_dir . $identifier;
/* パブリックディレクトリへのパス */
$public_path = public_path() . $path;
/* サムネイルのパブリックディレクトリへのパス */
$public_thumbnail_path = public_path() . $thumbnail_path;
if ($file->validateFile() && $file->save($public_path))
{
/* リサイズ処理を実行 */
$this->resizing($public_path,$public_thumbnail_path);
$data = File::create([
'mime' => $uploadFile['type'],
'size' => $request->getTotalSize(),
'storage_path' => $path,
'thumbnail_path' => $thumbnail_path,
'filename' => $uploadFile['name'],
'disk' => 'local'
]);
$file->deleteChunks();
return \Response::json($data, 200);
}
}
public function resizing($path,$thumbnail_path){
$image = Image::make($path);
if($image->width() > 1400){
$image->resize(1400, null, function ($constraint) {
$constraint->aspectRatio();
})->save($path);
}
if($image->height() > 800){
$image->resize(null,800, function ($constraint) {
$constraint->aspectRatio();
})->save($path);
}
$image->resize(150,null, function ($constraint) {
$constraint->aspectRatio();
})->save($thumbnail_path);
}
public function postDelete(Request $req,$id)
{
$file = File::find($id);
$file->delete();
return \Response::json([], 200);
}
}
ルーティングとビューファイルの作成
ルーティングは以下のようにします。
Route::get('/uploads' ,'UploadController@index');
Route::post('/uploads' ,'FilesController@postUpload');
Route::post('/file/delete/{id}' ,'FilesController@postDelete');
補足ですが今回テンプレートのビューはこんな感じです。
bootstrapを使用しています。
{{-- resources/views/public.blade.php --}}
<!DOCTYPE html>
<html lang="ja" ng-app="app">
<head>
<meta charset="UTF-8">
<title>Laravel Demo</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
<meta name="robots" content="index,follow">
<meta name="SKYPE_TOOLBAR" content="SKYPE_TOOLBAR_PARSER_COMPATIBLE">
<meta name="format-detection" content="telephone=no">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
@yield('styles')
<!--[if lt IE 9]>
<script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="//oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<div id="page">
<div id="contents">
@yield('content')
</div><!-- / #contents -->
</div><!-- #page -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
@yield('scripts')
</body>
</html>
flow.jsが必要なのでhttps://github.com/flowjs/flow.jsここから落としてくるかgit clone https://github.com/flowjs/flow.jsとしてpublic/common/js/内にflow.min.jsを移動してください。
storage/tmpとpublic/uploadディレクトリが必要なので作成し、パーミッションを変更します。
mkdir storage/tmp public/upload chmod 777 storage/tmp public/upload
resources/views/upload/index.blade.phpは以下のようにします。
※今回CSSや、JSをそのまま記述していますが別ファイルにしても可能です。
※ただし、ajaxの際にcsrf_tokenが必要なのでtokenをhiddenに入れて取得するなどの工夫が必要です。ご注意ください。
{{-- resources/views/upload/index.blade.php --}}
@extends('public')
<?php
function formatBytes($bytes, $precision = 2, array $units = null)
{
if ( abs($bytes) < 1024 ){
$precision = 0;
}
if ( is_array($units) === false ){
$units = array('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB');
}
if ( $bytes < 0 ){
$sign = '-';
$bytes = abs($bytes);
}else{
$sign = '';
}
$exp = floor(log($bytes) / log(1024));
$unit = $units[$exp];
$bytes = $bytes / pow(1024, floor($exp));
$bytes = sprintf('%.'.$precision.'f', $bytes);
return $sign.$bytes.' '.$unit;
}
?>
@section('content')
<div class="page-header">
<div class="container">
<h1>Upload Demo</h1>
</div>
</div>
<div class="container">
<div id="upload-container">
<div class="flow-error">
<div class="alert alert-danger">
</div>
<div class="alert alert-success">
</div>
</div>
<div class="flow-drop mb10" ondragenter="jQuery(this).addClass('flow-dragover');" ondragend="jQuery(this).removeClass('flow-dragover');" ondrop="jQuery(this).removeClass('flow-dragover');">
ここにファイルをドロップ または <a class="ml10 flow-browse btn btn-primary">ファイルを選択</a>
</div>
<div class="progress flow-progress">
<div class="progress-bar progress-bar-striped active" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
<span class="sr-only">45% Complete</span>
</div>
</div>
<ul class="flow-list list-group">
@foreach( $files as $file)
<li class="flow-file list-group-item ">
<a href="{{ $file->storage_path }}" target="_blank"><img src="{{ $file->thumbnail_path }}" width="30" height="auto" class="mr10" /></a>
<span class="flow-file-name mr10"><a href="{{ $file->storage_path }}" target="_blank">{{ $file->filename }}</a></span>
<span class="flow-file-size mr10">{!! formatBytes($file->size) !!}</span>
<span class="flow-delete pull-right btn btn-xs btn-danger" data-id="{{ $file->id}}">削除</span>
</li>
@endforeach
</ul>
</div>
</div>
@endsection
@section('styles')
<style>
.ml10{
margin-left: 10px;
}
.mr10{
margin-right: 10px;
}
.mb10{
margin-bottom: 10px;
}
/* Uploader: Drag & Drop */
.flow-error { font-size:14px;display: none; }
.flow-error >div{ display: none; }
.flow-drop {padding:30px 15px; font-size:13px; text-align:center; color:#666; background-color:#fafafa; border:2px dashed #ccc; border-radius:4px; margin-top:40px; z-index:9999; display:none;}
.flow-dragover {padding:30px; color:#555; background-color:#ddd; border:1px solid #999;}
.flow-progress{display: none;}
/* Uploader: Progress bar */
.is-paused .progress-resume-link {display:inline;}
.is-paused .progress-pause-link {display:none;}
.is-complete .progress-pause {display:none;}
/* Uploader: List of items being uploaded */
.flow-list {margin-top: 30px;}
.uploader-item {width:148px; height:90px; background-color:#666; position:relative; border:2px solid black; float:left; margin:0 6px 6px 0;}
.uploader-item-thumbnail {width:100%; height:100%; position:absolute; top:0; left:0;}
.uploader-item img.uploader-item-thumbnail {opacity:0;}
.uploader-item-creating-thumbnail {padding:0 5px; font-size:9px; color:white;}
.uploader-item-title {position:absolute; font-size:9px; line-height:11px; padding:3px 50px 3px 5px; bottom:0; left:0; right:0; color:white; background-color:rgba(0,0,0,0.6); min-height:27px;}
.uploader-item-status {position:absolute; bottom:3px; right:3px;}
/* Uploader: Hover & Active status */
.uploader-item:hover, .is-active .uploader-item {border-color:#4a873c; cursor:pointer; }
.uploader-item:hover .uploader-item-title, .is-active .uploader-item .uploader-item-title {background-color:rgba(74,135,60,0.8);}
/* Uploader: Error status */
.is-error .uploader-item:hover, .is-active.is-error .uploader-item {border-color:#900;}
.is-error .uploader-item:hover .uploader-item-title, .is-active.is-error .uploader-item .uploader-item-title {background-color:rgba(153,0,0,0.6);}
.is-error .uploader-item-creating-thumbnail {display:none;}
</style>
@endsection
@section('scripts')
<script src="/common/js/flow.min.js"></script>
<script>
(function () {
var isImage = true;
var r = new Flow({
simultaneousUploads : 1,
target: '/uploads',
permanentErrors:[404, 500, 501],
headers: { 'X-CSRF-TOKEN': '{{csrf_token()}}'},
testChunks:false
});
// Flow.js isn't supported, fall back on a different method
if (!r.support) {
$('.flow-error').show();
return ;
}
// Show a place for dropping/selecting files
$('.flow-drop').show();
r.assignDrop($('.flow-drop')[0]);
r.assignBrowse($('.flow-browse')[0]);
// Handle file add event
r.on('fileAdded', function(file){
isImage = true;
if('png' != file.getExtension() && 'gif' != file.getExtension() && 'jpg' != file.getExtension() && 'JPG' != file.getExtension() && 'jpeg' != file.getExtension()){
alert('danger','ファイル形式が正しくありません。',4000);
isImage = false;
}
if( isImage ) {
// Show progress bar
$('.flow-progress, .flow-list').show();
// Add the file to the list
$('.flow-list').append(
'<li class="flow-file list-group-item flow-file-'+file.uniqueIdentifier+'">' +
'Uploading <span class="flow-file-name mr10"></span> ' +
'<span class="flow-file-size mr10"></span> ' +
'<span class="flow-file-progress mr10"></span> '
);
var $self = $('.flow-file-'+file.uniqueIdentifier);
$self.find('.flow-file-name').text(file.name);
$self.find('.flow-file-size').text(readablizeBytes(file.size));
}
});
r.on('filesSubmitted', function(file) {
if( isImage ) r.upload();
});
r.on('fileSuccess', function(file,message){
setTimeout(function(){
$('.flow-progress').fadeOut(400,function(){
$(this).hide();
$('.progress-bar').css({width:'0'});
})
},1000)
var response = JSON.parse(message);
var $self = $('.flow-file-'+file.uniqueIdentifier);
$self.html('<a href="'+ response.storage_path + '" target="_blank"><img src="'+ response.thumbnail_path+'" width="30" height="auto" class="mr10" /></a>'+
'<span class="flow-file-name mr10"><a href="'+ response.storage_path + '" target="_blank">'+ response.filename + '</a></span> ' +
'<span class="flow-file-size mr10">'+ readablizeBytes(response.size) + '</span>'+
'<span class="flow-delete pull-right btn btn-xs btn-danger" data-id="' + response.id + '">削除</span>');
});
r.on('fileError', function(file, message){
// Reflect that the file upload has resulted in error
$('.flow-progress').hide();
$('.flow-file-'+file.uniqueIdentifier).hide();
var response = JSON.parse(message);
alert('danger',response.error,4000);
});
r.on('fileProgress', function(file){
// Handle progress for both the file and the overall upload
$('.flow-file-'+file.uniqueIdentifier+' .flow-file-progress')
.html(Math.floor(file.progress()*100) + '% '
+ readablizeBytes(file.averageSpeed) + '/s '
+ secondsToStr(file.timeRemaining()) + ' remaining') ;
$('.progress-bar').css({width:Math.floor(r.progress()*100) + '%'});
});
$(document).on('click','.flow-delete',function(){
var self = $(this);
if(window.confirm('ファイルを削除します。よろしいですか?')){
var id = $(this).data('id');
$.ajax({
type : 'POST',
headers: { 'X-CSRF-TOKEN': '{{csrf_token()}}'},
url : '/file/delete/' + id
})
.success(function(data){
self.parent().fadeOut(400,function(){
$(this).remove();
});
alert('success','ファイルを削除しました。',4000);
});
}
})
function alert(type,message,timeout){
$('.flow-error').find('.alert').hide();
$('.flow-error').show();
$('.flow-error').find('.alert-' + type).text(message).fadeIn(400,function(){
setTimeout(function(){
$(this).fadeOut(400,function(){
$(this).hide();
$('.flow-error').hide();
});
},timeout)
});
}
})();
function readablizeBytes(bytes) {
var s = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'];
var e = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e];
}
function secondsToStr (temp) {
function numberEnding (number) {
return (number > 1) ? 's' : '';
}
var years = Math.floor(temp / 31536000);
if (years) {
return years + ' year' + numberEnding(years);
}
var days = Math.floor((temp %= 31536000) / 86400);
if (days) {
return days + ' day' + numberEnding(days);
}
var hours = Math.floor((temp %= 86400) / 3600);
if (hours) {
return hours + ' hour' + numberEnding(hours);
}
var minutes = Math.floor((temp %= 3600) / 60);
if (minutes) {
return minutes + ' minute' + numberEnding(minutes);
}
var seconds = temp % 60;
return seconds + ' second' + numberEnding(seconds);
}
</script>
@endsection
これで完成です。
解説
FilesController.phpではpostUploadでstorage/tmpにchunkを保存していきます。
すべてのchunkが保存し終わるとそれをひとまとめにしてuploadに保存しています。
今回は画像のみでの実装にしていますが、mimetypeなどの判定を加えてさまざまなファイルのアップロードが可能です。
みなさんも是非試してみてください。