跳到主要內容
黯羽輕揚每天積累一點點

GraphQL

免費2017-06-10#Solution#GraphQL与RESTful#GraphQL与REST#GraphQL指南#GraphQL vs RESTful

老早就聽說了 GraphQL,一直沒有機會仔細瞭解。看到一篇還算不錯的文章,趁機入門

寫在前面

本文第一部分翻譯自 REST 2.0 Is Here and Its Name Is GraphQL,標題很有視覺衝擊力,不小心上鉤了

剩餘部分是對 GraphQL 的思考。現在,我們邊看譯文邊匯聚疑問

一. 譯文

GraphQL 是一種 API 查詢語言。雖然與 REST 有本質區別,但 GraphQL 可以作為 REST 的備選項,它提供了高效能、良好的開發體驗和一些強大的工具

透過本文,我們來看看怎樣用 REST 和 GraphQL 來處理一些常見場景。本文附有 3 個專案,提供了流行電影和演員資訊 API,還用 HTML 和 jQuery 搭了個簡單的前端應用,可以查看對應的 REST 和 GraphQL 源碼

我們將透過這些 API 來看這兩種技術的差異,以便瞭解其優缺點。開始之前,先佈置舞台,快速過一下這些技術是怎麼冒出來的

Web 早期

Web 早期很簡單,早期的 Web 應用就是靜態 HTML 文件。演化到網站想包含存在資料庫(例如 SQL)裡的動態內容,並透過 JavaScript 來添加互動功能。絕大多數的 Web 內容都透過桌上型電腦的 Web 瀏覽器來存取的,看起來一切都很美好

Web Server fetches data & outputs HTML

REST:API 的出現

快進到 2007 年 Steve Jobs 介紹 iPhone 的時候,除了智慧型手機對世界、文化和交流的深遠影響,也讓開發者的工作變複雜了很多。智慧型手機破壞了開發現狀,短短幾年後,我們突然就有了桌上型電腦、iPhone、Android 和平板電腦

作為回應,開發者開始用 RESTful API 給形狀各異、尺寸不同的應用提供數據,新的開發模型差不多是這樣:

REST maps URIs to Resource

GraphQL:API 的演進

GraphQL 是一種 API 查詢語言,由 Facebook 設計並開源。可以把 GraphQL 當做 REST 的備選項來構建 API,而 REST 是一種概念模型,可以用來設計和實現 API。GraphQL 是一種標準化的語言、type 系統和在客戶端和伺服器之間建立起強約定的規範定義。讓所有設備之間通信有了一套標準語言,簡化了創建大型跨平台應用的過程

用 GraphQL 的話,圖就變成了這樣子:

GraphQL is a query language for APIs

GraphQL vs REST

下面的部分,建議跟著源碼走,可以在 隨附的 GitHub 儲存庫 找到本文相關源碼

源碼包含 3 個專案:

  • RESTful API 實現

  • GraphQL API 實現

  • 一個用 jQuery 和 HTML 搭的簡單客戶端網頁

為了儘量簡單地對比這兩種技術,專案故意設計得很簡單

想跟著做的話,先打開 3 個終端視窗,再 cd 到專案庫的 RESTfulGraphQLClient 目錄,透過 npm run dev 啟動服務。準備好這些再接著往下看 :)

透過 REST 查詢

我們的 RESTful API 還有一些端點(endpoints):

EndpointDescription
/moviesreturns an Array of objects containing links to our movies (e.g. [ { href: ‘http://localhost/movie/1’ } ]
/movie/:idreturns a single movie with id = :id
/movie/:id/actorsreturns an array of objects containing links to actors in the movie with id = :id
/actorsreturns an Array of objects containing links to actors
/actor/:idreturns a single actor with id = :id
/actor/:id/moviesreturns an array of objects containing links to movies that the actor with id = :id has acted in

注意:我們這麼簡單的數據模型就已經有 6 個端點要維護和建立文件了

一起想像一下我們是客戶端開發者,需要用這些 movie API 透過 HTML 和 jQuery 來構建一個簡單的網頁。為了構建這個頁面,我們需要關於 movie 和對應 actor 的資訊。我們的 API 具備我們需要的所有功能,所以直接獲取數據

開個新終端執行:

curl localhost:3000/movies

應該會得到這樣的回應:

[
  {
    "href": "http://localhost:3000/movie/1"
  },
  {
    "href": "http://localhost:3000/movie/2"
  },
  {
    "href": "http://localhost:3000/movie/3"
  },
  {
    "href": "http://localhost:3000/movie/4"
  },
  {
    "href": "http://localhost:3000/movie/5"
  }
]

以 RESTful 方式,API 返回一個連結陣列,每個連結對應實際的電影對象。然後執行 curl http://localhost:3000/movie/1 來獲取第一個電影,第二個就 curl http://localhost:3000/movie/2 ……以此類推

app.js 可以看到我們用來獲取頁面需要的所有數據的方法:

const API_URL = 'http://localhost:3000/movies';
function fetchDataV1() {

  // 1 call to get the movie links
  $.get(API_URL, movieLinks => {
    movieLinks.forEach(movieLink => {

      // For each movie link, grab the movie object
      $.get(movieLink.href, movie => {
        $('#movies').append(buildMovieElement(movie))

        // One call (for each movie) to get the links to actors in this movie
        $.get(movie.actors, actorLinks => {
          actorLinks.forEach(actorLink => {

            // For each actor for each movie, grab the actor object
            $.get(actorLink.href, actor => {
              const selector = '#' + getMovieId(movie) + ' .actors';
              const actorElement = buildActorElement(actor);
              $(selector).append(actorElement);
            })
          })
        })
      })
    })
  })
}

正如你注意到的,看起來不太理想。為了完成這一切,我們做了 1 + M + M + sum(Am) 次 API 呼叫,其中 M 是 movie 數量,sum(Am)M 部電影中所有 actor 的總數。對於小數據需求的應用可能還行,但不適用於大型生產系統

結論呢?我們簡單的 RESTful 方法不合適,為了優化 API,我們可能找後端團隊要一個專用的 /moviesAndActors 介面來支持這個頁面。只要這個介面好了,我們就可以把 1 + M + M + sum(Am) 次網路請求換成 1 次請求

curl http://localhost:3000/moviesAndActors

會返回一個這樣的回應:

[
  {
    "id": 1,
    "title": "The Shawshank Redemption",
    "release_year": 1993,
    "tags": [
      "Crime",
      "Drama"
    ],
    "rating": 9.3,
    "actors": [
      {
        "id": 1,
        "name": "Tim Robbins",
        "dob": "10/16/1958",
        "num_credits": 73,
        "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMTI1OTYxNzAxOF5BMl5BanBnXkFtZTYwNTE5ODI4._V1_.jpg",
        "href": "http://localhost:3000/actor/1",
        "movies": "http://localhost:3000/actor/1/movies"
      },
      {
        "id": 2,
        "name": "Morgan Freeman",
        "dob": "06/01/1937",
        "num_credits": 120,
        "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMTc0MDMyMzI2OF5BMl5BanBnXkFtZTcwMzM2OTk1MQ@@._V1_UX214_CR0,0,214,317_AL_.jpg",
        "href": "http://localhost:3000/actor/2",
        "movies": "http://localhost:3000/actor/2/movies"
      }
    ],
    "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BODU4MjU4NjIwNl5BMl5BanBnXkFtZTgwMDU2MjEyMDE@._V1_UX182_CR0,0,182,268_AL_.jpg",
    "href": "http://localhost:3000/movie/1"
  },
  ...
]

太好了!只需 1 個請求,我們就能取到頁面需要的所有數據。在 Client 目錄的 app.js 可以看到這個優化具體實現:

const MOVIES_AND_ACTORS_URL = 'http://localhost:3000/moviesAndActors';
function fetchDataV2() {
  $.get(MOVIES_AND_ACTORS_URL, movies => renderRoot(movies));
}
function renderRoot(movies) {
  movies.forEach(movie => {
    $('#movies').append(buildMovieElement(movie));
    movie.actors && movie.actors.forEach(actor => {
      const selector = '#' + getMovieId(movie) + ' .actors';
      const actorElement = buildActorElement(actor);
      $(selector).append(actorElement);
    })
  });
}

我們的新應用會比之前的版本更快一些,但還不夠完美。如果打開 http://localhost:4000 看我們的頁面的話,會看到:

Move demo page

仔細看的話,會注意到我們的頁面只用到了 movietitleimage 以及每個 actornameimage(其實,我們只用到了 movie 對象 8 個欄位中的 2 個,和 actor 對象 7 個欄位中的 2 個)。也就是說我們浪費了從網路請求拿到的 3/4 的資訊!這樣過分使用頻寬會非常影響效能,還會帶來額外基礎設施成本

機智的後端開發者會輕蔑一笑,並快速實現個特殊的查詢參數叫 fields,接受一組欄位名,可以動態決定具體請求應該返回哪些欄位

例如,我們可能會用 curl http://localhost:3000/moviesAndActors?fields=title,image 代替 curl http://localhost:3000/moviesAndActors。甚至還會有另一個特殊查詢參數 actor_fields,用來指定 actor 模型應該含有哪些欄位,例如 curl http://localhost:3000/moviesAndActors?fields=title,image&actor_fields=name,image

現在,這差不多是我們的簡單應用的最佳實現了,但它引入了一個壞習慣,為客戶端應用中特定的頁面創建訂製化介面。當開始構建一個與網頁和 Android 應用展示資訊不同的 iOS 應用時,這個問題會更加明顯

如果我們可以構建一個通用的 API,顯式說明我們數據模型中的實體和這些實體之間關係,又不會帶來 1 + M + M + sum(Am) 的效能問題,那該多好啊?好消息!我們能做到!

用 GraphQL 查詢

用 GraphQL 的話,我們可以直接跳到最佳查詢,透過一條簡單直觀的查詢,一點不冗餘地獲取我們需要的所有資訊:

query MoviesAndActors {
  movies {
    title
    image
    actors {
      image
      name
    }
  }
}

強烈建議!手動試試,打開 http://localhost:5000 的 GraphiQL(一個很棒的瀏覽器 GraphQL IDE),執行上面的查詢

現在,我們稍微深入一點

想想 GraphQL

GraphQL 採用了一種與 REST 完全不同的 API 方法,沒有依賴 HTTP 結構,比如動詞和 URI,而是在數據之上提出了直觀的查詢語言和強大的 type 系統層,提供客戶端和伺服器之間的強約定,查詢語言提供了一種讓客戶端開發者可以永久獲取任何頁面想要的任意數據的機制

GraphQL 鼓勵把數據看作一個虛擬資訊圖,包含資訊的實體叫做 type,這些 type 可以透過 fields 彼此關聯。查詢從根開始,遍歷這個虛擬圖需要的資訊

這個「虛擬圖」叫做 schema,schema 是構成 API 數據模型的 type、interface、enum 和 union 的集合。GraphQL 還包含了一種方便的 schema 語言,可以用來定義我們的 API。例如,這就是我們 movie API 對應的 schema:

schema {
    query: Query
}

type Query {
    movies: [Movie]
    actors: [Actor]
    movie(id: Int!): Movie
    actor(id: Int!): Actor
    searchMovies(term: String): [Movie]
    searchActors(term: String): [Actor]
}

type Movie {
    id: Int
    title: String
    image: String
    release_year: Int
    tags: [String]
    rating: Float
    actors: [Actor]
}

type Actor {
    id: Int
    name: String
    image: String
    dob: String
    num_credits: Int
    movies: [Movie]
}

type 系統開啟了一大堆好東西的大門,包括更好的工具,更好的文件和跟高效的應用。這塊能扯的東西太多,但現在,我們先跳過,關注更多展示 REST 與 GraphQL 差異的場景

GraphQL vs Rest:版本控制

用 google 隨便搜一下就能得到關於(或者涉及) REST API 版本控制的很多觀點。這裡不深入探究,只是想強調這是一個有意義的問題。版本控制難的一個因素是通常很難知道什麼資訊在被哪些應用和設備使用

添加資訊一般很容易,無論是 REST 還是 GraphQL,添加欄位的話,會流入 REST 客戶端,而會被 GraphQL 安全忽略,除非改變查詢。然而,刪除和編輯資訊就大有不同了

在 REST 方式中,很難知道欄位級的哪些資訊被使用了。我們能知道一個介面 /movies 被用了,但不知道客戶端在用 titleimage,還是 2 個都用了。一種可行的解決方案是添加一個查詢參數指定返回哪些欄位,但這些參數通常都是可選的。因此,經常看到端點級的變化,比如引入一個新端點 /v2/movies。這樣可以,但增加了我們 API 的表面積,同時給開發者帶來了不斷更新和提供詳盡文件的負擔

GraphQL 中的版本控制則不同,每個 GraphQL 查詢都需要準確描述什麼欄位被請求了。這種強制要求,意味著我們精確知道什麼資訊被請求了,並允許我們進一步詢問請求頻率和由誰發起。GraphQL 也支持用廢棄欄位和廢棄原因資訊修飾一個 schema 的原語(primitives)

GraphQL 中的版本控制:

Evolve your API without versions

GraphQL vs REST:快取

REST 裡的快取直接而高效,實際上,快取是 REST 的 6 個原則約束之一,被內置到了 RESTful 設計裡。如果一個來自 /movies/1 端點的回應說可以快取,將來對 /movies/1 的任何請求都可���簡單的換成快取裡的東西,非常簡單

GraphQL 中的快取處理稍微有些不同,快取一個 GraphQL API 通常需要對 API 的每個對象引入某種唯一標識。每個對象都有一個唯一標識符的話,客戶端可以建立標準化的快取,用這個標識符來做可靠的快取、更新和過期。客戶端發起引用該對象的下游查詢時,就用該對象的快取版本。如果想知道關於 GraphQL 快取原理的更多資訊,有 一篇好文章深入討論了這個話題

GraphQL vs REST:開發體驗

開發體驗是應用開發很重要的一方面,也是我們作為工程師投入很多時間構建好工具的原因。這裡的對比是有些主觀的,但我認為仍有必要提及

嘗試 REST,並且它確實有一套豐富的工具生態系統,幫助開發者建立文件、測試和檢查 RESTful API。說到這裡,開發者為 REST API 的擴展付出了巨大代價。介面數量瞬間爆炸,不一致性越來越明顯,版本控制越來越困難

GraphQL 在開發體驗方面確實有過人之處。type 系統已經打開了各種不可思議的工具的大門,比如 GraphiQL IDE,以及文件內置到 schema 裡。GraphQL 裡只有一個端點,並且不依賴文件來找那些數據可用,你擁有了一個類型安全的語言並且能夠自動補全可用的東西,用這個來快速創建 API。GraphQL 也能配合流行的前端框架和工具使用,比如 React 和 Redux。如果考慮用 React 構建應用的話,強烈推薦看看 Relay 或者 Apollo client

總結

GraphQL 提供了一些自用的強大工具集,用來構建高效的數據驅動應用。REST 不會立刻消失,但會有很多需要的東西,尤其是要構建客戶端應用時

如果想進一步深入瞭解,請查看 Scaphold.io’s GraphQL Backend as a ServiceAWS 分分鐘部署能用於生產的 GraphQL API,然後就可以訂制擴展自己的業務邏輯了

希望您喜歡這篇文章,有任何想法或者評論,我都很樂意交流,感謝閱讀!

二. 思考

多一層介面之上的抽象,確實能夠帶來更大靈活性,比如只需要實現原子介面,就能自由組合出返回內容

注意:上面的譯文說 GraphQL 是數據之上的抽象,實際上應該是介面上的抽象(只是介面概念弱化了,不對外,弱化的介面更接近 SQL 語句之類的東西),如果每一個欄位都對應一個查詢介面,那麼很容易實現一個通用的介面管理層,來完成 GraphQL 的所有功能。事實上,GraphQL 就是提供了這樣的通用定義

那麼最大的問題應該是存在冗餘查詢,因為能自由組合 field 返回的前提是先精確到 field 級。也就是說本來一個強介面返回一堆欄位,現在要求每個欄位都提供一個弱介面,這樣才能根據一條自定義查詢,精確組裝出返回內容

當然,可以透過查詢優化來緩解一部分冗餘查詢,比如根據欄位相依關係,對欄位打包查詢。但在複雜場景下,這種優化可能並不容易實現

如果有一個資料庫(或者抽象查詢層)內置了這種優化,解決效能問題,那麼相信 GraphQL 將獲得壓倒性的優勢,首先再也不用無休止的加介面加介面了,另外維護一組標準統一的東西,和維護 n 個介面且存在同一介面不同版本的情況,幾乎不用思考如何選擇

至於前端生態配合(Redux 畢竟不那麼通用),明顯不算是大問題

評論

暫無評論,快來發表你的看法吧

提交評論