Spring for GraphQL

Spring for GraphQL

简介

GraphQL 是用于 API 的查询语言,也是一个服务器端的运行时,被用来执行指定类型的查询。

注:经过和 JPA 结合,它可以做到仅仅返回用户查询的字段,并不会做额外的查询,而且可以在单次查询中调用多个接口。

基础使用

引入如下依赖:

  • Lombok
  • Spring Web
  • Spring Data JPA
  • 数据库(本文使用 MySQL)
1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework:spring-webflux'
testImplementation 'org.springframework.graphql:spring-graphql-test'
}

编写如下模型类:

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
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.Hibernate;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

@Getter
@Setter
@ToString
@RequiredArgsConstructor
@AllArgsConstructor
@Entity
public class Author {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
@ToString.Exclude
private List<Book> books = new ArrayList<>();

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Author author = (Author) o;
return getId() != null && Objects.equals(getId(), author.getId());
}

@Override
public int hashCode() {
return getClass().hashCode();
}

}
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
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.Hibernate;

import java.util.Objects;

@Getter
@Setter
@ToString
@RequiredArgsConstructor
@AllArgsConstructor
@Entity
public class Book {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;

private String publisher;

@ManyToOne(fetch = FetchType.LAZY)
@ToString.Exclude
private Author author;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Book book = (Book) o;
return getId() != null && Objects.equals(getId(), book.getId());
}

@Override
public int hashCode() {
return getClass().hashCode();
}

}

编写如下 Repository :

1
2
3
4
5
6
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AuthorRepository extends JpaRepository<Author,Long> {
}
1
2
3
4
5
6
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}

在主类中插入测试数据:

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
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.util.ArrayList;
import java.util.List;

@SpringBootApplication
public class GqlApplication {

public static void main(String[] args) {
SpringApplication.run(GqlApplication.class, args);
}

@Bean
ApplicationRunner applicationRunner(AuthorRepository authorRepository, BookRepository bookRepository) {
return args -> {
Author josh = authorRepository.save(new Author(null, "Josh", new ArrayList<>()));
Author mark = authorRepository.save(new Author(null, "Mark", new ArrayList<>()));
bookRepository.saveAll(List.of(
new Book(null, "Java 11", "Tom", josh),
new Book(null, "Java 12", "Jerry", mark),
new Book(null, "Java 13", "Spike", josh)
));
};
}
}

编写 Controller :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import jakarta.annotation.Resource;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
public class TestController {

@Resource
private AuthorRepository authorRepository;

@QueryMapping
List<Author> authors() {
return authorRepository.findAll();
}

}

编写 src/main/resources/graphql/schema.graphqls 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Query {
authors: [Author]
}

type Author {
id: ID!
name: String!
books: [Book]
}

type Book {
id: ID!
title: String!
publisher: String
}

编写 application.yaml 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
port: 8080
spring:
application:
name: gql
datasource:
driver-class-name: ${JDBC_DRIVER:com.mysql.cj.jdbc.Driver}
url: ${MYSQL_URI:jdbc:mysql://xxx.xxx.xxx.xxx:3306/xxx}
username: ${MYSQL_USERNAME:xxxx}
password: ${MYSQL_PASSWORD:xxxx}
jackson:
time-zone: Asia/Shanghai
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
graphql:
graphiql:
enabled: true

启动程序,然后访问 http://localhost:8080/graphiql 即可看到调试控制台,输入如下内容即可完成测试。

1
2
3
4
5
6
7
8
9
10
query {
authors {
id
name
books {
id
title
}
}
}

数据分页

GraphQL 本身包含自己的 分页请求模型与方式,在项目中可以使用如下方式实现。

修改 Repository:

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.graphql.data.GraphQlRepository;

@GraphQlRepository
public interface AuthorRepository extends ListCrudRepository<Author, Long> {

Window<Author> findBy(ScrollPosition position, Limit limit);

}

注:此处可以额外添加查询参数和排序等内容。

修改请求类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import jakarta.annotation.Resource;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.query.ScrollSubrange;
import org.springframework.stereotype.Controller;

@Controller
public class TestController {

@Resource
private AuthorRepository authorRepository;

@QueryMapping
Window<Author> authors(ScrollSubrange subrange) {
ScrollPosition scrollPosition = subrange.position().orElse(ScrollPosition.offset());
Limit limit = Limit.of(subrange.count().orElse(1));
return authorRepository.findBy(scrollPosition, limit);
}

}

修改 graphql 配置文件:

1
2
3
4
5
6
7
8
type Query {
authors(first: Int,last: Int,before: String,after: String): AuthorConnection
}

type Author {
id: ID!
name: String!
}

在 graphiql 页面中即可使用如下查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
query{
authors {
edges{
node {
id
name
}
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
}
}

嵌套查询

在 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
import jakarta.annotation.Resource;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
public class TestController {

@Resource
private AuthorRepository authorRepository;

@Resource
private BookRepository bookRepository;

@QueryMapping
List<Author> authors() {
return authorRepository.findAll();
}

@SchemaMapping
List<Book> books(Author author,@Argument String publisher) {
return bookRepository.findAllByAuthorIdAndPublisherLike(author.getId(), publisher);
}

}

src/main/resources/graphql/schema.graphqls 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Query {
authors: [Author]
}

type Author {
id: ID!
name: String!
books(publisher:String): [Book]
}

type Book {
id: ID!
title: String!
publisher: String
}

WebSocket

首先需要切换 Web 至 WebFlux,样例如下:

1
2
3
4
5
6
7
8
9
10
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.graphql:spring-graphql-test'
}

编写 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
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;

import java.time.Duration;
import java.time.Instant;
import java.util.function.Supplier;
import java.util.stream.Stream;

@Controller
public class SampleController {

@QueryMapping
public String greeting() {
return "Hello world!";
}

@SubscriptionMapping
public Flux<String> greetings() {
return Flux.fromStream(Stream.generate(new Supplier<String>() {
@Override
public String get() {
return "Hello " + Instant.now() + "!";
}
})).delayElements(Duration.ofSeconds(1)).take(5);
}

}

编写 src/main/resources/graphql/schema.graphqls 配置文件:

1
2
3
4
5
6
type Query {
greeting: String
}
type Subscription {
greetings: String
}

编写 application.yaml 配置文件:

1
2
3
4
5
6
7
8
9
10
server:
port: 8080
spring:
application:
name: gql
graphql:
graphiql:
enabled: true
websocket:
path: /graphql

启动程序,然后访问 http://localhost:8080/graphiql 即可看到调试控制台,输入如下内容即可完成测试。

1
2
3
subscription {
greetings
}

还可以按照如下样例编写单元测试

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
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest;
import org.springframework.graphql.test.tester.GraphQlTester;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;

@GraphQlTest(SampleController.class)
public class WebFluxWebSocketSampleTests {

@Autowired
private GraphQlTester graphQlTester;

@Test
void greetingMono() {
this.graphQlTester.document("{greeting}")
.execute()
.path("greeting")
.entity(String.class)
.isEqualTo("Hello world!");
}

@Test
void subscriptionWithResponse() {
Flux<GraphQlTester.Response> result = this.graphQlTester.document("subscription { greetings }")
.executeSubscription()
.toFlux();

StepVerifier.create(result)
.consumeNextWith(response -> response.path("greetings").hasValue())
.consumeNextWith(response -> response.path("greetings").hasValue())
.consumeNextWith(response -> response.path("greetings").hasValue())
.expectNextCount(2)
.verifyComplete();
}

}

在页面中可以按照如下样例编写读取程序 src/main/resources/static/index.html

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
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GraphQL over WebSocket</title>
<script type="text/javascript" src="https://unpkg.com/graphql-ws/umd/graphql-ws.js"></script>
</head>
<body>
<p>Check the console for subscription messages.</p>
<script type="text/javascript">
const client = graphqlWs.createClient({
url: 'ws://localhost:8080/graphql',
});

// query
(async () => {
const result = await new Promise((resolve, reject) => {
let result;
client.subscribe(
{
query: '{ greeting }',
},
{
next: (data) => (result = data),
error: reject,
complete: () => resolve(result),
},
);
});

console.log("Query result: " + result);
})();

// subscription
(async () => {
const onNext = (data) => {
console.log("Subscription data:", data);
};

await new Promise((resolve, reject) => {
client.subscribe(
{
query: 'subscription { greetings }',
},
{
next: onNext,
error: reject,
complete: resolve,
},
);
});
})();

</script>
</body>
</html>

RSocket

首先需要切换 Web 至 WebFlux,并引入 RSocket 样例如下:

1
2
3
4
5
6
7
8
9
10
11
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-rsocket'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.graphql:spring-graphql-test'
}

编写 Record 类:

1
2
public record Message(String name, String content) {
}

编写 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
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;

import java.time.Duration;
import java.time.Instant;
import java.util.function.Supplier;
import java.util.stream.Stream;

@Controller
public class SampleController {

@MessageMapping("graphql")
public String greeting() {
return "Hello world!";
}

@MessageMapping("graphql")
public Flux<String> greetings() {
return Flux.fromStream(Stream.generate(new Supplier<String>() {
@Override
public String get() {
return "Hello " + Instant.now() + "!";
}
})).delayElements(Duration.ofSeconds(1)).take(5);
}

}

编写 src/main/resources/graphql/schema.graphqls 配置文件:

1
2
3
4
5
6
type Query {
greeting: String
}
type Subscription {
greetings: String
}

编写 src/main/resources/application.yaml 配置文件

1
2
3
4
5
6
7
8
server:
port: 8080
spring:
rsocket:
server:
port: 7000
mapping-path: /rsocket
transport: websocket

参照 RSocket 文档使用如下命令即可完成测试:

1
2
java -jar rsc.jar --request --route=graphql --dataMimeType="application/graphql+json" --data '{"query": "query {\n  greeting\n}"}' --debug ws://localhost:7000/rsocket
java -jar rsc.jar --stream --route=graphql --dataMimeType="application/graphql+json" --data='{"subscription": "subscription { greetings { greeting } }"}' --debug ws://localhost:7000/rsocket

或者使用 RSocket Requests In HTTP Client:

1
2
3
4
5
6
7
8
9
### query
GRAPHQL rsocketws://localhost:8080/rsocket/graphql

query {greeting}

### sub
GRAPHQL rsocketws://localhost:8080/rsocket/graphql

subscription { greetings }

IDEA 插件

在 IDEA 插件中可以找到 GraphQL 插件,此插件可以完成一些代码提示和运行测试的功能。

在安装完成后可以编写如下配置文件 graphql.config.yaml

1
2
3
4
5
6
7
8
9
10
schema: schema.graphqls
documents: '**/*.graphql'
exclude: 'src/**/__tests__/**'
include: src/**
extensions:
endpoints:
default:
url: http://localhost:8080/graphql
headers:
Authorization: Bearer ${TOKEN}

单元测试

可以编写如下样例进行单元测试:

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
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.security.test.context.support.WithMockUser;

@Import({AuthorRepository.class})
@DataJpaTest
@AutoConfigureGraphQlTester
@AutoConfigureMockMvc
class TestControllerTest {

@Resource
private GraphQlTester graphQlTester;

@Test
@WithMockUser(username = "test", roles = "USER")
void testFindAll() {
// language=GraphQL
String document = """
query {
authors {
id
name
}
}
""";
graphQlTester.document(document).execute().path("authors").entityList(Author.class).hasSize(2);
}

}

与 Spring Security 集成

注:此处样例默认使用 JWT ,如需详细代码请参照 Spring Security 文档。需要值得注意的是,在配置完成后 graphqil 就不能正常使用了,我尝试将相应链接进行开放但还是在发送请求时遇到了 js 相关的问题,且 graphiql 无法访问 IntrospectionQuery 也就没有了代码提示等功能,但是 Postman 是可以正常工作的。

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
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

import static jakarta.servlet.DispatcherType.ERROR;
import static jakarta.servlet.DispatcherType.FORWARD;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {

@Value("${jwt.public.key}")
RSAPublicKey publicKey;

@Value("${jwt.private.key}")
RSAPrivateKey privateKey;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
)
.exceptionHandling((exceptions) -> exceptions.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
);
return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}

@Bean
JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(this.publicKey).privateKey(this.privateKey).build();
return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
}

}

然后在方法上添加如下注解:

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
import jakarta.annotation.Resource;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
public class TestController {

@Resource
private AuthorRepository authorRepository;

@Resource
private UserServiceImpl userService;

@Secured("SCOPE_ROLE_USER")
@QueryMapping
List<Author> authors() {
return authorRepository.findAll();
}

@PreAuthorize("permitAll()")
@QueryMapping
String login(@Argument String username, @Argument String password) {
return userService.login(username, password);
}

}

参考资料

Introduction to Spring GraphQL with Spring Boot

Spring Fro GraphQL 官方文档

GraphQL 官方文档

官方 WebSocket 例程


Spring for GraphQL
https://wangqian0306.github.io/2023/graphql/
作者
WangQian
发布于
2023年5月22日
许可协议