6.2启用超媒体

6.2启用超媒体

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

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

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

例如,假设一个客户端请求一个最近设计的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属性的值附加到路径为 /designURL。同样,如果它希望对其中一个成分执行HTTP操作,它需要知道它可以将成分的id附加到路径为 /ingredientsURL。在这两种情况下,都需要在路径前面加上 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超链接。在本例中,tacosingredients都有引用这些资源的自链接,整个列表都有一个引用自身的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/recentGET请求中,用于返回的最近的tacos列表。

6.2.1添加超链接

Spring HATEOAS提供了两种表示超链接资源的主要类型:ResourceResourcesResource类型表示单个资源,而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对象之前需要添加一个链接,该链接的关系名称为recentsURLhttp://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的链接,它的基本路径是 /designControllerLinkBuilder使用控制器的基本路径作为正在创建的链接对象的基础。

接下来是对任何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();
    }
}

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

另外,TacoResource不包含Tacoid属性。这是因为不需要在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()方法来实例化给定TacoTacoResource。如果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,但它使用的是IngredientIngredientResource对象,而不是TacoTacoResource对象。

说到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数据和那些tacoingredient数据。响应应该类似于程序清单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,即使这意味着如果APIURL模式发生变化,客户端代码可能会被破坏。

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

上一页
下一页