【Laravel】Laravel Excelを使ったcsvエクスポートでの処理軽量化とコードリーディング

前回の記事の深掘り版です。
https://qiita.com/yuta-2001/items/a3ff68ba7b75a379e6e0

csvエクスポートは、PHPの組み込み関数でもLaravel Excelの機能でも基本的に重めの処理になっています。そこで今回はLaravel Excelを使用した場合にできる処理最適化の方法について調べてみたのでまとめています。
また、Laravel Excelの内部のコードを読んでその軽量化の仕組みを理解していきます！

# 結論：処理最適化にはqueryメソッドを使おう

前回の記事ではcollectionメソッドを使用してデータを取得していましたが、この部分をqueryメソッドにすることでパフォーマンスの向上を図ることができます。

## queryメソッド使用法

以下で使用することができます。
特段collectionメソッドの時と記述は変わりませんが、これをするだけで処理のパフォーマンスを向上させることが出来ます。

```php
// 1. 以下を追加でuseする
use Maatwebsite\Excel\Concerns\FromQuery;

// 2. useしたインターフェースを使用
class UserExport implements FromQuery

// 3. queryメソッドを定義する
// User::all() => User::query()にすることで、クエリビルダインスタンスを返す
public function query()
{
    User::query();
}
```

## queryメソッドを深掘り(スキップ可)

queryメソッドの用途や内部の仕組みをしっかりと理解しようと思い深掘りしたので参考までに。使用するだけでしたら上の使用法のみで大丈夫なのでスキップしても大丈夫です🙆

### FromQueryインターフェースの内容

queryメソッドを定義する際に使用したFromQueryインターフェースの内容は以下のようになっています。

```php
namespace Maatwebsite\Excel\Concerns;

use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder;

interface FromQuery
{
    /**
     * @return Builder|EloquentBuilder|Relation
     */
    public function query();
}
```
端的にいうとこんな感じでも良いよってことですね。

```php
//　会社に紐づくユーザーを全取得
// Illuminate\Database\Eloquent\Relations\Relation;を返します。
public function query()
{
    return $this->company->users();
}
```

### queryメソッドの内部処理

queryメソッドがどのように動いているかを見るために今回はExcelのdownloadメソッドが呼び出された際の動きを見ていきたいと思います。

まずはこのメソッドがどのように定義されているかを見ます。

#### Maatwebsite\Excel\Facades\Excel.php (downloadメソッド)

```php
public function download($export, string $fileName, string $writerType = null, array $headers = [])
{
    return response()->download(
        $this->export($export, $fileName, $writerType)->getLocalPath(),
        $fileName,
        $headers　
    )->deleteFileAfterSend(true);
}
```

##### コード説明

レスポンスに関しては単純にLaravelのdownloadメソッドを使っているみたいです。第一引数のExportファイルは$this→exportの第一引数として実行されていますね。では続いてはこのexportの定義内容を追っていきます。

#### Maatwebsite\Excel\Facades\Excel.php (exportメソッド)

```php
/**
 * @param  object  $export
 * @param  string|null  $fileName
 * @param  string  $writerType
 * @return TemporaryFile
 *
 * @throws \PhpOffice\PhpSpreadsheet\Exception
 */
protected function export($export, string $fileName, string $writerType = null): TemporaryFile
{
    $writerType = FileTypeDetector::detectStrict($fileName, $writerType);

    return $this->writer->export($export, $writerType);
}
```

##### コード説明
FileTypeDetector::detectstrick(\\$filename, \\$weiterType);
この部分でなんのファイルタイプでエクスポートを実施するかを判定していますね。
まだqueryメソッドとは関係なさそうなのでサクッと終わらせます。
続いて$this→writerのexportメソッドを見ていきましょう。

#### Maatwebsite\Excel\src\Writer.php (exportメソッド)

```php
/**
 * @param  object  $export
 * @param  string  $writerType
 * @return TemporaryFile
 *
 * @throws \PhpOffice\PhpSpreadsheet\Exception
 */
public function export($export, string $writerType): TemporaryFile
{
    $this->open($export);

    $sheetExports = [$export];
    if ($export instanceof WithMultipleSheets) {
        $sheetExports = $export->sheets();
    }

    foreach ($sheetExports as $sheetExport) {
        $this->addNewSheet()->export($sheetExport);
    }

    return $this->write($export, $this->temporaryFileFactory->makeLocal(null, strtolower($writerType)), $writerType);
}
```

##### コード説明
\\$this→openに関しては重い上にqueryメソッドと関係なさそうなので説明を省きます。
\\$sheetExportsの中に渡ってきた\\$export(Exportファイルのオブジェクト)を配列に入れ、その後の分岐で複数シートのオプションが適用されているかを調べています。
今回は複数シートではないので要素は一つです。
なのでforeach内の\\$this→addNewSheet()→export()は一度のみ実行されます。
続いては呼び出し先のメソッドを追うためにaddNewsheetメソッドを見ていきます。

##### Maatwebsite\Excel\src\Writer.php (addNewSheetメソッド)

```php
/**
 * @param  int|null  $sheetIndex
 * @return Sheet
 *
 * @throws \PhpOffice\PhpSpreadsheet\Exception
 */
public function addNewSheet(int $sheetIndex = null)
{
    return new Sheet($this->spreadsheet->createSheet($sheetIndex));
}
```

##### コード説明
このメソッドでは単純にSheetというクラスをインスタンス化しているみたいです。
sheetを複数作る場合には引数に何枚目のシートかが入るようになっていますが、今回は関係ないので続いてはSheetクラスのexportメソッドを見ていきます。

#### Maatwebsite\Excel\src\Sheet.php (exportメソッド)

```php
/**
 * @param  object  $sheetExport
 *
 * @throws \PhpOffice\PhpSpreadsheet\Exception
 * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
 */
public function export($sheetExport)
{
    $this->open($sheetExport);

    if ($sheetExport instanceof FromView) {
        $this->fromView($sheetExport);
    } else {
        if ($sheetExport instanceof FromQuery) {
            $this->fromQuery($sheetExport, $this->worksheet);
        }

        if ($sheetExport instanceof FromCollection) {
            $this->fromCollection($sheetExport);
        }

        if ($sheetExport instanceof FromArray) {
            $this->fromArray($sheetExport);
        }

        if ($sheetExport instanceof FromIterator) {
            $this->fromIterator($sheetExport);
        }

        if ($sheetExport instanceof FromGenerator) {
            $this->fromGenerator($sheetExport);
        }
    }

    $this->close($sheetExport);
}
```

##### コード説明
ここでやっとqueryメソッドと直接関わりのあるコードが出てきます。
queryメソッドを使用するためには冒頭で説明したようにfromQueryインターフェースを継承する必要があります。
ここでは継承元のインターフェースがどれかによって実行するメソッドを振り分けています。
では続いてはfromQueryメソッドとその関連コードを見ていきます。

#### Maatwebsite\Excel\src\Sheet.php (fromQueryメソッド)

```php
/**
 * @param  FromQuery  $sheetExport
 * @param  Worksheet  $worksheet
 */
public function fromQuery(FromQuery $sheetExport, Worksheet $worksheet)
{
    $sheetExport->query()->chunk($this->getChunkSize($sheetExport), function ($chunk) use ($sheetExport) {
        $this->appendRows($chunk, $sheetExport);
    });
}
```

**(getChunkSizeメソッド)**

```php
/**
 * @param  object|WithCustomChunkSize  $export
 * @return int
 */
private function getChunkSize($export): int
{
    if ($export instanceof WithCustomChunkSize) {
        return $export->chunkSize();
    }

    return $this->chunkSize;
}
```

**(construct内部)**

```php
/**
 * @param  Worksheet  $worksheet
 */
public function __construct(Worksheet $worksheet)
{
    $this->worksheet            = $worksheet;
    $this->chunkSize            = config('excel.exports.chunk_size', 100);
    $this->temporaryFileFactory = app(TemporaryFileFactory::class);
}
```

##### コード説明
ここをみると他のメソッドとfromQueryでの違いがわかりますね。
fromQueryがなぜ公式から軽量であると言われているかというと、このchunkメソッドが理由でしょう。
chunkメソッドはDBファサードが提供しているメソッドで、個切れにして処理を実行することができるものです。
chunkの詳しい説明は以下readableで確認してみてください。
https://readouble.com/laravel/9.x/ja/queries.html#chunking-results

また、チャンクするレコードの単位はgetChunkSizeメソッドで取得してきています。このメソッドではチャンクサイズに関するオプションが作成したExportオブジェクトに含まれているかを判定し、特になかった場合はconstructで定義しているexcel設定ファイルに記載の値を適用しています。

ちなみにですが、fromCollectionはこんな感じです。
開発者がExportクラスに定義したcollectionメソッドを呼び出した後に、allメソッドを実行して配列にしているみたいです。
また、このことよりcursor等の処理を軽くするメソッドに関してはほぼ意味をなさないことがわかりました。。。（汗）

**Maatwebsite\Excel\src\Sheet.php (fromCollectionメソッド)**

```php
/**
 * @param  FromCollection  $sheetExport
 */
public function fromCollection(FromCollection $sheetExport)
{
    $this->appendRows($sheetExport->collection()->all(), $sheetExport);
}
```

続いては最後にfromQueryでもfromCollectionでも内部で呼び出しているappendRowsメソッドを見ていきたいと思います。

#### Maatwebsite\Excel\src\Sheet.php (appendRowsメソッド)

```php
/**
 * @param  iterable  $rows
 * @param  object  $sheetExport
 */
public function appendRows($rows, $sheetExport)
{
    if (method_exists($sheetExport, 'prepareRows')) {
        $rows = $sheetExport->prepareRows($rows);
    }

    $rows = (new Collection($rows))->flatMap(function ($row) use ($sheetExport) {
        if ($sheetExport instanceof WithMapping) {
            $row = $sheetExport->map($row);
        }

        if ($sheetExport instanceof WithCustomValueBinder) {
            SpreadsheetCell::setValueBinder($sheetExport);
        }

        return ArrayHelper::ensureMultipleRows(
            static::mapArraybleRow($row)
        );
    })->toArray();

    $this->append(
        $rows,
        $sheetExport instanceof WithCustomStartCell ? $sheetExport->startCell() : null,
        $this->hasStrictNullComparison($sheetExport)
    );
}
```

##### コード説明
queryメソッドを使用した場合だと、チャンクする分のデータが\\$rowsに、Exportクラスのオブジェクトが\\$sheetExportに渡ってきます。
ここでは\\$rowsに格納されているデータ群に対してflatMapメソッドを使用して、それぞれの行に対してオプション等が設定されているかを検証しながら加工された配列に変換しています。
（flatMapの概要については以下のreadableを確認してください。）
https://readouble.com/laravel/9.x/ja/collections.html#method-flatmap

append内ではシートへの行追加等の作業をしていますが、ここからはqueryメソッドとは関係ありませんので割愛します。

## queryメソッド内部処理のまとめ

簡潔にいうと、queryの内部ではDBファサードが提供している**chunkメソッドを使用して、メモリを随時開放することで、処理を軽量にしている**みたいです！！
また、chunkするレコード数に関してはconfigファイル内の**exports.chunk_size**で定義できます。
chunkはwithとも互換性あるので、リレーション先も出力したい場合にはwithメソッドも使えるとさらに良いです🙆

# 最後に

今回はパフォーマンス向上のためにqueryメソッドを使用してみようといったことで、queryメソッドを深掘りしました。
かなり使用のメリットは理解できました。
Collectionの偉大さと、パッケージを開発する方々のすごさにも改めて気がつきました。