はじめに
今携わっているプロジェクトで凄腕エンジニアさんと一緒に開発をさせていただいているのですが、その凄腕エンジニアさんから教えていただいた例外の話がとても勉強になり、
さらにこの例外の話を他のプロジェクトのエンジニアさんに伝えたところ、反応が良く、とても勉強になりました!という声をいただけたので、アウトプットしていきたいと思います。
(この記事の中で凄腕エンジニアさんのことはTさんと呼ぶことにします。)
※【凄腕エンジニアさんから学んだ例外の話】の補足 というQiita記事を書きました。
この記事を読み終わった後に疑問が残った人などは補足資料として読んでいただけると嬉しいです。
例外の考え方の源
Tさんの例外の考え方は
http://diveintopython3-ja.rdy.jp/your-first-python-program.html#exceptions
↑こちらのPythonの例外の考え方に大きく影響を受けているようです。
例外とは何か? 通常これはエラーであり、何かがうまくいかなかったことを知らせるものだ(エラーではない例外もあるが、現時点ではそれは気にしないでおこう)。一部のプログラミング言語は、エラーを戻り値として返すことを奨励しており、あなたは戻り値をチェックする。Pythonは例外の使用を奨励しており、あなたはそれを処理するのだ。
例外は、必ずしもプログラムのクラッシュを引き起こすわけではない。例外は処理できるのだ。例外の原因が完全にコードのバグ(存在しない変数にアクセスするなどの)であることもあるが、例外の発生が予期できることもある。ファイルを開こうとしたとき、そのファイルは存在しないかもしれない。モジュールをインポートしようとするとき、そのモジュールはインストールされていないかもしれない。データベースに接続しようとしたとき、そのデータベースは利用できないかもしれないし、アクセスするための適切なセキュリティ証明書を持っていないかもしれない。もし、そのコードが例外を発生させることを知っているのならば、try...exceptブロックを使って例外を処理すべきだ。
例外は、それを発生させた関数の中で処理される必要はない。もし、その関数が例外を処理しない場合は、例外はその関数を呼び出している関数へ渡され、更にその関数を呼び出している関数に渡され、以降同様に「スタックをさかのぼる」。もしどの関数でも例外が処理されない場合は、プログラムはクラッシュし、Pythonが "Traceback" というものを標準エラー出力に出力し、一連の流れが終わる。繰り返すが、これは望む結果かもしれない。それはあなたのプログラムが何をするのかによるのだ。
例外はそんなに大層な、難しいものではなくて、期待する挙動以外は全て例外で良くて、かといって全部キャッチする必要はなくて、必要なものだけキャッチしてそうじゃないものはキャッチしなくて良い。
例外は時にはキャッチせずに見せるべきところに見せる必要がある。
そういうようなものなんだよと教えていただきました。
このような例外の捉え方や考え方を聞いてから、例外に対してあまり身構えなくなりました(難しく考えすぎないようになりました)。
具体的な話
方針:例外のケースごとに独自例外を作り、それを送出するようにする
たとえば申請APIを作るとして、事前条件として未申請の状態でなければいけなかったとします(申請状態でこのAPIを叩かれるのは期待していないとします)。
その時の例外対応としてApplicationExceptionクラスを作ります(自分のプロジェクトではApplicationの部分はプロジェクト名になってたりします)。
もし既に申請がされていたらApplicationExceptionを送出するようにします。
// 事前条件のチェック
if(申請済みかどうか){
throw new ApplicationException('already submitted');
}
申請処理()
ApplicationExceptionが送出されたということは事前に想定内できている例外が送出された という表現になります。
自分が携わっているプロジェクトではバックエンドもフロントエンドもTypeScriptを利用していて、モノリポチックな構成になっているので型の共有ができます。
ですので、フロントエンド側では送出された例外がApplicationExceptionだったら、「申請済みです」 というフラッシュメッセージを出すというふうに制御しています。
そして運用にのって使われてきたタイミングで、たとえば【ある項目が全て入力されている】という事前条件(一個でも入力されていない項目があった場合には申請できないという条件)を発見したとします。
もし事前条件を満たせない状態で申請をした場合は、「入力項目に不備があります」というフラッシュメッセージを出したいというふうになったとします。
項目を入力するページがあってそのページに申請ボタンがあって申請できる。申請に必要な項目は申請APIのバリデートでやるべき。
というのはそうなのですが、自分が想像していたのは少し違いまして、仮登録のような形で項目を入力してもらい、あるタイミングで申請ボタンを押す(この申請ボタンは一覧ページなど、項目を入力するページとは違う場所にある) というような画面を想像していました。
背景を共有できず、例が紛らわしくて申し訳ありません!
そうなった場合、AlreadySubmittedExceptionクラス(既に申請されている例外。命名は許してください。。。)と、IncompleteInputExceptionクラス(入力不備があったよ例外。命名は許してください。。。)を作成し、それを送出するようにします。
// 事前条件のチェック
if(入力不備があるかどうか){
throw new IncompleteInputException('incomplete input');
}
if(申請済みかどうか){
throw new AlreadySubmittedException('already submitted');
}
申請処理()
そしてフロントエンド側でもし例外がAlreadySubmittedExceptionだったら、「申請済みです」 というフラッシュメッセージを出す。
もし例外がIncompleteInputExceptionだったら、「入力項目に不備があります」というフラッシュメッセージを出す。
というふうに制御するようにします。
こうすることで、申請されている状態で申請ボタンを押してしまった場合と、入力項目に不備があった状態で申請ボタンを押してしまった場合とのフラッシュメッセージを変えることができます。
補足:最初から具体的な例外クラスは作らない。運用にのって不便が出てきたタイミングで具体的な例外クラスを作る。
上の段落ではまずApplicationExceptionクラスを作りました。
最初からAlreadySubmittedExceptionクラス(具体的なクラス)は作りませんでした。
これには理由があって、APIの使われ方によっては、そこまで対応する必要があるかどうかは最初からわからないからです。
実はApplicationExceptionだけでよかった可能性もあったのです。
(申請APIの事前条件が「申請していない状態」というもののみであれば、
もしくは複数の事前条件があったとしてどちらかが満たせなかった場合に「申請できませんでした」というフラッシュメッセージを出すだけで良いというような要件だった場合などです。)
サービスが運用にのって使われる回数が増えてくるとさまざまな状況でコードが実行されるので、見えなかった事前条件や、わかりにくいフラッシュメッセージがわかってきたりします。
このタイミングで一番良いさじ加減の例外対応の形が見えてきます。
(具体でいうと、どういう例外クラスを作って、どういうフラッシュメッセージをフロントエンドに見せてあげるのが良いのかなどがわかってきます。)
複数の事前条件が見え、その事前条件が満たされていない場合に、そのケースごとにブラウザでの対応を変えたい。
と明確になったタイミングで具体的な例外クラスを作るのが良いかと思います。
補足:例外を投げる時に渡すメッセージについて
先ほどの擬似的なコードでは例外のメッセージに、「already submitted」や「incomplete input」というメッセージを渡しました。
ここのメッセージに関しては、サービス利用者、悪意のあるサービス利用者、開発者の視点(SentryのSlackグループに入っている人なども含め)でメッセージを考えます。
メッセージが丁寧すぎると、詳細すぎると悪意のあるサービス利用者に攻撃のヒントを与えることになりますし、もしサービス利用者にそのメッセージが見られた時になんだこの文?って思われるのも良くないですし、Sentryや例外の通知をみて、調べにくい、すぐにわからないというのも良くないです。
ここら辺のバランスを考えてメッセージを考えます。
ちなみにですが、今自分の携わっているプロダクトでは管理者しか叩けない、叩かないAPIの独自例外に関しては所々日本語でメッセージを入れていたりします。
そうすることによって調査がしやすかったりします。
補足:例外が起こった時の挙動を決めるのはプレゼンテーション層
よくある設計の話でドメイン層を分離させるというのがあります。
ドメイン層に業務ロジックをまとめ、それをWebやバッチから呼び出したりするような感じです。
(またここら辺の設計の話はまたどこかでQiitaにまとめられればと思います。。。)
例えばドメイン層で例外をcatchして、ログを残す、Slackに通知する、Sentryに通知するということをしてしまっている場合、例外が起こった時の挙動をドメイン層で決めてしまっています。
Webやバッチという文脈によってログを残す残さないを変えたり、通知先を変えたりなど、例外が起こった時の挙動はプレゼンテーション単位で制御したい場面は多いと思います。
ですので基本はcontrollerの依存先のメソッドではtry-catchで例外が起こった時の挙動を決めるのではなく、例外を投げるだけにして、controllerでその例外が起こった時の挙動を制御するようにします。
自分が開発に携わっているプロダクトでも、controllerでtry-catchをして例外が起こった挙動を制御するようにしています。
例外を握りつぶしても良い場合、トランザクションでロールバックが必要な場合、このロジックで例外が出たらWebやバッチなどのプレゼンテーションに関係なくある場所へ通知させたい などの用件があった場合はドメイン層でのtry-catchも必要になってきます。
ここまでドメイン層という言葉を使ってきましたが、この章ではcontrollerやバッチの依存先のクラスやメソッドでtry-catchで例外が起こった時の挙動を決めてしまうと、controller、バッチそれぞれで例外が起こったときの挙動を定義しづらくなってしまうので、例外が起こった時の挙動はcontroller側やバッチ側(プレゼンテーション側)で決めるのが良さそう ということを表現したかったです。
補足:この方針の何が嬉しいのか
例外をトリアージ、調査する時に楽です。
たとえばSentryを使うとSlackで通知されるタイミングでどのような例外が起こったのかが一目でわかります。
このときに独自例外を使っているとその例外クラス名と、例外を投げるときに渡したメッセージをSlack上で確認することができます。
↑この【RangeError】の部分が独自例外クラス名、【Invalid time value】の部分が例外を投げるときに渡したエラーメッセージになるイメージです。
管理画面のAPIに関してはここのエラーメッセージを日本語にしている部分もあり、ここが日本語だと本当に調査が楽です。
独自例外クラス名とエラーメッセージが分かれば、そのクラス名やメッセージから何が起こったのかが大枠わかりますし、コードを調べるときも独自例外クラス名やエラーメッセージでコードに全体検索をかければ、どこで何が起こっているのかを調べることは容易です。
あとは逆に、起こったのが独自例外ではなかった場合に気持ち緊急度が増します。
こちらが考慮しきれていなかった場合だったりもするので。。。
あとは例えばこの例外が起こったときはこの値を返す。。。としてしまうと、呼び出し元が大変になってしまったり、呼び出す階層が深くなってしまうと呼び出し元に何が起こったのかを伝えるのが大変になってしまいます。
その点例外を投げるようにすると階層が深くなってしまっても呼び出し元に何がどこで起こったのかを値で表現するよりかは伝えやすくなります。
補足:メンテナンス前提の例外
何か操作した時にバッグエンドでどんな例外が起こったのかをしっかりとブラウザに伝えて、フロントエンドではバックエンドで起きた例外別に適切な案内をユーザーさんにすることができて、さらに例外が起こった時にエンジニアが素早く調査して原因を究明して改善していく。
このような体験の最大化のために例外を利用するという意味で、メンテナンス前提の例外という言葉は意識しています。
(例外の裏にはユーザーさんや開発に携わっているエンジニアがいることを意識しています。)
どのタイミングで例外を考えるのか
最初は例外処理を考えずにまずは正常系の処理を書いていきます。
その後動くようになったらテストとかで色々と操作をしてみて、例外がでたらそこで初めて例外処理を書きます。
最初から例外のことを頭に入れながら書くのではなく、まずは正常系、その後色々と動かしてみて例外処理を考えるという手順です。
(とはいえ、Tさんも最初から例外を考えて書くこともあるらしいので、場面による部分もあります。)
最後に
今まで自分は例外に関しては、携わったプロダクトごとのコードの形で対応してきましたが、そこに対して自分なりの考え方があったわけではありませんでした。
今回携わっているプロダクトでは、Tさんに例外の考え方を教えていただき、それをプロダクトの中でも実践していき、経験を通して自分の中にも例外に関する考え方が生まれつつあります。
例外を有意義に使うことで、ユーザーが使いやすいような、エンジニアが改善しやすいようなプロダクト開発につながるということが今回大きな学びでした。
例外の話はプロダクトやメンバーのコンテキストによって正解が違ってくる部分だと思うので、この記事の例外の話が絶対的な正解なわけではないですが、今回の記事を通して何かしらの学びを提供できていたら嬉しいです。
今後も、ユーザーにたくさん価値提供できるプロダクトを開発できるようになるためにも、色々とキャッチアップ、アウトプットしていきます。
Tさんからの学びに関しては
凄腕エンジニアと一緒に働いて学んだ技術以外の大切なこと
にもまとめていますので、興味がある方は是非読んでみてください。
(Tさん改めてありがとうございます!)
補足記事
何か物足りないな〜、わからないことが多いな〜と思った方は是非下記の記事にも目を通してみてください。
補足記事と、汎用的な広い範囲での例外の話をまとめた記事です。