GraphQL ist eine von Facebook entwickelte API-Abfragesprache. In diesem Artikel wird eine beispielhafte Implementierung der GraphQL-API in der JVM erläutert, insbesondere unter Verwendung der Kotlin-Sprache und des Micronaut- Frameworks . Die meisten Beispiele können auf anderen Java / Kotlin-Frameworks wiederverwendet werden. Anschließend wird gezeigt, wie mehrere GraphQL-Dienste in einem einzigen Datengraphen kombiniert werden, um eine gemeinsame Schnittstelle für den Zugriff auf alle Datenquellen bereitzustellen. Dies wird mit Apollo Server und Apollo Federation implementiert . Als Ergebnis wird die folgende Architektur erhalten:

Jede Komponente der Architektur hebt verschiedene Probleme hervor, die während der Entwicklung der GraphQL-API auftreten können. Das Domänenmodell enthält Daten zu den Planeten des Sonnensystems und ihren Satelliten.
:
Planet service
, GraphQL, :
implementation("io.micronaut.graphql:micronaut-graphql:$micronautGraphQLVersion")
implementation("io.gqljf:graphql-java-federation:$graphqlJavaFederationVersion")
( )
GraphQL Java Micronaut, , , , GraphQL . Spring and Micronaut; GET POST /graphql
. — , GraphQL Java Apollo Federation.
GraphQL Schema Definition Language (SDL) :
type Query {
planets: [Planet!]!
planet(id: ID!): Planet
planetByName(name: String!): Planet
}
type Mutation {
createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
}
type Subscription {
latestPlanet: Planet!
}
type Planet @key(fields: "id") {
id: ID!
name: String!
# from an astronomical point of view
type: Type!
isRotatingAroundSun: Boolean! @deprecated(reason: "Now it is not in doubt. Do not use this field")
details: Details!
}
interface Details {
meanRadius: Float!
mass: BigDecimal!
}
type InhabitedPlanetDetails implements Details {
meanRadius: Float!
mass: BigDecimal!
# in billions
population: Float!
}
type UninhabitedPlanetDetails implements Details {
meanRadius: Float!
mass: BigDecimal!
}
enum Type {
TERRESTRIAL_PLANET
GAS_GIANT
ICE_GIANT
DWARF_PLANET
}
input DetailsInput {
meanRadius: Float!
mass: MassInput!
population: Float
}
input MassInput {
number: Float!
tenPower: Int!
}
scalar BigDecimal
Planet service ( )
Planet.id
ID
, 5- . GraphQL Java . , null, ( Kotlin GraphQL nullable ). @directive
’ . . IntelliJ IDEA, JS GraphQL plugin .
GraphQL API:
schema-first
(, , API),
code-first
; . schema-first . .
Micronaut , GraphQL IDE — GraphiQL — GraphQL :
graphql:
graphiql:
enabled: true
GraphiQL ( )
Main :
object PlanetServiceApplication {
@JvmStatic
fun main(args: Array<String>) {
Micronaut.build()
.packages("io.graphqlfederation.planetservice")
.mainClass(PlanetServiceApplication.javaClass)
.start()
}
}
Main ( )
GraphQL :
@Bean
@Singleton
fun graphQL(resourceResolver: ResourceResolver): GraphQL {
val schemaInputStream = resourceResolver.getResourceAsStream("classpath:schema.graphqls").get()
val transformedGraphQLSchema = FederatedSchemaBuilder()
.schemaInputStream(schemaInputStream)
.runtimeWiring(createRuntimeWiring())
.excludeSubscriptionsFromApolloSdl(true)
.build()
return GraphQL.newGraphQL(transformedGraphQLSchema)
.instrumentation(
ChainedInstrumentation(
listOf(
FederatedTracingInstrumentation()
)
)
)
.build()
}
GraphQL ( )
FederatedSchemaBuilder
Apollo Federation. GraphQL Java , (. ).
RuntimeWiring
— data fetcher’, type resolver’ , GraphQLSchema
; :
private fun createRuntimeWiring(): RuntimeWiring {
val detailsTypeResolver = TypeResolver { env ->
when (val details = env.getObject() as DetailsDto) {
is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
}
}
return RuntimeWiring.newRuntimeWiring()
.type("Query") { builder ->
builder
.dataFetcher("planets", planetsDataFetcher)
.dataFetcher("planet", planetDataFetcher)
.dataFetcher("planetByName", planetByNameDataFetcher)
}
.type("Mutation") { builder ->
builder.dataFetcher("createPlanet", createPlanetDataFetcher)
}
.type("Subscription") { builder ->
builder.dataFetcher("latestPlanet", latestPlanetDataFetcher)
}
.type("Planet") { builder ->
builder.dataFetcher("details", detailsDataFetcher)
}
.type("Details") { builder ->
builder.typeResolver(detailsTypeResolver)
}
.type("Type") { builder ->
builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
}
.build()
}
RuntimeWiring
( )
root- Query
( root- Mutation
Subscription
), , planets
, , DataFetcher
:
@Singleton
class PlanetsDataFetcher(
private val planetService: PlanetService,
private val planetConverter: PlanetConverter
) : DataFetcher<List<PlanetDto>> {
override fun get(env: DataFetchingEnvironment): List<PlanetDto> = planetService.getAll()
.map { planetConverter.toDto(it) }
}
PlanetsDataFetcher
( )
env
. DTO, :
@Singleton
class PlanetConverter : GenericConverter<Planet, PlanetDto> {
override fun toDto(entity: Planet): PlanetDto {
val details = DetailsDto(id = entity.detailsId)
return PlanetDto(
id = entity.id,
name = entity.name,
type = entity.type,
details = details
)
}
}
PlanetConverter
( )
GenericConverter
— Entity → DTO
. , details
— , , API. , details
id
. , RuntimeWiring
details
Planet
DataFetcher
, ( details.id
, ):
@Singleton
class DetailsDataFetcher : DataFetcher<CompletableFuture<DetailsDto>> {
private val log = LoggerFactory.getLogger(this.javaClass)
override fun get(env: DataFetchingEnvironment): CompletableFuture<DetailsDto> {
val planetDto = env.getSource<PlanetDto>()
log.info("Resolve `details` field for planet: ${planetDto.name}")
val dataLoader: DataLoader<Long, DetailsDto> = env.getDataLoader("details")
return dataLoader.load(planetDto.details.id)
}
}
DetailsDataFetcher
( )
, CompletableFuture
. Details
DetailsService
, , N+1: , :
{
planets {
name
details {
meanRadius
}
}
}
GraphQL
details
SQL . java-dataloader; BatchLoader
DataLoaderRegistry
:
@Bean
@Singleton
fun detailsBatchLoader(): BatchLoader<Long, DetailsDto> = BatchLoader { keys ->
CompletableFuture.supplyAsync {
detailsService.getByIds(keys)
.map { detailsConverter.toDto(it) }
}
}
@Bean
fun dataLoaderRegistry() = DataLoaderRegistry().apply {
val detailsDataLoader = DataLoader.newDataLoader(detailsBatchLoader())
register("details", detailsDataLoader)
}
BatchLoader
DataLoaderRegistry
( )
BatchLoader
Details
. , SQL N+1. , GraphQL , SQL . BatchLoader
stateless , . DataLoader
BatchLoader
; stateful, , DataLoaderRegistry
. - GraphQL , . GraphQL Java.
Details
GraphQL , RuntimeWiring
TypeResolver
, GraphQL DTO :
val detailsTypeResolver = TypeResolver { env ->
when (val details = env.getObject() as DetailsDto) {
is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
}
}
TypeResolver
( )
enum’ :
.type("Type") { builder ->
builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
}
enum ( )
http://localhost:8082/graphiql
GraphiQL IDE, , ; IDE 3 : (query/mutation/subscription), :

GraphQL IDE, , GraphQL Playground Altair ( desktop , web-). :

query, , : _service
_entities
. , GraphQL Java Apollo Federation; .
Planet
, :

type
, @deprecated
isRotatingAroundSun
.
:
type Mutation {
createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
}
( )
query, . , , input
type
:
input DetailsInput {
meanRadius: Float!
mass: MassInput!
population: Float
}
input MassInput {
number: Float!
tenPower: Int!
}
Input
Query, DataFetcher
:
@Singleton
class CreatePlanetDataFetcher(
private val objectMapper: ObjectMapper,
private val planetService: PlanetService,
private val planetConverter: PlanetConverter
) : DataFetcher<PlanetDto> {
private val log = LoggerFactory.getLogger(this.javaClass)
override fun get(env: DataFetchingEnvironment): PlanetDto {
log.info("Trying to create planet")
val name = env.getArgument<String>("name")
val type = env.getArgument<Planet.Type>("type")
val detailsInputDto = objectMapper.convertValue(env.getArgument("details"), DetailsInputDto::class.java)
val newPlanet = planetService.create(
name,
type,
detailsInputDto.meanRadius,
detailsInputDto.mass.number,
detailsInputDto.mass.tenPower,
detailsInputDto.population
)
return planetConverter.toDto(newPlanet)
}
}
DataFetcher
( )
, - . subscription:
type Subscription {
latestPlanet: Planet!
}
Subscription ( )
DataFetcher
subscription Publisher
:
@Singleton
class LatestPlanetDataFetcher(
private val planetService: PlanetService,
private val planetConverter: PlanetConverter
) : DataFetcher<Publisher<PlanetDto>> {
override fun get(environment: DataFetchingEnvironment) = planetService.getLatestPlanet().map { planetConverter.toDto(it) }
}
DataFetcher
subscription ( )
mutation subscription GraphQL IDE IDE; ( IDE subscription URL ws://localhost:8082/graphql-ws
):
subscription {
latestPlanet {
name
type
}
}
subscription
:
mutation {
createPlanet(
name: "Pluto"
type: DWARF_PLANET
details: { meanRadius: 50.0, mass: { number: 0.0146, tenPower: 24 } }
) {
id
}
}
mutation
:

Subscription’ Micronaut :
graphql:
graphql-ws:
enabled: true
GraphQL WebSocket ( )
subscription’ Micronaut — chat application. GraphQL Java.
query mutation :
@Test
fun testPlanets() {
val query = """
{
planets {
id
name
type
details {
meanRadius
mass
... on InhabitedPlanetDetails {
population
}
}
}
}
""".trimIndent()
val response = graphQLClient.sendRequest(query, object : TypeReference<List<PlanetDto>>() {})
assertThat(response, hasSize(8))
assertThat(
response, contains(
hasProperty("name", `is`("Mercury")),
hasProperty("name", `is`("Venus")),
hasProperty("name", `is`("Earth")),
hasProperty("name", `is`("Mars")),
hasProperty("name", `is`("Jupiter")),
hasProperty("name", `is`("Saturn")),
hasProperty("name", `is`("Uranus")),
hasProperty("name", `is`("Neptune"))
)
)
}
query ( )
query query, :
private val planetFragment = """
fragment planetFragment on Planet {
id
name
type
details {
meanRadius
mass
... on InhabitedPlanetDetails {
population
}
}
}
""".trimIndent()
@Test
fun testPlanetById() {
val earthId = 3
val query = """
{
planet(id: $earthId) {
... planetFragment
}
}
$planetFragment
""".trimIndent()
val response = graphQLClient.sendRequest(query, object : TypeReference<PlanetDto>() {})
}
Query ( )
variables, :
@Test
fun testPlanetByName() {
val variables = mapOf("name" to "Earth")
val query = """
query testPlanetByName(${'$'}name: String!){
planetByName(name: ${'$'}name) {
... planetFragment
}
}
$planetFragment
""".trimIndent()
val response = graphQLClient.sendRequest(query, variables, null, object : TypeReference<PlanetDto>() {})
}
Query ( )
, . . Kotlin raw strings, string templates, , $
( GraphQL) ${'$'}
.
GraphQLClient
— (framework-agnostic OkHttp). Java GraphQL , , Apollo GraphQL Client for Android and the JVM, .
in-memory H2 ORM Hibernate, micronaut-data-hibernate-jpa
. .
Auth service
GraphQL . JWT. Auth service JWT query mutation:
type Query {
validateToken(token: String!): Boolean!
}
type Mutation {
signIn(data: SignInData!): SignInResponse!
}
input SignInData {
username: String!
password: String!
}
type SignInResponse {
username: String!
token: String!
}
Auth service ( )
JWT, GraphQL IDE (Auth service URL http://localhost:8081/graphql
):
mutation {
signIn(data: {username: "john_doe", password: "password"}) {
token
}
}
JWT
Authorization
( Altair GraphQL Playground IDE) ; . Bearer $JWT
.
JWT micronaut-security-jwt
.
Satellite service
:
type Query {
satellites: [Satellite!]!
satellite(id: ID!): Satellite
satelliteByName(name: String!): Satellite
}
type Satellite {
id: ID!
name: String!
lifeExists: LifeExists!
firstSpacecraftLandingDate: Date
}
type Planet @key(fields: "id") @extends {
id: ID! @external
satellites: [Satellite!]!
}
enum LifeExists {
YES,
OPEN_QUESTION,
NO_DATA
}
scalar Date
Satellite service ( )
, Satellite
lifeExists
. , , GraphQL query/mutation/subscription , . . GraphQL /graphql
. , — GraphQL-specific , ( ):
micronaut:
security:
enabled: true
intercept-url-map:
- pattern: /graphql
httpMethod: POST
access:
- isAnonymous()
- pattern: /graphiql
httpMethod: GET
access:
- isAnonymous()
Security ( )
DataFetcher
, :
@Singleton
class LifeExistsDataFetcher(
private val satelliteService: SatelliteService
) : DataFetcher<Satellite.LifeExists> {
override fun get(env: DataFetchingEnvironment): Satellite.LifeExists {
val id = env.getSource<SatelliteDto>().id
return satelliteService.getLifeExists(id)
}
}
LifeExistsDataFetcher
( )
:
@Singleton
class SatelliteService(
private val repository: SatelliteRepository,
private val securityService: SecurityService
) {
fun getLifeExists(id: Long): Satellite.LifeExists {
val userIsAuthenticated = securityService.isAuthenticated
if (userIsAuthenticated) {
return repository.findById(id)
.orElseThrow { RuntimeException("Can't find satellite by id=$id") }
.lifeExists
} else {
throw RuntimeException("`lifeExists` property can only be accessed by authenticated users")
}
}
}
SatelliteService
( )
Authorization
JWT (. ):
{
satellite(id: "1") {
name
lifeExists
}
}
. ( Base64 ):
micronaut:
security:
token:
jwt:
enabled: true
signatures:
secret:
validation:
base64: true
# In real life, the secret should NOT be under source control (instead of it, for example, in environment variable).
# It is here just for simplicity.
secret: 'TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdA=='
jws-algorithm: HS256
JWT ( )
. JWT ( validateToken
, ).
Date
, DateTime
GraphQL Java graphql-java-extended-scalars (com.graphql-java:graphql-java-extended-scalars:$graphqlJavaExtendedScalarsVersion
-). (scalar Date
) :
private fun createRuntimeWiring(): RuntimeWiring = RuntimeWiring.newRuntimeWiring()
.scalar(ExtendedScalars.Date)
.build()
( )
:
{
satelliteByName(name: "Moon") {
firstSpacecraftLandingDate
}
}
Request
{
"data": {
"satelliteByName": {
"firstSpacecraftLandingDate": "1959-09-13"
}
}
}
Response
GraphQL API (. ). , :
{
planet(id: "1") {
star {
planets {
star {
planets {
star {
... # more deep nesting!
}
}
}
}
}
}
}
“” query
, MaxQueryDepthInstrumentation
. query MaxQueryComplexityInstrumentation
; FieldComplexityCalculator
, . ( FieldComplexityCalculator
, , — , 1):
return GraphQL.newGraphQL(transformedGraphQLSchema)
.instrumentation(
ChainedInstrumentation(
listOf(
FederatedTracingInstrumentation(),
MaxQueryComplexityInstrumentation(50, FieldComplexityCalculator { env, child ->
1 + child
}),
MaxQueryDepthInstrumentation(5)
)
)
)
.build()
( )
, MaxQueryDepthInstrumentation
/ MaxQueryComplexityInstrumentation
, IDE, . . IDE IntrospectionQuery
, ( GitHub). FederatedTracingInstrumentation
, Apollo Gateway ( Apollo Graph Manager; , ). GraphQL Java.
GraphQL . -, , Micronaut:
@Singleton
@Primary
class HeaderValueProviderGraphQLExecutionInputCustomizer : DefaultGraphQLExecutionInputCustomizer() {
override fun customize(executionInput: ExecutionInput, httpRequest: HttpRequest<*>): Publisher<ExecutionInput> {
val context = HTTPRequestHeaders { headerName ->
httpRequest.headers[headerName]
}
return Publishers.just(executionInput.transform {
it.context(context)
})
}
}
GraphQLExecutionInputCustomizer
( )
FederatedTracingInstrumentation
, , , Apollo Server , .
:
@Singleton
class CustomDataFetcherExceptionHandler : SimpleDataFetcherExceptionHandler() {
private val log = LoggerFactory.getLogger(this.javaClass)
override fun onException(handlerParameters: DataFetcherExceptionHandlerParameters): DataFetcherExceptionHandlerResult {
val exception = handlerParameters.exception
log.error("Exception while GraphQL data fetching", exception)
val error = object : GraphQLError {
override fun getMessage(): String = "There was an error: ${exception.message}"
override fun getErrorType(): ErrorType? = null
override fun getLocations(): MutableList<SourceLocation>? = null
}
return DataFetcherExceptionHandlerResult.newResult().error(error).build()
}
}
( )
— GraphQL (Planet
) ( ) Apollo Server. Planet
Planet service :
type Planet @key(fields: "id") {
id: ID!
name: String!
# from an astronomical point of view
type: Type!
isRotatingAroundSun: Boolean! @deprecated(reason: "Now it is not in doubt. Do not use this field")
details: Details!
}
Planet
Planet service ( )
Satellite service Planet
satellites
(, , non-nullable non-nullable ):
type Satellite {
id: ID!
name: String!
lifeExists: LifeExists!
firstSpacecraftLandingDate: Date
}
type Planet @key(fields: "id") @extends {
id: ID! @external
satellites: [Satellite!]!
}
Planet
Satellite service ( )
Apollo Federation Planet
— entity — , ( Satellite service, stub Planet
). @key
, , . @extends
, Planet
— , ( Planet service). Apollo Federation Apollo.
Apollo Federation; GraphQL Java, :
API; GitHub.
, Planet
FederatedEntityResolver
( , , Planet.satellites
); FederatedSchemaBuilder
:
@Bean
@Singleton
fun graphQL(resourceResolver: ResourceResolver): GraphQL {
val planetEntityResolver = object : FederatedEntityResolver<Long, PlanetDto>("Planet", { id ->
log.info("`Planet` entity with id=$id was requested")
val satellites = satelliteService.getByPlanetId(id)
PlanetDto(id = id, satellites = satellites.map { satelliteConverter.toDto(it) })
}) {}
val transformedGraphQLSchema = FederatedSchemaBuilder()
.schemaInputStream(schemaInputStream)
.runtimeWiring(createRuntimeWiring())
.federatedEntitiesResolvers(listOf(planetEntityResolver))
.build()
}
GraphQL Satellite service ( )
query (_service
and _entities
), Apollo Server. query , Apollo Server’. Apollo Federation - standalone-. API .
Apollo Server
Apollo Server Apollo Federation :
, , frontend- , .
— schema stitching — Apollo deprecated. , , : Nadel. GraphQL Java Apollo Federation; .
:
{
"name": "api-gateway",
"main": "gateway.js",
"scripts": {
"start-gateway": "nodemon gateway.js"
},
"devDependencies": {
"concurrently": "5.1.0",
"nodemon": "2.0.2"
},
"dependencies": {
"@apollo/gateway": "0.12.0",
"apollo-server": "2.10.0",
"graphql": "14.6.0"
}
}
-, ( )
const {ApolloServer} = require("apollo-server");
const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");
class AuthenticatedDataSource extends RemoteGraphQLDataSource {
willSendRequest({request, context}) {
request.http.headers.set('Authorization', context.authHeaderValue);
}
}
const gateway = new ApolloGateway({
serviceList: [
{name: "auth-service", url: "http://localhost:8081/graphql"},
{name: "planet-service", url: "http://localhost:8082/graphql"},
{name: "satellite-service", url: "http://localhost:8083/graphql"}
],
buildService({name, url}) {
return new AuthenticatedDataSource({url});
},
});
const server = new ApolloServer({
gateway, subscriptions: false, context: ({req}) => ({
authHeaderValue: req.headers.authorization
})
});
server.listen().then(({url}) => {
console.log(` Server ready at ${url}`);
});
Apollo Server ( )
, ( ); , .
, ( Authorization
). security, , JWT apollo-server
.
, 3 GraphQL Java , , cd
apollo-server
, :
npm install
npm run start-gateway
:
[nodemon] 2.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node gateway.js`
Server ready at http://localhost:4000/
[INFO] Sat Feb 15 2020 13:22:37 GMT+0300 (Moscow Standard Time) apollo-gateway: Gateway successfully loaded schema.
* Mode: unmanaged
Apollo Server
GraphQL :

http://localhost:4000/playground
Playground IDE.
, , query MaxQueryComplexityInstrumentation
/ MaxQueryDepthInstrumentation
, GraphQL IDE . , Apollo Server query { _service { sdl } }
IntrospectionQuery
.
, :
, /, downstream Apollo Server’, Federation; , Apollo.
GraphQL JVM. API GraphQL Java GraphQL API; . Apollo Server, Apollo Federation graphql-java-federation. GitHub. !