graphQL学习笔记

Posted 冰尘传说

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了graphQL学习笔记相关的知识,希望对你有一定的参考价值。

graphQL的学习笔记

学习参考资料来至graphQL中文网


类型/字段/查询

在服务端上定义类型,和类型的字段,对每个字段提供解析函数


// 定义类型User
type Query {
me: User
}

// 定义User的字段
type User {
id: ID
name: String
}

// 定义解析函数
function Query_me(request) {
return request.auth.user;
}

// 定义字段的解析函数
function User_id(user) {
return user.getId();
}

function User_name(user) {
return user.getName();
}

这样一个graphQL服务就能跑起来了,客户端这样查询


{
me {
name
}
}

返回的结果如下


{
"me" : {
"name" : "63isOK"
}
}

通过字段进行查询,返回的结果可能是字符串,也可能是对象,
如果是对象,则可以对这个对象的字段进行sub-selection,也叫次级选择.


参数

每一个字段和嵌套对象都能有自己的一组参数,
这样graphQL才能代替多次其他API调用.

参数可以指定标量scalar,让服务端做一次转换,eg:序列化.


// id 1000是参数; FOOT是标量
{
human(id: "1000") {
name
height(unit: FOOT) // 告诉服务器:身高按英尺返回,不要按厘米
}
}


别名

通过episode来为字段指定一个新的别名


{
empireHero: hero(episode: EMPIRE) {
name
}
jediHero: hero(episode: JEDI) {
name
}
}


片段

片段是可复用单元


// 引用片段,前面加...
{
leftComparison: hero(episode: EMPIRE) {
...comparisonFields
}
rightComparison: hero(episode: JEDI) {
...comparisonFields
}
}

// 定义可复用的片段
fragment comparisonFields on Character {
name
appearsIn
friends {
name
}
}

最后的查询结果是这样的


{
"data": {
"leftComparison": {
"name": "Luke Skywalker",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
],
"friends": [
{
"name": "Han Solo"
}
]
},
"rightComparison": {
"name": "R2-D2",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
],
"friends": [
{
"name": "Luke Skywalker"
}
]
}
}
}

片段中能包含变量


query HeroComparison($first: Int = 3) {
leftComparison: hero(episode: EMPIRE) {
...comparisonFields
}
rightComparison: hero(episode: JEDI) {
...comparisonFields
}
}

fragment comparisonFields on Character {
name
friendsConnection(first: $first) {
totalCount
}
}


操作名称

每个graphQL请求都可以有 操作类型/操作名称/具体操作,
上面看到的那些例子,都是具体操作.


query HeroNameAndFriends {
hero {
name
friends {
name
}
}
}

目前graphQL规定了3种操作类型:

  • query 查询

  • mutation 修改

  • subscription 订阅

如果一个文档中有多个操作,那么操作名是必须的.
建议所有操作都带上操作名,有助于调试和服务端记录日志.

这个操作名称可以理解为函数名,graphQL的请求名.


变量

graphQL会讲变量提取到查询之外,然后作为分离字典传进去,
这具体的过程我们不用太关系,那来看看变量的使用.

所有声明的变量必须是:标量/枚举/输入对象类型,
如果想要传递一个复杂对象到一个字段,必须知道服务器上匹配的类型.


// 变量定义用$开头,冒号后面是变量类型
$e: Episode

// 变量定义分可选和必选,变量类型后面带!的是必选,
$b: ByteDance

// 变量支持默认参数
$j: String = "jd"

使用变量之前要做3件事:

  • 用$a替换查询中的静态值

  • 声明$a为查询接收的变量之一

  • 用"变量名:值"的方式将变量丢到查询中

3件事做完的结果和下面类似:


// 多了一个#开头的说明
// 操作名后面的括号里,申请了此次查询支持的变量
// $episode 最终会替换成具体的值,会加入到变量字典中
# { "graphiql": true, "variables": { "episode": JEDI } }
query HeroNameAndFriends($episode: Episode) {
hero(episode: $episode) {
name
friends {
name
}
}
}


指令

变量可以构建动态查询(更加具体点就是可以动态构建查询语句),
但无法动态改变查询结构,指令就是用来动态改变查询结构的.


// 利用变量来动态控制结构
query Hero($episode: Episode, $withFriends: Boolean!) {
hero(episode: $episode) {
name
friends @include(if: $withFriends) {
name
}
}
}

graphQL规定的两个核心指令:

  • @include(if: Boolean) 仅在参数为 true 时,包含此字段

  • @skip(if: Boolean) 如果参数为 true,跳过此字段

指令可以附加在字段上,也可以附加包含片段的字段上.


变更

前面也说了,操作类型有3种:查询query,变更mutation,订阅subscription.
现在讨论的就是mutation.

发送变更后,可以请求其嵌套的字段,以便查询新状态.


// 变更请求
mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
createReview(episode: $ep, review: $review) {
stars
commentary
}
}

实际上的输入的参数:


{
"ep": "JEDI",
"review": {
"stars": 5,
"commentary": "This is a great movie!"
}
}

变更的对象是createReview,变更之后里面进行了查询.

一个变更请求可以包含多个字段,这样一个请求就能替换多个其他API(rest api).

另外,查询是并行执行,变更是线性执行.


内联片段

因为graphQl中存在接口和联合类型,
如果查询的字段返回的是接口或联合类型,就需要使用"内敛片段"来获取下层数据.


// hero对应的是Character类型
// $ep变量可以影响hero的具体类型
// name是常规类型,可以直接用次级选择
query HeroForEpisode($ep: Episode!) {
hero(episode: $ep) {
name

// 这个写法叫内联片段,只有hero的具体类型为Droid是,
// 才会返回次级选择 primaryFunction
... on Droid {
primaryFunction
}
... on Human {
height
}
}
}


元字段

当不知道服务端的类型时,可以丢一个__typename给服务端,
服务端会丢一个对象名称过来.


// search字段返回的是一个联合
// 通过__typename可以在客户端区分具体的类型
{
search(text: "an") {
__typename
... on Human {
name
}
... on Droid {
name
}
... on Starship {
name
}
}
}

接下来描述scheme


类型系统

查询语句就是选择对象的字段


// 就是选择root/hero的name字段和appearsIn字段
{
hero {
name
appearsIn
}
}

schema可以翻译为规划,概要,设计图等,
schema是给服务端用的,每当一个查询请求过来时,
服务端就会根据schema验证并执行.schema有对数据的确切描述,
有哪些字段,会返回哪些类型,类型下有哪些字段可用,等等.


类型语言

有了schema之后,服务端用什么语言实现,就解耦了,
可以是js,也是Go,也可以是C++,这都没关系,
有了graphQL schema language之后,就实现了编程语言无关了.


对象类型和字段

graphQL schema最基本的单位是对象类型


// 用graphQL schema language来描述对象类型
// Character是对象类型,拥有两个字段 name和appearsIn
// String是内置的标量类型之一. String!表示非空
type Character {
name: String!
appearsIn: [Episode!]!
}

标量类型就是无法在对其做次级选择.


schema中的参数

对象类型(schema中的),每个字段都可以有零个或多个参数


// 参数都是具名的,就是带名字的
// length字段有一个参数
type Starship {
id: ID!
name: String!
length(unit: LengthUnit = METER): Float
}

参数可以是可选或必选,如果是必选还可能有默认值.


schema中的查询和变更类型

在schema中,这是两个特殊类型


schema {
query: Query
mutation: Mutation
}

每个graphQL服务都有一个query类型,至于mutation类型是可能有一个.
这两个对象定义了graphQL查询请求的入口.


// 这是一个查询
query {
hero {
name
}
droid(id: "2000") {
name
}
}

// 对应的结果如下
{
"data": {
"hero": {
"name": "R2-D2"
},
"droid": {
"name": "C-3PO"
}
}
}

这表示在graphQL服务器上,需要一个Query类型,且有hero/droid两个字段


type Query {
hero(episode: Episode): Character
droid(id: ID!): Droid
}


标量类型

对象类型有自己的名字和字段,但标量类型是无法进行次级选择的,
因为他们是graphQL查询的叶子节点.


// 查询
// name和appearsIn就是标量类型
{
hero {
name
appearsIn
}
}

// 结果
{
"data": {
"hero": {
"name": "R2-D2",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
]
}
}
}

除了自己定义的标量类型,graphQL还有一组内置的标量类型:

  • Int, 表示有符号32位整型

  • Float, 有符号双精度浮点数

  • String, UTF-8字符串

  • Boolean, true/false

  • ID, 唯一标识符

定义标量类型非常简单


scalar Data


枚举类型

枚举enum是一种特殊的标量


// 定义枚举
enum Episode {
NEWHOPE
EMPIRE
JEDI
}


列表和非空

graphQL中只能定义对象类型/标量/枚举,
至于其他的扩展,都是通过添加类型修饰符来实现的


// 通过String加!,表示非空,一旦客户端传空过来,服务端就返回一个错误
// 通过List修饰,也就是[],表示这个字段会返回到这个类型的数组中
type Character {
name: String!
appearsIn: [Episode]!
}

数组和非空可以同时使用,[String!],表示数组元素不能是非空,
数组可以是空数组(0个元素),也可以数组本身是空.


接口

接口是抽象类型,接口定义中包含了某些字段,
对象类型必须包含这些字段,才算是实现了这个接口.和Go中的接口非常类似.


// 接口的定义
// 要实现接口,必须包含这些字段
interface Character {
id: ID!
name: String!
friends: [Character]
appearsIn: [Episode]!
}

接口可用在:返回一个对象或一组对象,或一组不同类型的对象时.


// 下面的Human和Droid都实现了Character
type Human implements Character {
id: ID!
name: String!
friends: [Character]
appearsIn: [Episode]!
starships: [Starship]
totalCredits: Int
}

type Droid implements Character {
id: ID!
name: String!
friends: [Character]
appearsIn: [Episode]!
primaryFunction: String
}

以下查询会出错


// 因为接口Character中并没有primaryFunction字段
query HeroForEpisode($ep: Episode!) {
hero(episode: $ep) {
name
primaryFunction
}
}

此时需要用内联片段解决


query HeroForEpisode($ep: Episode!) {
hero(episode: $ep) {
name
... on Droid {
primaryFunction
}
}
}


联合类型

联合和接口类似,只是没有指定必须要有共同字段


// 联合的定义
union SearchResult = Human | Droid | Starship

联合的成员是具体对象,不能用接口或联合来作为联合成员.


// 查询时需要用__typename 来获取具体对象类型
{
search(text: "an") {
__typename
... on Human {
name
height
}
... on Droid {
name
primaryFunction
}
... on Starship {
name
length
}
}
}

// 返回
{
"data": {
"search": [
{
"__typename": "Human",
"name": "Han Solo",
"height": 1.8
},
{
"__typename": "Human",
"name": "Leia Organa",
"height": 1.5
},
{
"__typename": "Starship",
"name": "TIE Advanced x1",
"length": 9.2
}
]
}
}

__typename也称为条件片段,会被解析位String,
在客户端,通过这个值可以区分不同的数据类型.

因为Human和Droid都实现了Character接口,所以查询还可以简化:


// 将共同字段提取出来
{
search(text: "an") {
__typename
... on Character {
name
}
... on Human {
height
}
... on Droid {
primaryFunction
}
... on Starship {
// 这个name不能省,是因为Starship没有实现Character接口
name
length
}
}
}


输入类型

在GraphQL schema language,输入类型和常规对象一模一样,
除了关键字.


// 常规类型是type,输入类型是input
input ReviewInput {
stars: Int!
commentary: String
}

输入类型常用于传递复杂对象,特别是变更中(mutation).


// 很多时候,变更就是创建新对象
mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
createReview(episode: $ep, review: $review) {
stars
commentary
}
}

输入类型上的字段也可以代指输入对象类型,
字段是不能拥有参数的.


验证

在查询请求创建时,有客户端或服务端来检查有效性,而不是运行时检查.

典型的无效查询:片段引用自身或创造了回环,这会导致结果无边界.
还有一种典型的无效查询:查询的字段不存在.
下面是其他规范,不遵循也会导致无效查询:

  • 字段的返回不是标量或枚举,就需要明确指定字段对应类型的子字段

  • 字段是标量,再对齐做次级选择是无效的

下面是通过片段来获取实现特有的字段


// hero对应的是Character类型,用片段才能取到实现的字段
{
hero {
name
...DroidFields
}
}

fragment DroidFields on Droid {
primaryFunction
}

具名片段非常适合多次使用的场合,单次使用还是用内联片段


// 内联是指写法,内联也是有名字的
{
hero {
name
... on Droid {
primaryFunction
}
}
}


执行

查询请求被验证后,就是服务器执行.


// 查询
{
hero {
name
}
}

// 结果
{
"data": {
"hero" : {
"name" : "63isOK"
}
}
}

每个字段都有一个resolver的函数,执行的时候,就是调用这个函数,
用来产生一个值,如果这个值是标量(包括字符串/数值/枚举等),
执行完成,如果是非标量,就继续解析该对象的字段.

所有的查询,最终以标量值结束.


Root和解析器

再服务端顶层,
graphQL api中,都会有一个类型表示入口点,Root类型或Query类型.


// 服务端的解析器写法
// 4个参数 obj是上层对象,如果human属于Root的字段,obj就不会被使用
// args 是客户端graphQL请求传入的参数,就如请求参数id
// context 上下文,每个解析器都需要这个字段
// info 保持了字段的特定信息和schema的详细信息
Query: {
human(obj, args, context, info) {
return context.db.loadHumanByID(args.id).then(
userData => new Human(userData)
)
}
}


异步解析器

还是上面的例子


Query: {
human(obj, args, context, info) {
return context.db.loadHumanByID(args.id).then(
userData => new Human(userData)
)
}
}

context.db 说明context提供了一个数据库访问对象,
context.db.loadHumanByID(args.id)写法就是通过id获取信息,
这个操作是异步的,因为她返回的是Promise对象,
和js中的异步操作很类似,最后从数据库获取的信息userData用于
初始化一个Human实例.

一旦Human实例构造之后,还会递归获取字段


// 这里的obj就是上面构造的Human实例
Human: {
name(obj, args, context, info) {
return obj.name
}
}

name字段的解析器,其实不是很重要了,因为很多graphQL库已经帮忙做了,
处理的方法是:如果开发者没有提供name字段的解析器,就直接去obj中取同名字段.


标量强制

graphQl的类型系统和具体编程语句的实现需要做转换


// 类型定义
type Human {
name: String
appearsIn: [Episode]
}

// 枚举定义
enum Episode {
NEWHOPE
EMPIRE
JEDI
}

// 返回结果
{
"data": {
"human": {
"name": "Han Solo",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
],
}
}
}

下面是appearsIn的解析


// obj.appearsIn的值是4,5,6,而不是NEWHOPE,EMPIRE,JEDI
Human: {
appearsIn(obj) {
return obj.appearsIn // returns [ 4, 5, 6 ]
}
}

这是因为在实现的编程实现服务端中,用的是4,5,6来表示appearsIn,
这是为了做到语言无关,在graphQL的类型系统中,会将数字转换成枚举值.

这就是强制标量的例子.


解析数组

先看查询和定义


// 类型定义
type Human {
name: String
starships: [Starship]
}

// 查询
{
human(id: 1002) {
name
starships {
name
}
}
}

// 结果
{
"data": {
"human": {
"name": "Han Solo",
"starships": [
{
"name": "Millenium Falcon"
},
{
"name": "Imperial shuttle"
}
]
}
}
}

下面是数组解析,或者是列表解析:


Human: {
starships(obj, args, context, info) {
return obj.starshipIDs.map(
id => context.db.loadStarshipByID(id).then(
shipData => new Starship(shipData)
)
)
}
}

context.db 数据库, loadStarshipByID(id).then 查询方法,返回的是Promises,
再加上上层的obj.starshipIDs.map,这就是返回一个Promises列表,
这些异步操作都是并发执行的.

当所有的字段都被解析了,会存成kv对,key是字段名,value是对应的结果,
通常,整体结果会以json格式返回到客户端.


内省

schema支持哪些查询,是通过内省系统知道的.

可以通过__schema来查询服务器哪些类型可用


{
// 告诉我有哪些类型,他们的name是什么
// 我们查询的是types
__schema {
types {
name
}
}
}

// 结果,有很多
{
"data": {
"__schema": {
"types": [
{
"name": "Query"
},
{
"name": "Episode"
},
{
"name": "Character"
},
{
"name": "ID"
},
{
"name": "String"
},
{
"name": "Int"
},
{
"name": "FriendsConnection"
},
{
"name": "FriendsEdge"
},
{
"name": "PageInfo"
},
{
"name": "Boolean"
},
{
"name": "Review"
},
{
"name": "SearchResult"
},
{
"name": "Human"
},
{
"name": "LengthUnit"
},
{
"name": "Float"
},
{
"name": "Starship"
},
{
"name": "Droid"
},
{
"name": "Mutation"
},
{
"name": "ReviewInput"
},
{
"name": "__Schema"
},
{
"name": "__Type"
},
{
"name": "__TypeKind"
},
{
"name": "__Field"
},
{
"name": "__InputValue"
},
{
"name": "__EnumValue"
},
{
"name": "__Directive"
},
{
"name": "__DirectiveLocation"
}
]
}
}
}

Query/Episode/Character/Droid/Mutation 是在类型系统中定义的;
String/ID/Int 是类型系统的内建标量;
__Schema/__TypeKind/__Field这种以__开头的是内省系统的一部分,
也是下面查询时会用到的.

我们也可以问:有哪些可用查询


// 查的是queryType
{
__schema {
queryType {
name
}
}
}

// 结果是
{
"data": {
"__schema": {
"queryType": {
"name": "Query"
}
}
}
}

也可以只查指定类型


// 查Droid类型的name
// 用的是 __type 不是__schema
// kind返回的是__TypeKind枚举类型,值包含接口类型或对象类型
{
__type(name: "Droid") {
name
kind
}
}

// 结果
{
"data": {
"__type": {
"name": "Droid",
"kind": "OBJECT"
}
}
}

查字段也非常有用


// fileds返回字段的name和type(type还包含类型名和kind)
{
__type(name: "Droid") {
name
fields {
name
type {
name
kind
}
}
}
}

// 返回结果
// 正好是5个字段
{
"data": {
"__type": {
"name": "Droid",
"fields": [
{
"name": "id",
"type": {
"name": null, // 没有名字是因为有!修饰
"kind": "NON_NULL", // 非空,对应!
"ofType": {
"name": "ID",
"kind": "SCALAR"
}
}
},
{
"name": "name",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "String",
"kind": "SCALAR"
}
}
},
{
"name": "friends",
"type": {
"name": null, // 没有名字是因为有[]修饰
"kind": "LIST",
"ofType": {
"name": "Character",
"kind": "INTERFACE"
}
}
},
{
"name": "appearsIn",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": null,
"kind": "LIST"
}
}
},
{
"name": "primaryFunction",
"type": {
"name": "String",
"kind": "SCALAR",
"ofType": null
}
}
]
}
}
}

// 对比一下Droid的定义
type Droid implements Character {
id: ID!
name: String!
friends: [Character]
appearsIn: [Episode]!
primaryFunction: String
}

还有就是内省系统可以获取文档,基于此可以做出文档浏览器.
graphQL官网和github v4 api都有文档浏览器.
这样会提供很多ide体验,就像msdn对于vs来说.

内省系统还包含很多.


最佳实现

graphQl并没有定义和api相关的网络处理/授权/分页等常用功能,
graphQl推荐使用工程上通用的方法来实现这些功能.


http方面

graphQL通常通过单入口来提供http服务


json + gzip压缩

json文本+gzip压缩,表示更好的网络性能,

开启gzip压缩需要做两件事:

  • 服务端开启支持gzip

  • 客户端的http请求头上添加 Accept-Encoding: gzip


版本控制

api之所以有版本的概念,是因为api的变更是破坏性的,
graphQL可以通过添加新类型和新字段来避免破坏性变更.
所以,graphQL不需要版本控制


db/net服务的异常是经常出现,graphQl中每个字段都默认为null,
所以在设计时,需要考虑所有可能导致错误的清空.

non-null字段永远不会返回null,如果发生了错误,
她的父级字段会被置为null.


分页

长列表返回时,可以使用分页,first/after参数可以用于指定列表的区域.
Connections是分页的最佳实现,可以参考.

first是取前N个,after的参数是一个特定的id,表示从xxx之后取前5个.


服务端的批处理和缓存

在服务端,可以将多个数据请求通过DataLoader工具打成一个包,
丢给底层数据库或微服务.


graph的思考

以下是很多人实践后的总结

  • graphQL中,一切皆是图,符合人自然的心智模型

  • 命名是构建接口最困难且最重要的部分

  • 在业务逻辑层检验执行业务域规则的正确性,且是唯一来源

  • graphQL的schema是表达"是什么",而不是"怎么做"

  • 一次一步,更加频繁低验证和获取反馈


通过http提供服务

因为http无处不在,是最常见的c/s协议,以下是graphQL使用http的准则:

  • 网络请求管道,graphQL应该放在所有身份验证中间件之后

  • graphQL服务器在单URL/入口上运行,常是/graphql

  • graphQL服务器要支持http的get/post方法

    • 查询的变量可以丢在variables中,也可以用operationName指定操作名

    • http get: http://myapi/graphql?query={me{name}}

    • http post: 要指定content type为application/json

http post的请求体如下:


// variables是可选的
// operationName也是可选,只有当查询包含多个操作时才需要
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}

响应应该在http的响应体中,json格式


// 只有发生错误才包含data字段
// 如果没有错误就不应该出现errors字段
{
"data": { ... },
"errors": [ ... ]
}

另外, graphiQL在测试和开发阶段非常有用,在生产环境应该禁用.


授权

不推荐在graphQL中放置授权逻辑.


分页

基于游标的分页时最强大的分页.


// 请求3-4
friends(first:2 offset:2)

// 请求上一次获取到的最后一个朋友之后的两个结果
friends(first:2 after:$friendId)

// 游标
riends(first:2 after:$friendCursor)

游标建议用base64编码.


// 引入edge(边)的概念
// edge中既有游标cursor,也有底层节点node
{
hero {
name
friends(first:2) {
edges {
node {
name
}
cursor
}
}
}
}

现在可以用游标进行分页,但不知道何时达到结尾,
所以需要总数信息和下一页是否存在的信息


{
hero {
name
friends(first:2) {
totalCount // 总计数
edges { // 边
node { // 底层节点
name
}
cursor // 游标
}
pageInfo { // 页信息
endCursor // end游标
hasNextPage // 是否有下一页
}
}
}
}

pageInfo中也包含startCursor.


全局对象识别

服务端为对象做标识,就是ID


// 有了id,就可以直接访问对象,简单点就称为对象节点
{
node(id: "4") {
id
... on User {
name
}
}
}

查询对象的node字段,对应着Node类型


// Node是接口类型,拥有非空id
interface Node {
id: ID!
}

上面的User通过如下定义实现Node接口


type User implements Node {
id: ID!
name: String!
}

以下是使用节点的准则:

  • 每个服务端都必须提供Node接口

    • Node接口只有一个非空的ID

对Node的内省要符合以下结果,当然还包括其他内省


// 内省查询字段
{
__type(name: "Node") {
name
kind
fields {
name
type {
kind
ofType {
name
kind
}
}
}
}
}

// 结果
{
"__type": {
"name": "Node",
"kind": "INTERFACE",
"fields": [
{
"name": "id",
"type": {
"kind": "NON_NULL",
"ofType": {
"name": "ID",
"kind": "SCALAR"
}
}
}
]
}
}

服务端必须提供要给根字段node,返回类型是Node,
如果查询中,有两个对象使用同样的ID,则这两个对象必须相等,
这是处于稳定性考虑.


缓存

通过对象标识来让客户端构建丰富的缓存.
后端如果没有为每个对象构建uuid(ID),那么graphQL层就要构建ID,
这样客户端使用http时,可以利用http缓存,提高效率.


以上是关于graphQL学习笔记的主要内容,如果未能解决你的问题,请参考以下文章

graphQL学习笔记

在 Java 的 GraphQL 查询中添加片段

GraphQL 片段的片段

盖茨比没有找到graphql片段

在graphql中嵌套片段

nestjs 是不是支持 graphql 片段?