Skip to content

Spring HATEOAS:让你的 REST API 更智能 🚀 ​

什么是 HATEOAS?为什么需要它? ​

想象一下,你在使用一个网站时,每次都需要记住所有的 URL 地址才能导航到不同的页面。这听起来很麻烦,对吧?同样的问题也存在于 REST API 中。

NOTE

HATEOAS 全称是 Hypermedia As The Engine Of Application State(超媒体作为应用状态引擎)。它是 REST 架构约束中的一个重要概念,让 API 响应不仅包含数据,还包含相关操作的链接信息。

传统 REST API 的痛点 ​

在传统的 REST API 中,客户端需要硬编码所有的 URL:

kotlin
// 客户端需要知道所有的 URL 路径
class UserService {
    fun getUser(id: Long): User {
        return restTemplate.getForObject("/api/users/$id", User::class.java)
    }
    
    fun getUserOrders(id: Long): List<Order> {
        // 硬编码的 URL,难以维护
        return restTemplate.getForObject("/api/users/$id/orders", List::class.java)
    }
    
    fun deleteUser(id: Long) {
        // 如果 URL 变更,客户端代码也需要修改
        restTemplate.delete("/api/users/$id")
    }
}
json
{
  "id": 1,
  "name": "张三",
  "email": "zhangsan@example.com",
  "status": "active"
}

HATEOAS 的解决方案 ​

HATEOAS 让 API 响应变得"自解释",就像网页中的超链接一样:

json
{
  "id": 1,
  "name": "张三",
  "email": "zhangsan@example.com",
  "status": "active",
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/users/1"
    },
    "orders": {
      "href": "http://localhost:8080/api/users/1/orders"
    },
    "edit": {
      "href": "http://localhost:8080/api/users/1"
    },
    "delete": {
      "href": "http://localhost:8080/api/users/1"
    }
  }
}

TIP

通过 HATEOAS,客户端可以动态发现可用的操作,而不需要硬编码 URL。这就像浏览网页时,你可以通过点击链接导航,而不需要记住每个页面的地址。

Spring Boot 中的 HATEOAS 自动配置 ​

Spring Boot 为 HATEOAS 提供了开箱即用的自动配置,让开发变得更加简单。

添加依赖 ​

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

WARNING

spring-boot-starter-hateoas 专门用于 Spring MVC,不应与 Spring WebFlux 结合使用。如果要在 WebFlux 中使用 HATEOAS,需要直接添加 org.springframework.hateoas:spring-hateoas 依赖。

自动配置的好处 ​

Spring Boot 的自动配置为我们提供了:

  1. 无需手动启用:替代了 @EnableHypermediaSupport 注解
  2. 预配置的 Bean:包括 LinkDiscoverers(客户端支持)和 ObjectMapper
  3. 智能序列化:自动将响应序列化为合适的超媒体格式

实战示例:构建支持 HATEOAS 的用户管理 API ​

让我们通过一个完整的示例来看看如何在 Spring Boot 中使用 HATEOAS:

1. 创建数据模型 ​

kotlin
@Entity
@Table(name = "users")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @Column(nullable = false)
    val name: String,
    
    @Column(nullable = false, unique = true)
    val email: String,
    
    @Enumerated(EnumType.STRING)
    val status: UserStatus = UserStatus.ACTIVE
)

enum class UserStatus {
    ACTIVE, INACTIVE, SUSPENDED
}

2. 创建 HATEOAS 表示模型 ​

kotlin
import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation

@Relation(collectionRelation = "users") 
data class UserRepresentation(
    val id: Long,
    val name: String,
    val email: String,
    val status: UserStatus
) : RepresentationModel<UserRepresentation>() 

// 扩展函数,用于转换实体到表示模型
fun User.toRepresentation(): UserRepresentation {
    return UserRepresentation(
        id = this.id,
        name = this.name,
        email = this.email,
        status = this.status
    )
}

3. 创建支持 HATEOAS 的控制器 ​

kotlin
@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService
) {

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): EntityModel<UserRepresentation> {
        val user = userService.findById(id)
        val userRep = user.toRepresentation()
        
        return EntityModel.of(userRep) 
            .add(linkTo(methodOn(UserController::class.java).getUser(id)).withSelfRel()) 
            .add(linkTo(methodOn(UserController::class.java).getUserOrders(id)).withRel("orders")) 
            .apply {
                // 根据用户状态动态添加链接
                if (user.status == UserStatus.ACTIVE) {
                    add(linkTo(methodOn(UserController::class.java).suspendUser(id)).withRel("suspend"))
                }
                if (user.status == UserStatus.SUSPENDED) {
                    add(linkTo(methodOn(UserController::class.java).activateUser(id)).withRel("activate"))
                }
            }
    }

    @GetMapping
    fun getAllUsers(@PageableDefault(size = 10) pageable: Pageable): PagedModel<EntityModel<UserRepresentation>> {
        val users = userService.findAll(pageable)
        
        val userRepresentations = users.content.map { user ->
            EntityModel.of(user.toRepresentation())
                .add(linkTo(methodOn(UserController::class.java).getUser(user.id)).withSelfRel())
        }
        
        return PagedModel.of(userRepresentations, PagedModel.PageMetadata(
            users.size.toLong(),
            users.number.toLong(),
            users.totalElements,
            users.totalPages.toLong()
        )).add(linkTo(methodOn(UserController::class.java).getAllUsers(pageable)).withSelfRel()) 
    }

    @GetMapping("/{id}/orders")
    fun getUserOrders(@PathVariable id: Long): CollectionModel<EntityModel<OrderRepresentation>> {
        val orders = userService.getUserOrders(id)
        
        val orderRepresentations = orders.map { order ->
            EntityModel.of(order.toRepresentation())
                .add(linkTo(methodOn(OrderController::class.java).getOrder(order.id)).withSelfRel())
        }
        
        return CollectionModel.of(orderRepresentations)
            .add(linkTo(methodOn(UserController::class.java).getUserOrders(id)).withSelfRel())
            .add(linkTo(methodOn(UserController::class.java).getUser(id)).withRel("user"))
    }

    @PostMapping("/{id}/suspend")
    fun suspendUser(@PathVariable id: Long): EntityModel<UserRepresentation> {
        val user = userService.suspendUser(id)
        return EntityModel.of(user.toRepresentation())
            .add(linkTo(methodOn(UserController::class.java).getUser(id)).withSelfRel())
            .add(linkTo(methodOn(UserController::class.java).activateUser(id)).withRel("activate"))
    }

    @PostMapping("/{id}/activate")
    fun activateUser(@PathVariable id: Long): EntityModel<UserRepresentation> {
        val user = userService.activateUser(id)
        return EntityModel.of(user.toRepresentation())
            .add(linkTo(methodOn(UserController::class.java).getUser(id)).withSelfRel())
            .add(linkTo(methodOn(UserController::class.java).suspendUser(id)).withRel("suspend"))
    }
}

4. 响应示例 ​

当我们调用 GET /api/users/1 时,会得到如下响应:

json
{
  "id": 1,
  "name": "张三",
  "email": "zhangsan@example.com",
  "status": "ACTIVE",
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/users/1"
    },
    "orders": {
      "href": "http://localhost:8080/api/users/1/orders"
    },
    "suspend": {
      "href": "http://localhost:8080/api/users/1/suspend"
    }
  }
}

IMPORTANT

注意链接是如何根据用户状态动态生成的。活跃用户显示"suspend"链接,而被暂停的用户会显示"activate"链接。

核心概念深入理解 ​

RepresentationModel 家族 ​

Spring HATEOAS 提供了几个核心类来构建超媒体响应:

RepresentationModel 类型说明

  • RepresentationModel: 基础表示模型,包含链接信息
  • EntityModel: 包装单个实体对象
  • CollectionModel: 包装实体集合
  • PagedModel: 包装分页数据,继承自 CollectionModel
kotlin
// 单个实体
val userEntity = EntityModel.of(user)
    .add(linkTo(UserController::class.java).slash(user.id).withSelfRel())

// 集合
val usersCollection = CollectionModel.of(users)
    .add(linkTo(UserController::class.java).withSelfRel())

// 分页数据
val pagedUsers = PagedModel.of(users, pageMetadata)
    .add(linkTo(UserController::class.java).withSelfRel())

链接构建策略 ​

Spring HATEOAS 提供了多种构建链接的方式:

kotlin
class UserController {
    
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): EntityModel<UserRepresentation> {
        val user = userService.findById(id)
        
        return EntityModel.of(user.toRepresentation())
            // 方式1:使用 linkTo 和 methodOn(类型安全)
            .add(linkTo(methodOn(UserController::class.java).getUser(id)).withSelfRel())
            
            // 方式2:直接构建链接
            .add(Link.of("/api/users/$id").withSelfRel())
            
            // 方式3:使用 UriComponentsBuilder
            .add(Link.of(
                UriComponentsBuilder.fromPath("/api/users/{id}")
                    .buildAndExpand(id)
                    .toUriString()
            ).withSelfRel())
    }
}

TIP

推荐使用 linkTo(methodOn(...)) 方式,因为它提供了编译时类型安全,当控制器方法签名改变时,编译器会提醒你更新相关代码。

配置和自定义 ​

媒体类型配置 ​

默认情况下,Spring Boot HATEOAS 会将 application/json 请求的响应转换为 application/hal+json 格式:

yaml
spring:
  hateoas:
    # 禁用 HAL 作为默认 JSON 媒体类型
    use-hal-as-default-json-media-type: false

自定义 HAL 配置 ​

kotlin
@Configuration
class HateoasConfiguration {
    
    @Bean
    fun halConfiguration(): HalConfiguration {
        return HalConfiguration()
            .withLinkRelationType("links") // 自定义链接属性名
            .withEmbeddedRelationType("embedded") // 自定义嵌入属性名
    }
    
    @Bean
    fun hypermediaMappingInformation(): HypermediaMappingInformation {
        return HypermediaMappingInformation.builder()
            .withRootLinksFor(User::class.java) { user ->
                Links.of(
                    linkTo(methodOn(UserController::class.java).getUser(user.id)).withSelfRel()
                )
            }
            .build()
    }
}

禁用自动配置 ​

如果需要完全控制 HATEOAS 配置,可以使用 @EnableHypermediaSupport:

kotlin
@SpringBootApplication
@EnableHypermediaSupport(type = [HypermediaType.HAL]) 
class Application

// 注意:这会禁用 ObjectMapper 的自动配置

客户端支持 ​

Spring HATEOAS 也提供了客户端支持,让消费 HATEOAS API 变得更简单:

kotlin
@Service
class UserClientService {
    
    private val restTemplate = RestTemplate()
    
    fun getUser(id: Long): EntityModel<UserRepresentation> {
        return restTemplate.getForObject(
            "/api/users/$id", 
            object : ParameterizedTypeReference<EntityModel<UserRepresentation>>() {}
        )!!
    }
    
    fun followOrdersLink(userEntity: EntityModel<UserRepresentation>): List<Order> {
        val ordersLink = userEntity.getLink("orders") 
        
        return if (ordersLink.isPresent) {
            restTemplate.getForObject(
                ordersLink.get().href,
                object : ParameterizedTypeReference<CollectionModel<EntityModel<OrderRepresentation>>>() {}
            )?.content?.map { it.content } ?: emptyList()
        } else {
            emptyList()
        }
    }
}

最佳实践和注意事项 ​

1. 链接关系命名规范 ​

使用标准的 IANA 链接关系或自定义但一致的命名:

kotlin
// 推荐:使用标准关系名
.add(linkTo(...).withRel(IanaLinkRelations.EDIT))
.add(linkTo(...).withRel(IanaLinkRelations.DELETE))

// 或者使用一致的自定义关系名
.add(linkTo(...).withRel("user-orders"))
.add(linkTo(...).withRel("user-profile"))

2. 条件链接 ​

根据业务状态动态添加链接:

kotlin
fun buildUserLinks(user: User): EntityModel<UserRepresentation> {
    val entity = EntityModel.of(user.toRepresentation())
        .add(linkTo(methodOn(UserController::class.java).getUser(user.id)).withSelfRel())
    
    // 根据用户权限添加链接
    if (hasPermission(user, "EDIT")) {
        entity.add(linkTo(methodOn(UserController::class.java).updateUser(user.id, null)).withRel("edit"))
    }
    
    // 根据业务状态添加链接
    when (user.status) {
        UserStatus.ACTIVE -> entity.add(linkTo(methodOn(UserController::class.java).suspendUser(user.id)).withRel("suspend"))
        UserStatus.SUSPENDED -> entity.add(linkTo(methodOn(UserController::class.java).activateUser(user.id)).withRel("activate"))
        else -> { /* 不添加状态变更链接 */ }
    }
    
    return entity
}

3. 性能考虑 ​

WARNING

构建大量链接可能影响性能,特别是在处理大型集合时。考虑使用缓存或延迟加载策略。

kotlin
@Service
class UserRepresentationService {
    
    @Cacheable("user-links") 
    fun buildUserEntity(user: User): EntityModel<UserRepresentation> {
        return EntityModel.of(user.toRepresentation())
            .add(buildUserLinks(user))
    }
    
    // 对于大型集合,考虑简化链接
    fun buildUserSummaryEntity(user: User): EntityModel<UserRepresentation> {
        return EntityModel.of(user.toRepresentation())
            .add(linkTo(methodOn(UserController::class.java).getUser(user.id)).withSelfRel())
            // 只包含最重要的链接
    }
}

总结 ​

Spring HATEOAS 通过为 REST API 添加超媒体支持,让 API 变得更加智能和自描述。它解决了传统 REST API 中客户端需要硬编码 URL 的问题,提供了更好的解耦性和可发现性。

核心优势 ​

HATEOAS 的核心价值

  1. 动态发现:客户端可以动态发现可用操作
  2. 松耦合:减少客户端对服务端 URL 结构的依赖
  3. 状态驱动:根据资源状态提供不同的操作选项
  4. 自文档化:API 响应本身就包含了导航信息

何时使用 HATEOAS ​

  • ✅ 构建面向外部的公共 API
  • ✅ 需要支持多种客户端的场景
  • ✅ 复杂的业务流程和状态转换
  • ✅ 希望 API 具有良好的可发现性

CAUTION

HATEOAS 会增加响应大小和复杂性,在简单的内部 API 或性能要求极高的场景中需要权衡使用。

通过 Spring Boot 的自动配置,使用 HATEOAS 变得非常简单。只需添加依赖,创建表示模型,并在控制器中构建链接即可。这让你的 REST API 不仅能提供数据,还能指导客户端如何与之交互,真正实现了"超媒体作为应用状态引擎"的理念。 🎉