Spring Boot整合GraphQL

核心依赖:

1
2
3
4
5
6
<!-- graphql starter -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>graphql-spring-boot-starter</artifactId>
<version>11.1.0</version>
</dependency>

创建工程

chunyu-graphql模块中创建子模块kickstart,引入Spring BootGraphQL等依赖,pom.xml文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>chunyu-graphql</artifactId>
<groupId>com.sunchaser.chunyu</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>kickstart</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<springboot.version>2.6.4</springboot.version>
<graphql.starter.version>11.1.0</graphql.starter.version>
</properties>

<dependencyManagement>
<dependencies>
<!-- spring boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${springboot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- graphql starter -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>graphql-spring-boot-starter</artifactId>
<version>${graphql.starter.version}</version>
</dependency>
<!-- graphql 单元测试 -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>graphql-spring-boot-starter-test</artifactId>
<version>${graphql.starter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

创建Spring Boot启动类ChunYuKickStartGraphQLApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.sunchaser.chunyu.graphql.kickstart;

import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

/**
* kickstart spring boot 启动类
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@SpringBootApplication
public class ChunYuKickStartGraphQLApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ChunYuKickStartGraphQLApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}

第一个GraphQL查询

默认GraphQLSchema文件都存放在classpath类路径下,文件后缀名为.graphqls

1
2
3
graphql:
tools:
schema-location-pattern: "**/*.graphqls" # graphql schema location

下面以查询用户User为例创建第一个GraphQL Schema

按照约定,我们在resources目录下创建graphql/query.graphqls文件,它被称为GraphQL查询文件,之后所有的查询都将写在该文件中。

编写Schema

第一个查询Schema写法如下:

1
2
3
type Query {
user(id: ID): User
}

语法解读:首先type: Query {}定义了该模式的类型是Query查询,然后user(id: ID): User表示一个方法,接收一个类型为IDid参数,返回一个User对象。

由于User暂不存在,所以IDE暂时报错,我们创建graphql/user/user.graphqls文件,编写内容如下:

1
2
3
4
5
6
7
type User {
id: ID
name: String
sex: String
age: Int
address: String
}

创建自定义的type类型User,包含五个字段。

编写Java Bean

每一个自定义type都需要一个Java类与之对应。创建User.java类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.sunchaser.chunyu.graphql.kickstart.model;

import lombok.Builder;
import lombok.Value;

import java.util.UUID;

/**
* User.java -> user.graphqls
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@Value
@Builder
public class User {
UUID id;
String name;
String sex;
Integer age;
String address;
}

lombok@Value注解将类和属性声明为final并提供属性的getter方法;@Builder注解提供建造者模式

编写查询解析器

每个GraphQL查询都需要有一个与之匹配的查询解析器GraphQLQueryResolver

创建UserQueryResolver.java类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.sunchaser.chunyu.graphql.kickstart.resolver.user.query;

import com.sunchaser.chunyu.graphql.kickstart.model.User;
import graphql.kickstart.tools.GraphQLQueryResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.UUID;

/**
* graphql user query resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@Component
@Slf4j
public class UserQueryResolver implements GraphQLQueryResolver {

public User user(String id) {
log.info("Retrieving user id: {}", id);
return User.builder()
.id(UUID.randomUUID())
.name("SunChaser")
.sex("男")
.age(18)
.address("HangZhou China")
.build();
}
}

方法user匹配query.graphqls文件中的user

这里方法user的入参id可以指定为UUID类型,也可用String类型来兼容。

至此,我们的第一个GraphQL查询代码就编写完成。

GraphQL IDE

GraphQL服务端编写完成后我们需要用客户端进行调用,最简单的方式我们可以用Postman进行调用,当然GraphQL社区也提供了一些高效的IDE工具供我们选择,例如GraphiQLPlaygroundAltair等。

一个典型的GraphQL查询写法如下:

1
2
3
4
5
6
7
8
{
user(id: "be4af231-fcd3-4ed4-bcf3-505247197dfa") {
id
name
sex
age
}
}

语法解读:首先用一个花括号{}包裹整个语句,然后user对应着query.graphqls中的user,入参id是一个UUID,最后需要查询idnamesexage这四个字段。当然我们可根据实际情况增加或减少需要查询的字段。

Postman

Postman中的查询示例如下:

image-20220509195320767

Playground

pom.xml中添加依赖:

1
2
3
4
5
6
7
<!-- graphql的一个网页IDE -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>playground-spring-boot-starter</artifactId>
<version>${graphql.starter.version}</version>
<scope>runtime</scope>
</dependency>

默认访问路径为http://localhost:8080/playground。查询示例如下:

image-20220510115358294

可用的配置项有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
graphql:
playground:
mapping: /playground
endpoint: /graphql
subscriptionEndpoint: /subscriptions
staticPath.base: my-playground-resources-folder
enabled: true
pageTitle: Playground
cdn:
enabled: false
version: latest
settings:
editor.cursorShape: line
editor.fontFamily: "'Source Code Pro', 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace"
editor.fontSize: 14
editor.reuseHeaders: true
editor.theme: dark
general.betaUpdates: false
prettier.printWidth: 80
prettier.tabWidth: 2
prettier.useTabs: false
request.credentials: omit
schema.polling.enable: true
schema.polling.endpointFilter: "*localhost*"
schema.polling.interval: 2000
schema.disableComments: true
tracing.hideTracingResponse: true
headers:
headerFor: AllTabs
tabs:
- name: Example Tab
query: classpath:exampleQuery.graphql
headers:
SomeHeader: Some value
variables: classpath:variables.json
responses:
- classpath:exampleResponse1.json
- classpath:exampleResponse2.json

初始化Tab

Playground可以在启动时初始化Tab,用来提供一些查询示例。

1
2
3
4
5
6
7
8
graphql:
playground:
headers:
Authorization: SunChaser
tabs:
- name: User sample query
query: classpath:playground/user.graphql
variables: classpath:playground/user-variables.json

playground/user.graphql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Write your query or mutation here
query GET_USER($id: ID!) {
user(id: $id) {
id
name
age
address {
province
city
}
createdOn
createdAt
}
}

mutation CREATE_USER($name: String!) {
createUser(input: {
name: $name
age: 10
sex: MAN
address: {
province: "Zhe Jiang"
city: "Hang Zhou"
}
}) {
id
name
}
}

playground/user-variables.json:

1
2
3
4
{
"id": "c508301f-ba8c-4907-a5da-990b6612e560",
"name": "SunChaser"
}

GraphiQL

pom.xml中添加依赖:

1
2
3
4
5
6
<!-- graphiql 和playground类似 -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>graphiql-spring-boot-starter</artifactId>
<version>${graphql.starter.version}</version>
</dependency>

默认访问路径为http://localhost:8080/graphiql。查询示例如下:

image-20220510141439077

可用的配置项有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
graphql:
graphiql:
mapping: /graphiql
endpoint:
graphql: /graphql
subscriptions: /subscriptions
subscriptions:
timeout: 30
reconnect: false
basePath: /
enabled: true
pageTitle: GraphiQL
cdn:
enabled: false
version: latest
props:
resources:
query: query.graphql
defaultQuery: defaultQuery.graphql
variables: variables.json
variables:
editorTheme: "solarized light"
headers:
Authorization: "Bearer <your-token>"

Altair

pom.xml中添加依赖:

1
2
3
4
5
6
<!-- altair -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>altair-spring-boot-starter</artifactId>
<version>${graphql.starter.version}</version>
</dependency>

默认访问路径为http://localhost:8080/altair。查询示例如下:

image-20220510142821995

可用的配置项有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graphql:
altair:
enabled: true
mapping: /altair
subscriptions:
timeout: 30
reconnect: false
static:
base-path: /
page-title: Altair
cdn:
enabled: false
version: 4.0.2
options:
endpoint-url: /graphql
subscriptions-endpoint: /subscriptions
initial-settings:
theme: dracula
initial-headers:
Authorization: "Bearer <your-token>"
resources:
initial-query: defaultQuery.graphql
initial-variables: variables.graphql
initial-pre-request-script: pre-request.graphql
initial-post-request-script: post-request.graphql

Schema最佳实践

下面是一些Schema设计的最佳实践。

面向对象

尽量采用面向对象化的Schema设计。例如User中的address,它可以拆分为省、市、区及详细地址,所以我们最好将它设计为一个单独的Schema,这样会更面向对象。创建graphql/user/address.graphqls文件,内容如下:

1
2
3
4
5
6
type Address {
province: String
city: String
area: String
detailAddress: String
}

然后将user.graphqls中的address字段类型修改为Address,同时JavaUser中的address字段也要同步修改为Address类,创建Address类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.sunchaser.chunyu.graphql.kickstart.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Address.java -> address.graphqls
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/10
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String province;
private String city;
private String area;
private String detailAddress;
}

枚举

对于一些取值范围有限的字段,优先使用枚举类型Schema。例如User中的sex,它仅有“男”和“女”两个取值,创建graphql/user/sex.graphqls文件,内容如下:

1
2
3
4
enum Sex {
MAN
WOMAN
}

然后将user.graphqls中的sex字段类型修改为Sex,同时JavaUser中的sex字段也要同步修改为SexEnum枚举,创建SexEnum枚举类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.sunchaser.chunyu.graphql.kickstart.model;

/**
* SexEnum.java -> sex.graphqls
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/10
*/
public enum SexEnum {
MAN,
WOMAN,
;
}

注释

写注释是一个良好的素养。.graphql文件中的注释以#开头,例如:

1
2
3
4
5
# All available queries on this graphql server
type Query {
# 根据ID查询用户信息
user(id: ID): User
}

校验

Schema中的方法的入参出参等可以进行一些基本规则校验。

非空

方法的入参出参、自定义类型中的字段可以指定为非空(不能为null)。类型后面加英文叹号!,例如:

1
2
3
4
5
# All available queries on this graphql server
type Query {
# 根据ID查询用户信息
user(id: ID!): User!
}
1
2
3
4
5
6
7
type User {
id: ID!
name: String!
sex: Sex
age: Int
address: Address
}

JSR303校验

pom.xml中添加依赖:

1
2
3
4
5
<!-- JSR303 校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

可以和Spring MVC一样用JSR303规范中的注解来校验Bean。使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.sunchaser.chunyu.graphql.kickstart.resolver.user.query;

import com.sunchaser.chunyu.graphql.kickstart.model.Address;
import com.sunchaser.chunyu.graphql.kickstart.model.SexEnum;
import com.sunchaser.chunyu.graphql.kickstart.model.User;
import graphql.kickstart.tools.GraphQLQueryResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import javax.validation.constraints.NotBlank;
import java.util.UUID;

/**
* graphql user resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@Component
@Slf4j
@Validated
public class UserQueryResolver implements GraphQLQueryResolver {

public User user(@NotBlank String id) {
log.info("Retrieving user id: {}", id);
return User.builder()
.id(UUID.randomUUID())
.name("SunChaser")
.sex(SexEnum.MAN)
.age(18)
.address(Address.builder()
.province("ZheJiang")
.city("HangZhou")
.area("BinJiang")
.detailAddress("SunChaser")
.build()
)
.build();
}
}

最大查询深度

某些情况下Schema可能会出现类型嵌套的情况。以user.graphqls为例,假设一个User有一个“儿子”,即:

1
2
3
4
5
6
7
8
type User {
id: ID!
name: String!
sex: Sex
age: Int
address: Address
son: User
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.sunchaser.chunyu.graphql.kickstart.model;

import lombok.Builder;
import lombok.Value;

import java.util.UUID;

/**
* User.java -> user.graphqls
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@Value
@Builder
public class User {
UUID id;
String name;
SexEnum sex;
Integer age;
Address address;
User son;
}

这时查询就会有一个查询深度的概念。例如以下查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
user(id: "be4af231-fcd3-4ed4-bcf3-505247197dfa") {
id
name
son {
id
name
son {
id
name
son {
id
name
son {
id
name
son {
id
name
# ...无限嵌套
}
}
}
}
}
}
}

正所谓”子子孙孙无穷匮也“,如果服务端不加以限制,内存迟早溢出。

我们可以通过graphql.servlet.max-query-depth配置项设置最大查询深度。例如:

1
2
3
graphql:
servlet:
max-query-depth: 13

建议设置为13或以上。因为一些GraphQL IDE在进行心跳探活时的查询深度会达到13,比如playground

服务端最佳实践

子解析器

实际业务中我们的数据可能来源于不同的下游微服务。以User为例,姓名性别等基本信息来源于用户微服务,而地址信息来源于地址微服务。这时我们就可以将地址信息的查询放在一个单独的子解析器中。

创建AddressResolver.java类代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.sunchaser.chunyu.graphql.kickstart.resolver.user;

import com.sunchaser.chunyu.graphql.kickstart.model.Address;
import com.sunchaser.chunyu.graphql.kickstart.model.User;
import graphql.kickstart.tools.GraphQLResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* graphql address resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/10
*/
@Component
@Slf4j
public class AddressResolver implements GraphQLResolver<User> {

public Address address(User user) {
log.info("Retrieving address data for user id: {}", user.getId());
return Address.builder()
.province("ZheJiang")
.city("HangZhou")
.area("BinJiang")
.detailAddress("SunChaser")
.build();
}
}

AddressResolver类实现了graphql.kickstart.tools.GraphQLResolver接口,泛型声明为User,方法名address对应着user.graphqls中的address字段名,方法入参User会由kickstart框架在运行时自动注入,方法的返回值是一个Address对象。

于是,我们就可以将UserQueryResolver中查询address的部分移动到AddressResolverAddressResolver称为一个子解析器。

客户端查询示例如下:

1
2
3
4
5
6
7
8
9
10
{
user(id: "be4af231-fcd3-4ed4-bcf3-505247197dfa") {
id
name
address {
province
city
}
}
}

全局异常处理

GraphQL查询解析器抛出异常时,kickstart框架默认的全局异常处理器DefaultGraphQLErrorHandler会返回固定的异常信息Internal Server Error(s) while executing query。但实际业务开发中我们更希望能返回自定义的异常信息,所以需要自定义一个全局异常处理器。kickstart框架支持Spring MVC形式的全局异常处理。

开启全局异常处理:

1
2
3
graphql:
servlet:
exception-handlers-enabled: true

创建全局异常处理器GraphQLExceptionHandler,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.sunchaser.sparrow.javaee.graphql.exceptions;

import graphql.GraphQLException;
import graphql.kickstart.spring.error.ThrowableGraphQLError;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.validation.ConstraintViolationException;

/**
* 全局异常处理器
*
* @author sunchaser admin@lilu.org.cn
* @see graphql.kickstart.execution.error.DefaultGraphQLErrorHandler
* @since JDK8 2022/5/6
*/
@Component
public class GraphQLExceptionHandler {

@ExceptionHandler({GraphQLException.class, ConstraintViolationException.class})
public ThrowableGraphQLError handle(Exception e) {
return new ThrowableGraphQLError(e);
}

@ExceptionHandler(RuntimeException.class)
public ThrowableGraphQLError handle(RuntimeException e) {
return new ThrowableGraphQLError(e, "Internal Server Error");
}
}

使用Spring MVC中的@ExceptionHandler注解来处理异常,将异常包装为ThrowableGraphQLError对象,对于GraphQLExceptionConstraintViolationException异常来说我们返回原异常中携带的错误信息,除此之外的其它RuntimeException异常我们用固定字符串Internal Server Error来防止异常信息外泄。

DataFetcherResult包装返回结果

DataFetcherResult包含解析器正常返回的数据data和错误列表errors。使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.sunchaser.chunyu.graphql.kickstart.resolver.user;

import com.sunchaser.chunyu.graphql.kickstart.model.Address;
import com.sunchaser.chunyu.graphql.kickstart.model.User;
import graphql.execution.DataFetcherResult;
import graphql.kickstart.execution.error.GenericGraphQLError;
import graphql.kickstart.tools.GraphQLResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* graphql address resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/10
*/
@Component
@Slf4j
public class AddressResolver implements GraphQLResolver<User> {

public DataFetcherResult<Address> address(User user) {
log.info("Retrieving address data for user id: {}", user.getId());

// throw new GraphQLException("SQL Error");
// throw new RuntimeException("SQL Error");

return DataFetcherResult.<Address>newResult()
.data(Address.builder()
.province("ZheJiang")
.city("HangZhou")
.area("BinJiang")
.detailAddress("SunChaser")
.build())
.error(new GenericGraphQLError("get address error"))
.build();
}
}

异步

默认情况下,每个解析器都是同步执行的。为了提高效率,我们可以进行异步处理,让解析器返回一个CompletableFuture对象。代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.sunchaser.chunyu.graphql.kickstart.resolver.user;

import com.sunchaser.chunyu.graphql.kickstart.model.Address;
import com.sunchaser.chunyu.graphql.kickstart.model.User;
import graphql.execution.DataFetcherResult;
import graphql.kickstart.execution.error.GenericGraphQLError;
import graphql.kickstart.tools.GraphQLResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* graphql address resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/10
*/
@Component
@Slf4j
public class AddressResolver implements GraphQLResolver<User> {

private final ExecutorService EXECUTOR = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

public CompletableFuture<DataFetcherResult<Address>> address(User user) {
return CompletableFuture.supplyAsync(
() -> {
log.info("Retrieving address data for user id: {}", user.getId());
return DataFetcherResult.<Address>newResult()
.data(Address.builder()
.province("ZheJiang")
.city("HangZhou")
.area("BinJiang")
.detailAddress("SunChaser")
.build())
.error(new GenericGraphQLError("get address error"))
.build();
},
EXECUTOR
);
}
}

Mutation

除了查询,GraphQL还支持更新服务端的数据,包括新增、修改和删除,这统一称为突变Mutation

resources目录下创建graphql/mutation.graphqls文件,之后所有的Mutation都将写在该文件中。下面以创建User为例创建一个Mutation

编写Schema

graphql/mutation.graphqls代码如下:

1
2
3
4
5
# All mutations available in graphql
type Mutation {
# Create a user
createUser(input: CreateUserInput!): User!
}

接收一个非空输入参数CreateUserInput,返回一个非空User对象。

创建graphql/user/input/createUserInput.graphqls文件,编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
input CreateUserInput {
name: String!
sex: Sex
age: Int
address: AddressInput!
}

input AddressInput {
province: String
city: String
area: String
detailAddress: String
}

由于CreateUserInput的类型是input,它包含的字段不能是type类型,所以address.graphqls无法被复用,这里声明了一个input AddressInput,字段完全一致。

编写Java Bean

创建CreateUserInput类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.sunchaser.chunyu.graphql.kickstart.model.input;

import com.sunchaser.chunyu.graphql.kickstart.model.Address;
import com.sunchaser.chunyu.graphql.kickstart.model.SexEnum;
import lombok.Data;

/**
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/11
*/
@Data
public class CreateUserInput {
private String name;
private SexEnum sex;
private Integer age;
private Address address;
}

这里的Address.java类可以进行复用,注意需要让其具有无参构造函数。

编写Mutation解析器

Mutation对应的解析器是GraphQLMutationResolver

创建UserMutation类,实现GraphQLMutationResolver接口,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.sunchaser.chunyu.graphql.kickstart.resolver.user.mutation;

import com.sunchaser.chunyu.graphql.kickstart.model.User;
import com.sunchaser.chunyu.graphql.kickstart.model.input.CreateUserInput;
import graphql.kickstart.tools.GraphQLMutationResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import javax.validation.Valid;
import java.util.UUID;

/**
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/11
*/
@Component
@Slf4j
@Validated
public class UserMutation implements GraphQLMutationResolver {

public User createUser(@Valid CreateUserInput input) {
log.info("Creating user for {}", input.getName());

return User.builder()
.id(UUID.randomUUID())
.name(input.getName())
.sex(input.getSex())
.age(input.getAge())
.address(input.getAddress())
.build();
}
}

方法名createUser匹配mutation.graphqls中的createUser,入参为CreateUserInput,这里也可以和Spring MVC一样使用JSR303注解校验。

客户端调用

客户端中可以指定Schema的类型并取名以便同时存在多个Schema。以Playground为例,使用示例如下:

image-20220511211036181

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Write your query or mutation here
query GET_USER {
user(id: "be4af231-fcd3-4ed4-bcf3-505247197dfa") {
id
name
address {
province
}
}
}
mutation CREATE_USER {
createUser(input: {
name: "SunChaser"
age: 10
sex: MAN
address: {
province: "Zhe Jiang"
city: "Hang Zhou"
}
}) {
id
name
}
}

文件上传

graphql/mutation.graphqls中添加文件上传Schema如下:

1
2
# Upload a file
uploadFile: ID!

创建文件上传解析器UploadFileMutation类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.sunchaser.chunyu.graphql.kickstart.resolver.user.mutation;

import graphql.kickstart.servlet.context.DefaultGraphQLServletContext;
import graphql.kickstart.tools.GraphQLMutationResolver;
import graphql.schema.DataFetchingEnvironment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.http.Part;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;

/**
* graphql upload file mutation resolver
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/12
*/
@Component
@Slf4j
public class UploadFileMutation implements GraphQLMutationResolver {

public String uploadFile(DataFetchingEnvironment environment) {
log.info("Uploading file");

// 获取graphql Servlet上下文
DefaultGraphQLServletContext context = environment.getContext();

// 获取文件对象javax.servlet.http.Part
List<Part> fileParts = context.getFileParts();

fileParts.forEach(part -> {
// part.getInputStream();
log.info("uploading: {}, size: {}", part.getSubmittedFileName(), part.getSize());
});

return UUID.randomUUID().toString();
}
}

Postman调用示例如下:

image-20220512145749326

DataFetchingEnvironment

Environment环境,能获取很多与GraphQL请求相关的信息,可将其指定为Resolver解析器方法的最后一个参数,kickstart框架会自动注入。使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public User createUser(@Valid CreateUserInput input, DataFetchingEnvironment environment) {
log.info("Creating user. input: {}", input);

// 请求中包含的需要查询的字段集合
DataFetchingFieldSelectionSet selectionSet = environment.getSelectionSet();

// 获取所有查询字段
List<String> fieldNames = selectionSet.getFields()
.stream()
.map(SelectedField::getName)
.collect(Collectors.toList());

// 查询字段中是否包含id
boolean containsId = selectionSet.contains("id");

// 查询字段中是否同时包含id和name
boolean containsAllOfIdAndName = selectionSet.containsAllOf("id", "name");

// 查询字段中是否包含id或name(任意一个)
boolean containsAnyOfIdAndName = selectionSet.containsAnyOf("id", "name");

// 上下文
DefaultGraphQLServletContext context = environment.getContext();

// Servlet API
HttpServletRequest request = context.getHttpServletRequest();
HttpServletResponse response = context.getHttpServletResponse();

return User.builder()
.id(UUID.randomUUID())
.name(input.getName())
.sex(input.getSex())
.age(input.getAge())
.address(input.getAddress())
.build();
}

Scalar

GraphQL的类型系统中,查询的叶子节点称为Scalar标量。

GraphQL规范中只定义了五种标量类型:

  • String:字符串。
  • Boolean:布尔值。
  • Int:带符号的32位整数。
  • Float:带符号的双精度浮点数。
  • ID:唯一ID,序列化方式与字符串相同。

graphql-java类库在规范的基础上扩展了以下六种Scalar标量类型:

  • Longjava.lang.Long
  • Shortjava.lang.Short
  • Bytejava.lang.Byte
  • BigDecimaljava.math.BigDecimal
  • BigIntegerjava.math.BigInteger
  • Charjava.lang.Character

可在graphql.Scalars类中查看所有系统标量类型的实例。

扩展Scalar类型

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-scalars</artifactId>
<version>${graphql.extended.scalars.version}</version>
<exclusions>
<exclusion>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java</artifactId>
</exclusion>
</exclusions>
</dependency>

Spring中注入需要使用的扩展Scalar类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.sunchaser.chunyu.graphql.kickstart.config;

import graphql.scalars.ExtendedScalars;
import graphql.schema.GraphQLScalarType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Extend Scalar Configuration
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/16
*/
@Configuration
public class ScalarConfig {

@Bean
public GraphQLScalarType nonNegativeInt() {
return ExtendedScalars.NonNegativeInt;
}

@Bean
public GraphQLScalarType date() {
return ExtendedScalars.Date;
}

@Bean
public GraphQLScalarType dateTime() {
return ExtendedScalars.DateTime;
}
}

可在graphql.scalars.ExtendedScalars类中查看该库的所有扩展Scalar类型。

使用示例:

graphql/query.graphqls

1
2
3
4
5
6
7
8
9
10
# @see graphql.scalars.ExtendedScalars
scalar NonNegativeInt
scalar Date
scalar DateTime

# All available queries on this graphql server
type Query {
# 根据ID查询用户信息
user(id: ID!): User!
}

graphql/user.graphqls

1
2
3
4
5
6
7
8
9
10
type User {
id: ID!
name: String!
sex: Sex
age: NonNegativeInt
address: Address
son: User
createdOn: Date
createdAt: DateTime
}

User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.sunchaser.chunyu.graphql.kickstart.model;

import lombok.Builder;
import lombok.Value;

import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.UUID;

/**
* User.java -> user.graphqls
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/9
*/
@Value
@Builder
public class User {
UUID id;
String name;
SexEnum sex;
Integer age;
Address address;
User son;
LocalDate createdOn;
ZonedDateTime createdAt;
}

Date对应的Java类型是LocalDateDateTime对应的Java类型是ZonedDateTime

graphql-java-extended-scalars包里面的扩展ScalarJava8LocalDateTime

自定义Scalar类型

Java8LocalDateTime为例。

自定义Scalar示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package com.sunchaser.chunyu.graphql.kickstart.scalars.datetime;

import graphql.language.StringValue;
import graphql.schema.*;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.Function;

import static graphql.scalars.util.Kit.typeName;

/**
* LocalDateTime Scalar
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/18
*/
public class LocalDateTimeScalar {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static final GraphQLScalarType INSTANCE;

private LocalDateTimeScalar() {
}

static {
INSTANCE = GraphQLScalarType.newScalar()
.name("LocalDateTime")
.description("An implementation of Java8 LocalDateTime Scalar")
.coercing(new Coercing<LocalDateTime, Object>() {

@Override
public Object serialize(Object dataFetcherResult) throws CoercingSerializeException {
return serializeLocalDateTime(dataFetcherResult);
}

@Override
public LocalDateTime parseValue(Object input) throws CoercingParseValueException {
return parseLocalDateTimeFromVariable(input);
}

@Override
public LocalDateTime parseLiteral(Object input) throws CoercingParseLiteralException {
return parseLocalDateTimeFromAstLiteral(input);
}
})
.build();
}

private static Object serializeLocalDateTime(Object dataFetcherResult) {
LocalDateTime localDateTime;
if (dataFetcherResult instanceof LocalDateTime) {
localDateTime = (LocalDateTime) dataFetcherResult;
} else {
throw new CoercingSerializeException(
"Expected something we can convert to 'java.time.LocalDateTime' but was '" + typeName(dataFetcherResult) + "'."
);
}
try {
return FORMATTER.format(localDateTime);
} catch (Exception e) {
throw new CoercingSerializeException(
"Unable to turn TemporalAccessor into LocalDateTime because of : '" + e.getMessage() + "'."
);
}
}

private static LocalDateTime parseLocalDateTimeFromVariable(Object input) {
LocalDateTime localDateTime;
if (input instanceof LocalDateTime) {
localDateTime = (LocalDateTime) input;
} else if (input instanceof String) {
localDateTime = parseLocalDateTime(input.toString(), CoercingParseValueException::new);
} else {
throw new CoercingParseValueException(
"Expected a 'String' but was '" + typeName(input) + "'."
);
}
return localDateTime;
}

private static LocalDateTime parseLocalDateTimeFromAstLiteral(Object input) {
if (!(input instanceof StringValue)) {
throw new CoercingParseLiteralException(
"Expected AST type 'StringValue' but was '" + typeName(input) + "'."
);
}
return parseLocalDateTime(((StringValue) input).getValue(), CoercingParseLiteralException::new);
}

private static LocalDateTime parseLocalDateTime(String s, Function<String, RuntimeException> exceptionMaker) {
try {
return LocalDateTime.parse(s, FORMATTER);
} catch (Exception e) {
throw exceptionMaker.apply("Invalid LocalDateTime value : '" + s + "'. because of : '" + e.getMessage() + "'");
}
}
}

Scalar中真正起作用的是graphql.schema.Coercing接口的实现,它包含以下三个方法:

  • parseValue:将输入变量转化为Java对象。
  • parseLiteral:将输入的AST文字(graphql.language.Value)转化为Java对象。
  • serialize:将一个Java对象转化为该Scalar类型需要输出的形式。

看下面这个mutation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mutation CREATE_USER($name: String!, $createdOn: LocalDateTime) {
createUser(input: {
name: $name
age: 10
sex: MAN
createdOn: $createdOn
createdAt: "2022-05-18 20:44:37"
address: {
province: "Zhe Jiang"
city: "Hang Zhou"
}
}) {
id
name
createdOn
createdAt
}
}

三个方法的调用时机分别为:

  • parseValue:当将$createdOn转化为LocalDateTime对象时调用。
  • parseLiteral:当将"2022-05-18 20:44:37"转化为LocalDateTime对象时被调用。
  • serialize:当createdOn字段被查询输出时调用。

Listener监听器

Servlet类似,GraphQL请求也支持监听器机制,只需实现GraphQLServletListener接口。

以记录请求耗时为例,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.sunchaser.chunyu.graphql.kickstart.listener;

import graphql.kickstart.servlet.core.GraphQLServletListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.time.LocalDateTime;

/**
* GraphQL Servlet Listener
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/18
*/
@Component
@Slf4j
public class LoggingListener implements GraphQLServletListener {

@Override
public RequestCallback onRequest(HttpServletRequest request, HttpServletResponse response) {
LocalDateTime startTime = LocalDateTime.now();
log.info("Received graphql request");
return new GraphQLServletListener.RequestCallback() {

@Override
public void onSuccess(HttpServletRequest request, HttpServletResponse response) {
// no-op
}

@Override
public void onError(HttpServletRequest request, HttpServletResponse response, Throwable throwable) {
// no-op
}

@Override
public void onFinally(HttpServletRequest request, HttpServletResponse response) {
log.info("Completed Request. Time Taken: {}", Duration.between(startTime, LocalDateTime.now()));
}
};
}
}

GraphQLContext上下文

前面我们用DataFetchingEnvironment#getContext方法获取过GraphQL请求的默认上下文DefaultGraphQLServletContext,它能在所有解析器中使用,并且支持自定义。

以获取HTTP Header中的user_id字段为例,使用静态代理设计模式自定义上下文。代码示例如下:

CustomGraphQLContext.java自定义上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.sunchaser.chunyu.graphql.kickstart.context;

import graphql.kickstart.servlet.context.GraphQLServletContext;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import org.dataloader.DataLoaderRegistry;

import javax.security.auth.Subject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* 自定义GraphQL Servlet上下文
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/18
*/
@Getter
@AllArgsConstructor
public class CustomGraphQLContext implements GraphQLServletContext {

private final String userId;
private final GraphQLServletContext context;

@Override
public List<Part> getFileParts() {
return context.getFileParts();
}

@Override
public Map<String, List<Part>> getParts() {
return context.getParts();
}

@Override
public HttpServletRequest getHttpServletRequest() {
return context.getHttpServletRequest();
}

@Override
public HttpServletResponse getHttpServletResponse() {
return context.getHttpServletResponse();
}

@Override
public Optional<Subject> getSubject() {
return context.getSubject();
}

@Override
public @NonNull DataLoaderRegistry getDataLoaderRegistry() {
return context.getDataLoaderRegistry();
}
}

CustomGraphQLContextBuilder.java上下文构建器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.sunchaser.chunyu.graphql.kickstart.context;

import graphql.kickstart.execution.context.GraphQLContext;
import graphql.kickstart.servlet.context.DefaultGraphQLServletContext;
import graphql.kickstart.servlet.context.GraphQLServletContextBuilder;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.websocket.Session;
import javax.websocket.server.HandshakeRequest;

/**
* 自定义上下文构建器
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/18
*/
@Component
public class CustomGraphQLContextBuilder implements GraphQLServletContextBuilder {

@Override
public GraphQLContext build(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
String userId = httpServletRequest.getHeader("user_id");
DefaultGraphQLServletContext context = DefaultGraphQLServletContext.createServletContext()
.with(httpServletRequest)
.with(httpServletResponse)
.build();
return new CustomGraphQLContext(userId, context);
}

@Override
public GraphQLContext build(Session session, HandshakeRequest handshakeRequest) {
throw new IllegalStateException("UnSupported");
}

@Override
public GraphQLContext build() {
throw new IllegalStateException("UnSupported");
}
}

Instrumentation

graphql.execution.instrumentation.Instrumentation接口提供了很多beginXXX的方法,这允许我们在GraphQL请求的各个阶段进行扩展,例如做性能监控和链路追踪等。每个beginXXX方法被调用时必须返回一个非nullInstrumentationContext对象,该对象包含两个回调,一个onDispatched在被分派时回调,另一个onCompleted在完成时回调。

以记录请求信息为例,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.sunchaser.chunyu.graphql.kickstart.instrumentation;

import graphql.ExecutionResult;
import graphql.execution.ExecutionId;
import graphql.execution.instrumentation.InstrumentationContext;
import graphql.execution.instrumentation.SimpleInstrumentation;
import graphql.execution.instrumentation.SimpleInstrumentationContext;
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;

/**
* 请求日志记录Instrumentation
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/19
*/
@Component
@Slf4j
public class RequestLoggingInstrumentation extends SimpleInstrumentation {

@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
LocalDateTime startTime = LocalDateTime.now();
ExecutionId executionId = parameters.getExecutionInput().getExecutionId();
log.info("{}: query: {} with variables: {}", executionId, parameters.getQuery(), parameters.getVariables());
return SimpleInstrumentationContext.whenCompleted((executionResult, throwable) -> {
Duration duration = Duration.between(startTime, LocalDateTime.now());
if (throwable == null) {
log.info("{}: completed successfully in: {}", executionId, duration);
} else {
log.error("{}: failed in: {}", executionId, duration, throwable);
}
});
}
}

Tracing链路追踪

graphql.execution.instrumentation.tracing.TracingInstrumentation类提供了链路追踪的能力,在Spring Boot中开启链路追踪仅需添加以下配置项:

1
2
3
graphql:
servlet:
tracing-enabled: true

playground为例,发送请求后可点击右下角TRACING查看链路信息:

image-20220519144008840

Subscription发布订阅

GraphQL也提供了类似WebSocket协议的服务端主动推送能力,基于响应式流。

引入依赖:

1
2
3
4
5
6
<!-- reactor -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<!-- <version>3.4.15</version> -->
</dependency>

创建订阅graphql/subscription.graphqls文件:

1
2
3
4
type Subscription {
users: User
user(name: String!): User
}

定义了两个订阅:users订阅所有用户,user订阅指定name的用户。

创建订阅解析器UserSubscription.java,编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.sunchaser.chunyu.graphql.kickstart.resolver.user.subscription;

import com.sunchaser.chunyu.graphql.kickstart.model.User;
import com.sunchaser.chunyu.graphql.kickstart.publisher.UserPublisher;
import graphql.kickstart.servlet.context.DefaultGraphQLWebSocketContext;
import graphql.kickstart.tools.GraphQLSubscriptionResolver;
import graphql.schema.DataFetchingEnvironment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.stereotype.Component;

/**
* subscription 发布订阅
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/19
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class UserSubscription implements GraphQLSubscriptionResolver {

private final UserPublisher publisher;

public Publisher<User> users(DataFetchingEnvironment environment) {
DefaultGraphQLWebSocketContext context = environment.getContext();
return publisher.getUsersPublisher();
}

public Publisher<User> user(String name) {
return publisher.getUserPublisherFor(name);
}
}

其中UserPublisher.java是基于reactor响应式流的推送,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.sunchaser.chunyu.graphql.kickstart.publisher;

import com.sunchaser.chunyu.graphql.kickstart.model.User;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

import java.util.UUID;

/**
* user发布者
*
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/5/19
*/
@Component
@Slf4j
public class UserPublisher {

private final Sinks.Many<User> sink;
private final Flux<User> flux;

public UserPublisher() {
sink = Sinks.many().multicast().directBestEffort();
flux = sink.asFlux();
}

public void publish(User user) {
sink.tryEmitNext(user);
}

public Publisher<User> getUsersPublisher() {
return flux.map(user -> {
log.info("Publishing user {}", user);
return user;
});
}

public Publisher<User> getUserPublisherFor(String name) {
return flux.filter(user -> name.equals(user.getName()))
.map(user -> {
log.info("Publishing individual subscription for user {}", user);
return user;
});
}
}

接下来在createUser创建用户的时候要通过UserPublisher#publish进行事件的发布,修改UserMutation#createUser方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class UserMutation implements GraphQLMutationResolver {

private final UserPublisher publisher;

public User createUser(@Valid CreateUserInput input, DataFetchingEnvironment environment) {
log.info("Creating user. input: {}", input);

// ......

User user = User.builder()
.id(UUID.randomUUID())
.name(input.getName())
.sex(input.getSex())
.age(input.getAge())
.createdOn(input.getCreatedOn().toLocalDate())
.createdAt(input.getCreatedAt())
.address(input.getAddress())
.build();

publisher.publish(user);
return user;
}
}

另外,为了在订阅中通过DataFetchingEnvironment获取上下文,我们需要在自定义的上下文构建器中构建基于WebSocket的上下文,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CustomGraphQLContextBuilder implements GraphQLServletContextBuilder {

// ......

@Override
public GraphQLContext build(Session session, HandshakeRequest handshakeRequest) {
return DefaultGraphQLWebSocketContext.createWebSocketContext()
.with(session)
.with(handshakeRequest)
.build();
}

// ......
}

其它

IDEA插件

image-20220519185300116

作用:在IDEA中写graphqls文件会有提示,同时文件前面会有icon图标等。

voyager

可以查看graphql schema之间的关系图。

引入依赖:

1
2
3
4
5
6
7
<!--可以查看graphql之间的关系图-->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>voyager-spring-boot-starter</artifactId>
<version>${graphql.starter.version}</version>
<scope>runtime</scope>
</dependency>

默认访问路径为:http://localhost:8080/voyager,界面示例如下:

image-20220519190359955

可配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
graphql:
voyager:
enabled: true
basePath: /
mapping: /voyager
endpoint: /graphql
cdn:
enabled: false
version: latest
pageTitle: Voyager
displayOptions:
skipRelay: true
skipDeprecated: true
rootType: Query
sortByAlphabet: false
showLeafFields: true
hideRoot: false
hideDocs: false
hideSettings: false