Eisen's Blog

© 2024. All rights reserved.

spring boot validation

2021 February-07

之前在 当前的校验异常处理 提到了目前校验和异常处理的一些问题。最近找到了一些看起来更专业化的方案,这里对这几种校验方案做罗列。文中大量的内容都是从 naturalprogrammer 学习到,后文应该还会再去引用里面的内容。

有关预定义的 Bean Validation 注解(@Email, @NotBlank 等)就不再赘述。

Bean Validation 的异常处理时机

在 Spring Controller 中的方法里,标记有 @Valid 注解的字段会触发 Bean Validation。在 spring boot 异常处理 也介绍了,如果出现校验报错,会抛出异常 MethodArgumentNotValidException

@SpringBootApplication
@RestController
@RequestMapping("/")
public class ExceptionApplication {

  public static void main(String[] args) {
    SpringApplication.run(ExceptionApplication.class, args);
  }

  @PostMapping("hello")
  public Hello postHello(@RequestBody @Valid Hello hello) {
    return hello;
  }
}

class Hello {

  @NotBlank
  private String name;
  private int value;

  public String getName() {
    return name;
  }

  public int getValue() {
    return value;
  }
}

不过除了在 Controller 层级做校验外,可能在 Application Service 层级也经常有校验的需求。这部分的校验可以通过 @Validated 注解配合实现:

@SpringBootApplication
@RestController
@RequestMapping("/")
public class ExceptionApplication {

  @Autowired
  private HelloService helloService;

  public static void main(String[] args) {
    SpringApplication.run(ExceptionApplication.class, args);
  }

  @PostMapping("hello")
  public Hello postHello(@RequestBody Hello hello) {
    helloService.processHello(hello);
    return hello;
  }

}

@Validated
@Service
class HelloService {
  public void processHello(@Valid Hello hello) {}
}

如上述代码所示,在 HelloService 上添加注解 Validated 并给其参数 Hello 增加注解 @Valid 在该参数传递时也会触发校验逻辑。如果报错就抛出异常 ConstraintViolationException

Bean Validation 自定义注解

包含自定义校验

除了预定义的注解外,还可以创建自定义注解并提供相应的校验逻辑,下面就直接给出这么一个例子:

@NotBlank()
@Size(min=4, max=255)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=NameValidator.class)
public @interface Name {
  String message() default "Invalid name";
  Class[] groups() default {};
  Class[] payload() default {};
}

@Component
class NameValidator implements ConstraintValidator<Name, String> {
  private static Set<String> RESERVED_NAMES = new HashSet<String>() {{
    add("admin");
    add("administration");
    add("public");
    add("private");
  }};

  @Override
  public boolean isValid(String name, ConstraintValidatorContext context) {
    return name != null && !RESERVED_NAMES.contains(name.toLowerCase());
  }
}

可以看到 @Name 上面首先包含了两个预定义的校验注解 @NotBlank@Size 然后引入一个自定义的 NameValidator 用于校验所名字是否为保留字段,如果是就报错。

name validation
name validation

这里有一个没有解决的细节:

  @Override
  public boolean isValid(String name, ConstraintValidatorContext context) {
    // 虽然明明有额外的 @NotBlank 字段,但是依然不能保证这里传递的 name 是非空,
    // 感觉是这个 Validator 的优先级要高于其他预定义的校验
    return name != null && !RESERVED_NAMES.contains(name.toLowerCase());
  }

不包含自定义校验

当然,也可以给一个不包含额外 Validator 的注解:

@NotBlank()
@Size(min = 4, max = 255)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {}) // validator 为空
public @interface Name {
  String message() default "Invalid name";
  Class[] groups() default {};
  Class[] payload() default {};
}

这样的好处是可以把一些公共的校验逻辑抽取出来作为一个整体,方便理解和复用。

跨字段校验

上面提到的是单个字段的校验,如果涉及到多个字段一起的校验,就需要用到类级别的标注了:

@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=ConfirmValueValidator.class)
public @interface ConfirmValue {
  String message() default "Can not confirm value";
  Class[] groups() default {};
  Class[] payload() default {};
}

@Component
class ConfirmValueValidator implements ConstraintValidator<ConfirmValue, Hello> {
  @Override
  public boolean isValid(Hello value, ConstraintValidatorContext context) {
    return value.getConfirmValue() == value.getValue();
  }
}

// Hello
@ConfirmValue
class Hello {
  @Name
  private String name;
  private int value;
  private int confirmValue;
  public String getName() {
    return name;
  }
  public int getValue() {
    return value;
  }
  public int getConfirmValue() {
    return confirmValue;
  }
}

报错信息如下:

20210208185553

在上面的报错信息可以看到一个细节,报错信息无法下达到具体一个字段,而是落在了 hello 这个对象上。如果我们希望校验信息是落在具体的 hello.value 上可以有如下的修改:

@Component
class ConfirmValueValidator implements ConstraintValidator<ConfirmValue, Hello> {
  @Override
  public boolean isValid(Hello value, ConstraintValidatorContext context) {
    boolean isValid = value.getConfirmValue() == value.getValue();
    if (!isValid) {
      context.disableDefaultConstraintViolation();
      context
        .buildConstraintViolationWithTemplate( "{my.custom.template}" )
        .addPropertyNode("value").addConstraintViolation();
    }
    return isValid;
  }
}

ConstraintValidatorContext 允许设置具体如何覆盖默认的报错行为,以便展示想要的报错内容。这部分内容在 6.2.1. Custom property paths 有详尽的介绍。

Validator 中的依赖注入

最后,Validator 是可以像其他类一样做依赖注入的:


@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=UniqueEmailValidator.class)
public @interface UniqueEmail {
  String message() default "duplicated email";
  Class[] groups() default {};
  Class[] payload() default {};
}

@Component
class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
  @Autowired EmailService emailService;
  @Override
  public boolean isValid(String email, ConstraintValidatorContext context) {
    return !emailService.containsEmail(email);
  }
}

@Component
class EmailService {
  public boolean containsEmail(String email) {
    return email.equals("[email protected]");
  }
}

也就是说,与数据库通讯的东西也是可以做校验的,把与数据链接相关的对象注入就可以使用了。

Error Message 的处理

另外 validation 的报错信息也是需要做自定义的。这里给一个还不错的方案,不过没有深究一些细节。

国际化

首先,error message 是按照国际化的思路处理的:给一个 ValidationMessages_<LANG>.properties 文件。然后在注解的 message 字段通过 {<KEY_NAME>} 的方式给传递过来,这里给一些例子看就明白:

首先修改前面的 @Name 注解里面的 message:

public @interface Name {
  String message() default "{validation.name.default}"; // <----- 修改了这里
  Class[] groups() default {};
  Class[] payload() default {};
}

然后给两个新的 properties 文件,放在 src/main/resources 目录下(就是默认的放资源文件的地方,懂的都懂):

// ValidationMessages_en.properties
validation.name.default=Invalid name

// ValidationMessages_zh_CN.properties
validation.name.default=错误的名字格式

发个请求试试看,报错信息已经成了中文:

20210208194218

当然,如果强行给一个 Accept-Language: en 的 http 头就可以返回对应的英文信息:

20210208194626

再介绍下 message 中两种可以传递的参数:

  1. 注解中的参数可以直接用 {xx} 的形式传递,例如对于 @Size 注解有两个参数 minmax 可以将 message 写成以下的样子:
// ValidationMessages_en.properties
validation.name.size=name long shound between {min} and {max}

// ValidationMessages_zh_CN.properties
validation.name.size=名字的长度要在 {min} 和 {max} 之间
  1. 还有一个 ${} 可以做特殊的表达式解析,具体的文章可以看 Spring Validation Message Interpolation。最常用的是 ${validatedValue}validatedValue 是用户的输入。这个规则甚至是在类级别的校验注解里也是工作的:
// ValidationMessages_en.properties
validation.name.default=Invalid name
validation.name.size=name long shound between {min} and {max}
validation.confirm=value not equal: ${validatedValue.value} != ${validatedValue.confirmValue}

注意 这里遇到一个小坑,如果 validatedValue 所在的类不是 public 的,那么反射会出问题,导致解析失败。目前我测试的就是必须要把上文中 Hello.class 移动到一个独立的文件并且标记为 public 才可以工作。

中文处理

既然用到了 .properties 文件自然就遇到了这个 java 中臭名昭著的编码问题,中文显示乱码。这里按照如下对 Intellj 的配置做修改可以解决问题。

2021 07 26 15 23 56

顺便提一句,Intellij 里面的 Resource bundles 也挺好用的:

20210208212752

这部分介绍就到这里了,后文会继续介绍如何建立自定义 exception handler 体系以捕捉各种层级的报错并统一返回格式。


pytorch tensors

2021 January-18

最近在都 Deep Learning with Pytorch 这本书,目前为止感觉还是不错。尤其是它里面的手绘风插图,真的是印象深刻,好感顿生。

第三章介绍了 tensor 的数据结构,感觉讲的挺好的,做一个笔记,加深下印象。

本质

首先,tensor 感觉就和 numpy array 非常非常像,简单的讲就是多维矩阵。但是和 python 的 list 相比,tensor / numpy array 是被分配的连续内存空间。

数据类型

通过 dtype 参数可以指定 tensor 的数据类型。其名字和 numpy 里面的类型几乎一致:

  • torch.float32 / torch.float
  • torch.float64 / torch.double
  • torch.float16 / torch.half
  • torch.int8
  • torch.unint8
  • torch.int16 / torch.short
  • torch.int32 / torch.int
  • torch.int64 / torch.long
  • torch.bool

默认都是用 float 类型,double 也不会增加什么好处。然后 tensor 可以作为其他 tensor 的索引,但是必须是 long 类型。torch.tensor([2, 2]) 会给类型为 long 。需要注意的是 numpy 默认的浮点类型是 float64,而 tensor 默认的是 float32:

Storage

每一个 tensor 的真是数据是由 torch.Storage 来维护的,它本质上就是一段连续的内存空间而已。Tensor 仅仅是为 storage 提供了 offset 和 stride 之后的视图而已。

对于不同的 tensor 虽然它的 shape 不一样,但也可能指向了同一个的 storage。通过 tensor.storage() 可以直接访问它的内容:

不论 tensor 本身维度如何,其下的 storage 永远是一个一维数组。当然,修改 storage 的数据后,其 tensor 的数据也一定会发生变化。

size offset stride

  • offset 是 tensor 在 storage 的位置
  • size 是当前 tensor 的维度
  • stride 是这个 tensor 各个维度 +1 所需要跨越的 storage 索引个数

那么当 tensor 本身发生了 transpose 之后,其实就是 size stride 发生了变化,其 storage 并没有变化的。

device 属性

tensor 创建时可以指定其属于什么设备:

points_gpu = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]], device='cuda')

默认是 cpu。

当然也可以把 cpu 的 tensor 拷贝到 gpu:

points_gpu = points.to(device='cuda')

和 numpy 相互转换

只要都在主内存里,numpy array 和 tensor 之间的转换是非常高效的。那么这个好处就是可以享受 numpy 生态的各种类库。

points = torch.from_numpy(points_np)
points_np = points.numpy()

spring boot 异常处理

2020 December-07

最近相对比较系统的看了 spring mvc / spring boot 有关异常处理的内容。这里做一个总结。相关的代码放到了 springboot exception tutorial

之前确实没有比较系统的看 springmvc / springboot 有关异常处理的内容,这里直接记录一些我觉得比较有意义的知识片段以及具体的实践代码。

spring mvc 的异常处理逻辑

@SpringBootApplication
@RestController
@RequestMapping("/")
public class ExceptionApplication {

  public static void main(String[] args) {
    SpringApplication.run(ExceptionApplication.class, args);
  }
}

如上所示,这是一个最基础的 SpringBootApplication 我们首先提供一个直接报错的 Handler 看看发送请求之后的效果:

@SpringBootApplication
@RestController
@RequestMapping("/")
public class ExceptionApplication {

  public static void main(String[] args) {
    SpringApplication.run(ExceptionApplication.class, args);
  }

  @GetMapping("hello")
  public String getHello() {
    throw new IllegalStateException();
  }
}

发送请求 curl http://localhost:8080/hello 会返回以下结果:

{
    "timestamp": "2020-12-07T16:34:17.488+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "",
    "path": "/hello"
}

后台会打出对应的报错 stack:

java.lang.IllegalStateException: null
	at com.example.exception.ExceptionApplication.getHello(ExceptionApplication.java:39) ~[main/:na]
	...

那么这个默认的报错处理是谁处理的呢?在17.11.3 Handling Standard Spring MVC Exceptions做了解释,DefaultHandlerExceptionResolver 会按照具体的 Exception 类型为请求结果报送默认的 StatusCode 具体的逻辑我贴出来以下,比较后面用的还是比较多的:

2020 12 08 01 08 09

当然也可以去源代码里直接看。

不过文档里提到这个处理并不会返回 Body。那上面那个 JSON 的返回结果怎么来的呢?

在文档里,17.11.5 Customizing the Default Servlet Container Error Page 就做了解释:

When the status of the response is set to an error status code and the body of the response is empty, Servlet containers commonly render an HTML formatted error page. 当返回结果是错误码,但是返回的 body 为空的时候,servlet 通常会给返回一个错误页面。

而 springboot 则帮我们做了这个事情,给了一个 BasicErrorController 所以就有了上面的那个报错结果了。

知道了这个内容,我们就可以从两个地方入手去处理异常了。

覆盖默认的 BasicErrorController 的结果

比如在 sprinmvc 中我们常常在 @RequestBody 上使用 @Valid 注解,配合 BeanValidation 实现输入参数的校验,那么校验报错的时候会跑出异常 MethodArgumentNotValidException ,以下代码是我们什么都不处理的效果:

@SpringBootApplication
@RestController
@RequestMapping("/")
public class ExceptionApplication {

  public static void main(String[] args) {
    SpringApplication.run(ExceptionApplication.class, args);
  }

  @GetMapping("hello")
  public String getHello() {
    throw new IllegalStateException();
  }

  @PostMapping("hello")
  public Hello postHello(@RequestBody @Valid Hello hello) {
    return hello;
  }
}

class Hello {

  @NotBlank
  private String name;
  private int value;

  public String getName() {
    return name;
  }

  public int getValue() {
    return value;
  }
}

发送请求:

$curl --location --request POST 'localhost:8080/hello' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": ""
}'

返回结果:

{
    "timestamp": "2020-12-07T17:16:23.153+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/hello"
}

可以看到,非常的没有意义,知道错了,但是不知道具体错在哪里。Springboot 提供了对 DefaultErrorAttributes 扩展的方法,可以修改上面那个 JSON 的结构:

@Component
class MyCustomErrorAttributes extends DefaultErrorAttributes {

  @Override
  public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, ErrorAttributeOptions errorAttributeOptions) {
    errorAttributeOptions = errorAttributeOptions.including(Include.EXCEPTION,
        Include.BINDING_ERRORS);
    Map<String, Object> errorAttributes =
        super.getErrorAttributes(webRequest, errorAttributeOptions);
    return errorAttributes;
  }
}

在此发送一样的请求,会返回以下的结果:

{
    "timestamp": "2020-12-07T17:25:16.336+00:00",
    "status": 400,
    "error": "Bad Request",
    "exception": "org.springframework.web.bind.MethodArgumentNotValidException",
    "message": "",
    "errors": [
        {
            "codes": [
                "NotBlank.hello.name",
                "NotBlank.name",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
                    "codes": [
                        "hello.name",
                        "name"
                    ],
                    "arguments": null,
                    "defaultMessage": "name",
                    "code": "name"
                }
            ],
            "defaultMessage": "不能为空",
            "objectName": "hello",
            "field": "name",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotBlank"
        }
    ],
    "path": "/hello"
}

增加了两个额外的字段 Include.EXCEPTION Include.BINDING_ERRORS 可以更清楚的知道具体是什么异常以及具体什么报错。

如果这样子还不满意,可以直接去继承 BasicErrorController 通常这么做是为了支持更多的返回类型(比如 XML)。

注意 MethodArgumentNotValidException 还是蛮重要的,后续会经常出现。

RestControllerAdvice

如果不想要默认的处理,那么就需要写一个 RestControllerAdvice 来处理所有的异常。我知道还有一些其他方式处理自定义异常,但是目前来看灵活性最高的就是这个了。

@RestControllerAdvice
class MyExceptionHandler extends ResponseEntityExceptionHandler {

  @ExceptionHandler({IllegalStateException.class})
  protected ResponseEntity<Object> handleConflict(
      RuntimeException ex, WebRequest request) {
    return handleExceptionInternal(ex, "error",
        new HttpHeaders(), HttpStatus.CONFLICT, request);
  }
}

有了这部分代码,上文中的 @GetMapping("hello") public String getHello() { throw new IllegalStateException(); } 将会走 handleConflict 方法处理。注意这里 ResponseEntityExceptionHandler 是 Spring 推荐的 ControllerAdvice 的基类,它提供了大量 spring mvc 报的异常的处理方法,通过对这些方法做重载可以自定义具体的报错信息。这篇就先不讲了。后续补充。

参考

  1. Error Handling for REST with Spring
  2. spring lemon
  3. ResponseEntityExceptionHandler