こんにちは!Xイノベーション本部プロダクトイノベーションセンターの米久保 剛です。
弊社のテックブログ上では今回が初めての記事執筆となります。アーキテクチャ設計やアプリケーション設計の話を中心に、不定期に情報発信していきたいと考えています。
YAGNI原則
YAGNI原則をご存知でしょうか。
エクストリーム・プログラミング(XP)の重要な原則の一つであるこの原則は、You Ain't Gonna Need Itのアクロニム(頭字語)から命名されています。日本語にすると「どうせ要らないって」というニュアンスでしょうか。推測に基づいて余計な機能を作り込んだところで将来実際に使われる可能性は低く、時間と労力を無駄にするばかりかコードの複雑化などのリスクさえあります。ですから、現時点でわかっている要件をちょうど満たすだけの機能を実装すべきであるとYAGNI原則は主張します。
YAGNI原則は機能(振る舞い)の大小に限った話ではなく、設計についても言えるものです。つまり、不確定な将来の要件に対応するための仕組みをあらかじめ作っておくという、無駄になる可能性のある過剰設計を抑制するのです。推測に基づいた設計は、無駄なだけでなく、間違った設計や良くない設計を生んでしまう危険性もはらみます。正しい設計を行えるのに十分な情報が得られる時点(つまり、本当にその要件が必要となるとき)まで、設計判断を遅らせるのです。
サンプル
サンプルコードで確認しましょう。(本記事中の完全なソースコードは、筆者のGitHubリポジトリで確認可能です)。
最初の設計
次のコードは、商品リストをCSVファイルに出力するプログラムです。サンプルコードなのでCLIですが、Webアプリケーションの場合は、クライアントからのリクエストを受け付けて処理を行うコントローラーに該当すると考えてください。
// main_1.ts import { Product, getProducts } from "./products.js"; import { outputCsv } from "./output_csv_1.js"; const products: Product[] = getProducts(); // 商品リスト取得 outputCsv(products); // CSVファイル出力
実際のCSV出力処理は、別のファイルに関数を定義し、 csv-writer
というNodeモジュールを利用して実装しています。
// output_csv_1.ts import { createObjectCsvWriter } from "csv-writer"; import { Product } from "./products.js"; const outputCsv = (products: Product[]): void => { const csvWriter = createObjectCsvWriter({ path: "./dist/products.csv", header: [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, { id: "notes", title: "備考" }, ], }); csvWriter.writeRecords(products).then(() => { console.log("...CSVを出力しました"); }); }; export { outputCsv };
この関数内にCSVのレイアウト情報がベタ書きされていることに、不安を抱いたという方は、プログラマーのセンスとして正しいです。なぜなら、運用に入って以下のような仕様変更要求が発生した場合に、現在の設計だと容易に対応できないケースがあるからです。
- カラムの追加や削除、順序変更
- データの加工
- レコードのソート順の変更
- 条件に応じたレイアウトの切り替え
仮にこれらの要求にすべて応えられるような設計をしようとすると、プログラムの数は増え、処理も複雑化するでしょう。また、現時点でリアルな要件が決まっていないのならば、何らかの仮定に基づいて仕様を決めて進めることになります。この仮定が外れていた場合は、結局設計を見直す必要が生じ、大きな手戻りとなってしまうのです。
ですので、素敵な設計をしたいという欲求をぐっとこらえて、現時点で十分な設計にとどめることがYAGNIの考え方となります。現在の設計でも、一つ目の仕様変更要求「カラムの追加や削除、順序変更」であれば問題なく対応可能です。
設計の見直し
次に、「条件に応じたレイアウトの切り替え」の仕様変更要求が実際に発生したとしましょう。具体的には次の内容とします。
- ログインユーザーが管理者の場合はCSVにすべての列を出力するが、通常のユーザーの場合は列を限定する
当初の設計は、CSVのレイアウトは固定で一つであることを前提としているため、この仕様変更要求を満たすことができません。設計を見直すときです。
まず、CSV出力関数を修正し、出力対象のカラム情報を引数で受け取るようにします。
// output_csv_2.ts import { createObjectCsvWriter } from "csv-writer"; import { Product } from "./products.js"; const outputCsv = ( products: Product[], columns: { id: string; title: string }[] ): void => { const csvWriter = createObjectCsvWriter({ path: "./dist/products.csv", header: columns, }); csvWriter.writeRecords(products).then(() => { console.log("...CSVを出力しました"); }); }; export { outputCsv };
呼び出し元のプログラムを修正します。
// main_2.ts import { Product, getProducts } from "./products.js"; import { User, getUser } from "./users.js"; import { outputCsv } from "./output_csv_2.js"; const userId = 1; // 1: Alice(管理者), 2: Bob const loginUser: User = getUser(userId); // ログインユーザー情報取得 const products: Product[] = getProducts(); // 商品リスト取得 let columns; if (loginUser.isAdmin) { columns = [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "supplier", title: "仕入先" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, { id: "notes", title: "備考" }, ]; } else { columns = [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, ]; } outputCsv(products, columns); // CSVファイル出力
これで仕様変更要求に対応することができました。
ただ、if-else
による条件分岐は少し気になりますね。
リファクタリング
この条件分岐が放つ「不吉な匂い」は、このままでよいでしょうか。
この程度の単純な条件分岐であれば一旦よしとし、次の条件分岐が発生するときにリファクタリングするという考え方もあるでしょう。しかしながら、次の変更時にもリファクタリングが先送りされ、そのときは永久にやってこないということが往々にしてあります。設計判断はYAGNI原則によって先送りしますが、リファクタリングは先送りすべきでないのです。
コントローラーの役割を担うプログラムは、処理フローを記述すべきであって、詳細なルールを記述すべきではありません。そう考えると、条件分岐を記述する場所が適切ではないようです。
条件に応じてCSVレイアウトを決定する役割を分離して、新しい関数を導入します。
// csv_layout_1.ts import { User } from "./users.js"; type Column = { id: string; title: string; }; const getProductsCsvLayout = (user: User): Column[] => { if (user.isAdmin) { return [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "supplier", title: "仕入先" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, { id: "notes", title: "備考" }, ]; } else { return [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, ]; } }; export { Column, getProductsCsvLayout };
呼び出し側は次のようにすっきりし、処理フローの見通しがよくなりました。
// main_3.ts import { Product, getProducts } from "./products.js"; import { User, getUser } from "./users.js"; import { Column, getProductsCsvLayout } from "./csv_layout_1.js"; import { outputCsv } from "./output_csv_2.js"; const userId = 1; // 1: Alice(管理者), 2: Bob const loginUser: User = getUser(userId); // ログインユーザー情報取得 const products: Product[] = getProducts(); // 商品リスト取得 const columns: Column[] = getProductsCsvLayout(loginUser); // CSVレイアウト取得 outputCsv(products, columns); // CSVファイル出力
拡張性
拡張性(extensibility) は、ソフトウェアに機能を追加したり、機能を拡張したりすることが容易に行えることを表す品質特性です。
YAGNI原則は、不確かな推測に基づいた早すぎる拡張性の導入を抑制します。本当にそれが必要となるときまで、拡張性を導入するという設計判断を遅延させるのです。
ただし、建前はそうであっても、実際にはあらかじめ拡張性をもたせた設計をするケースもあります。例えば以下のようなケースです。
もちろん、先を見越して拡張性を導入することが誤った設計を生んでしまうリスクとのトレードオフであることを認識する必要はあります。
ふたたび、サンプル
拡張性を先に考慮しておくという設計判断を妥当だと考えたならば、最初の実装段階で、先のサンプルの最終形まで持っていくことは「あり」です。
パッケージソフトにおける拡張性の重要さについて少し言及しましたので、さらに掘り下げてみましょう。パッケージソフトの機能を拡張する手段は様々ですが、例えば以下のような方法があります。
- 設定ファイルによって振る舞いを変更する
- 設定テーブルによって振る舞いを変更する
- アドオン開発によって振る舞いを変更する
製本本体のソースコードに手を入れるモディフィケーションという方法がアドオン開発と呼ばれることもあるのですが、モディフィケーションにはデメリットもあります。Add-onの文字通り、追加モジュールを製品に足す方法が優れていると言えるでしょう。
今回のサンプルコードに以下の要件が与えられたという前提で、対応方法の例を見てみましょう。
- アドオン開発によって、出力するCSVファイルのレイアウトを変更できること
アドオンのインターフェース設計
アドオンモジュールを製品に組み込むためには、接合面としてのインターフェースを定める必要があります。
オブジェクト指向設計ならばその名のとおり「インターフェース」を定義することになりますが、今回は関数型のアプローチで設計しているため、インターフェースの役割となる関数の型を定義しました。
(TypeScriptでは type
文で型名を宣言できますが、関数もその対象となります)。
// csv_layout_2.ts(一部抜粋) type AddonFunction = (user: User) => Column[];
アドオンの実装
アドオンモジュールには、 AddonFunction
型の関数の実体を実装します。製品本体から利用できるように、 default
キーワードを付けてエクスポートしています。
// addon.js import { User } from "./users.js"; import { Column, AddonFunction } from "./csv_layout_2.js"; const getProductsCsvLayout: AddonFunction = (user: User): Column[] => { return [ { id: "category", title: "カテゴリー" }, { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "price", title: "単価" }, ]; }; export default getProductsCsvLayout;
アドオンの呼び出し
今回のサンプルコードでは、es2020のDynamic import機能を用いて、アドオンモジュールを動的に読み込んで関数を呼びだすように実装しました。
// csv_layout_2.ts(一部抜粋) const getProductsCsvLayout = async (user: User): Promise<Column[]> => { const addonModulePath = "./addon.js"; return import(addonModulePath) .then((module) => { // アドオンモジュールが存在する場合はそれを利用 console.log("アドオンモジュールを読み込みました"); return module.default(user); }) .catch((error) => { // アドオンモジュールが存在しない場合は標準の振る舞い if (user.isAdmin) { return [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "supplier", title: "仕入先" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, { id: "notes", title: "備考" }, ]; } else { return [ { id: "productCode", title: "商品コード" }, { id: "productName", title: "商品名" }, { id: "price", title: "単価" }, { id: "category", title: "カテゴリー" }, ]; } }); };
サンプルのため、所定の場所に所定のファイル名でアドオンモジュールが格納されていることを前提としています。実際のプロダクトでは、設定ファイルにパスやファイル名を記述したり、管理画面から登録できるようにしたりすることになるでしょう。
マイクロカーネルアーキテクチャ
アドオンモジュールを用いた拡張性の実現方法について、要点をまとめると以下のとおりです。
- ソフトウェアの機能を拡張できる箇所(拡張点)を定める
- 拡張点のインターフェースを定義する
- 拡張点においてアドオンモジュールを読み込んで実行する仕組みを作る
これは、マイクロカーネルアーキテクチャと呼ばれるアーキテクチャスタイルです。
まとめ
- YAGNI原則に従い、不用意な過剰設計を避け、設計をシンプルに保っておくことは開発のベロシティを向上させます
- いつでもリファクタリングによって設計を洗練させることができます
- 必要な場合は、YAGNI原則から離れて拡張性を事前に設計することも間違いではありません。つまるところ設計とは常にトレードオフなのです
執筆:@tyonekubo、レビュー:@nakamura.toshihiro
(Shodoで執筆されました)