在原生 JavaScript 中转换包含嵌套对象层次结构的复杂数据时,我通常必须编写复杂的嵌套循环结构。这些对心理要求很高的实现会占用宝贵的开发资源,而这些资源应该专用于实现业务逻辑。业务逻辑为客户和最终用户提供了真正的价值,而实现和维护复杂的映射逻辑只会减慢开发速度。

JSONata 是 JSON 数据的查询和转换语言。与其名称相反,引用实现使用普通 JavaScript 对象,而不是严格的 JSON。我已经开始在越来越多的项目中使用JSONata,我在NearForm的同事也同意JSONata为数据映射难题提供了一个优雅的解决方案。它为从数据中查询值和将数据转换为其他格式(具有筛选、映射和聚合)提供了简洁而强大的语法。这种简洁的语法使我在转换数据时能够编写和维护更少的代码行。JSONata 有助于解决这种头痛问题,并让我和我的团队腾出时间解决代码中的实际业务问题。

当然,这是有代价的:优雅的语法和功能在性能方面是昂贵的。当我的NearForm同事开始为JSONata寻找更多的用例时,我决定对各种转换的性能进行基准测试,以将JSONata与本机JavaScript实现进行比较。

在实现方面,使用 JSONata 的性能成本是否值得额外的好处?我将在本文中尝试回答这个问题。

TL;DR:通常是可以的,但这取决于您是否具有特定的性能约束。

什么是JSONata,为什么我喜欢它?

在映射复杂的数据层次结构时,我喜欢将 JSONata 与 JSONPath(类似于 XPath)进行比较。API很少就通用格式达成一致,因此我发现自己必须不断解析从API接收的数据并将其转换为有用的东西。这可能是一个内部模型,它将保存在后端或提供给客户端。在NearForm,我的同事们甚至看到了后端实现,其中允许开发人员以最适合消费调用方的方式构建他们的API模式,并约束他们提供JSONata表达式以将模式映射到内部数据模型。

JSONata的另一个常见用例是后端集成 - 从一个系统的API获取数据,对其进行转换,然后将数据写入独立系统的API。这是JSONata真正闪耀的用例。简洁的语法使重新格式化数据变得轻而易举。

无论是在模型之间转换数据,还是从数据集聚合统计信息,JSONata 都能通过优雅、简洁和强大的语法来简化任务。

没有数据,没有问题

与 JSONPath 或常用的 lodash get 函数类似,对未定义值工作的查询只不返回任何内容。对于嵌套字段也是如此。与这种便利性相关的风险是没有错误。如果查询到深度嵌套的字段,并且路径上的任何字段都是未定义的,则不会出现任何错误。如果在对象路径中出错,并且应用程序继续运行,则不会有数据。这可能会使调试复杂查询变得有些困难,但在使用可选字段时会使其变得简单得多。以下示例与一个应用程序相关,该应用程序需要创建与用户家庭住址关联的所有唯一城市的列表,并忽略没有家庭住址的用户。

users.addresses[type='home'].city

使用谓词筛选数据

上面的示例还演示了 JSONata 的谓词语法,该语法允许您以流畅的语法筛选对象数组。它非常强大,使缩小输入数据范围变得非常简单。有时,您希望按深度嵌套字段(可能遍历数组)筛选数据,并且可能基于相关值筛选数据。JSONata 中的谓词语法涵盖了所有这些用例。

沿途测绘

映射是转换语言的全部意义所在。在 JSONata 中,点运算符不仅用于引用对象的属性。相反,它实现映射操作。这使您可以循环访问对象集合,并以流畅的语法将它们映射到新对象。
例如,电子商务应用程序需要计算用户购物车中每件商品的小计:

orders.item.(price * quantity)

使用聚合计算动态值

在进行数据转换时,聚合非常强大。JSONata 包括一组标准的聚合函数,如 sum、min、max 和 average。
例如,对于银行应用程序,您不必映射所有交易,然后减少它们以计算总和,只需在转换数据时调用总和聚合即可。

$sum(accounts.transactions.amount)

干变量

软件工程师被教导要接受DRY原理。转换数据时,有时需要变量来消除可能很长的重复表达式。如果需要从深度嵌套对象解析多个字段,JSONata 允许您将该对象存储在变量中,以便您可以解析变量中的字段,而不是重复深度嵌套对象的路径。但是,由于 JSONata 独特的语法,带有变量的 JSONata 表达式可能更难阅读,因此最好在工具包中保留变量,以备真正需要时使用。

想象一下,在映射用户时,我的应用程序需要访问该位置以设置多个字段:

users.(
  $location := addresses[type='home'].city;
  {
    "location": $location,
    "id": $location & '-' & lastName  })

上下文绑定到 JOIN 相关数据

这是 JSONata 的一项高级功能,可以真正节省时间。您可能正在处理一个集成项目,在该项目中,您需要调用多个 API 并在代码中重建相关数据。这就是 JSONata 中的上下文绑定运算符真正闪耀的地方。在功能上,它类似于 SQL JOIN。我可以将相关数据联接到新对象中。在纯 JavaScript 中执行此操作通常需要嵌套循环循环迭代初始集合,然后在第二个集合中找到相关数据。

在此示例中,我们的电子商务后端具有一个用于返回商品 ID 的订单 API 和一个用于商品详细信息的单独 API。我们可以使用 JSONata 上下文绑定运算符联接这两个响应。

user.orders.items@$i.(inventory.items.details)@$d[$i.id=$d.id].{
  "quantity": $i.quantity,
  "description": $d.description}

JSONata蜜月的结束

我爱JSONata。当我们转换数据时,它为我和我的同事削减了大量的工作。通过更少的代码和更少的脑力消耗,它促进了快速的开发速度和更轻松的维护。但是,所有这些好处都是有代价的吗?而且,如果是,我们可以衡量这个成本吗?
第一个大问题是性能。如果 JSONata 的性能成本与原生 JavaScript 相比不利,则可能不适合大规模和重负载的节点.js后端。当服务需要每小时为数百万个请求提供服务时,每个 CPU 周期都变得弥足珍贵。请务必注意,并非每个后端服务都是如此。

另一个挑战是JSONata是一种领域特定的语言,需要一些增加开发人员的加入。语法非常紧凑,并提供了在加入新团队成员时必须识别的学习曲线。有些人可能会争辩说,紧凑的语法不太易读,但它的紧凑性有助于编写简洁的实现。与长函数和充满嵌套循环的文件(带有map和reduce)相比,尤其如此。

关于语法的最后一点是,调试可能是一个挑战。null 值或未定义值没有错误这一事实意味着计算表达式可能不会返回任何内容 — 没有错误,只是什么都没有。调试 JSONata 表达式时需要一些特定的技能集才能有效。我喜欢通过始终在转换传入数据之前对其进行验证并始终对 JSONata 表达式进行单元测试来缓解这些"意外"。

对速度的需求

我决定测试我的假设,即JSONata不能像原生JavaScript那样具有性能。我使用基准测试创建了一些性能基准.js。用于运行基准测试的所有代码都可以在GitHub上找到 这里.

首先,我需要一些数据。我前往诺贝尔奖开发者专区,决定使用他们的版本 2 获奖者和 nobelPrizes API 端点。

基准称为API,以获得从2000年到2005年的所有诺贝尔奖获得者和诺贝尔奖。然后对数据执行不同复杂度的几种转换。我尝试使用普通的JavaScript和JSONata表达式编写等效的转换。基准测试衡量每个转换每秒可以执行多少次操作,比较JavaScript和JSONata实现。

测试是在我的System 76笔记本电脑上运行的,该笔记本电脑配备了一个核心i5处理器,其中16 GB RAM运行Pop!_OS 20.04。我使用 node.js 14.14.0 运行了测试,允许我在转换的 JavaScript 实现中使用可选的链接运算符。这一点很重要,因为如果我使用旧版本的node.js编写JavaScript转换,那么等效的实现将更加冗长,以确保定义属性。

数据

以下是诺贝尔奖获得者的样本文件。为了提高可读性,我删除了一些未使用的字段。可以从此处的 API 获取完整文档。

{
  id: '745',
  knownName: { en: 'A. Michael Spence', se: 'A. Michael Spence' },
  givenName: { en: 'A. Michael', se: 'A. Michael' },
  familyName: { en: 'Spence', se: 'Spence' },
  fullName: { en: 'A. Michael Spence', se: 'A. Michael Spence' },
  gender: 'male',
  birth: {
    date: '1943-00-00',
    place: {
      city: { en: 'Montclair, NJ', no: 'Montclair, NJ', se: 'Montclair, NJ' },
      country: { en: 'USA', no: 'USA', se: 'USA' },
      cityNow: { en: 'Montclair, NJ', no: 'Montclair, NJ', se: 'Montclair, NJ' },
      countryNow: { en: 'USA', no: 'USA', se: 'USA' },
      continent: { en: 'North America' },
      locationString: {
        en: 'Montclair, NJ, USA',
        no: 'Montclair, NJ, USA',
        se: 'Montclair, NJ, USA'
      }
    }
  },
  links: {
    // ...
  },
  nobelPrizes: [
  // ...
  ]}

以下是诺贝尔奖文件的样本文件。如上例所示,我删除了一些字段以提高可读性。您可以从此处的 API 访问完整文档。

{
  awardYear: '2000',
  category: { en: 'Chemistry', no: 'Kjemi', se: 'Kemi' },
  categoryFullName: {
    en: 'The Nobel Prize in Chemistry',
    no: 'Nobelprisen i kjemi',
    se: 'Nobelpriset i kemi'
  },
  dateAwarded: '2000-10-10',
  prizeAmount: 9000000,
  prizeAmountAdjusted: 11453996,
  links: {
    // ...
  },
  laureates: [
    {
      id: '729',
      knownName: { en: 'Alan Heeger' },
      portion: '1/3',
      sortOrder: '1',
      motivation: {
        en: 'for the discovery and development of conductive polymers',
        se: 'för upptäckten och utvecklandet av ledande polymerer'
      },
      links: {
        // ...
      }
    },
  // ...
  ]}

细心的读者可能会注意到,我测试过的一些转换已经由 API 提供。这是真的,这些转换是人为的。但是,它们使用从多个 API 端点获取的真实数据,使其与实际 Web 服务器相关。

基准测试

转换 1:简单映射

第一个基准测试收集了获奖者文件的所有英文名称。

脚本

function jsSimple(input) {
  return input.laureates  .map((laureate) => laureate?.knownName?.en)
  .filter((name) => name);}

杰森娜塔

const jsonataSimple = jsonata('laureates.knownName.en');

转换 2:复杂映射

脚本

function jsComplex(input) {
  return input?.laureates.map((l) => ({
    name: l?.knownName?.en || l?.orgName.en,
    gender: l?.gender,
    prizes: l?.nobelPrizes.map((p) => p?.categoryFullName?.en)
  }));}

杰森娜塔

const jsonataComplex = jsonata(`
  laureates.{
    "name": knownName.en ? knownName.en : orgName.en,
    "gender": gender,
    "prizes": nobelPrizes.categoryFullName.en[]
  }
`);

转换 3:联接相关数据

脚本

function jsJoin({laureates, prizes}) {
  // Map over each prize (flatMap automatically removes the resulting nesting)
  return prizes.nobelPrizes.flatMap((prize) =>
    // Filter all laureates who have an id associated with the prize.
    // This is complex because each prize can have multiple laureates.
    laureates.laureates    .filter((laureate) =>
      prize.laureates      .map((prizeLaureate) => prizeLaureate.id)
      .includes(laureate.id)
    )
    // Map each laureate and prize to the new data structure
    .map((laureate) => ({
      name: laureate?.knownName?.en,
      gender: laureate?.gender,
      prize: prize?.categoryFullName?.en    }))
  );}

杰森娜塔

const jsonataJoin = jsonata(`
  (prizes.nobelPrizes)@$p.(laureates.laureates)@$l[$l.id in $p.laureates.id].{
    "name": $l.knownName.en,
    "gender": $l.gender,
    "prize": $p.categoryFullName.en
  }
`);

基准测试结果

以下是基准测试的结果:

杰森娜塔

基准测试的结果不言自明。原生JavaScript实现总是更快,有时是数百个因素。实际的基准测试套件包含的基准测试比我在这里展示的要多,但它们强化了相同的结论。

一个重要的免责声明:我的JavaScript实现是用纯JavaScript编写的,很少强调代码重用。这允许一个可以通过JavaScript运行时进行大量优化的实现。大多数项目将查找库或创建自己的通用解决方案,以允许在转换之间重用代码。结果是,我的基准代表了最坏的情况。在现实世界的系统中,性能的差异可能不会那么明显。在这些系统中,这些纯JavaScript实现的增加复杂性将对开发和维护速度产生更大的影响。

结论

与许多编码问题一样,最佳解决方案并不总是用基准来衡量。相反,成为软件工程师的艺术包括理解需求并选择最符合业务和技术限制的解决方案。性能只是构建应用程序所涉及的支柱之一。在确定最佳解决方案时,开发速度和维护同样重要(甚至更重要)。
JSONata 仍然很快,特别是对于对 API 进行多次调用或使用数据库的大量工作的应用程序,这些操作通常比转换消耗更多的时间。修复应用程序中性能问题的一个重要原则是避免追求微优化。相反,应根据其影响和实施复杂性来选择性能增强功能。

目前,我不能推荐JSONata用于高负载应用程序。在每小时需要为数百万个请求提供服务的服务中,每个 CPU 周期都变得非常宝贵。

但是,对于集成系统的项目,我不能推荐JSONata。它已成为我工具包中的重要工具。从这些服务中挤出几毫秒的性能是没有用的,尤其是当您考虑使用 JSONata 时可能获得的开发和维护速度的收益时。

我希望看到 JSONata 未来有一些性能改进。有一些与项目绩效相关的未决问题。随着 JSONata 性能的提高,我相信性能、开发速度和维护之间的平衡将发生变化,JSONata 将很快成为我的许多项目中有价值的解决方案,包括高负载应用程序。