Spring HATEOAS 详细介绍

让我用一个餐厅点餐的类比来帮你理解 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: 有三种:

  1. IANA标准关系:selfnextprevfirstlastcollection

  2. Web Linking关系:stylesheeticon

  3. 自定义关系:orderpaymentinvoice

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就是帮你自动生成这些"指示牌"的工具,让客户端只需要跟着指示牌走,不需要记住整个地图。

相关推荐
JaguarJack2 小时前
成为高级 PHP 开发者需要的思维转变
后端·php·服务端
夏幻灵2 小时前
配置环境变量的核心目的
java
BingoGo2 小时前
成为高级 PHP 开发者需要的思维转变
后端·php
廋到被风吹走2 小时前
【Spring】Spring Core解析
java·spring·rpc
Leonardo_Fibonacci2 小时前
skbbs-day5
java·开发语言·mybatis
源代码•宸2 小时前
goframe框架签到系统项目开发(用户认证中间件、实现Refresh-token接口)
数据库·经验分享·后端·算法·中间件·跨域·refreshtoken
高山上有一只小老虎2 小时前
IDEA Community如何使用外置的tomcat
java·ide·intellij-idea
海南java第二人2 小时前
Java类加载机制深度解析:从双亲委派到自定义加载的完整指南
java·spring
Victor3562 小时前
Hibernate(5)什么是Hibernate的配置文件?
后端