كيفية الرسم البياني على Kotlin و Micronaut وإنشاء نقطة وصول واحدة إلى API للعديد من الخدمات الصغيرة

GraphQL هي لغة استعلام API تم تطويرها بواسطة Facebook. ستناقش هذه المقالة مثالاً على تنفيذ GraphQL API على JVM ، على وجه الخصوص ، باستخدام لغة Kotlin وإطار Micronaut ؛ يمكن إعادة استخدام معظم الأمثلة على أطر Java / Kotlin أخرى. ثم سيتم توضيح كيفية دمج العديد من خدمات GraphQL في رسم بياني واحد للبيانات لتوفير واجهة مشتركة للوصول إلى جميع مصادر البيانات. يتم تنفيذ ذلك باستخدام Apollo Server و Apollo Federation . ونتيجة لذلك ، سيتم الحصول على البنية التالية:


هندسة معمارية


يسلط كل مكون من مكونات الهيكل الضوء على العديد من المشكلات التي قد تنشأ أثناء تطوير واجهة برمجة تطبيقات GraphQL. يتضمن نموذج المجال بيانات عن كواكب النظام الشمسي وأقمارها الصناعية.


:



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()
                    // uncomment if you need to enable the instrumentations. but this may affect showing documentation in a GraphQL client
                    // MaxQueryComplexityInstrumentation(50),
                    // MaxQueryDepthInstrumentation(5)
                )
            )
        )
        .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 ( )


GenericConverterEntity → 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's scope is `Singleton`, because `BatchLoader` is stateless
@Bean
@Singleton
fun detailsBatchLoader(): BatchLoader<Long, DetailsDto> = BatchLoader { keys ->
    CompletableFuture.supplyAsync {
        detailsService.getByIds(keys)
            .map { detailsConverter.toDto(it) }
    }
}

// bean's (default) scope is `Prototype`, because `DataLoader` is stateful
@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, :


مستندات altair


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>() {})

    // assertions
}

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>() {})

    // assertions
}

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
) {

    // other stuff

    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()
    // other stuff
    .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)
    // other stuff
    .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
// mark it as primary to override the default one
@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 Planetentity — , ( 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 {

    // other stuff

    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()

    // other stuff
}

GraphQL Satellite service ( )


query (_service and _entities), Apollo Server. query , Apollo Server’. Apollo Federation - standalone-. API .


Apollo Server


Apollo Server Apollo Federation :


  • GraphQL




, , 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 :


خادم altair أبولو


http://localhost:4000/playground Playground IDE.


, , query MaxQueryComplexityInstrumentation / MaxQueryDepthInstrumentation , GraphQL IDE . , Apollo Server query { _service { sdl } } IntrospectionQuery.


, :


  • subscription’ Apollo Gateway’ ( - standalone GraphQL Java )


    Planet service .excludeSubscriptionsFromApolloSdl(true).


  • , GraphQL ,



, /, downstream Apollo Server’, Federation; , Apollo.



GraphQL JVM. API GraphQL Java GraphQL API; . Apollo Server, Apollo Federation graphql-java-federation. GitHub. !




All Articles