こんにちは、PLAY CLOUD本部 プラットフォーム技術部開発第1Gのキムテヒョンです。 入社してから早くも3ヶ月が経ちました。新しい環境で様々な技術に触れながら、多くのことを学んでいます。 特に、良いコードについての考えが深まってきましたが、今回の記事ではデザインパターンについてお話ししたいと思います。
良いコードとは何か?
私はいつも「良いコードとは何か?」「良い設計とは何か?」について考え、それを実現するために日々勉強を重ねています。
皆さんが考える良いコードと良い設計とは何でしょうか?
私は読みやすく、変更や拡張が容易なコードが良いコードだと考えています。これを実現するためには、いくつかの重要な要素を考慮する必要があります。
- 可読性:誰もが読んで理解しやすいコード。例えば、変数名や関数名を明確につけ、不要なコメントの代わりにコードで意図を伝えることです。
- 変更容易性:要件が変更されても最小限の修正で対応できる構造を持つこと。例えば、ロバート・C・マーティン の提唱した単一責任の原則を守ることや、依存関係を分離する設計のことです。
- 拡張性:新しい機能を追加する際に、既存のコードを極力修正せずに追加できる構造。例えば、インターフェースと抽象化を積極的に活用する方式です。
このような目標を達成する方法の一つとしてGoFが提唱したテザインパターンがあります。
デザインパターンは、ソフトウェア開発で繰り返し発生する問題を解決するために考案された再利用可能なソリューションです。
言い換えれば、コードの構造化と問題解決における検証済みのベストプラクティスです。
今回の記事では、私が実際の業務で活用したデザインパターンの一つであるストラテジーパターンをご紹介したいと思います。
ストラテジーパターンとは?
ストラテジーパターンは、実行するアルゴリズム(動作)をカプセル化し、動的に切り替えられるようにするデザインパターンです。主に次のような場合に使用されます。
- 複数のアルゴリズム(動作)を実行時に選択して変更する必要がある場合
- 例:状況に応じて異なるソートアルゴリズム(クイックソート、マージソートなど)を適用する場合
- 似たような機能を持つクラスが多く、特定の動作(アルゴリズム)のみ異なる場合
- 例:決済システムで、支払い方法(クレジットカード、PayPal、仮想通貨など)を動的に変更する必要がある場合
- 条件分岐(if-else や switch)の乱用を避けたい場合
- 例:多数の条件分岐をクラスや関数に分離することで、可読性を向上させ、保守性も高まる
- オブジェクトの特定の動作を外部で定義したい場合
- 例:ゲームキャラクターの攻撃方法(剣攻撃、魔法攻撃など)を柔軟に変更する必要がある場合
つまり、ストラテジーパターンはオブジェクトの動作を動的に変更する必要がある場合に有効であり、保守性や拡張性の向上に役立ちます。
ストラテジーパターンの使用例
割引率計算システムでのリファクタリング
概念を説明するために割引率計算システムを例として挙げます。
このシステムでは、さまざまな割引方式を柔軟に適用できるようにストラテジーパターンを活用してリファクタリングしてみます。
BEFORE
before.ts
// 注文の型定義 type Order = { amount: number; items: unknown[]; // 今回は例示用なので unknown に }; class DiscountCalculator { calculateDiscount(order: Order, userType: string, seasonType: string): number { let discount = 0; const orderAmount = order.amount; // 会員ランク別の割引 if (userType === 'NORMAL') { if (orderAmount >= 50000) { discount += 1000; } } else if (userType === 'VIP') { if (orderAmount >= 100000) { discount += orderAmount * 0.1; } else { discount += 2000; } } else if (userType === 'VVIP') { if (orderAmount >= 200000) { discount += orderAmount * 0.2; } else { discount += orderAmount * 0.1; } } else { throw new Error('Invalid user type'); } // シーズン別の割引 if (seasonType === 'SPRING_SALE') { if (orderAmount >= 100000) { discount += 10000; } else if (orderAmount >= 50000) { discount += 5000; } } else if (seasonType === 'SUMMER_SALE') { // 夏シーズンは全品目20%オフ discount += orderAmount * 0.2; } else if (seasonType === 'CHRISTMAS_SALE') { if (order.items.length >= 3) { // 3点以上購入時 discount += orderAmount * 0.25; } else { discount += orderAmount * 0.1; } } else { throw new Error('Invalid season type'); } // 重複割引の制限 if (discount > orderAmount * 0.4) { discount = orderAmount * 0.4; // 最大40%までの割引 } return discount; } } // 使用例 const calculator = new DiscountCalculator(); const order = { amount: 120000, items: ['item1', 'item2'] } as const satisfies Order; const userType = 'VIP'; const seasonType = 'SUMMER_SALE'; const discount = calculator.calculateDiscount(order, userType, seasonType);
このコードにはいくつかの重大な問題点があります。
- *1単一責任の原則(SRP)違反
- 1つのクラスがすべての割引ポリシーを処理している
- 会員ランク別の割引とシーズン割引が1つのメソッドに混在している
- 複雑な条件分岐
- ネストされた if-else 文により、コードの可読性が低下
- 条件分岐が増えるほど、コードの複雑さが増加
- 拡張性の問題
- 新しい割引ポリシーを追加するたびに既存のメソッドを修正する必要がある
- *2オープン・クローズドの原則(OCP) に違反
- 保守の難しさ
- 特定の割引ポリシーを修正すると、他のポリシーに影響を与える可能性がある
- コードが長くなるほど、バグが発生しやすくなる
これらの問題を解決するために、ストラテジーパターンを適用してみましょう。
AFTER
after.ts
// 注文の型定義 type Order = { amount: number; items: unknown[]; // 今回は例示用なので unknown に }; // 割引関数の型定義 type DiscountMethod = (order: Order) => number; // ユーザ別割引関数 const normalUserDiscountMethod: DiscountMethod = (order: Order): number => { return order.amount >= 50000 ? 1000 : 0; } const vipUserDiscountMethod: DiscountMethod = (order: Order): number => { return order.amount >= 100000 ? order.amount * 0.1 : 2000; } const vVipUserDiscountMethod: DiscountMethod = (order: Order): number => { return order.amount >= 200000 ? order.amount * 0.2 : order.amount * 0.1; } const userDiscountMethods = new Map<string, DiscountMethod>([ ["NORMAL", normalUserDiscountMethod], ["VIP", vipUserDiscountMethod], ["VVIP", vVipUserDiscountMethod], ]); // シーズン別割引関数 const springSeasonDiscountMethod: DiscountMethod = (order: Order): number => { if (order.amount >= 100000) { return 10000; } if (order.amount >= 50000) { return 5000; } return 0; }; const summerSeasonDiscountMethod: DiscountMethod = (order: Order): number => { return order.amount * 0.2; }; const christmasSeasonDiscountMethod: DiscountMethod = (order: Order): number => { return order.items.length >= 3 ? order.amount * 0.25 : order.amount * 0.1; }; const seasonDiscountMethods = new Map<string, DiscountMethod>([ ["SPRING_SALE", springSeasonDiscountMethod], ["SUMMER_SALE", summerSeasonDiscountMethod], ["CHRISTMAS_SALE", christmasSeasonDiscountMethod], ]); class DiscountCalculator { calculateDiscount(order: Order, userType: string, seasonType: string): number { // 会員ランク別の割引 const userDiscount = (userDiscountMethods.get(userType) ?? (() => { throw new Error('Invalid user type'); }))(order); // シーズン別の割引 const seasonDiscount = (seasonDiscountMethods.get(seasonType) ?? (() => { throw new Error('Invalid season type'); }))(order); // 全体の割引 let discount = userDiscount + seasonDiscount; // 重複割引の制限 const orderAmount = order.amount; if (discount > orderAmount * 0.4) { discount = orderAmount * 0.4; // 最大40%までの割引 } return discount; } } // 使用例 const calculator = new DiscountCalculator(); const order = { amount: 120000, items: ['item1', 'item2'] } as const satisfies Order; const userType = 'VIP'; const seasonType = 'SUMMER_SALE'; const discount = calculator.calculateDiscount(order, userType, seasonType);
今回は割引処理が状態を持たないため、カプセル化されたビジネスロジックの共通のインターフェースとして、TypeScriptの型定義を用いるという形でストラテジーパターンを適用してみました。
ストラテジーパターンを適用した後の改善点を見てみましょう:
- クリーンなコード構造
- 各割引ポリシーが独立した関数に分離
- 割引ポリシーごとに責務が明確に分割
- 柔軟な拡張性
- 新しい割引ポリシーを追加する際、割引処理本体のコードを修正する必要なし
- 新しいストラテジーを追加するだけで対応可能
- OCP(オープン・クローズドの原則)を遵守
- 向上した保守性
- 各割引ポリシーのロジックを独立して修正可能
- 他のポリシーに影響を与えることなく特定のポリシーのみ変更できる
- テストの容易さ
- 各ストラテジーを独立してテスト可能
- モックオブジェクト(Mock)を使ったテストが容易
- ビジネスロジックの明確化
- 各割引ポリシーの意図が明確に表現される
- コードを通じてビジネスルールを簡単に理解できる
このように、ストラテジーパターンを適用することで、複雑なif-else文はクリーンな構造に置き換えられ、コードのメンテナンス性と拡張性が大きく向上しました。
特に新しい割引ポリシーを追加したり、既存のポリシーを修正する際に発生する可能性のあるリスクを最小限に抑えることができるようになりました。
実際のプロジェクトでも、このようなさまざまなポリシーやアルゴリズムを処理する必要がある状況が頻繁に発生しますが、ストラテジーパターンはこのような状況で非常に効果的な解決策となり得ます。
プロダクションでの使用例(Passport.js)
Passport.jsは、Node.jsで最も広く使用されている認証ミドルウェアライブラリです。
ExpressやKoaなどのWebフレームワーク上で、さまざまな認証方式を簡単に実装できるように設計されています。
このライブラリは ストラテジーパターンを活用し、認証戦略をプラグインのような形で提供しています。
開発者は 必要な認証戦略を選択するだけで簡単に適用できるため、拡張性が高く、柔軟な設計が可能になります。
// ローカルストラテジー(ユーザー名/パスワードベースの認証) passport.use( new LocalStrategy((username, password, done) => { // 認証ロジック }) ); // JWTストラテジー(トークンベースの認証) passport.use( new JwtStrategy({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() }, (payload, done) => { // 認証ロジック }) ); // OAuthストラテジー(ソーシャルログイン) passport.use( new GoogleStrategy({ clientID: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET }, (accessToken, refreshToken, profile, done) => { // 認証ロジック }) );
各認証戦略は独立したモジュールとして動作しながらも、同じインターフェースを共有しています。
これにより、開発者はさまざまな認証方式を統一された方法で処理でき、新しい認証方式を追加したり、既存の方式を変更したりすることが非常に容易になります。
これは戦略パターンの実際の活用例として非常にわかりやすい例です。
複雑な認証ロジックをカプセル化し、さまざまな認証方式を柔軟に切り替えられるようにすることで、拡張性が高く、保守しやすい認証システムの構築が可能になります。
実務での活用例
お客様の要件として、あるデータの変更を通知する機能を追加する必要がありました。
変更の通知方法はメール、Webhook、Slackの3種類でした。
- メール:メールのフォーマットに合わせてデータを加工し、送信。
- Webhook:HTTPリクエスト形式に合わせてJSONデータを送信。
- Slack:Slackのメッセージ形式でデータを生成し、送信。
「変更を通知する」という共通の動作を実行しながらも、実装方法は異なる設計が必要な状況でした。 このような場合にストラテジーパターンを意識してコードを書くことで拡張性が高く、変更に強い設計にすることができました。
おわりに
デザインパターンは、複雑な問題を解決し、コードの品質を高めるために非常に有用なツールです。
今回の記事で取り上げたストラテジーパターン以外にも多くのパターンが存在しますので、学習し実際のプロジェクトで活用してみてください。
その過程で、より良い設計とコードを作り上げる喜びを感じることができるでしょう。
ただし、デザインパターンを盲目的に適用することが必ずしも良いコードを生むわけではありません。デザインパターンは、あくまでも「検証された解決策の一つ」に過ぎません。
プロジェクトの要件や状況に応じて、より単純な解決策や、あるいは全く異なるアプローチが適している場合もあります。デザインパターン以外にも、SOLIDの原則やクリーンアーキテクチャなど、様々な設計手法やベストプラクティスが存在します。
これらの知識を積み重ね、状況に応じて最適な選択ができるようになることが、より良い設計への近道かもしれません。
皆さんの設計とコードはいかがでしょうか?一緒に考える機会になれば幸いです。
*1:ロバート・C・マーティンが提唱していた数々の設計原則の中からチョイスされたものである。 各クラスはそれぞれ一つだけの責務を持つべきである。https://ja.wikipedia.org/wiki/SOLID
*2:ロバート・C・マーティンが提唱していた数々の設計原則の中からチョイスされたものである。 ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれているべきであり、修正に対して閉じていなければならない。 https://ja.wikipedia.org/wiki/SOLID