Spring GraphQL 使用记录

Spring GraphQL 是今年 Spring 新发布的一个集成 Graphql 的 Spring Application,凭借着 Spiring boot 开箱即用的特性,能够非常快地构建出一个可用的 Graphql 服务。然而尽管可用,但其文档实在是写得一言难尽,尤其是对于一些进一步的功能来说,需要靠自己看 API 文档来摸索。

以下记录使用的相关情况

Setup

可以通过构建 Spring boot 项目来进行初始化

使用 Gradle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // Spring GraphQL Boot starter
    implementation 'org.springframework.experimental:graphql-spring-boot-starter:1.0.0-SNAPSHOT'

    // ...
}

repositories {
    mavenCentral()
    maven { url 'https://repo.spring.io/milestone' }  // Spring milestones
    maven { url 'https://repo.spring.io/snapshot' }   // Spring snapshots
}

或者使用 Maven

 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
<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    // Spring GraphQL Boot starter
    <dependency>
        <groupId>org.springframework.experimental</groupId>
        <artifactId>graphql-spring-boot-starter</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </dependency>

    <!-- ... -->

</dependencies>

<!-- For Spring project milestones or snapshot releases -->
<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
    </repository>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/snapshot</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
</repositories>

导入依赖后,将 Graphql Schema 文件放到 resources/graphql 目录下并进行相应的配置即可运行,运行后在设定的路径可以看到网页版的 playground

1
2
3
4
5
6
7
8
9
spring:
  graphql:
    graphiql: # 提供辅助用的网页端 playground
      enabled: true
      path: /
    schema: # 提供 schema
      printer:
        enabled: true
    path: /query # Graphql endpoint

Query and Mutation

Schema 中提供的 query 和 mutation 通过注解映射到 Controller 中方法进行处理,并返回相应的数据。此处默认使用的 convention 的方式,只要方法的名字及其参数的名字与 Schema 声明的一致则可自动映射,否则也可以通过注解的参数进行设置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Controller
public class TodoController {

    @QueryMapping
    public List<TodoItem> todoItems(GraphQLContext cxt) {
        var u = getUserInContext(cxt);
        return todoService.getTodoItemsByUser(u);
    }

    @MutationMapping
    public TodoItem createTodoItem(@Argument TodoItemInput todo, GraphQLContext cxt) {
        var user = getUserInContext(cxt);
        return todoService.createTodoItem(todo, user);
    }
}

方法的参数支持

  • DataFetchingEnvironment: 包含本次查询当中丰富的相关信息,包括查询的变量,context等信息
  • 通过注解 @Argument 可以为参数绑定传入的相应变量
  • GraphQLContext: 用于存储访问上下文中相应数据内容

Authorization

怎样进行权限认证,文档中只是简单地给了段文字介绍说可以用 Spring Security 来进行权限认证。然而我的需求也没有这么复杂,只需要简单地拦截请求,查看有无携带 token 访问,如果带 token 的访问则将其转换成相应的用户并给后续交给后续的 Controller 来进行处理。

此处我使用的是 WebInterceptor 来进行请求的拦截处理,主要的思路为:

  1. 拦截每个请求,尝试取请求中 header 中的 Authorization 字段
  2. 如果没有这个字段,则跳过
  3. 如果有这个字段,则尝试根据 token 取出相应的 User,如果找不到,则抛出无效 token 的错误
  4. 找到后将该 User 对象加入到 graphQLContext 当中供 Controller 使用。
 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
@Component
public class MyInterceptor implements WebInterceptor {

    private final UserService userService;

    @Autowired
    public MyInterceptor(UserService userService) {
        this.userService = userService;
    }

    @Override
    public Mono<WebOutput> intercept(WebInput webInput, WebGraphQlHandler next) {
        var authHeader = webInput.getHeaders().get("Authorization");
        var q = webInput.toExecutionInput();
        if (Objects.isNull(authHeader)||authHeader.size()==0) {
            return next.handle(webInput);
        }

        var user = userService.getUserByToken(authHeader.get(0));
        if (user.isEmpty()) {
            return Mono.just(new WebOutput(webInput,
                    new ExecutionResultImpl(GraphqlExceptionBuilder.genError(GraphqlExceptionBuilder.ErrorType.INVALID_TOKEN))));
        }

        webInput.configureExecutionInput(((executionInput, builder) -> {
            Map<String, Object> context = new HashMap<>() {{
                put("user", user.get());
            }};

            return builder.graphQLContext(context).build();
        }));

        return next.handle(webInput);

    }

}

在后续的 controller 方法当中,如果需要使用到用户信息或者需要用户权限,则直接从 graphQLContext 当中取出相应的对象,如果取不到则抛出相应的 error

1
2
3
4
private User getUserInContext(GraphQLContext cxt) {
        return (User) cxt.getOrEmpty("user")
                .orElseThrow(()-> GraphqlExceptionBuilder.genError(GraphqlExceptionBuilder.ErrorType.FORBIDDEN));
    }

Error Handler

在应用运行过程所有抛出的异常最终都会以 GraphQL 的形式返回给客户端。一开始我还以为是会直接将抛出的 GraphQLError 直接返回给用户,所以直接在代码内部抛出错误,企图通过 Controller 最终返回给用户。不过发现这样做的话,我在 GraphlQLErrorException 当中设置的 extensions 总会没有效果,而是全被归类为 INTERNAL_ERROR。

后来发现是所有的异常都会跑去一个 ErrorResolver 的东西处理,会重新把 Error 重新封装一次。所以我们必须自己写一个 ExceptionHandler 来将其覆盖

1
2
3
4
5
6
7
@Component
public class DataFetcherExceptionHandler extends DataFetcherExceptionResolverAdapter {
    @Override
    public GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
            return (GraphQLError) ex;
    }
}

另外,GraphQLError 的生成我是通过自定义了一个 Builder 工具类来辅助生成,通过枚举类型来生成包含 message,error 的 Error

 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
public class GraphqlExceptionBuilder {

    public enum ErrorType {
        DUPLICATED_USER,
        NO_USER,
        WRONG_PASSWORD,
        INVALID_TOKEN,
        FORBIDDEN,
        NO_ITEM,
    }

    public static Map<String, Object> updateErrorCode(ErrorType errorType) {
        return new HashMap<>() {{
            put("code", errorType.name());
        }};
    }

    public static GraphqlErrorException genError(ErrorType errorType) {
        var err = GraphqlErrorException.newErrorException();
        switch (errorType) {
            case DUPLICATED_USER:
                err.message("existed username");
                break;
            case NO_USER:
                err.message("not exist username");
                break;
            case WRONG_PASSWORD:
                err.message("wrong password");
                break;
            case INVALID_TOKEN:
                err.message("invalid token");
                break;
            case FORBIDDEN:
                err.message("access forbidden");
                break;
            case NO_ITEM:
                err.message("no such query items");
                break;
        }

        err.extensions(updateErrorCode(errorType));
        return err.build();
    }
}