BLOG スタッフブログ

LaravelとFlow.jsを使って画像アップロード

Category | Blog
Tag |
/ 2,509views

laravel-flow

こんにちは制作の奥田です。
先日30歳を迎えました。年齢を聞かれ、答えた自分が「あ、自分30歳になったんや・・・」と少し戸惑ってしまうほど中身はまだまだですw
さて、ちょっとしたアプリケーションを作る際に画像のアップロードは欠かせませんよね?
そんな時、非同期で複数のチャンクに分割してアップロードしてくれる「flow.js」を使うとストレスなくファイルをアップロードできます。
今回はLaravelを使ってファイルのアップロード、取得、削除までを解説いたします。

flow-php-serverとImageのインストール

今回解説するLaravelのバージョンは5.2ですが、Laravel4系でも同じように実装可能だと思います。(一部コードの変更が必要です。)
まずどんな完成かイメージできない方のために、完成イメージはこんな感じです。
laravel-upload-demo01
でははじめに必要なパッケージをインストールします。

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

Category | Blog
Tag |
Author | Mineo Okuda / 2,509views

Company information

〒651-0087
神戸市中央区御幸通 8-1-6 神戸国際会館 22F

Contact us

WEBに関するお問い合わせは
078-570-5755 (10:00 - 18:00)