前言
Roy Thomas Fielding 在其博士论文《Architectural Styles and the Design of Network-based Software Architectures》第五章中系统阐述了表述性状态转移(Representational State Transfer,REST)架构风格。REST 并非一种协议或标准,而是一组架构约束,其核心目标是在分布式超媒体系统中实现可伸缩性、通用性、独立部署性和延迟优化。
在日常 Java 后端开发中,我们构建的 HTTP 接口本质上就是 REST 架构约束的具体落地。然而,许多开发者对 RESTful 的理解停留在"URL 看起来好看"的层面,缺乏对 HTTP 协议语义的深入理解。本文基于 Fielding 论文的核心思想,结合 Java(Spring Boot)实践,系统说明如何规范地设计 Java 接口。
一、资源的识别:URL 设计规范
1.1 论文原旨
"REST 中信息的关键抽象是资源。任何可以被命名的事物都可以是资源:文档或图像、临时服务、其他资源的集合、非虚拟对象等等。资源是到一组实体的概念映射,而不是在任何特定时间点对应于该映射的实体。"
原文(Section 5.2.1.1):
"The key abstraction of information in REST is a resource. Any information that can be named can be a resource: a document or image, a temporal service (e.g. "today's weather in Los Angeles"), a collection of other resources, a non-virtual object (e.g. a person), and so on. In other words, any concept that might be the target of an author's hypertext reference must fit within the definition of a resource. A resource is a conceptual mapping to a set of entities, not the entity that corresponds to the mapping at any particular point in time."
REST 的第一个接口约束是资源标识 。URL 作为资源标识符,应当标识的是"概念"而非"实现"。URL 中不应暴露技术实现细节(如 .do、.action、.jsp),也不应包含动词,因为动词属于 HTTP 方法(统一接口)的职责。
1.2 规范要求
| 规则 | 说明 |
|---|---|
| 使用名词而非动词 | 操作语义由 HTTP 方法表达 |
| 使用复数形式 | 表示资源的集合 |
| 使用小写字母,单词间用连字符 | 保持一致性 |
| 嵌套资源表达从属关系 | 最多不超过三级 |
| 不暴露技术实现 | 不含 .json、.xml 后缀 |
1.3 示例
反例 --- 在 URL 中嵌入动词和技术细节:
/getUserById?id=123
/user/delete?id=123
/api/user/list.do
/createOrder.action?userId=123&productId=456
正例 --- 用名词标识资源,用路径表达层级:
GET /users/123
GET /users
POST /users
PUT /users/123
DELETE /users/123
GET /users/123/orders
POST /users/123/orders
GET /users/123/orders/456
1.4 Spring Boot 代码示例
java
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
// GET /users --- 获取用户列表
@GetMapping
public List<UserVO> listUsers(@RequestParam(required = false) String name,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.listUsers(name, page, size);
}
// GET /users/{id} --- 获取单个用户
@GetMapping("/{id}")
public UserVO getUser(@PathVariable Long id) {
return userService.getUser(id);
}
// POST /users --- 创建用户
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserVO createUser(@RequestBody @Valid CreateUserDTO dto) {
return userService.createUser(dto);
}
// PUT /users/{id} --- 全量更新用户
@PutMapping("/{id}")
public UserVO updateUser(@PathVariable Long id,
@RequestBody @Valid UpdateUserDTO dto) {
return userService.updateUser(id, dto);
}
// DELETE /users/{id} --- 删除用户
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
}
嵌套资源控制器:
java
@RestController
@RequestMapping("/users/{userId}/orders")
public class UserOrderController {
@Autowired
private OrderService orderService;
// GET /users/123/orders --- 获取用户的所有订单
@GetMapping
public List<OrderVO> listOrders(@PathVariable Long userId,
@RequestParam(defaultValue = "1") int page) {
return orderService.listByUserId(userId, page);
}
// POST /users/123/orders --- 为用户创建订单
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderVO createOrder(@PathVariable Long userId,
@RequestBody @Valid CreateOrderDTO dto) {
return orderService.createOrder(userId, dto);
}
// GET /users/123/orders/456 --- 获取用户的某个订单
@GetMapping("/{orderId}")
public OrderVO getOrder(@PathVariable Long userId,
@PathVariable Long orderId) {
return orderService.getOrder(userId, orderId);
}
}
二、统一接口:HTTP 方法的语义映射
2.1 论文原旨
"REST 架构风格区别于其他基于网络的风格的核心特征是其强调组件之间的统一接口。通过将软件工程的通用性原则应用于组件接口,整体系统架构得以简化,交互的可见性得到改善。"
原文(Section 5.1.5):
"The central feature that distinguishes the REST architectural style from other network-based styles is its emphasis on a uniform interface between components. By applying the software engineering principle of generality to the component interface, the overall system architecture is simplified and the visibility of interactions is improved."
REST 定义了四个接口约束,其中第二个是通过表述操纵资源。HTTP 协议为此提供了标准方法集(GET、POST、PUT、DELETE、PATCH 等),每个方法都有明确的语义约定。接口设计者应当严格遵循这些语义,而非发明自定义的"伪 REST"操作。
2.2 HTTP 方法与 CRUD 的映射
| HTTP 方法 | 语义 | 幂等性 | 安全性 | 典型用途 |
|---|---|---|---|---|
| GET | 获取资源的表述 | 幂等 | 安全 | 查询 |
| POST | 在集合中创建新资源,或触发处理 | 非幂等 | 不安全 | 新增、复杂操作 |
| PUT | 全量替换目标资源 | 幂等 | 不安全 | 整体更新 |
| PATCH | 部分修改目标资源 | 幂等 | 不安全 | 局部更新 |
| DELETE | 删除目标资源 | 幂等 | 不安全 | 删除 |
幂等性:多次调用产生相同结果。GET 多次请求同一资源,结果不变;PUT 多次提交相同数据,结果不变;DELETE 多次删除同一资源,最终状态相同。只有 POST 是非幂等的------多次提交可能创建多个资源。
2.3 常见误用与纠正
误用一:用 GET 执行删除操作
GET /users/delete?id=123
这违反了 GET 的安全语义。GET 请求应当可被缓存、可被浏览器预加载、可被搜索引擎爬取,执行删除操作会导致严重的安全问题。
纠正:
DELETE /users/123
误用二:用 POST 完成所有操作
POST /users?action=update&id=123
POST /users?action=delete&id=123
POST /users?action=query
这实际上是把 HTTP 当成了隧道,完全无视了统一接口的约束。
纠正: 分别使用 PUT、DELETE、GET。
误用三:在 URL 中混入动词
POST /users/123/activate
POST /users/123/deactivate
POST /users/123/changePassword
纠正方案一 --- 将状态变更视为对资源的 PUT/PATCH:
PATCH /users/123
Body: { "status": "ACTIVE" }
纠正方案二 --- 将操作本身建模为资源(命令模式):
POST /users/123/activations --- 创建一个"激活"动作资源
POST /users/123/password-changes --- 创建一个"改密"动作资源
2.4 PUT 与 PATCH 的区别
PUT 要求客户端提供资源的完整表述,服务器用其完全替换目标资源:
java
// PUT /users/123 --- 全量替换,未传的字段会被置空
@PutMapping("/{id}")
public UserVO updateUser(@PathVariable Long id,
@RequestBody UpdateUserDTO dto) {
// dto 必须包含所有必填字段
return userService.replaceUser(id, dto);
}
PATCH 只传递需要修改的字段:
java
// PATCH /users/123 --- 局部更新,只修改传入的字段
@PatchMapping("/{id}")
public UserVO patchUser(@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
return userService.partialUpdate(id, updates);
}
请求示例对比:
# PUT --- 必须传完整数据
PUT /users/123
{
"name": "张三",
"email": "zhangsan@example.com",
"phone": "13800138000",
"address": "北京市朝阳区"
}
# PATCH --- 只传需要修改的字段
PATCH /users/123
{
"phone": "13900139000"
}
三、无状态约束:认证与会话管理
3.1 论文原旨
"通信在本质上必须是无状态的......每个请求从客户端到服务器必须包含理解该请求所需的所有信息,且不能利用服务器上存储的任何上下文。会话状态因此完全保存在客户端。"
原文(Section 5.1.3):
"We next add a constraint to the client-server interaction: communication must be stateless in nature, such that each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server. Session state is therefore kept entirely on the client."
这是 REST 架构中最常被违反的约束。许多 Java Web 应用使用 HttpSession 在服务器端保存用户会话状态,这在集群部署时会导致会话粘滞(Session Sticky)问题,严重制约水平扩展能力。
3.2 反模式:服务器端 Session
java
// 反模式 --- 将状态存储在服务器端 Session 中
@RestController
public class CartController {
// 将购物车数据存在 HttpSession 中
@PostMapping("/cart/add")
public void addToCart(@RequestBody CartItem item, HttpSession session) {
List<CartItem> cart = (List<CartItem>) session.getAttribute("cart");
if (cart == null) {
cart = new ArrayList<>();
}
cart.add(item);
session.setAttribute("cart", cart); // 状态保存在服务器!
}
@GetMapping("/cart")
public List<CartItem> getCart(HttpSession session) {
return (List<CartItem>) session.getAttribute("cart");
}
}
问题:
- 服务器必须为每个用户维护 Session 对象,占用内存
- 集群部署需要 Session 复制或粘滞路由
- 服务器重启后 Session 丢失
3.3 正确做法:基于 Token 的无状态认证
java
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AuthenticationManager authManager;
// POST /auth/login --- 登录获取 Token
@PostMapping("/login")
public TokenVO login(@RequestBody LoginDTO dto) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
String token = jwtTokenProvider.createToken(auth);
return new TokenVO(token);
}
}
java
// 每个请求携带 Token,服务器不保存任何会话状态
@RestController
@RequestMapping("/cart")
public class CartController {
@Autowired
private CartService cartService;
// POST /cart/items --- 添加购物车项
// 客户端在 Header 中携带身份信息
@PostMapping("/items")
@ResponseStatus(HttpStatus.CREATED)
public CartItemVO addItem(@RequestHeader("Authorization") String token,
@RequestBody AddCartItemDTO dto) {
Long userId = JwtTokenProvider.extractUserId(token); // 从 Token 中解析用户信息
return cartService.addItem(userId, dto);
}
// GET /cart --- 获取购物车
// 每个请求都包含完整信息,服务器无需记住之前的状态
@GetMapping
public CartVO getCart(@RequestHeader("Authorization") String token) {
Long userId = JwtTokenProvider.extractUserId(token);
return cartService.getCart(userId);
}
}
JWT 工具类示例:
java
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
public String createToken(Authentication auth) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(((UserDetails) auth.getPrincipal()).getUsername())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public static Long extractUserId(String token) {
// 解析 Token,获取用户 ID
// 无需查询数据库或 Session
String subject = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody()
.getSubject();
return Long.parseLong(subject);
}
}
购物车数据应持久化到数据库或 Redis 中(以用户 ID 为键),而非存放在 Session 中。这样任何服务器实例都能通过用户 ID 查询到购物车数据,实现真正的无状态。
四、表述与内容协商:响应格式设计
4.1 论文原旨
"REST 组件通过以/与不断演进的标准数据类型集合中某一种匹配的格式/传输资源表述来进行通信,这种选择基于接收者的能力或期望以及资源的性质动态进行。"
原文(Section 5.2.1):
"REST components communicate by transferring a representation of a resource in a format matching one of an evolving set of standard data types, selected dynamically based on the capabilities or desires of the recipient and the nature of the resource."
"表述的数据格式被称为媒体类型。"原文(Section 5.2.1.2):
"The data format of a representation is known as a media type."
REST 的第二个接口约束要求通过表述来操纵资源。HTTP 协议通过 Content-Type 和 Accept 头实现了内容协商机制。Java 接口应当善用这一机制。
4.2 统一响应结构
java
// 统一响应封装
public class ApiResponse<T> {
private int code;
private String message;
private T data;
private long timestamp;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
response.setTimestamp(System.currentTimeMillis());
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
response.setTimestamp(System.currentTimeMillis());
return response;
}
// getter/setter ...
}
使用示例:
java
@RestController
@RequestMapping("/products")
public class ProductController {
// GET /products/123
@GetMapping("/{id}")
public ApiResponse<ProductVO> getProduct(@PathVariable Long id) {
ProductVO product = productService.getById(id);
return ApiResponse.success(product);
}
// GET /products
@GetMapping
public ApiResponse<PageResult<ProductVO>> listProducts(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.success(productService.list(page, size));
}
}
响应示例:
json
{
"code": 200,
"message": "success",
"data": {
"id": 123,
"name": "MacBook Pro",
"price": 12999.00
},
"timestamp": 1716825600000
}
4.3 内容协商
java
@RestController
@RequestMapping("/reports")
public class ReportController {
// 通过 Accept 头协商返回格式
// Accept: application/json → 返回 JSON
// Accept: application/xml → 返回 XML
@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public ReportVO getReport(@PathVariable Long id) {
return reportService.getById(id);
}
// 特定格式端点 --- 如导出 PDF
@GetMapping(value = "/{id}/export", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> exportPdf(@PathVariable Long id) {
byte[] pdf = reportService.exportPdf(id);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=report-" + id + ".pdf")
.body(pdf);
}
// 客户端通过 Content-Type 告知服务器请求体的格式
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public ReportVO createReport(@RequestBody CreateReportDTO dto) {
return reportService.create(dto);
}
}
4.4 分页响应设计
java
// 分页结果封装
public class PageResult<T> {
private List<T> items;
private int page;
private int size;
private long total;
private int totalPages;
public static <T> PageResult<T> of(List<T> items, int page, int size, long total) {
PageResult<T> result = new PageResult<>();
result.setItems(items);
result.setPage(page);
result.setSize(size);
result.setTotal(total);
result.setTotalPages((int) Math.ceil((double) total / size));
return result;
}
}
响应示例:
json
{
"code": 200,
"message": "success",
"data": {
"items": [
{"id": 1, "name": "商品A"},
{"id": 2, "name": "商品B"}
],
"page": 1,
"size": 20,
"total": 156,
"totalPages": 8
}
}
五、自描述消息:HTTP 状态码的合理使用
5.1 论文原旨
"REST 通过将消息约束为自描述来启用中间处理:交互在请求之间是无状态的,使用标准方法和媒体类型来指示语义和交换信息,并且响应明确指示可缓存性。"
原文(Section 5.3.1):
"REST enables intermediate processing by constraining messages to be self-descriptive: interaction is stateless between requests, standard methods and media types are used to indicate semantics and exchange information, and responses explicitly indicate cacheability."
REST 的第三个接口约束是自描述消息。HTTP 状态码就是消息自描述的关键机制。用 200 包打天下是接口设计中最常见的反模式之一。
5.2 常用状态码及使用场景
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 OK | 成功 | GET/PUT/PATCH 成功 |
| 201 Created | 已创建 | POST 创建资源成功 |
| 204 No Content | 成功但无返回内容 | DELETE 成功 |
| 400 Bad Request | 客户端请求错误 | 参数校验失败 |
| 401 Unauthorized | 未认证 | Token 缺失或过期 |
| 403 Forbidden | 无权限 | 已认证但无操作权限 |
| 404 Not Found | 资源不存在 | 查询的资源 ID 不存在 |
| 409 Conflict | 冲突 | 创建时资源已存在 |
| 422 Unprocessable Entity | 无法处理的实体 | 业务规则校验失败 |
| 500 Internal Server Error | 服务器内部错误 | 未预期的异常 |
5.3 Spring Boot 代码示例
java
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
// POST --- 创建成功返回 201
@PostMapping
public ResponseEntity<ApiResponse<UserVO>> createUser(
@RequestBody @Valid CreateUserDTO dto) {
UserVO user = userService.createUser(dto);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(user.getId())
.toUri();
return ResponseEntity.created(location) // 201 Created
.body(ApiResponse.success(user));
}
// DELETE --- 删除成功返回 204
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
// GET --- 查询成功返回 200
@GetMapping("/{id}")
public ApiResponse<UserVO> getUser(@PathVariable Long id) {
return ApiResponse.success(userService.getUser(id));
}
// PUT --- 更新成功返回 200
@PutMapping("/{id}")
public ApiResponse<UserVO> updateUser(@PathVariable Long id,
@RequestBody @Valid UpdateUserDTO dto) {
return ApiResponse.success(userService.updateUser(id, dto));
}
}
5.4 全局异常处理与状态码映射
java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 400 --- 参数校验失败
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining("; "));
return ApiResponse.error(400, message);
}
// 401 --- 未认证
@ExceptionHandler(UnauthenticatedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> handleUnauthenticated(UnauthenticatedException e) {
return ApiResponse.error(401, "请先登录");
}
// 403 --- 无权限
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleForbidden(AccessDeniedException e) {
return ApiResponse.error(403, "无操作权限");
}
// 404 --- 资源不存在
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNotFound(ResourceNotFoundException e) {
return ApiResponse.error(404, e.getMessage());
}
// 409 --- 资源冲突(如用户名已存在)
@ExceptionHandler(DuplicateResourceException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiResponse<Void> handleConflict(DuplicateResourceException e) {
return ApiResponse.error(409, e.getMessage());
}
// 422 --- 业务规则校验失败
@ExceptionHandler(BusinessRuleException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ApiResponse<Void> handleBusinessRule(BusinessRuleException e) {
return ApiResponse.error(422, e.getMessage());
}
// 500 --- 兜底
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGeneric(Exception e) {
return ApiResponse.error(500, "服务器内部错误");
}
}
反例 --- 所有响应都返回 200:
json
// 反模式:用 200 包打天下,错误信息塞在 body 里
HTTP/1.1 200 OK
{
"code": 404,
"message": "用户不存在",
"data": null
}
这种做法使得 HTTP 状态码形同虚设。中间件(网关、负载均衡器、缓存)无法通过状态码判断请求是否成功,监控系统也无法准确统计错误率。
正例 --- 状态码与响应语义一致:
json
HTTP/1.1 404 Not Found
{
"code": 404,
"message": "用户不存在",
"timestamp": 1716825600000
}
六、缓存约束:利用 HTTP 缓存机制
6.1 论文原旨
"缓存约束要求对请求的响应中的数据被隐式或显式标记为可缓存或不可缓存。如果响应是可缓存的,则客户端缓存有权为后续的等效请求重用该响应数据。"
原文(Section 5.1.4):
"Cache constraints require that the data within a response to a request be implicitly or explicitly labeled as cacheable or non-cacheable. If a response is cacheable, then a client cache is given the right to reuse that response data for later, equivalent requests."
"最高效的网络请求是不使用网络的请求。换言之,重用缓存响应的能力带来了应用性能的显著改善。"原文(Section 5.3.3):
"An interesting observation is that the most efficient network request is one that doesn't use the network. In other words, the ability to reuse a cached response results in a considerable improvement in application performance."
REST 架构将缓存作为一级约束,HTTP 协议为此提供了 ETag、Last-Modified、Cache-Control 等标准头。Java 接口设计应当充分利用这些机制,而非在应用层重复造轮子。
6.2 Spring Boot 缓存控制示例
java
@RestController
@RequestMapping("/articles")
public class ArticleController {
@Autowired
private ArticleService articleService;
// 方式一:通过 Cache-Control 头控制缓存策略
@GetMapping("/{id}")
public ResponseEntity<ArticleVO> getArticle(@PathVariable Long id) {
ArticleVO article = articleService.getById(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(30, TimeUnit.MINUTES)
.cachePublic()) // 公共缓存,CDN 可缓存
.eTag("\"" + article.getVersion() + "\"") // 用版本号作为 ETag
.lastModified(article.getUpdatedAt()) // 最后修改时间
.body(article);
}
// 方式二:使用 Spring 的 ShallowEtagHeaderFilter 自动生成 ETag
// 在配置类中注册 Filter 即可,Spring 会根据响应内容自动计算 ETag
}
缓存协商流程:
第一次请求:
GET /articles/123
→ 响应:200 OK
ETag: "v2"
Last-Modified: Mon, 27 May 2024 08:00:00 GMT
Cache-Control: max-age=1800, public
第二次请求(客户端带上 If-None-Match):
GET /articles/123
If-None-Match: "v2"
→ 响应:304 Not Modified(不返回 body,节省带宽)
内容变更后:
GET /articles/123
If-None-Match: "v2"
→ 响应:200 OK
ETag: "v3"
(返回新内容)
6.3 敏感数据禁止缓存
java
@RestController
@RequestMapping("/users")
public class UserController {
// 用户敏感信息 --- 禁止缓存
@GetMapping("/me")
public ResponseEntity<UserVO> getCurrentUser(@AuthUser Long userId) {
UserVO user = userService.getUser(userId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) // 不缓存
.cacheControl(CacheControl.noCache()) // 必须重新验证
.header("Pragma", "no-cache") // HTTP/1.0 兼容
.body(user);
}
}
七、分层系统:接口版本管理
7.1 论文原旨
"分层系统风格允许架构由分层层次组成,方法是约束组件行为,使得每个组件不能'看到'与之交互的直接层之外的内容。通过将系统知识限制在单一层内,我们限制了整体系统复杂度并促进了底层独立性。"
原文(Section 5.1.6):
"The layered system style allows an architecture to be composed of hierarchical layers by constraining component behavior such that each component cannot "see" beyond the immediate layer with which they are interacting. By restricting knowledge of the system to a single layer, we place a bound on the overall system complexity and promote substrate independence."
在接口设计中,分层系统约束体现在:客户端不应感知服务器内部的分层架构(Controller → Service → DAO → Database)。同时,接口需要版本管理来保证客户端和服务器的独立演进。
7.2 版本管理方案
方案一:URL 路径版本(最常用)
java
@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
@GetMapping("/{id}")
public UserV1VO getUser(@PathVariable Long id) {
// V1 版本:返回 name 字段
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
@GetMapping("/{id}")
public UserV2VO getUser(@PathVariable Long id) {
// V2 版本:name 拆分为 firstName 和 lastName
}
}
方案二:请求头版本
java
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserV1VO getUserV1(@PathVariable Long id) { /* ... */ }
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserV2VO getUserV2(@PathVariable Long id) { /* ... */ }
}
方案三:Accept 头版本(最 RESTful)
java
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping(value = "/{id}", produces = "application/vnd.myapp.v1+json")
public UserV1VO getUserV1(@PathVariable Long id) { /* ... */ }
@GetMapping(value = "/{id}", produces = "application/vnd.myapp.v2+json")
public UserV2VO getUserV2(@PathVariable Long id) { /* ... */ }
}
推荐使用方案一(URL 路径版本),虽然不是最"纯粹"的 REST,但最直观、最易于调试和路由。
八、HATEOAS:超媒体作为应用状态的引擎
8.1 论文原旨
"REST 由四个接口约束定义:资源标识;通过表述操纵资源;自描述消息;以及超媒体作为应用状态的引擎(Hypermedia as the Engine of Application State,HATEOAS)。"
原文(Section 5.1.5):
"REST is defined by four interface constraints: identification of resources; manipulation of resources through representations; self-descriptive messages; and, hypermedia as the engine of application state."
HATEOAS 是 REST 最容易被忽略的约束,也是区分"RESTful API"和"HTTP API"的关键。其核心思想是:响应中不仅包含数据,还包含客户端可以执行的下一步操作的链接。客户端无需硬编码 URL,而是通过解析响应中的链接来驱动状态转移。
8.2 Spring HATEOAS 示例
引入依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
java
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/{id}")
public EntityModel<OrderVO> getOrder(@PathVariable Long id) {
OrderVO order = orderService.getById(id);
EntityModel<OrderVO> resource = EntityModel.of(order);
// 根据订单状态,添加可用的操作链接
resource.add(linkTo(methodOn(OrderController.class)
.getOrder(id)).withSelfRel());
if (order.getStatus() == Status.PENDING) {
// 待支付状态 → 提供支付链接
resource.add(linkTo(methodOn(OrderController.class)
.payOrder(id, null)).withRel("pay"));
// 待支付状态 → 提供取消链接
resource.add(linkTo(methodOn(OrderController.class)
.cancelOrder(id)).withRel("cancel"));
}
if (order.getStatus() == Status.PAID) {
// 已支付状态 → 提供发货链接
resource.add(linkTo(methodOn(OrderController.class)
.shipOrder(id, null)).withRel("ship"));
}
// 所有状态都可以查看详情
resource.add(linkTo(methodOn(UserOrderController.class)
.getOrder(order.getUserId(), id)).withRel("user-order"));
return resource;
}
@PostMapping("/{id}/payment")
@ResponseStatus(HttpStatus.OK)
public EntityModel<OrderVO> payOrder(@PathVariable Long id,
@RequestBody PaymentDTO dto) {
return EntityModel.of(orderService.pay(id, dto));
}
@PostMapping("/{id}/cancellation")
@ResponseStatus(HttpStatus.OK)
public EntityModel<OrderVO> cancelOrder(@PathVariable Long id) {
return EntityModel.of(orderService.cancel(id));
}
@PostMapping("/{id}/shipment")
@ResponseStatus(HttpStatus.OK)
public EntityModel<OrderVO> shipOrder(@PathVariable Long id,
@RequestBody ShipDTO dto) {
return EntityModel.of(orderService.ship(id, dto));
}
}
HATEOAS 响应示例(待支付订单):
json
{
"id": 456,
"status": "PENDING",
"amount": 299.00,
"items": [
{"productId": 1, "productName": "商品A", "quantity": 2}
],
"_links": {
"self": {"href": "http://api.example.com/orders/456"},
"pay": {"href": "http://api.example.com/orders/456/payment"},
"cancel": {"href": "http://api.example.com/orders/456/cancellation"},
"user-order": {"href": "http://api.example.com/users/123/orders/456"}
}
}
支付成功后,响应自动变化:
json
{
"id": 456,
"status": "PAID",
"amount": 299.00,
"_links": {
"self": {"href": "http://api.example.com/orders/456"},
"ship": {"href": "http://api.example.com/orders/456/shipment"},
"user-order": {"href": "http://api.example.com/users/123/orders/456"}
}
}
客户端不再需要硬编码
pay、cancel、ship的 URL,而是根据响应中_links的有无来决定显示哪些按钮。这正是 Fielding 所说的"超媒体作为应用状态的引擎"。
九、完整案例:RESTful 风格的订单管理接口
综合以上所有规范,以下是一个完整的订单管理接口设计。
9.1 接口清单
| HTTP 方法 | URL | 说明 | 状态码 |
|---|---|---|---|
| GET | /api/v1/orders | 查询订单列表 | 200 |
| POST | /api/v1/orders | 创建订单 | 201 |
| GET | /api/v1/orders/{id} | 查询订单详情 | 200 / 404 |
| PATCH | /api/v1/orders/{id} | 更新订单 | 200 |
| DELETE | /api/v1/orders/{id} | 取消订单 | 204 |
| POST | /api/v1/orders/{id}/payment | 支付订单 | 200 |
| POST | /api/v1/orders/{id}/shipment | 发货 | 200 |
9.2 完整代码
java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 查询订单列表
* GET /api/v1/orders?status=PENDING&page=1&size=20
*/
@GetMapping
public ApiResponse<PageResult<OrderVO>> listOrders(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestHeader("Authorization") String token) {
Long userId = JwtTokenProvider.extractUserId(token);
return ApiResponse.success(orderService.list(userId, status, page, size));
}
/**
* 创建订单
* POST /api/v1/orders
* Content-Type: application/json
*/
@PostMapping
public ResponseEntity<ApiResponse<OrderVO>> createOrder(
@RequestHeader("Authorization") String token,
@RequestBody @Valid CreateOrderDTO dto) {
Long userId = JwtTokenProvider.extractUserId(token);
OrderVO order = orderService.create(userId, dto);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(order.getId())
.toUri();
return ResponseEntity.created(location).body(ApiResponse.success(order));
}
/**
* 查询订单详情
* GET /api/v1/orders/456
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<OrderVO>> getOrder(
@PathVariable Long id,
@RequestHeader("Authorization") String token) {
Long userId = JwtTokenProvider.extractUserId(token);
OrderVO order = orderService.getById(userId, id);
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache())
.eTag("\"" + order.getVersion() + "\"")
.body(ApiResponse.success(order));
}
/**
* 取消订单
* DELETE /api/v1/orders/456
*/
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void cancelOrder(@PathVariable Long id,
@RequestHeader("Authorization") String token) {
Long userId = JwtTokenProvider.extractUserId(token);
orderService.cancel(userId, id);
}
/**
* 支付订单
* POST /api/v1/orders/456/payment
*/
@PostMapping("/{id}/payment")
public ApiResponse<OrderVO> payOrder(
@PathVariable Long id,
@RequestHeader("Authorization") String token,
@RequestBody @Valid PaymentDTO dto) {
Long userId = JwtTokenProvider.extractUserId(token);
return ApiResponse.success(orderService.pay(userId, id, dto));
}
}
9.3 完整请求/响应示例
创建订单:
POST /api/v1/orders HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
{
"items": [
{"productId": 1, "quantity": 2},
{"productId": 5, "quantity": 1}
],
"shippingAddress": "北京市海淀区xxx路xxx号"
}
HTTP/1.1 201 Created
Location: /api/v1/orders/456
Content-Type: application/json
{
"code": 201,
"message": "success",
"data": {
"id": 456,
"status": "PENDING",
"totalAmount": 599.00,
"items": [
{"productId": 1, "productName": "商品A", "quantity": 2, "price": 199.00},
{"productId": 5, "productName": "商品B", "quantity": 1, "price": 201.00}
],
"createdAt": "2024-05-27T10:30:00Z"
},
"timestamp": 1716825600000
}
查询不存在的订单:
GET /api/v1/orders/999 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"code": 404,
"message": "订单不存在",
"timestamp": 1716825600000
}
参数校验失败:
POST /api/v1/orders HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
{
"items": [],
"shippingAddress": ""
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": 400,
"message": "items: 订单商品不能为空; shippingAddress: 收货地址不能为空",
"timestamp": 1716825600000
}
十、RESTful 接口设计速查清单
| REST 约束 | 对应实践 | 是否遵守 |
|---|---|---|
| 资源标识 | URL 使用名词、复数形式,不含动词和技术后缀 | ☐ |
| 统一接口 | 正确使用 GET/POST/PUT/PATCH/DELETE 语义 | ☐ |
| 无状态 | 不使用 HttpSession,认证信息由客户端每次携带 | ☐ |
| 自描述消息 | 正确使用 HTTP 状态码,非 200 包打天下 | ☐ |
| 缓存 | GET 响应设置 Cache-Control、ETag | ☐ |
| 表述协商 | 正确使用 Content-Type、Accept 头 | ☐ |
| 分层系统 | 客户端不感知服务器内部架构,接口有版本管理 | ☐ |
| HATEOAS | 响应中包含相关操作的超链接 | ☐ |
十一、面试话术
以下整理了面试中关于 RESTful 的高频问题及参考回答。建议理解后用自己的语言表述,不要死记硬背。
Q1:什么是 REST?什么是 RESTful?
REST 的全称是 Representational State Transfer,即"表述性状态转移",是 Roy Fielding 在 2000 年的博士论文中提出的一种架构风格,不是协议也不是标准。它定义了一组约束,用于指导分布式超媒体系统的设计。而"RESTful"是指遵循 REST 约束设计的接口或服务。
REST 的核心思想是把后端的一切抽象为"资源",客户端通过统一的接口(HTTP 标准方法)对资源进行操作,服务器返回资源的"表述"(通常是 JSON 或 XML),整个过程是无状态的。
Q2:REST 有哪六大约束?
REST 包含六大架构约束:
- 客户端-服务器:关注点分离,前端负责用户界面,后端负责数据存储,各自独立演进。
- 无状态:每次请求必须包含所有必要信息,服务器不保存客户端会话状态。这提升了可见性、可靠性和可伸缩性。
- 可缓存:响应必须明确标识是否可缓存,减少不必要的网络交互,提升性能。
- 统一接口:这是 REST 区别于其他架构风格的核心。通过标准化的接口(HTTP 方法 + 资源标识符 + 自描述消息 + HATEOAS)简化整体架构。
- 分层系统:客户端不需要知道是直接连到终端服务器还是中间层,中间层可以提供负载均衡、缓存、安全策略等功能。
- 按需代码(可选):服务器可以临时向客户端发送可执行代码(如 JavaScript)来扩展客户端功能。
在实际项目中,前四个约束是必须遵守的,第五个通常由 Nginx 网关等中间件天然实现,第六个是可选的。
Q3:RESTful 接口的 URL 应该怎么设计?
RESTful 的 URL 应该只包含名词 ,用复数形式表示资源集合,操作语义由 HTTP 方法来表达。
比如
GET /users/123表示获取用户,DELETE /users/123表示删除用户,URL 中不应该出现/getUser、/deleteUser这样的动词。对于资源之间的从属关系,用路径嵌套表示,比如
GET /users/123/orders获取用户 123 的所有订单。但嵌套层级建议不超过三级,过深的嵌套可以改用查询参数,比如GET /orders?userId=123。URL 中也不应该暴露技术实现细节,比如
.do、.action、.jsp这些后缀不应该出现在接口路径中。
Q4:GET、POST、PUT、PATCH、DELETE 分别什么区别?
这五个 HTTP 方法对应不同的语义:
- GET 用于获取资源,是安全且幂等的,不应该产生副作用,可以被缓存。
- POST 用于创建新资源或触发处理逻辑,不是幂等的,多次调用可能创建多个资源。
- PUT 用于全量替换目标资源,是幂等的,客户端需要提供资源的完整数据。
- PATCH 用于局部更新资源,也是幂等的,客户端只传需要修改的字段。
- DELETE 用于删除资源,是幂等的,删除一个已经不存在的资源结果相同。
幂等性的意思是:对同一资源执行同一操作一次和多次,产生的效果是相同的。理解幂等性对于接口设计很重要,它决定了请求是否可以安全地重试。
Q5:PUT 和 PATCH 有什么区别?
PUT 是全量替换,客户端必须提供资源的所有字段。如果某个字段没传,服务端会把该字段置为 null 或默认值。
PATCH 是局部更新,客户端只传需要修改的字段,没传的字段保持不变。
举个例子,更新用户信息时,如果只想改手机号:
- 用 PUT 的话,必须把 name、email、address 等所有字段都传一遍,否则未传的字段会被清空。
- 用 PATCH 的话,只需要传
{ "phone": "13800138000" }即可。在实际项目中,PATCH 使用更频繁,因为前端表单通常只修改部分字段。
Q6:为什么说 REST 接口应该是无状态的?Session 不能用吗?
无状态是 REST 的核心约束之一。它的含义是:服务器不会在两次请求之间为客户端保存任何上下文信息,每个请求必须包含服务器理解该请求所需的全部数据。
如果使用 HttpSession 在服务器端保存会话状态,会带来几个问题:
- 集群部署时需要做 Session 同步或粘滞路由,增加了架构复杂度。
- 服务器要为每个在线用户维护 Session 对象,占用大量内存,限制了水平扩展能力。
- 服务器重启后 Session 丢失,用户体验受损。
在实际项目中,我们通常用 JWT Token 来实现无状态认证。客户端在每次请求的 Authorization 头中携带 Token,服务器解析 Token 就能识别用户身份,不需要在服务器端存储任何会话信息。业务状态(比如购物车)则持久化到数据库或 Redis 中。
Q7:HTTP 状态码怎么用?为什么不能用 200 包打天下?
HTTP 状态码是 REST"自描述消息"约束的体现。每种状态码都有明确含义:
- 2xx 表示成功:200 正常、201 创建成功、204 删除成功无返回体。
- 4xx 表示客户端错误:400 参数校验失败、401 未认证、403 无权限、404 资源不存在、409 资源冲突、422 业务规则校验失败。
- 5xx 表示服务端错误:500 内部错误。
如果所有响应都返回 200,然后在 body 里用
code: 404表示错误,这会带来几个问题:
- 网关、负载均衡器、APM 监控等基础设施无法通过 HTTP 状态码判断请求是否成功。
- 违反了 HTTP 协议的自描述语义,中间件无法做缓存、重试等优化。
- 前端需要在业务层解析错误,增加了对接复杂度。
正确做法是 HTTP 状态码与响应体中的业务状态码保持一致,并在 Spring Boot 中通过
@RestControllerAdvice全局异常处理器统一管理。
Q8:你知道 HATEOAS 吗?在实际项目中用过吗?
HATEOAS 是 Hypermedia As The Engine Of Application State 的缩写,翻译为"超媒体作为应用状态的引擎"。它是 REST 四个接口约束中最容易被忽略的一个。
它的核心思想是:API 的响应中不仅包含数据,还包含客户端可以执行的下一步操作的链接。客户端不需要硬编码 URL,而是通过解析响应中的超链接来驱动状态流转。
举个例子,查询一个待支付订单时,响应中会包含
pay和cancel的链接;订单支付完成后,响应中自动变为ship链接。客户端根据链接的有无来决定显示哪些操作按钮。在实际项目中,完全实现 HATEOAS 的团队比较少,因为这增加了接口设计的复杂度。但在金融、支付等对接口演进性要求高的场景中,使用 Spring HATEOAS 来实现是非常有价值的。即使不完全实现,也应该理解其设计思想------让接口具有自发现性。
Q9:你的接口是怎么做版本管理的?
接口版本管理有三种常见方案:
- URL 路径版本 :比如
/api/v1/users和/api/v2/users。最直观、最常用,方便路由和调试,我们项目中用的就是这种方式。- 请求头版本 :通过
X-API-Version: 2这样的自定义头来区分版本。URL 保持不变,但调试不太方便。- Accept 头版本 :通过
Accept: application/vnd.myapp.v2+json这种媒体类型来协商。最符合 REST 原理,但实际使用较少。版本管理的核心原则是:新版本不能破坏老版本的客户端。当需要对接口做不兼容变更时(比如字段重命名、返回结构变化),就升级版本号,老版本接口继续维护,给客户端充足的迁移时间。
Q10:如果面试官让你现场设计一个 RESTful 接口,怎么组织回答?
可以按以下框架来组织回答:
第一步:识别资源
从业务需求中提取核心资源,确定资源名称(名词、复数),明确资源之间的从属关系。
第二步:设计 URL 和方法映射
根据操作类型选择合适的 HTTP 方法,列出完整的接口清单(方法 + URL + 说明)。
第三步:定义请求和响应结构
设计请求参数(路径参数、查询参数、请求体)和响应结构(统一封装、分页格式),明确 Content-Type。
第四步:确定状态码
为每个接口的成功和失败场景分配合适的 HTTP 状态码。
第五步:考虑非功能性需求
认证方式(无状态 Token)、缓存策略、版本管理、错误处理。
比如面试官说"设计一个博客系统的文章管理接口",我的回答是:
资源是 Article,URL 设计为
/api/v1/articles。GET 获取列表,POST 创建,GET/{id}获取详情,PUT/{id}全量更新,PATCH/{id}局部更新,DELETE/{id}删除。列表支持?author=xxx&tag=xxx&page=1&size=20查询参数。创建成功返回 201 和 Location 头,删除成功返回 204。用 JWT 做无状态认证,用@RestControllerAdvice统一异常处理,列表接口设置 Cache-Control 和 ETag 做缓存协商。
参考资料
-
Fielding, Roy Thomas. Architectural Styles and the Design of Network-based Software Architectures. Doctoral dissertation, University of California, Irvine, 2000. Chapter 5: Representational State Transfer (REST).
- 原文地址:https://ics.uci.edu/\~fielding/pubs/dissertation/rest_arch_style.htm
- 中文翻译见同目录《第五章:表述性状态转移(REST)》
-
RFC 9110 --- HTTP Semantics. IETF, 2022. 定义了 HTTP 方法(GET、POST、PUT、DELETE、PATCH)、状态码和头字段的标准语义。
-
RFC 9111 --- HTTP Caching. IETF, 2022. 定义了 HTTP 缓存机制,包括 Cache-Control、ETag、Last-Modified 等。
-
Spring Framework Documentation. https://docs.spring.io/spring-framework/reference/
-
Spring HATEOAS Reference. https://docs.spring.io/spring-hateoas/docs/current/reference/html/