Java 17实现函数式错误处理 - softwaremill


在每个程序中,都会有出现问题并发生错误的情况。正因为如此,每种语言都有某种机制来帮助开发人员处理这种情况。在 Java 中,几乎所有代码和库都通过抛出异常来处理遇到的错误。抛出异常是一个非常强大的解决方案,当您第一次想到它时,它有很多好处。另一方面,异常也可以在不必要的时候被过度使用,这种方法肯定有一些缺点。

现在,在 Java 17 中,通过使用模式匹配和密封Seal接口等新功能,以稍微不同、更实用的方式进行错误处理比以往任何时候都容易。因此,我想向您展示如何通过利用所有这些新功能来替换处理错误的标准方法。我们将看一个简单的应用程序,它可能会在几个步骤中失败。我们将首先了解如何对异常进行错误处理,然后我们如何在不引发单个异常的情况下实现相同的目标。

示例应用程序
作为我们示例的应用程序非常简单,但同时也足够复杂,可以模拟现实世界的问题。这是一个 HTTP 端点,我们需要在其中提供两个输入:userId和reportName. 在输出中,它返回一个报告。获取Report对象的逻辑如下:

  • userId在内部数据库中查找具有给定用户的用户以查找email该用户
  • Report getReport(String reportName, String email)使用具有我们提供电子邮件和报告名称并接收报告的方法的外部 SDK 。

如您所见,我们需要链接两个操作来返回结果,并且这两个操作可能会以多种方式失败。在第一个操作中,我们可能会遇到一些意想不到的 DB 问题,因为数据库中可能不存在 userId 或其他一些与 DB 相关的意外错误。

在第二个操作中,我们使用外部 SDK 并且无法访问源代码,我们只是从文档中知道它会抛出一些未经检查的异常,例如InvalidReportNameException,ReportForUserNotFoundException等。

带异常的错误处理
让我们看看如何使用异常来解决我们的问题。我们ReportService在其中使用ReportClient,这是一个来自外部库的类(因此我们无权访问它的代码)。

public class ReportService {

  private final ReportClient reportClient;

  public Report getReportV1(String email, String reportName) {
    return reportClient.getReport(email, reportName);
  }
}

还有一个UserService只调用由 Spring Data 生成的存储库:

public class UserService {

  private final UserRepository userRepository;

  public User getUserV1(String userId) {
    return userRepository.getUser(userId).get();
  }

现在我们需要在基于 Spring 的应用程序中创建一个 HTTP 端点,该端点应该返回Report给定的值userId和reportName。所以控制器看起来像这样:

public class ReportController {

  private final ReportService reportService;
  private final UserService userService;

  @GetMapping("/report")
  public Report getReport(@RequestParam String userId, @RequestParam String reportName) {
    User user = userService.getUserV1(userId);
    return reportService.getReportV1(user.email(), reportName);
  }

现在一切都会正常工作,除非发生一些错误,比如抛出一些异常的getReport()方法。ReportClient在这种情况下,如果我们的端点的调用者提供userId的不存在,我们将返回状态 500 和一些通用错误消息。这对于我们 API 的用户来说并不完美,可能我们希望返回一个带有漂亮错误消息的 404。执行此操作的标准方法是捕获我们感兴趣的异常并重新抛出一些自定义异常,然后在使用ErrorHandler注释注释的类中处理这些异常@ExceptionHandler。

所以我们的方法变成了这样:

public User getUserV1(String userId) {
  Optional<User> optionalUser;
  try {
    optionalUser = userRepository.getUser(userId);
  } catch (Exception e) {
    throw new DatabaseAccessException(e);
  }
  return optionalUser.orElseThrow(() -> new UserNotFoundInDbException(userId));
}
public Report getReportV1(String email, String reportName) {
  try {
    return reportClient.getReport(email, reportName);
  } catch (InvalidReportNameException e) {
    throw new ReportNameNotFoundInReportApiException();
  } catch (ReportForUserNotFoundException e) {
    throw new UserNotFoundInReportApiException();
  } catch (Exception e) {
    throw new ReportClientUnexpectedException(e);
  }
}

现在我们需要一个处理异常并将它们映射到 HTTP 响应的类:

@RestControllerAdvice
public class ErrorHandler {

  @ExceptionHandler(ReportNameNotFoundInReportApiException.class)
  @ResponseStatus(BAD_REQUEST)
  public ApiErrorResponse handleReportNameNotFoundInReportApiException(ReportNameNotFoundInReportApiException ex) {
    return new ApiErrorResponse(400, "Invalid Report name.");
  }

  @ExceptionHandler(UserNotFoundInDbException.class)
  @ResponseStatus(NOT_FOUND)
  public ApiErrorResponse handleUserNotFoundInDbException(UserNotFoundInDbException ex) {
    return new ApiErrorResponse(404,
"Provided user id doesn't exist.");
  }
// and so on...
}

标准方式的缺点
如果你仔细看我们当前的逻辑,你会注意到我们抛出异常的地方ClientService本质ErrorHandler上是一个复杂的 GOTO 语句。我会说情况更糟。除了例外,我们真的不知道
它将在哪里处理,而使用 GOTO,我们至少清楚地知道它。众所周知,应该不惜一切代价避免使用 GOTO 语句。它只会让我们的代码更难推理。如果我们有许多可能引发异常的服务,并且我们将每个服务的结果链接到多个层中,那么代码执行的可能组合的数量会急剧增加,并且很难推断任何给定点上的每个可能的故障。

除此之外,抛出异常是一项相对昂贵的操作。当我们创建和抛出异常时,我们需要构建一个堆栈跟踪,为此,我们需要遍历堆栈以收集所有方法名称、类和行号。我们捕获异常越远,我们拥有的堆栈跟踪就越大。除了花费 CPU 时间来构建堆栈跟踪之外,我们还需要将其保存在内存中,这有时也可能很昂贵。

现在,让我们回顾一下异常的定义。根据甲骨文:

异常是在程序执行期间发生的事件,它破坏了程序指令的正常流程。

如果我们想到一个由于连接问题而失败的数据库读取操作,它完全符合这个定义。然而,使用异常来控制程序的流程是很常见的,包括程序的“正常”流程。现在,让我们问自己一个问题。我们是否期望我们的客户可能会提供一个不存在的 userId?在我们的案例中,答案是肯定的,在我看来,在上面提到的例子中,这是一个完全正常的案例,一点也不例外。所以我们需要注意,在我们的示例中,我们有两种类型的错误,一些是非常意外的错误,例如数据库连接问题,还有一些是正常执行流程的一部分,例如数据库中不存在用户或验证错误。当我们为“正常”抛出异常时

函数式错误处理
任一类型作为返回类型
如果我们想改变它,我们可以从函数式语言的世界中汲取灵感。在函数式编程中,一切都是值,包括错误。为了实现这一点,我们可以让我们的函数返回一个特殊的数据类型,我们可以用它来表示错误和成功值。这种数据类型的一个例子是Either构造。在我们的例子中,你可以把它想象成 Java Optional,但它是可选的,它带有一些额外的信息,比如值不存在的原因。在 Java 中,Either数据类型不存在于标准库中,但可以通过名为 的库轻松添加Vavr,这为我们带来了函数式编程世界中的许多出色特性。

现在,我们的ReportService可能变成这样:

public class ReportService {

  private final ReportClient reportClient;

  public Either<GeneralError, Report> getReportV2(String email, String reportName) {
    return Try.of(() -> reportClient.getReport(email, reportName)).toEither()
      .mapLeft(reportServiceException -> switch (reportServiceException) {
        case InvalidReportNameException e -> new InvalidReportNameError(reportName);
        case ReportForUserNotFoundException e -> new ReportForUserNotFoundError(email);
        default -> new ReportClientUnexpectedError(reportServiceException);
      });
  }

这UserService:

public class UserService {

  private final UserRepository userRepository;

  public Either<GeneralError, User> getUserV2(String userId) {
    return Try.of(() -> userRepository.getUser(userId))
      .toEither()
      .map(Option::ofOptional)
      .<GeneralError>mapLeft(DatabaseError::new)
      .flatMap(optionalUser -> optionalUser.toEither(new ReportForUserNotFoundError(userId)));
  }

错误模型和密封接口
现在,我们的方法 fromUserService可能返回或者返回GeneralError对象User。该类GeneralError是一个由我们定义的接口,我们将用它来表示我们的错误模型。Java 15 引入了一种称为密封接口的东西作为预览功能。现在,在 Java 17 中,这个特性已经完成,它是我们错误模型的完美选择。

public sealed interface GeneralError permits ReportError, UserError, ServiceError {
}
public sealed interface ReportError extends GeneralError {
  record ReportForUserNotFoundError(String email) implements ReportError { }
  record InvalidReportNameError(String wrongName) implements ReportError { }
  record ReportClientUnexpectedError(Throwable cause) implements ReportError { }
}
public sealed interface ServiceError extends GeneralError {
   record DatabaseError(Throwable cause) implements ServiceError { }
   record ReportApiError(Throwable cause) implements ServiceError { }
}
public sealed interface UserError extends GeneralError {
   record UserNotFoundError(String userId) implements UserError { }
}

模式匹配
最后,我们可以使用我们的新服务和错误模型并将控制器层重构为以下版本并完全删除ErrorHandler该类:

public class FunctionalReportController {

  private final ObjectMapper objectMapper;
  private final ReportService reportService;
  private final UserService userService;

  @GetMapping("/report")
  public ResponseEntity<String> getReport(@RequestParam String userId, @RequestParam String reportName) {
    return userService.getUserV2(userId)  
// Either<GeneralError, User>
      .flatMap(user -> reportService.getReportV2(user.email(), reportName))
// Either<GeneralError, Report>
      .mapLeft(this::mapError)
// Either<ApiErrorResponse, Report>
      .fold(this::createErrorResponse, this::createSuccessResponse);
  }

  private ApiErrorResponse mapError(GeneralError obj) {
    return switch (obj) {
      case InvalidReportNameError e -> new ApiErrorResponse(400,
"Invalid report name.");
      case ReportForUserNotFoundError e -> new ApiErrorResponse(404,
"Report for given user not found.");
      case UserNotFoundError e -> new ApiErrorResponse(404,
"Provided user id doesn't exist.");
      case DatabaseError e -> new ApiErrorResponse(500,
"Internal server error.");
      case ReportApiError e -> new ApiErrorResponse(500,
"Internal server error.");
      case ReportClientUnexpectedError e -> new ApiErrorResponse(500,
"Internal server error.");
    };
  }

  private ResponseEntity<String> createSuccessResponse(Object responseBody) {
    return ResponseEntity.status(200).body(toJson(responseBody));
  }

  private ResponseEntity<String> createErrorResponse(ApiErrorResponse errorResponse) {
    return ResponseEntity.status(errorResponse.statusCode()).body(toJson(errorResponse));
  }

  private String toJson(Object object) {
    return Try.of(() -> objectMapper.writeValueAsString(object))
      .getOrElseThrow(e -> new RuntimeException(
"Cannot deserialize response", e));
  }

}

您可以注意到 Controller 类在行数方面有所增加。可以说这是一种劣势,但不一定是真的。在异常驱动的控制器示例中,我们在控制器方法中只有两行代码,但很难推断出哪里会出错。在这里,您可以看到所有可能的情况,并在可能的最新阶段执行错误处理部分。而且,在行数方面,似乎更长,但请记住,我们完全删除了ErrorHandler类,所以在行数方面,两种解决方案都是相似的。

还值得讨论两件非常重要的事情,这要归功于 Java 17,它们是switch case 模式匹配和模式exhaustion。

在 Java 17 之前,我们有 switch 语句,但我们执行 switch 操作的变量的类型必须是 String、Integer 或 Enum 类型。在我们的示例中,我们将一个类型的对象传递GeneralError给 switch 语句并将其映射到适当的错误响应。这是一个非常有用的功能,使我们的代码易于阅读,并且由于在 switch 语句中引入了模式匹配,这成为可能。

另一件值得注意的事情是我们使用了 switch 语句,但没有默认情况。到目前为止,Java 无法检查我们是否在 switch 语句中涵盖了所有可能的情况(枚举除外),我们通常不得不设置默认情况。在我们的例子中,它不会那么好,因为如果我们引入了一些新的错误类型并且在其中一种情况下忘记处理它,它将被作为默认类型处理。现在,如果我们在 switch 语句中添加一些新错误并且不为其添加 case,我们将看到一个编译错误,并清楚地显示缺少 case,因此现在不可能忘记处理新错误。

有人可能会问:这怎么可能?好吧,之前我说过密封接口是我们GeneralError班级的完美选择。这是因为由于密封类,模式耗尽是可能的。在GeneralError类中,我们声明了只有UserError,ReportError并且GeneralError可以扩展这个接口。这就是编译器如何知道可能的类型GeneralError是什么并且可以检测到我们没有涵盖所有可能的情况。

概括
我们已经看到如何通过两种方式解决处理错误的相同问题,首先是抛出异常,然后是使用EitherVavr 库中的构造并利用最新的 Java 17 特性。

这篇博文的目的不是说服您停止使用异常,而是展示一种不同的方法。我认为我们不能,甚至不应该完全摆脱异常,可能某种混合方法在大多数情况下效果最好。

我认为在抛出异常之前,我们应该考虑一下我们是否正在处理异常情况。无论是我们想要堆栈跟踪并稍后分析它发生的原因和位置,还是我们只是处理通常的业务路径。

完整应用程序的源代码可以在这里找到。