のんラボ

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

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

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

こんにちは。のんです。

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

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

GitHubプロジェクトはこちら

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

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

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

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

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

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

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

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

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

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

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

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

入力値について

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

copied.$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の内容も割愛します。

注目するのはこちら

copied.$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割くらい軽いようです。

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

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

copied.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に登録します

copied.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を起動させるのは負荷的に嫌いました。

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

オリジナル画像の設置

copied.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 の内容も見てみます。

copied.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を使いまわします。

copied.$file->uniqueFileNameWithOriginExtension();

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

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

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

サムネイル画像の生成

copied.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 の内容も見てみます。

copied.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;
}

です。

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

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

次に

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

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

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

copied.$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 のほうが良いのでこちらを採用しています。

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

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

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

copied.$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);

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

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

copied.imagedestroy($thumbnail);

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

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

が返ってきます。

最後に

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

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

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

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

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

また記事にします。

そのときはよしなに。

.