https://nilshartmann.net / @nilshartmann
Freiberuflicher Software-Entwickler, Berater und Trainer aus Hamburg
Java | JavaScript, TypeScript | React | GraphQL
Was ist GraphQL?
Die Abfragesprache
APIs beschreiben
GraphQL APIs mit Spring for GraphQL implementieren
13:30 bis 15:00 Teil 1
15:00-15:30 Kaffeepause ☕️ 🍰
15:30 bis 17:00 Teil 2
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
query {
story {
id title writtenBy {
id user { name }
}
}
}
query {
story(storyId: 5) {
id title writtenBy {
id user { name }
}
}
}
Ausgeführt werden Operationen. Der Operation-Type beschreibt, was in der Anfrage getan werden soll
Der Operation-Type bestimmt auch den Einstiegspunkt in den Objekt-Graphen
query NewestStory {
story {
id
title
}
}
Mit einem Fragment beschreibst Du eine wiederverwendbare Menge von Feldern
fragment BaseMember on Member {
id joined
user { is username }
}
query {
story {
writtenBy { ...BaseMember }
reactions {
givenBy { ...BaseMember }
}
}
}
Ein Union-Type kann mehr als einen Typ zurückliefern:
mutation addComment
(input: { storyId: "1", content: "..." }) {
...on AddCommentSuccessPayload {
newComment { id }
}
...on AddCommentFailurePayload {
errorMessage
}
}
Ein Interface erzwingt gemeinsame Felder an den Objekten, die das Interface implementieren (ähnlich wie in Java mit Methoden)
id
und createdAt
sind am Node
-Interface definiert
query {
node(id: "...") {
id
createdAt
...on Story { title body }
...on Comment { content }
}
}
query ($storyId: ID!) {
story(id: $storyId) {
id
title body
}
}
Mach' dich mit der GraphQL-Abfragesprache vertraut
Docs
-Explorer (rechts oben) verwenden, um die API zu untersuchendata
: Die gelesenen Daten (Struktur darunter entspricht der Abfrage)errors
: Liste mit (technischen) Fehlern, u.a. Fehlermeldungextensions
: Freibelegbares Objekt für proprietäre Erweiterungen (z.B. Debug-Informationen)Beschreibung eines Objekt Types
type Story {
id: ID!
title: String!
body: String!
}
"""
A `Story` is the main object in our service.
"""
type Story {
"""Identifies this object"""
id: ID!
# todo: implement new tags-field (PROJ-666)
}
type Member {
id: ID!
username: String!
# no exclamation mark: can be null
likes: Int
amount: Float!
activeMember: Boolean
}
enum ReactionType {
like,
laugh,
thumbUp
}
type Member {
# ...
}
type Comment {
# ...
}
type Story {
"""Reference to the Member that has written this Story"""
writtenBy: Member!
comments: [Comment!]!
}
type Story {
# Mandatory argument maxLength, defaults to 20
# if not specified by the client
excerpt(maxLength: Int! = 20): String!
}
type Query {
"""Returns a List of all stories"""
stories: [Story!]!
"""Returns a story by its ID or null"""
story(id: ID!): Story
}
type Mutation {
addComment(storyId: ID!, memberId: ID!, content: String!): Comment!
}
input
definiert, sieht ansonsten aus wie ein Objekt-Type
input AddCommentInput {
storyId: ID!
memberId: ID!
content: String!
}
type Mutation {
addComment(input: AddCommentInput!): Comment!
}
type AddCommentSuccess { newComment: Comment! }
type AddCommentFailed { errorMessage: String! }
union AddCommentResult = AddCommentSuccess | AddCommentFailed
type Mutation {
addComment(input: AddCommentInput!): AddCommentResult!
}
interface Node {
id: ID!
}
type Story implements Node {
id: ID! # Field defined in Node-Interface
title: String! # additional Story fields
}
type Comment implements Node {
id: ID!
content: String!
}
type User {
id: ID!
email: String
}
type Query {
# Returns either Story or Comment, but not User
node(id: ID!): Node
}
/api/v1
, /api/v2
)deprecated
markieren, um anzuzeigen, dass sie nicht genutzt werden sollen
type Query {
# Ausgangspunkt
getStoryById(id: ID!): Story
}
type Query {
getStoryById(id: ID!): Story
# Neues Feld, beeinträchtigt bestehenden Client nicht
stories: [Story!]!
}
type Query {
getStoryById(id: ID!): Story @deprecated("Use story instead")
story(id: ID!): Story
stories: [Story!]!
}
.graphqls
abgelegtmain/resources/graphql
# story.graphqls
extend type Query {
story(id: ID!): Story
}
# comment.graphqls
extend type Query {
comment(id: ID): Comment
}
resources/graphql/publy.graphqls
und beschreibe darin eine API, die folgende Objekt-Typen enthält:id
und profileImage
id
, title
und body
und einer Referenz (writtenBy
) auf den Memberid
-Feld ist vom Typ ID
alle anderen Stringsstories
: Liefert eine Liste der Story
-Objekte zurück. Nicht nullable.story
: Liefert eine einzelne Story zurück. Das Feld soll ein Argument haben: storyId
. Das Feld kann null
zurückgebengraphqls
-Datei gemacht hast, musst Du die Anwendung neu bauen, und in GraphiQL
die Seite im Browser neu laden.stories
bzw story
-Query ausführst. 1
steps/01-schema
DataFetcher
zum Ermitteln der Daten (später mehr)errors
-Feld in der Antwort) data
-Feld bereitsgestelltZentrales Interface in GraphQL-Java: Ermitteln die Daten für ein Feld eines Queries
Query.story
oder Story.title
Resolver
genanntinterface DataFetcher<T> {
T get(DataFetchingEnvironment environment);
}
Beispiel:
public class PublyDataFetchers {
public DataFetcher<String> pingFetcher = new DataFetcher() {
@Override
public String get(DataFetchingEnvironment env) {
String msg = env.getArgumentOrDefault("msg", "Pong!");
return msg;
}
};
}
Jeder DataFetcher wird im RuntimeWiring einem Feld der API zugewiesen
Spring for GraphQL bietet eine Abstraktion von GraphQL-Java an
Erstes Release von Spring GraphQL enthalten in Spring Boot 2.7 (Mai 2022)
Features:
spring-boot-starter-graphql
für Spring BootAnstatt DataFetcher
werden Handler-Funktionen an Controller
-Klassen implementiert
@RequestMapping
@Controller
public GraphQLController {
@QueryMapping
public String ping() { return "Pong!" }
}
@QueryMapping
annotiert@MutationMapping
, Subscription: @SubscriptionMapping
value
setzen)@Argument
um ein einzelnes Argument zu erhalten
type Query {
ping(msg: String): String!
}
@QueryMapping
public String ping(@Argument String msg) {
return "Hello " + msg;
}
@Arguments
, um Parameter in einer Java-Klasse zusammenzufassen
type Query {
greet(name: String, msg: String): String!
}
class GreetingParams { private String name; private String msg; /* getter+setter... */ }
@QueryMapping
public String greet(@Arguments GreetingParams params) {
return "Hello " + params.getName();
}
@ProjectedPayload
um einzelne Parameter in einem Interface zusammenzufassen
type Query {
greet(name: String, msg: String): String!
}
@ProjectedPayload
interface GreetingParams { String getName(); String getMsg(); }
@QueryMapping
public String greet(@Argument GreetingParams params) {
return "Hello " + params.getName();
}
input GreetingInput { name: String, msg: String }
type Query {
greet(input: GreetingInput!): String!
}
class GreetingInput { private String name; private String msg; /* getter+setter... */ }
// oder JDK 17 Records:
record GreetingInput(String name, String msg) {}
@QueryMapping
public String greet(@Argument GreetingInput input) {
return "Hello " + input.getName();
}
class GreetingInput { @Size(min=5) private String name; private String msg; /* getter+setter... */ }
@QueryMapping
public String greet(@Valid @Argument GreetingInput input) {
return "Hello " + input.getName();
}
Implementiere zwei Handler-Funktionen
Query.stories
und für Query.story(id: ID!)
nh.publy.backend.graphql.PublyGraphQLController
implementieren.StoryRepository
verwenden.
query {
stories {
id title body
writtenBy {
id profileImage
}
}
}
query {
story(storyId: 1) {
id title body
writtenBy {
id profileImage
}
}
}
steps/10_handler_function
body
- und writtenBy
-Feld an der Story
-EntityDie Funktion wird dann mit @SchemaMapping
annotiert
Source
-Objekt, auf dem das Feld ermittelt werden soll@Controller
class GraphQLController {
@SchemaMapping
public String excerpt(Story source, @Argument int maxLength) {
return source.getBody().substring(0, maxLength);
}
}
Wenn die als Parameter übergebene Klasse nicht so heißt, wie im GraphQL Schema,
kann mit typename
der Typ explizit gesetzt werden
@SchemaMapping(typeName="Story")
public String excerpt(StoryDto source) {
// ...
}
@QueryMapping
und @MutationMapping
sind nur Aliase für
@SchemaMapping(typeName="Query")
bzw.@SchemaMapping(typeName="Mutation")
CompletableFuture
, Flux
oder Mono
-Objekte zurückliefern @Service
public class DomainService {
@Async
CompletableFuture<String> determineExcerpt(Story story, int maxLenght) {
// long running task...
}
}
@Controller
public class GraphQLController {
@Autowired DomainService domainService;
@SchemaMapping
public CompletableFuture<String> excerpt(Story story, @Argument int maxLength) {
return domainService.determineExcerpt(story, maxLength); // asynchron bzw. reaktiv
}
}
Wir erweitern den Member-Typen um das User-Objekt:
type User {
id: ID!
name: String!
email: String!
}
type Member {
# ...
user: User
}
Die Member
-Klasse hat aber keine Referenz auf den User (nur dessen Id)
@Entity
public class Member {
// ...
@NotNull
private String userId;
public String getUserId() { return this.userId };
}
Der User kommt aus dem UserService
(externer Micro-Service)
User
, Felder: id
(ID), name
(String) und email
(String). Alle non-nullable. Member
-Typen um das Feld user
(Type: User
, nullable).user
zurück und warum? UserService
ist bereits implementiert
query {
story(storyId: 1) {
id
writtenBy {
id
# Hier sollten nun Daten kommen:
user {
id name email
}
}
}
}
steps/15_schema-mapping
query {
stories {
writtenBy { user { id email } }
}
}
Ein DataLoader
"verzögert" das Laden von Daten
DataLoader
"verzögert" das Laden von Datenwhere ID in ...
)Key
: Typ des Keys, den Du beim Verwenden an den DataLoader übergeben willstValue
: Typ des Java-Objekts, das der DataLoader für einen Key zurückliefertpublic CompletableFuture<User> user(Member member, DataLoader<String, User> userLoader) {
String userId = member.getUserId(); // UserId ist in DB gespeichert
return userLoader.load(userId); // Laden des Users wird verzögert
}
BatchLoaderRegistry
gibt es HilfsfunktionenBatchLoaderRegistry
steht als Spring Bean zur VerfügungforTypePair
registrierst Du einen BatchLoader für ein Key-Value-PaarBatchLoaderEnvironment
public PublyGraphQLController(/* ... */, BatchLoaderRegistry registry) {
registry.forTypePair(String.class, User.class).registerBatchLoader(
(List<String> keys, BatchLoaderEnvironment env) -> {
// findUsers liefert Flux<User> zurück.
return userService.findUsers(keys);
}
);
}
null
zurückgegeben werdenMappedBatchLoader
verwendet werden, der ein Mono-Objekt mit einer Map zurückliefertpublic PublyGraphQLController(/*... */, BatchLoaderRegistry registry) {
registry.forTypePair(String.class, User.class).registerMappedBatchLoader(
(List<String> keys, BatchLoaderEnvironment env) -> {
// zurückgeben: Mono<Map<String, User>>
}
);
}
equals
und hashCode
-Methoden in den Objekten, die als Keys verwendet werden, korrekt implementiert sind!@Controller
public class PublyGraphQLController {
@BatchMapping
public Flux<User> user(List<Member> member) {
List<String> keys = member.stream().map(Member::getUserId).collect(Collectors.toList());
return userService.findUsers(keys);
}
// -- oder: --
@BatchMapping
public Mono<Map<Member, User>> user(List<Member> member) {
// ...
}
// BatchLoaderRegistry und SchemaMapping für User-Feld entfallen jetzt
}
DataFetchingFieldSelectionSet
enthält eine Liste aller Felder, die im aktuellen Query abgefragt sindDataFetchingFieldSelectionSet
kannst Du an deine Handler-Funktionen übergeben lassen.@QueryMapping List<Story> stories(DataFetchingFieldSelectionSet selectionSet) {
if (s.contains("comments") ) {
// SQL Statement mit JOIN auf Comment-Tabelle
return ...;
}
// SQL Statement nur mit Stories
return ...;
}
Wenn ihr noch Fragen habt, könnt ihr mich erreichen:
Twitter: @nilshartmann