Обзор GraphQL-фреймворков на Java

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В предыдущей статье мы поговорили о том, что такое graphQL, почему решили на него переходить, какие у него есть достоинства и недостатки. Но что делать дальше, если вы всё-таки решились внедрить graphQL в java-проект? Какие на данный момент есть фреймворки, чем они отличаются и какой вообще выбрать? Именно с этими проблемами мы и столкнулись почти год назад и не нашли адекватного ответа в одном источнике. Собрав по крупицам инфу из разных ресурсов (статьи, документации, доклады) и потыкав палочкой несколько фреймворков, мы сформировали общую базу знаний о них, которой и захотелось поделиться.

Если вам интересно, какие есть современные graphQL-фреймворки на java, как выбрать подходящий и посмотреть, как он может выглядеть в продакшне — эта статья для вас.

Всем привет! Меня зовут Артем, я бэкенд-разработчик в hh.ru. Сегодня мы поговорим о современных решениях в GraphQL-мире на Java.

Статья также доступна в видеоформате.


Тишина в библиотеке!

Сегодня почти на всех популярных языках программирования есть хотя бы одно GraphQL-решение. Конечно, больше всего эта тема развита в JS-мире. Java скорее на “догоняющей” позиции, но это не значит, что там всё сыро и грустно. Эти решения вполне production-ready, большинство из них проверены временем, и ими спокойно можно пользоваться. 


GraphQL-java 

Первое, на что стоит обратить внимание — библиотека Graphql-java. Это настоящий движок GraphQL, и более того – он единственный. А значит, какими бы фреймворками вы ни пользовались, в итоге в кишочках всё равно будет использована именно эта либа. Реализация data fetching, работа с контекстом, обработка ошибок, мониторинг, ограничение запроса, видимость полей и даже dataloader — всё это уже реализовано в движке. Соответственно, можно его использовать напрямую или менять фреймворки довольно смело, чтобы определиться, какой подходит вам больше всего. Graphql-java — opensource, она разрабатывается простыми смертными ребятами, а последний коммит был буквально на днях. В общем, этот движок активно развивается. 

Впрочем, несмотря на все плюсы вам следует хорошенько подумать, стоит ли использовать её в прямом виде. Мы вот не используем. Эта библиотека низкоуровневая, гибкая, а значит вербозная. Фреймворки в том числе помогают справиться с этим. Разумеется, движок вполне можно использовать напрямую, но это будет менее удобно. 

Кроме этой библиотеки я выделил три фреймворка, заслуживающих внимания. Всё остальное — это, в основном, довольно мелкие библиотеки. 

Итого

Graphql-java:

Достоинства

Недостатки

Движок graphql

Вербозная

Нет аналогов

Низкоуровневая

Гибкая

Schema-first vs Code-first

Но для начала давайте рассмотрим два ключевых подхода к проектированию graphql-API на бекенде. Есть два противоборствующих лагеря — schema-first и code-first решения.

При классическом schema-first подходе мы сначала описываем graphql-схему и потом на её основе в коде реализуем модели и data fetchers. Плюсы такого подхода в том, что проектированием и разработкой схемы могут заниматься разные люди и даже департаменты — например, аналитики проектируют схему, а разработчики её имплементируют. Также может быть удобно написать схему и сразу отдать её клиентам, а самим параллельно разрабатывать бекенд. Минусом же можно отметить необходимость в реализации как схемы, так и кода — это может занимать чуть больше времени при разработке API + теперь появляется 2 источника, которые обязаны не конфликтовать друг с другом и быть полностью синхронизированы - лишнее звено, которое может поломаться.

При code-first подходе мы пишем только код и на основе аннотаций фреймворк сам генерирует схему. Здесь у нас только 1 источник правды, но и graphql-схему без кода не построишь.

Итоговое сравнение

Code-first

Schema-first

1 источник правды - код

Описываем и схему, и код

Нет кода - нет схемы

Сначала разрабатываем схему, на основе неё пишем код


Domain Graph Service

И первый фреймворк, на который мы обратим внимание — это DGS (Domain Graph Service). Если вы были на докладе Пола Беккера на JPoint 2021 — вы уже знаете, о чём я. Остальным рекомендую ознакомиться, доклад очень классный.

Изначально был придуман Netflix в 2019 году, а уже в 2020 его выложили в opensource. И это полноценный фреймворк — он помогает работать с обслуживающим GraphQL-кодом, писать юнит-тесты, предоставляет свой error handling, code-gen для генерации data fetchers на основе схемы, ну и так далее. Это schema-first решение. И всё это production-ready, Netflix на полную его используют. 

И всё-таки мы выбрали другое решение.

Во-первых, DGS — schema-first, а нам бы хотелось заиспользовать code-first подход - легче поднять, чуть быстрее разрабатывать, нет необходимости в разработке схемы без кода.

Во-вторых, DGS использует spring boot. И это прекрасно! Но мы внутри компании его не используем - у нас свой фреймворк, который использует чистый spring-core. Конечно, это не значит, что его не получится поднять — у нас получилось завести, предварительно пообщавшись с Полом на тему норм ли вообще поднимать без бута или авторы так не рекомендуют (норм). Но для этого нужно было разобраться в самом коде фреймворка, найти и объявить вручную с десяток незадокументированных и не всегда понятных бинов, которые в новых версиях DGS могут поломаться. В общем, не бесплатно в обслуживании.

Ну и в-третьих, даже несмотря на то, что это — полноценный фреймворк, вам всё равно придётся дописывать его для работы с юнит-тестами,  error handling, мониторингом etc. Просто потому что ваш проект растёт и вам не будет хватать существующих решений.

Тем не менее, он очень крутой. Поэтому мы пометили его для себя “звездочкой” — решили, что в случае чего вернемся к нему.

Итого

DGS:

  • schema-first

  • opensource от Netflix

  • На Spring-boot

  • Полноценный фреймворк

Java SPQR

Следующая либа, которую мы разберём — это Java SPQR.

Проверенная годами opensource-библиотека. Кроме того, это ещё и единственное code-first решение, к тому же не полноценный фреймворк, что довольно круто. Всё, что делает эта либа — реализует code-first подход и немножко помогает работать с обслуживающим GraphQL-кодом. Нас это абсолютно устраивало, на ней мы и остановились.

Но не смотря на наш выбор, сложно советовать её использовать на данный момент, ибо она забросилась. Последний коммит был больше года назад, ответов на issues не было, поддержки тоже нет.

Почему это может быть важно - как пример, graphql поддерживает наследование, и в 2020 году graphql-спека, а потом уже graphql-java, подхватила возможность работы с множественным наследованием интерфейсов. И вот уже 2022 год, но в SPQR нельзя воспользоваться этой новой фичей.

Однако, совсем недавно ментейнер ответил о планах возобновить работу над проектом, что не может не радовать.

Итого
  • Единственное code-first решение

  • Минималистичен, не фреймворк

  • Заброшен, но есть планы по возобновлению проекта

Spring GraphQL

Последний фреймворк, о котором хочется поговорить — это Spring GraphQL.

Совсем свежачок, вышел в июле 2021. Джош Лонг о нём рассказывал на осеннем Joker 2021. Тоже schema-first подход, интеграция со спрингом (спасибо кэп), немного повторяет DGS — также есть свои обработчики ошибок, поддержка написания юнит-тестов, более удобная работа с data fetchers.

Стоит присмотреться, но аккуратно — фреймворк вышел недавно.

Итого

Spring GraphQL:

  • Schema-first

  • Интеграция со спрингом

  • Полноценный фреймворк

  • Вышел совсем недавно

Ну и как это выглядит?

Теперь давайте создадим простой graphql-сервер.  В качестве стандартного стека будем использовать Java и Spring, а в качестве GraphQL — SPQR, в которой используется движок Graphql-java. Го в код!

Полный прототип можно скачать здесь.

GraphQL bean

Для начала создадим главный GraphQL бин, который будет выполнять все запросы.

@Configuration
public class GraphQLConfig {
  
  private final CandidateResolver candidateResolver;
  private final ResumeResolver resumeResolver;

  public GraphQLConfig(CandidateResolver candidateResolver,
                       ResumeResolver resumeResolver) {
    this.candidateResolver = candidateResolver;
    this.resumeResolver = resumeResolver;
  }

  @Bean
  public GraphQLSchema getGraphQLSchema() {
    return new GraphQLSchemaGenerator()
      .withBasePackages("com.example.graphql.demo.models")
      .withOperationsFromSingletons(candidateResolver, resumeResolver)
      .generate();
  }

  @Bean
  public GraphQL getGraphQL(GraphQLSchema graphQLSchema) {
    return GraphQL.newGraphQL(graphQLSchema)
      .queryExecutionStrategy(new AsyncExecutionStrategy())
      .instrumentation(new CustomTracingInstrumentation())
      .build();
  }
}

Для выполнения ему нужно знать схему - GraphQLSchema - но так как SPQR про code-first подход, то мы заиспользуем генератор схемы, который по полям моделей из корневого package сам её построит.
После этого мы определим ExecutionStrategy — стратегия выполнения graphql-запроса. По дефолту каждая нода в графе выполняется асинхронно и за это как раз отвечает AsyncExecutionStrategy, который в случае чего можно поменять.
После этого переопределим инструментейшены (о них поговорим отдельно) и запустим бин.

Endpoint

Нам нужно откуда-то получить запрос, поэтому создадим обычный POST-метод, принимающий query. Он будет един для всех graphql-запросов, в отличие от REST, где на каждый запрос мы делали отдельный метод.
И после этого передадим запрос на выполнение graphql-бину.

@RestController
public class DemoController {

  private final GraphQL graphQL;

  @Autowired
  DemoController(GraphQL graphQL) {
    this.graphQL = graphQL;
  }

  @PostMapping(path = "graphql",
    consumes = MediaType.APPLICATION_JSON_VALUE,
    produces = MediaType.APPLICATION_JSON_VALUE)
  public ExecutionResult graphql(@RequestBody EntryPoint entryPoint) {
    ExecutionInput executionInput = ExecutionInput.newExecutionInput()
      .query(entryPoint.query)
      .build();
    return graphQL.execute(executionInput);
  }

  public static class EntryPoint {
    public String query;
  }
}

Входные точки

Мы описали схему, мы умеем принимать запросы — но где описать входные точки в этот граф? За это в graphql отвечают Data Fetchers (или по-другому Resolvers) — бин, в котором мы опишем ноды графа.

@GraphQLQuery(name = "candidates")
  public CompletableFuture<List<Candidate>> getCandidates() {
    return CompletableFuture.supplyAsync(candidateService::getCandidates);
  }

В данном случае мы создали входную точку candidates, которая возвращает некую модель Candidate.

public class Candidate {
  private Integer id;
  private String firstName;
  private String lastName;
  private String email;
  private String phone;
  // getters and setters are omitted
}

Более того, как раз по моделям в резолверах SPQR и построит схему.

Конечно, можно и нужно, чтобы таких нод было как можно больше, чтобы они переплетались между собой, создавая граф. Поэтому создадим ещё одну ноду resumes и свяжем её с кандидатами с помощью @GraphQLContext.

@GraphQLQuery(name = "resumes")
  public CompletableFuture<List<Resume>> getResumes(@GraphQLContext Candidate candidate) {
    return CompletableFuture.supplyAsync(() -> resumeService.getResumes(candidate));
  }

public class Resume {
  private Integer id;
  private String lastExperience;
  private Salary salary;
    // getters and setters are omitted
}

public class Salary {
  private String currency;
  private Integer amount;
    // getters and setters are omitted
}

Работает это так - если в самом candidates запросить что-то из resumes, только тогда отработает этот резолвер.

Instrumentation

Помимо всего прочего, мы наверняка захотим мониторить состояние выполнения запроса: сколько времени выполняется каждый резолвер, сколько выполняется полный запрос, какие ошибки мы можем словить. Для этого при регистрации graphql-бина можно прописать Instrumentations — как дефолтные, так и кастомные.

Технически это класс, имплементящий interface Instrumentation (в нашем случае отнаследовались от class SimpleInstrumentation, обычной заглушки, чтобы не реализовывать все методы).
В нём прописаны методы, вызывающиеся при определённом состоянии запроса: когда запрос только начался выполняться, когда вызвался резолвер, когда он закончился выполняться etc.

CustomTracingInstrumentation
public class CustomTracingInstrumentation extends SimpleInstrumentation {

	Logger logger = LoggerFactory.getLogger(CustomTracingInstrumentation.class);

	static class TracingState implements InstrumentationState {
		long startTime;
	}

	// Cоздаём контекст трэйсинга для конкретного запроса
	@Override
	public InstrumentationState createState() {
		return new TracingState();
	}

	// Выполняется перед каждым запросом. Инициализируем контекст трейсинга для замеров времени выполнения
	@Override
	public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
		TracingState tracingState = parameters.getInstrumentationState();
		tracingState.startTime = System.currentTimeMillis();
		return super.beginExecution(parameters);
	}

	// Выполняется при завершении запроса. С помощью totalTime мерим время выполнения всего запроса
	@Override
	public CompletableFuture<ExecutionResult> instrumentExecutionResult(ExecutionResult executionResult, InstrumentationExecutionParameters parameters) {
		TracingState tracingState = parameters.getInstrumentationState();
		long totalTime = System.currentTimeMillis() - tracingState.startTime;
		logger.info("Total execution time: {} ms", totalTime);
		return super.instrumentExecutionResult(executionResult, parameters);
	}
  
	// Выполняется при каждом вызове DataFetcher/Resolver. С помощью него будем мерить время выполнения каждого резолвера
	@Override
	public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters) {
		// Так как любое поле в графе потенциально может быть резолвером, оставим только те, которые хотя бы что-то делают
		if (parameters.isTrivialDataFetcher()) {
		return dataFetcher;
		}
		return environment {
  		long startTime = System.currentTimeMillis();
  		Object result = dataFetcher.get(environment);
  		// Так как все ноды в нашем случае выполняются асинхронно, замерим время только для них
  		if(result instanceof CompletableFuture) {
    		((CompletableFuture<?>) result).whenComplete((r, ex); {
      		long totalTime = System.currentTimeMillis() - startTime;
      		logger.info("Resolver {} took {} ms", findResolverTag(parameters), totalTime);
    		});
  		}
  		return result;
		};
	}
  
	// Ветьеватая логика получения имени резолвера и его родителя (для лучшего понимания откуда вызывалась нода)
	private String findResolverTag(InstrumentationFieldFetchParameters parameters) {
		GraphQLOutputType type = parameters.getExecutionStepInfo().getParent().getType();
		GraphQLObjectType parent;
		if (type instanceof GraphQLNonNull) {
			parent = (GraphQLObjectType) ((GraphQLNonNull) type).getWrappedType();
		} else {
			parent = (GraphQLObjectType) type;
		}
		return parent.getName() + "." + parameters.getExecutionStepInfo().getPath().getSegmentName();
	}
}

На самом деле, Instrumentation довольно мощный функционал, который можно использовать не только для мониторинга. Например, уже реализованный MaxQueryDepthInstrumentationиз graphql-java мерит максимальную глубину запроса и при превышении отменяет запрос, a с помощью MaxQueryComplexityInstrumentation можно поставить веса конкретным нодам и управлять сложностью запроса (но с ним есть нюансы, о них ещё поговорим в отдельной статье).

Этого достаточно, чтобы запустить наш сервис.

Сам запрос

{
    candidates {
        id,
        firstName,
        lastName,
        phone,
        email,
        resumes {
            id,
            lastExperience,
            salary {
                currency,
                amount
            }
        }
    }
}
Ответ будет в стандартном для сервиса json-формате
{
    "errors": [],
    "data": {
        "candidates": [
            {
                "id": 1,
                "firstName": "Леонид",
                "lastName": "Якубович",
                "phone": "88005553535",
                "email": "leonid@yakubovich.ru",
                "resumes": [
                    {
                        "id": 501,
                        "lastExperience": "Ведущий шоу Поле чудес",
                        "salary": {
                            "currency": "RUB",
                            "amount": 40000
                        }
                    }
                ]
            },
            {
                "id": 2,
                "firstName": "Леонид",
                "lastName": "Агутин",
                "phone": "88005553030",
                "email": "hophey@lalaley.com",
                "resumes": [
                    {
                        "id": 502,
                        "lastExperience": "Ведущий шоу Голос",
                        "salary": {
                            "currency": "RUB",
                            "amount": 50000
                        }
                    }
                ]
            }
        ]
    },
    "extensions": null,
    "dataPresent": true
}

Заключение

Вот как обстоят graphql-дела в java-мире. Мы рассмотрели разные фреймворки, оценили их достоинства и недостатки, а потом реализовали простенький graphql сервис на java. Надеюсь, вам было полезно. 

Конечно, развивая эту технологию, вы наверняка столкнетесь со множеством других кейсов. Их мы обязательно разберём в следующих статьях. Но по крайней мере, сейчас нам удалось создать работающий прототип, и это было относительно легко.

Источник: https://habr.com/ru/company/hh/blog/681910/


Интересные статьи

Интересные статьи

Эта статья является пересказом моего доклада на Java-конференции SnowOne 2021 года. LJV — проект, созданный в 2004 году как инструмент для преподавания языка Java студентам. Он позволяет визуализирова...
Kalm — бесплатное приложение с открытым исходным кодом. Представляет собой стандартный контроллер Kubernetes, который можно установить в любой кластер (версии v1.15 и выше), включая Amazon EKS и Googl...
Сталкивались ли вы когда-нибудь с необходимостью работы с аппаратным обеспечением устройств из веб-приложения, а, когда оказывалось, что это невозможно, создавали ли нативное приложение д...
Привет, Хабр! Наша прошлая статья, в которой мы анализировали рынок вакансий и зарплат профессии «аналитик данных», была очень тепло встречена. Поэтому мы решили продолжить. Встречайте об...
Данная статья будет состоять из 3 частей (Теория/Методы и алгоритмы для решение задач/Разработка и реализация на Java) для описания полной картины. Первая статья будет включать только...