REST API 最佳实践
概述
REST(Representational State Transfer)是一种基于 HTTP 协议的架构风格。Spring MVC 提供了丰富的注解来支持 RESTful API 开发。本文系统性地介绍响应体设计、版本管理、HATEOAS、分页规范、接口文档生成及企业级实战落地。
一、统一响应体 Result<T> 泛型设计
统一响应体让前端可以统一处理成功、失败和异常情况,降低对接成本。
java
public class Result<T> implements Serializable {
private int code;
private String message;
private T data;
private long timestamp;
private String traceId;
private Result() { this.timestamp = System.currentTimeMillis(); }
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.code = ResultCode.SUCCESS.getCode();
result.message = ResultCode.SUCCESS.getMessage();
result.data = data;
return result;
}
public static <T> Result<T> failure(int code, String message) {
Result<T> result = new Result<>();
result.code = code;
result.message = message;
return result;
}
}java
public enum ResultCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未授权"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "资源不存在"),
CONFLICT(409, "资源冲突"),
TOO_MANY_REQUESTS(429, "请求过于频繁"),
INTERNAL_ERROR(500, "服务器内部错误"),
USER_NOT_FOUND(10001, "用户不存在"),
TOKEN_EXPIRED(10004, "令牌已过期"),
PERMISSION_DENIED(10006, "权限不足"),
VALIDATION_FAILED(10010, "数据校验失败");
private final int code;
private final String message;
ResultCode(int code, String message) { this.code = code; this.message = message; }
public int getCode() { return code; }
public String getMessage() { return message; }
}java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.failure(ResultCode.VALIDATION_FAILED.getCode(), msg);
}
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException ex) {
return Result.failure(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleUnknown(Exception ex) {
log.error("系统内部异常", ex);
return Result.failure(ResultCode.INTERNAL_ERROR);
}
}json
{
"code": 200, "message": "操作成功",
"data": { "id": 1, "username": "admin", "email": "admin@example.com" },
"timestamp": 1753200000000, "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
---
## 二、API 版本管理 4 种策略
### 2.1 URI 路径版本
```java
@RestController @RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
return Result.success(new UserVO(id, "v1-user"));
}
}
@RestController @RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public Result<UserV2VO> getUser(@PathVariable Long id) {
return Result.success(new UserV2VO(id, "v2-user", "user@example.com"));
}
}优点:直观、易于缓存。缺点:URL 语义被版本号污染。
2.2 请求头版本
java
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion { String value(); }java
@RestController
public class UserVersionedController {
@GetMapping("/api/users") @ApiVersion("1.0")
public Result<UserVO> getUsersV1() { return Result.success(new UserVO(1L, "v1-user")); }
@GetMapping("/api/users") @ApiVersion("2.0")
public Result<UserV2VO> getUsersV2() { return Result.success(new UserV2VO(1L, "v2-user", "user@example.com")); }
}text
GET /api/users
X-API-Version: 1.0优点:URL 干净。缺点:浏览器调试不便,部分网关无法识别。
2.3 请求参数版本
java
@RestController
public class UserParamVersionController {
@GetMapping(value = "/api/users", params = "version=1")
public Result<UserVO> getUsersV1() { return Result.success(new UserVO(1L, "v1-user")); }
@GetMapping(value = "/api/users", params = "version=2")
public Result<UserV2VO> getUsersV2() { return Result.success(new UserV2VO(1L, "v2-user", "user@example.com")); }
}text
GET /api/users?version=1优点:实现简单。缺点:参数污染查询语义。
2.4 Content-Type 版本(Accept Header 协商)
java
@RestController
public class UserContentTypeController {
@GetMapping(value = "/api/users", produces = "application/vnd.example.v1+json")
public Result<UserVO> getUsersV1() { return Result.success(new UserVO(1L, "v1-user")); }
@GetMapping(value = "/api/users", produces = "application/vnd.example.v2+json")
public Result<UserV2VO> getUsersV2() { return Result.success(new UserV2VO(1L, "v2-user", "user@example.com")); }
}text
GET /api/users
Accept: application/vnd.example.v1+json优点:符合 RESTful 规范。缺点:客户端实现复杂。
2.5 四种策略对比
| 策略 | 可缓存性 | URL 污染 | 调试难度 | 网关兼容 | 推荐场景 |
|---|---|---|---|---|---|
| URI 路径 | 高 | 有 | 低 | 高 | 公开 API、对外服务 |
| 请求头 | 中 | 无 | 中 | 中 | 内部微服务 |
| 请求参数 | 中 | 有 | 低 | 高 | 过渡期、快速迭代 |
| Content-Type | 高 | 无 | 高 | 低 | 严格 REST 风格 |
三、HATEOAS 概念与实现
HATEOAS(Hypermedia as the Engine of Application State)是 REST 成熟度模型 Level 3 的核心要求:API 响应中包含超媒体链接,客户端通过链接导航而非预设 URL。
yaml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>java
public class UserModel extends RepresentationModel<UserModel> {
private Long id; private String username; private String email;
}java
@RestController @RequestMapping("/api/users")
public class UserHateoasController {
@GetMapping("/{id}")
public EntityModel<UserModel> getUser(@PathVariable Long id) {
UserModel user = new UserModel(id, "alice", "alice@example.com");
return EntityModel.of(user,
linkTo(methodOn(UserHateoasController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(UserHateoasController.class).listUsers()).withRel("users"));
}
@GetMapping
public CollectionModel<UserModel> listUsers() {
List<UserModel> users = Arrays.asList(
new UserModel(1L, "alice", "alice@example.com"),
new UserModel(2L, "bob", "bob@example.com"));
users.forEach(u -> u.add(linkTo(methodOn(UserHateoasController.class).getUser(u.getId())).withSelfRel()));
return CollectionModel.of(users, linkTo(methodOn(UserHateoasController.class).listUsers()).withSelfRel());
}
}json
{ "id": 1, "username": "alice", "_links": {
"self": { "href": "http://localhost:8080/api/users/1" },
"users": { "href": "http://localhost:8080/api/users" }
} }自定义 Link 与 Affordance:
java
Link templatedLink = Link.of("/api/users{?page,size}", "search");
Link selfLink = linkTo(methodOn(UserHateoasController.class).getUser(id)).withSelfRel()
.andAffordance(afford(HttpMethod.DELETE)).andAffordance(afford(HttpMethod.PATCH));3.3 HATEOAS 优缺点
优点:API 自描述、降低客户端耦合。缺点:响应体积增大、实现复杂度提高。
现实考量:完全 HATEOAS 在微服务中较少见,建议在核心对外开放 API 上使用。
四、分页规范
4.1 Spring 分页机制
yaml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>java
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByStatus(int status, Pageable pageable);
Slice<User> findByUsernameContaining(String keyword, Pageable pageable);
}Page<T>:包含总记录数、总页数等完整元数据Slice<T>:仅包含是否有下一页,适合无限滚动
java
@RestController @RequestMapping("/api/users")
public class UserPageController {
@GetMapping
public Result<Page<User>> listUsers(
@PageableDefault(page = 0, size = 20, sort = "id,asc") Pageable pageable) {
return Result.success(userRepository.findAll(pageable));
}
}text
GET /api/users?page=0&size=10&sort=id,asc&sort=username,desc4.2 自定义分页响应
直接暴露 Page 会导致序列化耦合,推荐自定义 DTO。
java
public class PageResult<T> {
private List<T> content;
private int page, size;
private long totalElements;
private int totalPages;
private boolean hasPrevious, hasNext, first, last;
public PageResult(Page<T> page) {
this.content = page.getContent();
this.page = page.getNumber();
this.size = page.getSize();
this.totalElements = page.getTotalElements();
this.totalPages = page.getTotalPages();
this.hasPrevious = page.hasPrevious();
this.hasNext = page.hasNext();
this.first = page.isFirst();
this.last = page.isLast();
}
public static <T> PageResult<T> of(Page<T> page) { return new PageResult<>(page); }
}java
@GetMapping
public Result<PageResult<UserVO>> listUsers(@PageableDefault(page = 0, size = 20) Pageable pageable) {
Page<UserVO> voPage = userRepository.findAll(pageable).map(UserVO::new);
return Result.success(PageResult.of(voPage));
}json
{
"code": 200,
"data": {
"content": [{ "id": 1, "username": "alice" }],
"page": 0, "size": 20, "totalElements": 42,
"totalPages": 3, "hasPrevious": false, "hasNext": true
},
"timestamp": 1753200000000
}4.3 Cursor 分页(Keyset Pagination)
java
@RestController @RequestMapping("/api/v2/users")
public class UserCursorController {
@GetMapping
public Result<List<UserVO>> listUsers(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int limit) {
String sql = cursor == null
? "SELECT * FROM users ORDER BY id ASC LIMIT ?"
: "SELECT * FROM users WHERE id > ? ORDER BY id ASC LIMIT ?";
List<UserVO> users = jdbcTemplate.query(sql,
new BeanPropertyRowMapper<>(UserVO.class), cursor != null ? cursor : 0, limit);
return Result.success(users);
}
}| 维度 | Page 分页 | Cursor 分页 |
|---|---|---|
| 偏移量效率 | 深度翻页慢 | 始终高效 |
| 随机跳页 | 支持 | 不支持 |
| 实现复杂度 | 低 | 中 |
五、OpenAPI / Swagger 文档自动生成
5.1 springdoc-openapi 配置
yaml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>yaml
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
packages-to-scan: com.example.controller5.2 全局 OpenAPI 信息配置
java
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info().title("开放平台 RESTful API").version("1.0.0")
.contact(new Contact().name("API 支持团队").email("api-support@example.com")))
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
.components(new Components().addSecuritySchemes("BearerAuth",
new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT")));
}
}5.3 Controller 注解
java
@RestController @RequestMapping("/api/users")
@Tag(name = "用户管理", description = "用户 CRUD 接口")
public class UserDocController {
@Operation(summary = "根据 ID 获取用户")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "成功",
content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "404", description = "用户不存在")
})
@GetMapping("/{id}")
public Result<UserVO> getUser(@Parameter(description = "用户 ID") @PathVariable Long id) {
return Result.success(new UserVO(id, "alice"));
}
@Operation(summary = "创建用户") @PostMapping
public Result<UserVO> createUser(@RequestBody @Valid UserCreateRequest request) {
return Result.success(new UserVO(1L, request.getUsername()));
}
}5.4 分组文档与生产安全
yaml
springdoc:
group-configs:
- group: user-api
paths-to-match: /api/users/**
packages-to-scan: com.example.controller.user
- group: order-api
paths-to-match: /api/orders/**
packages-to-scan: com.example.controller.orderyaml
# application-prod.yml
springdoc:
swagger-ui:
enabled: false
api-docs:
enabled: false隐藏特定端点:@Operation(hidden = true)
六、实战:开放平台 RESTful API 规范落地
6.1 统一日志——MDC 请求追踪
java
@Component @Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String traceId = httpRequest.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
httpResponse.setHeader("X-Trace-Id", traceId);
try { chain.doFilter(request, response); }
finally { MDC.remove("traceId"); }
}
}java
@Aspect @Component
public class ApiLogAspect {
private static final Logger log = LoggerFactory.getLogger("API_ACCESS_LOG");
@Around("@within(org.springframework.web.bind.annotation.RestController)")
public Object logApiAccess(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return joinPoint.proceed();
HttpServletRequest request = attributes.getRequest();
long start = System.currentTimeMillis();
log.info("[REQUEST] {} {} | args={}", request.getMethod(), request.getRequestURI(), joinPoint.getArgs());
try {
Object result = joinPoint.proceed();
log.info("[RESPONSE] {} {} | elapsed={}ms", request.getMethod(), request.getRequestURI(),
System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
log.error("[RESPONSE] {} {} | status=500 | elapsed={}ms | error={}", request.getMethod(),
request.getRequestURI(), System.currentTimeMillis() - start, e.getMessage());
throw e;
}
}
}6.2 签名验签 HMAC
java
public class HmacSigner {
public static String sign(String secret, String content) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(content.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException("HMAC 签名失败", e);
}
}
public static boolean verify(String secret, String content, String expectedSign) {
return MessageDigest.isEqual(sign(secret, content).getBytes(StandardCharsets.UTF_8),
expectedSign.getBytes(StandardCharsets.UTF_8));
}
}java
@Component
public class SignVerifyInterceptor implements HandlerInterceptor {
private static final long SIGN_VALID_DURATION = 5 * 60 * 1000L;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (handler instanceof HandlerMethod hm && hm.getMethodAnnotation(SkipSign.class) != null) {
return true;
}
String appId = request.getHeader("X-App-Id");
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
if (appId == null || timestamp == null || nonce == null || signature == null) {
writeError(response, 400, "缺少签名头信息"); return false;
}
if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > SIGN_VALID_DURATION) {
writeError(response, 401, "请求已过期"); return false;
}
String secret = getSecretByAppId(appId);
if (secret == null) { writeError(response, 401, "无效的 AppId"); return false; }
String body = (request instanceof CachedBodyHttpServletRequest cached) ? cached.getBody() : "";
String signContent = appId + "\n" + timestamp + "\n" + nonce + "\n" + body;
if (!HmacSigner.verify(secret, signContent, signature)) {
writeError(response, 401, "签名验证失败"); return false;
}
return true;
}
private void writeError(HttpServletResponse response, int code, String message) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code);
response.getWriter().write(new ObjectMapper().writeValueAsString(Result.failure(code, message)));
}
private String getSecretByAppId(String appId) {
return Map.of("app_001", "sk-xxxxxxxxxxxx").get(appId);
}
}java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired private SignVerifyInterceptor signVerifyInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(signVerifyInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**", "/api-docs/**", "/swagger-ui/**");
}
}6.3 频率限制(滑动窗口)
java
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int windowSeconds() default 1;
int maxRequests() default 100;
String message() default "请求过于频繁,请稍后再试";
}java
@Aspect @Component
public class RateLimitAspect {
private final Map<String, Deque<Long>> counterMap = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object doRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return joinPoint.proceed();
String clientIp = getClientIp(attributes.getRequest());
String appId = attributes.getRequest().getHeader("X-App-Id");
String key = clientIp + ":" + (appId != null ? appId : "anonymous");
if (!allowRequest(key, rateLimit.windowSeconds(), rateLimit.maxRequests())) {
throw new TooManyRequestsException(rateLimit.message());
}
return joinPoint.proceed();
}
private synchronized boolean allowRequest(String key, int windowSeconds, int maxRequests) {
long now = System.currentTimeMillis();
long windowStart = now - (windowSeconds * 1000L);
Deque<Long> deque = counterMap.computeIfAbsent(key, k -> new LinkedList<>());
while (!deque.isEmpty() && deque.peekFirst() < windowStart) deque.pollFirst();
if (deque.size() >= maxRequests) return false;
deque.addLast(now);
return true;
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
return (ip != null && !ip.isBlank()) ? ip : request.getRemoteAddr();
}
}java
@RestController @RequestMapping("/api/users")
public class UserController {
@RateLimit(windowSeconds = 1, maxRequests = 10)
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
return Result.success(new UserVO(id, "alice"));
}
}6.4 完整配置与请求流程
yaml
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
default-property-inclusion: non_null
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
packages-to-scan: com.example.openapi.controller
open-platform:
sign:
valid-duration: 300000
rate-limit:
default-window: 1
default-max-requests: 100text
客户端 开放平台服务
| |
|--- POST /api/users ------------------------> |
| Headers: X-App-Id, X-Timestamp, |
| X-Nonce, X-Signature |
| Body: {"username":"alice"} |
| |
| [TraceIdFilter] → [SignVerifyInterceptor] |
| → [RateLimitAspect] → [ApiLogAspect] |
| → [Controller] |
| |
|<-- Result<UserVO> ----------------------------|
| Headers: X-Trace-Id: a1b2c3d4... |
| Body: {"code":200,"data":{...}} |总结
本文系统性地介绍了 Spring MVC 中构建 RESTful API 的核心实践:
- 统一响应体
Result<T>:通过泛型设计 + 全局异常处理,保证 API 响应格式一致 - API 版本管理:URI 路径、请求头、请求参数、Content-Type 四种策略各有适用场景
- HATEOAS:实现 REST 成熟度模型 Level 3,让 API 具备自描述能力
- 分页规范:基于 Spring Data
Page/Pageable实现标准分页,引入 Cursor 分页应对大数据量 - OpenAPI/Swagger:使用 springdoc-openapi 自动生成文档,支持分组、生产环境关闭
- 开放平台实战:统一日志(TraceId)、HMAC 签名验签、频率限制(滑动窗口),形成完整 API 治理方案