Spring Boot GraphQL 實戰 03_分頁、全域性異常處理和非同步載入

Coder小黑發表於2021-01-13

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 的原始碼:

DefaultConnection

PageInfo 中儲存了和分頁相關的一些資訊:

PageInfo

編寫 graphqls 檔案

Relay 式分頁中定義了一些規範:

  • 向前分頁,在向前分頁中,有兩個必要引數:firstafter

    • 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);
}

query

更多參考資料: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();
    }
}

CustomExceptionHandler

客戶端錯誤資訊

自定義 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();
        }
    }
}

當客戶端發起請求時,讓我們來一起看一下後臺的日誌輸出,注意看日誌輸出的先後順序和執行執行緒名:

非同步載入日誌輸出

相關文章