モジュール依存関係のリファクタリング

プログラムのサイズが大きくなるにつれて、小さな変更を加えるために全てを理解する必要がないように、プログラムをモジュールに分割することが重要になります。これらのモジュールは、多くの場合、異なるチームによって提供され、動的に結合されます。このリファクタリングのエッセイでは、プレゼンテーション-ドメイン-データレイヤリングを使用して小さなプログラムを分割します。次に、サービスロケータと依存性注入のパターンを導入するために、これらのモジュール間の依存関係をリファクタリングします。これらは異なる言語に適用されますが、見た目が異なるため、JavaとクラスレスなJavaScriptスタイルの両方でこれらのリファクタリングを示します。

2015年10月13日



プログラムが数百行を超えると、プログラムをどのようにモジュールに分割するかを考える必要があります。少なくとも、編集をより適切に管理するために、より小さなファイルを用意することは有用です。しかし、より重要なのは、変更を行うためにプログラム全体を頭に入れておく必要がないようにプログラムを分割することです。

適切に設計されたモジュール構造では、小さな変更を加える必要がある場合、より大きなプログラムのごく一部しか理解する必要がありません。小さな変更がモジュールを跨いで行われる場合もありますが、ほとんどの場合、単一のモジュールとその隣接モジュールだけを理解すれば済みます。

プログラムをモジュールに分割する上で最も難しいのは、モジュールの境界を決定することです。これには簡単なガイドラインはありません。実際、私の人生における主要なテーマは、良好なモジュールの境界がどのようなものになるかを理解しようとすることです。良好なモジュールの境界を描く上で最も重要なのは、行う変更に注意を払い、一緒に変更されるコードが同じモジュールまたは近くのモジュールにあるようにコードをリファクタリングすることです。

これに加えて、さまざまな部分が互いにどのように関連しているかを分離するメカニズムがあります。最も単純なケースでは、サプライヤを呼び出すクライアントモジュールがあります。しかし、クライアントプログラムがサプライヤの組み合わせについてあまりにも多くを知る必要がないため、これらのクライアントとサプライヤの構成は複雑になることがよくあります。

この問題を例を用いて検討します。コードの一塊を取り上げ、それをどのように部分に分割できるかを見ていきます。実際、2つの異なる言語(JavaとJavaScript)を使用してこれを実行します。それらの名前は似ていますが、モジュール性に関して実際に非常に異なります。

出発点

高度な売上データ分析を行うスタートアップ企業から始めます。彼らは、製品の売上を予測するのに非常に役立つ貴重な指標であるゴンドルフ数を保有しています。彼らのウェブアプリケーションは、企業の売上データを取得し、高度なアルゴリズムに投入し、製品とそのゴンドルフ数の単純な表を出力します。

初期状態のコードはすべて単一のファイルにあり、セクションごとに説明します。最初に、HTMLで表を出力するコードを示します。

app.js

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

出力のインデントの要件がソースコードのインデントと一致しないため、複数行文字列を使用しません。

これは世界で最も洗練されたUIではなく、シングルページやレスポンシブな世界では非常に平凡です。この例では、UIがさまざまな時点で`gondorffNumber`関数を呼び出す必要があることだけが重要です。

次に、ゴンドルフ数の計算に進みます。

app.js

  function gondorffNumber(product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

  function baselineRange(product){
    // redacted
  }
  function deriveEpoch(countingBase) {
    // redacted
  }
  function hookerExpiry() {
    // redacted
  }

それは私たちにとって百万ドルのアルゴリズムには見えないかもしれませんが、幸いにもこのコードの重要な部分ではありません。重要なのは、ゴンドルフ数の計算に関するこのロジックは、売上データソースの何らかの種類から基本的なデータを返す2つの関数(`salesDataFor`と`recordCounts`)を必要とすることです。これらのデータソース関数は特に洗練されていません。単にCSVファイルから取得したデータをフィルタリングするだけです。

app.js

  function salesDataFor(product, start, end) {
    return salesData()
      .filter(r =>
        (r.product === product)
        && (new Date(r.date) >= start)
        && (new Date(r.date) < end)
      );
  }
  function recordCounts(start) {
    return salesData()
      .filter(r => new Date(r.date) >= start)
      .length
  }
  function salesData() {
    const data = readFileSync('sales.csv', {encoding: 'utf8'});
    return data
      .split('\n')
      .slice(1)
      .map(makeRecord)
      ;
  }
  function makeRecord(line) {
    const [product,date,quantityString,location] = line.split(/\s*,\s*/);
    const quantity =  parseInt(quantityString, 10);
    return { product, date, quantity, location };
  }

この議論に関しては、これらの関数は完全に退屈です。完全性の観点からのみ示しています。それらについて重要なのは、データソースからデータを取得し、それを単純なオブジェクトに整形し、2つの異なる形式でコアアルゴリズムコードに提供することです。

現時点では、Javaバージョンは非常に似ており、最初にHTML生成があります。

class App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format("  <tr><td>%s</td><td>%4.2f</td></tr>", p, gondorffNumber(p)));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

ゴンドルフアルゴリズム

class App...

  public double gondorffNumber(String product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

  private LocalDate baselineRange(String product) {
    //redacted
  }
  private LocalDate deriveEpoch(long base) {
    //redacted
  }
  private LocalDate hookerExpiry() {
    // yup, redacted too
  }

データソースコードの本体はそれほど重要ではないため、メソッド宣言のみを示します。

class App

  private Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) {
    // unimportant details
  }
  private long recordCounts(LocalDate start) {
    // unimportant details
  }

プレゼンテーション-ドメイン-データ レイヤリング

前に述べたように、モジュールの境界の設定は微妙でニュアンスのある技術ですが、多くの人が従うガイドラインの1つはプレゼンテーション-ドメイン-データ レイヤリングです。つまり、プレゼンテーションコード(UI)、ビジネスロジック、データアクセスを分離することです。この種の分割に従うには、正当な理由があります。これらの3つのカテゴリのそれぞれは、異なる懸念事項に関する思考を含み、タスクを支援するために異なるフレームワークを使用することがよくあります。さらに、置換の欲求もあります。同じコアビジネスロジックを使用する複数のプレゼンテーション、または異なる環境で異なるデータソースを使用するビジネスロジックです。

そのため、この例ではこの一般的な分割に従い、置換の正当性も強調します。結局のところ、このゴンドルフ数は非常に貴重な指標であるため、多くの人がそれを使用したいと考えています。そのため、複数のアプリケーションで簡単に再利用できる単位としてパッケージ化することを促しています。さらに、すべてのアプリケーションが売上データをCSVファイルに保持するわけではありません。一部はデータベースまたはリモートマイクロサービスを使用します。アプリケーション開発者は、ゴンドルフコードを取得し、彼女が自分で作成するか、別の開発者から取得する特定のデータソースに接続できるようにしたいと考えています。

しかし、これらすべてを有効にするためのリファクタリングに着手する前に、プレゼンテーション-ドメイン-データレイヤリングには限界があることを強調する必要があります。モジュール性の一般的なルールは、可能であれば、変更の影響を1つのモジュールに限定することです。しかし、分離されたプレゼンテーション-ドメイン-データモジュールは、多くの場合、一緒に変更する必要があります。データフィールドを追加するという単純な行為は、通常、すべてを更新します。その結果、私はこのアプローチをより小さな範囲で使用することを好みますが、より大きなアプリケーションでは、上位レベルのモジュールを異なるラインに沿って開発する必要があります。特に、プレゼンテーション-ドメイン-データレイヤーをチーム境界の基礎として使用すべきではありません。

分割の実行

プレゼンテーションを分離することからモジュールへの分割を開始します。JavaScriptの場合、これはほとんどコードを新しいファイルに切り貼りするだけです。

gondorff.es6

  export default function gondorffNumber …
  function gondorffEpoch(product) {…
  function baselineRange(product){…
  function deriveEpoch(countingBase) { …
  function hookerExpiry() { …
  function salesDataFor(product, start, end) { …
  function recordCounts(start) { …
  function salesData() { …
  function makeRecord(line) { …

`export default`を使用することで、`gondorffNumber`への参照をインポートでき、インポート文を追加するだけです。

app.es6

  import gondorffNumber from './gondorff.es6'

Java側では、ほぼ同じくらい簡単です。再び、`emitGondorff`以外のすべてを新しいクラスにコピーします。

class Gondorff…

  public double gondorffNumber(String product) { …
  private LocalDate gondorffEpoch(String product) { …
  private LocalDate baselineRange(String product) { …
  private LocalDate deriveEpoch(long base) { …
  private LocalDate hookerExpiry() { …

  Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) { …
  long recordCounts(LocalDate start) {…
  Stream<SalesRecord> salesData() { …
  private SalesRecord makeSalesRecord(String line) { …

元の`App`クラスでは、新しいクラスを新しいパッケージに入れる場合を除き、インポートは必要ありませんが、新しいクラスをインスタンス化する必要があります。

class App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format("  <tr><td>%s</td><td>%4.2f</td></tr>", p, new Gondorff().gondorffNumber(p)));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

次に、計算ロジックとデータレコードを提供するコードを分離します。

dataSource.es6…

  export function salesDataFor(product, start, end) {

  export function recordCounts(start) {

  function salesData() { …
  function makeRecord(line) { …

この移動と前の移動の違いは、ゴンドルフファイルが1つではなく2つの関数をインポートする必要があることです。このインポートで実行でき、他に変更を加える必要はありません。

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'

Javaバージョンは前のケースと非常に似ており、新しいクラスに移動し、新しいオブジェクトに対してクラスをインスタンス化します。

class DataSource…

  public Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) { …
  public long recordCounts(LocalDate start) {…
  Stream<SalesRecord> salesData() { …
  private SalesRecord makeSalesRecord(String line) { …

class Gondorff...

  public double gondorffNumber(String product) {
    return new DataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new DataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

このファイルへの分割は、それほど興味深いものではない機械的なプロセスです。しかし、興味深いリファクタリングに到達する前に必要な最初のステップです。

リンカ置換

コードを複数のモジュールに分割することは役立ちますが、これらすべてにおいて興味深い困難は、ゴンドルフ計算を個別のコンポーネントとして配布したいという願望です。現在、ゴンドルフ計算は、売上データが特定のパスを持つCSVファイルから取得されると仮定しています。データソースロジックを分離することで、それを変更する能力が得られますが、現在のメカニズムは扱いにくく、検討する必要がある他のオプションがあります。

では、現在のメカニズムは何でしょうか?本質的に、これはリンカ置換と呼ぶものです。「リンカ」という用語は、Cなどのコンパイル済みプログラムに遡り、リンク段階で、個別のコンパイルユニット全体でシンボルが解決されます。JavaScriptでは、インポートコマンドのファイルのルックアップパスを操作することで、これと同等のものを実現できます。

売上記録をCSVファイルではなく、SQLデータベースでクエリを実行する環境にこのアプリケーションをインストールしたいと想像してみましょう。これを実現するには、まず、`salesDataFor`と`recordCounts`のエクスポートされた関数があり、ゴンドルフファイルが期待する形式でデータ返す`CorporateDatabaseDataSource`ファイルを作成する必要があります。次に、この新しいファイルでDataSourceファイルを置き換えます。アプリケーションを実行すると、「リンク」された`DataSource`ファイルが使用されます。

リンキングのための何らかのパスルックアップメカニズムに依存する多くの動的言語では、リンカ置換は単純なコンポーネント置換のための非常に優れたテクニックです。動作させるためにコードを変更する必要はありません。ファイルへの単純な分離を実行するだけです。ビルドスクリプトがある場合、異なるファイルパスに異なるファイルをコピーするだけで、異なるデータソース環境用のコードをビルドできます。これは、プログラムを小さな断片に分割しておくことの利点を示しています。元の作成者が置換を考慮していなくても、それらの断片の置換が可能になります。予期しないカスタマイズが可能になります。

Javaでリンカ置換を実行するには、本質的に同じタスクです。`DataSource`を`Gondorff`とは別のjarファイルにパッケージ化する必要があります。次に、`Gondorff`のユーザーに、適切なメソッドを持つ`DataSource`というクラスを作成し、それをクラスパスに配置するよう指示します。

ただし、Javaでは、インターフェースの抽出をデータソースに適用する追加のステップを実行します。

public interface DataSource {
  Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end);
  long recordCounts(LocalDate start);
}
public class CsvDataSource implements DataSource {

このように必要なインターフェースを使用することは、ゴンドルフがデータソースから何を期待しているかを明示的にするため役立ちます。

動的言語の欠点の1つは、この明示性が欠如していることであり、別々に開発されたコンポーネントを組み合わせる際に問題となる可能性があります。JavaScriptのモジュールシステムはここでうまく機能します。モジュールの依存関係を静的に定義するため、明示的で静的にチェックできるからです。静的宣言にはコストとメリットがありますが、最近の言語設計における優れた進歩の1つは、言語を純粋に静的または動的として扱うのではなく、静的宣言に対するよりニュアンスのあるアプローチを試みることです。

リンカ置換は、コンポーネント作成者側の作業量が少なく、予期せぬカスタマイズにも対応できるという利点があります。しかし、欠点もあります。Javaなどの環境では、扱いが面倒な場合があります。コードは置換の仕組みを明らかにしないため、コードベースで置換を制御するメカニズムがありません。

コードに存在しないことの重要な結果は、置換を動的に行うことができないことです。つまり、プログラムがアセンブルされて実行された後、データソースを変更することはできません。これは本番環境では通常大きな問題ではありませんが、データソースのホットスワップが有用なケースもありますが、少数派です。しかし、動的置換の価値はテストにあります。テストのために定型データを提供するテストダブルを使用したいことは非常に一般的であり、多くの場合、異なるテストケースに対して異なるダブルを投入したいことを意味します。

コードベースの明示性の向上とテストのための動的置換のこれらの要求は、通常、パス検索に頼るのではなく、コンポーネントの接続方法を明示的に指定できる他の代替手段を探求するように導きます。

各呼び出しでのデータソースをパラメータとして渡す

gondorffを異なるデータソースで呼び出すことをサポートしたい場合、それを実行する明白な方法は、呼び出すたびにパラメータとして渡すことです。

まず、Javaバージョンの現状から始め、DataSourceインターフェースを抽出した後のJavaバージョンの様子を見てみましょう。

class App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

class Gondorff...

  public double gondorffNumber(String product) {
    return new CsvDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new CsvDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

データソースをパラメータとして渡すと、結果のコードは次のようになります。

class App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p, new CsvDataSource())
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

class Gondorff...

  public double gondorffNumber(String product, DataSource dataSource) {
    return dataSource.salesDataFor(product, gondorffEpoch(product, dataSource), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product, DataSource dataSource) {
    final long countingBase = dataSource.recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

このリファクタリングは、いくつかの小さなステップで実行できます。

  • gondorffEpochdataSourceを追加するためにAdd Parameterを使用します。
  • 新しく追加されたdataSourceパラメータを使用するように、new CsvDataSource()への呼び出しを置き換えます。
  • コンパイルしてテストします。
  • gondorffNumberについても繰り返します。

次に、JavaScriptバージョンを見てみましょう。これも現状を示します。

app.es6…

  import gondorffNumber from './gondorff.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'

  export default function gondorffNumber(product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

この場合、両方の関数をパラメータとして渡すことができます。

app.es6…

  import gondorffNumber from './gondorff.es6'
  import * as dataSource from './dataSource.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product, dataSource.salesDataFor, dataSource.recordCounts).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'
  
  export default function gondorffNumber(product, salesDataFor, recordCounts) {
    return salesDataFor(product, gondorffEpoch(product, recordCounts), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product, recordCounts) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

Javaの例と同様に、最初にgondorffEpochAdd Parameterを適用し、コンパイルしてテストし、次に各関数に対してgondoffNumberにも同じことを行うことができます。

この状況では、salesDataFor関数とrecordCounts関数の両方を単一のデータソースオブジェクトに配置し、代わりにそれを渡す方が良いでしょう。基本的にIntroduce Parameter Objectを使用します。この記事ではこれを行いません。第一級関数の操作のより良いデモンストレーションになるからです。しかし、gondorffがデータソースからより多くの関数を使用する必要がある場合は、そうします。

データソースファイル名のパラメータ化

さらに、データソースのファイル名をパラメータ化できます。Javaバージョンでは、ファイル名用のフィールドをデータソースに追加し、Add Parameterをそのコンストラクタに追加することでこれを行います。

class CsvDataSource…

  private String filename;
  public CsvDataSource(String filename) {
    this.filename = filename;
  }

class App…

  public String emitGondorff(List<String> products) {
    DataSource dataSource = new CsvDataSource("sales.csv");
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p, dataSource)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

JavaScriptバージョンでは、データソースで必要な関数にAdd Parameterを使用する必要があります。

dataSource.es6…

  export function salesDataFor(product, start, end, filename) {
    return salesData(filename)
      .filter(r =>
      (r.product === product)
      && (new Date(r.date) >= start)
      && (new Date(r.date) < end)
    );
  }
  export function recordCounts(start, filename) {
    return salesData(filename)
      .filter(r => new Date(r.date) >= start)
      .length
  }

現状のままでは、ファイル名パラメータをgondorff関数に配置する必要がありますが、実際にはそれらのことは知る必要はありません。シンプルなアダプタを作成することでこれを修正できます。

dataSourceAdapter.es6…

  import * as ds from './dataSource.es6'
  
  export default function(filename) {
    return {
      salesDataFor(product, start, end) {return ds.salesDataFor(product, start, end, filename)},
      recordCounts(start) {return ds.recordCounts(start, filename)}
    }
  }

アプリケーションコードは、gondorff関数にデータソースを渡すときにこのアダプタを使用します。

app.es6…

  import gondorffNumber from './gondorff.es6'
  import * as dataSource from './dataSource.es6'
  import createDataSource from './dataSourceAdapter.es6'

  function emitGondorff(products) {
    function line(product) {
      const dataSource = createDataSource('sales.csv');
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product, dataSource.salesDataFor, dataSource.recordCounts).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

パラメータ化のトレードオフ

gondorffへの各呼び出しでデータソースを渡すことで、必要な動的置換を実現できます。アプリケーション開発者として、任意のデータソースを使用でき、必要に応じてスタブデータソースを渡して簡単にテストすることもできます。

しかし、このように各呼び出しでパラメータを使用することにも欠点があります。まず、データソース(またはその関数)を、それを使用する、またはそれを使用する別の関数を呼び出すすべてのgondorff関数にパラメータとして渡す必要があります。これにより、データソースがいたるところをさまようトランプトデータになる可能性があります。

より深刻な問題は、gondorffを使用するアプリケーションモジュールがあるたびに、データソースの作成と構成もできることを保証する必要があることです。複数の必須コンポーネントを必要とする汎用コンポーネントがあり、それぞれに独自の必須コンポーネントセットがある場合、構成が複雑になる可能性があります。gondorffを使用するたびに、gondorffオブジェクトの構成方法に関する知識を埋め込む必要があります。これはコードを複雑にし、理解と使用を難しくする重複です。

依存関係を見ることでこれを視覚化できます。データソースをパラメータとして導入する前は、依存関係は次のようになります。

データソースをパラメータとして渡すと、次のようになります。

これらの図では、使用依存関係と作成依存関係を区別しています。使用依存関係とは、クライアントモジュールがサプライヤで定義された関数を呼び出すことを意味します。gondorffとデータソースの間には常に使用依存関係があります。作成依存関係は、構成と作成のために通常サプライヤモジュールについてより多くの情報を必要とするため、はるかに密接な依存関係です。(作成依存関係は使用依存関係を意味します。)各呼び出しでパラメータを使用すると、gondorffからの依存関係は作成から使用に削減されますが、任意のアプリケーションからの作成依存関係が導入されます。

作成依存関係の問題に加えて、本番コードでは実際にはデータソースを変更したくないという別の問題もあります。gondorffへの各呼び出しでパラメータを渡すことは、呼び出し間でパラメータが変化することを意味しますが、ここでgondorffNumberを呼び出すたびに、常にまったく同じデータソースを渡しています。その不協和音は、6ヶ月後に私を混乱させる可能性があります。

データソースの構成が常に同じである場合、一度設定して、使用するたびに参照するのが理にかなっています。しかし、そうする場合、gondorffを一度設定し、使用するたびに完全に構成されたgondorffを使用しても構いません。

したがって、各回のパラメータの使用状況を調べたので、バージョン管理システムを使用して、このセクションの最初から行ったハードリセットを行い、別のパスを探求します。

単一サービス

gondorffとdataSourceの両方の重要な特性は、どちらも単一のサービスオブジェクトとして機能できることです。サービスオブジェクトはEvans分類の一部であり、データを中心としたエンティティや値ではなく、アクティビティを中心としたオブジェクトを参照しています。サービスオブジェクトを「サービス」と呼ぶことがよくありますが、ネットワークアクセス可能なコンポーネントではないため、SOAのサービスとは異なります。関数型の世界では、サービスは多くの場合単なる関数ですが、関数セットを単一の物として扱いたい状況が見られる場合があります。データソースでは、単一のデータソースの一部と考えることができる2つの関数があります。

また、「単一」と言いましたが、これは実行コンテキスト全体でこれらを1つだけ持つことが概念的に理にかなっていることを意味します。サービスは通常ステートレスであるため、1つだけ存在することが理にかなっています。実行コンテキストで何かが単一である場合、プログラム内でグローバルに参照できることを意味します。設定にコストがかかる場合、または操作しているリソースに同時実行制約がある場合など、シングルトンにすることを強制したい場合もあります。実行しているプロセス全体で1つだけ存在する場合も、スレッド固有のストレージを使用してスレッドごとに複数存在する場合もあります。しかし、いずれの場合も、コードの観点からは、1つだけ存在します。

gondorff計算機とデータソースを単一のサービスにすることを選択した場合、アプリケーションの起動時に一度構成し、後で使用する際に参照するのが理にかなっています。

これにより、サービスの処理方法に区別が導入されます。構成と使用の分離です。この分離を行うためにコードをリファクタリングする方法はいくつかあります。サービスロケータパターンまたは依存性注入パターンを導入します。サービスロケータから始めます。

サービスロケータの導入

サービスロケータパターンの背後にある考え方は、コンポーネントがサービスを見つけるための単一ポイントを持つことです。ロケータはサービスのレジストリです。使用時には、クライアントはレジストリのグローバルルックアップを使用し、特定のサービスをレジストリに要求します。構成は、必要なすべてのサービスを使用してロケータを設定します。

それを導入するためのリファクタリングの最初のステップは、ロケータを作成することです。グローバルレコード以上の単純な構造なので、JavaScriptバージョンはいくつかの変数と単純なイニシャライザだけです。

serviceLocator.es6…

  export let salesDataFor;
  export let recordCounts;
  export let gondorffNumber;
  
  export function initialize(arg) {
    salesDataFor: arg.salesDataFor;
    recordCounts: arg.recordCounts;
    gondorffNumber = arg.gondorffNumber;
  }

export letは、変数を他のモジュールに読み取り専用ビューとしてエクスポートします。[1]

Javaの場合は、もちろん少し長くなります。

class ServiceLocator…

  private static ServiceLocator soleInstance;
  private DataSource dataSource;
  private Gondorff gondorff;

  public static DataSource dataSource() {
     return soleInstance.dataSource;
  }

  public static Gondorff gondorff() {
    return soleInstance.gondorff;
  }

  public static void initialize(ServiceLocator arg) {
    soleInstance = arg;
  }

  public ServiceLocator(DataSource dataSource, Gondorff gondorff) {
    this.dataSource = dataSource;
    this.gondorff = gondorff;
  }

この状況では、ロケータのクライアントがデータの保存場所を知る必要がないように、静的メソッドのインターフェースを提供することを好みます。しかし、テストのための置換を容易にするために、データにシングルトンインスタンスを使用することを好みます。

どちらの場合も、サービスロケータは属性のセットです。

ロケータを使用するようにJavaScriptをリファクタリングする

ロケータが定義されたので、次のステップはサービスをロケータに移動することです。gondorffから始めます。サービスロケータを構成するために、サービスロケータを構成する小さなモジュールを作成します。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  
  export default function() {
    locator.initialize({gondorffNumber: gondorffImpl});
  }

この関数がインポートされ、アプリケーションの起動時に呼び出されるようにする必要があります。

いくつかのスタートアップファイル…

  import initializeServices from './configureServices.es6';

  initializeServices();

思い出のために、現在のアプリケーションコード(以前のリバート後)を示します。

app.es6…

  import gondorffNumber from './gondorff.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

代わりにサービスロケータを使用するには、インポートステートメントを調整するだけです。

app.es6…

  import gondorffNumber from './gondorff.es6'
  import {gondorffNumber} from './serviceLocator.es6';

この変更だけでテストを実行して、問題がないことを確認できます(「問題の発見」よりも聞こえが良いです)。その変更が終わったら、データソースについても同様の変更を行います。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import * as dataSource from './dataSource.es6' ;
  
  
  export default function() {
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: gondorffImpl
    });
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './serviceLocator.es6'

以前と同じリファクタリングを使用してファイル名をパラメータ化できます。今回は、変更はサービス構成関数にのみ影響します。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import * as dataSource from './dataSource.es6' ;
  import createDataSource from './dataSourceAdapter.es6'
  
  
  export default function() {
    const dataSource = createDataSource('sales.csv');
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: gondorffImpl
    });
  }

dataSourceAdapter.es6…

  import * as ds from './dataSource.es6'
  
  export default function(filename) {
    return {
      salesDataFor(product, start, end) {return ds.salesDataFor(product, start, end, filename)},
      recordCounts(start) {return ds.recordCounts(start, filename)}
    }
  }

Java

Javaの場合もほぼ同じです。サービスロケータを設定する構成クラスを作成します。

class ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(null, new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

そして、アプリケーションの起動時にどこかでこれへの呼び出しがあることを確認します。

現在のアプリケーションコードは次のようになります。

class App…

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

これで、ロケータを使用してgondorffオブジェクトを取得できます。

class App…

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              ServiceLocator.gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

データソースオブジェクトを混合に追加するには、最初にロケータに追加します。

class ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource(), new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

現在、gondorffオブジェクトは次のようになります。

class Gondorff…

  public double gondorffNumber(String product) {
    return new CsvDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new CsvDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

サービスロケータを使用すると、このように変化します。

class Gondorff…

  public double gondorffNumber(String product) {
    return ServiceLocator.dataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = ServiceLocator.dataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

JavaScriptの場合と同様に、ファイル名をパラメーター化しても、サービス設定コードのみが変更されます。

class ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource("sales.csv"), new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

サービスロケータを使用することによる影響

サービスロケーターを使用することの直接的な影響は、3つのコンポーネント間の依存関係が変化することです。コンポーネントの単純な分割後、依存関係は次のようになります。

サービスロケーターを導入することで、主要なモジュール間のすべての作成依存関係がなくなります。[2]。もちろん、これはすべての作成依存関係を持つサービス設定モジュールを無視しています。

アプリケーションのカスタマイズがサービス設定関数によって行われていることに気づいた方もいるかもしれません。これは、以前回避する必要があると言っていた、同じリンカー置換メカニズムによってカスタマイズが行われていることを意味します。ある程度は正しいですが、サービス設定モジュールが明らかに独立しているという事実は、私に多くの柔軟性を与えます。ライブラリプロバイダーは、さまざまなデータソース実装を提供でき、クライアントは、設定ファイル、環境変数、またはコマンドライン変数などの設定パラメーターに基づいて、実行時に1つを選択するサービス設定モジュールを作成できます。設定ファイルからパラメーターを導入するためのリファクタリングの可能性がありますが、それは別の機会に譲ります。

しかし、サービスロケーターを使用する特定の結果として、テスト用にサービスを簡単に置き換えることができます。このようにGondorffのデータソースにテストスタブを入れることができます。

テスト…

  it('can stub a data source', function() {
    const data = [
      {product: "p", date: "2015-07-01", quantity: 175}
    ];
    const newLocator = {
      recordCounts: () => 500,
      salesDataFor: () => data,
      gondorffNumber: serviceLocator.gondorffNumber
    };
    serviceLocator.initialize(newLocator);
    assert.closeTo(549.7787, serviceLocator.gondorffNumber("p"), 0.001);
  });

クラス テスター…

  @Test
  public void can_stub_data_source() throws Exception {
    ServiceLocator.initialize(new ServiceLocator(new DataSourceStub(), ServiceLocator.gondorff()));
    assertEquals(549.7787, ServiceLocator.gondorff().gondorffNumber("p"), 0.001);
  }
  private class DataSourceStub implements DataSource {
    @Override
    public Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) {
      return Collections.singletonList(new SalesRecord("p", LocalDate.of(2015, 7, 1), 175)).stream();
    }
    @Override
    public long recordCounts(LocalDate start) {
      return 500;
    }
  }

分割フェーズ

この記事に取り組んでいる間、ケント・ベックを訪ねました。彼の自家製チーズをごちそうになった後、私たちの会話はリファクタリングの話題になり、彼は10年前に認識していたが、まともな書かれた形式にはなっていない重要なリファクタリングについて教えてくれました。このリファクタリングは、複雑な計算を取り、それを2つのフェーズに分割し、最初のフェーズがその結果を中間結果データ構造を持つ2番目のフェーズに渡すことを含んでいました。このパターンの大規模な例としては、コンパイラで使用されているものが挙げられます。コンパイラは、トークン化、構文解析、コード生成など多くのフェーズに作業を分割し、トークンストリームや構文木などのデータ構造を中間結果として使用します。

帰宅してこの記事を再開すると、このようにサービスロケーターを導入することは、Split Phaseリファクタリングの例であるとすぐに認識しました。サービスロケーターを中間結果として使用して、サービスオブジェクトの設定を独自のフェーズに抽出し、configure-servicesフェーズの結果をプログラムの残りの部分に渡しました。

このように計算を個別のフェーズに分割することは、各フェーズの異なるニーズについて個別に考えることができ、各フェーズの結果(中間結果で)が明確に示され、各フェーズは中間結果をチェックまたは提供することで独立してテストできるため、便利なリファクタリングです。このリファクタリングは、中間結果を不変のデータ構造として扱う場合に特に効果があり、以前のフェーズによって生成されたデータの変更動作について推論する必要なく、後のフェーズのコードを使用する利点が得られます。

この記事を書いているのは、ケントとの会話からわずか1ヶ月ですが、Split Phaseの概念はリファクタリングに使用する上で強力なものだと感じています。多くの優れたパターンと同様に、明らかさの概念があります。何十年もやってきたことに名前を付けているだけのように感じます。しかし、そのような名前は小さなものではありません。このように頻繁に使用されるテクニックに名前を付けると、他の人と話すのが容易になり、自分の考え方も変わります。無意識のうちに実行される場合よりも、より中心的な役割とより意図的な使用が与えられます。

依存性注入

サービスロケーターを使用すると、コンポーネントオブジェクトはサービスロケーターの動作方法を知る必要があるという欠点があります。Gondorff計算機が、同じサービスロケーター機構を使用するよく理解されたアプリケーションの範囲内でのみ使用される場合、これは問題ではありませんが、財産を作るためにそれを販売したい場合、その結合は問題です。熱心な購入者が全員サービスロケーターを使用する場合でも、同じAPIを使用するとは限りません。必要なのは、言語自体に組み込まれているもの以外の機構を必要とせずに、Gondorffをデータソースで設定する方法です。

これは、依存性注入と呼ばれる異なる形式の設定につながったニーズです。依存性注入は、特にJavaの世界で、それを実装するためのあらゆる種類のフレームワークとともに、多く宣伝されています。これらのフレームワークは便利ですが、基本的な考え方は非常にシンプルであり、この例を単純な実装にリファクタリングすることで説明します。

Javaの例

このアイデアの中心は、依存コンポーネントの設定のための特別な規則やツールについて知る必要なく、Gondorffオブジェクトのようなコンポーネントを作成できることです。Javaでこれを行う自然な方法は、データソースを保持するフィールドをGondorffオブジェクトに持つことです。そのフィールドは、セッターを使用するか、構築中に、フィールドを通常どおりに設定することで、サービス設定によって設定できます。Gondorffオブジェクトは何か役に立つことを行うためにデータソースを必要とするため、私の通常の方法は、それをコンストラクターに入れることです。

class Gondorff…

  private DataSource dataSource;

  public Gondorff(DataSource dataSource) {
    this.dataSource = dataSource;
  }
  private DataSource getDataSource() {
    return (dataSource != null) ? dataSource : ServiceLocator.dataSource();
  }
  public double gondorffNumber(String product) {
    return getDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = getDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

class ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource("sales.csv"), new Gondorff(null));
      ServiceLocator.initialize(locator);
    }
  }

アクセサーgetDataSourceを入れることで、より小さなステップでリファクタリングを行うことができます。このコードはサービスロケーターを使用して行われた設定で正常に機能します。ロケーターを設定するテストを、この新しい依存性注入メカニズムを使用するテストに徐々に置き換えることができます。最初のリファクタリングでは、フィールドを追加してAdd Parameterを適用します。呼び出し側は、最初はnull引数を使用してコンストラクターを使用でき、データソースを提供するために1つずつ処理し、変更ごとにテストできます。(もちろん、設定フェーズですべてのサービス設定を行うため、通常、呼び出し側は多くありません。より多くの呼び出し元を取得するのは、テストでのスタブです。)

class ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      DataSource dataSource = new CsvDataSource("sales.csv");
      ServiceLocator locator = new ServiceLocator(dataSource, new Gondorff(dataSource));
      ServiceLocator.initialize(locator);
    }
  }

すべて完了したら、Gondorffオブジェクトからサービスロケーターへのすべての参照を削除できます。

class Gondorff…

  private DataSource getDataSource() {
    return (dataSource != null) ? dataSource : ServiceLocator.dataSource();
    return dataSource;
  }

getDataSourceをインライン化することもできます。

JavaScriptの例

JavaScriptの例ではクラスを避けているため、追加のフレームワークなしでGondorff計算機がデータソース関数を取得できるようにする方法は、各呼び出しでパラメーターとして渡すことです。

Gondorff.es6…

  import {recordCounts} from './serviceLocator.es6'
  
  export default function gondorffNumber(product, salesDataFor, recordCounts) {
    return salesDataFor(product, gondorffEpoch(product, recordCounts), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product, recordCounts) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

もちろん、以前はこのアプローチを使用していましたが、今回はクライアントが各呼び出しでセットアップを行う必要がないようにする必要があります。これを行うには、クライアントに部分適用されたGondorff関数を提供できます。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import createDataSource from './dataSourceAdapter.es6'
  
  
  export default function() {
    const dataSource = createDataSource('sales.csv');
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: (product) => gondorffImpl(product, dataSource.salesDataFor, dataSource.recordCounts)
    });
  }

影響

使用フェーズ中の依存関係を見ると、図は次のようになります。

これと以前のサービスロケーターの使用との唯一の違いは、Gondorffとサービスロケーターの間に依存関係がなくなったことです。これが依存性注入を使用する全体的なポイントです。(設定フェーズの依存関係は、同じ作成依存関係のセットです。)

Gondorffからサービスロケーターへの依存関係を削除したら、サービスロケーターからデータソースフィールドを完全に削除することもできます(サービスロケーターからデータソースを取得する必要がある他のクラスがない場合)。アプリケーションクラスにGondorffオブジェクトを提供するために依存性注入を使用することもできますが、アプリケーションクラスは共有されないため、ロケーターを使用しても不利にならないため、それを行う価値ははるかに低くなります。このように、サービスロケーターと依存性注入のパターンが一緒に使用されることがよくあります。サービスロケーターを使用して、さらに設定が依存性注入によって行われた初期サービスを取得します。依存性注入コンテナーは、サービスを検索するメカニズムを提供することによって、しばしばサービスロケーターとして使用されます。

結論

このリファクタリングエピソードの重要なメッセージは、サービス設定のフェーズとサービスの使用のフェーズを分割することです。サービスロケーターと依存性注入を使用してこれを実行する方法の正確さはそれほど問題ではなく、状況によって異なります。これらの状況によっては、これらの依存関係を管理するためのパッケージ化されたフレームワークにつながる場合もあれば、ケースが単純な場合は独自に作成しても問題ない場合があります。


脚注

1: これらの例はBabelを使用して開発しました。現時点では、Babelにはバグがあり、エクスポートされた変数を再代入できます。ES6の仕様では、エクスポートされた変数は読み取り専用のビューとしてエクスポートされます。

2: Javaバージョンのサービスロケーターは、型シグネチャで言及されているため、Gondorffとデータソースに依存しているという議論があるかもしれません。ロケーターはそれらのクラスのメソッドを実際に呼び出さないため、ここではそれを無視しています。いくつかの型体操でそれらの静的型依存関係を削除することもできますが、治療法が病気よりも悪いだろうと疑っています。

謝辞

Pete HodgsonとAxel Rauschmayerは、私のJavaScriptの改善に貴重な助けを提供してくれました。Ben Wu(伍斌)は、役立つイラストを提供してくれました。Jean-Noël Rouvignacは、いくつかのタイプミスを修正してくれました。

重要な改訂

2015年10月13日: 初版公開