原文出处:www.infoq.cn/article/AEq…
摘要
graphql查询出的基础数据和业务需求往往有些差异,需要研发同学加工后才能渲染展示。通过硬编码的方式对数据进行加工处理无法满足应用快速开发的需求,也与graphql配置化的思想相悖。本文介绍通过指令和表达式实现graphql查询的计算能力,减少代码开发和服务发版上线,提高业务迭代效率。
背景
计算需求概述
graphql作为接口描述语言,可对其治理的数据进行便捷的查询,但真实的业务场景除了获取基础数据外,往往需要对数据进行加工处理,概括如下:
- 结果字段加工:对基础数据进行加工后展示。例如将‘分’单位的数字价格转为‘元’单位的价格文案、使用默认值兜底null、将状态code转换成对应文案等;
- 列表过滤、排序:通过id列表查询出数据详情列表之后,往往需要根据详情信息对结果列表进行过滤排序,例如过滤掉商品列表中在售状态为false的商品,将商品按照销量进行排序;
- 参数处理:对参数列表进行过滤,例如过滤掉itemIdList中为0的itemId;对参数进行转换,例如将redis的key前缀拼接到itemId前边、作为请求redis数据源的key;
- 数据编排依赖:类似于mysql中的子查询,将一个字段的解析结果作为另一个字段的获取参数;
- 控制流:通过请求变量判断是否请求指定的字段,graphql原生指令@include和@skip只支持bool类型的变量,但真实的业务场景判断规则更加复杂,往往存在逻辑计算。
本文介绍通过指令和表达式实现graphql查询计算能力的配置化,达到快速开发应用、提高业务迭代效率的目的。
为何使用指令
将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指令描述是否请求某个字段:
query userInfo($userId:Int, $needEmail:Boolean!){
userInfo(userId:$userId){
userId
userName
age
# 当 $needEmail 为true时才会请求、返回email字段
email @include(if:$needEmail)
}
}
graphql-java框架集成了graphql协议原生指令:在执行引擎中判断每个字段是否带有 @incldue 指令,有的话则根据起用到的变量信息判断是否请求该字段,@skip实现同理。
自定义指令实现思路相同:
- 根据数据处理需求设计指令;
- 在查询中使用指令对查询元素进行注解描述;
- 在查询引擎中获取指令信息和查询上下文,执行符合指令语义的行为。
graphql-java提供了Instrumentation机制,该机制类似于spring中的切面,可在数据处理的各个阶段获取到校验、查询各个阶段的上下文信息,并可改变执行上下文信息和结果、或中断查询的执行。
问题和方案
基于Instrumentation,graphql-calculator实现了一套具有参数处理、结果字段加工、数据依赖编排和控制流能力的指令集。该指令集可使表达式对上下文数据进行加工转换,其默认表达式引擎为aviatorscript。
github地址:github.com/dugenkui03/…
集合过滤、排序
问题简述
通过id列表获取到数据详情集合之后,往往需要根据数据详情对集合进行过滤,或者按照指定规则对集合进行排序。
如下查询,通过商品id列表获取到商品详情集合,业务场景需要将库存为0、非在售状态的商品过滤掉,然后按照售价递增排序。 如果硬编码形式实现则需要走编码、调试、部署、上线等步骤,流程长、响应慢。
query filterUnSaleCommodity($ItemIds:[Int]){
commodity{
filteredItemList: itemList(itemIds: $ItemIds){
itemId
onSale
name
salePrice
stockAmount
}
}
}
解决方案
针对集合过滤、排序的需求,graphql-calculator定义了@filter和@srotBy指令对集合进行动态处理:
directive @filter(predicate: String!) on FIELD
- predicate:过滤判断表达式,会应用在每个集合元素上,结果为true的元素会被保留,当@filter用在叶子节点上时,表达式变量为key为
ele、value为元素值。
directive @sortBy(comparator: String!, reversed: Boolean = false) on FIELD
- comparator:用户比较列表元素顺序的比较器,当@filter用在叶子节点上时,表达式变量为key为
ele、value为元素值; - reversed:是否逆序排序;
使用 @filter 和 @sortBy 指令对商品列表进行过滤并排序的查询如下:
query filterUnSaleCommodity($ItemIds:[Int]){
commodity{
filteredItemList: itemList(itemIds: $ItemIds)
@filter(predicate: "onSale && stockAmount>0")
@sortBy(comparator: "salePrice")
{
itemId
onSale
name
salePrice
stockAmount
}
}
}
参数处理
问题简述
在调用数据源接口时,经常需要把上游传递的参数进行过滤、去重或者转换等,不同的业务场景可能有不同的转换规则。有时候线上出现意想不到的参数,也需要我们通过配置化的方式对参数进行即刻生效的处理,而非紧急修改代码、上线这种漫长的流程。
例如下述查询,查询在线用户详情信息。调用方传递的参数可能存在未登录用户参数,即userId为0。如果数据源接口没有兼容这种异常情况、则会导致接口意想不到的行为或结果。此时需要我们对参数进行过滤。
query simpleArgumentTransformTest($userIds:[Int]){
consumer{
userInfoList(userIds: $userIds){
userId
name
age
}
}
}
解决方案
针对需要对参数进行处理的场景,graphql-calculator定义了@argumentTransform对请求参数进行处理,包括参数转换、列表参数过滤、元素转换:
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对参数进行过滤的查询如下:
query simpleArgumentTransformTest($userIds:[Int]){
consumer{
userInfoList(userIds: $userIds)
@argumentTransform(argumentName: "userIds",operateType: FILTER,expression: "ele!=0")
{
userId
name
age
}
}
}
数据编排
问题简述
所谓的数据编排就是将一个字段的结果、作为另外一个字段的输入。例如从商品列表中抽取出商品的货主id列表、作为参数去获取卖家个人信息详情。
如果仅仅是用graphql来僵硬的获取数据,则做法为:
- 通过第一次查询
queryItemInfo获取商品基本信息; - 解析
queryItemInfo查询结果,获取商品列表中的卖家id列表; - 使用第2步解析的卖家id列表,获取卖家个人信息;
# 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对作为参数的字段进行描述:
directive @fetchSource(name: String!, sourceConvert:String) on FIELD
- name:被注解的字段作为被依赖数据时的source名称,一个查询中的source名称具有唯一性;
- sourceConvert:对source进行转换的表达式,如果被注解的字段在列表中、则每个元素都会被该表达式转换。
@fetchSource是进行数据编排的基础,不管是作为参数进行流程编排、还是后续讲到的数据加工。当要用到其他字段结果作为参数进行计算时、都是通过@fetchSource将被依赖的数据进行描述、保存为其他字段指令可获取的数据。
通过指令实现数据依赖编排的查询如下:
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转换成对应文案等。
示例为获取商品基本信息的查询,‘#’ 注解的信息为需要加工处理出的字段,该查询所要加工的字段已经结构化的清晰的展示出来,要执行的加工逻辑通用简单。
query itemBaseInfo_case01($itemIds:[Int]){
commodity{
itemList(itemIds: $itemIds){
itemId
name
# 分->元:salePrice/100
salePrice
# 1. 自营;2.第三方店铺:分别使用文案 自营正品、三方好货 描述
itemType
}
}
}
解决方案
graphql-calculator定义了@map指令用于字段结果的加工计算,该指令可通过参数dependencySources获取到其他字段结果、实现类似于mysql中join计算的能力。
directive @map(mapper:String!, dependencySources:String) on FIELD
- mapper:计算被注解字段值的表达式,被注解字段绑定的DataFetcher不会执行;
- dependencySources:表达式依赖的source,sourceName如果和父节点绑定DataFetcher的获取结果key相同,则计算表达式时会覆父节点中的数据。
使用@map对字段结果进行加工的查询如下:
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中硬编码实现则不够灵活,难以满足各种场景的控制需求。
query userInfoQuery($userId:Int){
consumer{
userInfo(userId: $userId){
userId
age
name
# 期望只有v2版本的客户端可以获取到该字段
# 客户端版本可以作为请求变量
email
}
}
}
解决方案
graphql-calculator定义了@includeBy指令判断是否请求指定字段,该指令可理解为graphql内置指令@include的拓展版本,但起判断逻辑为表达式、表达式参数为所有请求变量。
directive @includeBy(predicate: String!, dependencySources:[String!]) on FIELD
- predicate:判断是否解析该字段的表达式;
- dependencySources:表达式参数除了请求变量外,还可使用其他source。
使用@includeBy判断是否请求email的查询如下:
query queryMoreDetail_case01($userId:Int,$clientVersion:String){
consumer{
userInfo(
userId: $userId,
# 受限于graphql原生语法校验,变量必须被明确的作为参数使用
clientVersion: $clientVersion){
userId
age
name
# 只在v2版本的客户端中展示
email @includeBy(predicate: "clientVersion == 'v2'")
}
}
}
参考资料&交流反馈
作者介绍
笔者为开源组件graphql-java的活跃contributor、主要参与了15、16版本的指令能力升级和语法校验,graphql协议contributor。欢迎使用、关注graphql-calculator:github.com/dugenkui03/… 。