hello,大家好,我是小黑,又和大家見面啦~
今天我們來繼續學習 Spring Boot GraphQL 實戰,我們使用的框架是 https://github.com/graphql-java-kickstart/graphql-spring-boot
本期,我們將使用 H2 和 Spring Data JPA 來構建資料庫和簡單的查詢,不熟悉的同學可以自行去網上查閱相關資料學習。
完整專案 github 地址:https://github.com/shenjianeng/graphql-spring-boot-example
分頁查詢
基於偏移量的分頁
基於偏移量的分頁,即通過 SQL 的 limit 來實現分頁。
優點是實現簡單,使用成本低。缺點是在資料量過大時,進行大翻頁時可能會有效能問題。
先來編寫 graphqls
檔案:
type PageResult{
items:[Student]!
pageNo:Int!
pageSize:Int!
totalCount:Int!
}
type Student{
id:ID!
name:String!
}
type Query{
findAll(pageNo:Int!,pageSize:Int!):PageResult!
}
對應的 Java Bean 就不在這裡贅述了,讀者感興趣的話可以自行查詢小黑同學上傳在 github 上的原始碼。
其中,最主要的 StudentGraphQLQueryResolver
原始碼如下:
@Component
@RequiredArgsConstructor
public class StudentGraphQLQueryResolver implements GraphQLQueryResolver {
private final StudentRepository studentRepository;
public PageResult<Student> findAll(int pageNo, int pageSize) {
Page<Student> page = studentRepository.findAll(PageRequest.of(pageNo - 1, pageSize));
PageResult<Student> pageResult = new PageResult<>();
pageResult.setItems(page.getContent());
pageResult.setPageNo(pageNo);
pageResult.setPageSize(page.getSize());
pageResult.setTotalCount((int) page.getTotalElements());
return pageResult;
}
}
啟動應用,測試結果如下圖:
基於遊標的分頁
基於遊標的分頁,即通過遊標來跟蹤資料獲取的位置。
遊標的選取有時候可以非常簡單,例如可以將所獲得資料的最後一個物件的 ID 作為遊標。
GraphQL 遊標分頁是 Relay 風格式的,更多規範資訊可以查閱:https://relay.dev/graphql/connections.htm
Connection 物件
在 Relay 分頁查詢中,分頁結果需要返回 Connection
物件。
先來簡單看一下 Connection
的預設實現 graphql.relay.DefaultConnection
的原始碼:
PageInfo
中儲存了和分頁相關的一些資訊:
編寫 graphqls 檔案
Relay 式分頁中定義了一些規範:
-
向前分頁,在向前分頁中,有兩個必要引數:
first
和after
first
:從指定遊標開始,獲取多少個資料after
:指定的遊標位置
-
向後分頁,在向後分頁中,也有兩個必要引數:
-
last
:指定取遊標前的多少個資料 -
before
:與last
搭配使用,用來指定遊標位置
-
type Query{
students(first: Int, after: String): StudentConnection @connection(for: "Student")
}
實現分頁方法
對應 StudentGraphQLQueryResolver
原始碼如下:
public Connection<Student> students(int first, String after) {
String afterToUsed = StringUtils.defaultIfEmpty(after, "0");
Integer minId = studentRepository.findMinId();
Integer maxId = studentRepository.findMaxId();
// 從 after 遊標開始,取 first 個資料
// 這裡故意取 first + 1 個數,用來判斷是否還有下一頁資料
List<Student> students =
studentRepository.findByIdGreaterThan(Integer.valueOf(afterToUsed), PageRequest.of(0, first + 1));
List<Edge<Student>> edges = students.stream()
.limit(first)
.map(student -> new DefaultEdge<>(student, new DefaultConnectionCursor(String.valueOf(student.getId()))))
.collect(Collectors.toList());
PageInfo pageInfo =
new DefaultPageInfo(
new DefaultConnectionCursor(String.valueOf(minId)),
new DefaultConnectionCursor(String.valueOf(maxId)),
Integer.parseInt(afterToUsed) > minId,
students.size() > first);
return new DefaultConnection<>(edges, pageInfo);
}
更多參考資料:https://www.graphql-java-kickstart.com/tools/relay/
使用 validation 校驗引數
在 SpringMVC 中, javax.validation
的一系列註解可以幫我們完成引數校驗,那在 GraphQL 中能否也使用 javax.validation
來進行引數合法性校驗呢?答案是可行的。
下面,我們就構建一個簡單的案例來嘗試一下。
type Teacher{
id:ID!
name:String!
age:Int
}
type Mutation{
createTeacher(teacherInput:TeacherInput!):Teacher
}
input TeacherInput{
id:ID!
name:String!
age:Int!
}
@Data
public class Teacher {
private int id;
private String name;
private int age;
}
@Data
public class TeacherInput {
@Min(value = 1, message = "id錯誤")
private int id;
@Length(min = 2, max = 10, message = "名稱過長")
private String name;
@Range(min = 1, max = 100, message = "年齡不正確")
private int age;
}
@Validated
@Component
public class TeacherGraphQLMutationResolver implements GraphQLMutationResolver {
public Teacher createTeacher(@Valid TeacherInput input) {
Teacher teacher = new Teacher();
teacher.setId(input.getId());
teacher.setName(input.getName());
teacher.setAge(input.getAge());
return teacher;
}
}
可以看到,當客戶端輸入非法的引數時,服務端引數校驗失敗,但此時客戶端看到的錯誤資訊並不友好。那這個應該如何解決呢?
想想我們在 Spring MVC 中是怎麼解決這個問題的?一般,這種情況下,我們會自定義全域性異常處理器,然後由這些全域性異常處理器來處理這些引數校驗失敗的異常,同時返回給客戶端更友好的提示。
那現在我們是不是也可以這樣做呢?我們當前使用的 graphql-spring-boot 框架支不支援全域性異常處理呢?
全域性異常處理
使用 @ExceptionHandler
Spring MVC 允許我們使用 @ExceptionHandler
來自定義 HTTP 錯誤響應。
在 graphql-spring-boot 框架中也新增了對該註釋的支援,用於以將異常轉換為有效的 GraphQLError
物件。
要使用 @ExceptionHandler
註解的方法簽名必須滿足以下要求:
public GraphQLError singleError(Exception e);
public GraphQLError singleError(Exception e, ErrorContext ctx);
public Collection<GraphQLError> multipleErrors(Exception e);
public Collection<GraphQLError> multipleErrors(Exception e, ErrorContext ctx);
下面,我們就來簡單嘗試一下。
@Component
public class CustomExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public GraphQLError constraintViolationExceptionHandler(ConstraintViolationException ex, ErrorContext ctx) {
return GraphqlErrorBuilder.newError()
.message(ex.getMessage())
.locations(ctx.getLocations())
.path(ctx.getPath())
.build();
}
}
自定義 GraphQLErrorHandler
第二種處理方式:可以通過實現 graphql.kickstart.execution.error.GraphQLErrorHandler
介面來自定義異常處理器。
需要注意的是,一旦系統中自定義了 GraphQLErrorHandler
元件,那麼 @ExceptionHandler
的處理方式就會失效。
@Slf4j
@Component
public class CustomGraphQLErrorHandler implements GraphQLErrorHandler {
@Override
public List<GraphQLError> processErrors(List<GraphQLError> errors) {
log.info("Handle errors: {}", errors);
return Collections.singletonList(new GenericGraphQLError("系統異常,請稍後嘗試"));
}
}
非同步 Resolver
非同步載入的實現其實也很簡單,直接使用 CompletableFuture
作為 Resolver 的返回物件即可。
type Query{
getTeachers:[Teacher]
}
@Slf4j
@Component
public class TeacherGraphQLQueryResolver implements GraphQLQueryResolver {
private final ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
@PreDestroy
public void destroy() {
executor.shutdown();
}
public CompletableFuture<Collection<Teacher>> getTeachers() {
log.info("start getTeachers...");
CompletableFuture<Collection<Teacher>> future = CompletableFuture.supplyAsync(() -> {
log.info("invoke getTeachers...");
sleep();
Teacher teacher = new Teacher();
teacher.setId(666);
teacher.setName("coder小黑");
teacher.setAge(17);
return Collections.singletonList(teacher);
}, executor);
log.info("end getTeachers...");
return future;
}
private void sleep() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
當客戶端發起請求時,讓我們來一起看一下後臺的日誌輸出,注意看日誌輸出的先後順序和執行執行緒名: