Spring Boot
整合GraphQL
核心依赖:
1 2 3 4 5 6 <dependency > <groupId > com.graphql-java-kickstart</groupId > <artifactId > graphql-spring-boot-starter</artifactId > <version > 11.1.0</version > </dependency >
创建工程 在chunyu-graphql
模块中创建子模块kickstart
,引入Spring Boot
及GraphQL
等依赖,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 > <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 > <dependency > <groupId > com.graphql-java-kickstart</groupId > <artifactId > graphql-spring-boot-starter</artifactId > <version > ${graphql.starter.version}</version > </dependency > <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;@SpringBootApplication public class ChunYuKickStartGraphQLApplication { public static void main (String[] args) { new SpringApplicationBuilder (ChunYuKickStartGraphQLApplication.class) .web(WebApplicationType.SERVLET) .run(args); } }
第一个GraphQL
查询 默认GraphQL
的Schema
文件都存放在classpath
类路径下,文件后缀名为.graphqls
。
1 2 3 graphql: tools: schema-location-pattern: "**/*.graphqls"
下面以查询用户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
表示一个方法,接收一个类型为ID
的id
参数,返回一个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;@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;@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
工具供我们选择,例如GraphiQL
、Playground
及Altair
等。
一个典型的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
,最后需要查询id
、name
、sex
和age
这四个字段。当然我们可根据实际情况增加或减少需要查询的字段。
Postman
Postman
中的查询示例如下:
pom.xml
中添加依赖:
1 2 3 4 5 6 7 <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
。查询示例如下:
可用的配置项有:
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 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" }
pom.xml
中添加依赖:
1 2 3 4 5 6 <dependency > <groupId > com.graphql-java-kickstart</groupId > <artifactId > graphiql-spring-boot-starter</artifactId > <version > ${graphql.starter.version}</version > </dependency >
默认访问路径为http://localhost:8080/graphiql
。查询示例如下:
可用的配置项有:
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>"
pom.xml
中添加依赖:
1 2 3 4 5 6 <dependency > <groupId > com.graphql-java-kickstart</groupId > <artifactId > altair-spring-boot-starter</artifactId > <version > ${graphql.starter.version}</version > </dependency >
默认访问路径为http://localhost:8080/altair
。查询示例如下:
可用的配置项有:
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
,同时Java
类User
中的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;@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
,同时Java
类User
中的sex
字段也要同步修改为SexEnum
枚举,创建SexEnum
枚举类如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.sunchaser.chunyu.graphql.kickstart.model;public enum SexEnum { MAN, WOMAN, ; }
注释 写注释是一个良好的素养。.graphql
文件中的注释以#
开头,例如:
1 2 3 4 5 type Query { user( id : ID) : User }
校验 Schema
中的方法的入参出参等可以进行一些基本规则校验。
非空 方法的入参出参、自定义类型中的字段可以指定为非空(不能为null
)。类型后面加英文叹号!
,例如:
1 2 3 4 5 type Query { 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 <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;@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;@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;@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
的部分移动到AddressResolver
。AddressResolver
称为一个子解析器。
客户端查询示例如下:
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;@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
对象,对于GraphQLException
和ConstraintViolationException
异常来说我们返回原异常中携带的错误信息,除此之外的其它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;@Component @Slf4j public class AddressResolver implements GraphQLResolver <User> { public DataFetcherResult<Address> address (User user) { 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(); } }
异步 默认情况下,每个解析器都是同步执行的。为了提高效率,我们可以进行异步处理,让解析器返回一个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;@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 type Mutation { 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;@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;@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
为例,使用示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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
如下:
创建文件上传解析器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;@Component @Slf4j public class UploadFileMutation implements GraphQLMutationResolver { public String uploadFile (DataFetchingEnvironment environment) { log.info("Uploading file" ); DefaultGraphQLServletContext context = environment.getContext(); List<Part> fileParts = context.getFileParts(); fileParts.forEach(part -> { log.info("uploading: {}, size: {}" , part.getSubmittedFileName(), part.getSize()); }); return UUID.randomUUID().toString(); } }
用Postman
调用示例如下:
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()); boolean containsId = selectionSet.contains("id" ); boolean containsAllOfIdAndName = selectionSet.containsAllOf("id" , "name" ); boolean containsAnyOfIdAndName = selectionSet.containsAnyOf("id" , "name" ); DefaultGraphQLServletContext context = environment.getContext(); 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
标量类型:
Long
:java.lang.Long
Short
:java.lang.Short
Byte
:java.lang.Byte
BigDecimal
:java.math.BigDecimal
BigInteger
:java.math.BigInteger
Char
:java.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;@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 scalar NonNegativeIntscalar Datescalar DateTimetype Query { 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;@Value @Builder public class User { UUID id; String name; SexEnum sex; Integer age; Address address; User son; LocalDate createdOn; ZonedDateTime createdAt; }
Date
对应的Java
类型是LocalDate
,DateTime
对应的Java
类型是ZonedDateTime
。
graphql-java-extended-scalars
包里面的扩展Scalar
无Java8
的LocalDateTime
。
自定义Scalar
类型 以Java8
的LocalDateTime
为例。
自定义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;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;@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) { } @Override public void onError (HttpServletRequest request, HttpServletResponse response, Throwable throwable) { } @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;@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;@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
方法被调用时必须返回一个非null
的InstrumentationContext
对象,该对象包含两个回调,一个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;@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
查看链路信息:
Subscription
发布订阅GraphQL
也提供了类似WebSocket
协议的服务端主动推送能力,基于响应式流。
引入依赖:
1 2 3 4 5 6 <dependency > <groupId > io.projectreactor</groupId > <artifactId > reactor-core</artifactId > </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;@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;@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
插件
作用:在IDEA
中写graphqls
文件会有提示,同时文件前面会有icon
图标等。
可以查看graphql schema
之间的关系图。
引入依赖:
1 2 3 4 5 6 7 <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
,界面示例如下:
可配置项:
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