分佈式架構設計之RestAPI HAL
在上一篇文章《分佈式架構設計之Rest API》中,我對什麼是Rest進行了詳細的介紹,同時以書本的CRUD爲例,實現了Rest API的基本操作。但是細心的讀者可能會發現,所有接口返回的數據格式並不統一,沒有一定的規範性,更重要的是接口通信的內容並未很明確地體現“資源”的概念,所以在這篇文章就來介紹下現在流行的HAL風格的數據格式,需要提及的是:HAL(Hypertext Application Language)是一種API數據格式風格,同時也能規範接口通信內容的格式,降低客戶端與服務器端接口API的耦合度。
l 什麼是HAL
l 實例的驗證
一、什麼是HAL
上面的拼圖並不一個創新,是直接從HAL的作者Mike kelly的官方網站(http://stateless.co/hal_specification.html)上覆制的一份,它包含三個標準部分:
1、狀態(State)
指的是資源本身的固有屬性,比如:書本的資源表述如下:
{
…
"id": 1,
"name": "《Web進階實戰教材》",
"tag": "編程語言",
"price": 68.59
…
}
2、鏈接(Links)
鏈接定義了與當前資源相關的一組資源的集合,比如:
"_links":{
"self": {
"href": "…"
}
}
正如上所示,鏈接包含了三部分組成:鏈接名稱、目標地址及訪問的地址參數。
3、子資源(Embedded Resource)
指的是描述在當前資源的內部,其嵌套資源的定義。比如:
"_embedded":{
"books": [
{
"id": 1,
"name": "《Web進階實戰教材》",
"tag": "編程語言",
"price": 68.59,
"updateTime": 1502556100000,
"createTime":1502556100000,
"_links": {
"ex:items": {
"href":"…"
},
"self": {
"href": "…"
},
"curies": [
{
"href":"…",
"name":"ex",
"templated": true
}
]
}
},
{
"id": 2,
"name": "《Ruby進階實戰教材》",
"tag": "編程語言",
"price": 68.59,
"updateTime":1502691808000,
"createTime": 1502691808000,
"_links": {
"ex:items": {
"href":"http://localhost:8080/cwteam/2"
},
"self": {
"href":"http://localhost:8080/cwteam/books/2"
},
"curies": [
{
"href":"http://localhost:8080/cwteam/books/{rel}",
"name":"ex",
"templated": true
}
]
}
}
]
}
另外,HAL規範是圍繞資源和鏈接兩個概念展開的。資源的表達包含鏈接、嵌套的資源和狀態。資源的狀態一般指的是資源本身所包含的數據,鏈接則包含其指向的目標地址(URI),所表達的關係和其它可選的相關屬性。正如上面所示json格式,資源的鏈接包含在_links屬性對應的鍵值對中,而其中的鍵(key)是鏈接的關係,而值(value)則是另一個包含href等其它鏈接屬性的對象或對象數組。當前所包含的資源,則由_embedded屬性表示,其值是包含了其它資源的哈希對象或對象數組。
使用 URL 作爲鏈接的關係帶來的問題是 URL 作爲屬性名稱來說顯得過長,而且不同關係的 URL 的大部分內容是重複的。爲了解決這個問題,可以使用 Curie,而Curie 可以作爲鏈接關係 URL 的模板。鏈接的關係聲明時使用 Curie 的名稱作爲前綴,不用提供完整的 URL。應用中聲明的 Curie 出現在_links 屬性中。代碼中定義了 URI 模板爲“http://localhost:8080/exlist/rels/{rel}”的名爲 ex 的 Curie。在使用了 Curie 之後,名爲 items 的鏈接關係變成了包含前綴的“ex:items”的形式。這就表示該鏈接的關係實際上是“http://localhost:8080/exlist/rels/items”。
二、實例的驗證
我們以上一篇文章的例子爲基礎,對其實現HAL的規範化。可在此之前,有必要介紹下Spring的子項目HATEOAS對HAL的支持,也是Rest風格中最複雜的約束,現階段,Spring HATEOAS僅支持一種超媒體表達格式,同時我們也只需要在應用的配置類上使用
@EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL)這個註解,就可以啓用對超媒體類型的支持,如下所示:
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL)
public class ServletConfigextends WebMvcConfigurerAdapter {
…
}
在啓用了該支持後,服務端輸出的表達格式就會遵循HAL規範。當然,啓用了超媒體支持後,會默認啓用@EnableEntityLinks功能(下面介紹),同時應用還需要相關的定製表達,使HAL的表達更加友好,那麼就要如下操作:
首先,內嵌在_embedded中的內容,是由RelProvider接口實現提供,對應我們的應用而言,只需要在內嵌資源對應的模型中添加Relation註解即可,如下所示:
@Relation(value="book", collectionRelation="books")
public class Book extends BaseModel {
…
}
需要注意的是,當內嵌資源使用Book作爲模型時,單個資源則使用book作爲屬性,而多個資源則使用books作爲屬性。
另外,如果需要添加Curie,那麼需要提供CurieProvider接口實現。這裏我們使用已有的DefaultCurieProvider類並提供Curie的前綴和URI模版,具體如下:
@Bean
public CurieProvider curieProvider() {
return newDefaultCurieProvider("ex",
new UriTemplate("http://localhost:8080/cwteam/books/{rel}"));
}
好了,有了上面的準備工作之後,我們就可以進入HAL主題實現了,具體技術實現如下所示:
1、Maven依賴
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>0.23.RELEASE</version>
</dependency>
建議使用最新版本。
2、基礎模型
public class BaseModel implements Identifiable<Long> {
private Long id;
public Long getId() {
return id;
}
public booleanequals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BaseModel that = (BaseModel) o;
if (id != null ? !id.equals(that.id) : that.id != null) return false;
return true;
}
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
}
該類爲所有資源模型類的父級,實現了資源標誌接口Identifiable<Long> 。
3、書本模型
@Relation(value="book", collectionRelation="books")
public class Book extends BaseModel {
private long id;
private String name;
private String tag;
private double price;
private Timestamp updateTime;
private TimestampcreateTime;
…
}
如果內嵌資源爲單個,則返回鍵屬性爲book,如果內嵌資源爲多個,那麼返回鍵屬性爲books。
4、鏈接信息
爲了把模型對象轉爲滿足HATEOAS約束的資源,那麼就需要添加鏈接信息。在Spring HATEOAS中,org.springframework.hateoas.Link類是用來表示和生成鏈接的,該類也遵循着Atom規範中對應鏈接的定義,包括rel和href兩個屬性。屬性rel表示鏈接關係,href則表示鏈接指向的資源標誌符,一般爲URI。
在創建資源時,需要繼承Spring HATEOAS提供的org.springframwork.hateoas.Resource類,該類提供了簡單的方式創建資源鏈接。如下即爲書本模型類對應的資源類BookResource的實現方式:
public class BookResource extends Resource {
public BookResource(BaseModel list) {
super(list);
Long listId = list.getId();
add(linkTo(getClass()).slash(listId).withRel("items"));
}
}
該類主要的工作是構建資源對象,併爲每個資源創建一個附加非self的資源鏈接。
5、組裝資源
一般我們需要將模型類對象轉換爲對應的資源對象,比如:把Book類對象轉換爲BookResource類對象。一般的做法就是new BookResource(books)方式來轉換。我們也可以(推薦)使用SpringHATEOAS提供的資源組裝器把轉換邏輯封裝起來。該組裝起可以自動創建rel屬性和href鏈接,具體如下:
public classBookResourceAssembler extends ResourceAssemblerSupport<BaseModel,BookResource> {
public BookResourceAssembler(Class<?> sourceClass) {
super(sourceClass,BookResource.class);
}
public BookResource toResource(BaseModel entity) {
BookResourceresource = createResourceWithId(entity.getId(),entity);
return resource;
}
protected BookResource instantiateResource(BaseModel entity) {
return new BookResource(entity);
}
}
創建此類時,需要指定使用資源的控制器(Class<?> sourceClass),以用來確定生成鏈接的地址信息。
ResourceAssemblerSupport 類的默認實現是通過反射來創建資源對象的。toResource 方法用來完成實際的轉換。此處使用了 ResourceAssemblerSupport類的 createResourceWithId 方法來創建一個包含 self 鏈接的資源對象。
BookResourceAssembler 類的 instantiateResource 方法用來根據一個模型類 Book 的對象創建出 BookResource對象。
需要注意的是:
單個資源轉換:
newBookResourceAssembler(getClass()).toResource(entity);
多個資源轉換:
newResources<>(newBookResourceAssembler(getClass()).toResources(entities), link);
6、模型類創建鏈接
上面介紹的是通過 Spring MVC 控制器來創建鏈接,另外一種做法是從模型類中創建。這是因爲控制器通常用來暴露某個模型類。如RestApiAction類直接暴露模型類Book,並提供了訪問 Book 資源集合和單個 Book 資源的接口。對於這樣的情況,並不需要通過控制器來創建相關的鏈接,而可以使用 EntityLinks。首先需要在控制器類中通過“@ExposesResourceFor”註解聲明其所暴露的模型類,如下所示:
@RestController
@RequestMapping("/books")
@ExposesResourceFor(BaseModel.class)
public class RestApiAction extends BaseAction {
…
}
另外在 Spring 應用的配置類中需要通過“@EnableEntityLinks”註解來啓用 EntityLinks 功能,如果上面有啓用了超媒體支持,那麼該註解自動啓用。而此EntityLinks功能依賴spring-plugin-core組建包,maven依賴如下:
<dependency>
<groupId>org.springframework.plugin</groupId>
<artifactId>spring-plugin-core</artifactId>
<version>1.1.0.RELEASE</version>
</dependency>
那麼如何使用EntityLinks?如下所示:
@Autowired
EntityLinks entityLinks;
entityLinks.linkForSingleResource(BaseModel.class, entity);
需要注意的是,爲了 linkForSingleResource 方法可以正常工作,控制器類中需要包含訪問單個資源的方法,而且其“@RequestMapping”是類似“/{id}”這樣的形式。
有了上面的準備之後,我們來看看書本Book的CRUD有何改進:
1、BaseAction類添加了構建單個或多個資源的方法:
// 構建單個資源對象
public BookResource genResultListByCode(BaseModel entity) {
return newBookResourceAssembler(getClass()).toResource(entity);
}
// 構建多個資源對象
public Resources<BookResource>genResultList(List<BaseModel> entities) {
Linklink = linkTo(getClass()).withSelfRel();
return newResources<>(newBookResourceAssembler(getClass()).toResources(entities), link);
}
同時改進了請求頭的返回處理:
@Autowired
EntityLinks entityLinks;
// 返回請求頭的信息
public HttpHeaders genHeaders(BaseModel entity) {
HttpHeadersheaders = new HttpHeaders();
headers.setLocation(entityLinks.linkForSingleResource(BaseModel.class, entity).toUri());
return headers;
}
2、檢索所有書籍
後端:
// 檢索所有書本
@RequestMapping(method=RequestMethod.GET,produces="application/hal+json")
public Resources<BookResource>readBooksByHal() throws Exception {
List<BaseModel>result = bookService.readBooks();
if(null == result || result.size() == 0) {
throw newDataNotFoundException();
}
return genResultList(result);
}
前端:
// 檢索所有書籍
function readBooks(){
$.ajax({
url:'/cwteam/books',
data:null,
type:"get",
dataType:'json',
contentType:'application/hal+json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
需要注意的是ajax請求的contentType必須與服務接口相同,並且必須同時爲application/hal+json時,才能支持HAL格式結果返回。同理,除HTTP請求頭返回的信息外,json/xml格式的數據都需要配置內容類型爲application/hal+json,否則非HAL風格格式。
請求結果:
3、檢索指定書籍
後端:
// 根據書號檢索一本書
@RequestMapping(value="/{id}",method=RequestMethod.GET,produces="application/hal+json")
public BookResource books(@PathVariable long id) throws Exception {
Bookresult = bookService.readBook(id);
if(null == result) {
throw new DataNotFoundException(id,10001);
}
return genResultListByCode(result);
}
前端:
// 根據書號檢索一本書
function readBook(){
$.ajax({
url:'/cwteam/books/1',
data:null,
type:"get",
dataType:'json',
contentType:'application/hal+json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
請求結果:
4、上架一本書
後端:
// 上架一本書
@RequestMapping(method=RequestMethod.POST,produces="application/json")
public ResponseEntity<?> createBook(@RequestBody Book book) throws Exception {
Bookresult = bookService.createBook(book);
if(null == result) {
throw new DataNotFoundException();
}
return newResponseEntity<Object>(genHeaders(result),HttpStatus.CREATED);
}
前端:
// 上架一本書
function createBook(){
var jsonStr = "{\"id\":3,\"name\":\"《Ruby進階實戰教材》\",\"tag\":\"編程語言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books',
data:jsonStr,
type:"post",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
請求結果:
5、更新一本書
後端:
// 更新一本書
@RequestMapping(value="/{id}",method=RequestMethod.PUT,produces="application/json")
public BookResource updateBook(@PathVariable long id,@RequestBody Book book) throws Exception {
Bookresult = bookService.updateBook(book);
if(null == result) {
throw newDataNotFoundException();
}
return genResultListByCode(result);
}
前端:
// 更新一本書
function updateBook(){
var jsonStr = "{\"id\":1,\"name\":\"《Web進階實戰教材》\",\"tag\":\"編程語言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books/1',
data:jsonStr,
type:"put",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
請求結果:
6、下架一本書
後端:
// 下架一本書
@RequestMapping(value="/{id}",method=RequestMethod.DELETE,produces="application/json")
public ResponseEntity<?> deleteBook(@PathVariable long id) throws Exception {
int rows = bookService.deleteBook(id);
if(rows <= 0) {
throw newDataNotFoundException();
}
return newResponseEntity<Object>(HttpStatus.NO_CONTENT);
}
前端:
// 下架一本書
function deleteBook(){
var jsonStr = "{\"id\":3,\"name\":\"《WEB進階實戰教材》\",\"tag\":\"編程語言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books/3',
data:jsonStr,
type:"delete",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
請求結果:
好了,由於作者水平有限,如有不正確或是誤導的地方,請不吝指出討論(技術交流羣:497552060(新))