摘自:《鳳凰架構:構建可靠的大型分散式系統》周志明 著
著者前言
很多人會拿REST與RPC相比較,其實,REST無論是在思想上、在概念上,還是在使用範圍上,與RPC都不盡相同,充其量只能算是有一些相似,應用會有一部分重合之處,但本質上並不是同一型別的東西。
REST與RPC在思想上差異的核心是抽象的目標不一樣,即程序導向的程式設計思想與面向資源的程式設計思想兩者之間的區別。
REST與RPC在概念上的不同是指REST並不是一種遠端服務呼叫協議,甚至可以把定語也去掉,它就不是一種協議。
常有人批評某個系統介面“設計得不夠RESTful”,其實這句話本身就有些爭議,REST只能說是風格而不是規範、協議,並且能完全符合REST所有指導原則的系統也是不多見的。
REST的歷史和定義
REST源於Roy Thomas Fielding在2000年發表的博士論文“Architectural Styles and the Design of Network-based Software Architectures”
筆者比較推薦先理解什麼是HTTP,再配合一些實際例子來對兩者進行類比,以更清楚地瞭解REST,你會發現REST實際上是“HTT”(Hypertext Transfer)的進一步抽象,兩者的關係就如同介面與實現類的關係一般。
REST的定義
REST(Representational State Transfer,表徵狀態轉移)
資源(Resource):譬如你現在正在閱讀一篇名為《REST設計風格》的文章,這篇文章的內容本身(你可以將其理解為蘊含的資訊、資料)稱之為“資源”。無論你是透過閱讀購買的圖書、瀏覽器上的網頁還是列印出來的文稿,無論是在電腦螢幕上閱讀還是在手機上閱讀,儘管呈現的樣子各不相同,但其中的資訊是不變的,你所閱讀的仍是同一份“資源”。
·表徵(Representation):當你透過瀏覽器閱讀此文章時,瀏覽器會向服務端發出“我需要這個資源的HTML格式”的請求,服務端向瀏覽器返回的這個HTML就被稱為“表徵”,你也可以透過其他方式拿到本文的PDF、Markdown、RSS等其他形式的版本,它們同樣是一個資源的多種表徵。可見“表徵”是指資訊與使用者互動時的表示形式,這與我們軟體分層架構中常說的“表示層”(Presentation Layer)的語義其實是一致的。
·狀態(State):當你讀完了這篇文章,想看後面是什麼內容時,你向服務端發出“給我下一篇文章”的請求。但是“下一篇”是個相對概念,必須依賴“當前你正在閱讀的文章是哪一篇”才能正確回應,這類在特定語境中才能產生的上下文資訊被稱為“狀態”。我們所說的有狀態(Stateful)抑或是無狀態(Stateless),都是隻相對於服務端來說的,服務端要完成“取下一篇”的請求,要麼自己記住使用者的狀態,如這個使用者現在閱讀的是哪一篇文章,這稱為有狀態;要麼由客戶端來記住狀態,在請求的時候明確告訴服務端,如我正在閱讀某某文章,現在要讀它的下一篇,這稱為無狀態。
·轉移(Transfer):無論狀態是由服務端還是由客戶端來提供,“取下一篇文章”這個行為邏輯只能由服務端來提供,因為只有服務端擁有該資源及其表徵形式。服務端透過某種方式,把“使用者當前閱讀的文章”轉變成“下一篇文章”,這就被稱為“表徵狀態轉移”。
透過“閱讀文章”這個例子,相信你應該能夠理解“表徵狀態轉移”的含義了。藉著這個故事的上下文狀態,筆者再繼續介紹幾個現在不涉及但稍後要用到的概念。
·統一介面(Uniform Interface):上面說的服務端“透過某種方式”讓表徵狀態轉移,那具體是什麼方式呢?如果你真的是用瀏覽器閱讀本文電子版的話,請把本文滾動到結尾處,右下角有下一篇文章的URI超連結地址,這是服務端渲染這篇文章時就預置好的,點選它讓頁面跳轉到下一篇,就是所謂“某種方式”的其中一種方式。任何人都不會對點選超連結網頁出現跳轉感到奇怪,但你細想一下,URI的含義是統一資源識別符號,是一個名詞,如何能表達出“轉移”動作的含義呢?答案是HTTP協議中已經提前約定好了一套“統一介面”,它包括GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS七種基本操作,任何一個支援HTTP協議的伺服器都會遵守這套規定,對特定的URI採取這些操作,伺服器就會觸發相應的表徵狀態轉移。
·超文字驅動(Hypertext Driven):儘管表徵狀態轉移是由瀏覽器主動向伺服器發出請求所引發的,該請求導致了“在瀏覽器螢幕上顯示出了下一篇文章的內容”的結果。但是,我們都清楚這不可能真的是瀏覽器的主動意圖,瀏覽器是根據使用者輸入的URI地址來找到網站首頁,讀取伺服器給予的首頁超文字內容後,瀏覽器再透過超文字內部的連結來導航到這篇文章,閱讀結束時,也是透過超文字內部的連結再導航到下一篇。瀏覽器作為所有網站的通用的客戶端,任何網站的導航(狀態轉移)行為都不可能是預置於瀏覽器程式碼之中,而是由伺服器發出的請求響應資訊(超文字)來驅動的。這點與其他帶有客戶端的軟體有十分本質的區別,在那些軟體中,業務邏輯往往是預置於程式程式碼之中的,有專門的頁面控制器(無論在服務端還是在客戶端中)來驅動頁面的狀態轉移。
·自描述訊息(Self-Descriptive Message):由於資源的表徵可能存在多種不同形態,在訊息中應當有明確的資訊來告知客戶端該訊息的型別以及應如何處理這條訊息。一種被廣泛採用的自描述方法是在名為“Content-Type”的HTTP Header中標識出網際網路媒體型別(MIME type),譬如“Content-Type:application/json;charset=utf-8”說明該資源會以JSON的格式來返回,請使用UTF-8字符集進行處理。
REST的原則
Fielding認為,一套理想的、完全滿足REST風格的系統應該滿足以下六大原則。
1.客戶端與服務端分離(Client-Server)
2.無狀態(Stateless)
3.可快取(Cacheability)
4.分層系統(Layered System)
5.統一介面(Uniform Interface)
下面以一個例子來說明:譬如,對於幾乎每個系統都有的登入和登出功能,如果你理解成登入對應於login()服務,登出對應於logout()服務這樣兩個獨立服務,這是“符合人類思維”的;如果你理解成登入是PUT Session,登出是DELETE Session,這樣你只需要設計一種“Session資源”即可滿足需求,甚至以後對Session的其他需求,如查詢登入使用者的資訊,就是GET Session而已,其他操作如修改使用者資訊等也都可以被這同一套設計囊括在內,這便是“抽象程度更高”帶來的好處。
6.按需程式碼(Code-On-Demand)
RMM
RESTful Web APIs和RESTful Web Services的作者Leonard Richardson曾提出一個衡量“服務有多麼REST”的Richardson成熟度模型(Richardson Maturity Model,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 Application State,HATEOAS”,其實都是指同一件事情。
demo案例
醫生預約系統
作為一名病人,我想要從系統中得知指定日期內我熟悉的醫生是否具有空閒時間,以便於我向該醫生預約就診。
第0級醫院開放了一個/appointmentService的Web API,傳入日期、醫生姓名等引數,可以得到該時間段內該名醫生的空閒時間,該API的一次HTTP呼叫如下所示:
1 POST /appointmentService?action=query HTTP/1.1 2 3 {date: "2020-03-04", doctor: "mjones"}
然後伺服器會傳回一個包含了所需資訊的回應:
1 HTTP/1.1 200 OK 2 3 [ 4 {start:"14:00", end: "14:50", doctor: "mjones"}, 5 {start:"16:00", end: "16:50", doctor: "mjones"} 6 ]
得到了醫生空閒的結果後,筆者覺得14:00比較合適,於是進行預約確認,並提交了個人基本資訊:
1 POST /appointmentService?action=comfirm HTTP/1.1 2 3 { 4 appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"}, 5 patient: {name: icyfenix, age: 30, ……} 6 }
如果預約成功,那我能夠收到一個預約成功的響應:
1 HTTP/1.1 200 OK 2 3 { 4 code: 0, 5 message: "Successful confirmation of appointment" 6 }
如果出現問題,譬如有人在我前面搶先預約了,那麼我會在響應中收到某種錯誤訊息:
1 HTTP/1.1 200 OK 2 3 { 4 code: 1 5 message: "doctor not available" 6 }
至此,整個預約服務宣告完成,直接明瞭,我們採用的是非常直觀的基於RPC風格的服務設計,似乎很容易就解決了所有問題,但真的是這樣嗎?
第1級
第0級是RPC的風格,如果需求永遠不會變化,那它完全可以良好地工作下去。但是,如果你不想為預約醫生之外的其他操作、為獲取空閒時間之外的其他資訊去編寫額外的方法,或者改動現有方法的介面,那還是應該考慮一下如何使用REST來抽象資源。
通往REST的第一步是引入資源的概念,在API中的基本體現是圍繞資源而不是過程來設計服務,說得直白一點,可以理解為服務的Endpoint應該是一個名詞而不是動詞。此外,每次請求中都應包含資源的ID,所有操作均透過資源ID來進行,譬如,獲取醫生指定時間的空閒檔期:1 POST /doctors/mjones HTTP/1.1 2 3 {date: "2020-03-04"}
然後伺服器傳回一組包含了ID資訊的檔期清單,注意,ID是資源的唯一編號,有ID即代表“醫生的檔期”被視為一種資源:
1 HTTP/1.1 200 OK 2 3 [ 4 {id: 1234, start:"14:00", end: "14:50", doctor: "mjones"}, 5 {id: 5678, start:"16:00", end: "16:50", doctor: "mjones"} 6 ]
筆者還是覺得14:00的時間比較合適,於是又進行預約確認,並提交了個人基本資訊:
1 POST /schedules/1234 HTTP/1.1 2 3 {name: icyfenix, age: 30, ……}
後面預約成功或者失敗的響應訊息在這個級別裡面與之前一致,就不重複了。
比起第0級,第1級的特徵是引入了資源,透過資源ID作為主要線索與服務互動,但第1級至少還有三個問題沒有解決:
一是隻處理了查詢和預約,如果臨時想換個時間,要調整預約,或者病忽然好了,想刪除預約,這都需要提供新的服務介面;
二是處理結果響應時,只能依靠結果中的code、message這些欄位做分支判斷,每一套服務都要設計可能發生錯誤的code,這很難考慮全面,而且也不利於對某些通用的錯誤做統一處理;
三是沒有考慮認證授權等安全方面的內容,譬如要求只有登入使用者才允許查詢醫生檔期時間,某些醫生可能只對VIP開放,需要特定級別的病人才能預約,等等。
第2級
第1級遺留的三個問題都可以透過引入統一介面來解決。HTTP協議的七個標準方法是經過精心設計的,只要架構師的抽象能力夠用,它們幾乎能涵蓋資源可能遇到的所有操作場景。
REST的具體做法是:把不同業務需求抽象為對資源的增加、修改、刪除等操作來解決第一個問題;
使用HTTP協議的Status Code,它可以涵蓋大多數資源操作可能出現的異常,也可以自定義擴充套件,以此解決第二個問題;
依靠HTTP Header中攜帶的額外認證、授權資訊來解決第三個問題,這個在實戰中並沒有體現,後文會在5.3節中介紹相關內容。
按這個思路,獲取醫生檔期,應採用具有查詢語義的GET操作進行:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1
然後伺服器會傳回一個包含了所需資訊的回應:
HTTP/1.1 200 OK [ {id: 1234, start:"14:00", end: "14:50", doctor: "mjones"}, {id: 5678, start:"16:00", end: "16:50", doctor: "mjones"} ]
筆者仍然覺得14:00的時間比較合適,於是進行預約確認,並提交了個人基本資訊,用以建立預約,這是符合POST的語義的:
POST /schedules/1234 HTTP/1.1 {name: icyfenix, 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 { schedules:[ { id: 1234, start:"14:00", end: "14:50", doctor: "mjones", links: [ {rel: "comfirm schedule", href: "/schedules/1234"} ] }, { id: 5678, start:"16:00", end: "16:50", doctor: "mjones", links: [ {rel: "comfirm schedule", href: "/schedules/5678"} ] } ], links: [ {rel: "doctor info", href: "/doctors/mjones/info"} ] }
如果做到了第3級REST,那服務端的API和客戶端也是完全解耦的,此時如果你要調整服務數量,或者對同一個服務做API升級時將會變得非常簡單。
好處、不足與爭議
好處
REST提出以資源為主體的服務設計風格,可以帶來不少好處。
- 降低服務介面的學習成本。
- 資源天然具有集合與層次結構。
- REST繫結於HTTP協議。
不足與爭議
1)面向資源的程式設計思想只適合做CRUD,程序導向、物件導向程式設計才能處理真正複雜的業務邏輯。
筆者再重複一遍,面向資源的程式設計思想與另外兩種主流程式設計思想只是抽象問題時所處的立場不同,只有選擇不同,沒有高下之分。
2)REST與HTTP完全繫結,不適合應用於要求高效能傳輸的場景中。
3)REST不利於事務支援。
4)REST沒有傳輸可靠性支援。
5)REST缺乏對資源進行“部分”和“批次”處理的能力。
譬如你準備給某個使用者的名字增加一個“VIP”字首,提交一個PUT請求修改這個使用者的名稱即可,而你要給1000個使用者加VIP字首時,如果真的去呼叫1000次PUT,瀏覽器會回應HTTP/1.1 429 Too Many Requests。此時,你就不得不先建立一個任務資源(如名為“VIP-Modify-Task”),把1000個使用者的ID交給這個任務,然後驅動任務進入執行狀態。
又譬如你去網店買東西,下單、凍結庫存、支付、加積分、扣減庫存這一系列步驟會涉及多個資源的變化,你可能面臨不得不建立一種“事務”的抽象資源,或者用某種具體的資源(譬如“結算單”)貫穿這個過程的始終,每次操作其他資源時都帶著事務或者結算單的ID。HTTP協議由於本身的無狀態性,會相對不適合(並非不能夠)處理這類業務場景。