聊聊 Spring HATEOAS


在〈簡介 RestTemplate〉中,為了介紹怎麼使用 RestTemplate,設置了一個簡單的 REST 網站,這些網站透過 HTTP 動詞,結合 URI,使用 JSON 作為交換媒介,JSON 的格式非常單純,例如請求 http://localhost:8080/messages/1 的話會傳回:

{
    "text": "msg1"
}

如果請求 http://localhost:8080/messages 的話會傳回:

[
    {
        "text": "msg1"
    },
    {
        "text": "msg2"
    }
]

容易理解又單純的格式,除了資料本身的資訊之外,也沒別的了,也正因為沒別的資訊,你得在文件上記錄有哪些端點、互動的方式、API 之間的關係等,API 消費者若依文件來編寫了服務的請求等,哪天 API 變動,消費者也得跟著作出因應的修改,API 消費者與提供者之間有著緊密的關係。

還記得在〈@RepositoryRestResource〉中看到的 JSON 回應嗎?你怎麼知道有哪些 API 可以使用呢?因為 JSON 回應本身就包含了鏈結、參數等 API 相關的資訊,有了這些資訊,我們可以從這個 API 探尋到另一個 API,從而理解到怎麼使用網站上提供的服務。

而且,API 消費者可以編寫程式,自動導覽至相關 API,若 API 供應者變動了服務介面,由於 JSON 回應有著一致的格式,API 消費者可以自動因應變化。

那麼〈@RepositoryRestResource〉中的 JSON 回應有什麼規範嗎?若你察看回應標頭,會發現它的內容類型為 application/hal+json,而不是 application/json,當然,application/hal+json 還是一種 application/json,只不過在 JSON 加上了 HAL - Hypertext Application Language 約束,也就是不再是隨意地 JSON 結構了。

Spring Data REST 實際上是透過 Spring HATEOAS,自動建立對應於 Repository 的相關 API…嗯?又多了一個名詞…HATEOAS?

這就得來聊聊 Leonard Richardson 在 QCon Talk 談到的 REST 成熟度模型了,這是個用來檢視、思考 REST 應用程度與方向的模型,它將 REST 的應用成熟度分為四個階段。

LEVEL 0 使用一個 URI 與一個 HTTP 方法,基本上就是單純使用 HTTP 作為傳輸協定,服務使用的 URI 只是個接收請求進行回應的端點,HTTP 方法只是用來發起請求,至於請求的相關細節,例如想進行的動作、必須提供的資料等,全部包含在發送過去的文件之中,像是 XML、JSON 等其他(自訂)格式,回應使用某個文件格式傳回,當中包含了請求操作後的結果。

你可以想像只使用一個 /messages,它接受 POST 請求,想要查詢某個訊息、全部訊息、刪除訊息、修改訊息等,都在 POST 本體中使用某個格式的文件指定。

簡單來說,這個階段的應用就想像成是個連線程式,有指定的連線位置,傳送指定格式的封包,SOAP、XML-RPC 等是屬於這個階段的代表。

Level 1 使用多個 URI 與一個 HTTP 方法,URI 代表了資源,像是 /show_message/create_message/update_message/delete_message 都是資源,HTTP 方法只是用來發起請求,至於請求的細節由請求本體來提供,例如,在請求 /show_message 這項資源時,若包含 all 請求參數,表示顯示全部的訊息,若是 "id=1" 這類請求參數,表示顯示指定的訊息。

Level 2 使用多個 URI、多個 HTTP 方法,並善用 HTTP 回應狀態碼,URI 用來代表資源,像是 /messages/messages/1,HTTP 方法用來表示想進行的操作,例如 GET /messages 表示取得全部訊息,GET /messages/1 表示取得指定訊息,POST /messages 表示新增訊息、DELETE /messages/1 表示刪除指定訊息等,〈簡介 RestTemplate〉的簡單應用程式就是此類。

Level 3 更進一步地,支援 HATEOAS(Hypermedia As The Engine Of Application State)的概念,就類似 HTML 頁面鏈結,你可以從這個頁面得知可通往哪些頁面,在 REST 的 Level 3 模型中,客戶端可以從某個資源,知道還有哪些其他相關的資源,以及如何對它進行操作,〈@RepositoryRestResource〉的範例專案,就是這一類。

HATEOAS 是個概念,實際上如何從一個資源得知其他的資源,該採用哪個格式,格式中該採用哪些識別名稱,需要有實作規範,HAL 就是實作規範之一,採用 JSON 格式、ATOM (RFC 4287) 鏈結語法等,也就是你在〈@RepositoryRestResource〉看到的格式。

Martin Fowler 在〈Richardson Maturity Model〉中,也有對 REST 成熟度模型作了詮譯,有興趣可以進一步閱讀。

如果你想要實作出可以支援 HATEOAS 概念的 REST 服務,可以使用 Spring HATEOAS,例如,要將〈簡介 RestTemplate〉中的專案,實作為支援 HATEOAS 概念,可以在 build.gradle 中加入:

implementation('org.springframework.boot:spring-boot-starter-hateoas')

想讓 Message 能轉換為 HAL 格式的方式之一,是繼承 ResourceSupport,如此 Message 會有個可以加入 Link 實例的 add 方法可以使用,另一個方式是建構 Resource 時指定 Message 以及 Link 實例。

建立 Link 實例時,可以直接指定鏈結:

Link link = new Link("http://localhost:8080/messages/1");

因此,若 message 參考了 Message 實例,可以令控制器的處理方法傳回:

new Resource<Message>(message, new Link("http://localhost:8080/messages/1"));

不過,更有彈性的方式之一,可以在控制器標註 @RequestMapping 並指明根對應,然後透過 ControllerLinkBuilder 來建構 Link,它有個 linkTo 方法,可以自省控制器類別找出資源的 URI 根對應,進一步地,還可以建構帶有 self 等資訊的 Link 實例,例如:

new Resource<Message>(
    message, 
    linkTo(RestTmplApplication.class).slash("1").withSelfRel()
);

如果處理器傳回了以上實例,那麼最後的得到 JSON 格式會是:

{
    "text": "msg1",
    "_links": {
        "self": {
            "href": "http://localhost:8080/messages/1"
        }
    }
}

內容類型回應標頭也會自動變成 application/hal+json,因此,可以將 RestTmplApplication 重構為:

package cc.openhome;

...略

@SpringBootApplication
@RestController
@RequestMapping("messages")
public class RestTmplApplication {
    public static void main(String[] args) {
        SpringApplication.run(RestTmplApplication.class, args);
    }

    List<Message> messages = new ArrayList<Message>() {{
        add(new Message("msg1"));
        add(new Message("msg2"));
    }};

    @GetMapping("/")
    public Resources<Resource<Message>> index() {
        List<Resource<Message>> reslt = 
                IntStream.range(0, messages.size())
                         .mapToObj(idx -> new Resource<>(messages.get(idx), link(String.valueOf(idx + 1))))
                         .collect(toList());

        return new Resources<>(reslt, linkTo(RestTmplApplication.class).withSelfRel());
    }

    @GetMapping("/{id}")
    public Resource<Message> show(@PathVariable("id") String id) {
        return new Resource<>(messages.get(Integer.parseInt(id) - 1), link(id));
    }

    @PostMapping("/")
    public Resource<Message> create(@RequestBody Message message) {
        messages.add(message);
        return new Resource<>(message);
    }

    @DeleteMapping("/{id}")
    public Resource<Message> delete(@PathVariable("id") String id) {
        return new Resource<>(messages.remove(Integer.parseInt(id) - 1));
    }

    private Link link(String id) {
        return linkTo(RestTmplApplication.class).slash(id).withSelfRel();
    }
}

在這邊留意到 Resources,這可以用來包含多個 MessageLink 實例,傳回的 JSON 會像是:

{
    "_embedded": {
        "messageList": [
            {
                "text": "msg1",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/messages/1"
                    }
                }
            },
            {
                "text": "msg2",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/messages/2"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost:8080/messages"
        }
    }
}

當然,傳回的 JSON 變得複雜多了,若想用 RestTemplate 來消費這個 JSON,會需要 HAL 轉換器,這就在下篇文件來談了。