6.2 启用超媒体

6.2 启用超媒体

到目前为止,创建的 API 是相当基本的,但是只要使用它的客户机知道 API 的 URL 模式,它就可以工作。例如,客户端可能被硬编码,知道它可以向 /design/recent 接口发出 GET 请求来获得最近创建的 tacos 列表。同样地,它可能是硬编码的,以知道它可以将该列表中的任何 taco 的 ID 附加到 /design 接口中,以获得特定 taco 资源的 URL。

使用硬编码的 URL 模式和字符串操作在 API 客户机代码中很常见。但是请想象一下,如果 API 的 URL 模式改变了,会发生什么。硬编码的客户端代码相对于 API 已经过时了,因此会被破坏。硬编码 API url 并在其上使用字符串操作会使客户端代码变得兼容性弱。

超媒体作为应用程序状态的引擎(HATEOAS),是一种创建自描述 API 的方法,其中从 API 返回的资源包含到相关资源的链接。这使客户机能够在对 API 的 url 了解最少的情况下引导 API。相反,它理解 API 提供的资源之间的关系,并在遍历这些关系时使用对这些关系的理解来发现 API 的 url。

例如,假设一个客户端请求一个最近设计的 tacos 列表。在它的原始形式,没有超链接,最近的 tacos 列表将在客户端以 JSON 的形式接收,看起来像这样(为了简洁起见,除了列表中的第一个 taco 外,其他都被剪掉了):

[
    {
        "id": 4,
        "name": "Veg-Out",
        "createdAt": "2018-01-31T20:15:53.219+0000",
        "ingredients": [
            {"id": "FLTO", "name": "Flour Tortilla", "type": "WRAP"},
            {"id": "COTO", "name": "Corn Tortilla", "type": "WRAP"},
            {"id": "TMTO", "name": "Diced Tomatoes", "type": "VEGGIES"},
            {"id": "LETC", "name": "Lettuce", "type": "VEGGIES"},
            {"id": "SLSA", "name": "Salsa", "type": "SAUCE"}
        ]
    },
    ...
]

如果客户端希望在 taco 本身上获取或执行其他 HTTP 操作,则需要(通过硬编码)知道可以将 id 属性的值附加到路径为 /design 的 URL。同样,如果它希望对其中一个成分执行 HTTP 操作,它需要知道它可以将成分的 id 附加到路径为 /ingredients 的 URL。在这两种情况下,都需要在路径前面加上 http://https:// 和 API 的主机名。

相反,如果使用超媒体启用了 API,则该 API 将描述自己的 url,从而使客户端无需进行硬编码。如果嵌入了超链接,那么最近创建的 tacos 列表可能与下面的列表类似。程序清单 6.3 包含超链接的 taco 资源列表

{
    "_embedded": {
        "tacoResourceList": [
            {
                "name": "Veg-Out",
                "createdAt": "2018-01-31T20:15:53.219+0000",
                "ingredients": [
                    {
                        "name": "Flour Tortilla", "type": "WRAP",
                        "_links": {
                            "self": { "href": "http://localhost:8080/ingredients/FLTO" }
                        }
                    },
                    {
                        "name": "Corn Tortilla", "type": "WRAP",
                        "_links": {
                            "self": { "href": "http://localhost:8080/ingredients/COTO" }
                        }
                    },
                    {
                        "name": "Diced Tomatoes", "type": "VEGGIES",
                        "_links": {
                            "self": { "href": "http://localhost:8080/ingredients/TMTO" }
                        }
                    },
                    {
                        "name": "Lettuce", "type": "VEGGIES",
                        "_links": {
                            "self": { "href": "http://localhost:8080/ingredients/LETC" }
                        }
                    },
                    {
                        "name": "Salsa", "type": "SAUCE",
                        "_links": {
                            "self": { "href": "http://localhost:8080/ingredients/SLSA" }
                        }
                    }
                ],
                "_links": {
                    "self": { "href": "http://localhost:8080/design/4" }
                }
            },
            ...
        ]
    },
    "_links": {
        "recents": {
            "href": "http://localhost:8080/design/recent"
        }
    }
}

这种特殊风格的 HATEOAS 被称为 HAL(超文本应用语言)这是一种简单且常用的格式,用于在 JSON 响应中嵌入超链接。

虽然这个列表不像以前那样简洁,但它确实提供了一些有用的信息。这个新的 tacos 列表中的每个元素都包含一个名为 _links 的属性,该属性包含用于客户端引导的 API 超链接。在本例中,tacos 和 ingredients 都有引用这些资源的自链接,整个列表都有一个引用自身的 recents 链接。

如果客户端应用程序需要对列表中的 taco 执行 HTTP 请求,则不需要了解 taco 资源的 URL 是什么样子的。相反,它知道请求自链接,该链接映射到 http://localhost:8080/design/4。如果客户想要处理特定的成分,它只需要遵循该成分的自链接。

Spring HATEOAS 项目为 Spring 提供了超链接支持。它提供了一组类和资源汇编器,可用于在从 Spring MVC 控制器返回资源之前向资源添加链接。

要在 Taco Cloud API 中启用超媒体,需要将 Spring HATEOAS starter 依赖项添加到构建中:

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

这个启动程序不仅将 Spring HATEOAS 添加到项目的类路径中,还提供了自动配置来启用 Spring HATEOAS。需要做的就是重新设计控制器以返回资源类型,而不是域类型。首先,将超级媒体链接添加到 /design/recent 的 GET 请求中,用于返回的最近的 tacos 列表。

6.2.1 添加超链接

Spring HATEOAS 提供了两种表示超链接资源的主要类型:Resource 和 Resources。Resource 类型表示单个资源,而 Resources 是资源的集合。这两种类型都能够携带其他资源的链接。当从 Spring MVC REST 控制器方法返回时,它们携带的链接将包含在客户端接收到的 JSON(或 XML)中。

要将超链接添加到最近创建的 tacos 列表,需要重新访问程序清单 6.2 中显示的 recentTacos() 方法。最初的实现返回了一个 List 列表,这在当时是可以的,但是现在需要它来返回一个 Resources 对象。下面的程序清单显示了 recentTacos() 的新实现,其中包括在最近的 tacos 列表中启用超链接的第一步。程序清单 6.4 为资源添加超链接

@GetMapping("/recent")
public Resources<Resource<Taco>> recentTacos() {
    PageRequest page = PageRequest.of(
        0, 12, Sort.by("createdAt").descending());

    List<Taco> tacos = tacoRepo.findAll(page).getContent();
    Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);

    recentResources.add(
        new Link("http://localhost:8080/design/recent", "recents"));

    return recentResources;
}

在这个新版本的 recentTacos() 中,不再直接返回 tacos 列表。而是使用 Resources.wrap() 将 tacos 列表包装为 Resources<Resource> 的实例,该实例最终从该方法返回。但是在返回 Resources 对象之前需要添加一个链接,该链接的关系名称为 recents,URL 为 http://localhost:8080/design/recent。因此,以下 JSON 片段包含在 API 请求返回的资源中:

"_links": {
    "recents": {
        "href": "http://localhost:8080/design/recent"
    }
}

这是一个好的开始,但你仍有一些工作要做。此时,添加的惟一链接就是整个列表;没有链接添加到 taco 资源本身或每个 taco 的成分,很快就会加上的。但首先,需要处理为 recents 链接提供的硬编码 URL。

像这样硬编码一个 URL 是非常糟糕的主意。除非 Taco Cloud 仅限于在自己的开发机器上运行应用程序,否则需要一种方法来避免在 URL 中硬编码 localhost:8080。幸运的是,Spring HATEOAS 以链接构建器的形式提供了帮助。

Spring HATEOAS 链接生成器中最有用的是 ControllerLinkBuilder。这个链接生成器非常聪明,无需硬编码就可以知道主机名是什么。它还提供了一个方便的连贯的 API,帮助你构建相对于任何控制器的基本 URL 的链接。

使用 ControllerLinkBuilder,可以重写硬编码的链接在 recentTacos() 中创建的 Link,如下所示:

Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);
recentResources.add(
    ControllerLinkBuilder.linkTo(DesignTacoController.class)
                         .slash("recent")
                         .withRel("recents"));

不仅不再需要硬编码主机名,还不必指定 /design 路径。相反,需要一个指向 DesignTacoController 的链接,它的基本路径是 /design。ControllerLinkBuilder 使用控制器的基本路径作为正在创建的链接对象的基础。

接下来是对任何 Spring 项目中我最喜欢的方法之一的调用:slash()。我喜欢这个方法因为它简洁地描述了它要做的事情。它确实在 URL 后面附加了一个斜杠 / 和给定的值,因此,URL 的路径是 /design/recent

最后,为链接指定一个关系名。在本例中,关系被命名为 recents。

尽管我非常喜欢 slash() 方法,ControllerLinkBuilder 有另一个方法可以帮助消除与链接 url 相关的硬编码。可以通过给予它在控制器上的方法来调用 linkTo(),而不是调用 slash(),并让 ControllerLinkBuilder 从控制器基础路径和方法映射路径中派生出基础 URL。下面的代码以这种方式使用了 linkTo() 方法:

Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);
recentResources.add(
    linkTo(methodOn(DesignTacoController.class).recentTacos())
    .withRel("recents"));

这里我决定静态地引用 linkTo() 和 methodOn() 方法(都来自 ControllerLinkBuilder),以使代码更易于阅读。methodOn() 方法获取控制器类并允许调用 recentTacos() 方法,该方法被 ControllerLinkBuilder 拦截,不仅用于确定控制器的基本路径,还用于确定映射到 recentTacos() 的路径。现在,整个 URL 都是从控制器的映射中派生出来的,而且绝对没有硬编码的部分,非常好~

6.2.2 创建资源装配器

现在需要向列表中包含的 taco 资源添加链接。一种选择是循环遍历 Resources 对象中携带的每个资 Resource 元素,分别为每个元素添加一个链接。但是这有点乏味,无论在哪里返回 taco 资源列表,都需要在 API 中重复编写代码。

我们需要一个不同的策略。

将定义一个实用工具类,将 taco 对象转换为新的 TacoResource 对象,而不是让 Resources.wrap() 为列表中的每个 taco 创建一个资源对象。TacoResource 对象看起来很像 Taco,但是它也能够携带链接。下面程序清单显示了 TacoResource 的样子。程序清单 6.5 携带域数据的 taco 资源和超链接列表数据

package tacos.web.api;

import java.util.Date;
import java.util.List;
import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Ingredient;
import tacos.Taco;

public class TacoResource extends ResourceSupport {

    @Getter
    private final String name;

    @Getter
    private final Date createdAt;

    @Getter
    private final List<Ingredient> ingredients;

    public TacoResource(Taco taco) {
        this.name = taco.getName();
        this.createdAt = taco.getCreatedAt();
        this.ingredients = taco.getIngredients();
    }
}

在很多方面,TacoResource 与 Taco 域类型并没有太大的不同。它们都有 name、createAt 和 ingredients 属性。但是 TacoResource 扩展了 ResourceSupport 以继承链接对象列表和管理链接列表的方法。

另外,TacoResource 不包含 Taco 的 id 属性。这是因为不需要在 API 中公开任何特定于数据库的 id。从 API 客户机的角度来看,资源的自链接将作为资源的标识符。

注意:

域和资源:分开还是放一起?一些 Spring 开发人员可能会选择通过扩展他们的域类型 ResourceSupport,来将他们的域类型和资源类型组合成单个类型,正确的方法没有对错之分。我选择创建一个单独的资源类型,这样 Taco 就不会在不需要链接的情况下不必要地与资源链接混杂在一起。另外,通过创建一个单独的资源类型,我可以很容易地去掉 id 属性,这样就不会在 API 中暴露它。

TacoResource 只有一个构造函数,它接受一个 Taco 并将相关属性从 Taco 复制到自己的属性。这使得将单个 Taco 对象转换为 TacoResource 变得很容易。但是,如果到此为止,仍然需要循环才能将 Taco 对象列表转换为 Resources

为了帮助将 Taco 对象转换为 TacoResource 对象,还需要创建一个资源装配器,如下程序清单所示。程序清单 6.6 装配 taco 资源的资源装配器

package tacos.web.api;

import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import tacos.Taco;

public class TacoResourceAssembler extends ResourceAssemblerSupport<Taco, TacoResource> {

    public TacoResourceAssembler() {
        super(DesignTacoController.class, TacoResource.class);
    }

    @Override
    protected TacoResource instantiateResource(Taco taco) {
        return new TacoResource(taco);
    }

    @Override
    public TacoResource toResource(Taco taco) {
        return createResourceWithId(taco.getId(), taco);
    }
}

TacoResourceAssembler 有一个默认构造函数,它通知超类(ResourceAssemblySupport),在创建 TacoResource 时,它将使用 DesignTacoController 来确定它创建的链接中的任何 url 的基本路径。

重写 instantiateResource() 方法来实例化给定 Taco 的 TacoResource。如果 TacoResource 有一个默认的构造函数,那么这个方法是可选的。但是,在本例中,TacoResource 需要使用 Taco 进行构造,因此需要覆盖它。

最后,toResource() 方法是继承 ResourceAssemblySupport 时唯一严格要求的方法。这里,它从 Taco 创建一个 TacoResource 对象,并自动给它一个自链接,该链接的 URL 来自 Taco 对象的 id 属性。

从表面上看,toResource() 似乎具有与 instantiateResource() 类似的用途,但它们的用途略有不同。虽然 instantiateResource() 仅用于实例化资源对象,但 toResource() 不仅用于创建资源对象,还用于用链接填充它。在背后,toResource() 将调用 instantiateResource()。

现在调整 recentTacos() 方法来使用 TacoResourceAssembler:

@GetMapping("/recent")
public Resources<TacoResource> recentTacos() {
    PageRequest page = PageRequest.of(
        0, 12, Sort.by("createdAt").descending());

    List<Taco> tacos = tacoRepo.findAll(page).getContent();
    List<TacoResource> tacoResources = new TacoResourceAssembler().toResources(tacos);

    Resources<TacoResource> recentResources = new Resources<TacoResource>(tacoResources);
    recentResources.add(
        linkTo(methodOn(DesignTacoController.class).recentTacos())
        .withRel("recents"));

    return recentResources;
}

recentTacos() 现在不是返回一个 Resources<Resource>,而是返回一个 Resources,以利用新的 TacoResource 类型。从存储库获取 Taco 之后,将 Taco 对象列表传递给 TacoResourceAssembler 上的 toResources() 方法。这个方便的方法循环遍历所有 Taco 对象,然后调用在 TacoResourceAssembler 中覆盖的 toResource() 方法来创建 TacoResource 对象列表。

通过 TacoResource 列表,可以创建一个 Resources 对象,然后使用 recentTacos() 以前版本中的 recents 链接填充它。

此时,对 /design/recent 接口的 GET 请求将生成一个 taco 列表,其中每个 taco 都有一个自链接和一个 recents 链接,但这些成分之间仍然没有联系。为了解决这个问题,你需要为原料创建一个新的资源装配器:

package tacos.web.api;

import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import tacos.Ingredient;

class IngredientResourceAssembler extends
    ResourceAssemblerSupport<Ingredient, IngredientResource> {

    public IngredientResourceAssembler() {
        super(IngredientController2.class, IngredientResource.class);
    }

    @Override
    public IngredientResource toResource(Ingredient ingredient) {
        return createResourceWithId(ingredient.getId(), ingredient);
    }

    @Override
    protected IngredientResource instantiateResource(Ingredient ingredient) {
        return new IngredientResource(ingredient);
    }
}

如你所见,IngredientResourceAssembler 很像 TacoResourceAssembler,但它使用的是 Ingredient 和 IngredientResource 对象,而不是 Taco 和 TacoResource 对象。

说到 IngredientResource,它是这样的:

package tacos.web.api;

import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Ingredient;
import tacos.Ingredient.Type;

public class IngredientResource extends ResourceSupport {

    @Getter
    private String name;

    @Getter
    private Type type;

    public IngredientResource(Ingredient ingredient) {
        this.name = ingredient.getName();
        this.type = ingredient.getType();
    }
}

与 TacoResource 一样,IngredientResource 继承了 ResourceSupport 并将相关属性从域类型复制到它自己的属性集中(不包括 id 属性)。

剩下要做的就是对 TacoResource 做一些轻微的修改,这样它就会携带一个 IngredientResource 对象,而不是 Ingredient 对象:

package tacos.web.api;

import java.util.Date;
import java.util.List;
import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Taco;

public class TacoResource extends ResourceSupport {
    private static final IngredientResourceAssembler
        ingredientAssembler = new IngredientResourceAssembler();

    @Getter
    private final String name;

    @Getter
    private final Date createdAt;

    @Getter
    private final List<IngredientResource> ingredients;

    public TacoResource(Taco taco) {
        this.name = taco.getName();
        this.createdAt = taco.getCreatedAt();
        this.ingredients = ingredientAssembler.toResources(taco.getIngredients());
    }
}

这个新版本的 TacoResource 创建了一个 IngredientResourceAssembly 的静态实例,并使用它的 toResource() 方法将给定 Taco 对象的 Ingredient 列表转换为 IngredientResouce 列表。

最近的 tacos 列表现在完全嵌套了超链接,不仅是为它自己(recents 链接),而且为它所有的 tacos 数据和那些 taco 的 ingredient 数据。响应应该类似于程序清单 6.3 中的 JSON。你可以在这里停下来,然后继续下一个话题。但首先我要解决程序清单 6.3 中一些令人困扰的问题。

6.2.3 嵌套命名关系

如果仔细看看程序清单 6.3,会发现顶级元素像这样:

{
    "_embedded": {
        "tacoResourceList": [
            ...
        ]
    }
}

最值得注意的是 tacoResourceList 这个名称,它源于 List 中创建的 Resources 对象实例,虽然不太可能,但是如果将 TacoResource 类名称重构为其他名称,那么 JSON 中的字段名将会需要更改以与之匹配,这可能会破坏任何依赖该名称的客户端。

@Relation 注解可以帮助打破 JSON 字段名与 Java 中定义的资源类型类名之间的耦合。通过在 TacoResource 上使用 @Relationip 注解,可以指定 Spring HATEOAS 应该如何在 JSON 结果中字段的命名:

@Relation(value="taco", collectionRelation="tacos")
public class TacoResources extends ResourcesSupport {
    ...
}

在这里,已经指定当资源对象中使用 TacoResource 对象列表时,应该将其命名为 tacos。虽然在我们的 API 中没有使用它,但是一个 TacoResource 对象应该在 JSON 中被称为 taco。

因此,从 /design/recent 返回的 JSON 现在看起来是这样的(无论在 TacoResource 上执行或不执行什么重构):

{
    "_embedded": {
        "tacos": [
            ...
        ]
    }
}

Spring HATEOAS 使向 API 添加链接变得非常简单明了。尽管如此,它确实添加了几行不需要的代码。因此,一些开发人员可能会选择不在他们的 API 中使用 HATEOAS,即使这意味着如果 API 的 URL 模式发生变化,客户端代码可能会被破坏。

如果在存储库中使用 Spring Data,可能会有一个双赢的方案。让我们看看 Spring Data REST 如何根据第 3 章中使用 Spring Data 创建的数据存储库自动创建 API。

上一页
下一页