
В этой статье вы узнаете, как использовать Spring for GraphQL в своем приложении Spring Boot.
Spring for GraphQL — относительно новый проект. Версия 1.0 была выпущена несколько месяцев назад. До этого релиза нам приходилось подключать сторонние библиотеки, чтобы упростить реализацию GraphQL в приложении Spring Boot. Я уже описал два альтернативных решения в своих предыдущих статьях. В следующей статье вы узнаете о проекте GraphQL Java Kickstart. В другой статье вы увидите, как создавать более сложные запросы GraphQL с помощью библиотеки Netflix DGS.
Мы будем использовать очень похожую схему и модель сущностей, как и в этих двух статьях о Spring Boot и GraphQL.
Исходный код
Если вы хотите попробовать сделать это самостоятельно, вы всегда можете посмотреть на мой исходный код. Для этого вам нужно клонировать мой репозиторий GitHub. Затем просто следуйте моим инструкциям.
Во-первых, вы должны перейти в каталог sample-app-spring-graphql. Наш пример на Spring Boot предоставляет API на базе GraphQL и подключается к базе данных H2 в памяти. Он использует Spring Data JPA в качестве слоя для взаимодействия с базой данных. Есть три сущности Employee, Department и Organization. Каждая из них хранится в отдельной таблице. Вот модель отношений.

Начало работы со Spring for GraphQL
В дополнение к стандартным модулям Spring Boot нам необходимо включить следующие две зависимости:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-graphql</artifactId> </dependency> <dependency> <groupId>org.springframework.graphql</groupId> <artifactId>spring-graphql-test</artifactId> <scope>test</scope> </dependency>
spring-graph-test предоставляет дополнительные возможности для построения модульных тестов. Стартер поставляется с необходимыми библиотеками и автоконфигурацией. Однако он не включает интерфейс GraphiQL. Чтобы включить его, мы должны установить следующее свойство в файле application.yml:
spring: graphql: graphiql: enabled: true
По умолчанию Spring for GraphQL пытается загрузить файлы схемы из каталога src/main/resources/graphql. Он ищет там файлы с расширениями .graphqls или .gqls. Приведем схему GraphQL для сущности Department. Тип Department ссылается на два других типа: Organization и Employee (список сотрудников). Есть два запроса для поиска всех отделов и отдела по id и одна мутация для добавления нового отдела.
type Query { departments: [Department] department(id: ID!): Department! } type Mutation { newDepartment(department: DepartmentInput!): Department } input DepartmentInput { name: String! organizationId: Int } type Department { id: ID! name: String! organization: Organization employees: [Employee] }
Схема типа Organization очень похожа. Из более сложных вещей нам нужно обрабатывать соединения с типами Employee и Department.
extend type Query { organizations: [Organization] organization(id: ID!): Organization! } extend type Mutation { newOrganization(organization: OrganizationInput!): Organization } input OrganizationInput { name: String! } type Organization { id: ID! name: String! employees: [Employee] departments: [Department] }
И последняя схема — для типа Employee. В отличие от предыдущих схем, она определяет тип, отвечающий за обработку фильтрации. EmployeeFilter может фильтровать по зарплате, должности или возрасту. Существует также метод запроса для обработки фильтрации — employeesWithFilter.
extend type Query { employees: [Employee] employeesWithFilter(filter: EmployeeFilter): [Employee] employee(id: ID!): Employee! } extend type Mutation { newEmployee(employee: EmployeeInput!): Employee } input EmployeeInput { firstName: String! lastName: String! position: String! salary: Int age: Int organizationId: Int! departmentId: Int! } type Employee { id: ID! firstName: String! lastName: String! position: String! salary: Int age: Int department: Department organization: Organization } input EmployeeFilter { salary: FilterField age: FilterField position: FilterField } input FilterField { operator: String! value: String! }
Создание сущностей
Не держите на меня зла, но я использую Lombok при реализации сущностей. Вот сущность Employee, соответствующая типу Employee, определенному в схеме GraphQL.
@Entity @Data @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Employee { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include private Integer id; private String firstName; private String lastName; private String position; private int salary; private int age; @ManyToOne(fetch = FetchType.LAZY) private Department department; @ManyToOne(fetch = FetchType.LAZY) private Organization organization; }
Здесь создается сущность Department.
@Entity @Data @AllArgsConstructor @NoArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Department { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include private Integer id; private String name; @OneToMany(mappedBy = "department") private Set<Employee> employees; @ManyToOne(fetch = FetchType.LAZY) private Organization organization; }
Наконец, мы можем взглянуть на сущность Organization.
@Entity @Data @AllArgsConstructor @NoArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Organization { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include private Integer id; private String name; @OneToMany(mappedBy = "organization") private Set<Department> departments; @OneToMany(mappedBy = "organization") private Set<Employee> employees; }
Использование GraphQL for Spring со Spring Boot
Spring for GraphQL предоставляет модель программирования на основе аннотаций, используя известный паттерн @Controller. Также можно адаптировать библиотеку Querydsl и использовать ее вместе со Spring Data JPA. Затем вы можете использовать ее в своих репозиториях Spring Data, аннотированных с помощью @GraphQLRepository. В этой статье я буду использовать стандартный JPA Criteria API для генерации более сложных запросов с фильтрами и объединениями.
Начнем с нашего первого контроллера. По сравнению с обеими предыдущими статьями о Netflix DGS и GraphQL Java Kickstart, мы будем хранить запросы и мутации в одном классе. Нам нужно аннотировать методы запросов с помощью @QueryMapping, а методы мутации с помощью @MutationMapping. Последний метод запроса employeesWithFilter выполняет расширенную фильтрацию на основе динамического списка полей, переданных во входном типе EmployeeFilter. Чтобы передать входной параметр, мы должны аннотировать аргумент метода с помощью @Argument.
@Controller public class EmployeeController { DepartmentRepository departmentRepository; EmployeeRepository employeeRepository; OrganizationRepository organizationRepository; EmployeeController(DepartmentRepository departmentRepository, EmployeeRepository employeeRepository, OrganizationRepository organizationRepository) { this.departmentRepository = departmentRepository; this.employeeRepository = employeeRepository; this.organizationRepository = organizationRepository; } @QueryMapping public Iterable<Employee> employees() { return employeeRepository.findAll(); } @QueryMapping public Employee employee(@Argument Integer id) { return employeeRepository.findById(id).orElseThrow(); } @MutationMapping public Employee newEmployee(@Argument EmployeeInput employee) { Department department = departmentRepository .findById(employee.getDepartmentId()).get(); Organization organization = organizationRepository .findById(employee.getOrganizationId()).get(); return employeeRepository.save(new Employee(null, employee.getFirstName(), employee.getLastName(), employee.getPosition(), employee.getAge(), employee.getSalary(), department, organization)); } @QueryMapping public Iterable<Employee> employeesWithFilter( @Argument EmployeeFilter filter) { Specification<Employee> spec = null; if (filter.getSalary() != null) spec = bySalary(filter.getSalary()); if (filter.getAge() != null) spec = (spec == null ? byAge(filter.getAge()) : spec.and(byAge(filter.getAge()))); if (filter.getPosition() != null) spec = (spec == null ? byPosition(filter.getPosition()) : spec.and(byPosition(filter.getPosition()))); if (spec != null) return employeeRepository.findAll(spec); else return employeeRepository.findAll(); } private Specification<Employee> bySalary(FilterField filterField) { return (root, query, builder) -> filterField .generateCriteria(builder, root.get("salary")); } private Specification<Employee> byAge(FilterField filterField) { return (root, query, builder) -> filterField .generateCriteria(builder, root.get("age")); } private Specification<Employee> byPosition(FilterField filterField) { return (root, query, builder) -> filterField .generateCriteria(builder, root.get("position")); } }
Вот наша реализация репозитория JPA. Чтобы использовать JPA Criteria API, нам необходимо, чтобы он расширял интерфейс JpaSpecificationExecutor. Это же правило применяется и к другим репозиториям: DepartmentRepository и OrganizationRepository.
public interface EmployeeRepository extends CrudRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> { }
Теперь давайте переключимся на другой контроллер. Вот реализация контроллера DepartmentController. Здесь показан пример выборки отношений. Мы используем DataFetchingEnvironment, чтобы определить, содержит ли входной запрос поле отношения. В нашем случае это могут быть employees или organization. Если любое из этих полей определено, мы добавляем конкретное отношение в оператор JOIN. Тот же подход применяется к методам department и deparments.
@Controller public class DepartmentController { DepartmentRepository departmentRepository; OrganizationRepository organizationRepository; DepartmentController(DepartmentRepository departmentRepository, OrganizationRepository organizationRepository) { this.departmentRepository = departmentRepository; this.organizationRepository = organizationRepository; } @MutationMapping public Department newDepartment(@Argument DepartmentInput department) { Organization organization = organizationRepository .findById(department.getOrganizationId()).get(); return departmentRepository.save(new Department(null, department.getName(), null, organization)); } @QueryMapping public Iterable<Department> departments(DataFetchingEnvironment environment) { DataFetchingFieldSelectionSet s = environment.getSelectionSet(); List<Specification<Department>> specifications = new ArrayList<>(); if (s.contains("employees") && !s.contains("organization")) return departmentRepository.findAll(fetchEmployees()); else if (!s.contains("employees") && s.contains("organization")) return departmentRepository.findAll(fetchOrganization()); else if (s.contains("employees") && s.contains("organization")) return departmentRepository.findAll(fetchEmployees().and(fetchOrganization())); else return departmentRepository.findAll(); } @QueryMapping public Department department(@Argument Integer id, DataFetchingEnvironment environment) { Specification<Department> spec = byId(id); DataFetchingFieldSelectionSet selectionSet = environment .getSelectionSet(); if (selectionSet.contains("employees")) spec = spec.and(fetchEmployees()); if (selectionSet.contains("organization")) spec = spec.and(fetchOrganization()); return departmentRepository.findOne(spec).orElseThrow(NoSuchElementException::new); } private Specification<Department> fetchOrganization() { return (root, query, builder) -> { Fetch<Department, Organization> f = root .fetch("organization", JoinType.LEFT); Join<Department, Organization> join = (Join<Department, Organization>) f; return join.getOn(); }; } private Specification<Department> fetchEmployees() { return (root, query, builder) -> { Fetch<Department, Employee> f = root .fetch("employees", JoinType.LEFT); Join<Department, Employee> join = (Join<Department, Employee>) f; return join.getOn(); }; } private Specification<Department> byId(Integer id) { return (root, query, builder) -> builder.equal(root.get("id"), id); } }
Вот реализация контроллера OrganizationController.
@Controller public class OrganizationController { OrganizationRepository repository; OrganizationController(OrganizationRepository repository) { this.repository = repository; } @MutationMapping public Organization newOrganization(@Argument OrganizationInput organization) { return repository.save(new Organization(null, organization.getName(), null, null)); } @QueryMapping public Iterable<Organization> organizations() { return repository.findAll(); } @QueryMapping public Organization organization(@Argument Integer id, DataFetchingEnvironment environment) { Specification<Organization> spec = byId(id); DataFetchingFieldSelectionSet selectionSet = environment .getSelectionSet(); if (selectionSet.contains("employees")) spec = spec.and(fetchEmployees()); if (selectionSet.contains("departments")) spec = spec.and(fetchDepartments()); return repository.findOne(spec).orElseThrow(); } private Specification<Organization> fetchDepartments() { return (root, query, builder) -> { Fetch<Organization, Department> f = root .fetch("departments", JoinType.LEFT); Join<Organization, Department> join = (Join<Organization, Department>) f; return join.getOn(); }; } private Specification<Organization> fetchEmployees() { return (root, query, builder) -> { Fetch<Organization, Employee> f = root .fetch("employees", JoinType.LEFT); Join<Organization, Employee> join = (Join<Organization, Employee>) f; return join.getOn(); }; } private Specification<Organization> byId(Integer id) { return (root, query, builder) -> builder.equal(root.get("id"), id); } }
Создание модульных тестов
После того как мы создали всю логику, пришло время ее протестировать. В следующем разделе я покажу вам, как использовать для этого GraphiQL IDE.
Здесь мы сосредоточимся на модульных тестах. Самый простой способ начать тестировать Spring for GraphQL — использовать bean-компонент GraphQLTester. Мы можем использовать его в мок веб-среде. Вы также можете создавать тесты для уровня HTTP с помощью другого компонента — HttpGraphQlTester. Однако для этого требуется предоставить экземпляр WebTestClient.
Вот тест для Employee @Controller. Каждый раз мы создаем встроенный запрос, используя нотацию GraphQL. Нам нужно аннотировать весь тестовый класс с помощью @AutoConfigureGraphQlTester. Затем мы можем использовать DSL API, предоставляемый GraphQLTester для получения и проверки данных из бэкенда. Помимо двух простых тестов, мы также проверяем, нормально ли работает EmployeeFilter в методе findWithFilter.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureGraphQlTester public class EmployeeControllerTests { @Autowired private GraphQlTester tester; @Test void addEmployee() { String query = "mutation { newEmployee(employee: { firstName: \"John\" lastName: \"Wick\" position: \"developer\" salary: 10000 age: 20 departmentId: 1 organizationId: 1}) { id } }"; Employee employee = tester.document(query) .execute() .path("data.newEmployee") .entity(Employee.class) .get(); Assertions.assertNotNull(employee); Assertions.assertNotNull(employee.getId()); } @Test void findAll() { String query = "{ employees { id firstName lastName salary } }"; List<Employee> employees = tester.document(query) .execute() .path("data.employees[*]") .entityList(Employee.class) .get(); Assertions.assertTrue(employees.size() > 0); Assertions.assertNotNull(employees.get(0).getId()); Assertions.assertNotNull(employees.get(0).getFirstName()); } @Test void findById() { String query = "{ employee(id: 1) { id firstName lastName salary } }"; Employee employee = tester.document(query) .execute() .path("data.employee") .entity(Employee.class) .get(); Assertions.assertNotNull(employee); Assertions.assertNotNull(employee.getId()); Assertions.assertNotNull(employee.getFirstName()); } @Test void findWithFilter() { String query = "{ employeesWithFilter(filter: { salary: { operator: \"gt\" value: \"12000\" } }) { id firstName lastName salary } }"; List<Employee> employees = tester.document(query) .execute() .path("data.employeesWithFilter[*]") .entityList(Employee.class) .get(); Assertions.assertTrue(employees.size() > 0); Assertions.assertNotNull(employees.get(0).getId()); Assertions.assertNotNull(employees.get(0).getFirstName()); } }
Тесты для типа Deparment очень похожи. Кроме того, мы тестируем операторы соединения в тестовом методе findById, объявляя поле organization в запросе.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureGraphQlTester public class DepartmentControllerTests { @Autowired private GraphQlTester tester; @Test void addDepartment() { String query = "mutation { newDepartment(department: { name: \"Test10\" organizationId: 1}) { id } }"; Department department = tester.document(query) .execute() .path("data.newDepartment") .entity(Department.class) .get(); Assertions.assertNotNull(department); Assertions.assertNotNull(department.getId()); } @Test void findAll() { String query = "{ departments { id name } }"; List<Department> departments = tester.document(query) .execute() .path("data.departments[*]") .entityList(Department.class) .get(); Assertions.assertTrue(departments.size() > 0); Assertions.assertNotNull(departments.get(0).getId()); Assertions.assertNotNull(departments.get(0).getName()); } @Test void findById() { String query = "{ department(id: 1) { id name organization { id } } }"; Department department = tester.document(query) .execute() .path("data.department") .entity(Department.class) .get(); Assertions.assertNotNull(department); Assertions.assertNotNull(department.getId()); Assertions.assertNotNull(department.getOrganization()); Assertions.assertNotNull(department.getOrganization().getId()); } }
Каждый раз, клонируя мой репозиторий, вы можете быть уверены, что примеры работают нормально благодаря автоматизированным тестам. Вы всегда можете проверить статус сборки репозитория в моем конвейере CircleCI.

Тестирование с помощью GraphiQL
Мы можем легко запустить приложение с помощью следующей команды Maven:
$ mvn clean spring-boot:run
Как только вы это сделаете, вы сможете получить доступ к инструменту GraphiQL по адресу http://localhost:8080/graphiql. При запуске приложение вставляет некоторые демонстрационные данные в базу данных H2. GraphiQL предоставляет контекстную помощь при построении запросов GraphQL. Вот пример запроса, протестированного там.

Заключительные мысли
Spring for GraphQL — очень интересный проект, и я буду внимательно следить за его развитием.
Помимо поддержки @Controller, я попытался использовать интеграцию querydsl с репозиториями Spring Data JPA. Однако у меня возникли некоторые проблемы с этим, и поэтому я не стал помещать эту тему в статью.
На данный момент Spring for GraphQL является третьим надежным Java-фреймворком с высокоуровневой поддержкой GraphQL для Spring Boot. Моим выбором по-прежнему остается Netflix DGS, но Spring for GraphQL находится в стадии активной разработки. Так что, вероятно, в скором времени мы можем ожидать новых и полезных функций.
ссылка на оригинал статьи https://habr.com/ru/articles/720360/
Добавить комментарий