写点什么

如何优雅地扩展 GraphQL 系统能力

2021 年 9 月 21 日

如何优雅地扩展GraphQL系统能力

背景

为什么要扩展 GraphQL 系统能力


GraphQL 可将 API 表示的数据通过解析函数映射到 GraphQL 的 schema 中,为 API 提供一套类型化的完整描述,使得客户端能够根据所需准确地获取相应数据。

 

在真实业务场景中,除了获取基础数据外,往往还会有一些对数据进行加工转换和编排控制的需求,例如对数值字段取精或者转换成展示文案、对列表字段进行排序过滤去重、根据条件判断是否请求查询中的某些字段、将一个字段的解析结果作为另外一个字段的入参等。

 

原生的 GraphQL 查询为获取基础数据提供了便捷,但是计算能力不足导致其结果经常不能满足业务需求,数据往往需要加工转换、甚至经过多次编排查询,才能展示给用户。

GraphQL 的能力扩展机制


GraphQL 提供指令作为执行和校验能力的扩展机制。指令的定义包括指令名称、参数列表、可使用位置和是否可在同一位置重复使用等四个元素,用户可以使用指令描述自定义的执行行为或校验规则。

 

以内置指令@skip为例,该指令定义如下:

directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
复制代码

 

@skip主要是解决指定条件满足时跳过某些字段的获取解析。判断条件结果为指令参数if。该指令可使用的位置有查询字段、命名片段和内联片段,使用时将指令放置在要生效的元素后即可,示例如下:

query myQuery($someTest: Boolean!) {  experimentalField @skip(if: $someTest)}
复制代码

 

在实际业务场景中,是否跳过某些字段获取的条件大多情况需要根据请求变量进行计算判断。例如为 App 渲染数据时,低于指定版本的客户端不用请求某些字段,该条件判断无法通过请求变量只有客户端版本号的原生查询实现。

 

GraphQL 原生指令只有@skip@include@deprecated@specifiedBy ,说明见Type-System.Directives,提供的能力有限,不能满足业务计算所需。

GraphQL 系统能力扩展实践


本文以GraphQL Calculator为例,介绍对 GraphQL 系统能力进行扩展的实践。

 

开源代码托管地址:https://github.com/graphql-calculator/graphql-calculator

指令分类


指令使用位置分为两类:可执行位 ExecutableDirectiveLocation 和类型系统位 TypeSystemDirectiveLocation。

# ExecutableDirectiveLocationQUERY # 查询操作MUTATION # 更新操作SUBSCRIPTION # 订阅操作FIELD # 查询字段FRAGMENT_DEFINITION # 命名片段定义FRAGMENT_SPREAD # 命名片段INLINE_FRAGMENT # 内联片段VARIABLE_DEFINITION # 查询变量

# TypeSystemDirectiveLocationSCALAR # 标量OBJECT # 对象FIELD_DEFINITION # 字段定义ARGUMENT_DEFINITION # 参数定义INTERFACE # 接口UNION # 联合类型ENUM # 枚举ENUM_VALUE # 枚举值INPUT_OBJECT # 输入对象INPUT_FIELD_DEFINITION # 输入字段定义
复制代码

 

GraphQL 规范并不会限制指令只能定义在可执行位或者类型系统位,但是为了明确指令是用在查询上、还是对于类型系统生效,往往只将指令的生效位置限定在其中一种:

 

  • 对于可执行位指令,其作用往往跟业务场景相关。例如,每个查询所要跳过的字段都可能不同,因此@skip的生效位置为FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

  • 对于类型系统位指令,主要是对类型系统本身额外信息、执行行为的描述。 例如@deprecated说明了一个字段将要被废弃的原因,其定义位置为FIELD_DEFINITION | ENUM_VALUE

 

本文重点讲解查询指令的实现:根据不同的业务场景,对查询进行不同的计算。

定义指令


指令应该服务于特定类型的数据结构和通用的算法处理,而不是特定的业务场景,为特定的业务场景定义指令将使得指令系统变得臃肿、难以维护。GraphQL Calculator参考了常见的编程概念对指令进行定义:

  • 字段加工:通过表达式对结果字段进行加工转换;

  • 数组处理:对结果中的数组字段进行过滤、排序、去重;

  • 参数转换:对请求参数进行转换,包括加工、过滤、使用其他字段获取结果进行替换;

  • 数据编排:将指定字段的获取结果作为全局可获取的上下文,为其他字段或参数的加工转换提供可依赖的数据;

  • 控制流:@skip@include拓展版本,通过表达式判断是否请求注解的字段或片断。


指令的命名会直接影响指令的易用性。GraphQL Calculator指令的命名和语义参考了java.util.stream.Stream和 GraphQL 规范原生指令,易于理解和使用,例如@filter@sort@skipBy

执行引擎


GraphQL 的 Java 实现提供了Instrumentation机制,该机制可在查询的各个阶段获取到执行上下文,可对执行信息进行记录、修改。该机制的核心接口有InstrumentationInstrumentationContextInstrumentationState

 

  • Instrumentation

 

Instrumentation主要可获取指令及执行上下文信息,并对数据进行记录、修改。该接口部分方法及说明例举如下:

public interface Instrumentation {    // 创建执行上下文对象    default InstrumentationState createState() {        return null;    }

// 根据本次请求上下文和schema创建执行上下文对象 default InstrumentationState createState(InstrumentationCreateStateParameters parameters) { return createState(); } // 在解析查询dsl前调用 InstrumentationContext<Document> beginParse(InstrumentationExecutionParameters parameters);

// 在验证查询对象前调用 InstrumentationContext<List<ValidationError>> beginValidation(InstrumentationValidationParameters parameters);

// 修改DataFetcher行为 default DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters) { return dataFetcher; } // 在列表字段结束之后可进行的回调动作 default InstrumentationContext<ExecutionResult> beginFieldListComplete(InstrumentationFieldCompleteParameters parameters) { return noOp(); }

// 对最终的查询结果进行修改 default CompletableFuture<ExecutionResult> instrumentExecutionResult(ExecutionResult executionResult, InstrumentationExecutionParameters parameters) { return CompletableFuture.completedFuture(executionResult); }

......

}
复制代码

 

  • InstrumentationContext

 

InstrumentationContextInstrumentation 部分方法的返回结果,该对象包含两个回调方法,回调动作将在Instrumentation 的方法对应的执行阶段被调用。

public interface InstrumentationContext<T> {

void onDispatched(CompletableFuture<T> result); void onCompleted(T result, Throwable t);}
复制代码

 

  • InstrumentationState

 

InstrumentationState可保存查询产生的中间数据,经常在记录中间数据或在不同的执行线程间传递数据。为了保证该对象可被多个线程同时读写,其实现一般是线程安全的。

 

此外,指令的合法使用往往有些前置条件,例如过滤指令不可用在简单对象或基本类型字段上。GraphQL 的 Java 库提供了基于访问者模式实现的QueryVisitor ,可在其方法中获取到查询的字段、内联片段和片段定义的上下文信息,便于实现自定义的校验规则。

 

public interface QueryVisitor {    void visitField(QueryVisitorFieldEnvironment queryVisitorFieldEnvironment);        void visitInlineFragment(QueryVisitorInlineFragmentEnvironment queryVisitorInlineFragmentEnvironment);

void visitFragmentSpread(QueryVisitorFragmentSpreadEnvironment queryVisitorFragmentSpreadEnvironment);}
复制代码

实现示例


  • 定义指令

 

定义@filter对数组类型字段进行过滤,保留断言表达式predicate 结果为 true 的元素,predicate参数为所注解数组元素的字段名称与字段值的映射 Map。

directive @filter(predicate: String!) on FIELD
复制代码

 

  • 实现指令

 

GraphQL 执行引擎获取查询结果可分为 fetch 和 complete 两个阶段:fetch 阶段根据请求参数和上下文获取该节点的原始数据,并分析该节点类型,递归获取其子孙节点的原始数据;complete 阶段对应 fetch 的递归出栈,处于 complete 阶段的节点及其子孙节点已经全部完成解析和异常处理。

 

例如,对于获取用户详情列表的查询 queryUserList,对应的示意图如下。

 

query queryUserList($userId:[Int]){    # userInfoList类型为 [UserInfo]    userInfoList(userIds: $userId)    {        userId        age        firstName        lastName    }}
复制代码



Instrumentation#beginFieldListComplete中可获取到解析完成的列表字段结果,该方法可过滤不符合断言的元素。继承实现如下:

 

@Overridepublic InstrumentationContext<ExecutionResult> beginFieldListComplete(InstrumentationFieldCompleteParameters parameters) {    return new InstrumentationContext<ExecutionResult>() {        @Override        public void onDispatched(CompletableFuture<ExecutionResult> result) {            // ignored        }        @Override        public void onCompleted(ExecutionResult result, Throwable t) {            if (result == null || result.getData() == null || CollectionUtil.arraySize(result.getData()) == 0) {                return;            }            List<Directive> directives = parameters.getExecutionStepInfo().getField().getSingleField().getDirectives();            if (directives != null && !directives.isEmpty()) {                // 数据过滤                filterResultByDirective(result, directives);            }        }    };}
复制代码

 

由于在Instrumentation#beginFieldListComplete 节点只能获取到数组对象,但不能返回新的对象进行替换,因此需要保证在此获取到的数组类型是可进行过滤操作的,例如java.util.Collection的实现类,不可是不能改变大小的数组类型。可在Instrumentation#instrumentDataFetcher中对 fetch 阶段的结果进行转换,替换为可进行过滤操作的集合类型。

 

  • 校验指令使用

 

通过QueryVisitor实现自定义指令的校验规则,以校验@filter参数表达式不可为空为例,其实现核心代码如下:

public class BasicRule implements QueryVisitor {       @Override    public void visitField(QueryVisitorFieldEnvironment environment) {        // 只在进入该节点的时候进行校验处理,避免重复处理        if (environment.getTraverserContext().getPhase() != TraverserContext.Phase.ENTER) {            return;        }               for (Directive directive : environment.getField().getDirectives()) {            if (Objects.equals(directive.getName(), FILTER.getName())) {                GraphQLType innerType = GraphQLTypeUtil.unwrapNonNull(                        environment.getFieldDefinition().getType()                );                // 判断指令指令是否注解在列表类型字段上                if (!GraphQLTypeUtil.isList(innerType)) {                    String errorMsg = String.format("@filter must define on list type, instead {%s}.", fieldFullPath);                    addValidError(location, errorMsg);                    continue;                }}
复制代码


  • 使用指令

 

获取用户详情列表时,通过@filter过滤出年龄大于等于 18 的用户。

query filterUserByAge($userId:[Int]){    userInfoList(userIds: $userId)    @filter(predicate: "age>=18")    {        userId        age        firstName        lastName    }}
复制代码


参考资料:


 

作者介绍:


杜艮魁,GraphQL Calculator 作者,GraphQL Java 活跃 contributor。

2021 年 9 月 21 日 08:003514

评论

发布
暂无评论
发现更多内容

EMAS 移动 DevOps 解决方案 —— Mobile DevOps

应用研发平台EMAS

阿里云 DevOps 运维 开发 emas

5G矿山,工业真金,以及智能体矿井

脑极体

深入浅出Spark

大数志

大数据 spark 数据科学

五周 - 总结

水浴清风

《具有算法和程序的离散数学基础》PDF免费下载

计算机与AI

算法 离散数学

讲真,你知道Python咋来的吗?

华为云开发者社区

Java Python 编程语言 C语言 代码

架构师训练营 -week10-总结

大刘

极客大学架构师训练营

有了它,数据库也能空中加油,一边迁移一边跑起来

数据君

腾讯云数据库登陆印尼,金融行业显神威

数据君

tdsql

区块链数字版权应用落地开发,区块链版权溯源解决方案

13530558032

“通证经济”实质是生产关系的变革

CECBC区块链专委会

通证经济

我就不信2W字把源码拆的这么碎,你还不明白mybatis缓存

996小迁

Java 源码 架构 面试 mybatis

跨国区块链投资 花式“割韭菜”骗光你的钱

CECBC区块链专委会

区块链

我是如何在五年阿里面试官的连珠炮问下三面斩获Java岗offer,最后定级P6的呢?

Java~~~

学习工作即游戏:游戏化生存的现实物语

脑极体

精心整理MySQL基本使用(数据库的操作、数据类型、MySQL的常用命令)

ShenDu_Linux

c++ MySQL 程序员 数据类型

WSL2:我在原生的Win10玩转Linux系统

梁桂钊

白皮书丨关于工业互联网,你想知道的都在这儿

华为云开发者社区

工业互联网 华为云 白皮书 ICT 智能

从数据库巨人身上撕开一道口子

数据君

数字时代,如何跟上互联网医院的建设潮?

CECBC区块链专委会

数字化医疗

架构师训练营第 10 周学习总结

netspecial

极客大学架构师训练营

【得物技术】供应链库存幂等实战分享

得物技术

幂等 供应链 得物技术部 实战 得物

架构方法

raox

极客大学架构师训练营

区块链跨境溯源平台搭建,助力跨境电商防伪溯源

13530558032

清华大佬马士兵告诉你从阿里P5级一直学到P8架构师的成长路线+视频教程!

比伯

Java 编程 架构 面试 计算机

go-zero 如何扛住流量冲击(二)

万俊峰Kevin

Go microservice

Python进阶——什么是上下文管理器?

Kaito

Python

架构师训练营第一周UML图

Geek_xq

Week 1 学习总结

J

极客大学架构师训练营

一张图彻底理解Spring如何解决循环依赖!!

冰河

spring aop ioc 源码解析 循环依赖

tcp/ip协议栈——epoll的内部实现原理

Linux服务器开发

TCP/IP 后端开发 epoll 网络协议栈 服务器开发

开源中间件技术学习路线

开源中间件技术学习路线

如何优雅地扩展GraphQL系统能力-InfoQ