こんにちは!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 で執筆されました )