让我用一个餐厅点餐的类比来帮你理解 HATEOAS 和 Spring HATEOAS。
一、先理解 HATEOAS 的核心思想
场景:在餐厅点餐
传统 API(没有 HATEOAS):
-
你去餐厅,服务员给你一份菜单
-
你必须知道要喊:"服务员,我要点菜!"
-
你必须知道要说:"给我一份意大利面"
-
你必须知道吃完后要说:"结账!"
-
你必须记住所有可能的操作和对应的指令
HATEOAS API(有 HATEOAS):
-
你进餐厅,服务员说:
-
"欢迎!这是菜单(包含菜品和'点菜'按钮)"
-
你点菜后,服务员返回:
- "已收到您的订单(包含'查看订单'、'修改订单'、'付款'按钮)"
-
你付款后,服务员返回:
- "付款成功(包含'开发票'、'评价'、'再来一单'按钮)"
-
-
你不需要记住任何固定指令,服务员每次都会告诉你下一步能做什么
二、Spring HATEOAS 解决什么问题
传统 REST API 的问题
// 客户端需要硬编码这些 URL
String getOrdersUrl = "http://api.example.com/orders"; // 如果这个URL改变,客户端就坏了
String createOrderUrl = "http://api.example.com/orders"; // 客户端必须知道这里是POST
String cancelOrderUrl = "http://api.example.com/orders/{id}/cancel"; // 客户端必须知道这个模式
Spring HATEOAS 的解决方案
// 客户端不关心具体URL,只关心链接关系
Link selfLink = response.getLink("self"); // 获取"查看自己"的链接
Link cancelLink = response.getLink("cancel"); // 获取"取消"的链接
// URL可以任意变化,只要关系名称不变
三、实际代码示例详解
示例1:简单的订单系统
1. 实体类(Order.java)
// 普通的Java对象
public class Order {
private Long id;
private String customerName;
private BigDecimal total;
private OrderStatus status; // PENDING, PAID, CANCELLED
// 构造器、getter、setter
public boolean canBeCancelled() {
return status == OrderStatus.PENDING;
}
}
2. 资源表示类(OrderResource.java)
// 继承 EntityModel,这样就能添加链接
// 这就像给"订单"这个普通对象穿上"超链接"的外套
public class OrderResource extends EntityModel<Order> {
// 可以有额外的属性
private String message;
public OrderResource(Order order) {
super(order); // 把订单对象放进去
this.message = "订单详情";
}
// 也可以不继承,直接用EntityModel.of()包装
}
3. 控制器(OrderController.java) - 详细解释
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 获取单个订单
* 返回的不仅是一个订单对象,还包含它能做什么操作的链接
*/
@GetMapping("/{id}")
public EntityModel<Order> getOrder(@PathVariable Long id) {
// 1. 获取订单数据
Order order = orderService.findById(id);
// 2. 创建资源模型(订单+链接)
EntityModel<Order> resource = EntityModel.of(order);
// 3. 添加"自链接"(查看自己)
// linkTo: 创建链接
// methodOn: 指向哪个控制器方法
// withSelfRel(): 关系名为"self"
resource.add(
linkTo(methodOn(OrderController.class).getOrder(id))
.withSelfRel()
);
// 4. 添加"返回列表"链接
resource.add(
linkTo(methodOn(OrderController.class).getAllOrders())
.withRel("collection") // 关系名"collection"
);
// 5. 根据状态动态添加链接
if (order.canBeCancelled()) {
// 只有待处理的订单才能取消
resource.add(
linkTo(methodOn(OrderController.class).cancelOrder(id, null))
.withRel("cancel") // 关系名"cancel"
);
}
if (order.getStatus() == OrderStatus.PAID) {
// 已支付的订单可以开发票
resource.add(
linkTo(methodOn(InvoiceController.class).createInvoice(order.getId()))
.withRel("invoice")
);
}
return resource;
}
}
4. 查看返回的JSON(HAL格式)
{
"id": 123,
"customerName": "张三",
"total": 100.00,
"status": "PENDING",
// 这是Spring HATEOAS添加的链接部分
"_links": {
"self": {
"href": "http://localhost:8080/api/orders/123"
},
"collection": {
"href": "http://localhost:8080/api/orders"
},
"cancel": {
"href": "http://localhost:8080/api/orders/123/cancel"
}
}
}
四、linkTo 和 methodOn 的工作原理
这两个方法是Spring HATEOAS的魔法所在:
// 这行代码做了什么?
linkTo(methodOn(OrderController.class).getOrder(id)).withSelfRel()
// 分解:
// 1. methodOn(OrderController.class) 创建一个Controller的代理
// 2. .getOrder(id) 调用代理的方法,Spring HATEOAS会记录:调用的是getOrder方法,参数是id
// 3. linkTo() 根据上一步的记录,查找@RequestMapping注解,生成URL
// 4. withSelfRel() 给这个链接命名"self"
等价于:
// 手动构建URL(不推荐,容易出错)
String url = "/api/orders/" + id;
Link link = new Link(url, "self");
// 使用ControllerLinkBuilder(简化版)
Link link = ControllerLinkBuilder
.linkTo(OrderController.class) // 指定Controller
.slash("orders") // 添加路径
.slash(id) // 添加ID
.withSelfRel();
五、完整的增删改查示例
OrderController.java 完整版
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// 获取所有订单
@GetMapping
public CollectionModel<EntityModel<Order>> getAllOrders() {
List<Order> orders = orderService.findAll();
// 将每个订单转换为资源模型
List<EntityModel<Order>> orderResources = orders.stream()
.map(order -> EntityModel.of(order,
linkTo(methodOn(OrderController.class)
.getOrder(order.getId())).withSelfRel()
))
.collect(Collectors.toList());
// 包装成集合资源
return CollectionModel.of(orderResources,
linkTo(methodOn(OrderController.class).getAllOrders())
.withSelfRel(),
linkTo(methodOn(OrderController.class).createOrder(null))
.withRel("create") // 如何创建新订单
);
}
// 创建订单
@PostMapping
public ResponseEntity<EntityModel<Order>> createOrder(@RequestBody Order order) {
Order savedOrder = orderService.save(order);
// 创建资源
EntityModel<Order> resource = EntityModel.of(savedOrder,
linkTo(methodOn(OrderController.class)
.getOrder(savedOrder.getId())).withSelfRel()
);
// 返回201 Created,包含Location头
return ResponseEntity.created(
linkTo(methodOn(OrderController.class)
.getOrder(savedOrder.getId())).toUri()
).body(resource);
}
// 取消订单
@PostMapping("/{id}/cancel")
public ResponseEntity<?> cancelOrder(@PathVariable Long id,
@RequestBody CancelRequest request) {
orderService.cancel(id, request.getReason());
// 取消后返回订单详情
return ResponseEntity.ok(getOrder(id));
}
}
六、RepresentationModelAssembler 的作用
这是一个转换器,把普通对象转换成带链接的资源对象:
@Component
public class OrderModelAssembler
implements RepresentationModelAssembler<Order, EntityModel<Order>> {
// 单个对象转换
@Override
public EntityModel<Order> toModel(Order order) {
return EntityModel.of(order,
linkTo(methodOn(OrderController.class)
.getOrder(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId(), null))
.withRel("cancel"),
linkTo(methodOn(PaymentController.class)
.getOrderPayments(order.getId()))
.withRel("payments")
);
}
// 集合转换
public CollectionModel<EntityModel<Order>> toCollectionModel(
List<Order> orders, boolean includeCreateLink) {
// 先调用父类方法转换每个订单
CollectionModel<EntityModel<Order>> collectionModel =
RepresentationModelAssembler.super.toCollectionModel(orders);
// 添加集合级别的链接
collectionModel.add(
linkTo(methodOn(OrderController.class)
.getAllOrders()).withSelfRel()
);
if (includeCreateLink) {
collectionModel.add(
linkTo(methodOn(OrderController.class)
.createOrder(null)).withRel("create")
);
}
return collectionModel;
}
}
在Controller中使用:
@GetMapping("/{id}")
public EntityModel<Order> getOrder(@PathVariable Long id) {
Order order = orderService.findById(id);
return assembler.toModel(order); // 一行代码搞定!
}
七、客户端如何使用这样的API
传统客户端调用:
// 硬编码的URL
String apiBase = "http://api.example.com";
String ordersUrl = apiBase + "/api/orders";
// 1. 获取订单列表
Response ordersResponse = restTemplate.getForEntity(ordersUrl, String.class);
// 2. 从响应中提取订单ID
Long orderId = parseOrderId(ordersResponse);
// 3. 硬编码取消URL
String cancelUrl = apiBase + "/api/orders/" + orderId + "/cancel";
restTemplate.postForEntity(cancelUrl, null, Void.class);
使用Spring HATEOAS客户端:
// 1. 发现入口点
String apiRoot = "http://api.example.com/api";
ResponseEntity<EntityModel<Object>> rootResponse =
restTemplate.exchange(apiRoot, HttpMethod.GET, null,
new ParameterizedTypeReference<EntityModel<Object>>() {});
// 2. 提取"orders"链接
Link ordersLink = rootResponse.getBody().getLink("orders").orElseThrow();
// 3. 获取订单列表
ResponseEntity<CollectionModel<EntityModel<Order>>> ordersResponse =
restTemplate.exchange(ordersLink.toUri(), HttpMethod.GET, null,
new ParameterizedTypeReference<CollectionModel<EntityModel<Order>>>() {});
// 4. 获取第一个订单
EntityModel<Order> firstOrder = ordersResponse.getBody().getContent().iterator().next();
// 5. 从订单中提取"cancel"链接
Link cancelLink = firstOrder.getLink("cancel").orElseThrow();
// 6. 取消订单(不需要知道具体URL!)
restTemplate.postForEntity(cancelLink.toUri(), null, Void.class);
八、实际好处
1. API演进更容易
// 旧URL:/api/v1/orders/{id}/cancel
// 新URL:/api/v2/orders/{id}/actions/cancel
// 客户端代码完全不变!因为客户端只关心"cancel"这个关系名
// 服务器返回什么URL,客户端就用什么URL
2. 权限控制更灵活
// 管理员看到更多链接
if (user.hasRole("ADMIN")) {
resource.add(linkTo(methodOn(AdminController.class)
.refundOrder(order.getId())).withRel("refund"));
}
3. 状态控制
// 只有特定状态的订单才有某些操作
if (order.getStatus() == OrderStatus.SHIPPED) {
resource.add(linkTo(methodOn(TrackingController.class)
.getTracking(order.getId())).withRel("tracking"));
}
九、常见问题解答
Q: 为什么用 EntityModel.of()而不是 new EntityModel<>()?
A: EntityModel.of()是工厂方法,可以确保对象正确初始化。它内部会设置一些必要的属性。
Q: 链接关系名(rel)有什么规范?
A: 有三种:
-
IANA标准关系:
self、next、prev、first、last、collection -
Web Linking关系:
stylesheet、icon -
自定义关系:
order、payment、invoice
Q: 如何测试HATEOAS API?
@Test
void shouldReturnOrderWithLinks() throws Exception {
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$._links.self.href").exists())
.andExpect(jsonPath("$._links.cancel.href").exists())
.andExpect(jsonPath("$._links.collection.href").exists());
}
十、总结比喻
把API想象成一个网站导航:
-
传统API:给你一张地图,告诉你"书店在这里,餐厅在那里",地图变了就得重印
-
HATEOAS API:每个地方都有指示牌
-
在首页:"想去书店?点这里"
-
在书店:"想买书?点这里"、"想结账?点这里"
-
在收银台:"要发票?点这里"、"要袋子?点这里"
-
Spring HATEOAS就是帮你自动生成这些"指示牌"的工具,让客户端只需要跟着指示牌走,不需要记住整个地图。