S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】

2022/03/06 2022/06/12 #S3 #ストレージサーバー

S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】

こんにちは。のんです。

前回に引き続き自前でストレージサーバーを開発していこうと思います。

今回はこのアプリのメイン機能であるファイルアップロード機能について書こうと思います。

GitHubプロジェクトはこちら

ファイルアップロード時の仕様

基本的にWEBアプリケーションで利用しようと思っています。

オリジナルのファイルも置いておくつもりですが、通信量の削減のためサムネイル画像も生成してどちらを採用するか選べるようにしたいです。また、どの形式の画像を作成するかも決められるように死体と思います。

  • アップロードした画像のサムネイル画像を生成する
  • アップロードした画像と任意の拡張子の画像を生成する

の2つを主な機能として作成していきます。

APIでファイルアップロードできるエンドポイントを用意する

APIでファイルアップロードするために、画像をbase64エンコードした状態の文字列を送信するようにしました。他にも必要な情報として

  • 画像データ
  • ファイル名
  • マイムタイプ

を入力値として用意します。

POST http://localhost/files HTTP/1.1
Authorization: Digest token
Content-Type: application/json

{
    "fileName": "ファイル名",
    "mimetype": "image/jpeg",
    "file": "画像をbase64エンコードした文字列"
}

このようなエンドポイントを用意します。

入力値について

流石にとても長いコードになってしまうので、詳しくはこちらのコードをご覧ください。

$requestBody = $request->getParsedBody();
try {
    try {
        $fileEncoded = $requestBody['file'] ?? '';
        $fileDecoded = base64_decode($fileEncoded, true);
        if ($fileDecoded === false) {
            throw new Base64Exception('Failed to base64 decode.');
        }
        $fileString = new FileString($fileDecoded);

        $fileName = new FileName($requestBody['fileName'] ?? '');
        $clientId = new ClientId((string)$requestBody['client_id']);
        $mimeType = new MimeType($requestBody['mimetype'] ?? MimeType::MIME_TYPE_WEBP);
    } catch (InvalidArgumentException $e) {
        $this->logger->error($e);
        throw new HttpBadRequestException($e->getMessage());
    } catch (Base64Exception $e) {
        $this->logger->error($e);
        throw new HttpInternalErrorException($e->getMessage());
    }
} catch (HttpException $e) {
    return $e->getApiProblemResponse();
}

各ValueObjectの内容も割愛します。

注目するのはこちら

$mimeType = new MimeType($requestBody['mimetype'] ?? MimeType::MIME_TYPE_WEBP);

マイムタイプのデフォルトは image/webp としています。これはWEBアプリで画像を取り扱う際にSEOや負荷的に有利な画像形式だからです。

WebP lossless images are 26% smaller in size compared to PNGs. WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index.

WebPロスレス画像は、PNGと比較して26%サイズが小さくなっています。WebPロッシー画像は、同等のSSIM品質指数で、同等のJPEG画像より25~34%小さい。
by deepl

とあるように、PNGやJPEGと比べ大体3割くらい軽いようです。

実際のファイル操作について

入力値を設定できたので、ユースケース層に値を渡して実際に処理をしていきます。

public function process(UploadImageInputPort $inputPort): UploadImageOutputPort
{
    $file = $this->fileFactory->newImageFile($inputPort->clientId(), $inputPort->fileName(), $inputPort->fileString());

    // Save Webp extension.
    $file->changeThumbnailMimeType($inputPort->mimeType());

    try {
        $this->fileRepository->beginTransaction();
        $this->fileRepository->create($file);
    } catch (Throwable $e) {
        $this->fileRepository->rollback();
        $this->logger->error($e);
        throw new UploadFileException('Failed to register database.');
    }

    try {
        $originFilePath = $this->fileService->uploadOriginImage($file);
        $this->logger->debug($originFilePath);
    } catch (Throwable $e) {
        $this->fileRepository->rollback();
        $this->logger->error($e);
        throw new UploadFileException('Failed to upload origin file.');
    }

    try {
        $thumbnailFilePath = $this->fileService->uploadThumbnailImage($file);
        $this->logger->debug($thumbnailFilePath);
    } catch (Throwable $e) {
        $this->fileRepository->rollback();
        if (!unlink($originFilePath)) {
            $this->logger->error(sprintf('Failed to delete origin file. -- %s', $originFilePath));
        }
        $this->logger->error($e);
        throw new UploadFileException('Failed to upload thumbnail file.');
    }

    $this->fileRepository->commit();

    return new UploadImageOutput(
        $file->identifier(),
        $file->fileNameWithOriginExtension(),
        $file->uniqueFileNameWithOriginExtension(),
        FileService::UPLOAD_ORIGIN_DIRECTORY . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithOriginExtension(),
        FileService::UPLOAD_THUMBNAIL_DIRECTORY . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension(),
    );
}

初めにDBに登録します

try {
    $this->fileRepository->beginTransaction();
    $this->fileRepository->create($file);
} catch (Throwable $e) {
    $this->fileRepository->rollback();
    $this->logger->error($e);
    throw new UploadFileException('Failed to register database.');
}

ファイルをアップロード・生成したあとにロールバックするのは面倒くさいので、先にDB処理を行ってしまいます。

DBに登録するのは

  • オリジナルの画像情報
  • サムネイルの画像情報

のみで、ファイルそのものは登録しません。DBに画像を登録することも考えましたが、その場合画像を出力するときにDBアクセスが必要になってしまいます。

基本的にパーミッションの設定をするつもりはなかったし、画像を返すためにPHPを起動させるのは負荷的に嫌いました。

なので、ファイルそのものは文字列としてはではなく、ファイルとしてサーバーに設置します。

オリジナル画像の設置

try {
    $originFilePath = $this->fileService->uploadOriginImage($file);
    $this->logger->debug($originFilePath);
} catch (Throwable $e) {
    $this->fileRepository->rollback();
    $this->logger->error($e);
    throw new UploadFileException('Failed to upload origin file.');
}

です。失敗したらDBロールバックをしていますね。

続いて uploadOriginImage の内容も見てみます。

public function uploadOriginImage(File $file): string
{
    $uploadStorageDirectory = getcwd() . self::UPLOAD_ORIGIN_DIRECTORY;
    $this->createDir($uploadStorageDirectory);

    $originFilePath = $uploadStorageDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithOriginExtension();
    $byte = file_put_contents($originFilePath, (string)$file->fileString());
    if ($byte === false) {
        throw new UploadFileException('Failed to upload file.');
    }

    $this->logger->debug(sprintf('%s is %s bytes.', $file->fileNameWithOriginExtension(), $byte));

    return $originFilePath;
}

ファイル名は入力されたファイル名ではなく、内部で生成されたDBのULIDを使いまわします。

$file->uniqueFileNameWithOriginExtension();

ファイルの設置はPHPのビルトインメソッドである file_put_contents を利用します。

$byte = file_put_contents($originFilePath, (string)$file->fileString());

file_put_contents の仕様はこちら。第2引数にbase64エンコードした画像文字列を渡しています。

サムネイル画像の生成

try {
    $thumbnailFilePath = $this->fileService->uploadThumbnailImage($file);
    $this->logger->debug($thumbnailFilePath);
} catch (Throwable $e) {
    $this->fileRepository->rollback();
    if (!unlink($originFilePath)) {
        $this->logger->error(sprintf('Failed to delete origin file. -- %s', $originFilePath));
    }
    $this->logger->error($e);
    throw new UploadFileException('Failed to upload thumbnail file.');
}

です。失敗したらDBロールバックをしていますね。

続いて uploadThumbnailImage の内容も見てみます。

public function uploadThumbnailImage(File $file, int $resizeWidth = self::FULL_HD_WIDTH / 2): string
{
    $uploadThumbnailDirectory = getcwd() . self::UPLOAD_THUMBNAIL_DIRECTORY;
    $this->createDir($uploadThumbnailDirectory);

    [$originWidth, $originHeight, $type] = getimagesizefromstring((string)$file->fileString());
    $this->logger->debug(sprintf('width: %s, height: %s, type: %s -- %s', $originWidth, $originHeight, $type, $file->identifier()));

    $source = imagecreatefromstring((string)$file->fileString());
    if ($source === false) {
        throw new UploadFileException('Failed to upload file.');
    }

    $ratio = $originWidth >= $resizeWidth ? $resizeWidth / $originWidth : 1;
    $newWidth = (int)($originWidth * $ratio);
    $newHeight = (int)($originHeight * $ratio);

    $thumbnail = imagecreatetruecolor($newWidth, $newHeight);
    if (!imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originWidth, $originHeight)) {
        throw new UploadFileException('Failed to upload file.');
    }

    $mimeType = $file->thumbnailMimeType();
    $thumbnailFilePath = $uploadThumbnailDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension();
    if ($mimeType->isBmp()) {
        $result = imagebmp($thumbnail, $thumbnailFilePath);
    } elseif ($mimeType->isGif()) {
        $result = imagegif($thumbnail, $thumbnailFilePath);
    } elseif ($mimeType->isJpeg()) {
        $result = imagejpeg($thumbnail, $thumbnailFilePath);
    } elseif ($mimeType->isPng()) {
        $result = imagepng($thumbnail, $thumbnailFilePath);
    } elseif ($mimeType->isWebp()) {
        $result = imagewebp($thumbnail, $thumbnailFilePath);
    } else {
        $this->logger->error(sprintf('Unknown mimetype. [%s]', $mimeType));
        throw new LogicException('Unknown mimetype.');
    }

    if (!$result) {
        throw new UploadFileException('Failed to upload file.');
    }

    imagedestroy($thumbnail);

    return $thumbnailFilePath;
}

です。

$source = imagecreatefromstring((string)$file->fileString());
if ($source === false) {
    throw new UploadFileException('Failed to upload file.');
}

まずは imagecreatefromstring でbase64エンコードした画像文字列から画像リソースを取得します。

次に

$ratio = $originWidth >= $resizeWidth ? $resizeWidth / $originWidth : 1;
$newWidth = (int)($originWidth * $ratio);
$newHeight = (int)($originHeight * $ratio);

このメソッドに渡した $resizeWidth に応じて比率を導く計算です。デフォルトはFULL HD画像の半分の大きさにしています。

サムネイル用の新しい幅と高さが計算できたら、その結果を利用してリサイズ処理を行います。

$thumbnail = imagecreatetruecolor($newWidth, $newHeight);
if (!imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originWidth, $originHeight)) {
    throw new UploadFileException('Failed to upload file.');
}

imagecreatetruecolor でベースになる画像を生成します。大きさを設定するために通していますね。

大事なのはここからです。

imagecopyresampled を利用してサムネイル画像を生成します。先に生成したものをベースにサムネイル画像をリサイズするという感じですね。

ここで話に挙がるのは imagecopyresized というメソッドです。PHPのドキュメントを見ると、

イメージは半分サイズで出力されますが、 imagecopyresampled() を使用するとより良い品質になります。

と書いてあります。

速度的には imagecopyresized のほうが早いけど品質的に imagecopyresampled のほうが良いのでこちらを採用しています。

詳しくはこちらをご覧ください。

リサイズのために生成したリソースに画像形式を設定する

リサイズしたソースに画像形式を設定します。

$mimeType = $file->thumbnailMimeType();
$thumbnailFilePath = $uploadThumbnailDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension();
if ($mimeType->isBmp()) {
    $result = imagebmp($thumbnail, $thumbnailFilePath);
} elseif ($mimeType->isGif()) {
    $result = imagegif($thumbnail, $thumbnailFilePath);
} elseif ($mimeType->isJpeg()) {
    $result = imagejpeg($thumbnail, $thumbnailFilePath);
} elseif ($mimeType->isPng()) {
    $result = imagepng($thumbnail, $thumbnailFilePath);
} elseif ($mimeType->isWebp()) {
    $result = imagewebp($thumbnail, $thumbnailFilePath);
} else {
    $this->logger->error(sprintf('Unknown mimetype. [%s]', $mimeType));
    throw new LogicException('Unknown mimetype.');
}

if (!$result) {
    throw new UploadFileException('Failed to upload file.');
}

imagedestroy($thumbnail);

リサイズしたソースと設置するパスをマイムタイプによって利用するメソッドを使い分けて設置します。

最後に処理中に確保しているメモリを開放します。

imagedestroy($thumbnail);

実際に出力・生成する内容はこちら

{
    "message": "Successfully created file.",
    "id": "ULID",
    "originFileName": "入力したファイル名",
    "fileName": "ULIDのファイル名",
    "originPath": "オリジナル画像のURL",
    "thumbnailPath": "サムネイル画像のURL"
}

が返ってきます。

生成されたサムネイル画像はこちら。

サムネイル画像

オリジナル画像はこちら(画像をそのまま表示してしまうとページロードに時間がかかるのでリンクにしています。)

https://storage.nozomi.bike/storage/origin/01G5ADZ10FFMFMJWF38AV89W4G.jpeg

最後に

これで、ストレージサーバーとして最低限の機能を実装することができました。

あとは画像の削除や、定期的なクリーンアップ、全拡張子の対応や、画像ファイル以外の対応などやろうと思えば色々案が出てきますね。

ひとまず、現状自分が欲しい機能は実装できたので、これで連載は終了です。

強いて希望を挙げればこのブログのファイルアップロード先を変更して記事にしたかったというのがありますね。

次回は何を書こうかな。久しぶりにバイク関連の記事でも書くかもしれません。

また記事にします。

そのときはよしなに。

.

のん

名刺 : About me.

YouTube : のんラボ

Twitter : @nonz250

Github : nonz250

Qiita : @nonz250

My Qiita posts My Qiita contributions My Qiita followers

主にPHPを使用し、サーバーサイドを担当。最近はフロントにも興味津々。

なにかを作ったりいじったりするのが好きで、個人開発なども行っている。

趣味はバイクアイコン画像は大抵愛車の「Z250」である。友達にアイコン描いてもらえて嬉しい。

PHP / Laravel / CakePHP2 / CakePHP3 / Vue / Nuxt / C# / etc...

Tags

#のんラボ #Laravel #Vue #個人開発 #ブログ #プログラミング #javascript #Html5 #WEBサービス #Twitter #今年の抱負メーカー #勉強方法 #PWA #モバイルアプリ #Android #ツーリング #バイクに乗るエンジニア #Z250 #秋吉台 #能登半島 #バイク #冒険 #東尋坊 #Squid #リバースプロキシ #hosts #axios #cropper #AdSense #Bootstrap #MySQL #高速化 #トドTask #Telescope #デバッグ #composer #テスト #セキュリティ #POSレジ #スマレジ #本部機能 #バリデーション #入力チェック #Mac #Chrome #テスト駆動開発 #開発手法 #UI #デザイン #WEBサイト #機能美 #PHP #Laravel 6 #コメント #バージョンアップ #vue-cli #localhost #BIツール #売上分析 #TANAX #MFK250 #ツアーシェルケース2 #RESTful #API #REST API #実務的 #PHP Tech Tutor #Smaregi Tech Talk #勉強会 # ブログ #CakePHP3 #CSRF #VSCode #開発環境 #CakePHP3.0 #さくらのレンタルサーバー #モジュールモード #シェル #メール #Gmail #relay #OGP #エラーページ #抱負 #家庭教師 #ドメイン駆動設計 #DDD #読書会 #那智の滝 #伊勢志摩 #伊勢志摩スカイライン #フロント #三方五湖 #レインボーライン #ボーイスカウト・ルール #プログラマが知るべき97のこと #リファクタリング #ユビキタス言語 #車輪の再発明 #マイクロサービス #デプロイ #QA #laravel-mix #Tips #storybook #@storybook/addon-actions #昇降デスク #コードレス #書斎 #オフィス #リモートワーク #働き方 #エラーハンドリング #スマレジ4 #pixel 5 #レビュー #スマレコ #TDD #RSS #404 #高山ダム #ラーツー #React #Nuxt #node_modules #エラー #インポート #設定方法 #環境構築 #Docker #フォレストパーク神野山 #学生向け #PR #採用 #Node.js #npm #しまなみ海道 #youtube #CSS #IE #SLA #Rust #千里浜なぎさドライブウェイ #千里浜 #インサイドセールス #曽爾高原 #無線LAN #ポートフォリオ #バルカンS #納車 #Next.js #チームビルディング #リーダー #悩み #Github Actions #Marp #ネタ #サーバー移行 #S3 #ストレージサーバー #RFC 7807 #Digest #認証 #Xdebug #CLI #西日本 #例外