在 Spring Boot 中处理 GraphQL 异常

1、概览

本文将带你了解如何在 Spring Boot 中处理 GraphQL 异常,以及 GraphQL 规范中关于错误响应的规定。

2、根据 GraphQL 规范进行响应

根据 GraphQL 规范,每个接收到的请求都必须返回一个格式良好的响应。这个格式良好的响应是一个 Map,包含了来自相应成功或失败的请求操作的数据或错误。此外,响应可能包含部分成功的结果数据和字段错误。

响应 Map 的关键 key 是 errorsdataextensions

响应中的 errors 描述了请求操作过程中出现的任何异常、错误。如果没有错误发生,则响应中不得出现 errors 字段。接下来,我们将了解规范中描述的不同类型的错误。

data 字段描述了成功执行所请求操作的结果。如果操作是 Query,该字段就是 Query Root Operation 类型的对象。如果操作是 Mutation,则该字段是 Mutation Root Operation 类型的对象。

如果请求的操作在执行前就因信息缺失、验证错误或语法错误而失败,那么响应中就必须没有 data 字段。如果操作在执行过程中失败,且结果不成功,则 data 必须为 null

响应 map 可能包含一个名为 extensions 的附加字段,它是一个 map 对象。该字段便于实现者在响应中提供他们认为合适的其他自定义内容。因此,对其内容格式没有额外的限制。

如果响应中没有 data 字段,则必须有 errors 字段,并且必须至少包含一个 error。此外,它还应说明失败的原因。

下面是一个 GraphQL 错误示例:

mutation {
  addVehicle(vin: "NDXT155NDFTV59834", year: 2021, make: "Toyota", model: "Camry", trim: "XLE",
             location: {zipcode: "75024", city: "Dallas", state: "TX"}) {
    vin
    year
    make
    model
    trim
  }
}

当违反唯一性约束时,错误响应如下所示:

{
  "data": null,
  "errors": [
    {
      "errorType": "DataFetchingException",
      "locations": [
        {
          "line": 2,
          "column": 5,
          "sourceName": null
        }
      ],
      "message": "Failed to add vehicle. Vehicle with vin NDXT155NDFTV59834 already present.",
      "path": [
        "addVehicle"
      ],
      "extensions": {
        "vin": "NDXT155NDFTV59834"
      }
    }
  ]
}

3、根据 GraphQL 规范的响应错误

响应中的 errors 字段是一个非空的 error 列表,每个 error 都是一个 map。

3.1、请求错误

顾名思义,如果请求本身存在任何问题,请求错误可能会在操作执行前发生。原因可能是请求数据解析失败、请求文档验证、不支持的操作或无效的请求值。

当出现请求错误时,表示执行尚未开始,这意味着响应中不会存在 data 数据。换句话说,响应中只包含 errors 数据。

让我们来看一个例子,演示输入语法无效的情况:

query {
  searchByVin(vin: "error) {
    vin
    year
    make
    model
    trim
  }
}

下面是语法错误的请求错误响应,在本例中是缺少引号:

{
  "data": null,
  "errors": [
    {
      "message": "Invalid Syntax",
      "locations": [
        {
          "line": 5,
          "column": 8,
          "sourceName": null
        }
      ],
      "errorType": "InvalidSyntax",
      "path": null,
      "extensions": null
    }
  ]
}

3.2、字段错误

字段错误,顾名思义,可能是由于无法将值强制转换为预期类型或在特定字段的值解析过程中出现内部错误。这意味着字段错误是在执行请求的操作过程中发生的。

如果出现字段错误,则会继续执行所请求的操作并返回部分结果,这意味着在响应中,dataerrors 都存在数据。

另一个示例:

query {
  searchAll {
    vin
    year
    make
    model
    trim
  }
}

这次,包含了 trim 字段,根据 GraphQL schema,该字段应是非空 null 的。

但是,其中一个 trim 值为空,因此我们只得到了部分数据,即 trim 值不为空的 vehicle,同时还得到了错误信息:

{
  "data": {
    "searchAll": [
      null,
      {
        "vin": "JTKKU4B41C1023346",
        "year": 2012,
        "make": "Toyota",
        "model": "Scion",
        "trim": "Xd"
      },
      {
        "vin": "1G1JC1444PZ215071",
        "year": 2000,
        "make": "Chevrolet",
        "model": "CAVALIER VL",
        "trim": "RS"
      }
    ]
  },
  "errors": [
    {
      "message": "Cannot return null for non-nullable type: 'String' within parent 'Vehicle' (/searchAll[0]/trim)",
      "path": [
        "searchAll",
        0,
        "trim"
      ],
      "errorType": "DataFetchingException",
      "locations": null,
      "extensions": null
    }
  ]
}

3.3、错误响应格式

如前所述,响应中的 errors 是一个或多个 error 的集合。而且,每个 error 都必须包含一个描述失败原因的 message key,以便客户端开发人员进行必要的更正,避免错误的发生。

每个错误还可能包含一个名为 locations 的 key,这是一个位置列表,指向请求的 GraphQL 文档中与错误相关的行。每个位置都是一个 map,其 key 分别是行(line)和列(column),提供了相关元素的行号和起始列号。

另一个可能成为错误的一部分的关键字是 path(路径)。它提供了从根元素到响应中具有错误的特定元素的值列表。path 值可以是表示字段名称或错误元素的索引的字符串,如果字段值是一个列表的话。如果错误与具有别名的字段相关,则 path 中的值应该是别名。

3.4、处理字段错误

无论字段错误发生在可为 null 字段还是不可为 null 字段上,都应像字段返回 mill 值一样进行处理,并且必须将 error(错误)添加到 errors 列表中。

对于可为 null 字段(nullable),响应中的字段值将为 null,但 errors 必须包含描述失败原因的字段错误和其他信息,如前一节所述。

另外,父字段会处理非 null 字段错误。如果父字段不可为 null,则错误处理会一直传播,直到找到可为 null 的父字段或根元素。

同样,如果列表字段包含不可为 null 的类型,且一个或多个列表元素返回 null,则整个列表解析为 null。此外,如果包含列表字段的父字段是不可为空的,那么错误处理会一直传播,直到找到一个可为 null 的父字段或根元素。

无论出于何种原因,在解析过程中如果对同一个字段引发了多个错误,那么对于该字段,只能将一个字段错误(error)添加到 errors 列表中。

4、Spring Boot GraphQL 库

创建 Spring Boot 应用。

添加 spring-boot-starter-graphql 模块,该模块引入了所需的 GraphQL 依赖。

还使用 spring-graphql-test 模块进行相关测试:

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

5、Spring Boot GraphQL 错误处理

本节主要介绍 Spring Boot 应用本身的 GraphQL 错误处理,不会涉及 GraphQL Java 和 GraphQL Spring Boot 应用开发。

在这个 Spring Boot 应用示例中,我们将根据 location 或 VIN(车辆识别码)更改或查询车辆(Vehicle)。通过此来演示不同的错误处理方式。

5.1、标准异常

一般来说,在 REST 应用中,我们会通过继承 RuntimeExceptionThrowable 来创建一个自定义运行时异常类:

public class InvalidInputException extends RuntimeException {
    public InvalidInputException(String message) {
        super(message);
    }
}

通过这种方法,我们可以看到 GraphQL 引擎会返回以下响应:

{
  "errors": [
    {
      "message": "INTERNAL_ERROR for 2c69042a-e7e6-c0c7-03cf-6026b1bbe559",
      "locations": [
        {
          "line": 2,
          "column": 5
        }
      ],
      "path": [
        "searchByLocation"
      ],
      "extensions": {
        "classification": "INTERNAL_ERROR"
      }
    }
  ],
  "data": null
}

在上述错误响应中,我们可以看到它不包含任何错误细节。

默认情况下,请求处理过程中的任何异常都由 ExceptionResolversExceptionHandler 类处理,该类实现了 GraphQL API 中的 DataFetcherExceptionHandler 接口。它允许应用注册一个或多个 DataFetcherExceptionResolver 组件。

这些解析器(Resolver)会被依次调用,直到其中一个解析器能够处理异常并将其解析为 GraphQLError。如果没有解析器能够处理异常,则将异常归类为 INTERNAL_ERROR。如上所示,它还包含执行 ID 和通用错误消息(Error Message)。

5.2、自定义异常

现在,让我们看看如果我们执行自定义的异常处理,响应会是什么样子。

首先,自定义一个异常:

public class VehicleNotFoundException extends RuntimeException {
    public VehicleNotFoundException(String message) {
        super(message);
    }
}

DataFetcherExceptionResolver 提供了一个异步方法。不过,在大多数情况下,只需继承 DataFetcherExceptionResolverAdapter 并覆盖其 resolveToSingleErrorresolveToMultipleErrors 方法之一即可同步解析异常。

现在,实现这个组件,并且返回一个带有异常消息的 NOT_FOUND 类别,而不是通用的错误。

@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
        if (ex instanceof VehicleNotFoundException) {
            return GraphqlErrorBuilder.newError()
              .errorType(ErrorType.NOT_FOUND)
              .message(ex.getMessage())
              .path(env.getExecutionStepInfo().getPath())
              .location(env.getField().getSourceLocation())
              .build();
        } else {
            return null;
        }
    }
}

如上,创建了一个 GraphQLError,其中包含适当的 “类别” 和其他错误详细信息,以便在 JSON 响应的 errors 中创建更有用的响应:

{
  "errors": [
    {
      "message": "Vehicle with vin: 123 not found.",
      "locations": [
        {
          "line": 2,
          "column": 5
        }
      ],
      "path": [
        "searchByVin"
      ],
      "extensions": {
        "classification": "NOT_FOUND"
      }
    }
  ],
  "data": {
    "searchByVin": null
  }
}

这个错误处理机制的一个重要细节是,未解析的异常将以 ERROR 级别记录在日志中,并包含与发送到客户端的错误相关的 executionId。如上所示,任何已解析的异常将以 DEBUG 级别记录在日志中。

6、总结

本文介绍了不同类型的 GraphQL 错误、如何按照规范格式化 GraphQL 错误,以及如何在 Spring Boot 中处理 GraphQL 错误。


参考:https://www.baeldung.com/spring-graphql-error-handling