朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)

powerzhuye發表於2019-02-15

Web API設計其實是一個挺重要的設計話題,許多公司都會有公司層面的Web API設計規範,幾乎所有的專案在詳細設計階段都會進行API設計,專案開發後都會有一份API文件供測試和聯調。本文嘗試根據自己的理解總結一下目前常見的四種API設計風格以及設計考慮點。

RPC

這是最常見的方式,RPC說的是本地呼叫遠端的方法,面向的是過程。

  • RPC形式的API組織形態是類和方法,或者說領域和行為。
  • 因此API的命名往往是一個動詞,比如GetUserInfo,CreateUser
  • 因為URI會非常多而且往往沒有一些約定規範,所以需要有詳細的文件。
  • 也是因為無拘無束,HTTP方法基本只用GET和POST,設計起來比較簡單。

這裡就不貼例子了,估計超過50%的API是這種分格的。

REST

是一種架構風格,有四個級別的成熟度:

  • 級別 0:定義一個 URI,所有操作是對此 URI 發出的 POST 請求。
  • 級別 1:為各個資源單獨建立 URI。
  • 級別 2:使用 HTTP 方法來定義對資源執行的操作。
  • 級別 3:使用超媒體(HATEOAS)。

級別0其實就是類RPC的風格,級別3是真正的REST,大多數號稱REST的API在級別2。REST實現一些要點包括:

  • REST形式的API組織形態是資源和實體,一切圍繞資源(級別1的要點)。設計流程包括:
    • 確定API提供的資源
    • 確定資源之間的關係
    • 根據資源型別和關係確定資源URI結構
    • 確定資源的結構體
  • 會定義一些標準方法(級別2的要點),然後把標準方法對映到實現(比如HTTP Method):
    朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
    • GET:獲取資源詳情或資源列表。對於collection型別的URI(比如**/customers**)就是獲取資源列表,對於item型別的URI(比如**/customers/1**)就是獲取一個資源。
    • POST:建立資源,請求體是新資源的內容。往往POST是用於為集合新增資源。
    • PUT:建立或修改資源,請求體是新資源的內容。往往PUT用於單個資源的新增或修改。實現上必須冪等。
    • PATCH:部分修改資源,請求體是修改的那部分內容。PUT一般要求提交整個資源進行修改,而PATCH用於修改部分內容(比如某個屬性)。
    • DELETE:移除資源。和GET一樣,對於collection型別的URI(比如**/customers**)就是刪除所有資源,對於item型別的URI(比如**/customers/1**)就是刪除一個資源。
  • 需要考慮資源之間的導航(級別3的要點,比如使用HATEOAS,HATEOAS是Hypertext as the Engine of Application State的縮寫)。有了資源導航,客戶端甚至可能不需要參閱文件就可以找到更多對自己有用的資源,不過HATEOAS沒有固定的標準,比如:
{
    "content": [ {
        "price": 499.00,
        "description": "Apple tablet device",
        "name": "iPad",
        "links": [ {
            "rel": "self",
            "href": "http://localhost:8080/product/1"
        } ],
        "attributes": {
            "connector": "socket"
        }
    }, {
        "price": 49.00,
        "description": "Dock for iPhone/iPad",
        "name": "Dock",
        "links": [ {
            "rel": "self",
            "href": "http://localhost:8080/product/3"
        } ],
        "attributes": {
            "connector": "plug"
        }
    } ],
    "links": [ {
        "rel": "product.search",
        "href": "http://localhost:8080/product/search"
    } ]
}   
複製程式碼

Spring框架也提供了相應的支援:spring.io/projects/sp…

@RestController
public class GreetingController {

    private static final String TEMPLATE = "Hello, %s!";

    @RequestMapping("/greeting")
    public HttpEntity<Greeting> greeting(
            @RequestParam(value = "name", required = false, defaultValue = "World") String name) {

        Greeting greeting = new Greeting(String.format(TEMPLATE, name));
        greeting.add(linkTo(methodOn(GreetingController.class).greeting(name)).withSelfRel());

        return new ResponseEntity<>(greeting, HttpStatus.OK);
    }
}
複製程式碼

產生如下的結果:

朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)

  • 除了之前提到的幾個要點,REST API的設計還有一些小點:
    • 必須無狀態的,相互獨立的,不區分順序的
    • API需要有一致的介面來解耦客戶端和服務實現,如果基於HTTP那麼務必使用HTTP的Method來操作資源,而且儘量使用HTTP響應碼來處理錯誤
    • 需要儘量考慮快取、版本控制、內容協商、部分響應等實現

可以說REST的API設計是需要設計感的,需要仔細來思考API的資源,資源之間的關係和導航,URI的定義等等。對於一套設計精良的REST API,其實客戶端只要知道可用資源清單,往往就可以輕易根據約定俗成的規範以及導航探索出大部分API。比較諷刺的是,有很多網站給前端和客戶端的介面是REST的,爬蟲開發者可以輕易探索到所有介面,甚至一些內部介面,畢竟猜一下REST的介面比RPC的介面容易的多。

作為補充,下面再列幾個有關REST API設計大家爭議討論糾結的比較多的幾個方面。

建立資源使用PUT還是POST

比如 stackoverflow.com/questions/6… ,總的來說大家基本認同微軟提到的三個方面:

  • 客戶端決定資源名用PUT,服務端決定資源名用POST
  • POST是把資源加入集合
  • PUT實現需要冪等

當然,有些公司的規範是建立資源僅僅是POST,不支援PUT

異常處理的HTTP響應狀態碼

  • REST的建議是應當考慮儘可能使用匹配的Http狀態碼來對應到錯誤型別,比如刪除使用者的操作:
    • 使用者找不到是404
    • 刪除成功後是204
    • 使用者因為有賬戶餘額無法刪除是409(客戶端的問題是4xx)
    • 其它服務端異常是500(服務端的問題是5xx)
  • 總體來說這個規範出發點是好的,實現起來落地比較困難,原因有下面幾個:
    • 狀態碼對應各種錯誤型別的對映關係沒有統一標準,工程師實現的時候五花八門
    • 實現起來可能需要在業務邏輯中耦合狀態碼,很難在GlobalExceptionHandler去做,除非事先先規範出十幾種異常
    • 如果使用了不正確的響應狀態可能會導致反向代理等觸發錯誤的一些操作,而且出現問題的時候搞不清楚是哪個層面出錯了
    • 各種Http Client對應非200狀態碼的處理方式不太一致
  • 有關這個問題的爭議,各大平臺的API實現有些遵從這個規範建議,有些是500甚至200打天下的,相關的國內外討論有:
  • 國內外的很多大廠對於這點的實現不盡相同,總的來說,我的建議是:
    • 如果我們明確API是REST的,而且API對外使用,應當使用合適的狀態碼來反映錯誤(建議控制在20個以內常用的),並且在文件中進行說明,而且出錯後需要在響應體補充細化的error資訊(包含code和message)
    • 如果REST API對內使用,那麼在客戶端和服務端商量好統一標準的情況下可以對響應碼型別進行收斂到幾個,實現起來也方便
    • 如果API是內部使用的RPC over HTTP形式,甚至可以退化到業務異常也使用200響應返回

返回資料是否需要包裝

看到過許多文章都在說,REST還是建議返回的資料本身就是實體資訊(或列表資訊),而不建議把資料進行一層包裝(Result)。如果需要有更多的資訊來補充的話,可以放到HTTP Header中,比如https://developer.github.com/v3/projects/cards/的API:

GET /projects/columns/:column_id/cards

Status: 200 OK
Link: <https://api.github.com/resource?page=2>; rel="next",
      <https://api.github.com/resource?page=5>; rel="last"
[
  {
    "url": "https://api.github.com/projects/columns/cards/1478",
    "id": 1478,
    "node_id": "MDExOlByb2plY3RDYXJkMTQ3OA==",
    "note": "Add payload for delete Project column",
    "created_at": "2016-09-05T14:21:06Z",
    "updated_at": "2016-09-05T14:20:22Z",
    "archived": false,
    "column_url": "https://api.github.com/projects/columns/367",
    "content_url": "https://api.github.com/repos/api-playground/projects-test/issues/3",
    "project_url": "https://api.github.com/projects/120"
  }
]
複製程式碼

之前我們給出的HATEOAS的例子是在響應體中有"content"和"links"的層級,也就是響應體並不是資源本身,是有包裝的,除了links,很多時候我們會直接以統一的格式來定義API響應結構體,比如:

{
	"code" : "",
	"message" : "",
	"path" : ""
	"time" : "",
    "data" : {},
    "links": []
}
複製程式碼

我個人比較喜歡這種方式,不喜歡使用HTTP頭,原因還是因為多變的部署和網路環境下,如果某些環節請求頭被修改了或丟棄了會很麻煩(還有麻煩的Header Key大小寫問題),響應體一般所有的代理都不會去動。

URI的設計層級是否超過兩層

微軟的API設計指南(文末有貼地址)中指出避免太複雜的層級資源,比如**/customers/1/orders/99/products過於複雜,可以退化為/customers/1/orders/orders/99/products**,不URI的複雜度不應該超過collection/item/collection,Google的一些API會層級比較多,比如:

API service: spanner.googleapis.com
A collection of instances: projects/*/instances/*.
A collection of instance operations: projects/*/instances/*/operations/*.
A collection of databases: projects/*/instances/*/databases/*.
A collection of database operations: projects/*/instances/*/databases/*/operations/*.
A collection of database sessions: projects/*/instances/*/databases/*/sessions/*
複製程式碼

這點我比較贊同微軟的規範,太深的層級在實現起來也不方便。

GraphQL

如果說RPC程式導向,REST面向資源,那麼GraphQL就是面向資料查詢了。“GraphQL 既是一種用於 API 的查詢語言也是一個滿足你資料查詢的執行時。 GraphQL 對你的 API 中的資料提供了一套易於理解的完整描述,使得客戶端能夠準確地獲得它需要的資料,而且沒有任何冗餘,也讓 API 更容易地隨著時間推移而演進,還能用於構建強大的開發者工具。”

採用GraphQL,甚至不需要有任何的介面文件,在定義了Schema之後,服務端實現Schema,客戶端可以檢視Schema,然後構建出自己需要的查詢請求來獲得自己需要的資料。

朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)

比如定義如下的Schema:

#
# Schemas must have at least a query root type
#
schema {
    query: Query
}

type Query {
    characters(
        episode: Episode
    ) : [Character]

    human(
        # The id of the human you are interested in
        id : ID!
    ) : Human

    droid(
        # The non null id of the droid you are interested in
        id: ID!
    ): Droid
}

# One of the films in the Star Wars Trilogy
enum Episode {
    # Released in 1977
    NEWHOPE
    # Released in 1980.
    EMPIRE
    # Released in 1983.
    JEDI
}

# A character in the Star Wars Trilogy
interface Character {
    # The id of the character.
    id: ID!
    # The name of the character.
    name: String!
    # The friends of the character, or an empty list if they
    # have none.
    friends: [Character]
    # Which movies they appear in.
    appearsIn: [Episode]!
    # All secrets about their past.
    secretBackstory : String @deprecated(reason : "We have decided that this is not canon")
}

# A humanoid creature in the Star Wars universe.
type Human implements Character {
    # The id of the human.
    id: ID!
    # The name of the human.
    name: String!
    # The friends of the human, or an empty list if they have none.
    friends: [Character]
    # Which movies they appear in.
    appearsIn: [Episode]!
    # The home planet of the human, or null if unknown.
    homePlanet: String
    # Where are they from and how they came to be who they are.
    secretBackstory : String @deprecated(reason : "We have decided that this is not canon")
}

# A mechanical creature in the Star Wars universe.
type Droid implements Character {
    # The id of the droid.
    id: ID!
    # The name of the droid.
    name: String!
    # The friends of the droid, or an empty list if they have none.
    friends: [Character]
    # Which movies they appear in.
    appearsIn: [Episode]!
    # The primary function of the droid.
    primaryFunction: String
    # Construction date and the name of the designer.
    secretBackstory : String @deprecated(reason : "We have decided that this is not canon")
}
複製程式碼

採用GraphQL Playground(github.com/prisma/grap…

朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
其實就是__schema:
朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
然後我們可以根據客戶端的UI需要自己來定義查詢請求,服務端會根據客戶端給的結構來返回資料:
朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
再來看看Github提供的GraphQL(更多參考https://developer.github.com/v4/guides/):
朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
查詢出了最後的三個我的repo:
朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
GraphQL就是通過Schema來明確資料的能力,服務端提供統一的唯一的API入口,然後客戶端來告訴服務端我要的具體資料結構(基本可以說不需要有API文件),有點客戶端驅動服務端的意思。雖然客戶端靈活了,但是GraphQL服務端的實現比較複雜和痛苦的,GraphQL不能替代其它幾種設計風格,並不是傳說中的REST 2.0。更多資訊參見 github.com/chentsulin/…

服務端驅動API

沒有高大上的英文縮寫,因為這種模式或風格是我自己想出來的,那就是通過API讓服務端來驅動客戶端,在之前的一些專案中也有過實踐。說白了,就是在API的返回結果中包含驅動客戶端去怎麼做的資訊,兩個層次:

  • 互動驅動:比如包含actionType和actionInfo,actionType可以是toast、alert、redirectView、redirectWebView等,actionInfo就是toast的資訊、alert的資訊、redirect的URL等。由服務端來明確客戶端在請求API後的互動行為的好處是:
    • 靈活:在緊急的時候還可以通過redirect方式進行救急,比如遇到特殊情況需要緊急進行邏輯修改可以直接在不發版的情況下切換到H5實現,甚至我們可以提供後臺讓產品或運營來配置互動的方式和資訊
    • 統一:有的時候會遇到不同的客戶端,iOS、Android、前端對於互動的實現不統一的情況,如果API結果可以規定這部分內容可以徹底避免這個問題
  • 行為驅動:更深一層的服務端驅動,可以實現一套API作為入口,讓客戶端進行呼叫,然後通過約定一套DSL告知客戶端應該呈現什麼,幹什麼。

之前有兩個這樣的專案採用了類似的API設計方式:

  • 貸款稽核:我們知道貸款的信用稽核邏輯往往會變動比較大,還涉及到客戶端的一些授權(比如運營商爬蟲),而且App的釋出更新往往比較困難(蘋果App Store以及安卓各大應用商店的稽核問題)。如果採用服務端驅動的架構來告知客戶端接下去應該呈現什麼介面做什麼,那麼會有很大的靈活性。
  • 客戶端爬蟲:我們知道如果採用服務端做爬蟲很多時候因為IP的問題會被封,所以需要找很多代理。某專案我們想出了客戶端共享代理的概念,使用手機客戶端來做分散式代理,由服務端驅動排程所有的客戶端,那麼這個時候客戶端需要聽從服務端的指示來做請求然後上報響應。

一般而言,對外的Web API是不會採用這種服務端驅動客戶端的方式來設計API的。對於某些特殊型別的專案,我們可以考慮採用這種服務端驅動的方式來設計API,讓客戶端變為一個不含邏輯的執行者,執行的是UI和互動。

選擇哪個模式

朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
user-gold-cdn.xitu.io/2019/2/15/1… 此文給出了一個有關RPC、REST、GRAPHQL選擇的決策方式可以參考,見上圖。

我覺得:

  • 在下列情況考慮RPC風格的API或說是RPC:
    • 偏向內部的API
    • 沒有太多的時間考慮API的設計或沒有架構師
    • 提供的API很難進行資源、物件抽象
    • 對效能有高要求
  • 在下列情況考慮REST風格:
    • 偏向外部API
    • 提供的API天生圍繞資源、物件、管理展開
    • 不能耦合客戶端實現
    • 資源的CRUD是可以對齊的(功能完整的)
  • 在下列情況考慮GraphQL:
    • 客戶端對於資料的需求多變
    • 資料具有圖的特點
  • 在下列情況考慮服務端驅動:
    • 客戶端發版更新困難,需要極端的靈活性控制客戶端
    • 僅限私有API

更多需要考慮的設計點

很多API設計指南都提到了下面這些設計考量點,也需要在設計的時候進行考慮:

  • 版本控制,比如:
  • 快取策略,比如:
    • 響應使用Cache-Control告知客戶端快取時間(max-age)、策略(private、public)
    • 響應使用ETag來進行資源版本控制
  • 部分響應:比如大的二進位制檔案需要考慮實現HEAD Method來表明資源允許分段下載,以及提供資源大小資訊:
HEAD https://adventure-works.com/products/10?fields=productImage HTTP/1.1

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Type: image/jpeg
Content-Length: 4580
複製程式碼

然後提供資源分段下載功能:

GET https://adventure-works.com/products/10?fields=productImage HTTP/1.1
Range: bytes=0-2499

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: image/jpeg
Content-Length: 2500
Content-Range: bytes 0-2499/4580
[...]
複製程式碼
  • 列表設計:需要在設計列表型別API的時候考慮分頁、投影、排序、查詢幾點,值得注意的是列表API的額外功能比較多,儘量進行命名的統一化規範

參考資料

相關文章