Query
はデータ取得のための型でquery
操作に対応するMutation
はデータ更新のための型でmutation
操作に対応するSubscription
はイベントをサブスクライブするための型でsubscription
操作に対応するschema
キーワードでカスタマイズできるschema {
query: MyQuery
}
型定義
type Mutation {
newArticle(title: String, content: String, categoryId: ID): Article
}
mutation
操作
mutation {
newArticle(title: "...", content: "...", categoryId: "1") {
id
}
}
型定義
type Subscription {
updatedArticle: Article
}
subscription
操作
subscription {
updatedArticle {
title
}
}
クエリ
query {
article(id: "1") {
title
category {
name
}
}
}
結果(JSON)
{
"data": {
"article": {
"title": "Spring GraphQL introduction",
"category": {
"name": "Spring"
}
}
}
}
GraphQLは1回のリクエストでルートとなるデータから関連を辿ってデータを取得して返す
また、1回のリクエストでクエリを複数個送信することも可能
query {
article1: article(id: "3") { title }
article2: article(id: "4") { title }
}
GraphQL
REST
クライアントは必要となるフィールドを明確に指定してクエリを組み立てるため、型にフィールドが追加されても影響が無い
フィールド削除のために、それを予告する@deprecated
ディレクティブが用意されている
type Article {
id: ID
title: String
body: String @deprecated(reason: "代わりに`content`を使うこと")
content: String
category: Category
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<!-- もしくは spring-boot-starter-webflux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
※Spring Boot 2.7.0-M1からSpring Bootリポジトリへ移動したためバージョンを指定しなくてよくなった
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
<!-- WebTestClient のため必要になるっぽい -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<scope>test</scope>
</dependency>
graphql
ディレクトリ内の次の拡張子のファイルが読み込み対象となる
.graphqls
.gqls
src/main/resources/graphql/schema.gqls
にでも書いておけばOKGraphiQLというGraphQLのGUIクライアントを有効化する
spring.graphql.graphiql.enabled=true
※なお、デフォルトで有効化されているので実際には明示的に設定する必要はない
やることは次の通り。
@Controller
を付与したコンポーネントを用意する(Spring Web MVCのコントローラーと同じ)@QueryMapping
/@MutationMapping
/@SubscriptionMapping
/@SchemaMapping
を付与したメソッドでデータのフェッチ方法を定義する@Controller
public class BlogController {
@QueryMapping
public Optional<Article> article(@Argument Integer id) {
return articleRepository.findById(id);
}
@SchemaMapping
public Category category(Article article) {
return article.getCategory();
}
}
type Article {
id: ID
title: String
content: String
category: Category
}
type Category {
id: ID
name: String
}
type Query {
article(id: ID): Article
}
T
java.util.Optional<T>
java.lang.Iterable<T>
, java.util.stream.Stream<T>
, java.util.Iterator<T>
, 配列reactor.core.publisher.Mono<T>
, reactor.core.publisher.Flux<T>
graphql.execution.DataFetcherResult<T>
java.util.concurrent.CompletionStage<T>
@AutoConfigureGraphQlTester
を付けるGraphQlTester
をインジェクションするGraphQlTester
を使ってテストをする@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureGraphQlTester
public class BlogTest {
@Autowired
private GraphQlTester graphQlTester;
@Test
void article() {
// GraphQlTesterを使ったテストコードをここに書く
}
}
GraphQlTester
へ渡すクエリを構築する
String query = "{" +
" article(id: 1) {" +
" title" +
" category {" +
" name" +
" }" +
" }" +
"}";
最近のJavaならテキストブロックで書けてありがたい
String query = """
{
article(id: 1) {
title
category {
name
}
}
}
""";
graphQlTester.query(query)
.execute()
.path("article.title")
.entity(String.class)
.isEqualTo("Spring GraphQL introduction")
.path("article.category.name")
.entity(String.class)
.isEqualTo("GraphQL");
※なおpath
メソッドに渡すパスにはJsonPathが使える
select * from article
でarticle
を複数件取得、取得したarticle
をループしながらselect * from category where id = ?
でcategory
を取得する
article
の件数分のクエリが発行されてしまうselect * from category where id = ?
をN回行っていたところを、select * from category where id in (...)
を1回行うようにする@BatchMapping
を付与したメソッドを定義するorg.dataloader.BatchLoader
、org.dataloader.MappedBatchLoader
へ対応)@BatchMapping
public List<Author> author(List<Comic> sources) {
List<Integer> ids = sources.stream().map(Comic::getAuthorId).toList();
Map<Integer, Author> authors = authorRepository.findByIds(ids).stream()
.collect(Collectors.toMap(Author::getId, Function.identity()));
return ids.stream().map(authors::get).toList();
}
詳しくはSpring GraphQL 1.0.0-M3でDataLoaderもアノテーションマッピングできるようになったを参照
org.dataloader.BatchLoader
を実装したクラスを作るorg.springframework.graphql.web.WebInterceptor
を実装したクラスを用意してintercept
メソッドでリクエストオブジェクトへDataLoaderを登録するenv
からDataLoaderを取り出し、load
メソッドを使用する@Component
public class AuthorLoader implements BatchLoader<Integer, Author> {
private final AuthorRepository authorRepository;
@Override
public CompletionStage<List<Author>> load(List<Integer> keys) {
List<Author> authors = authorRepository.findAllById(keys);
return CompletableFuture.completedStage(authors);
}
}
@Component
public class AuthorLoaderRegistration implements WebInterceptor {
private final AuthorLoader authorLoader;
@Override
public Mono<WebOutput> intercept(WebInput webInput, WebInterceptorChain chain) {
webInput.configureExecutionInput((input, builder) -> {
DataLoaderRegistry registry = new DataLoaderRegistry();
DataLoader<Integer, Author> dataLoader = DataLoaderFactory.newDataLoader(authorLoader);
registry.register("authorLoader", dataLoader);
return builder.dataLoaderRegistry(registry).build();
});
return chain.next(webInput);
}
}
@Controller
public class ComicController {
@SchemaMapping
public CompletableFuture<Author> author(Comic source, DataFetchingEnvironment env) {
DataLoader<Integer, Author> authorLoader = env.getDataLoader("authorLoader");
return authorLoader.load(source.getAuthorId());
}
}
DataLoaderOptions
のmaxBatchSize
でバッチサイズを設定できる
BatchLoader
の他にMappedBatchLoader
がある
public interface BatchLoader<K, V> {
CompletionStage<List<V>> load(List<K> keys);
}
public interface MappedBatchLoader<K, V> {
CompletionStage<Map<K, V>> load(Set<K> keys);
}
Relay is a JavaScript framework for building data-driven React applications.
Connection
と付いた型を定義するedges
とpageInfo
edges
はリストで各要素は返したい型とカーソルのペアpageInfo
は返されたページの情報String
またはString
にシリアライズされるカスタムscalar
type ExampleConnection {
edges: [ExampleEdge!]!
pageInfo: PageInfo!
}
type ExampleEdge {
node: Example!
cursor: String!
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
# 前方のカーソル
exampleForward(first: Int! = 10, after: String): ExampleConnection!
# 後方のカーソル
exampleBackward(last: Int! = 10, before: String): ExampleConnection!
}
3, 4, 5...
、後方は5, 4, 3...
とするのはダメということquery {
exampleForward(first: 10, after: "...") {
edges {
node { field1 field2 field3 }
cursor
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
}
}
graphql.relay.Connection<T>
graphql.relay.Edge<T>
graphql.relay.ConnectionCursor
graphql.relay.PageInfo
@QueryMapping
public Connection<Example> exampleForward(
@Argument int first, @Argument String after) {
List<Edge<Example>> edges = ...
PageInfo pageInfo = ...
return new DefaultConnection<>(edges, pageInfo);
}
List<Example> examples = ...
List<Edge<Example>> edges = examples.stream().map(example -> {
ConnectionCursor cursor = new DefaultConnectionCursor(example.getId());
return new DefaultEdge<>(example, cursor);
}).collect(Collectors.toList());
ConnectionCursor startCursor = new DefaultConnectionCursor("...");
ConnectionCursor endCursor = new DefaultConnectionCursor("...");
boolean hasPreviousPage = ...
boolean hasNextPage = ...
PageInfo pageInfo = new DefaultPageInfo(
startCursor, endCursor, hasPreviousPage, hasNextPage);
GraphQLの仕様にはエラーの表現も含まれている
{
"errors": [
{
"message": "...",
"location": [{"line": 1, "column": 2}],
"path": ["path", "to", "field"]
}
]
}
コントローラーから例外をスローするとエラーに変換してくれる
@QueryMapping
public Object hello() {
throw new Exception("Exception occurred while processing hello");
}
{
"errors": [{
"message": "Exception occurred while processing Foo.bar",
"location": [{"line": 1, "column": 2}],
"path": ["hello"]
}]
}
例外をハンドリングする場合はDataFetcherExceptionResolver
を実装する。DataFetcherExceptionResolverAdapter
を利用するのが少しだけ簡単。
@Component
public class MyExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
public GraphQLError resolveException(
Throwable e, DataFetchingEnvironment env) {
if(e instanceof MyException) {
GraphQLError error = GraphqlErrorBuilder.newError(env).message("...").build();
return error;
}
return null;
}
}
ScalarとEnumの実装方法を簡単に説明
scalar URI
@Component
public class ScalarDataWiring implements RuntimeWiringConfigurer {
@Override
public void configure(RuntimeWiring.Builder paramBuilder) {
Coercing<?, ?> coercing = new URICoercing();
GraphQLScalarType scalarType = GraphQLScalarType.newScalar()
.name("URI").coercing(coercing).build();
paramBuilder.scalar(scalarType);
}
}
public class URICoercing implements Coercing<URI, String> {
@Override
public String serialize(Object dataFetcherResult) throws CoercingSerializeException {
return dataFetcherResult.toString();
}
@Override
public URI parseValue(Object input) throws CoercingParseValueException {
return URI.create((String) input);
}
@Override
public URI parseLiteral(Object input) throws CoercingParseLiteralException {
return URI.create(((StringValue) input).getValue());
}
}
enum Visibility {
PUBLIC
PRIVATE
}
public enum Visibility {
PUBLIC,
PRIVATE;
}
@Component
public class EnumDataWiring implements RuntimeWiringConfigurer {
@Override
public void configure(RuntimeWiring.Builder paramBuilder) {
EnumValuesProvider enumValuesProvider =
new NaturalEnumValuesProvider<>(Visibility.class);
paramBuilder.type("Visibility", b -> b.enumValues(enumValuesProvider));
}
}
例えば
など
そういった場合はRestControllerを書けば良い(Spring Web MVC/Spring WebFluxが土台になっている強み)
GraphQL Javaから呼び出されるコントローラーのメソッドはSpring Web MVCのリクエストをハンドリングするスレッドと同じスレッドで実行されるとは限らない
そのためスレッドローカルで持ち回すような値はorg.springframework.graphql.execution.ThreadLocalAccessor
を実装して伝搬させてあげる必要がある
詳しくはSpring GraphQLリファレンスのContext Propagationセクションを参照
@PreAuthorize
や@Secured
を付けて保護する方法が記載されているspring-boot-starter-actuator
がクラスパス上に存在するとGraphQLリクエストのメトリクスが収集される
ここまでのデモで収集されたメトリクスを見てみる(というデモ)
Querydslのリポジトリからデータフェッチ定義が簡単に作れるらしい(まだ試していない)
詳しくはSpring GraphQLリファレンスのData Integrationセクションを参照
DataLoader
登録方法の改善など