Laravelのファイルアップロード機能実装とテストがやりやすかったので中身を見てみた

はじめに

初めまして。第1チームの土井です。

Laravelのファイルアップロード機能を使用する機会があり、ファイルアップロード機能の実装やテストがとてもやりやすいと感じたので共有したいと思います。

ファイルアップロード機能について

flysystem(PHP用のファイルストレージライブラリ)の紹介

 flysystemはPHP用のファイルストレージライブラリです。Laravelに公式に採用されているライブラリの一つであり、一つのインターフェースによって様々なファイルシステムを使用することができるようになっています。

設定方法

flysystemはLaravelに統合されているため、ローカルファイルストレージなどを使用する場合はconfig/filesystems.phpを編集するだけでファイルストレージを使用することができます。

今回はAWSのS3をファイルストレージとして使用するので、league/flysystem-aws-s3-v3というライブラリを別途導入する必要があります。ライブラリをインストールした後は、config/filesystems.phpを以下のように編集するだけでS3をファイルストレージシステムとして使用することができるようになります。

<?php

'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_KEY'),
        'secret' => env('AWS_SECRET'),
        'region' => env('AWS_REGION'),
        'bucket' => env('AWS_BUCKET'),
    ],
],

実装例

<?php

class UploadService {
    public function uploadToS3(UploadedFile $file, string $path, string $filename) {
        Storage::disk('s3')->putFileAs($path, $file, $filename);
    }
}

参考:File Storage - Laravel 10.x - The PHP Framework For Web Artisans

上記のコードのようにとても簡単にファイルアップロード部分を実装することができます。

ファイルアップロード機能のテストについて

実装例

<?php

class UploadServieTest extends TestCase {
    
    public function testUploadToS3() {
        Storage::fake('s3'); // フェイクストレージを作成
        $file = UploadedFile::fake()->image('test_image.jpeg'); // ファイルアップロードに使用するフェイクイメージを作成
        (new UploadService())->uploadToS3($file, 'path/to/image', 'test_image.jpeg');
        Storage::disk('s3')->assertExists($file->hashName()); // ファイルが保存され、ストレージに存在することを確認
    }
}

参考:HTTP Tests - Laravel 10.x - The PHP Framework For Web Artisans

こちらもとても簡単にテストを実装することができました。本来はプロセス外の依存ではMockeryなどのモックライブラリを使用してテストを実装すると思います。しかし、LaravelのStorageクラスには標準でStorageのfakeメソッドやフェイクの画像を作成できるimageメソッドが用意されており、開発者に必要なものが初めから用意されています。

ファイルアップロード機能の中身

では、ファイルアップロード機能の中身をみてみましょう。

Storage::fake('s3')では、指定されたファイルストレージシステムがローカルドライバに置き換わり、テスト用のローカルストレージが作成されます。

具体的には、以下の処理で新しくローカルドライバが作成され、テストで使用されるドライバとしてセットされています。

<?php

/**
 * Replace the given disk with a local testing disk.
 *
 * @param  string|null  $disk
 * @param  array  $config
 * @return \Illuminate\Contracts\Filesystem\Filesystem
 */
public static function fake($disk = null, array $config = [])
{
    $disk = $disk ?: static::$app['config']->get('filesystems.default');

    $root = storage_path('framework/testing/disks/'.$disk);

    if ($token = ParallelTesting::token()) {
        $root = "{$root}_test_{$token}";
    }

    (new Filesystem)->cleanDirectory($root);

    static::set($disk, $fake = static::createLocalDriver(array_merge($config, [
        'root' => $root,
    ])));

    return tap($fake)->buildTemporaryUrlsUsing(function ($path, $expiration) {
        return URL::to($path.'?expiration='.$expiration->getTimestamp());
    });
}

github.com

なので、S3のようなクラウドのストレージサービスを使用している場合でもローカルにフェイクのストレージが作成され、S3と疎通するかわりにローカルのストレージと疎通します。

UploadedFile::fake()ではfakeという名の通り、Illuminate\Http\Testing\FileFactoryオブジェクトというdummyファイルを作成するためのオブジェクトが返されます。

<?php

/**
 * Begin creating a new file fake.
 *
 * @return \Illuminate\Http\Testing\FileFactory
 */
public static function fake()
{
    return new FileFactory;
}

github.com

そして、UploadedFile::fake()->image('test_image.jpeg')で使用しているIlluminate\Http\Testing\FileFactory::imageメソッドではdummyイメージを作成できるようになっています。

<?php

/**
 * Create a new fake image.
 *
 * @param  string  $name
 * @param  int  $width
 * @param  int  $height
 * @return \Illuminate\Http\Testing\File
 *
 * @throws \LogicException
 */
public function image($name, $width = 10, $height = 10)
{
    return new File($name, $this->generateImage(
        $width, $height, pathinfo($name, PATHINFO_EXTENSION)
    ));
}

github.com

テストダブルが行いやすい作りになっていますね。

なぜストレージサービスを簡単に切り替えられるのか

では、なぜこのように簡単にストレージサービスを切り替えることができるのでしょうか?

それは、最初に紹介したflysystemというライブラリが一つのインターフェースで様々なファイルシステムを使えるようになっているからです。

内部では、FilesystemAdapterというインターフェースが定義されており、このインターフェースを実装している具象クラスがファイルシステムごとに定義されます。flysystemに具象クラスが用意されているため、S3の他にGoogle Cloud StorageやAzuru Blob Storageなども利用可能です

そして、内部ではLaravelのファイルシステムを使用するためのAdapterとしてFilesystemAdapterクラスが存在しています。さらに、そのAdapterの詳細な処理が取り替え可能な部品としてファイルシステムごとにインターフェースを介して実装されています。なので、AdapterパターンとStrategyパターンを合わせて使ってるというような実装になっているように思います。

イメージは上の画像のような実装です。詳しくみていきましょう。

FilesystemAdapterクラスはdriverやadapterをプロパティとして持ちます。adapterの型はFilesystemAdapterインターフェースで定義されているものです。adapterはインスタンスとして渡されます。そして、ファイル保存時に使用したputFileAsやテスト時に使用したassertExistsをメソッドとして持ちます。

では、FilesystemAdapterにadapterのインスタンスが渡される処理を追っていきましょう。

Storage::disk('s3')の処理を追っていくと以下の処理が確認できます。

<?php

/**
 * Resolve the given disk.
 *
 * @param  string  $name
 * @param  array|null  $config
 * @return \Illuminate\Contracts\Filesystem\Filesystem
 *
 * @throws \InvalidArgumentException
 */
protected function resolve($name, $config = null)
{
    $config ??= $this->getConfig($name);

    if (empty($config['driver'])) {
        throw new InvalidArgumentException("Disk [{$name}] does not have a configured driver.");
    }

    $name = $config['driver'];

    if (isset($this->customCreators[$name])) {
        return $this->callCustomCreator($config);
    }

    $driverMethod = 'create'.ucfirst($name).'Driver';

    if (! method_exists($this, $driverMethod)) {
        throw new InvalidArgumentException("Driver [{$name}] is not supported.");
    }

    return $this->{$driverMethod}($config);
    }

github.com

そして、最後のreturnしている部分を追うと以下の処理につながります。

<?php

/**
 * Create an instance of the Amazon S3 driver.
 *
 * @param  array  $config
 * @return \Illuminate\Contracts\Filesystem\Cloud
 */
public function createS3Driver(array $config)
{
    $s3Config = $this->formatS3Config($config);

    $root = (string) ($s3Config['root'] ?? '');

    $visibility = new AwsS3PortableVisibilityConverter(
        $config['visibility'] ?? Visibility::PUBLIC
    );

    $streamReads = $s3Config['stream_reads'] ?? false;

    $client = new S3Client($s3Config);

    $adapter = new S3Adapter($client, $s3Config['bucket'], $root, $visibility, null, $config['options'] ?? [], $streamReads);

    return new AwsS3V3Adapter(
        $this->createFlysystem($adapter, $config), $adapter, $s3Config, $client
    );
}

このreturnで返されているAwsS3V3AdapterはインターフェースのFilesystemAdapterを実装している具象クラスではなく、FilesystemAdapterを継承しているクラスになります。つまり、このreturn処理でAwsS3V3Adapter(FilesystemAdapter)にadapterのインスタンスが渡されます。

ちなみに、最後に再度少しだけ言及しますが、カスタムストレージを使用する場合は、$this->customCreators[$name])が存在する分岐に入るので処理が分かれます。ここでも最終的には、FilesystemAdapterクラスを継承したインスタンスが返されます。このあたりの処理はサービスプロバイダが関わっており、説明が長くなるため詳しくは言及しません。

次に、FilesystemAdapterインターフェースはFilesystemAdapterクラスで使用する抽象的な操作がメソッドとして用意されています。その抽象的な操作をするメソッドがFilesystemAdapterクラスで定義されている具体的な操作をするメソッドで使用されています。(実際にはFilesystemOperatorというクラスを介して使用されていますが、こちらの説明は長くなるため割愛)例えば、putFileAsではインターフェースで定義されているwriteStreamやwriteメソッドが使用されています。(厳密にはputFileAsの中でputが呼ばれており、その中でwriteStreamやwriteメソッドが使われているのです)

<?php

/**
 * Write the contents of a file.
 *
 * @param  string  $path
 * @param  \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource  $contents
 * @param  mixed  $options
 * @return string|bool
 */
public function put($path, $contents, $options = [])
{
    $options = is_string($options)
                 ? ['visibility' => $options]
                 : (array) $options;

    // If the given contents is actually a file or uploaded file instance than we will
    // automatically store the file using a stream. This provides a convenient path
    // for the developer to store streams without managing them manually in code.
    if ($contents instanceof File ||
        $contents instanceof UploadedFile) {
        return $this->putFile($path, $contents, $options);
    }

    try {
        if ($contents instanceof StreamInterface) {
            $this->driver->writeStream($path, $contents->detach(), $options);

            return true;
        }

        is_resource($contents)
            ? $this->driver->writeStream($path, $contents, $options)
            : $this->driver->write($path, $contents, $options);
    } catch (UnableToWriteFile|UnableToSetVisibility $e) {
        throw_if($this->throwsExceptions(), $e);

        return false;
    }

    return true;
}

github.com

最後に、インターフェースを実装している具象クラスです。例えば、AwsS3V3AdapterクラスではAWSのSDKを使用してS3と疎通する処理が書かれています。

Storage::disk('s3')の処理を追っていきましたが、そこで見た通り、resolveというメソッドが存在し、ストレージごとにcreateXXXDriverがFilesystemAdapterクラスを継承したインスタンスを返しています。カスタムストレージの場合も同じです。つまり、最終的には同じ型のインスタンスが生成され、インターフェースが同じであることからファイルストレージサービスを切り替えられていることがわかります。

カスタムストレージについて

Adapterが存在しているため、Laravelの公式ドキュメントにもある通り、FilesystemAdapterを実装した新しいクラスを加えることで、新しいファイルシステムを追加することが可能です。

カスタムストレージは、先ほど言及したようにresolveメソッドでcustomCreatersの分岐に入るために、サービスプロバイダのStorage::extendという処理によってcustomCreatersに登録されます。この辺りの説明はサービスプロバイダの説明が長くなるため割愛します。

おわりに

今回はLaravelのファイルシステムについて見てみました。

Laravelのファイルシステムは使いやすいなということとフレームワークの中身を見てとても勉強になりました。

今後もLaravelに関する情報を発信していきたいと思います。

ⓒ i-plug,inc. All Rights Reserved.