重構為適應性模型

我們的大部分軟體邏輯都是用我們的程式語言寫的,這些語言為我們提供了撰寫和演化此類邏輯的最佳環境。但在某些情況下,將該邏輯移至我們的命令式程式碼可以詮釋的資料結構中是有用的,我稱之為適應性模型。在這裡,我將展示 JavaScript 中的一些產品選擇邏輯,並展示如何將其重構為編碼在 JSON 中的簡單生產規則系統。此 JSON 資料讓我們能夠在使用不同程式語言的裝置之間共用此選擇邏輯,並在不更新這些裝置上的程式碼情況下更新此邏輯。

2015 年 11 月 19 日



我最近為位於亞特蘭提斯的 Hellenic Potions Corporation 做了一些諮詢。他們正在開發軟體應用程式,協助魔藥釀造師製作有效的魔藥。調製好魔藥的一個面向,就是取得魔藥配方中正確種類的成分。例如,某個飛行魔藥的配方需要蟋蟀的翅膀,但不同品種的蟋蟀在不同情況下有最佳表現。軟體可以建議在特定情況下哪個品種最佳,但問題在於該邏輯應如何編碼。

由於這個軟體團隊是一個酷團隊,他們的伺服器端軟體在 node.js 上執行。但魔藥釀造是一個混亂的產業流程 - 斯廷法利亞鳥真的會搞亂 wifi。因此,他們需要在客戶端執行品種建議邏輯,並支援 iOS 和 Android 的行動應用程式。問題在於,這導致了令人尷尬的重複 - 相同的邏輯在 JavaScript、Swift 和 Java 之間重複。變更它本身就是一項艱鉅的任務,不只所有程式碼都需要同步變更,還必須處理 App 商店,甚至一隻寵物米諾陶也在庫比蒂諾留下微不足道的印象。

一個選項是在每個裝置上執行邏輯的 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

這個函式採用規範,一個包含有關魔藥如何使用的資訊的簡單物件。然後,邏輯會查詢規範,將建議的蟋蟀品種新增到傳回的結果物件中。

這段程式碼有很多原始迷戀:蟋蟀品種、季節和國家都以字串文字表示。我想要將這些字串重構成自己的類型,但那是另一項重構,我會留待以後再做。

生產規則系統模式

當我想要用資料結構表示一些命令式程式碼時,我的第一個任務是找出我應該使用哪種類型的模型來建構該資料。選擇好的模型可以大幅簡化邏輯,確實有時候使用適應性模型是值得的,即使唯一的原因是讓邏輯更容易遵循。最糟的情況下,我必須從頭開始想出(並演化)這種模型,但通常我可以從現有的計算模型開始。

像這樣的一系列條件建議使用 產生式規則系統,這是一種特別的計算模型,非常適合表示在自適應模型中。產生式規則系統透過一組產生式規則來組織運算,每個規則都由兩個主要元素組成:條件和動作。產生式規則系統會執行所有規則,評估每個規則的條件,如果條件傳回 true,則執行動作。

為了展示這樣做的基本方式,我將針對前幾個條件探討這種方法。以下是它們的命令形式中的兩個條件

recommender.es6…

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

我可以使用兩個產生式規則物件清單的 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 環境中執行的需求。我需要將條件程式碼替換成我可以詮釋的資料。

重構第一行

我將分為兩個部分說明此重構情節。在第一部分中,我將採用這些前幾行(藍色)並將它們重構為製作規則。在第二部分中,我將處理更為棘手的巢狀條件(綠色)。

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 檔案是資料,但應視為程式碼:以相同的方式進行版本控制和測試。

季節條件

接下來是邏輯的第二行

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 屬性是否存在,然後才能測試其內容。每當我看到類似這樣的東西時,我都會痙攣性地去尋找Extract Method

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);
  }

在重構完成後,第二行現在變成具有參數的單一函數。在 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 使用單一值,但函數通常在某個時間點需要多個參數,而且從陣列開始並不需要很大的努力。

萃取國家邏輯

要處理的第三個條件如下所示

recommender.es6…

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

這引入了幾件事。首先,有一個新的規格屬性要探查:藥水將在哪些國家使用。其次,該國家測試與現有的季節測試結合。

我透過一次從頂部取得一個條件來執行此重構。但我現在承認我設計了這些條件,以便我們在條件中獲得逐漸增加的複雜性進程。這對教學是有好處的,但這不會是典型程式碼在現場出現的方式。我確實提倡一次重構一個條件,逐漸建立適應模型的表達能力,就像我這裡所做的一樣。然而,最好的方法是瀏覽程式碼並挑選邏輯片段進行處理,從簡單的事情開始,逐漸變得更複雜。這通常表示從上到下進行並非最簡單的方法。

在重構時,我喜歡一次做一件事,所以我將從處理國家測試開始。與之前的季節測試一樣,我從將國家測試邏輯擷取到其自己的函數開始。

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');
  });

我使用 mochachai 進行測試

我在其中傳入規格並針對現有的推薦邏輯執行它。但要單獨測試國家邏輯,我需要建立並傳入包含國家邏輯的模型,而沒有任何其他條件。我這裡不是在測試我的實際推薦模型,而是某些一般推薦模型的語意。根據程式碼,我需要使用某種類型的 測試替身 替換模型,這將允許我放入簡化的測試模型。

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);
  }

加入連接詞

在規格中測試作業國家/地區並非我處理第三條規則所需的全部

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);
  }

我希望這三個條件能讓您充分了解如何將命令式程式碼重構為自適應模型。我一次轉換一個邏輯區塊。如果模型無法處理區塊,我會使用擴充模型(新增函數、新增函數參數的能力)和重構命令式程式碼(將巢狀條件式替換為連接詞)的組合。

複雜的部分

以下是主要建議函數的目前狀態

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);
  }

這段程式碼之所以難以理解,原因之一是範圍最小持續時間的第一個區段被外部條件排除在外。我會想要移除它,並將其邏輯保留在範圍選擇器中,這表示我需要一個沒有建議的值。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);
  }

那是重複,所以我套用 Extract Method

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,它位於結果集中。解決此問題的方法之一是保護結果。

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);
  }

我可能會爭辯說,這將成為生產規則條件的一部分,但我認為它並不真正符合網域的語意。

另一個選擇是在最後過濾掉 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 );
  }

我使用「!=」來捕捉 Null 和 pickMinDuration 在沒有 minDuration 屬性的情況下傳回的未定義值

雖然這兩個都可行,但我並不熱衷於這樣傳遞 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,以便將非陣列值新增到陣列中。

這會對我的生產規則程式碼造成一點點困惑,它必須處理陣列和值。幸運的是,這是一個常見的問題,有一個常見的解決方案 - flatten 函式

recommender.es6…

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

由於常規 es6 沒有 flatten,我需要使用底線

移除 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 函式。這使其更接近傳統的產生式規則結構,其中條件和動作都是函式。處理此問題的簡單方法是新增一些處理作業至引擎,以處理結果函式或單一結果值。我將使用 Extract Method 執行此作業,先進行一連串小步驟

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 會使用規格,因此我必須使用 Add Parameter 將其傳入

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 模型和引擎個別都比原始程式碼大。單獨來看,這是一件壞事。然而,重要的收穫在於我們現在有一個建議邏輯的單一表示,可以在網站、IOS、Android 或任何其他可以讀取 JSON 檔案的環境中進行詮釋。這是一個相當大的優點,特別是如果邏輯實際上比我這裡所提供的更大時,你應該看看隱形藥水的建議邏輯。

這裡有另一個問題:自適應模型是否比命令式程式碼更容易修改。儘管它比較大,但它更規則。命令式程式碼的靈活性讓它更容易糾纏在一起,而自適應模型的表達力有限,有助於讓邏輯更容易遵循。即使沒有我們在 Atlantis 中遇到的多個執行環境問題,許多人仍然偏好自適應模型,原因就在於此。

我還應該總結一下重構過程。一旦我意識到需要用自適應模型替換一些命令式程式碼,我首先會勾勒出該自適應模型的第一個草稿 - 希望使用一個眾所周知的模型。然後,我取出命令式程式碼的小部分,並用它們來填充自適應模型。如果程式碼明顯不符合模型,我會將其重構成符合模型的形狀,然後將其移過去。如果自適應模型無法與當前程式碼片段配合使用,我會重構模型,讓它可以配合使用。

在此範例中,我用模型替換了所有命令式程式碼,但我不必這麼做。在任何時候,我都可以停止,並將一些邏輯留在模型中,而將一些邏輯留在命令式程式碼中。這對於邊緣情況很有用,這些情況會使模型的複雜性提高太多,不值得擴充模型來處理。在這種情況下,我們將接受重複和應用程式商店的不便,以應付這些邊緣情況,同時能夠透過模型更新來處理大部分規則變更。

一些進一步的重構

在我撰寫本文時,有幾個進一步的重構方向對我大聲疾呼。我可能會重新檢視這篇文章,在另一天加入這些方向。

重新組織模型

在我檢視 JSON 模型時,我認為會比較喜歡稍微重新組織它的結構,以便

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

我會有

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

這樣讓結構更規則一些。在逐漸遷移這個資料結構時,這裡有一些有趣的重構。

以查詢取代命令式分派

引擎目前使用 isActiveresult 函數來調度條件和結果函數。基本上,連接一個 case 語句(當然,如果我是個很酷的功能程式設計師,我會稱它為模式比對)。另一個選項是用一個查詢系統來取代命令式程式碼,其中條件 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 日:發布第一部分