はじめに
普段コーディングする際に意識していることを整理してみます。あくまで個人的な基本方針的なもので、状況によって優先順位は変わってくるかと思います。
ざっくりとした優先順位
優先順位というかできるだけ意識しているものが上にきていて、ほぼ無意識にやっていることほど下にある感じです。
-
テスタブルか
- DIしてるか
- 関数に副作用ないか
- 適度に関数が分割されているか
- 依存ライブラリにテストの機構があるか
- fixtureをきちんと設計しているか(共通のfixtureとテストケースごとのfixtureなど)
- テストを書いているか(自分のコードには必ずミスがある、くらいに思った方がいい)
-
バリデーションしているか
- 特に四則演算を扱う関数を複数組み合わせる際などは、それぞれの関数に渡ってくる引数の値を念入りにバリデーションする。正の整数か、特定の範囲内の数値かなど。例えば何らかの理由で想定外の値(負の値など)が引数として渡ってきた場合、特に何も意識せずに実装していれば、型としては問題がなくそのまま演算に使われてしまい、さらにその演算結果の値が次の関数に渡されてしまう可能性がある。そのような事象が仮に連鎖してしまうと、どこに問題があったかデバッグが困難になってくる。
-
エラーハンドリングできているか
- あらゆるイレギュラーなケースがカバーできているか(同時に同様のリクエストが送られてきた時に排他制御するかなど)
- エラー発生時の処理の分岐がきちんとされているか(機能全体が使えなくなるのかなど)
- クライアントに対して適切なエラーメッセージが送られているか(実装の詳細部分が推測できるようなものが含まれていないなど)
- 適切にロギングされているか(上記エラーメッセージがあくまでクライアントが必要とするエラーの結果のみに対し、できるだけ詳細を含めつつ個人情報をマスキングするなどセキュリティの考慮もできているか。ライブラリが返却するメッセージや外部通信時のHTTPエラーレスポンスといった詳細をできるだけ含める)
-
機能追加/変更しやすい形になっているか
- 結局はDBの設計に左右されるところが大きいため、未来の機能拡張の「可能性」を考慮したテーブル設計になっているか
- 未来の機能拡張の「可能性」を考慮したステータス/フラグ設計になっているか
- 抽象が詳細に依存せず、差し替えが容易な実装になっているか(依存性逆転の原則)
-
同様の処理がある場合、実装方法が統一されているか
- そしてどのような利点がありその実装方法を採用したかを明確にしておくことで、他の処理においても同じ利点を意識した実装方法を考えることができる
-
可読性と明瞭性が高いか
- 関数の深いネストは避け、親の関数で処理の流れが俯瞰できるか(孫を増やすのではなく、子を増やすイメージ。エラーハンドリングの設計もしやすくなる)
- if文の深いネストは避け、条件判定を切り出すなど(
if(isAdmin){...}
など) - コマンド名、ファイル名、関数名などだけで内容がほとんど分かるか
- コメントがなくても意図が伝わるように書きつつ、背景の説明など必要な際に適宜コメントを書いているか
- ディレクトリ構成が適切か
- 一貫性を持った実装になっているか(他の部分と違った実装になっている場合は理由をコメントに書く)
- Clear is better than clever
- パフォーマンスを意識しているか
-
うっかりとした誤爆を防げているか(ORMの使い方を間違って全件削除のDELETEなどが走らないようにMySQLの
--safe-updates
オプションを設定しておくなど)
テストに関しては可能な限りカバーしつつ、逆に自分はどこかでテストを書き忘れる、くらいの前提で実装もしています(例えばバリデーション的なところは念の為2レイヤーほどかませておけば、仮に一つすり抜けてしまった場合でもセーフティーネットがあるなど)。
可読性/俯瞰性のところは、ざっくりいうと参照先の内容を確認するためにファイル内で上下に行ったり来たり、もしくはネストが深く右へどんどん潜っていったりするような形ではなく、上から下に整頓された文章を読んでいけるような状態かと。もしくは要点を1箇所にまとめることで、そこを見るだけで全体像が容易に把握できるなど。
また、上記できていないところが見つかったら素速く軌道修正できているか、というのも個人的には重要なポイントだと考えています。特にテストを書く、ドキュメントを書くなどの行為においては必然的に色々と振り返ったり多角的に考える機会が生まれます。他にもリファクタリングしている最中に別の改善箇所が見えてきたり、他の人の意見を聞いたりコードを参考にすることでも今まで見えていなかったものが見えてくることがあります(気付いては直す、の繰り返しで、自分の場合は高速に反復横跳びをしながら前進していくようなイメージ。そしてこのように一見それぞれが独立したプロセスで存在しているようで実は相互に補完し合う関係にある、ということはよくあります)。
Fixtureをきちんと設計しているか
テストに使用する各モデルのfixtureをきちんと設計するということは、どのフィールドにどういったバリエーションを持たせるべきかを深く考えることに繋がる(ドメインモデルを育てる)のではと思っています。特にそのフィールドの値によって処理が変わるような場合は各ケースを押さえておくべきかと。「ああ、こういったバリエーションがあるのか」「この辺りの値が変わることで処理が変わるのか」といった様に、ある程度ドメイン知識が伝わるようなfixtureデータの設計が良さそうです。
また、以下のような点も考慮しても良いかもしれません。
- 共通で使うfixtureとテストの種類ごとに使うfixutreを分離する
- fixtureの識別にはユニークな形になるようにIDを使うようにする
- テストコードの可読性を高めるため、それがどういった属性を持ったfixtureなのか分かるような名前をつける(testUser1ではなく、adminUserなど)
- DBに挿入されているfixtureがテストで変更される場合は、次のテストケースに移る前にfixtureを元の状態に戻す(テストがfailした場合も元に状態に戻すように気をつける。テストのサンプルはこちら。)
- 上記、DBが元の状態に全て戻っているかを確認するテストなど
Before
export const userFixture = {
user1: {
name: "太郎", // 他に太郎がいるかもしれない
role: Role.Admin
createdAt: new Date(2020, 10, 1); // サーバのtimezoneによって時間が変わってくる
},
...
}
After
export const userFixture: UserFixture = {
admin: {
id: adminId,
name: "太郎",
role: Role.Admin
createdAt: new Date("2020-11-01T10:00:00+09:00"),
},
...
}
機能追加/変更しやすい形になっているか
アプリケーションが提供する機能は要件を満たすためのものに限定すべきですが(要件とは関係のない処理を実行「可能」な状態にしない)、機能を追加/変更したいといった際に容易に対応できるよう、内部で設計/実装をしておく必要があります。
例えば時系列で個別の処理が行われていくアプリケーションの開発時に(未来を意識して)以下のような思考で設計を進めることができます。
- まずタイムライン図を作成してそれぞれの処理の期間を区分、そしてそれぞれの期間におけるデータのあるべき状態を考えステータス名などを作成(処理が増えるごとに区分/ステータスを追加する、などの流れで機能追加が可能)
- 参照されるデータが可変な場合、参照されるデータをスケジュール化するなどして、処理が行われる時点によって参照先を変更(現在ユーザーがこのプランに契約しているが、来月から別のプランに変更したい場合など)
- あるエンティティが特定のステータスAを持っている場合、どういう経緯でステータスAになったか、といった情報をできるだけ細分化して保持しておくことで「過去に〇〇があったからこの状況の際にこうする」といった対応が可能となる
また、実装的なところで言うと、DIを活用して実装の差し替えをしやすくしておくなど。
この動画の最初の2分くらいでmaintainability(保守性)について上手くまとまっていました。
If your application is maintainable, you can make changes to it really quickly.
You can keep your business owners or project managers happy by being able to quickly add new features.
保守性が高ければスムーズに新規機能の追加を進めることができ、結果エンジニアも事業側もハッピーになるという内容です。 結局目指すべきゴールはそこではないでしょうか。
ステータス/フラグ設計
個人的には状態を表すフラグ・ステータスに関して、このような感じで考えています。
フラグがブーリアン型、ステータスを列挙型のようなイメージで、
- 時系列で状態が変化していく、もしくは順序が決まっていなくても複数の状態のうち一つの状態しかありえない場合はステータス(前者は例えば小学校 -> 中学校 -> 高校的な、後者はオフィスにいるのか、家にいるのかなど)。後者をフラグにしても良いのかもしれないですが、全く関連性のない他のフラグも同一のエンティティのフラグとして並ぶ可能性があり、その場合分かりにくくなる可能性があるかなと(このステータス、という一つのグルーピングでまとめることで見通しが良くなる、管理しやすくなる)
- 複数の状態の組み合わせの可能性がある場合、もしくは2パターンしか状態がありえない場合はフラグ
「可能性があるか」「ありえないか」がどちらを選ぶかの判断基準かなと。
同様の処理がある場合、実装方法が統一されているか
こちらに関してですが、逆に何でも処理をまとめ過ぎるとイレギュラーなケースへの対応が難しくなったり、後に変更が必要になった際にコードの修正が困難になってくる可能性も。下記の図のように、まだ経験が浅いうちはDRYを意識し過ぎてしまうところもあるかもしれません。共通化をしつつも個別対応が可能で、さらに差し替え可能(依存性逆転の原則)な実装になっていれば仕様の変更があっても素早く対応できそうです。
Clear is better than clever
Go言語の思想を表現するproverb(「格言」、的な意味)の一つに以下があります。
Clear is better than clever
恐らく「小難しくなく(変にカッコつけてなく)、内容が明瞭であることが重要」といった意味かと。そして以下のような文章もありました。
Clear code is independent of the low level details of function names and indentation because clear code is concerned with what the code is doing, not just how it is written down.
必ずしもreadable(可読性の高い)なコードがclear(目的や処理内容が明瞭)とは限らない、といった内容かと思います。つまり、目指すべきは読みやすく、さらに本質的な目的のところ&処理内容がぱっと見て想像がつくようなコードなのかと。
パフォーマンスを意識しているか
処理結果をどこかに保持(キャッシュ)して再利用することで全体の処理を高速化しようとする場合、storageのコストやキャッシュの更新をどうするかなど、デメリットになってくる可能性の部分も考慮する必要があります。
先読みする動的な設計
最後に設計に関して意識しているところをもう少しだけ。
まず先読みする、というところですが、設計時にあらゆる事を「逆算」することが重要なポイントではないかと思っています。
本番にリリース -> テスト環境でテスト -> フロントのコードの実装 -> バックエンドのコードの実装
という形で未来からやることを逆算して確認していきます。そしてコードのあるべき姿をイメージし、書く前にほとんどのコードを頭の中で組み立ててしまいます。
この思考プロセスには以下のようなメリットがあると考えています。
- 実装に反映する必要があるものを早めに見つけ出しておくことができる(テスト/QAをしやすい機構にしておくなど)
- 不明確な要素を特定し、早めに確認しておくことができる(実装ロジックに関わる仕様の細部、通信先のAPIのインターフェースなど)
例えばこのようなピラミッド型のテストケースのカバレッジを目指すのであれば、そもそもそのようなアプリケーションの構成や実装を考慮しておく必要があります。関数を役割ごとに分割してテスタブルにし(small test/unit test)、それらの関数を子供の関数として参照し一連のシーケンスを実行する親の関数をテストできるようにしておくなど(medium test/integraton test)。
動的、と表現しているのは時間の経過とともに開発に着手した当初最適と思われた設計もそうでなくなる可能性がある、という意味です(テクノロジーの進化、仕様の変更などのため)。一度設計したら終わりではなく、設計を定期的に再評価することが重要で、場合によっては再設計が必要となってくるかと思います。
また全ての要素(ユーザー体験、セキュリティー、コードのメンテナンス性等)において常に100点を取ることは難しいかと。それぞれの要素において、ここは最低限満たしている必要がある、といったラインを定義し、そのラインを満たした上で優先順位の高い方により注力する、というのがより現実的なイメージなのかもしれません。
まとめ
仕事全体で考えるともっと広い意味での優先順位があると思いますが、今回はあくまで自分がコードを書いている際の意識の整理でした。どのライブラリ/フレームワークを使うかなどに意識が行きがちですが、結局はこのあたりがきちんとできているかが重要かと思います。オニオンアーキテクチャなのかクリーンアーキテクチャなのかではなく、これらで解決しようとしていた本質的な部分がきちんと解決できていれば良いのではないでしょうか。