こんにちは、グループ経営ソリューション事業部でエンジニアをしている大浦です。
今回は、製品開発と切っても切れないアドオンの実現方法と、その選択がもたらす影響についてお話します。
製品におけるアドオン
アドオンとは、製品の標準機能を補完・拡張する追加要素です。
特定の顧客にとって重要だが標準機能では実現出来ない要件を実現する場合に選択肢となります。
アドオンは本当に必要か?
いきなり結論を覆すように聞こえるかもしれませんが、まず強調したいのは、
「製品にアドオンすることが本当に最善の解決策かを十分に検討することの重要性」 です。
アドオンがもたらす影響として以下のようなものがあります。
- 複雑性の増加: アドオンを追加することで製品のコードベースは複雑になり、メンテナンスが困難になります。
- サポートの負荷増: 顧客ごとのカスタマイズは、サポートチームにとっても大きな負担となります。
- ドキュメントの不足: アドオンは製品標準の機能に比べてドキュメントが不十分になりがちです。
- バグの特定が困難: 問題が発生した際に、製品固有の問題なのかアドオンの問題なのか判別が必要であり、切り分けが難しくなります。
できる限り、製品標準の機能で顧客の課題を解決できないかを模索しましょう。
もし顧客固有の要望を、適切に抽象化し標準機能として取り込めれば、製品の価値向上が期待でき、顧客にも喜ばれます。
しかし、現実には製品標準だけでは顧客の重要な課題を解決できない場合もあります。
製品開発において標準機能で実現する可能性は考えつくしたけれど、様々な理由で無理だと分かった場合に、どのようにアドオンを実現すべきかを考えてみましょう。
アドオンの実現方法
アドオンの実現方法は色々考えられます。
- 製品のコードベースを直接編集する(モディフィケーション)
- アドオンしたい機能のコードをコピーして別機能として作成する(コピーカスタマイズ)
- アプリケーションを拡張可能にし、実装を追加する(プラグイン)
- etc(マイクロサービス化、イベント駆動による拡張、API連携、...)
私はアドオンの実現方法はソフトウェアアーキテクチャの一部だと考えています。
書籍「ソフトウェアアーキテクチャの基礎」には、以下の原則が書かれています。
ソフトウェアアーキテクチャはトレードオフが全て
「どうやって」よりも「なぜ」の方がずっと重要
(引用: ソフトウェアアーキテクチャの基礎)
アドオンの実現方法も、どの方法が良いかはコンテキストに依存します。
製品のアドオン実装担当になった場合には、自身の置かれているコンテキストを考慮して判断することが必要です。
以下でそれぞれの方法の特徴を見ていきます。
方法1. 製品のコードベースを直接編集する(モディフィケーション)
1つ目は、製品のコードベースに直接必要な変更を行うモディフィケーションです。
特徴
Pros: 開発が迅速に行うことができ、あらゆる変更が可能 (○短期的な開発効率, パフォーマンス) Cons: コードベースが複雑化し、顧客数やバージョンが増えるごとに管理が困難 (×保守性, 拡張性)
モディフィケーションの例
例えば、経費精算申請の例を考えてみます。(架空のコードです)。
以下のようなJavaコードで申請処理が実現されているとします。
public KeihiService { public void apply(KeihiRequest request) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 申請承認者の設定 setApprover(request); // 申請実施 submitRequest(request); } }
ここで、今回アドオン要望としてA社から来た「申請者自身がマネージャの時は承認者を役員にする」という機能をモディフィケーションで実現します。
public KeihiService { public void apply(KeihiRequest request) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 承認者の設定 // A社アドオン: 申請者自身がマネージャの時は承認者を役員にする String applicantRole = request.getApplicant().getRole(); if ("Manager".equals(applicantRole)) { setExectiveApprover(request); } else { setApprover(request); } // 申請実施 submitRequest(request); } }
少しコードからいやな臭いがしてきましたが、実装自体はサクッと終わります。
次に、B社から続けて「プロジェクトを指定した場合は、承認者をプロジェクト管理者にする」という要望が来たとします。
A社に続きB社の要件もモディフィケーションで対応してみます。
public KeihiService { public void apply(ExpenseRequest request, Project project) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 承認者の設定 // A社アドオン: 申請者自身がマネージャの時は承認者を役員にする String applicantRole = request.getApplicant().getRole(); if ("Manager".equals(applicantRole)) { setExectiveApprover(request); // B社アドオン: プロジェクトを指定した場合は、承認者をプロジェクト管理者にする } else if (project != null) { setProjectOwnerApprover(request, project); } else { setApprover(request); } // 申請実施 submitRequest(request); } }
B社アドオンの実装者が実装しようとした際、コードには既に「A社アドオン」が実装されています。
実装者はそれを壊さないように実装しなければなりません。
補足
今回お話したい内容と少しずれてしまいますが、上記コードには気になる点があります。
- B社の環境においてA社のアドオンの機能が動いてしまうかもしれない。
→ これがB社にとって意図しない振る舞いとして現れた場合、顧客からの問合せとして顕在化します。 - applyの引数にB社のアドオンでしか利用しないprojectが追加されている。
→ B社のアドオン実装のみにフォーカスして実装するとこのような変更をしてしまうことがあります。この変更が妥当かは十分な議論が必要です。
実装者もレビューアも、上記のような観点を持つ必要があります。
Feature Toggle(機能の切り替え機構)や、条件分岐の整理のためにStrategyパターンの適用を検討すると意図しない副作用を減らせる可能性があります。
考察
モディフィケーションでの実現は短期的には効果的ですが、長期的には技術的負債を増やすリスクがあります。
新機能の追加やバージョンアップ時に大きな障害となる可能性が高いです。
方法2. アドオンしたい機能のコードをコピーして別機能として作成する(コピーカスタマイズ)
2つ目は、アドオンしたい機能のコードを顧客ごとに全てコピーし、別機能として作るコピーカスタマイズです。
特徴
Pros: 製品標準のコードは維持される、他の顧客向けに影響が出にくくなる (〇独立性, パフォーマンス) Cons: 冗長性が増し、差分管理しなければならないコードが多くなる(×保守性, 拡張性)
コピーカスタマイズの例
モディフィケーションの例と同じ要件がA社、B社からそれぞれ来た場合にコピーカスタマイズで実現する場合を考えます。
A社アドオン「申請者自身がマネージャの時は承認者を役員にする」
public KeihiServiceForCompanyA { public void apply(KeihiRequest request) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 承認者の設定 // A社アドオン: 申請者自身がマネージャの時は承認者を役員にする String applicantRole = request.getApplicant().getRole(); if ("Manager".equals(applicantRole)) { setExectiveApprover(request); } else { setApprover(request); } // 申請実施 submitRequest(request); } }
B社:「プロジェクトを指定した場合は、承認者をプロジェクト管理者にする」
public KeihiServiceForCompanyB { public void apply(ExpenseRequest request, Project project) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 承認者の設定 // B社アドオン: プロジェクトを指定した場合は、承認者をプロジェクト管理者にする if (project != null) { setProjectOwnerApprover(request, project); } else { setApprover(request); } // 申請実施 submitRequest(request); } }
モディフィケーションとの違いは、KeihiServiceクラスをコピーし、それぞれA社向け経費精算申請(KeihiServiceForCompanyA), B社向け経費精算申請(KeihiServiceForCompanyB)というクラスを作成してそれぞれ実装している所になります。
これにより、モディフィケーションの時に不安になった、「B社で動作する際のA社アドオンの影響」を軽減することが出来ます。
考察
複数社のアドオン実装が同一コードに入り乱れる複雑さは防げますが、管理するコードベースの量は増えます。これはモディフィケーションとは違ったコードベース管理の煩雑さ(例えば、Gitのブランチ運用やCI/CDパイプラインでの対処など)が発生します。
方法3. アプリケーションを拡張可能にし、実装を追加する(プラグイン)
3つ目は、拡張ポイントを製品側に作り、その実装を追加することで実現するプラグインです。
特徴
Pros: 必要な部分だけを拡張でき、アドオン機能を持続的に維持しやすい (○保守性, 拡張性, 再利用性) Cons: 拡張ポイントの設計が難しく、開発初期のコストが高くなります。(×開発効率, 複雑性, パフォーマンス)
プラグインの例
ここではあらかじめ「承認者の設定ロジックには顧客毎に違った要件が出る可能性が高い」ということが見えている前提でお話します。(このような観点はビジネスサイドが持っていることが多いです。)
元コードへの拡張ポイント作成
以下のように、元のコードにおける申請承認者の設定処理を、ApproverProviderFactoryというクラスを利用することで、実際の処理をApproverProviderに委譲します。
(注: 今回のコードでは、Javaで広く使われているSpring Frameworkの依存性注入を利用しています。)
@Service public KeihiService { @Autowired private ApproverProviderFactory approverProviderFactory; public void apply(KeihiRequest request) { // 申請内容の確認 checkRequest(request); // 合計金額の計算 calculateTotal(request); // 申請承認者の設定(拡張可能な実装) approverProviderFactory.get().setApprover(request); // 申請実施 submitRequest(request); } }
拡張ポイントとして追加されたApproverProviderFactoryの実装
元コードに埋め込んだApproverProviderFactoryの実装です。
少し複雑ですが、get()では以下のルールの通り、ApproverProviderインターフェースの実装クラスの数を見て、適切なApproverProviderの実装クラスを返却しています。
- ApproverProviderの実装クラスが1つの場合には、デフォルトの実装(DefaultApproverProvider)を返却します。
- ApproverProviderの実装クラスが2つの場合には、デフォルト でない 実装を返却します。
@Component public class ApproverProviderFactory { @Autowired private ApplicationContext applicationContext; public ApproverProvider get() { // ApproverProvider インターフェースを実装している全ての Bean を取得 Map<String, ApproverProvider> beans = applicationContext.getBeansOfType(ApproverProvider.class); int count = beans.size(); if (count == 1) { // 実装が1つしかなければ、アドオンは無いのでDefaultApproverProviderを使う return applicationContext.getBean(DefaultApproverProvider.class); } if (count == 2) { // 実装が2つあれば、アドオン実装があるとみなし、DefaultApproverProviderでない実装を使う for (ApproverProvider provider : beans.values()) { if (!(provider instanceof DefaultApproverProvider)) { return provider; } } } throw new IllegalStateException("Unexpected number of ApproverProvider implementations: " + count); } }
製品標準の機能としては、ApproverProvider実装としてDefaultApproverProviderクラスを実装しておきます。もし振る舞いを変えたい場合は、期待するアドオンの動作を別のApproverProviderの実装クラスとして定義します。ApproverProvierのアドオン実装が存在する場合には、実装クラスが2つになるため、アドオンした実装クラスが使われます。
こうすることで、元のコードに変更を加えることなく振る舞いの変更を実現できます。
今までの例で言えば、A社向け、B社向けそれぞれの振る舞いをするApproverProviderの実装クラスを作成します。それらの実装クラスを製品とは別のjar(以下、アドオンjar)として切り出し、製品コードの起動時に顧客に対応するアドオンjarを読み込むことで、製品コードに手を入れることなく、最小限の差分のみを管理することでアドオンを実現することができます。(=SOLID原則の1つであるオープン・クローズド原則に従い、新たな機能追加を既存コードの変更なしに実現できる点が大きなメリットです。)
考察
プラグインは、設計と実装には高度な技術が必要ですが、その分将来的な拡張性と保守性が向上します。一方、上手くプラグイン化出来たとしても、その箇所に変更があまり入らない場合は、余計な複雑さを埋め込んでしまいます。
アドオン方式選定まとめ
得られる特性 (〇) | 失われる特性 (×) | |
---|---|---|
モディフィケーション | 短期的な開発効率, パフォーマンス | 保守性, 拡張性 |
コピーカスタマイズ | 独立性, パフォーマンス | 保守性, 拡張性 |
プラグイン | 保守性, 拡張性, 再利用性 | 開発効率, 複雑性, パフォーマンス |
ここまで、3つのアドオン実現手法を紹介しました。
繰り返しになりますが、どの方法が良いかはコンテキストに依存します。
今回はモディフィケーションだと辛くなり、プラグインが有効となりそうなコードを例として書きました。ですが例えば「実現したいアドオン機能は後ほど製品の標準機能に取り込む可能性が高い」ということが分かっている場合は、迅速に実現できるモディフィケーションを選択することが妥当となるケースは十分に考えられます。
また「顧客ごとの完全なカスタマイズが求められ、他の顧客への影響を避けたいケース」であれば、コピーカスタマイズが最も良い選択肢となるかもしれません。
ここに挙げていない方式が妥当であることもあり、様々なメンバーと議論しながら妥当なものを選ぶことが重要です。
エンタープライズアプリケーションの製品開発はビジネスサイドまで含めたコンテキストを加味した判断の連続です。
これからも、トレードオフを意識して議論し判断しながら、今後も製品開発を推進していきたいと考えています。
最後までお読みいただき、ありがとうございます。
執筆:@oura.osamu、レビュー:@nakamura.toshihiro
(Shodoで執筆されました)