k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応

前回の記事 画像アップロード(1)Canvasでプレビューして普通に画像をアップロード の続きです。

今回は純正のファイル入力欄を隠してダミーのテキスト入力欄とボタンを配置することでスタイルシートによる見た目の変更に対応するとともに、クライアント側で簡易バリデーション(ファイルサイズ、横幅、高さのチェック)を行います。

コンテントタイプについてはクライアント側ではバリデーションを行わず、input要素にaccept属性を設定して、ファイルダイアログの表示対象を絞り込むようにします。

また、ファイルの入力欄を複数設けることで、複数のファイル送信に対応してみます。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードです。前回と同様、自作ライブラリの Volcanus_FileUploader を使います。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ダミーファイルフォーム,クライアントバリデーション,accept属性,複数ファイル対応
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name])) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

}
include __DIR__ . '/template.php';

複数ファイルになったので、前回と同じアップロードファイルの処理がループになっているほか、諸々の制限値などをHTML上で表示するために $form 変数の中に入れています。

$form['acceptType'] = のところはinput要素のaccept属性値(カンマ区切り)を生成しています。これ指定しておくと、ファイル選択ダイアログで表示するファイルを拡張子で絞り込んでくれるという便利機能です。(元々HTML4.01の頃から仕様にはあったんですが、近年になってようやくブラウザの対応が進んだようです)

アップローダのテンプレート (PHP)

画面のPHPテンプレートはこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/2/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="text" class="filename" />
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" style="visibility:hidden;width:0;height:0" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
            <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
                <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
                <ul>
<?php if (isset($file['size'])) : ?>
                    <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                    <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                    <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                    <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                    <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
                </ul>
            </div>
        </p>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

今回はファイル選択欄にスタイルシートでデザインを適用したいという要望に応えるため、これを隠した上でJavaScriptからテキスト入力欄へのクリックやボタンのクリックを拾って、ファイル選択欄を間接的にクリックさせる方法を採りました。

この手法はMozilla Developer Networkの以下の記事を参考にしました。

一つの入力欄で複数のファイルを選択させることもできますが、私の現場ではそれだと使いづらいという意見が出ているため、今のところこの方法が良いと考えています。

(こういうのもクリックジャッキングの一種と言えますし、あまり推奨される手法ではないとは思うのですが、現状でファイル選択欄の見た目を変える方法がないので…)

また、いくつかスタイル定義を追加したり、ファイル毎にバリデーションエラーを表示する枠を設けています。DOM操作用にclass属性を付与しているところもあります。

こんな感じで複数ファイルを受け付けるフォームが表示されます。

せっかくなので、スタイルシートでちょっとだけCSS3のプロパティを使ったりして、ボタンをナウい感じにしてみました!(ボタンだけ)

マウスカーソルをボタンに乗せたとき

ボタンを押したとき

アップローダJavaScript

JavaScriptは長くなったので別ファイルに定義しました。

multi/uploader.js

$(function() {

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();

    var clearFile = function(uploader) {
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var preview = function(uploader, file) {
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

});

ダミーフォームのためイベント定義が増えたり、クライアント側バリデーションエラーを扱うようになったことで、エラーメッセージの状態変更なども追加しています。

JavaScriptのコードがダサいのは勘弁してください。自分、MVVMとかよく分からないただのLAMPおじさんですので…。

クライアント側バリデーションエラーの時はこんな感じでエラーメッセージを表示します。

バリデーションエラーが解決されるまで送信ボタンを押せなくする等の工夫も入れた方が良いのでしょうけど、とりあえず表示のみに留めています。

クライアント側バリデーションエラーを無視して送信すると、このようにサーバ側バリデーションのエラーメッセージが表示されます。

クライアント側バリデーションエラーがなければこんな感じ。

サーバ側バリデーションエラーがなければ、前回と同様にそれぞれファイルの情報を表示します。

一時ファイル名をその都度生成してますので、同じファイルを送信してもOKです。

余談ですが、ファイルのレスポンスを返す際、アップロード時のファイル名を Content-Disposition ヘッダで返したいケースも当然あるでしょうし、他にも元画像の横幅や高さ、MIMEタイプなどもサーバ側で管理しておくと色々と便利です。(この辺、どういう風に扱うのが常道なんでしょうか?)

いずれにせよアプリケーションで管理する画像はデータベースから取得した他の情報に紐付けて扱うことになるので、最近はCMS系の小さい画像で良いアプリケーションでは、ファイルの内容をbase64エンコードしてデータベースに突っ込んだりしています。

たとえば「商品」エンティティと「画像」エンティティがあって、「商品画像」連関エンティティがあり、画像ファイル単体のレスポンスに必要な情報は「画像」エンティティに集約するような構造です。

SQLアンチパターンの「ファントムファイル」も解消できるし、SQLファイルでのダンプも可能、DataURIスキームでの利用も容易ということで、この方法を採用してからは表示側の処理もシンプルに作れるようになりました。(巨大なファイルを扱うケースではどうなのか分かりませんが…)

あえて「ファントムファイル」を採用する理由はアクセス負荷なのでしょうけど、単体のファイルレスポンスだったら普通にアプリケーション側でHTTPキャッシュに対応しますよね。それに、CMS系アプリケーションであれば、いずれにせよ紐付けられたエンティティの公開状態なども併せて見ないといけないわけで。

しかし、検索しても「ファントムファイル」を本気でアンチパターンと受け取ってない人が多いのが不思議です。ソーシャルなんとかやコンシューマ向け大規模サービスだとデメリットの方が大きいのかな。(今どきファイルはS3とか外部ストレージに置いてるってことなのかも…というかRDBMS自体使ってない?)

次の課題はドラッグ&ドロップ対応あたりでしょうか。もうちょっとだけ続けます。