GraphQL-Calculator 开源:基于指令和表达式实现查询的动态计算

2023-04-01 16:28:37 浏览数 (2)

作者 | 杜艮魁

审校 | 蔡芳芳

GraphQL 查询出的基础数据和业务需求往往有些差异,需要研发同学加工后才能渲染展示。而通过硬编码的方式对数据进行加工处理无法满足应用快速开发的需求,也与 GraphQL 配置化的思想相悖。本文将介绍如何通过指令和表达式实现 GraphQL 查询的计算能力,以减少代码开发和服务发版上线,提高业务迭代效率。

背   景

计算需求概述

GraphQL 作为接口描述语言,可对其治理的数据进行便捷的查询,但真实业务场景除了获取基础数据外,往往还需要对数据进行加工处理,概括如下:

  1. 结果字段加工:对基础数据进行加工后展示。例如将‘分’单位的数字价格转为‘元’单位的价格文案、使用默认值兜底 null、将状态 code 转换成对应文案等;
  2. 列表过滤、排序:通过 id 列表查询出数据详情列表之后,往往需要根据详情信息对结果列表进行过滤排序,例如过滤掉商品列表中在售状态为 false 的商品,将商品按照销量进行排序;
  3. 参数处理:对参数列表进行过滤,例如过滤掉 itemIdList 中为 0 的 itemId;对参数进行转换,例如将 Redis 的 key 前缀拼接到 itemId 前边、作为请求 Redis 数据源的 key;
  4. 数据编排依赖:类似于 MySQL 中的子查询,将一个字段的解析结果作为另一个字段的获取参数;
  5. 控制流:通过请求变量判断是否请求指定的字段,GraphQL 原生指令 @include 和 @skip 只支持 bool 类型的变量,但真实的业务场景判断规则更加复杂,往往存在逻辑计算。

为何使用指令

如果将 GraphQL 仅作为僵硬的取数工具,就违背了 GraphQL 配置化的初衷,也忽略了 GraphQL 的扩展能力。作为“接口查询语言”,GraphQL 提供指令作为查询执行能力的扩展机制。指令类似于 Java 注解,可对其进行注解的语言元素进行额外的信息描述。

Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

作为 GraphQL 官方指定的能力拓展机制,GraphQL 生态的框架对指令有更好的支持,基于指令的能力拓展和框架本身也具有更好的兼容性。

如何使用指令

指令主要是对 GraphQL 语言元素的信息描述,例如使用 @include 指令描述是否请求某个字段:

代码语言:javascript复制
query userInfo($userId:Int, $needEmail:Boolean!){    userInfo(userId:$userId){        userId        userName        age        # 当 $needEmail 为 true 时才会请求、返回 email 字段        email @include(if:$needEmail)    }}

GraphQL-java 框架集成了 GraphQL 协议原生指令:在执行引擎中判断每个字段是否带有 @incldue 指令,有的话则根据起用到的变量信息判断是否请求该字段,@skip 实现同理。

自定义指令实现思路相同:

  1. 根据数据处理需求设计指令;
  2. 在查询中使用指令对查询元素进行注解描述;
  3. 在查询引擎中获取指令信息和查询上下文,执行符合指令语义的行为。

GraphQL-java 提供了 Instrumentation 机制,该机制类似于 spring 中的切面,可在数据处理的各个阶段获取到校验、查询各个阶段的上下文信息,并可改变执行上下文信息和结果、或中断查询的执行。

问题和方案

基于 Instrumentation,GraphQL-calculator实现了一套具有参数处理、结果字段加工、数据依赖编排和控制流能力的指令集。 该指令集可使表达式对上下文数据进行加工转换,其默认表达式引擎为 aviatorscript。

集合过滤、排序

 问题简述

通过 id 列表获取到数据详情集合之后,往往需要根据数据详情对集合进行过滤,或者按照指定规则对集合进行排序。

如下查询,通过商品 id 列表获取到商品详情集合,业务场景需要将库存为 0、非在售状态的商品过滤掉,然后按照售价递增排序。如果硬编码形式实现则需要走编码、调试、部署、上线等步骤,流程长、响应慢。

代码语言:javascript复制
query commodityInfo($ItemIds:[Int]){    commodity{        filteredItemList: itemList(itemIds: $ItemIds){            itemId            onSale            name            salePrice            stockAmount        }    }}

 解决方案

针对集合过滤、排序的需求,GraphQL-calculator 定义了 @filter 和 @srotBy 指令对集合进行动态处理:

代码语言:javascript复制
directive @filter(predicate: String!) on FIELD
  • predicate:过滤判断表达式,会应用在每个集合元素上,结果为 true 的元素会被保留,当 @filter 用在叶子节点上时,表达式变量为 key 为ele、value 为元素值。
代码语言:javascript复制
directive @sortBy(comparator: String!, reversed: Boolean = false) on FIELD
  • comparator:用户比较列表元素顺序的比较器,当 @filter 用在叶子节点上时,表达式变量为 key 为ele、value 为元素值;
  • reversed:是否逆序排序;

使用 @filter 和 @sortBy 指令对商品列表进行过滤并排序的查询如下:

代码语言:javascript复制
query filterUnSaleAndSortCommodity($ItemIds:[Int]){    commodity{        filteredItemList: itemList(itemIds: $ItemIds)        @filter(predicate: "onSale && stockAmount>0")        @sortBy(comparator: "salePrice")        {            itemId            onSale            name            salePrice            stockAmount        }    }}

参数处理

 问题简述

在调用数据源接口时,经常需要把上游传递的参数进行过滤、去重或者转换等,不同的业务场景可能有不同的转换规则。有时候线上出现意想不到的参数,也需要我们通过配置化的方式对参数进行即刻生效的处理,而非紧急修改代码、上线这种漫长的流程。

例如下述查询,查询在线用户详情信息。调用方传递的参数可能存在未登录用户参数,即 userId 为 0。如果数据源接口没有兼容这种异常情况、则会导致接口意想不到的行为或结果。此时需要我们对参数进行过滤。

代码语言:javascript复制
query simpleArgumentTransformTest($userIds:[Int]){   consumer{       userInfoList(userIds: $userIds){           userId           name           age       }   }}

 解决方案

针对需要对参数进行处理的场景,GraphQL-calculator 定义了 @argumentTransform 对请求参数进行处理,包括参数转换、列表参数过滤、元素转换:

代码语言:javascript复制
directive @argumentTransform(argumentName:String!, operateType:ParamTransformType!, expression:String!, dependencySources:[String!]) on FIELD

enum ParamTransformType{    MAP # 参数转换    FILTER # 列表类型参数过滤    LIST_MAP # 列表类型参数元素转换}
  • argumentName:进行转换的参数名称,参数必须定义在被注解的字段上;
  • operateType:操作类型;
  • expression:计算新值、或者对参数进行过滤的表达式;
  • dependencySources:表达式依赖的 source,如果和参数变量同名则会覆盖后者,source 具体含义见数据编排。

使用 @argumentTransform 对参数进行过滤的查询如下:

代码语言:javascript复制
query simpleArgumentTransformTest($userIds:[Int]){   consumer{       userInfoList(userIds: $userIds)       @argumentTransform(argumentName: "userIds",operateType: FILTER,expression: "ele!=0")       {           userId           name           age       }   }}

数据编排

 问题简述

所谓的数据编排就是将一个字段的结果、作为另外一个字段的输入。例如从商品列表中抽取出商品的货主 id 列表、作为参数去获取卖家个人信息详情。

如果仅仅是用 GraphQL 来僵硬地获取数据,则做法为:

  1. 通过第一次查询queryItemInfo获取商品基本信息;
  2. 解析queryItemInfo查询结果,获取商品列表中的卖家 id 列表;
  3. 使用第 2 步解析的卖家 id 列表,获取卖家个人信息;
代码语言:javascript复制
# step 1: 获取商品详情列表

query queryItemInfo($itemIds:[Int]){    commodity{        itemList(itemIds: $itemIds){            itemId            # 商品货主 id            sellerId            name            salePrice            stockAmount        }    }}



# step 2:解析 queryItemInfo 结果,获取 $sellerIds;





# step 3:获取卖家详情列表

query querySellerInfo($sellerIds:[Int]){    business{        sellerInfoList(sellerIds: $sellerIds){            sellerId            name            age            email        }    }}

 解决方案

类似 MySQL 中的子查询,如果依赖逻辑合理,任何字段的获取结果都应当可以作为请求其他字段的参数。GraphQL-calculator 通过 @fetchSource 对作为参数的字段进行描述:

代码语言:javascript复制
directive @fetchSource(name: String!, sourceConvert:String) on FIELD
  • name:被注解的字段作为被依赖数据时的 source 名称,一个查询中的 source 名称具有唯一性;
  • sourceConvert:对 source 进行转换的表达式,如果被注解的字段在列表中、则每个元素都会被该表达式转换。

@fetchSource 是进行数据编排的基础,不管是作为参数进行流程编排、还是后续讲到的数据加工。当要用到其他字段结果作为参数进行计算时、都是通过 @fetchSource 将被依赖的数据进行描述、保存为其他字段指令可获取的数据。

通过指令实现数据依赖编排的查询如下:

代码语言:javascript复制
query simpleOrchestration($itemIds:[Int]){    commodity{        itemList(itemIds: $itemIds){            itemId            # 将被依赖的数据使用 @fetchSource 进行描述            sellerId @fetchSource(name: "sellerIdList")            name            salePrice            stockAmount        }    }

    business{        sellerInfoList(sellerIds: 1)        # 用 @argumentTransform 对参数进行转换        @argumentTransform(argumentName: "sellerIds",operateType: MAP,expression: "sellerIdList",dependencySources: ["sellerIdList"])        {            sellerId            name            age            email        }    }}

结果加工

 问题简述

当从某个业务域接口获取到基础数据后,往往需要对数据进行加工处理后才能在页面展示,例如根据用户 id 拼接出用户主页链接,将‘分’单位的数字价格转为‘元’单位的价格文案、使用默认值兜底 null、将状态 code 转换成对应文案等。

示例为获取商品基本信息的查询,‘#’ 注解的信息为需要加工处理出的字段,该查询所要加工的字段已经结构化的清晰的展示出来,要执行的加工逻辑通用简单。

代码语言:javascript复制
query itemBaseInfo_case01($itemIds:[Int]){    commodity{        itemList(itemIds: $itemIds){            itemId            name
            # 分 ->元:salePrice/100            salePrice
           # 1. 自营;2. 第三方店铺:分别使用文案 自营正品、三方好货 描述            itemType        }    }}

 解决方案

GraphQL-calculator 定义了 @map 指令用于字段结果的加工计算,该指令可通过参数 dependencySources 获取到其他字段结果、实现类似于 mysql 中 join 计算的能力。

代码语言:javascript复制
directive @map(mapper:String!, dependencySources:String) on FIELD
  • mapper:计算被注解字段值的表达式,被注解字段绑定的 DataFetcher 不会执行;
  • dependencySources:表达式依赖的 source,sourceName 如果和父节点绑定 DataFetcher 的获取结果 key 相同,则计算表达式时会覆父节点中的数据。

使用 @map 对字段结果进行加工的查询如下:

代码语言:javascript复制
query itemBaseInfo_case01($itemIds:[Int]){    commodity{        itemList(itemIds: $itemIds){            itemId            name            # 分 ->元:salePrice/100            salePrice @map(mapper:"salePrice/100")             # 1. 自营;2. 第三方店铺:分别使用文案 自营正品、三方好货 描述            itemTypeDesc: name @map(mapper:"itemType==1?' 自营正品':'三方好货'")        }    }}

控制流

 问题简述

GraphQL 内置了 @skip 和 @include 来决定是否请求指定字段,其参数为 bool 类型。但真实的场景往往存在逻辑计算,无法使用一个简单的 bool 类型参数表示是否请求指定字段。

如下查询,期望只有 v2 版本的客户端才可以看到 email 字段。这种if控制流的实现放在 DataFetcher 中硬编码实现则不够灵活,难以满足各种场景的控制需求。

代码语言:javascript复制
query userInfoQuery($userId:Int){    consumer{        userInfo(userId: $userId){            userId            age            name            # 期望只有 v2 版本的客户端可以获取到该字段            # 客户端版本可以作为请求变量            email        }    }}

 解决方案

GraphQL-calculator 定义了 @includeBy 指令判断是否请求指定字段,该指令可理解为 GraphQL 内置指令 @include 的拓展版本,但起判断逻辑为表达式、表达式参数为所有请求变量。

代码语言:javascript复制
directive @includeBy(predicate: String!, dependencySources:[String!]) on FIELD
  • predicate:判断是否解析该字段的表达式;
  • dependencySources:表达式参数除了请求变量外,还可使用其他 source。

使用 @includeBy 判断是否请求 email 的查询如下:

代码语言:javascript复制
query queryMoreDetail_case01($userId:Int,$clientVersion:String){    consumer{        userInfo(            userId: $userId,            # 受限于 GraphQL 原生语法校验,变量必须被明确的作为参数使用            clientVersion: $clientVersion){            userId            age            name            # 只在 v2 版本的客户端中展示            email @includeBy(predicate: "clientVersion == 'v2'")        }    }}

参考资料:

  • https://spec.graphql.org/
  • https://github.com/graphql-java/graphql-java
  • https://github.com/dugenkui03/graphql-java-calculator
  • https://stepzen.com/blog/graphql-directives

作者介绍:

杜艮魁,开源组件 GraphQL-java 的活跃 contributor,主要参与了 15、16 版本的指令能力升级和语法校验,GraphQL 协议 contributor。先后在美团快手从事 GraphQL 的平台化开发。

点击文末【阅读原文】移步InfoQ官网,内容更多更精彩!

今日好文推荐

PHP没你想的那么差

微服务需要一场由内至外的变革

被“监控”的打工人:因算法裁定“效率低下”,近150名员工遭解雇

携程试点每周两天居家办公反响热烈,76%的员工主动报名


InfoQ 写作平台欢迎所有热爱技术、热爱创作、热爱分享的内容创作者入驻!

还有更多超值活动等你来!

扫描下方二维码

填写申请,成为作者

开启你的创作之路吧~

点个在看少个 bug

0 人点赞