こんにちは制作の奥田です。
先日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などの判定を加えてさまざまなファイルのアップロードが可能です。
みなさんも是非試してみてください。