REST設計風格:你寫的 RESTful API 到第幾層了?


理解REST

在理解其真正概念前,我們先來明確: REST它的核心思想是面向資源的抽象(相對於RPC就是面向過程抽象),它是一種設計風格的指導,而非具有較強約束的協議。

REST源於Roy Thomas Fielding在2000年發表的博士論文“Architectural Stylesand the Design of Network-based Software Architectures”[1]提出的一種編程思想,並為這種程序設計風格取了一個很多人難以理解,但是今天已經廣為人知的名字——REST(Representational State Transfer,表征狀態轉移)。

如果拆分成獨立單詞RepresentationalStateTransfer,我們知道它們分別是表征狀態轉移的意思。但是合在一起,好像又不明白它想要表達的意思了。我們不妨先去理解什么是HTTP(畢竟REST是建立在HTTP之上的),你會發現REST實際上是“HTT”(Hypertext Transfer)的進一步抽象,兩者的關系就如同接口與實現類的關系一般。REST是對資源的抽象,何為資源?

資源(Resource):譬如你現在正在閱讀一篇名為《REST設計》的文章,這篇文章的內容本身(你可以將其理解為蘊含的信息、數據)稱之為“資源”。無論你是通過閱讀購買的圖書、瀏覽器上的網頁還是打印出來的文稿,無論是在電腦屏幕上閱讀還是在手機上閱讀,盡管呈現的樣子各不相同,但其中的信息是不變的,你所閱讀的仍是同一份“資源”。

然后我們以此文章為資源,來看看表征狀態轉移在閱讀文章過程的中含義:

表征(Representation):當你通過瀏覽器閱讀此文章時,瀏覽器會向服務端發出“我需要這個資源的HTML格式”的請求,服務端向瀏覽器返回的這個HTML就被稱為“表征”,你也可以通過其他方式拿到本文的PDF、Markdown、RSS等其他形式的版本,它們同樣是一個資源的多種表征。可見“表征”是指信息與用戶交互時的表示形式,這與我們軟件分層架構中常說的“表示層”(PresentationLayer)的語義其實是一致的。

狀態(State):當你讀完了這篇文章,想看后面是什么內容時,你向服務端發出“給我下一篇文章”的請求。但是“下一篇”是個相對概念,必須依賴“當前你正在閱讀的文章是哪一篇”才能正確回應,這類在特定語境中才能產生的上下文信息被稱為“狀態”。我們所說的有狀態(Stateful)抑或是無狀態(Stateless),都是只相對於服務端來說的,服務端要完成“取下一篇”的請求,要么自己記住用戶的狀態,如這個用戶現在閱讀的是哪一篇文章,這稱為有狀態;要么由客戶端來記住狀態,在請求的時候明確告訴服務端,如我正在閱讀某某文章,現在要讀它的下一篇,這稱為無狀態。

轉移(Transfer):無論狀態是由服務端還是由客戶端來提供,“取下一篇文章”這個行為邏輯只能由服務端來提供,因為只有服務端擁有該資源及其表征形式。服務端通過某種方式,把“用戶當前閱讀的文章”轉變成“下一篇文章”,這就被稱為“表征狀態轉移”。

RESTful系統的六大設計原則

1.客戶端與服務端分離(Client-Server)

將用戶界面所關注的邏輯和數據存儲所關注的邏輯分離開來,有助於提高用戶界面的跨平台的可移植性。相較以往的完全基於服務端控制和渲染(如JSP這類)的模式已甚少,一方面代碼倉庫的便捷性和易管理性成為了敏捷開發的障礙,另一方面得益於前端技術(從ES規范,到語言實現,再到前端框架等)在近年來的高速發展,造就了現如今的**前后端分離*模式:后端控制數據,前端控制渲染。

2.無狀態(Stateless)

REST希望服務端不用負責維護狀態,每一次從客戶端發送的請求中,應包括所有必要的上下文信息,會話信息也由客戶端負責保存維護,服務端只依據客戶端傳遞的狀態來執行業務處理邏輯,驅動整個應用的狀態變遷。

但現實是骨感的,大型系統的上下文狀態數量完全可能膨脹到客戶端無法承受的程度,在服務端的內存、會話、數據庫或者緩存等地方持有一定的狀態成為一種事實上存在,並將長期存在、被廣泛使用的主流方案。

3.可緩存(Cacheability)

無狀態服務雖然提升了系統的可見性、可靠性和可伸縮性,但降低了系統的網絡性。“降低網絡性”的通俗解釋是某個功能使用有狀態的設計時只需要一次(或少量)請求就能完成,使用無狀態的設計時則可能會需要多次請求,或者在請求中帶有額外冗余的信息。為了緩解這個矛盾,REST希望軟件系統能夠如同萬維網一樣,允許客戶端和中間的通信傳遞者(譬如代理)將部分服務端的應答緩存起來。當然,為了緩存能夠正確地運作,服務端的應答中必須直接或者間接地表明本身是否可以進行緩存、可以緩存多長時間,以避免客戶端在將來進行請求的時候得到過時的數據。運作良好的緩存機制可以減少客戶端、服務端之間的交互,甚至有些場景中可以完全避免交互,這就進一步提高了性能。

4.分層系統(Layered System)

這里所指的分層並不是表示層、服務層、持久層這種意義上的分層,而是指客戶端一般不需要知道是否直接連接到了最終的服務器,抑或連接到路徑上的中間服務器。中間服務器可以通過負載均衡和共享緩存的機制提高系統的可擴展性,這樣也便於緩存、伸縮和安全策略的部署。該原則的典型應用是內容分發網絡(ContentDistribution Network,CDN)。如果你是通過網站瀏覽到這篇文章的話,你所發出的請求一般(假設你在中國境內的話)並不是直接訪問位於GitHub Pages的源服務器,而是訪問了位於國內的CDN服務器,但作為用戶,你完全不需要感知到這一點。

5.統一接口(Uniform Interface)

這是REST的另一條核心原則,REST希望開發者面向資源編程,希望軟件系統設計的重點放在抽象系統該有哪些資源,而不是抽象系統該有哪些行為(服務)上。這條原則你可以類比計算機中對文件管理的操作來理解,管理文件可能會涉及創建、修改、刪除、移動等操作,這些操作數量是可數的,而且對所有文件都是固定、統一的。如果面向資源來設計系統,同樣會具有類似的操作特征,由於REST並沒有設計新的協議,所以這些操作都借用了HTTP協議中固有的操作命令來完成。

統一接口也是REST最容易陷入爭論的地方,基於網絡的軟件系統,到底是面向資源合適,還是面向服務更合適,這個問題恐怕在很長時間里都不會有定論,也許永遠都沒有。但是,已經有一個基本清晰的結論是:面向資源編程的抽象程度通常更高。抽象程度高帶來的壞處是距離人類的思維方式往往會更遠,而好處是通用程度往往會更好。用這樣的語言去詮釋REST,還是有些抽象,下面以一個例子來說明:譬如,對於幾乎每個系統都有的登錄和注銷功能,如果你理解成登錄對應於login()服務,注銷對應於logout()服務這樣兩個獨立服務,這是“符合人類思維”的;如果你理解成登錄是PUT Session,注銷是DELETE Session,這樣你只需要設計一種“Session資源”即可滿足需求,甚至以后對Session的其他需求,如查詢登錄用戶的信息,就是GET Session而已,其他操作如修改用戶信息等也都可以被這同一套設計囊括在內,這便是“抽象程度更高”帶來的好處。

如果想要在架構設計中合理恰當地利用統一接口,Fielding建議系統應能做到每次請求中都包含資源的ID,所有操作均通過資源ID來進行;建議每個資源都應該是自描述的消息;建議通過超文本來驅動應用狀態的轉移

6.按需代碼(Code-On-Demand)

按需代碼被Fielding列為一條可選原則。它是指任何按照客戶端(譬如瀏覽器)的請求,將可執行的軟件程序從服務端發送到客戶端的技術。按需代碼賦予了客戶端無須事先知道所有來自服務端的信息應該如何處理、如何運行的寬容度。舉個具體例子,以前的Java Applet技術,今天的WebAssembly等都屬於典型的按需代碼,蘊含着具體執行邏輯的代碼是存放在服務端,只有當客戶端請求了某個JavaApplet之后,代碼才會被傳輸並在客戶端機器中運行,結束后通常也會隨即在客戶端中被銷毀。將按需代碼列為可選原則的原因並非是它特別難以達到,更多是出於必要性和性價比的實際考慮。

RMM (Richardson MaturityModel)

RESTful Web APIs和RESTful Web Services的作者Leonard Richardson曾提出一個衡量“服務有多么REST”的Richardson成熟度模型(Richardson MaturityModel,RMM),以便讓那些原本不使用REST的系統,能夠逐步地導入REST。Richardson將服務接口“REST的程度”從低到高,分為0至3級。

  • 第0級(The Swamp of Plain Old XML):完全不REST。
  • 第1級(Resources):開始引入資源的概念。
  • 第2級(HTTP Verbs):引入統一接口,映射到HTTP協議的方法上。
  • 第3級(Hypermedia Controls):超媒體控制,在本文里面的說法是“超文本驅動”,在Fielding論文里的說法是“Hypertext As The Engine Of ApplicationState,HATEOAS”,其實都是指同一件事情。

下面借用Martin Fowler撰寫的關於RMM的文章中的實際例子(原文是XML寫的,這里簡化為JSON表示),來具體展示一下四種不同程度的REST反映到實際接口中會是怎樣的。假設你是那名程序員,你會怎么設計:

醫生預約系統

作為一名病人,我想要從系統中得知指定日期內我熟悉的醫生是否具有空閑時間,以便於我向該醫生預約就診。
請設計兩個RESTful接口:一個查詢空閑時間接口,一個預約就診接口。

第0級

醫院開放了一個/appointmentService的Web API,傳入日期、醫生姓名等參數,可以得到該時間段內該名醫生的空閑時間,該API的一次HTTP調用如下所示:

POST /appointmentService?query HTTP/1.1

{"data": "2020-03-04", "doctor": "mjones"}

然后服務器會傳回一個包含了所需信息的回應:

HTTP/1.1 200 OK

[
    {"start":"14:00", "end":"14:50", "doctor": "mjones"},
    {"start":"16:00", "end":"16:50", "doctor": "mjones"}
]

得到了醫生空閑的結果后,筆者覺得14:00比較合適,於是進行預約確認,並提交了個人基本信息:

POST /appointmentService?action=confirm HTTP/1.1

{
    "appointment": {"date": "2020-03-04", "start":"14:00", "end":"14:50", "doctor": "mjones"},
    "patient": {"name": "zio", "age": 30, ...}
}

如果預約成功,那我能夠收到一個預約成功的響應:

HTTP/1.1 200 OK

{
    "code": 0,
    "message": "Successful confirmation of appiontment"
}

如果出現問題,譬如有人在我前面搶先預約了,那么我會在響應中收到某種錯誤消息:

HTTP/1.1 200 OK

{
    "code": 1,
    "message": "doctor not available"
}

至此,整個預約服務宣告完成,直接明了,我們采用的是非常直觀的基於RPC風格的服務設計

第1級

第0級是RPC的風格,如果需求永遠不會變化,那它完全可以良好地工作下去。但是,如果你不想為預約醫生之外的其他操作、為獲取空閑時間之外的其他信息去編寫額外的方法,或者改動現有方法的接口,那還是應該考慮一下如何使用REST來抽象資源。

通往REST的第一步是引入資源的概念,在API中的基本體現是圍繞資源而不是過程來設計服務,說得直白一點,可以理解為服務的Endpoint應該是一個名詞而不是動詞。此外,每次請求中都應包含資源的ID,所有操作均通過資源ID來進行,譬如,獲取醫生指定時間的空閑檔期:

GET /doctors/mjones?date="2020-03-04" HTTP/1.1

然后服務器傳回一組包含了ID信息的檔期清單,注意,ID是資源的唯一編號,有ID即代表“醫生的檔期”被視為一種資源:

HTTP/1.1 200 OK

[
    {"id": 1, "start":"14:00", "end":"14:50", "doctor": "mjones"},
    {"id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones"}
]

筆者還是覺得14:00的時間比較合適,於是又進行預約確認,並提交了個人基本信息:

POST /schedules/1 HTTP/1.1

{"name": "zio", "age":30, ...}

后面預約成功或者失敗的響應消息在這個級別里面與之前一致,就不重復了。比起第0級,第1級的特征是引入了資源,通過資源ID作為主要線索與服務交互,但第1級至少還有三個問題沒有解決:一是只處理了查詢和預約,如果臨時想換個時間,要調整預約,或者病忽然好了,想刪除預約,這都需要提供新的服務接口;二是處理結果響應時,只能依靠結果中的code、message這些字段做分支判斷,每一套服務都要設計可能發生錯誤的code,這很難考慮全面,而且也不利於對某些通用的錯誤做統一處理;三是沒有考慮認證授權等安全方面的內容,譬如要求只有登錄用戶才允許查詢醫生檔期時間,某些醫生可能只對VIP開放,需要特定級別的病人才能預約,等等。

第二級

第1級遺留的三個問題都可以通過引入統一接口來解決。HTTP協議的七個標准方法是經過精心設計的,只要架構師的抽象能力夠用,它們幾乎能涵蓋資源可能遇到的所有操作場景。REST的具體做法是:把不同業務需求抽象為對資源的增加、修改、刪除等操作來解決第一個問題;使用HTTP協議的Status Code,它可以涵蓋大多數資源操作可能出現的異常,也可以自定義擴展,以此解決第二個問題;依靠HTTP Header中攜帶的額外認證、授權信息來解決第三個問題,這個在實戰中並沒有體現。

按這個思路,獲取醫生檔期,應采用具有查詢語義的GET操作進行:

GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

然后服務器會傳回一個包含了所需信息的回應:

HTTP/1.1 200 OK

[
    {"id": 1, "start":"14:00", "end":"14:50", "doctor": "mjones"},
    {"id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones"}
]

筆者仍然覺得14:00的時間比較合適,於是進行預約確認,並提交了個人基本信息,用以創建預約,這是符合POST的語義的:

POST /schedules/1 HTTP/1.1

{"name": "zio", "age":30, ...}

如果預約成功,那筆者能夠收到一個預約成功的響應:

HTTP/1.1 201 Created

Successful confirmation of appointment

[插圖]如果出現問題,譬如有人搶先預約了,那么筆者會在響應中收到某種錯誤消息:

HTTP/1.1 409 Conflict

doctor not available

第3級

第2級是目前絕大多數系統所到達的REST級別,但仍不是完美的,至少還存在一個問題:你是如何知道預約mjones醫生的檔期是需要訪問“/schedules/1234”這個服務Endpoint的?也許你第一時間甚至無法理解為何我會有這樣的疑問,這當然是程序代碼寫的呀!但REST並不認同這種已烙在程序員腦海中許久的想法。RMM中的超文本控制、Fielding論文中的HATEOAS和現在提的比較多的“超文本驅動”,所希望的是除了第一個請求是由你在瀏覽器地址欄輸入驅動之外,其他的請求都應該能夠自己描述清楚后續可能發生的狀態轉移,由超文本自身來驅動。所以,當你輸入了查詢的指令之后:

GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

服務器傳回的響應信息應該包括諸如如何預約檔期、如何了解醫生信息等可能的后續操作:

HTTP/1.1 200 OK

[
    {
        "id": 1, "start":"14:00", "end":"14:50", "doctor": "mjones",
        "links": [
            {"rel": "confirm schedule", "href": "/schedule/1"}
        ]
    },
    {
        "id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones",
        "links": [
            {"rel": "confirm schedule", "href": "/schedule/2"}
        ]
    }
]

如果做到了第3級REST,那服務端的API和客戶端也是完全解耦的,此時如果你要調整服務數量,或者對同一個服務做API升級時將會變得非常簡單。

對於第3級需要明確:如果客戶端指的是移動端這類發布升級成本較高的場景,這樣的設計確實友誼頗高;但如果是客戶端是web前端,它們的發布成本和服務端無差,那么可以case by case的去看待,是否要把這類“href”信息維護在服務端,這樣做是否有悖於“前后端分離”的思想。

推薦閱讀

《鳳凰架構·構建可靠的大型分布式系統》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM