Spring Graphql 初体验
代码地址:https://duangouyu.coding.net/p/demo/d/spring-graphql/git/tree/webflux
Graphql 是什么
GraphQL 是一个用于 API 的查询语言,是一个使用基于类型系统来执行查询的服务端运行时(类型系统由你的数据定义)。GraphQL 并没有和任何特定数据库或者存储引擎绑定,而是依靠你现有的代码和数据支撑
SpringGraphql 是什么
Spring GraphQL 是 GraphQL Java 团队的 GraphQL Java Spring 项目的继承者。它将成为所有 Spring、GraphQL 应用程序的基础。
搭建运行环境
SpringGraphql 正式版 对于 SpringBoot 的要求大于 2.7.0.m1
如果小于这个版本的springboot 可以使用 实验版本
<dependency> <groupId>org.springframework.experimental</groupId> <artifactId>graphql-spring-boot-starter</artifactId> <version>1.0.0-M4</version> </dependency>
build.gradle
plugins {
id 'org.springframework.boot' version '2.7.0-SNAPSHOT'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'top.mengshuo'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenLocal()
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://repo.spring.io/snapshot' }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'io.r2dbc:r2dbc-h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework:spring-webflux'
testImplementation 'org.springframework.graphql:spring-graphql-test'
testImplementation 'io.projectreactor:reactor-test'
}
test {
useJUnitPlatform()
}
schema.graphqls
在 resources 目录下创建 graphql 目录 添加 schema.graphqls 文件
此文件用于定义与客户端交互的接口信息 具体规范参考:https://graphql.cn/learn/schema/
graphql 一共有三种请求类型
query:查询
mutation:更改
subscription:订阅
# Query 定义查询操作
type Query {
userList: [User]
byId( id: ID!): User
}
# Mutation 定义更新操作
type Mutation {
addUser(user: InUser!) :User
}
# Subscription 订阅类型
type Subscription {
sub: User
}
# 定义输出的数据类型
type User {
id: ID!
name: String!
phone: String!
# 返回的数据中不包含这个 所以会子查询
tags: [Tag]
}
type Tag {
id: ID!
name: String!
}
# 定义输入的数据类型
input InUser {
name: String!
phone: String!
}
application.yml
server:
port: 8081
spring:
graphql:
# 指定 websocket 端点
websocket:
path: /graphql
# 默认访问端点
path: /graphql
# 网页测试
graphiql:
# 是否启用
enabled: true
# 访问地址
path: /graphiql
# 打印请求内容
schema:
printer:
enabled: true
datasource:
driver-class-name: org.h2.Driver
url: r2dbc:h2:file:./h2/db/graphql;MODE=MySQL
username: root
password: root
h2:
console:
# 是否允许网页访问,默认false
enabled: true
# h2数据库的访问路径:http://localhost:8080/h2(默认为/h2-console)
path: /h2
settings:
# 是否允许从其他地方访问,还是只允许从本地访问
web-allow-others: true
main:
allow-bean-definition-overriding: true
controller
完整代码请查看 git
package top.mengshuo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.graphql.data.method.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import top.mengshuo.entity.Tag;
import top.mengshuo.entity.User;
import top.mengshuo.entity.UserTag;
import top.mengshuo.repository.TagRepository;
import top.mengshuo.repository.UserRepository;
import top.mengshuo.repository.UserTagRepository;
import java.time.Duration;
/**
* graphql query
*
* @author mengshuo
* @since 2022-01-03
*/
@Controller
public class GraphqlController {
@Autowired
private UserRepository userRepository;
@Autowired
private TagRepository tagRepository;
@Autowired
private UserTagRepository userTagRepository;
/**
* 查询用户列表
* <p>
* - @QueryMapping 属于 @SchemaMapping 的派生注解 = @SchemaMapping(typeName = "Query")
*
* @return 用户列表
*/
@PreAuthorize("isAuthenticated()")
@QueryMapping(name = "userList")
public Flux<User> userList() {
return this.userRepository.findAll();
}
/**
* 根据id查询用户信息
* - @Argument 指定参数名称
*
* @param id 用户id
* @return res
*/
@PreAuthorize("hasAuthority('test:select')")
@QueryMapping(name = "byId")
public Mono<User> byId(@Argument Integer id) {
return this.userRepository.findById(id);
}
/**
* type User 中 tags 的子查询 <br/>
* typeName = "User" 指定父类型名称 field = "tags" 指定查询名称 <br/>
* 也就是 schema 中 User 的 tags 字段
*
* @param user 用户信息
* @return tags
*/
@SchemaMapping(typeName = "User", field = "tags")
public Flux<Tag> tags(User user) {
Flux<UserTag> userTags = this.userTagRepository.findByUid(user.getId());
// 通过 map 转换为 Flux<Integer> 类型数据 再进行查询
return this.tagRepository.findAllById(userTags.map(UserTag::getId));
}
/**
* - @MutationMapping 变更类型 和 @QueryMapping 一样都是派生注解
*
* @param user 用户信息
* @return res
*/
@MutationMapping(name = "addUser")
public Mono<User> addUser(@Argument User user) {
return this.userRepository.save(user);
}
/**
* 订阅数据 查询全部user数据每秒返回一个数据
*
* @return user
*/
@SubscriptionMapping(name = "sub")
public Flux<User> subscription() {
return this.userRepository.findAll().delayElements(Duration.ofSeconds(1));
}
}
认证授权处理
使用security
@PreAuthorize("isAuthenticated()") 必须认证 授权与其同理 添加权限表达式即可
/**
* 根据id查询用户信息
* - @Argument 指定参数名称
*
* @param id 用户id
* @return res
*/
@PreAuthorize("hasAuthority('test:select')")
@QueryMapping(name = "byId")
public Mono<User> byId(@Argument Integer id) {
return this.userRepository.findById(id);
}
自定义异常处理
自定义异常处理 集成 DataFetcherExceptionResolverAdapter
重写 resolveToMultipleErrors 解决多个异常 或者 resolveToSingleError 解决单个异常 的方法
org.springframework.graphql.execution.DataFetcherExceptionResolver 这是接口
这并不能处理全部异常,从注释文档来看只能处理 graphql.schema.DataFetcher 产生的异常
我理解的是graphql查询语句通过解析后的异常,也就是如果前端的查询语句与 schema.graphqls 中的不匹配则属于graphql解析的异常,比如查询的参数不符合要求,或者请求的字段不存在等,此时这个处理器并不会进行处理,只有通过解析后,产生的异常才会处理
package top.mengshuo.exception;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.cglib.proxy.UndeclaredThrowableException;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver;
import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* graphql 异常处理 <br/>
* {@link GraphQLError} 的实现类包含了graphql 的默认处理方法
* {@link ReactiveSecurityDataFetcherExceptionResolver} security 适配 webflux 的异常处理
* {@link SecurityDataFetcherExceptionResolver} security 适配 webmvc 的异常处理
*
* @author mengshuo
* @since 2022-01-05
*/
@Component
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
private static final Map<Class<? extends Throwable>, ErrorType> ERROR_TYPE_MAP =
new HashMap<>();
/*
初始化已知异常 也可以将信息放入到 异常枚举类 由枚举类提供查找方法 但是有耦合
*/
static {
ERROR_TYPE_MAP.put(AuthenticationException.class, ErrorType.UNAUTHORIZED);
}
/**
* 覆盖此方法以解决单个 GraphQL 错误的异常。
*
* @param ex 要解决的异常
* @param env 被调用的DataFetcher的环境
* @return 已解决的错误 或 null 如果未解决
*/
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
ErrorType errorType;
if (ex instanceof UndeclaredThrowableException) {
ex = ((UndeclaredThrowableException) ex).getUndeclaredThrowable();
}
/* web flux 不能这样处理
// 特殊处理的放入到 if 无需特殊处理的放入map减少代码冗余
if (ex instanceof AccessDeniedException) {
// 权限不足 如果是匿名用户 也就是没有登陆的情况下 返回 未登录 只能处理webmvc的security异常
// 因为 @PreAuthorize("isAuthenticated()") 也是 AccessDeniedException 异常,
// 因此就需要判定是否登录了 登录了就是没权限 没登陆就返回没登陆 这样前端就可以根据提示进行操作了
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
errorType = authentication.isAuthenticated() ? ErrorType.UNAUTHORIZED : ErrorType.FORBIDDEN;
} else {
errorType = ErrorType.UNAUTHORIZED;
}
} else {
// 非特殊类型直接获取并返回
errorType = ERROR_TYPE_MAP.get(ex.getClass());
}
*/
// 非特殊类型直接获取并返回
errorType = ERROR_TYPE_MAP.get(ex.getClass());
// 兜底处理 如果这不是最后一个处理器 如果返回null 则会继续寻找处理方法
if (errorType == null) {
return null;
}
return GraphqlErrorBuilder.newError(env).errorType(errorType).message(errorType.getMsg()).build();
}
}
package top.mengshuo.exception;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver;
import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.List;
/**
* 针对于webflux security的异常处理
* {@link ReactiveSecurityDataFetcherExceptionResolver} security 适配 webflux 的异常处理
* {@link SecurityDataFetcherExceptionResolver} security 适配 webmvc 的异常处理
*
* @author mengshuo
* @since 2022-01-09
*/
@Component
public class WebFluxSecurityExceptionResolver implements DataFetcherExceptionResolver {
/**
* 参考{@link ReactiveSecurityDataFetcherExceptionResolver#resolveException(java.lang.Throwable, graphql.schema.DataFetchingEnvironment)}
*
* @param exception 异常
* @param environment 数据解析上下文
* @return res
*/
@Override
public Mono<List<GraphQLError>> resolveException(Throwable exception, DataFetchingEnvironment environment) {
return ReactiveSecurityContextHolder.getContext()
.map(context ->
Collections.singletonList(GraphqlErrorBuilder.newError().errorType(ErrorType.FORBIDDEN).message(ErrorType.FORBIDDEN.getMsg()).build())
)
.switchIfEmpty(Mono.fromCallable(() ->
Collections.singletonList(GraphqlErrorBuilder.newError().errorType(ErrorType.UNAUTHORIZED).message(ErrorType.UNAUTHORIZED.getMsg()).build())
));
}
}
package top.mengshuo.exception;
import graphql.ErrorClassification;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 错误类型 实现 {@link ErrorClassification}
*
* @author mengshuo
* @since 2022-01-05
*/
@Getter
@AllArgsConstructor
public enum ErrorType implements ErrorClassification {
/**
* 默认error
*/
BAD_REQUEST("客户端错误"),
UNAUTHORIZED("未登录或登录失效"),
FORBIDDEN("未经授权,禁止访问"),
NOT_FOUND("未定义"),
INTERNAL_ERROR("系统内部错误, 请进行排查"),
;
private final String msg;
}
测试
可以访问:http://127.0.0.1:8081/graphiql
也可以安装浏览器插件 或者 postman
浏览器插件:https://chrome.google.com/webstore/detail/kjhjcgclphafojaeeickcokfbhlegecd
-
访问须认证的端点
POST /graphql HTTP/1.1 Host: 127.0.0.1:8081 Content-Type: application/json Content-Length: 73 {"query":"{ userList{ id name phone tags{ id name } } }","variables":{}}
-
访问需要权限的端点
使用 http basic 进行认证
POST /graphql HTTP/1.1 Host: 127.0.0.1:8081 Authorization: Basic bWVuZ3NodW86bWVuZ3NodW8= Content-Type: application/json Content-Length: 96 {"query":"query ($id: ID! ){ byId(id: $id) { id name tags { id name } } }","variables":{"id":1}}
-
多个端点同时调用 聚合数据
因为 byId 端点无权限所以只返回了 userList 端点
POST /graphql HTTP/1.1 Host: 127.0.0.1:8081 Authorization: Basic bWVuZ3NodW86bWVuZ3NodW8= Content-Type: application/json Content-Length: 101 {"query":"query ($id: ID! ){ byId(id: $id) { id name } userList{ id name}}\r\n","variables":{"id":1}}
-
subscription 类型
总结
Rest api 和 Graphql 并不冲突,rest 调用更加简单 而 grapqhl 则更加灵活,查询聚合数据时 rest可以通过增加接口来避免前端的多次调用,但是如果我只需要几个字段,那么多余的数据返回势必造成返回数据的冗余,而且增加了数据传输的内容
Graphql 是在Rest基础上进行改进的,设计是向前迭代的