在用 Spring MVC 构建 RESTful API 时进行验证和异常处理

这一部分介绍一下我发现的在 Spring MVC 下进行输入处理以及验证信息反馈方面的一些思路。完整的示例代码见 GitHub

区别请求对象和实体对象

目前我所构建的 spring boot 的服务都是 REST 风格的 API 了,很多时候处理的都是 json 的数据。在获取的 HTTP 请求中,BODY 中所传的也都不再是表单而是一个 json 了。看了很多的例子发现在 demo 中喜欢直接把输入转化成一个实体对象。比如我要注册用户,那么我就直接把请求中的 json 映射成一个 User,多方便。但是很明显,它只能处理简单的情况,强行使用容易把真正的业务实体中加入很多诡异的功能,比如什么 password confirm,这都是以前很多代码中会出现的。实际上就算是处理表单型的数据,也早就有了 form object 的概念了,不能够说换成 json 就倒回去吧,说白了这依然是个表单而已。

区别表单验证和业务逻辑验证

有输入就要有验证,表单验证一直是一个非常蛋疼的问题,一方面它有很多内容很无聊,比如检查非空呀,控制输入的类型呀,判断长度呀,需要一个标准的方法避免这种重复的代码。另一方面,有的时候验证中又存在业务逻辑,那到底把这个验证放到哪里以及用神马方法验证都是一个很容易让人犹豫不决的事情。

要解决这个,最好的办法就是明确的区分那种和业务逻辑关系不大的格式的验证以及业务逻辑中的验证。对于长度、必选、枚举、是不是电子邮箱、是不是 URL 用 Bean Validation 解决。对于有关业务逻辑的,比如是不是合法的产品型号、是不是重复的注册名等都在 Controller 中进行处理。下面分别对两种验证方式进行说明。

1. Bean Validation 异常处理

Spring MVC 中对异常的处理基本都是在 Controller 中抛出一个具体的 Runtime 异常(比如 ProductNotFoundException,然后通过 ExceptionHandler 的方式去捕捉并转换为具体的报错请求。具体的示例见这里,我就不再重复了,我们在这里会使用 ControllerAdvice 的方式处理这种比较通用的情况,对于某些特殊处理的情况在 Controller 加 ExceptionHandler 即可。这里想强调的是如何把一个报错转化成一个格式良好的、便于 RESTful API 消费方处理的 JSON 的。

首先,有一个 UsersApi 用于创建用户的方法:

@RestController
@RequestMapping("/users")
public class UsersApi {
    private UserRepository userRepository;

    @Autowired
    public UsersApi(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @RequestMapping(method = POST)
    public ResponseEntity createUser(@Valid @RequestBody CreateUser createUser, 
                                     BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException("Error in create user", bindingResult);
        }
        User user = new User(UUID.randomUUID().toString(), createUser.getUsername());
        userRepository.save(user);
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

可以看到,上面的 createUser 方法中,有两个参数 CreateUserBindingResult。其中 CreateUser 是一个 Form Object 用于处理创建用户的输入,它通过 Bean Validation 的方式定义输入的一些要求,通过 @Valid 的注解可是让 java 自动帮我们进行表单验证,表单验证的结果就被放在 BindingResult 中了。在这里处理报错的好处在于可以附上在当前 Controller 中特有的 message (Error in create user)CreateUser 类如下所示。

@Getter // lombok 注解
public class CreateUser {
    @NotBlank // hibernate.validator 注解
    private String username;
}

接着,我们有一个测试用例覆盖错误输入的情况。可以看到 should_400_with_wrong_parameter 通过 rest assured 方法对我们想要获得的结果格式进行了测试,setUp 方法以及 rest assured 内容见 [在 Spring Boot 1.5.3 中进行 Spring MVC 测试]({% post_url 2017-05-04-spring-mvc-and-test %})。

@RunWith(SpringRunner.class)
public class UsersApiTest {

    private UserRepository userRepository;

    @Before
    public void setUp() throws Exception {
        userRepository = mock(UserRepository.class);
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UsersApi(userRepository))
                                         .setControllerAdvice(
                                             new CustomizeExceptionHandler()).build();
        RestAssuredMockMvc.mockMvc(mockMvc);
    }

    @Test
    public void should_400_with_wrong_parameter() throws Exception {

        Map<String, Object> wrongParameter = new HashMap<String, Object>() {{
            put("name", "aisensiy");
        }};

        given()
            .contentType("application/json")
            .body(wrongParameter)
            .when().post("/users")
            .then().statusCode(400)
            .body("fieldErrors[0].field", equalTo("username"))
            .body("fieldErrors.size()", equalTo(1));
    }
}

错误情况下 Api 的 Response 大概是这个样子:

{
    "code": "InvalidRequest",
    "message": "Error in create user",
    "fieldErrors": [
        {
            "resource": "createUser", 
            "field": "username", 
            "code": "NotBlank",
            "message": "may not be empty"
        }
    ]
}

这里我们重点看 InvalidRequestException 的处理。

@RestControllerAdvice
public class CustomizeExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({InvalidRequestException.class})
    public ResponseEntity<Object> handleInvalidRequest(RuntimeException e, 
                                                       WebRequest request) {
        InvalidRequestException ire = (InvalidRequestException) e;

        List<FieldErrorResource> errorResources = 
            ire.getErrors().getFieldErrors().stream().map(fieldError ->
            new FieldErrorResource(fieldError.getObjectName(), 
                                   fieldError.getField(), 
                                   fieldError.getCode(),
                                   fieldError.getDefaultMessage())
                                  ).collect(Collectors.toList());

        ErrorResource error = new ErrorResource("InvalidRequest", 
                                                ire.getMessage(), 
                                                errorResources);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        return handleExceptionInternal(e, error, headers, BAD_REQUEST, request);
    }
}

handleInvalidRequest 方法把一个 InvalidRequestException 中的 FieldErrors 转化为 FieldErrorResource 然后通过一个 ErrorResource 方法包装后交给 handleExceptionInternal 方法并最终转换为一个 ResponseEntity

2. 业务逻辑错误处理

对于业务逻辑的报错,我们依然遵循上面的思路:将错误通过 BingResult 包装后抛出 InvalidRequestException。这里提供一个处理重复用户名的情况,需要在原来的 UsersApi 中做一些修改:

@RestController
@RequestMapping("/users")
public class UsersApi {
    private UserRepository userRepository;

    @Autowired
    public UsersApi(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @RequestMapping(method = GET)
    public List<UserData> getUsers() {
        return new ArrayList<>();
    }

    @RequestMapping(method = POST)
    public ResponseEntity createUser(@Valid @RequestBody CreateUser createUser, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException("Error in create user", bindingResult);
        }

        if (userRepository.findByUsername(createUser.getUsername()).isPresent()) {
            bindingResult.rejectValue("username", "Dupliated", "duplicated username");
            throw new InvalidRequestException("Error in create user", bindingResult);
        } // 处理重复用户名的问题
        User user = new User(UUID.randomUUID().toString(), createUser.getUsername());
        userRepository.save(user);
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

可以看到,通过使用 bindingResult.rejectValue 方法可以把我们自定义的报错添加进去.这里的报错使用了 UserRepository 如果想要在别的地方去处理类似的验证就需要注入它,远不如在这里来的简单清晰。对其的测试如下:

@Test
public void should_get_400_with_duplicated_username() throws Exception {
    User user = new User("123", "abc");
    when(userRepository.findByUsername(eq("abc"))).thenReturn(Optional.of(user));

    Map<String, Object> duplicatedUserName = new HashMap<String, Object>() {{
        put("username", "abc");
    }};

    given()
        .contentType("application/json")
        .body(duplicatedUserName)
        .when().post("/users")
        .then().statusCode(400)
        .body("message", equalTo("Error in create user"))
        .body("fieldErrors[0].field", equalTo("username"))
        .body("fieldErrors[0].message", equalTo("duplicated username"))
        .body("fieldErrors.size()", equalTo(1));
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • Spring Boot 参考指南 介绍 转载自:https://www.gitbook.com/book/qbgb...
    毛宇鹏阅读 46,358评论 6 343
  • 主要内容 将web请求映射到Spring控制器 绑定form参数 验证表单提交的参数 写在前面:关于Java We...
    程序熊大阅读 8,920评论 15 73
  • 〖作者:晴意是个学生,去上学也是理所当然的啦!晴:我不要上学!〗 【学校】 晴意正走在学院里,突然一个男生扔了一块...
    墨夜幻阅读 171评论 1 0
  • 今天似乎有点倒霉。早上把刚剥好的鸡蛋掉到地上,早餐没了。拿起杯子准备喝口茶压压惊,发现桌面放着的笔记本已经被浸...
    婧柯阅读 853评论 1 1