一种优雅的API请求和响应实践

15 阅读9分钟

1. 为什么写这个文章

我们经常在各种教程,博客,网站中看到,知乎,掘金等网站中,看到各种各样的文章教程,告诉我们如何定义一个Controller响应体。一般建议的方案是这样

public class CommonResult<T> {
    private Integer code;
    private String message;
    private T data;
    // ... getters and setters
}

然后会让你在Controller中使用这个类来定义响应体

@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping("/get")
    public CommonResult<String> get() {
        // 模拟从数据库中获取用户
        UserDto user = new UserDto();
        user.setId(1L);
        user.setName("user" + id);

        // 构建响应体
        CommonResult<UserDto> result = new CommonResult<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(user);
        return result;
    }
}

这样每次set可能比较麻烦,我们可以考虑使用构造函数来初始化响应体,或者构造两个静态方法来创建成功和失败的响应体

public class CommonResult<T> {
    // 构造两个静态方法来创建成功和失败的响应体
    public static <T> CommonResult<T> success(T data) {
        return new CommonResult<>(200, "success", data);
    }

    public static <T> CommonResult<T> fail(Integer code, String message) {
        return new CommonResult<>(code, message, null);
    }

    public CommonResult() {
    }

    public CommonResult(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }
    private Integer code;
    private String message;
    private T data;
    // ... getters and setters
}

大家都推荐这么做,但真的有必要这么做吗?如果还真的有必要,Spring为什么一直没有提供这么一个类来帮我们实现这个逻辑呢?

答案就是完全没有必要。我们可以直接返回一个对象,spring会自动将其转换为json格式。所以我们可以直接返回一个对象,而不需要定义一个响应体类。

@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping("/{id}")
    public UserDto get(
            @PathVariable Long id
    ) {
        // 模拟从数据库中获取用户
        UserDto user = new UserDto();
        user.setId(1L);
        user.setName("user" + id);
        return user;
    }

}

是的,一切就是这么简单,不需要自定义响应类,不需要区分成功和失败,你不需要做任何跟业务无关的其它工作。

2 如何定义API

有了上面的基础,我们接下来看看如何实现Restful API。包括,新增,修改,删除,获取,分页查询

2.1 定义API

根据Restful API的设计规范,我们接下来看看如何定义API

  • POST /users:创建用户
  • GET /users/{id}:获取用户
  • PUT /users/{id}:修改用户
  • DELETE /users/{id}:删除用户
  • GET /users:分页查询用户列表

2.1.1 新增用户

新增的接口定义如下,要注意

  • 响应体的接收,是一个包含完整用户信息的请求体.
  • 在方法上标注@ResponseStatus(HttpStatus.CREATED),表示返回的状态码为201 Created。
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserDto save(
            @RequestBody UserRequest request
    ) {
        // 模拟保存用户到数据库,将request中的属性保存到存储中,返回保存后的用户
        // user = service.save(request);
        // 这里的user用了模拟保存后的用户
        UserDto user = new UserDto();
        user.setId(1L);
        user.setName("user");
        return user;
    }

新增用户时,我们在请求体中传递完整用户信息。例如:

{
    "name": "user1"
}

新增用户后,我们可以在响应体中返回新增的用户信息。例如:

{
    "id": 1,
    "name": "user1"
}

新增的请求和响应区别主要在于是否有id字段。请求体中没有id字段,而响应体中包含id字段。当然还可能包含其它的生成的信息,例如创建时间,更新时间等。

2.1.2 获取用户

获取用户的接口定义如下,要注意

  • 路径变量中包含用户id.
  • 返回的响应体是一个包含完整用户信息的对象.
    @GetMapping("/{id}")
    public UserDto get(
            @PathVariable Long id
    ) {
        // 模拟从数据库中获取用户
        UserDto user = new UserDto();
        user.setId(id);
        user.setName("user" + id);
        return user;
    }

请求路径如下

GET /users/{id}

请求体包括完整的用户信息,例如:

{
    "id": 1,
    "name": "user1"
}

如果用户不存在,我们可以返回一个404 Not Found状态码,并在响应体中返回一个错误信息.例如:

{
    "code": 404,
    "message": "user not found",
    "data": null
}

但我们的代码到当前为止,还不能正常处理这种情况,这种情况在后面会详细介绍.

2.1.3 修改用户

修改用户的接口定义如下,要注意

  • 路径变量中包含用户id.
  • 请求体中包含完整用户信息.
  • 在方法上标注@ResponseStatus(HttpStatus.CREATED),表示返回的状态码为201 Created。
  • 返回的响应体是一个包含完整用户信息的对象.
    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.CREATED)
    public UserDto update(@PathVariable Long id, UserRequest request) {
        // 模拟保存用户到数据库,将request中的属性保存到存储中,返回保存后的用户
        // user = service.update(id,request);
        // 这里的user用了模拟更新后的用户
        UserDto user = new UserDto();
        user.setId(1L);
        user.setName("user");
        return user;
    }

请求路径如下:

PUT /users/{id}

请求体中包含完整用户信息。例如:

{
    "name": "user1"
}

修改用户后,我们应该在响应体中返回修改后的用户信息。例如:

{
    "id": 1,
    "name": "user1"
}

2.1.4 删除用户

删除用户的接口定义如下,要注意

  • 路径变量中包含用户id.
  • 在方法上标注@ResponseStatus(HttpStatus.NO_CONTENT),表示返回的状态码为204 No Content。
  • 返回的响应体是一个空对象.
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(
            @PathVariable Long id
    ) {
        // 模拟删除用户从数据库
        // service.delete(id);
    }

请求路径如下:

DELETE /users/{id}

请求体为空. 删除用户后,我们可以返回一个204 No Content状态码,表示删除成功.

2.1.5 分页查询用户列表

分页查询用户列表时,我们可以在URL中添加分页参数,例如:/users?page=0&size=10。其中,page表示当前页码,size表示每页数量。 但分页查询时,我们为了适配前端的分页相关的逻辑,通常会在响应体中包含分页相关的信息,例如总页数,总记录数等.例如:

{
    "page": 0,
    "size": 10,
    "totalPages": 1,
    "totalElements": 1,
    "content": [
        {
            "id": 1,
            "name": "user1"
        }
    ]
}

这种方式不太符合RESTful风格,因为它将分页相关的信息放在了响应体中,而不是放在响应头中.如果我们要符合RESTful风格,我们可以将分页相关的信息放在响应头中.例如:

X-Total-Count: 1
X-Total-Pages: 1

但通常情况下,我们不会这么做.而是直接返回一个包括分页信息的响应体.而且spring为我们的分页请求和响应都提供了相应的对象封装,而且这些对象跟你使用的持久层框架无关. 他们分别是

  • Pageable 用户接受分页参数
  • Page 分页查询结果对象,在spring boot 3.3以前,我们通常使用Page对象来封装分页查询结果.
  • PagedModel 分页查询结果封装对象,在spring 3.3以后,我们通常使用PagedModel对象来封装分页查询结果.

而要使用这些类也非常简单,只需要在你的项目中引入spring-data-commons依赖即可.

以下是一个Spring boot 3.3的分页查询示例

    @GetMapping
    public PagedModel<UserDto> find(
            @RequestParam(required = false) String keyword,
            Pageable pageable
    ) {
        // 模拟从数据库中获取用户列表,查询方法通常返回一个page对象
        // Page<UserDto> result=service.find(keyword, pageable);
        Page<UserDto> result = new PageImpl<>(List.of(new UserDto(1L, "user")), pageable, 1L);
        // 模拟从数据库中获取用户列表
        return new PagedModel<>(result);
    }

spring boot会自动解析分页参数,并将其转换为Pageable对象.我们不需要做任何额外的操作.如果你使用spring data 相关组件, 比如sping-data-jpa,spring-data-mongodb等,你可以直接在你的查询方法中使用Pageable参数,spring boot会自动将其转换为对应的分页查询. 如果你使用的是mybatis-plus等其它持久层框架,你也可以从pageable中获取分页参数,并手动构建分页查询.

2.2、如何处理异常

在实际开发中,我们需要考虑如何处理异常。包括要获取的对象不存在,请求参数错误,数据库操作异常等. 最常见的就是我们需要获取的对象不存在时,我们需要返回一个404 Not Found状态码.但我们的正常业务逻辑中,并不包括相关的代码. 这时,我们就可以借助全局异常处理机制,来实现相关的逻辑.

2.2.1 查询对象不存在时的异常处理

首先,我们需要定义一个自定义的异常类,例如: 注意标注@ResponseStatus(HttpStatus.NOT_FOUND),表示返回的状态码为404 Not Found。

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ObjectNotFoundException extends RuntimeException {

    @Serial
    private static final long serialVersionUID = -2269660443026133324L;

    public ObjectNotFoundException(String message) {
        super(message);
    }
}

当查询对象不存在时,我们可以抛出这个异常,例如:

    @RequestMapping("/{id}")
    public UserDto get(
            @PathVariable Long id
    ) {
        // 模拟从数据库中获取用户
        if (id > 1) {
            throw new ObjectNotFoundException("user not found");
        } else {
            UserDto user = new UserDto();
            user.setId(1L);
            user.setName("user" + id);
            return user;
        }
    }

通常情况下,我们不需要做任何额外的操作.因为spring boot会自动将这个异常转换为一个404 Not Found状态码. 示例:

查询URL:

GET /users/2

响应状态码:

404 Not Found

响应体如下

{
	"timestamp": "2025-11-13T15:43:18.265+00:00",
	"status": 404,
	"error": "Not Found",
	"path": "/users/3"
}

默认情况下,错误的message不会被包含在响应体中,这时,我们可以通过配置来开启这个功能. 例如,在application.properties中添加以下配置:

server.error.include-message=always

修改该配置后,应体如下

{
	"timestamp": "2025-11-13T15:43:18.265+00:00",
	"status": 404,
	"error": "Not Found",
	"message": "user not found",
	"path": "/users/3"
}

如果我们要进行精细的异常处理,可以定义多个异常处理器,分别处理不同的异常.如下:

  • 将类ErrorControllerAdvice标注为@ControllerAdvice,表示这是一个全局异常处理器.
  • 定义一个异常处理器方法,标注为@ExceptionHandler(ObjectNotFoundException.class),表示当抛出ObjectNotFoundException异常时,调用这个方法.
  • 标注@ResponseStatus(HttpStatus.NOT_FOUND),表示返回的状态码为404 Not Found.
  • 标注@ResponseBody,表示返回的响应体为一个Map对象.
  • 返回一个Map对象,包含错误码,错误消息,以及其他自定义的信息.
  • 定义一个全局异常处理器(这非常重要),因为它能给你的未处理的异常兜底.处理所有未被其他异常处理器捕获的异常.
@ControllerAdvice
public class ErrorControllerAdvice {

    @ExceptionHandler(ObjectNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ResponseBody
    public Map<String, Object> handleException(ObjectNotFoundException ex) {
        // 记录日志
        log.error("Object not found: {}", ex.getMessage(), ex);
        return Map.of(
                "code", HttpStatus.NOT_FOUND.value(),
                "message", ex.getMessage(),
                "other", "other value"
        );
    }
    
   /**
     * 定义一个全局异常处理器,处理所有未被其他异常处理器捕获的异常.
     * @param ex 异常信息
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ResponseBody
    public Map<String, Object> handleException(Exception ex) {
        log.error("Internal server error: {}", ex.getMessage(), ex);
        return Map.of(
                "code", HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "message", ex.getMessage(),
                "other", "other value"
        );
    }
}

我们可以定义多个异常处理器,分别处理不同的异常。这样,在我们的业务代码中,只需要根据业务场景,抛出不同的异常即可实现错误的响应。 而不需要我们在业务代码中进行都多余的处理,增强代码可读性。

总结

  1. 不要自定义所谓的通用响应体,而是使用spring boot默认的响应体.因为它已经足够。
  2. 通过抛出异常来处理各种非正常情况。
  3. 可以通过定义全局异常处理器,处理所有未被其他异常处理器捕获的异常.