跳到主要内容

生成客户端异常

这部分功能其实和ORM没有关系。

然而,既然提供了前后端API免对接,那么就必须提供这部分功能,否则就不是一套完整的方案。

异常簇

我们需要定义特别的Java/Kotlin业务异常,它们能够被直接翻译成客户端可以理解的数据。

该如何定义这种特别的业务异常类型呢?有两种极端的选择

  • 整个应用程序共享一个业务异常类,并内置客户端可以理解的error code

  • 每个业务错误都定义一个业务异常类

很明显,上述两种方案都不合适。第一种方案粒度太粗,全局的error code难以维护;第二种方案粒度太细,需要定义的异常类是在太多了。

所以,Jimmer选择折中方案,把业务异常分为多个簇,每个簇用一套Error Code。

用户可以采用两种方法定义异常簇

  • 自动生成

  • 纯手写

自动生成异常簇

Jimmer采用枚举来定义异常簇,枚举也是这簇异常的错误码,例如

定义异常簇

UserInfoErrorCode.java
package com.example.business.error;

import org.babyfish.jimmer.error.ErrorFamily;

@ErrorFamily
public enum UserInfoErrorCode {
ILLEGAL_USER_NAME,
PASSWORD_TOO_SHORT,
PASSWORDS_NOT_SAME
}

@org.babyfish.jimmer.error.ErrorFamily表示该枚举表示一簇业务异常,该枚举也是这簇异常的ErrorCode.

注解@ErrorFamily会被Jimmer的预编译器处理

信息

这里的预编译器,对于Java而言就是Annotation Processor;对于Kotlin而言就是KSP。

这部分类型已经在概述/快速上手/生成代码一节中讨论过,此处不在重复。

预编译器会会根据此枚举生成如下异常类

信息

用作声明异常簇的枚举可以选择用ErrorCodeError结尾。

  • 如果有这样的特殊结尾,异常类名 = 枚举名去掉这样的结尾并加 + Exception

  • 否则,异常类名 = 枚举名 + Exception

所以,这里生成的异常名为UserInfoException

经过Jimmer的编译,将会生成如下的异常类。

UserInfoException.java
public abstract class UserInfoException 
extends CodeBasedRuntimeException {

private UserInfoException(String message, Throwable cause) {
super(message, cause);
}

@Override
public abstract UserInfoErrorCode getCode();

public static IllegalUserName illegalUserName(@NotNull String message) {
return new IllegalUserName(
message,
null
);
}

public static IllegalUserName illegalUserName(
@NotNull String message,
@Nullable Throwable cause
) {
return new IllegalUserName(
message,
cause
);
}

public static PasswordTooShort passwordTooShort(@NotNull String message) {
return new PasswordTooShort(
message,
null
);
}

public static PasswordTooShort passwordTooShort(
@NotNull String message,
@Nullable Throwable cause
) {
return new PasswordTooShort(
message,
cause
);
}

public static PasswordsNotSame passwordsNotSame(@NotNull String message) {
return new PasswordsNotSame(
message,
null
);
}

public static passwordsNotSame passwordsNotSame(
@NotNull String message,
@Nullable Throwable cause
) {
return new PasswordsNotSame(
message,
cause
);
}

public static class IllegalUserName extends UserInfoException {
public IllegalUserName(String message, Throwable cause) {
super(message, cause);
}

@Override
public UserInfoErrorCode getCode() {
return UserInfoErrorCode.ILLEGAL_USER_NAME;
}

@Override
public Map<String, Object> getFields() {
return Collections.emptyMap();
}
}

public static class PasswordTooShort extends UserInfoException {
public PasswordTooShort(String message, Throwable cause) {
super(message, cause);
}

@Override
public UserInfoErrorCode getCode() {
return UserInfoErrorCode.PASSWORD_TOO_SHORT;
}

@Override
public Map<String, Object> getFields() {
return Collections.emptyMap();
}
}

public static class PasswordsNotSame extends UserInfoException {
public PasswordsNotSame(String message, Throwable cause) {
super(message, cause);
}

@Override
public UserInfoErrorCode getCode() {
return UserInfoErrorCode.PASSWORDS_NOT_SAME;
}

@Override
public Map<String, Object> getFields() {
return Collections.emptyMap();
}
}
}
  • ❶ 基于枚举错误码的异常必然继承org.babyfish.jimmer.error.CodeBasedRuntimeException

  • ❷ 这一簇异常的错误码的类型为UserInfoErrorCode

  • ❸❹ 构建错误码为ILLEGAL_USER_NAME的异常的静态方法

  • ❺❻ 构建错误码为PASSWORD_TOO_SHORT的异常的静态方法

  • ❼❽ 构建错误码为PASSWORDS_NOT_SAME的异常的静态方法

  • ❾ 异常类UserInfoException.IllegalUserName的错误码为ILLEGAL_USER_NAME

  • ❿ 异常类UserInfoException.PasswordTooShort的错误码为PASSWORD_TOO_SHORT

  • ⓫ 异常类UserInfoException.PasswordsNotSame的错误码为PASSWORDS_NOT_SAME

为异常码添加字段

可以为任何一个错误码添加附加字段。

比如,ILLEGAL_USER_NAME表示非法的用户名,即用户名包含了非法字符。我们可以为其添加字段illegalChars

UserInfoErrorCode.java
@ErrorFamily
public enum UserInfoErrorCode {

@ErrorField(name = "illegalChars", type = Character.class, list = true)
ILLEGAL_USER_NAME,

PASSWORD_TOO_SHORT,

PASSWORDS_NOT_SAME
}

生成的代码的如下

UserInfoException.java
public abstract class UserInfoException extends CodeBasedRuntimeException {

public static IllegalUserName illegalUserName(
@NotNull String message,
@NotNull List<Character> illegalChars
) {
...省略代码...
}

public static IllegalUserName illegalUserName(
@NotNull String message,
@Nullable Throwable cause,
@NotNull List<Character> illegalChars
) {
...省略代码...
}

public static class IllegalUserName extends UserInfoException {

@NotNull
private final List<Character> illegalChars;

public IllegalUserName(
String message,
Throwable cause,
@NotNull List<Character> illegalChars
) {
super(message, cause);
this.illegalChars = illegalChars;
}

@Override
public Map<String, Object> getFields() {
return Collections.singletonMap("illegalChars", illegalChars);
}

@NotNull
public List<Character> getIllegalChars() {
return illegalChars;
}

...省略其他代码...
}

...省略其他代码...
}

手写异常簇

【TODO】

为REST API声明异常

允许抛出整个异常簇

所谓抛出异常簇中部分异常,就是抛出抽象的异常类型 (例如:throws UserInfoException)

Jimmer允许任何HTTP服务方法抛出异常

  • Java: 使用throws语法

  • Kotlin:使用@kotlin.Throws注解

UserController.java
package com.example.service;

import org.babyfish.jimmer.client.ThrowsAll;

@PostMapping("/user")
public void registerUser(
@RequestBody RegisterUserInput input
) throws UserInfoException {
...省略代码...
}

允许抛出异常簇中部分异常

所谓抛出异常簇中部分异常,就是抛出具体的异常类型 (例如:throws UserInfoException.PasswordTooShort)

UserController.java
@PostMapping("/user")
public void registerUser(
@RequestBody RegisterUserInput input
) throws UserInfoException.PasswordTooShort {
...省略代码...
}

比较和建议

提示

其实允许抛出整个异常簇就是抛出簇内所有异常。

即,抛出整个异常簇throws UserInfoException,其实和抛出具体异常类型列表throws UserInfoException.IllegalUserName, UserInfoException.PasswordTooShort, UserInfoException.PasswordsNotSame等价。

建议尽量抛出具体的异常类型列表,以减少客户端需要处理的异常的种类。

导出服务端异常

前面的工作只是声明REST API有可能抛出何种异常。接下来我们讨论在服务端真正抛出异常

抛出异常

UserController.java
@PostMapping("/user")
public void registerUser(
@RequestBody RegisterUserInput input
) throws UserInfoException.IllegalUserName {
if (...某些条件...) {
List<Character> illegalChars = ......
throw UserInfoException.illegalUserName(
"The user name is invalid",
illegalChars
);
}
...省略其他代码...
}
警告

内部代码抛出的异常的种类,不得超过对外声明的种类

将异常消息写入HTTP响应

提示

只要使用了Jimmer的Spring Boot Starter,无需任何工作,服务抛出任何继承自CodeBasedRuntimeException的异常都会被自动翻译。

翻译结果为

{
"family":"USER_INFO_ERROR_CODE",
"code":"ILLEGAL_USER_NAME",
"illegalChars": ["&", "`", "@"]
}

为了方便开发和测试,可以在application.ymlappliction.properties中配置

jimmer:
error-translator:
debug-info-supported: true

此配置将会在HTTP返回中附带便于在开发和测试阶段定位问题的信息。由于内容较长,请点击下面的按钮查看结果。

注意

此配置仅用于辅助开发和测试,绝不能在生产环境中开启此开关!

比如,请在application-dev.ymlapplication-test.yml中开启此开关,但绝对不能在application-prod.yml中开启。

客户端

生成的TypeScript代码

上文中我们已经定义了一个异常簇USER_INFO_ERROR_CODE,假如还有另外一个异常簇PLATFORM_ERROR_CODE,采用自动生成的方式,原始枚举类型为。

PlatformErrorCode.java
@ErrorFamily
public enum PlatformErrorCode {
PERMISSION_DENIED,
DATA_IS_FROZEN,
SERVICE_IS_SUSPENDED,
}

假设Controller代码如下

UserController.kt
@RestController
public class UserController {

@PutMapping("/user")
public void registerUser(
BookInput input
) throws
UserInfoException,
PlatformException.ServiceIsSuspended {
......
}

@DeleteMapping("/user")
public void deleteUser(
long id
) throws
UserInfoException,
PlatformException.PermissionDenied {
......
}
}

统计各种异常被Java Web API方法throws子句或Kotlin Web方法的@Throws注解使用的情况,不难发现

USER_INFO_ERROR_CODEPLATFORM_ERROR_CODE
ILLEGAL_USER_NAMEUsedPERMISSION_DENIEDUsed
PASSWORD_TOO_SHORTUsedDATA_IS_FROZENNot used!
PASSWORD_NOT_SAMEUsedSERVICE_IS_SUSPENDEDUsed
信息

请注意我们定义了6种具体的异常类型,但是Web Api只throws了5种,PLATFORM_ERROR_CODE簇中的DATA_IS_FORZEN从未被使用,后文会讨论。

生成的TypeScript代码如下

export type AllErrors =
{
readonly family: "PLATFORM_ERROR_CODE",
readonly code: "SERVICE_IS_SUSPENDED"
} |
{
readonly family: "PLATFORM_ERROR_CODE",
readonly code: "PERMISSION_DENIED"
} |
{
readonly family: "USER_INFO_ERROR_CODE",
readonly code: "ILLEGAL_USER_NAME",
readonly "illegalChars": ReadonlyArray<number>
} |
{
readonly family: "USER_INFO_ERROR_CODE",
readonly code: "PASSWORDS_NOT_SAME"
} |
{
readonly family: "USER_INFO_ERROR_CODE",
readonly code: "PASSWORD_TOO_SHORT"
} |
...省略其他代码...
;

export type ApiErrors = {
"userController": {
"registerUser": AllErrors & (
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'ILLEGAL_USER_NAME',
readonly [key:string]: any
} |
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'PASSWORD_TOO_SHORT',
readonly [key:string]: any
} |
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'PASSWORDS_NOT_SAME',
readonly [key:string]: any
} |
{
readonly family: 'PLATFORM_ERROR_CODE',
readonly code: 'SERVICE_IS_SUSPENDED',
readonly [key:string]: any
}
),
"deleteUser": AllErrors & (
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'ILLEGAL_USER_NAME',
readonly [key:string]: any
} |
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'PASSWORD_TOO_SHORT',
readonly [key:string]: any
} |
{
readonly family: 'USER_INFO_ERROR_CODE',
readonly code: 'PASSWORDS_NOT_SAME',
readonly [key:string]: any
} |
{
readonly family: 'PLATFORM_ERROR_CODE',
readonly code: 'PERMISSION_DENIED',
readonly [key:string]: any
}
)
},
...如果还有更多Controller有抛出客户端异常的行为,将会出现在这里...
};
  • AllErrors定义所有错误类型,包括familycode以及各异常的自定义字段 (灰色北京一样)

    信息

    前文我们讨论过,虽然我定义了6种异常,但是只有5种被Web Api使用。因此,TypeScript代码中只有5种错误定义。

  • ❷ Web Api种HTTP各接口抛出的错误类型。

TypeScript IDE的效果

上面的TypeScript代码较多,考虑到部分读者没有TypeScript的背景知识,这里罗列一些IDE的智能提示截图

  • 全局类型ApiErrors下面有两个选项: userControllerbookController

  • ApiErrors["userController"]下面有三个选项: loginlogoutregisterUser

  • ApiErrors["userController"]["registerUser"]family字段有两个选项: USER_INFO_ERROR_CODEPLATFORM_ERROR_CODE

  • 一旦family被确定为UserInfoErrorCodecode字段有三个选项: ILLEGAL_USER_NAMEPASSWORD_TOO_SHORTPASSWORDS_NOT_SAME

  • 一旦code被确定为ILLEGAL_USER_NAME,则可以使用illegalChars附加字段