Appearance
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": "[email protected]",
"status": "active"
}
HATEOAS 的解决方案
HATEOAS 让 API 响应变得"自解释",就像网页中的超链接一样:
json
{
"id": 1,
"name": "张三",
"email": "[email protected]",
"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 的自动配置为我们提供了:
- 无需手动启用:替代了
@EnableHypermediaSupport
注解 - 预配置的 Bean:包括
LinkDiscoverers
(客户端支持)和ObjectMapper
- 智能序列化:自动将响应序列化为合适的超媒体格式
实战示例:构建支持 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": "[email protected]",
"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 的核心价值
- 动态发现:客户端可以动态发现可用操作
- 松耦合:减少客户端对服务端 URL 结构的依赖
- 状态驱动:根据资源状态提供不同的操作选项
- 自文档化:API 响应本身就包含了导航信息
何时使用 HATEOAS
- ✅ 构建面向外部的公共 API
- ✅ 需要支持多种客户端的场景
- ✅ 复杂的业务流程和状态转换
- ✅ 希望 API 具有良好的可发现性
CAUTION
HATEOAS 会增加响应大小和复杂性,在简单的内部 API 或性能要求极高的场景中需要权衡使用。
通过 Spring Boot 的自动配置,使用 HATEOAS 变得非常简单。只需添加依赖,创建表示模型,并在控制器中构建链接即可。这让你的 REST API 不仅能提供数据,还能指导客户端如何与之交互,真正实现了"超媒体作为应用状态引擎"的理念。 🎉