のんラボ

【自己流】エラーハンドリング【リライト記事】

2019/12/22 2022/08/03 【自己流】エラーハンドリング【リライト記事】

【自分流】エラーハンドリング・例外に対する考え方【Laravelとか含む】

こんにちは。のんです。

今回の記事は社内の勉強会で利用した記事のリライト記事になります。

内容はエラーハンドリングについてです。

例外に対する考え方について

言語にもよりますが、PHPer の自分にとって例外は goto 構文と同じです。一度例外をスローするとどこでキャッチされるかわからない不安があります。

そのあたり Rust や Go のようにエラー型を返す方式は「なるほど」と感じました。PHP でもこれらの言語のように例外に対して厳しく接するとこのような書き味になるかなと考えました。

 e.g.)copied.<?php
declare(strict_types=1);

$example = new Example();
if (!$example->isValid()) {
    // どこに飛んでいくかわからない。
    throw new RuntimeException();
}

例外のキャッチの仕方について

基本的に1階層ごとに必ずキャッチする方式がいいと思います。

採用するアーキテクチャにもよりますが、大抵は層ごとに関心事が別れていたり、役割が別れていたりします。
その層に必要な例外を独自に作成してスローするということです。

e.g.) Infrastructure 層の Repository では。

 e.g.)copied.<?php
declare(strict_types=1);

final class SampleRepository
{
    public function save(Sample $sample): void
    {
        $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id());
        if ($sampleModel instanceof SampleModel) {
            // データが存在しない場合は独自の例外をスローする。
            throw new SampleNotExistsException('Failed to save sample.');
        }
        
        // データ更新など
        
        if (!$sampleModel->save()) {
            // データの保存に失敗したときも独自の例外をスローする。
            throw new SaveSampleException('Failed to save sample.');
        }
    }
}

Sample のデータが無いときの例外 SampleNotExistsException 、データの保存に失敗したときの例外 SaveSampleException をそれぞれスローするようにします。

この例外は上の階層についての知識は特に持たず、この層独自の例外として宣言しておきます。

e.g.) Application 層の ApplicationService では。

 e.g.)copied.<?php
declare(strict_types=1);

final class UpdateSample
{
    private LoggerInterface $logger;
    private SampleRepository $sampleRepository;
    
    public function __construct(
        LoggerInterface $logger,
        SampleRepository $sampleRepository
    ) {
        $this->logger = $logger;
        $this->sampleRepository = $sampleRepository;
    }
    
    public function handle(UpdateSampleInputPort $input): void
    {
        // 何かしらの処理

        // 権限を調べるとか。
        if (!$sample->isOwner()) {
            // Application 層のユースケースに合った例外をスローする。
            // ここでは所有者ではない例外をスローする。
            throw new NotOwnerException('Not owned by Sample.');
        }

        // 何かしらの処理
        
        try {
            $this->sampleRepository->save($sample);
        } catch (SampleNotExistsException | SaveSampleException $e) {
            $this->logger->error($e);
            // Application 層のユースケースに合った例外をスローする。
            // 下の階層のメッセージをそのまま採用する。
            throw new UpdateSampleException($e->getMessage());
        } catch (Throwable $e) {
            $this->logger->error($e);
            // Application 層のユースケースに合った例外をスローする。
            // Fatal なエラーなので、ログのみに残してメッセージは固定とか。
            throw new UpdateSampleException('Fatal error.');
        }
    }
}

Repository 特有の例外をしっかりキャッチして、この層特有の例外をスローし直すということが重要だと思います。

バケツリレー的になってコードを書くのは面倒ですが、層ごとに関心事が別れた例外ができて、何が飛んでくるのかが良くわかるようになるので見通しが良くなります。

SaveSampleExceptionUpdateSampleException ということが重要です。

  • SaveSampleException は Repository で「データの保存に失敗する」という関心事についての例外。
  • UpdateSampleException は「 Sample を保存する」というユースケースが失敗したという関心事の例外。

です。

なので、文面は似ていますが、それぞれ関心事が違うので役割のレイヤーが違う。というイメージです。

こうすることで、層ごとに分離できるようになり他のユースケースなどでリポジトリを再利用しやすくなったり、違うコントローラーでユースケースを再利用することがしやすくなります。

e.g.) Adapter 層の Controller では。

 e.g.)copied.<?php
declare(strict_types=1);

final class UpdateSampleAction
{
    private LoggerInterface $logger;

    public function __construct(
        LoggerInterface $logger,
        UpdateSample $updateSample
    ) {
        $this->logger = $logger;
        $this->updateSample = $updateSample;
    }

    public function __invoke(UpdateSampleRequest $request)
    {
        try {
            try {
                // ユースケースに入力するための Input を作成する。
                // ここでは SampleValueObject が InvalidArgumentException をスローするとする。
                $input = UpdateSampleInput(new SampleValueObject($request->input('sample', '')));
            } catch (InvalidArgumentException $e) {
                $this->logger->error($e);
                // ValueObject のエラーなので、400 BadRequest を返す例外をスローする。
                throw new BadRequestHttpException($e->getMessage());
            }

            try {
                $this->updateSample->handle($input);
            } catch (NotOwnerException $e) {
                $this->logger->error($e);
                // 権限 のエラーなので、404 NotFound を返す例外をスローする。
                // (403 でもいいが、ここではデータがることを悟られたくないため404とする。要件にも寄る。)
                throw new NotFoundHttpException($e->getMessage());
            } catch (UpdateSampleException $e) {
                $this->logger->error($e);
                // 更新できなかったエラーなので、500 InternalServerError を返す例外をスローする。
                throw new InternalServerErrorHttpException($e->getMessage());
            }
        } catch (BadRequestHttpException | NotFoundHttpException $e) {
            $this->logger->error($e);
            // 「ユーザー起因のエラー」なので、そのままエラーレスポンスを返す。
            return $e->getApiProblemResponse(); // エラーレスポンスを返すためのメソッド。
        } catch (InternalServerErrorHttpException $e) {
            $this->logger->error($e);
            // 「システム起因のエラー」なので、レポートに残す目的で上位層のエラーハンドラにスローする。
            // Laravel を想定するなら Handler クラス。
            throw $e;
        } catch (Throwable $e) {
            $this->logger->error($e);
            // 「それ以外の想定外のエラー」なので、レポートに残す目的で上位層のエラーハンドラにスローする。
            // Laravel を想定するなら Handler クラス。
            throw $e;
        }
    }
}

Controller は Http Request や Http Response を捌く層なので、 Application 層からキャッチした例外や、 ValueObject を生成するときにスローされる例外を捌きます。

特に Controller では4xx系と5xx系で分けるべきと考えているので、キャッチした例外の種類(後述)でスローする HttpException に詰め替えてスローしています。

さらに、4xx系はレポートに残す必要の無い例外なので Adapter の Contoller の中でエラーレスポンスを返すようにします。対して、5xx系はレポートに残したい例外なので、上位のハンドラにスローしてレポーティングしてもらいます

例外の種類 / 階層について

これまでに少し出てきた例外の種類と階層について言及したいと思います。

まずは階層について

階層については前章の例がわかりやすいです。

  • プログラム上の例外
    • 引数が違う
    • DB接続に失敗した
  • 仕様上の例外
    • データにアクセスする権限がない
    • 更新に失敗した

プログラム上の例外はいわゆる低レイヤの例外なのでシステム的なエラーを保持します。
前章の例で説明すると Infrastructure 層の RepositorySampleNotExistsException や、SaveSampleException がこれに当たります。

仕様上の例外はいわゆるビジネスロジックの例外なので、仕様的なエラーを保持します。
前章の例で説明すると Application 層の ApplicationServiceNotOwnerException や、 UpdateSampleException に当たります。

ここまででバケツリレーした例外を最終的に Controller でキャッチして更に上位のハンドラにスローするかエラーレスポンスを返すか決めます。このときはエラーの種類(後述)が 重要になってきます。

次に種類について

例外の種類というのは、どのような性質を持つかということだと考えています。

  • 重要な例外
    • 想定できないシステムエラー
    • 実行中に発生してしまうランタイムエラー
  • 軽微な例外
    • 入力値が起因のエラー
    • 権限が起因のエラー

重要な例外開発者起因のエラー。つまり開発者がレポーティングしてきっちり監視しておきたいです。Laravel だと Handler などで収集外部サービスなど経由で監視したい。(後述)

前章では UpdateSampleException がこれに当たります。

軽微な例外ユーザー起因のエラー。つまり画面でエラーメッセージを出せるようなエラーですし、頻発して起こることが想定できます。なので管理する必要は薄いです。ロギングだけしておけば、レポーティングの必要はないでしょう。そのまま Controller で Response を返してしまいます。

前章では InvalidArgumentExceptionNotOwnerException がこれに当たります。

Laravel の Handler クラスについて

Laravel を利用しているプロジェクトでは、一般的に Handler.php が便利なので、そこにスローする形をとりがち。

Laravel というシステムに何も考えずに乗っているのはとても楽ちんではあるんですが、大規模なシステムのエラーを追おうとすると複雑になってしまいます。関心事の分離という観点でもしっかり Handler を利用すべき時とそうでない時を使い分けるべきです。

結論としてはこれまでにも述べているように、Handler には重要なエラーのみをスローするで良いかと思います。

その理由については書いていきます。

一般的に

などを Laravel で作成されているプロジェクトに導入するときは Handler に記載するでしょう。

copied.public function report(Throwable $e): void
{
    if ($this->shouldReport($e) && app()->bound('sentry')) {
        app('sentry')->captureException($e);
    }
    parent::report($e);
}

ということは4xx系のエラーも Handler にスローしてしまうとこれらも Sentry に記録され、本当にチェックしたい例外が埋もれてしまいます。
そこで Controller でエラーレスポンスを返してしまうもの(軽微な例外)と、 Handler にスローするもの(重要な例外)で種類分けするという流れです。このとき、Handler にスローされる例外の種類が少ないので render の中身もスッキリするでしょう。これもまた一つのメリットですね。

copied.public function render($request, Throwable $e)
{
    // 1. Laravel が実装している例外について
    if ($e instanceof NotFoundHttpException) {
        // Laravel の NotFoundHttpException をどのように返すか書く。
    }
    if ($e instanceof ValidationException) {
        // Laravel の ValidationException をどのように返すか書く。
    }
    
    // 2. Adapter 層の Controller で利用している HttpException の基底例外クラスについて
    if ($e instanceof HttpException) {
        // 独自に定義した例外の HttpException をどのように返すか書く。
        // API サーバーだと過程するなら RFC7807 に沿ったレスポンスを返すとか。
        return $e->getApiProblemResponse();
    }
    
    // 3. それにも対応していない想定外のエラーについて
    if ($e instanceof Throwable) {
        // どのように返すか書く。
    }
}

Laravel の4xx系エラー(軽微なエラー)をレポーティングをしたくないとすれば $dontReport に登録しておくのも手。

しかし、自分で決めた独自例外をハンドリングせず何もしないまま Handler へスローして、 $dontReport で制御しようとするのは安直で管理が煩雑になりやすい。

よって、 $dontReport に登録するものは Laravel 特有の例外のみで良さそうです。

あるあるなのが、エラー時に Boolean 型で判定するパターン。
Boolean で返すべきなのか、例外をスローすべきなのか。

copied.<?php
declare(strict_types=1);

final class SampleRepository
{
    public function save(Sample $sample): bool
    {
        $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id());
        // データ更新など
        return $sampleModel->save()
    }
    
    // or
    
    public function save(Sample $sample): void
    {
        $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id());
        // データ更新など
        if (!$sampleModel->save()) {
            throw new SaveSampleException('Failed to save sample.');
        }
    }
}

個人的には例外をスローでいいと思う。

理由としては、

  • 保存できなかった。というのはエラーなので例外をスローしたい
  • Boolean は ON / OFF とかに利用したい。成否を Boolean で表現するのは微妙(な気がする)
  • この save がなにか特定のオブジェクトを返す時、返り値が mixed になってしまい、可読性(見通し)が下がる

などがあります。

基本的に成否に関しては Boolean ではなく例外をスローしたい気持ち。Rust や Go も同じ考え方(のはず)

考えるべき点:データ取得処理でデータが無いときに NULL を利用する

次にあるあるなのが、DBからデータを探して存在しないときに null を返す場合です。
null で返すべきか、例外をスローすべきなのか。

copied.<?php
declare(strict_types=1);

final class SampleRepository
{
    public function findById(SampleIdentifier $sampleIdentifier)
    {
        // モデルを返すのかnullを返すのかわからない、mixed。
        return $this->sampleModel->newQuery()->find((string)$sampleIdentifier);
    }
    
    // or
    
    public function findById(SampleIdentifier $sampleIdentifier): SampleModel
    {
        $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id());
        if (!$sampleModel instanceof SampleModel) {
            throw new SampleNotExistsException();
        }
        return $sampleModel;
    }
}

個人的には例外をスローすべきと考えています。

そもそも nullable プログラムが嫌いというのが理由の筆頭です。

  • nullableを許容したくない
  • 例外をスローすることで「データが存在しない」というエラーを表現できる
  • 上位の層で拡張性が上がる。

が理由でしょうか?

例外を投げるべき。というより、 nullable を避けるべき。のほうがとても強い。

最後に

基本的に例外はガンガンスローすべきというのが私の結論となりました。

Rust や Go を書くと例外を投げる。ではなく、エラー型を返すというコードをよく書くようになり、エラーの流れがとてもわかりやすく感じました。
記事をネットで調べていると、中には例外が無いことに不満がある記事もありましたが、私は逆でした。
また、そもそも例外をスローしないようなコードを書こう。という設計についても理解できます。
ただ、そうすると Booleannull を返すしかなくなるので、 mixed な値を返す関数を大量精算してしまいます。それを避けるために仕方なく例外を利用する。というニュアンスでしょうか。PHPでもエラー型を返したい

try - catch 地獄(例外のバケツリレー)にはなりますが、エラー時のデータフローがよくなると感じているのでやるべきかな?とは思います。

今回は初心者にはあまり注目されない(?)エラーハンドリングについて書いてみました。

所属しているチームのメンバーもよく詰まっている部分なので、この機会に自分の考えを言語化してみました。

自分の考えを書く系の記事を少しずつ増やそうかな?と思っています。

そのときはよしなに。

.