重构为自适应模型

我们的大多数软件逻辑都是用编程语言编写的,这些语言为我们提供了编写和演化此类逻辑的最佳环境。但在某些情况下,将逻辑移动到我们的命令式代码可以解释的数据结构中是很有用的——我将其称为自适应模型。在这里,我将展示一些 JavaScript 中的产品选择逻辑,并展示如何将其重构为用 JSON 编码的简单生产规则系统。这些 JSON 数据允许我们在使用不同编程语言的设备之间共享此选择逻辑,并在不更新这些设备上的代码的情况下更新此逻辑。

2015 年 11 月 19 日



我最近为位于亚特兰蒂斯的希腊药剂公司做了一些咨询工作。他们正在开发软件应用程序,以帮助药剂师制作有效的药剂。好的药剂酿造的一个方面是在你的药剂配方中获得正确种类的成分。例如,某种飞行药剂的配方需要蟋蟀的翅膀,但不同品种的蟋蟀在不同情况下是最好的。该软件可以推荐在某些情况下哪些品种是最好的,但问题是如何对该逻辑进行编码。

由于这个软件团队是一个很酷的团队,他们的服务器端软件运行在 node.js 上。但药剂酿造是一个混乱的工业过程——斯廷法罗斯鸟真的会干扰 wifi。所以他们需要在客户端运行品种推荐逻辑,并支持 IOS 和 Android 的移动应用程序。问题是,这导致了尴尬的重复——相同的逻辑在 JavaScript、Swift 和 Java 之间重复。改变它本身就是一项工作,不仅所有代码都需要同步更改,你还必须处理 App Store,即使是宠物牛头怪在库比蒂诺也很少给人留下印象。

一种选择是在每个设备上运行 JavaScript 版本的逻辑,并使用在 Web 视图中运行代码的机制。但另一种选择是将推荐逻辑重构为数据——我将其称为自适应模型。这允许我们以 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);
  }

虽然这是一个复合条件,但它只表示一个意图——复合性质是因为我必须在测试其内容之前检查季节属性是否存在。每当看到这样的东西时,我都会不由自主地想到提取方法

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

这是重复的,所以我应用了提取方法

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 和当没有“minDuration”属性时“pickMinDuration”返回的未定义值

虽然这两种方法都有效,但我不想像这样到处乱扔 null。如果没有要返回的内容,我宁愿不返回任何内容,也不愿返回一些表示没有内容的信号。有一种经典的方法可以解决这个问题——不是返回单个值,而是返回一个列表。返回 nothing 则意味着返回空列表。

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,我需要使用 underscore

移除 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 模型和引擎分别比原始代码大。就其本身而言,这是一件坏事。然而,重要的收获是,我们现在有了一个单一的推荐逻辑表示,可以在网站、IOS、Android 或任何其他可以读取 JSON 文件的环境中解释。这是一个相当大的优势,特别是如果逻辑实际上比我在这里得到的更大——你应该看看隐形药水的推荐逻辑。

这里还有另一个问题:自适应模型是否比命令式代码更容易修改。虽然它更大,但它更规则。对于更大的规则集,命令式代码的灵活性可以让它更容易变得混乱,而自适应模型的有限表达能力可以帮助保持逻辑更容易理解。许多人出于这个原因而青睐自适应模型,即使他们没有我们在 Atlantis 中遇到的多执行环境问题。

我还应该总结一下重构过程。一旦我意识到我需要用自适应模型替换一些命令式代码,我首先会勾勒出该自适应模型的初稿——希望使用一个众所周知的模型。然后,我取一小段命令式代码,并用填充自适应模型来替换它们。如果代码与模型明显不符,我将对其进行重构以使其 phù hợp,然后将其移至。如果自适应模型与当前的代码片段不能很好地配合,我将重构模型以使其配合。

在这个例子中,我用模型替换了所有命令式代码,但我没有必要这样做。在任何时候,我都可以停止并将一些逻辑保留在模型中,而将一些逻辑保留在命令式代码中。这对于边缘情况很有用,因为边缘情况会增加模型的复杂性,以至于不值得扩展模型来处理。在这种情况下,我们将接受这些边缘情况的重复和应用商店的不便,同时能够通过模型更新来处理大多数规则更改。

一些进一步的重构

在我写这篇文章的时候,有几个进一步的重构方向在向我招手。我可能会改天再来看这篇文章,把那些内容加进去。

重组模型

当我查看 JSON 模型时,我认为更愿意稍微重新组织一下它的结构,以便不再是

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

我会的

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

这使得结构更加规则。在逐步迁移此数据结构时,这里有一些有趣的重构。

用查找替换命令式调度

引擎当前使用“isActive”和“result”函数调度条件和结果函数。本质上是连接一个 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 日:发布了第一部分