消费REST服务
你有没有过这样的经历:兴冲冲地跑去看电影,却发现自己是影厅中唯一的观众?这当然是一种很奇妙的经历,本质上来讲,这场电影变成了私人电影。你可以选择任意想要的座位,和屏幕上的角色交谈,甚至打开手机发推文,完全不用担心因为破坏了别人的观影体验而惹得别人生气。最棒的是,没有人会毁了你观看这部电影的心情。
对我来说,这样的事情并不常见。但是,遇到这种情况的时候,我会在想,如果我也不出现,会发生什么呢?工作人员还会播放这部影片吗?电影中的英雄还会拯救世界吗?电影播放结束后,工作人员还会打扫影院吗?
没有观众的电影就像没有客户端的API。这些API已经准备好接收和提供数据了,但是如果它们从来没有被调用过,那么它们还是API吗?这些API就像薛定谔的猫。在发起请求之前,我们并不知道这些API是否活跃,也不知道它们是否返回HTTP 404响应。
这种场景并不罕见:Spring应用除了提供对外API之外,同时要对另外一个应用的API发起请求。实际上,在微服务领域,这正变得越来越普遍。因此,花点时间研究一下如何使用Spring与REST API交互是非常值得的。
Spring应用可以采用多种方式来消费REST API。
-
RestTemplate:由Spring核心框架提供的简单、同步REST客户端。
-
Traverson:对Spring RestTemplate的包装,由Spring HATEOAS提供的支持超链接、同步的REST客户端,其灵感来源于同名的JavaScript库。
-
WebClient:反应式、异步REST的客户端。
现在,我们主要关注使用RestTemplate创建客户端。我将WebClient推迟到第12章介绍Spring的反应式Web框架时再讨论。如果你对编写支持超链接的客户端感兴趣,可以参阅Traverson的文档。
从客户端的角度来看,与REST资源进行交互涉及很多工作,而且大多数都是很单调乏味的样板式代码。如果使用较低层级HTTP库,客户端需要创建一个客户端实例和请求对象,执行请求,解析响应,将响应映射为领域对象,还要处理这个过程中可能会抛出的所有异常。不管发送什么样的HTTP请求,这种样板代码都要不断重复。
为了避免这种样板代码,Spring提供了RestTemplate。就像JDBCTemplate能够处理JDBC中丑陋的那部分代码一样,RestTemplate也能够将你从消费REST资源所面临的单调工作中解放出来。
RestTemplat提供了41个与REST资源交互的方法。我不会详细介绍它所提供的所有方法,而是只考虑12个独立的操作,这些操作的重载形式组成了完整的41个方法。这12个操作如表7.2所示。

除了TRACE,RestTemplate对每种标准的HTTP方法都提供了至少一个方法。除此之外,execute()和exchange()提供了较低层次的通用方法,以便使用任意的HTTP操作。
表7.2中的大多数操作都以如下的3种方法形式进行了重载:
-
使用String作为URL格式,并使用可变参数列表指明URL参数;
-
使用String作为URL格式,并使用Map<String,String>指明URL参数;
-
使用java.net.URI作为URL格式,不支持参数化URL。
明确了RestTemplate所提供的12个操作以及各个变种如何工作之后,我们就能以自己的方式编写消费REST资源的客户端了。
要使用RestTemplate,可以在需要的地方创建一个实例:
RestTemplate rest = new RestTemplate();
也可以将其声明为一个bean并注入到需要的地方:
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
我们从其支持的4个主要HTTP方法(也就是GET、PUT、DELETE和POST)入手,来研究RestTemplate的操作。不妨从GET方法的getForObject()和getForEntity()开始。
GET资源
假设我们现在想要通过Taco Cloud API获取某个配料。为了实现这一点,我们可以使用RestTemplate的getForObject()方法来获取配料。例如,如下的代码使用RestTemplate来根据ID来获取Ingredient对象:
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject("http://localhost:8080/ingredients/{id}",
Ingredient.class, ingredientId);
}
在这里,我们使用了getForObject()的变种形式,它接收一个String类型的URL并使用可变列表来指定URL变量。传递给getForObject()的ingredientId参数会用来填充给定URL的{id}占位符。尽管在本例中只有一个URL变量,但是有很重要的一点需要我们注意:变量参数会按照它们出现的顺序被设置到占位符中。
getForObject()方法的第二个参数是响应应该绑定的类型。在本例中,响应数据(很可能是JSON格式)应该被反序列化为要返回的Ingredient对象。
另外一种替代方案是使用Map来指定URL变量:
public Ingredient getIngredientById(String ingredientId) {
Map<String, String> urlVariables = new HashMap<>();
urlVariables.put("id", ingredientId);
return rest.getForObject("http://localhost:8080/ingredients/{id}",
Ingredient.class, urlVariables);
}
在本例中,ingredientId的值会映射到名为id的key上。当发起请求的时候,{id}占位符将会被替换成key为id的Map条目。
使用URI参数要稍微复杂一些,这种方式需要我们在调用getForObject()之前构建URI对象。在其他方面,它与另外两个变种非常类似:
public Ingredient getIngredientById(String ingredientId) {
Map<String, String> urlVariables = new HashMap<>();
urlVariables.put("id", ingredientId);
URI url = UriComponentsBuilder
.fromHttpUrl("http://localhost:8080/ingredients/{id}")
.build(urlVariables);
return rest.getForObject(url, Ingredient.class);
}
在这里,URI对象是通过String规范定义的,它的占位符会被Map中的条目替换。这与我们之前看到的getForObject()变种非常相似。getForObject()是获取资源的有效方式。但是,如果客户端需要的不仅仅是载荷体,那么可以考虑使用getForEntity()。
getForEntity()的工作方式和getForObject()类似,但是它所返回的并不是代表响应载荷的领域对象,而是会包裹领域对象的ResponseEntity对象。借助ResponseEntity对象能够访问很多响应细节,比如响应头信息。
例如,假设我们除了想要获取配料数据,还想要从响应中探查Date头信息。借助getForEntity(),这个需求能够很容易实现:
public Ingredient getIngredientById(String ingredientId) {
ResponseEntity<Ingredient> responseEntity =
rest.getForEntity("http://localhost:8080/ingredients/{id}",
Ingredient.class, ingredientId);
log.info("Fetched time: {}",
responseEntity.getHeaders().getDate());
return responseEntity.getBody();
}
getForEntity()有着与getForObject()方法相同参数的重载形式,所以我们可以按照可变列表参数的形式提供URL变量,也可以按照URI对象的形式调用getForEntity()。
PUT资源
为了发送HTTP PUT请求,RestTemplate提供了put()方法。put()方法的3个变种形式都会接收一个会被序列化并发送至给定URL的Object。就URL本身来讲,它可以按照URI对象或String的形式来指定。与getForObject()和getForEntity()类似,URL变量能够以可变参数列表或Map的形式提供。
假设我们想要使用一个新Ingredient对象的数据来替换某个配料资源,那么如下的代码片段就能做到这一点:
public void updateIngredient(Ingredient ingredient) {
rest.put("http://localhost:8080/ingredients/{id}",
ingredient, ingredient.getId());
}
在这里,URL是以String的形式指定的,该URL包含一个占位符,它会被给定Ingredient的id属性所替换。要发送的数据是Ingredient对象本身。put()方法返回void,所以我们没有必要处理返回值。
DELETE资源
假设Taco Cloud不想再提供某种配料,因此我们要从可选列表中将其完全删除。为了实现这一点,可以使用RestTemplate来调用delete()方法:
public void deleteIngredient(Ingredient ingredient) {
rest.delete("http://localhost:8080/ingredients/{id}",
ingredient.getId());
}
在本例中,我们只为delete()提供了URL(以String的形式指定)和URL变量值。但是,和其他的RestTemplate方法类似,URL能够以URI对象的方式来指定,URL参数也能够以Map的方式来声明。
POST资源
现在,我们假设要添加新的配料到Taco Cloud菜单中。为了实现这一点,我们可以向“…/ingredients”端点发送HTTP POST请求,并将配料数据放到请求体中。RestTemplate有3种发送POST请求的方法,每种方法都有相同的重载变种来指定URL。如果希望在POST请求之后得到新创建的Ingredient资源,可以按照如下的方式使用postForObject():
public Ingredient createIngredient(Ingredient ingredient) {
return rest.postForObject("http://localhost:8080/ingredients",
ingredient, Ingredient.class);
}
postForObject()方法的这个变种形式接收String类型的URL规范、要提交给服务器端的对象,以及响应体应该绑定的领域类型。尽管我们在这里没有用到,但是第4个参数可以是URL变量值的Map或可变参数的列表。它们能够替换到URL之中。
如果客户端还想要知道新创建资源的地址,那么可以调用postForLocation()方法,如下所示:
public java.net.URI createIngredient(Ingredient ingredient) {
return rest.postForLocation("http://localhost:8080/ingredients",
ingredient);
}
注意,postForLocation()有与postForObject()类似的工作方式,只不过它返回的是新创建资源的URI,而不是资源对象本身。这里返回的URI是从响应的Location头信息中派生出来的。如果同时需要地址和响应载荷,可以使用postForEntity()方法:
public Ingredient createIngredient(Ingredient ingredient) {
ResponseEntity<Ingredient> responseEntity =
rest.postForEntity("http://localhost:8080/ingredients",
ingredient,
Ingredient.class);
log.info("New resource created at {}",
responseEntity.getHeaders().getLocation());
return responseEntity.getBody();
}
尽管RestTemplate的方法可以实现不同的目的,但是用法非常相似。因此,我们很容易就可以精通RestTemplate,并将其用到客户端代码中。