Slide 1

Slide 1 text

1/34

Slide 2

Slide 2 text

梶川 琢馬 / 𝕏 @kajitack 株式会社TechBowl / プロダクトエンジニア 複数のプロダクトでPHPを使った開発を経験してきました。 運動不足解消のために始めたトライアスロンにハマってます。 2/34

Slide 3

Slide 3 text

新潟の好きなところ 佐渡ヶ島1周 3/34

Slide 4

Slide 4 text

アジェンダ 1. Result型とは? 2. PHPでのResult型の実装 3. 実際に導入できるか? 4. try-catchプロジェクトでも活かせる学び 4/34

Slide 5

Slide 5 text

Result型とは? 5/34

Slide 6

Slide 6 text

Result 型 try-catchとは別のエラーハンドリングのための型 戻り値として「 成功 」または「 失敗 」を持つ 成功 (Ok): 正常な値 を返す 失敗 (Err): エラー情報 を返す PHPには標準では実装されていない Result型を返す処理 if ($input === null) { // 失敗の場合 return Result::err('Input cannot be null'); } else { // 成功の場合 return Result::ok($input); } 結果の検証と値の取得 if ($result->isOk()) { $value = $result->unwrap(); } else { $error = $result->unwrapErr(); } 6/34

Slide 7

Slide 7 text

Databa DataLayer BusinessLayer ServiceLayer Client Databa DataLayer BusinessLayer ServiceLayer Client 例外発⽣! 処理中断 処理中断 ⼤域脱出!深くネストした呼び出しから 直接上位レイヤーへジャンプ alt [ データベースエラー発⽣] [ 正常処理] リクエスト処理 ビジネスロジック実⾏ データ取得 クエリ実⾏ DB エラー 例外の伝播 例外の伝播 例外をキャッチ エラーレスポンス 結果 加⼯データ 処理結果 成功レスポンス 例外(try-catch) goto文のように処理の流れが突然変わる 関数のシグネチャから例外の発生が読み取れない 例外宣言の漏れや、catchの漏れが発生しやすい 7/34

Slide 8

Slide 8 text

エラーの返し方の比較 try-catch PHPDocで何の例外が投げられるか記載 /** * @throws UserNotFoundException * @throws OutOfStockException */ public function placeOrder(OrderInput $input): Order { $user = $this->userRepository->findById($input->userId); if ($user === null) { throw new UserNotFoundException('ユーザーが存在しません'); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { throw new OutOfStockException('在庫が不足しています'); } $order = new Order(); return $order; } Result型 戻り値の型にResultを指定 /** * @return Result */ public function placeOrder(OrderInput $input): Result { $user = $this->userRepository->findById($input->userId); if ($user === null) { return Result::err(OrderError::UserNotFound); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { return Result::err(OrderError::OutOfStock); } $order = new Order(); return Result::ok($order); } 8/34

Slide 9

Slide 9 text

エラーハンドリングの比較 try-catch try { $order = $this->orderService->placeOrder($input); } catch (UserNotFoundException $e) { // ユーザーが見つからない場合の処理 } catch (OutOfStockException $e) { // 在庫切れの処理 } catch (GuestUserCannotOrderException $e) { // ゲストユーザーの注文処理 } catch (RestrictedProductIncludedException $e) { // 制限商品が含まれている場合の処理 } try-catch なくても動く $order = $this->orderService->placeOrder($input); Result型 unwrap() で成功した場合のみ値を取り出す $result = $this->orderService->placeOrder($input); if ($result->isErr()) { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合は値を取り出す $order = $result->unwrap(); 9/34

Slide 10

Slide 10 text

Databas DataLayer BusinessLayer ServiceLayer Client Databas DataLayer BusinessLayer ServiceLayer Client Result::err() を返す エラーチェック エラーチェック 直線的な処理の流れ! 戻り値として明⽰的にエラーを伝播 Result::ok() を返す alt [ データベースエラー発⽣] [ 正常処理] リクエスト処理 ビジネスロジック実⾏ データ取得 クエリ実⾏ エラー結果 "Result" "Result" エラーレスポンス 結果 "Result" "Result" 成功レスポンス Result型のメリット 関数のシグネチャから「この関数は失敗する可能性 がある」ことが明示的 呼び出し側は戻り値のチェックを強制される (静的解 析ツールは必要) 処理の流れが直線的 10/34

Slide 11

Slide 11 text

PHPでのResult型の実装 11/34

Slide 12

Slide 12 text

Result型のクラス定義 基底クラスの定義 Ok(T) Err(E) Result 値: T エラー: E /** * @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ abstract readonly class Result{} OkとErrそれぞれのクラスを定義 /** * @template T * @extends Result */ final readonly class Ok extends Result { /** * @param T $value */ public function __construct( private mixed $value ) { } } /** * @template E * @extends Result */ final readonly class Err extends Result { /** * @param E $value */ public function __construct( private mixed $value ) { } 12/34

Slide 13

Slide 13 text

ジェネリクスを使おう ジェネリクスを使わない場合... Result型はそれぞれのケースで定義するか、 ユニオン型やmixed型を使うことになってしまう class Result { // Bad: Unionでひたすら定義 public static function ok(User|Order|Product $value) { return new Ok($value); } // Bad: mixedで型安全性が失われる public static function err(mixed $error) { return new Err($error); } } // Bad 個別に定義 class UserResult { public static function ok(User $value) { return new Ok($value); } public static function err(UserError $error) { return new Err($error); } } 13/34

Slide 14

Slide 14 text

PHPのジェネリクス @template PHPにはネイティブなジェネリクス機能がない PHPDocと静的解析ツールを組み合わせて実現 @template でテンプレート型を定義 @return でTemplate型を指定 /** * @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ class Result {} /** * @return Result */ function getUserById(int $userId): Result { } 14/34

Slide 15

Slide 15 text

Result型のメソッド実装 基本操作メソッド isOk() 成功かどうかを確認 isErr() 失敗かどうかを確認 unwrap() 成功時の値を取得 unwrapErr() 失敗時のエラーを取得 unwrapOr(default) 成功時の値またはデフォ ルト値を取得 関数型プログラミング用メソッド map(fn) 成功値に関数を適用 mapErr(fn) エラー値に関数を適用 andThen(fn) 成功時に別のResultを返す関数を 適用 orElse(fn) 失敗時に別のResultを返す関数を適 用 etc... interfaceはRustのResult型を参考に。 最初は、基本的なメソッドだけでもOK 15/34

Slide 16

Slide 16 text

基本的なメソッドの実装 Ok public function isOk(): bool { return true; } public function isErr(): bool { return false; } /** * @return T */ public function unwrap(string $message = ''): mixed { return $this->value; } public function unwrapErr(string $message = 'called Result::unwrapErr() on an Ok value'): never { throw new \RuntimeException($message); } Err public function isOk(): bool { return false; } public function isErr(): bool { return true; } public function unwrap(string $message = 'called Result::unwrap() on an Err value'): never { throw new \RuntimeException($message); } /** * @return E */ public function unwrapErr(string $message = ''): mixed { return $this->value; } 16/34

Slide 17

Slide 17 text

基本的にはこれでも十分 $result = $this->orderService->placeOrder($input); if ($result->isErr()) { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合は値を取り出す $order = $result->unwrap(); 17/34

Slide 18

Slide 18 text

Result型を発展させていくと andThen(fn) 成功時に別のResultを返す関数を適用 $result = $this->placeOrder($input) ->andThen(fn($order) => $this->addPoint($order)) ->andThen(fn($order) => $this->notify($order)); if ($result->isErr()) { $error = $result->unwrapErr(); return response()->json([ 'error' => [ 'code' => $error->code(), 'message' => $error->message(), ] ], $error->statusCode()); } return response()->json(['message' => '注文完了!'], 201); 18/34

Slide 19

Slide 19 text

実際に導入できるのか? 19/34

Slide 20

Slide 20 text

例外を完全に置き換えるのは難しい 現実的な課題 一切例外を使わなくてもライブラリが投げてくる 例外を使いたいケース トランザクションのロールバック フレームワークのエラーハンドリング HRTTPレスポンスの生成 エラーレポート 完全に置き換えるのは現実的ではない オススメのアプローチ 単なるエラーハンドリングではなく、ビジネスロジッ クとして使用する 20/34

Slide 21

Slide 21 text

ビジネスロジックとして使用する 21/34

Slide 22

Slide 22 text

例外にビジネスロジック が含まれる 技術的例外 システムの異常状態 ネットワーク障害、DBコネクション切断など 実装ミスやインフラ側で対処が必要なエラー ビジネス例外 預金額を超える引き出し、在庫切れの注文など ビジネスロジックの一部として失敗が想定できるケ ース 22/34

Slide 23

Slide 23 text

失敗が想定できる例外? 「例外」という名前が示す通り、本来は「例外的 な」状況に使うべきもの しかし、失敗が想定できる結果であるビジネスケー スをなぜ「例外」としてモデリングするのか? 処理の失敗は「例外」ではなく、 成功と同様に考慮すべき結果 → Result型で表現する 23/34

Slide 24

Slide 24 text

責務をレイヤーで分けて考える 24/34

Slide 25

Slide 25 text

各レイヤーで投げる例外 ドメイン層で投げる例外はResult型で表現できそう 25/34

Slide 26

Slide 26 text

バリューオブジェクト 不変条件違反(バリデーションミス) LogicExceptionを投げる ここで投げた例外はフレームワークのハンドラまで キャッチしない ドメインロジック ビジネスプロセスの結果表現 -> Result型 リポジトリ 「見つからない」状態 -> Result型 アプリケーション ドメイン層のResult型を受け取り、Presentationに適 した例外を投げる ここで投げた例外はフレームワークのハンドラまで キャッチしない 26/34

Slide 27

Slide 27 text

Result 型のエラー型 E に何を指定するか? 1. Exceptionインスタンス 既存の例外ベースのコードとの互換性を保つ unwrap()時に例外を再度投げる実装も可能 2. カスタムのドメインエラー型 ドメイン層でビジネスロジックに特化する 失敗の種類をEnumで定義 失敗ケースをパターンマッチして処理を分岐できる enum UserError { NotFound, InsufficientBalance, InvalidInput, } $result = $userService->findById($userId); if ($result->isErr()) { // エラー処理 match ($result->unwrapErr()) { UserError::NotFound => $this->handleUserNotFound(), UserError::InsufficientBalance => $this->handleInsufficientBalance(), UserError::InvalidInput => $this->handleInvalidInput(), }; } 27/34

Slide 28

Slide 28 text

とはいえ、 導入は難しい ビジネスロジックがしっかりと分けられている必要がある 28/34

Slide 29

Slide 29 text

try-catchでもResult型の考え方を活かす 29/34

Slide 30

Slide 30 text

失敗のケースを明示的に 技術的例外 個別にcatchせず、フレームワークのハンドラに委譲 ビジネス例外 Result同様に、独自の例外クラスを定義 UserNotFoundException InsufficientStockException PaymentFailedException 明確なエラー処理を行う 30/34

Slide 31

Slide 31 text

例外発生の可能性を明示する /** * ユーザーをIDで検索する * @param int $userId ユーザーID * @throws UserNotFoundException ユーザーが見つからない場合 */ public function findById(int $userId): User エラー処理の漏れを防ぐ工夫 具体的な例外型を指定してcatchする キャッチした例外を「握り潰さない」 31/34

Slide 32

Slide 32 text

静的解析ツールの活用 PHPStanの例外チェック機能 throwしてるのに @throws アノテーションがない場 合や、catchしていない例外がある場合に警告 ビジネス例外を明示的に定義 exceptions: checkedExceptionClasses: # 独自定義した例外の基底クラス - Package\Domain\Exception\DomainException check: missingCheckedExceptionInThrows: true tooWideThrowType: true PHPStormのInspections PHP -> Analysis -> Unchecked Exceptions スルーしても良い例外を設定することで、過度なcatch を防ぐ 32/34

Slide 33

Slide 33 text

まとめ try-catchの問題点 明示的にエラーを扱いづらい Result型のメリット 成功/失敗を明示的に表現する型安全な方法 ビジネス例外をドメインロジックの一部として扱える 導入難しい try-catch避けられない カスタム例外を使ってもある程度の効果は得られる エラーもビジネスロジックの値として捉えることが大事 33/34

Slide 34

Slide 34 text

34/34