適応モデルへのリファクタリング

ソフトウェアロジックの大部分はプログラミング言語で記述されていますが、これらの言語は、そのようなロジックを記述および発展させるための最適な環境を提供します。しかし、そのロジックを命令型コードが解釈できるデータ構造に移行することが有用な状況もあります。私はこれを適応モデルと呼んでいます。ここでは、JavaScriptの製品選択ロジックを示し、それをJSONにエンコードされた単純なプロダクションルールシステムにリファクタリングする方法を示します。このJSONデータにより、異なるプログラミング言語を使用するデバイス間でこの選択ロジックを共有し、これらのデバイスのコードを更新せずにこのロジックを更新することができます。

2015年11月19日



先日、アトランティスにあるヘレニックポーションコーポレーションのコンサルティングを行いました。彼らは、ポーション醸造者が効果的なポーションを作るのを助けるソフトウェアアプリケーションを開発しています。優れたポーション醸造の1つの側面は、ポーションレシピに適切な種類の材料を入れることです。たとえば、特定の飛行ポーションのレシピにはコオロギの羽が必要ですが、コオロギの品種は状況によって異なります。ソフトウェアは、特定の状況で最適な品種を推奨できますが、問題は、そのロジックをどのようにエンコードするかです。

このソフトウェアチームは優れたチームであるため、サーバー側のソフトウェアはnode.jsで動作します。しかし、ポーション醸造は乱雑な産業プロセスであり、スティンファリアンの鳥はWi-Fiを本当に混乱させます。そのため、品種推奨ロジックをクライアント側で実行し、iOSとAndroidの両方のモバイルアプリをサポートする必要があります。問題は、これにより厄介な重複が発生したこと、つまり、同じロジックがJavaScript、Swift、Javaの間で複製されたことです。変更するのは非常に大変な作業でした。すべてのコードを同期して変更する必要があるだけでなく、App Storeにも対応する必要があり、ペットのミノタウロスでさえ、クパチーノではほとんど印象を与えません。

1つのオプションは、各デバイスでロジックのJavaScriptバージョンを実行し、ウェブビューでコードを実行するメカニズムを使用することです。しかし、別のオプションは、推奨ロジックをデータにリファクタリングすること、つまり私が適応モデルと呼ぶものです。これにより、ロジックをJSONデータ構造にエンコードでき、簡単に移動して異なるデバイスソフトウェアにロードできます。アプリケーションはロジックが更新されたかどうかを確認し、変更のたびに新しいバージョンを迅速にダウンロードできます。

開始コード

リファクタリングの例として使用する推奨ロジックのサンプルを次に示します。

recommender.es6…

  export default function (spec) {
    let result = [];
    if (spec.atNight) result.push("whispering death");
    if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
    if (spec.seasons && spec.seasons.includes("summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (spec.seasons && spec.seasons.includes("summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

この例は、JavaScript、EcmaScript 6です。

この関数は、仕様を受け取ります。これは、ポーションの使用方法に関する情報を含む単純なオブジェクトです。次に、ロジックは仕様を照会し、返された結果オブジェクトに推奨されるコオロギの品種を追加します。

このコードには、プリミティブオブセッションが多くあります。コオロギの品種、季節、国はすべてリテラル文字列で表現されています。これらの文字列を独自の型にリファクタリングしたいと思いますが、それは別の日のための別々のリファクタリングです。

プロダクションルールシステムパターン

命令型コードをデータ構造で表現する場合、最初のタスクはそのデータを構造化するモデルを決定することです。適切なモデルを選択することで、ロジックを大幅に簡素化できます。実際、ロジックをより理解しやすくするためだけに、適応モデルを使用する価値がある場合があります。最悪の場合、そのようなモデルを最初から作成(および進化)する必要がありますが、多くの場合、既存の計算モデルから始めることができます。

このような一連の条件分岐は、プロダクションルールシステムの使用を示唆しています。これは、適応モデルで表現するのに適した特定の計算モデルです。プロダクションルールシステムは、条件とアクションという2つの主要な要素で構成されるプロダクションルールの集合を通じて計算を整理します。プロダクションルールシステムはすべてのルールを実行し、各ルールの条件を評価し、条件がtrueを返す場合はアクションを実行します。

これを実行する基本的な方法を示すために、最初の2つの条件についてこのアプローチを検討します。これが命令型形式の2つの条件です。

recommender.es6…

  if (spec.atNight) result.push("whispering death");
  if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");

これらを2つのプロダクションルールオブジェクトのリストのJavaScriptデータ構造を使用してエンコードし、単純な関数を使用してモデルを実行できます。

recommendationModel.es6…

  export default [
    {
      condition: (spec) => spec.atNight,
      action: (result) => result.push("whispering death")
    },
    {
      condition: (spec) => spec.seasons && spec.seasons.includes("winter"),
      action: (result) => result.push("beefy")
    }
  ];

recommender.es6…

  import model from './recommendationModel.es6'
  function executeModel(spec) {
    let result = [];
    model
      .filter((r) => r.condition(spec))
      .forEach((r) => r.action(result));
    return result;
  }

ここでは、適応モデルの一般的な形式を確認できます。必要な特定のロジック(recommendationModel.es6)と、そのデータ構造を受け取って実行するエンジン(executeModel)を含むデータ構造があります。

この適応モデルは、プロダクションルールの一般的な実装です。しかし、私たちのプロダクションルールはそれよりも制約されています。まず、すべてのアクションはコオロギの品種の名前を結果に追加するだけなので、これを簡素化できます。

recommendationModel.es6…

  export default [
    {
      condition: (spec) => spec.atNight,
      result: "whispering death"
    },
    {
      condition: (spec) => spec.seasons && spec.seasons.includes("winter"),
      result: "beefy"
    }
  ];

recommender.es6…

  import model from './recommendationModel.es6'
  function executeModel(spec) {
    let result = [];
    model
      .filter((r) => r.condition(spec))
      .forEach((r) => result.push(r.result));
    return result;
  }

これにより、収集変数を削除することでエンジンをさらに簡素化できます。

recommender.es6…

  import model from './recommendationModel.es6'
  function executeModel(spec) {
    let result = [];
    return model
      .filter((r) => r.condition(spec))
      .map((r) => r.result);
    return result;
  }

その明白な簡素化は素晴らしいですが、条件はまだJavaScriptコードであり、JavaScript以外の環境で実行するニーズには適合しません。解釈できるデータで条件コードを置き換える必要があります。

最初の行のリファクタリング

このリファクタリングエピソードを2つのセクションで説明します。最初のセクションでは、これらの最初の数行(青)を取り、プロダクションルールにリファクタリングします。2番目の部分では、より厄介なネストされた条件(緑)に取り組みます。

recommender.es6…

  export default function (spec) {
    let result = [];
    if (spec.atNight) result.push("whispering death");
    if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
    if (spec.seasons && spec.seasons.includes("summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (spec.seasons && spec.seasons.includes("summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

夜間の条件をJSONで表現する

命令型形式では次のように見える最初の条件から始めます。

recommender.es6…

  if (spec.atNight) result.push("whispering death");

これをJSONで次のように表現したいと思います。

recommendationModel.json…

  [{"condition": "atNight", "result": "whispering death"}]

これを機能させる最初の部分は、JSONファイルを読み取り、推奨ロジックで使用できるようにすることです。

recommendationModel.es6…

  import fs from 'fs'
  let model;
  export function loadJson() {
    model = JSON.parse(fs.readFileSync('recommendationModel.json', {encoding: 'utf8'}));
  }
  export default function getModel() {
    return model;
  }

アプリケーションの初期化時に、ある時点でloadJsonを呼び出します。このモジュールが初期化後に使用されるデフォルトのエクスポート関数を持つようにgetModelを作成しました。

次に、条件を理解するようにエンジンを変更する必要があります。

recommender.es6…

  function executeModel(spec) {
    return getModel()
      .filter((r) => isActive(r, spec))
      .map((r) => r.result)
  }
  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    throw new Error("unable to handle " + rule.condition);
  }

最初の条件をJSONで表現できるようになったので、新しいプロダクションルールシステムで最初の条件を置き換える必要があります。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec));
    if (spec.atNight) result.push("whispering death");
    if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
    if (spec.seasons && spec.seasons.includes("summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    //… rest of conditions

リファクタリングエピソードと同様に、可能な限り最小の手順を実行したいので、一度に最小限の命令型コードを置き換えます。適応モデルと命令型コードを並行して実行することは簡単です。各置換ごとに、この推奨ロジックのすべてのテストを実行します。これは、これらのテストがどの程度の役割を果たしているかをレビューする良い機会でもあります。ロジックをデータに移行した後でも、テストは引き続き必要になります。JSONファイルはデータですが、コードとして扱う必要があります。バージョン管理され、同じようにテストされます。

季節条件

次は、ロジックの2行目です。

recommender.es6…

  if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");

ここで最初に気付くことは、複合条件があることですが、この複合条件は全体のコードの多くの場所で繰り返されています。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec));
    if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
    if (spec.seasons && spec.seasons.includes("summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (spec.seasons && spec.seasons.includes("summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

これは複合条件ですが、単一の意図のみを表しています。複合的な性質は、内容をテストする前にseasonsプロパティが存在することを確認する必要があるためです。このようなものを見ると、私は反射的に「メソッドの抽出」を取り出します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec));
    if (seasonIncludes(spec, "winter")) result.push("beefy");
    if (seasonIncludes(spec, "summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }
  function seasonIncludes(spec, arg) {
    return spec.seasons && spec.seasons.includes(arg);
  }

そのリファクタリングが完了したので、2行目は引数を持つ単一の関数になります。JSONで関数名と引数を表現することは、柔軟性を高める上で良い戦術なので、これを試してみます。

recommendationModel.json…

  [
    {"condition": "atNight", "result": "whispering death"},
    {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "beefy"}
  ]

recommender.es6…

  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    throw new Error("unable to handle " + rule.condition);
  }

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec));
    if (seasonIncludes(spec, "winter")) result.push("beefy");
    if (seasonIncludes(spec, "summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }
  function seasonIncludes(spec, arg) {
    return spec.seasons && spec.seasons.includes(arg);
  }
    // remainder of function…

厳密には、argに単一の値を使用できますが、関数は通常、ある時点で複数の引数が必要であり、配列から始めるのはそれほど大変ではありません。

国に関するロジックの抽出

3番目の条件は次のようになります。

recommender.es6…

  if (seasonIncludes(spec, "summer")) {
    if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
  }

これにより、いくつかの点が導入されます。まず、調査するspecの新しいプロパティ、ポーションが使用される国があります。次に、その国のテストが既存の季節テストと組み合わせられています。

私はこのリファクタリングを上から順に条件を1つずつ行うことで実行してきました。しかし、ここでは条件を工夫して、条件の複雑さが徐々に増加する進行を得たと告白します。これは教育上の理由から良いことですが、現場で一般的にコードが表示される方法ではありません。ここでは、1度に1つの条件をリファクタリングし、ここで行っているように、適応モデルの表現力を徐々に構築することを推奨します。ただし、最適な方法は、コードを確認して処理するロジックの断片を選択することです。単純なものから始めて、徐々に複雑にしていきます。通常、上から下に行くのが最も簡単な方法ではないことを意味します。

リファクタリングでは、一度に1つのことを行いたいので、国のテストの処理から始めます。以前の季節テストと同様に、国のテストロジックを独自の関数に抽出することから始めます。

recommender.es6…

  if (seasonIncludes(spec, "summer")) {
    if (countryIncludedIn(spec, ["sparta", "atlantis"])) result.push("white lightening");
  }

  function countryIncludedIn(spec, anArray) {
    return anArray.includes(spec.country);
  }

モデルのパラメータ化

以前のリファクタリングでは、次のステップは、移動しようとしている条件を組み込むためにJSONルールを拡張することでした。しかし、この場合は、最初にこのcountryIncludedInテストを単独で処理してから、後で季節テストと組み合わせることを試みたいと思います。これまでの私のテストは次のようでした。

  it('night only', function() {
    assert.include(recommender({atNight: true}), 'whispering death');
  });

テストにはmochaとchaiを使用しています。

既存のレコメンドロジックに対して仕様を渡して実行しました。しかし、国に関するロジック単体でテストするには、追加条件のない国に関するロジックを含むモデルを作成して渡す必要があります。ここでは実際のレコメンドモデルではなく、一般的なレコメンドモデルのセマンティクスをテストしています。現状のコードでは、簡素化されたテストモデルを投入できる何らかのテストダブルをモデルに使用しなければなりません。

recommender.es6…

  function executeModel(spec) {
    return getModel()
      .filter((r) => isActive(r, spec))
      .map((r) => r.result)
  }

このようなテストダブルの設定は可能ですが、煩雑なため、別の方法を取ります。まず、パラメータ追加を使用して、モデルをエンジンに渡します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    if (seasonIncludes(spec, "summer")) {
      if (countryIncludedIn(spec, ["sparta", "atlantis"])) result.push("white lightening");
    }
    //… remaining logic
  }

  function executeModel(spec, model) {
    return model
      .filter((r) => isActive(r, spec))
      .map((r) => r.result)
  }

その後、次のようなテストを作成できます。

  it('night only', function() {
    assert.include(
      executeModel({atNight: true}, [{"condition": "atNight", "result": "expected"}]),
      'expected');
  });

これで、国プロパティのみをテストするテストを作成できます。

  it("country", function () {
    const model = [{condition: 'countryIncludedIn', conditionArgs: ['sparta', 'atlantis'], result: 'expected'}];
    expect(executeModel({country: "sparta"}, model)).include("expected");
    expect(executeModel({country: "athens"}, model)).not.include("expected");
  });

そして、以下のようにしてテストをパスさせます。

recommender.es6…

  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country);
    throw new Error("unable to handle " + rule.condition);
  }

論理和の追加

仕様における運用国をテストするだけでは、3番目のルールに対応するのに十分ではありません。

recommender.es6…

  if (seasonIncludes(spec, "summer")) {
    if (countryIncludedIn(spec, ["sparta", "atlantis"])) result.push("white lightening");
  }

条件のネストにも対処する必要があります。このような適応型モデルを使用する場合、ロジックを単純な式に限定したいと考えています。ネストされたステートメントは、はるかに複雑な表現につながります。ネストされたif文であれば、ネストされたif文を連言にリファクタリングすることで簡単に解決できます。

recommender.es6…

  if (seasonIncludes(spec, "summer") && countryIncludedIn(spec, ["sparta", "atlantis"]))
    result.push("white lightening");

そのため、エンジンに連言(「and」)関数があれば、ルールベースを拡張してこのケースに対応できます。

recommendationModel.json…

  [
    {"condition": "atNight", "result": "whispering death"},
    {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "beefy"},
    {
      "condition": "and",
      "conditionArgs": [
        {"condition": "seasonIncludes",    "conditionArgs": ["summer"]},
        {"condition": "countryIncludedIn", "conditionArgs": ["sparta", "atlantis"]}
      ],
      "result": "white lightening"
    }
  ]

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    if (seasonIncludes(spec, "summer") && countryIncludedIn(spec, ["sparta", "atlantis"]))
      result.push("white lightening");
    //… remaining logic
  }

  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country);
    if (rule.condition === 'and') return rule.conditionArgs.every((arg) => isActive(arg, spec));
    throw new Error("unable to handle " + rule.condition);
  }

これらの3つの条件が、命令型コードを適応型モデルにリファクタリングする方法の良い例になることを願っています。ロジックを一度に1つの塊ずつ変換します。モデルがその塊を処理できない場合は、モデルの拡張(関数の追加、関数引数の追加機能の追加)と命令型コードのリファクタリング(ネストされた条件を連言で置き換え)を組み合わせて使用します。

複雑な部分

主なレコメンド関数の現在の状態を以下に示します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

最初の行をモデルに折り込んだので、大きな条件文が残っています。これはプロダクションルールのスタイルにはあまり合っていないように見えます。これは、基礎となるロジックがモデルに合っていないという意味ではありません。コードに少し手を加えることで、その形が明確になるということです。

しかし、ここには別の問題もあります。このコードは、仕様の新しいプロパティを調べています。ポーションの持続時間を最小限にしたい場合のバリエーションを推奨するものです(飛行ポーションでは非常に重要です)。条件コードは、より広範なパターンをある程度曖昧にしています。

レンジピッカーパターン

このような数値のテストを行う条件コードをよく見かけます。

  function someLogic (arg) {
    if      (arg <  5) return "low";
    else if (arg < 15) return "medium";
    else               return "high";
  }

コードの中心的な意図は、値の範囲のリストに応じて値を返すことです。この同じロジックを次のように表現できます。

  function logicWithPicker(arg) {
    const range = [
      [5, "low"],
      [15, "medium"],
      [Infinity, 'high']
    ];
    return pickFromRange(range, arg);
  }
  function pickFromRange(range, value) {
    const matchIndex = range.findIndex((r) => value < r[0]);
    return range[matchIndex][1];
  }

これまでにこの記事で説明してきたトリックと同じことを行っていることに気付くでしょう。つまり、ロジックをデータに移行させています。単純なセマンティックモデル、つまりブレークポイントと戻り値のテーブルと、そのモデルを実行する動作を考案しました。

多くのロジックからデータへの変更と同様に、常にこれを行うわけではありません。単純な条件ロジックは読みやすく、特に表形式の側面を強調するようにきちんとフォーマットされている場合はそうです。しかし、ブレークポイントが頻繁に変更される場合は、データを表現することで更新が容易になることがよくあります。この場合、このロジックを範囲選択ツールで表現することは、ロジックをデータとして表現するという私の全体的なニーズにより適しています。

条件分岐をレンジピッカーで置き換える

そのため、この次のバッチのコードをリファクタリングする最初のステップは、命令型コード内の最小期間のテストを範囲選択ツールで置き換えることです。夏のケースから始めます。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        result.push(pickFromRange(summerPicks, spec.minDuration));
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

このコードセクションが扱いにくい理由の1つは、最小期間の範囲の最初のバンドが外部条件によって除外されていることです。それを削除し、そのロジックを範囲選択ツール内に保持します。そのためには、推奨事項がない値が必要です。nullが自然な選択ですが、このような状況でnullを使用するときはいつも少し躊躇します。

次に、もう一方のケースを行います。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        result.push(pickFromRange(summerPicks, spec.minDuration));
      }
      else {
        result.push(pickFromRange(nonSummerPicks, spec.minDuration));
      }
    }
    return _.uniq(result);
  }

外側の条件分岐の削除

これらが完了したら、外部条件を取り除きたいと考えています。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (spec.minDuration >= 150) {
      if (seasonIncludes(spec, "summer")) {
        result.push(pickFromRange(summerPicks, spec.minDuration));
      }
      else {
        result.push(pickFromRange(nonSummerPicks, spec.minDuration));
      }
    }
    return _.uniq(result);
  }

しかし、これを実行すると、テストが失敗します。いくつかの問題があります。まず、条件はminDurationが150未満ではないかだけでなく、そもそも存在するかどうかもチェックしています。これは、多くのJavaScript操作の厄介な寛容性です。これは、範囲選択ツール関数を呼び出す前に、この値をチェックする必要があることを意味します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (spec.minDuration >= 150) {
    if (seasonIncludes(spec, "summer")) {
      if (spec.minDuration)
        result.push(pickFromRange(summerPicks, spec.minDuration));
    }
    else {
      if (spec.minDuration)
        result.push(pickFromRange(nonSummerPicks, spec.minDuration));
    }
    }
    return _.uniq(result);
  }

これは重複しているので、メソッド抽出を適用します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
      result.push(pickMinDuration(spec, summerPicks))
    }
    else {
      result.push(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }
  function pickMinDuration(spec, range) {
    if (spec.minDuration) {
      return pickFromRange(range, spec.minDuration);
    }
  }

推奨事項のない範囲の処理

しかし、nullが結果セットに残っているため、いくつかのテストがまだ失敗しています。これを修正する1つの方法は、結果を保護することです。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
      if (pickMinDuration(spec, summerPicks))
        result.push(pickMinDuration(spec, summerPicks))
    }
    else {
      if (pickMinDuration(spec, nonSummerPicks))
        result.push(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }

これはプロダクションルールの条件の一部であると主張することもできますが、ドメインのセマンティクスに合致するとは思いません。

もう1つの選択肢は、最後にnullをフィルタリングすることです。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, null],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, null],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
      result.push(pickMinDuration(spec, summerPicks))
    }
    else {
      result.push(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result).filter((v) => null != v );
  }

minDurationプロパティがない場合にpickMinDurationが返すnullとundefinedの両方をキャッチするために「!=」を使用します。

これらは両方とも機能しますが、このようにnullをばらまくのは好きではありません。返すものが何もない場合は、何も返さない方が、何もないことを示すシグナルを返すよりも好ましいです。この問題に対処する古典的な方法があります。単一の値を返すのではなく、リストを返すことです。何も返さないということは、空のリストを返すことを意味します。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, []],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, []],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
      result = result.concat(pickMinDuration(spec, summerPicks))
    }
    else {
      result = result.concat(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }
  function pickMinDuration(spec, range) {
    if (spec.minDuration)
      return pickFromRange(range, spec.minDuration);
    else return []
  }

JavaScriptでは、concatを定義して、配列以外の値を配列に追加します。

これはプロダクションルールコードに少し混乱をもたらします。プロダクションルールコードは、値だけでなく配列も処理する必要があります。幸いにも、これはフラット化関数という一般的な解決策を持つ一般的な問題です。

recommender.es6…

  function executeModel(spec, model) {
    return _.chain(model)
      .filter((r) => isActive(r, spec))
      .map((r) => r.result)
      .flatten()
      .value()
  }

通常のES6にはフラット化がないため、アンダースコアを使用する必要があります。

elseの削除

プロダクションルールにはelseの概念がないため、反転したifで置き換えます。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
      [150, []],
      [350, 'white lightening'],
      [570, 'little master'],
      [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, []],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
      result = result.concat(pickMinDuration(spec, summerPicks))
    }
    else {
    if (!seasonIncludes(spec, "summer")) {
      result = result.concat(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }

結果関数の追加

これで、命令型コードをプロダクションルールに変換しやすい形にリファクタリングしました。しかし、プロダクションルールはこれまで単純な値を返すことを期待していたため、命令型コードをプロダクションルールで置き換えるのは依然として簡単ではありません。これは、pickMinDuration関数を呼び出す必要があります。これは、条件とアクションの両方が関数である、古典的なプロダクションルール構造により近づいています。これを処理する簡単な方法は、結果関数または単一の結果値のいずれかを処理する処理をエンジンに追加することです。これは、いくつかの小さなステップで、まずメソッド抽出を使用します。

recommender.es6…

  function executeModel(spec, model) {
    return _.chain(model)
      .filter((r) => isActive(r, spec))
      .map((r) => result(r))
      .flatten()
      .value()
  }
  function result(r) {
    return r.result;
  }

pickMinDurationは仕様を受け取るため、パラメータ追加を使用して渡す必要があります。

recommender.es6…

  function executeModel(spec, model) {
    return _.chain(model)
      .filter((r) => isActive(r, spec))
      .map((r) => result(r, spec))
      .flatten()
      .value()
  }
  function result(r, spec) {
    return r.result;
  }

次に、最小期間ルールに対する処理を追加します。これは少し難しいので、専用のテストを作成します。

test.es6…

  describe('min duration rule', function () {
    const range = [
      [  5,        []      ],
      [  10,       'low'   ],
      [  Infinity, 'high'  ]
    ];
    const model = [{
      condition: 'pickMinDuration', conditionArgs: [range],
      resultFunction: 'pickMinDuration', resultArgs: [range]
    }];
    const testValues = [
      [  4.9, []        ],
      [  5,   ['low']   ],
      [  9.9, ['low']   ],
      [  10,  ['high']  ]
    ];
    testValues.forEach(function (v) {
      it(`pick for duration: ${v[0]}`, function () {
          expect(executeModel({minDuration: v[0]}, model)).deep.equal(v[1]);
        }
      )
    });
    it('empty spec', () => {expect(executeModel({}, model)).be.empty;})
  });

次に、ルールエンジンの結果関数を変更して、結果値または結果関数のいずれかを条件付きで処理し、条件テストで最小期間のケースを認識します。

recommender.es6…

  function executeModel(spec, model) {
    return _.chain(model)
      .filter((r) => isActive(r, spec))
      .map((r) => result(r, spec))
      .flatten()
      .value()
  }
  function result(r, spec) {
    if (r.result) return r.result;
    else if (r.resultFunction === 'pickMinDuration')
      return pickMinDuration(spec, r.resultArgs[0])
  }
  function isActive(rule, spec) {
  
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country);
    if (rule.condition === 'and') return rule.conditionArgs.every((arg) => isActive(arg, spec));
    if (rule.condition === 'pickMinDuration') return true;
    throw new Error("unable to handle " + rule.condition);
  }

これで準備が整ったので、モデルにルールを簡単に追加し、最初の条件を削除できます。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const summerPicks = [
    [150, []],
    [350, 'white lightening'],
    [570, 'little master'],
    [Infinity, 'wall']
    ];
    const nonSummerPicks = [
      [150, []],
      [450, 'white lightening'],
      [Infinity, 'little master']
    ];
    if (seasonIncludes(spec, "summer")) {
    result = result.concat(pickMinDuration(spec, summerPicks))
    }
    if (!seasonIncludes(spec, "summer")) {
      result = result.concat(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }

recommendationModel.json…

  [
    {"condition": "atNight", "result": "whispering death"},
    {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "beefy"},
    {
      "condition": "and",
      "conditionArgs": [
        {"condition": "seasonIncludes",    "conditionArgs": ["summer"]},
        {"condition": "countryIncludedIn", "conditionArgs": ["sparta", "atlantis"]}
  
      ],
      "result": "white lightening"
    },
    { "condition":"seasonIncludes",
      "conditionArgs": ["summer"],
      "resultFunction": "pickMinDuration",
      "resultArgs": [[
        [ 150,        []                  ],
        [ 350,        "white lightening"  ],
        [ 570,        "little master"     ],
        [ "Infinity", "wall"              ]
      ]]
    }
  ]

単純な結果値の削除

これはうまく機能しますが、結果値または結果関数のいずれかがあり、それを条件付きで処理する方法が好きではありません。結果関数のみにすることで、物事をより規則的にすることができます。値関数は単にその引数を返すだけです。

recommendationModel.json…

  [
    {"condition": "atNight", "result": "value", "resultArgs":["whispering death"]},
    {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "value", "resultArgs":["beefy"]},
    {
      "condition": "and",
      "conditionArgs": [
        {"condition": "seasonIncludes",    "conditionArgs": ["summer"]},
        {"condition": "countryIncludedIn", "conditionArgs": ["sparta", "atlantis"]}
      ],
      "result": "value",
      "resultArgs": ["white lightening"]
    },
    {
      "condition":"seasonIncludes",
      "conditionArgs": ["summer"],
      "result": "pickMinDuration",
      "resultArgs": [[
        [ 150,        []                  ],
        [ 350,        "white lightening"  ],
        [ 570,        "little master"     ],
        [ "Infinity", "wall"              ]
      ]]
    }
  ]

recommender.es6…

  function result(r, spec) {
    if (r.result === "value") return r.resultArgs[0];
    if (r.result === 'pickMinDuration')
      return pickMinDuration(spec, r.resultArgs[0]);
    throw new Error("unknown result function: " + r.result)
  }

これにより、モデルのJSONは冗長になりますが、エンジンをより規則的にすることができます。この状況では、たとえ冗長であっても、より規則的なモデルの方が好ましいです。冗長性は別の方法で修正できます。これは後で説明します。

否定条件の追加

条件の最後の部分をモデルに追加するには、モデルに否定関数が必要です。

recommender.es6…

  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country);
    if (rule.condition === 'and') return rule.conditionArgs.every((arg) => isActive(arg, spec));
    if (rule.condition === 'pickMinDuration') return true;
    if (rule.condition === 'not') return !isActive(rule.conditionArgs[0], spec);
    throw new Error("unable to handle " + rule.condition);
  }

そして、命令型ロジックの最後の部分を削除できます。

recommender.es6…

  export default function (spec) {
    let result = [];
    result = result.concat(executeModel(spec, getModel()));
    const nonSummerPicks = [
    [150, []],
    [450, 'white lightening'],
    [Infinity, 'little master']
    ];
    if (!seasonIncludes(spec, "summer")) {
    result = result.concat(pickMinDuration(spec, nonSummerPicks));
    }
    return _.uniq(result);
  }

recommendationModel.jsonに追加…

  {
    "condition":"not",
    "conditionArgs": [{"condition":"seasonIncludes", "conditionArgs": ["summer"]}],
    "result": "pickMinDuration",
    "resultArgs": [[
      [150,        []                  ],
      [450,        "white lightening"  ],
      ["Infinity", "little master"     ]
    ]]
  }

コードではなくモデルを使用する

これですべて完了したので、元の命令型コードからのすべての条件ロジック

recommender.es6…

  export default function (spec) {
    let result = [];
    if (spec.atNight) result.push("whispering death");
    if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
    if (spec.seasons && spec.seasons.includes("summer")) {
      if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
    }
    if (spec.minDuration >= 150) {
      if (spec.seasons && spec.seasons.includes("summer")) {
        if (spec.minDuration < 350) result.push("white lightening");
        else if (spec.minDuration < 570) result.push("little master");
        else result.push("wall");
      }
      else {
        if (spec.minDuration < 450) result.push("white lightening");
        else result.push("little master");
      }
    }
    return _.uniq(result);
  }

このJSONモデルに移動しました。

recommendationModel.json…

  [
    {"condition": "atNight", "result": "value", "resultArgs":["whispering death"]},
    {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "value", "resultArgs":["beefy"]},
    {
      "condition": "and",
      "conditionArgs": [
        {"condition": "seasonIncludes",    "conditionArgs": ["summer"]},
        {"condition": "countryIncludedIn", "conditionArgs": ["sparta", "atlantis"]}
      ],
      "result": "value",
      "resultArgs": ["white lightening"]
    },
    {
      "condition":"seasonIncludes",
      "conditionArgs": ["summer"],
      "result": "pickMinDuration",
      "resultArgs": [[
        [ 150,        []                  ],
        [ 350,        "white lightening"  ],
        [ 570,        "little master"     ],
        [ "Infinity", "wall"              ]
      ]]
    },
    {
      "condition":"not",
      "conditionArgs": [{"condition":"seasonIncludes", "conditionArgs": ["summer"]}],
      "result": "pickMinDuration",
      "resultArgs": [[
        [150,        []                  ],
        [450,        "white lightening"  ],
        ["Infinity", "little master"     ]
      ]]
    }
  ]

JSONモデルを解釈する次のエンジンを使用して。

recommender.es6…

  export default function (spec) {
    return executeModel(spec, getModel());
  }
  
  function pickMinDuration(spec, range) {
    return (spec.minDuration) ? pickFromRange(range, spec.minDuration) : [];
  }
  function countryIncludedIn(spec, anArray) {
    return anArray.includes(spec.country);
  }
  function seasonIncludes(spec, arg) {
    return spec.seasons && spec.seasons.includes(arg);
  }
  
  function executeModel(spec, model) {
    return _.chain(model)
      .filter((r) => isActive(r, spec))
      .map((r) => result(r, spec))
      .flatten()
      .uniq()
      .value()
  }
  function result(r, spec) {
    if (r.result === "value") return r.resultArgs[0];
    if (r.result === 'pickMinDuration')
      return pickMinDuration(spec, r.resultArgs[0]);
    throw new Error("unknown result function: " + r.result)
  }
  function isActive(rule, spec) {
    if (rule.condition === 'atNight') return spec.atNight;
    if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]);
    if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country);
    if (rule.condition === 'and') return rule.conditionArgs.every((arg) => isActive(arg, spec));
    if (rule.condition === 'pickMinDuration') return true;
    if (rule.condition === 'not') return !isActive(rule.conditionArgs[0], spec);
    throw new Error("unable to handle " + rule.condition);
  }

以前のコードを少し整理しました。

何が得られ、何が失われましたか?まず、コードは相当大きくなりました。JSONモデルとエンジンは、個別に元のコードよりも大きくなっています。それだけで、悪いことです。しかし、重要な利点は、Webサイト、iOS、Android、またはJSONファイルを読み取ることができるその他の環境で解釈できる、レコメンドロジックの単一の表現が得られることです。これは、ロジックが実際により大きい場合、特に大きな利点です。透明化ポーションのレコメンドロジックをご覧ください。

ここに、適応型モデルが命令型コードよりも変更しやすいかどうかという別の問題があります。大きくなりましたが、より規則的です。ルールが多数ある場合、命令型コードの柔軟性により、より簡単に混乱が生じる可能性がありますが、適応型モデルの表現力の制限により、ロジックをより簡単に追跡できます。多くの人が、アトランティスで直面した複数の実行環境の問題がなくても、この理由から適応型モデルを好みます。

リファクタリングのプロセスをまとめる必要があります。命令型コードを適応型モデルで置き換える必要があることに気付いたら、まずその適応型モデルの最初のドラフトをスケッチします。できれば、よく知られているモデルを使用します。次に、命令型コードの小さな部分を抽出し、適応型モデルを移行させます。コードがモデルに明確に適合しない場合は、適合する形にリファクタリングしてから移行します。適応型モデルがコードの現在の断片でうまく機能しない場合は、モデルをリファクタリングして機能するようにします。

この例では、すべての命令型コードをモデルで置き換えました。しかし、そうする必要はありません。いつでも停止し、モデルの一部と命令型コードの一部を残すことができます。これは、モデルの複雑さを高めるほど価値がないエッジケースに役立ちます。この場合、そのようなエッジケースに対して重複とアプリストアの不便さを受け入れ、モデルの更新を通じてルールの変更の大部分を処理できるようになります。

さらなるリファクタリング

これを書いていて、リファクタリングのさらなる方向性がいくつか思い浮かびました。後日、この記事に追加するかもしれません。

モデルの再編成

JSONモデルを見ると、その構造を少し再構成したいと考えています。つまり、

{
  "condition": …
  "conditionArgs": …
  "result": …
  "resultArgs": …
}

ではなく、

{
  "condition": {
    "name": …
    "args": …
  }
  "result": {
    "name": …
    "args": …
  }
}

を持ちたいのです。これにより、構造が少し規則的になります。このデータ構造を徐々に移行させる際に、興味深いリファクタリングがあります。

命令型ディスパッチをルックアップで置き換える

エンジンは現在、isActive関数とresult関数を使用して、条件関数と結果関数をディスパッチしています。基本的にケースステートメントを配線しています(もちろん、クールな関数型プログラマーであれば、パターンマッチングと呼びます)。別のオプションは、命令型コードをルックアップシステムで置き換えることです。ここでは、条件seasonIncludesがルックアップテーブルまたはリフレクションを介して関数に自動的にマッチングされます。

DSLによるモデルの表現

JSONモデルは非常に読みやすいですが、JSON構文ではルールを明確に表現できる方法が制限されています。さらに、モデルを本来よりも冗長にしても、意図的にモデルの規則性を優先しました。多くのルールを管理している場合、内部(JavaScriptを使用)または外部のドメイン固有言語を導入することをお勧めします。これにより、レコメンドルールの理解と変更が大幅に容易になります。

プリミティブオブセッションの除去

このコードでは、クリケットの品種、季節、国という概念をすべて文字列として表しています。これはJSONでの表現方法を模倣していますが、通常は、このような概念には特定の型を作成することをお勧めします。これにより、コードが明確になり、便利な動作を引き付けることができる場所が提供されます。

適応モデルの検証

現時点では、適応型モデルのエラーは実行してみないと検出できません。モデルが複雑になるにつれて、JSONが整形式であり、JSONによって強制される単純な構造を超えた暗黙的な構文規則に従っていることを検出できる検証操作を構築することが有用になります。そのような規則には、すべての節に条件と結果が含まれていること、`seasonIncludes`関数の引数が既知の季節であることが含まれます。

逆リファクタリング

あらゆるリファクタリングと同様に、逆の動きもあります。つまり、適応型モデルを命令型コードに置き換えることです。これも有益な方向性です。適応型モデルは、あまり馴染みのないアプローチであるため、保守が困難になる可能性があります。チームの経験豊富なメンバーの中には、適応型モデルを操作することで非常に生産性を上げている人がいる一方で、チームの他のメンバーは非常に扱いづらいと感じている状況によく遭遇します。場合によっては、その追加の生産性によって使い続ける価値がありますが、適応型モデルにメリットがない場合もあります。コードをデータとして表現する可能性に初めて出会ったとき、人々は興奮してそれを過剰に使用することがよくあります。これは問題ではなく、自然な学習過程の一部ですが、チームが行き過ぎたと認識した時点で削除することが重要です。

適応型モデルを命令型コードに置き換えるプロセスは、その逆のプロセスと似ており、最初にモデルの結果を命令型コードと合成できるように設定し、次に小さな塊でロジックを命令型コードに移動し、その都度テストします。ここでの大きな違いは、命令型コードの構造を適応型モデルの構造に反映させることができ、ほとんどの場合、そうすべきであるということです。その結果、命令型コードからモデルに移行する場合のように、モデルやコードの構造を調整する必要がありません。


謝辞

Andrew Slocum、Chelsea Komlo、Christian Treppo、Hugo Corbucciの各氏が、社内メーリングリストで本稿のドラフトについてコメントしてくださいました。Jean-Noël Rouvignac氏がいくつかのタイプミスを指摘してくださいました。

重要な改訂

2015年11月19日: 第二部(最終部)を公開

2015年11月11日: 第一部を公開