閱讀834 返回首頁    go 阿裏雲 go 技術社區[雲棲]


GraphQL 用例:使用 Golang 和 PostgreSQL 構建一個博客引擎 API

摘要

GraphQL 在生產環境中似乎難以使用:雖然對於建模功能來說圖接口非常靈活,但是並不適用於關係型存儲,不管是在實現還是性能方麵。

在這篇博客中,我們會設計並實現一個簡單的博客引擎 API,它支持以下功能:

  • 三種類型的資源(用戶、博文以及評論)支持多種功能(創建用戶、創建博文、給博文添加評論、關注其它用戶的博文和評論,等等。)
  • 使用 PostgreSQL 作為後端數據存儲(選擇它因為它是一個流行的關係型數據庫)。
  • 使用 Golang(開發 API 的一個流行語言)實現 API。

我們會比較簡單的 GraphQL 實現和純 REST 替代方案,在一種普通場景(呈現博客文章頁麵)下對比它們的實現複雜性和效率。

介紹

GraphQL 是一種 IDL(接口定義語言Interface Definition Language),設計者定義數據類型和並把數據建模為一個圖graph。每個頂點都是一種數據類型的一個實例,邊代表了節點之間的關係。這種方式非常靈活,能適應任何業務領域。然而,問題是設計過程更加複雜,而且傳統的數據存儲不能很好地映射到圖模型。閱讀附錄1了解更多關於這個問題的詳細信息。

GraphQL 在 2014 年由 Facebook 的工程師團隊首次提出。盡管它的優點和功能非常有趣而且引人注目,但它並沒有得到大規模應用。開發者需要權衡 REST 的設計簡單性、熟悉性、豐富的工具和 GraphQL 不會受限於 CRUD(LCTT 譯注:Create、Read、Update、Delete) 以及網絡性能(它優化了往返服務器的網絡)的靈活性。

大部分關於 GraphQL 的教程和指南都跳過了從數據存儲獲取數據以便解決查詢的問題。也就是,如何使用通用目的、流行存儲方案(例如關係型數據庫)為 GraphQL API 設計一個支持高效數據提取的數據庫。

這篇博客介紹構建一個博客引擎 GraphQL API 的流程。它的功能相當複雜。為了和基於 REST 的方法進行比較,它的範圍被限製為一個熟悉的業務領域。

這篇博客的文章結構如下:

  • 第一部分我們會設計一個 GraphQL 模式並介紹所使用語言的一些功能。
  • 第二部分是 PostgreSQL 數據庫的設計。
  • 第三部分介紹了使用 Golang 實現第一部分設計的 GraphQL 模式。
  • 第四部分我們以從後端獲取所需數據的角度來比較呈現博客文章頁麵的任務。

相關閱讀

在 GraphQL 中建模一個博客引擎

下述列表1包括了博客引擎 API 的全部模式。它顯示了組成圖的頂點的數據類型。頂點之間的關係,也就是邊,被建模為指定類型的屬性。


  1. type User {
  2. id: ID
  3. email: String!
  4. post(id: ID!): Post
  5. posts: [Post!]!
  6. follower(id: ID!): User
  7. followers: [User!]!
  8. followee(id: ID!): User
  9. followees: [User!]!
  10. }
  11. type Post {
  12. id: ID
  13. user: User!
  14. title: String!
  15. body: String!
  16. comment(id: ID!): Comment
  17. comments: [Comment!]!
  18. }
  19. type Comment {
  20. id: ID
  21. user: User!
  22. post: Post!
  23. title: String
  24. body: String!
  25. }
  26. type Query {
  27. user(id: ID!): User
  28. }
  29. type Mutation {
  30. createUser(email: String!): User
  31. removeUser(id: ID!): Boolean
  32. follow(follower: ID!, followee: ID!): Boolean
  33. unfollow(follower: ID!, followee: ID!): Boolean
  34. createPost(user: ID!, title: String!, body: String!): Post
  35. removePost(id: ID!): Boolean
  36. createComment(user: ID!, post: ID!, title: String!, body: String!): Comment
  37. removeComment(id: ID!): Boolean
  38. }

列表1

模式使用 GraphQL DSL 編寫,它用於定義自定義數據類型,例如 UserPost 和 Comment。該語言也提供了一係列原始數據類型,例如 StringBoolean 和 ID(它是String 的別名,但是有頂點唯一標識符的額外語義)。

Query 和 Mutation 是語法解析器能識別並用於查詢圖的可選類型。從 GraphQL API 讀取數據等同於遍曆圖。需要提供這樣一個起始頂點;該角色通過 Query 類型來實現。在這種情況中,所有圖的查詢都要從一個由 id user(id:ID!) 指定的用戶開始。對於寫數據,定義了 Mutation 頂點。它提供了一係列操作,建模為能遍曆(並返回)新創建頂點類型的參數化屬性。列表2是這些查詢的一些例子。

頂點屬性能被參數化,也就是能接受參數。在圖遍曆場景中,如果一個博文頂點有多個評論頂點,你可以通過指定 comment(id: ID) 隻遍曆其中的一個。所有這些都取決於設計,設計者可以選擇不提供到每個獨立頂點的直接路徑。

! 字符是一個類型後綴,適用於原始類型和用戶定義類型,它有兩種語義:

  • 當被用於參數化屬性的參數類型時,表示這個參數是必須的。
  • 當被用於一個屬性的返回類型時,表示當頂點被獲取時該屬性不會為空。
  • 也可以把它們組合起來,例如 [Comment!]! 表示一個非空 Comment 頂點鏈表,其中[][Comment] 是有效的,但 null, [null], [Comment, null] 就不是。

列表2 包括一係列用於博客 API 的 curl 命令,它們會使用 mutation 填充圖然後查詢圖以便獲取數據。要運行它們,按照 topliceanu/graphql-go-example 倉庫中的指令編譯並運行服務。


  1. # 創建用戶 12 3 的更改。更改和查詢類似,在該情景中我們檢索新創建用戶的 id email
  2. curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"user1@x.co"){id, email}}'
  3. curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"user2@x.co"){id, email}}'
  4. curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"user3@x.co"){id, email}}'
  5. # 為用戶添加博文的更改。為了和模式匹配我們需要檢索他們的 id,否則會出現錯誤。
  6. curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:1,title:"post1",body:"body1"){id}}'
  7. curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:1,title:"post2",body:"body2"){id}}'
  8. curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:2,title:"post3",body:"body3"){id}}'
  9. # 博文所有評論的更改。`createComment` 需要用戶 id,標題和正文。看列表 1 的模式。
  10. curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:2,post:1,title:"comment1",body:"comment1"){id}}'
  11. curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:1,post:3,title:"comment2",body:"comment2"){id}}'
  12. curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:3,post:3,title:"comment3",body:"comment3"){id}}'
  13. # 讓用戶 3 關注用戶 1 和用戶 2 的更改。注意 `follow` 更改隻返回一個布爾值而不需要指定。
  14. curl -XPOST http://vm:8080/graphql -d 'mutation {follow(follower:3, followee:1)}'
  15. curl -XPOST http://vm:8080/graphql -d 'mutation {follow(follower:3, followee:2)}'
  16. # 用戶獲取用戶 1 所有數據的查詢。
  17. curl -XPOST http://vm:8080/graphql -d '{user(id:1)}'
  18. # 用戶獲取用戶 2 和用戶 1 的關注者的查詢。
  19. curl -XPOST http://vm:8080/graphql -d '{user(id:2){followers{id, email}}}'
  20. curl -XPOST http://vm:8080/graphql -d '{user(id:1){followers{id, email}}}'
  21. # 檢測用戶 2 是否被用戶 1 關注的查詢。如果是,檢索用戶 1 email,否則返回空。
  22. curl -XPOST http://vm:8080/graphql -d '{user(id:2){follower(id:1){email}}}'
  23. # 返回用戶 3 關注的所有用戶 id email 的查詢。
  24. curl -XPOST http://vm:8080/graphql -d '{user(id:3){followees{id, email}}}'
  25. # 如果用戶 3 被用戶 1 關注,就獲取用戶 3 email 的查詢。
  26. curl -XPOST http://vm:8080/graphql -d '{user(id:1){followee(id:3){email}}}'
  27. # 獲取用戶 1 的第二篇博文的查詢,檢索它的標題和正文。如果博文 2 不是由用戶 1 創建的,就會返回空。
  28. curl -XPOST http://vm:8080/graphql -d '{user(id:1){post(id:2){title,body}}}'
  29. # 獲取用戶 1 的所有博文的所有數據的查詢。
  30. curl -XPOST http://vm:8080/graphql -d '{user(id:1){posts{id,title,body}}}'
  31. # 獲取寫博文 2 用戶的查詢,如果博文 2 是由 用戶 1 撰寫;一個現實語言靈活性的例證。
  32. curl -XPOST http://vm:8080/graphql -d '{user(id:1){post(id:2){user{id,email}}}}'

列表2

通過仔細設計 mutation 和類型屬性,可以實現強大而富有表達力的查詢。

設計 PostgreSQL 數據庫

關係型數據庫的設計,一如以往,由避免數據冗餘的需求驅動。選擇該方式有兩個原因:

  1. 表明實現 GraphQL API 不需要定製化的數據庫技術或者學習和使用新的設計技巧。
  2. 表明 GraphQL API 能在現有的數據庫之上創建,更具體地說,最初設計用於 REST 後端甚至傳統的呈現 HTML 站點的服務器端數據庫。

閱讀 附錄1 了解關於關係型和圖數據庫在構建 GraphQL API 方麵的區別。列表3 顯示了用於創建新數據庫的 SQL 命令。數據庫模式和 GraphQL 模式相對應。為了支持 follow/unfollow 更改,需要添加 followers 關係。


  1. CREATE TABLE IF NOT EXISTS users (
  2. id SERIAL PRIMARY KEY,
  3. email VARCHAR(100) NOT NULL
  4. );
  5. CREATE TABLE IF NOT EXISTS posts (
  6. id SERIAL PRIMARY KEY,
  7. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  8. title VARCHAR(200) NOT NULL,
  9. body TEXT NOT NULL
  10. );
  11. CREATE TABLE IF NOT EXISTS comments (
  12. id SERIAL PRIMARY KEY,
  13. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  14. post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  15. title VARCHAR(200) NOT NULL,
  16. body TEXT NOT NULL
  17. );
  18. CREATE TABLE IF NOT EXISTS followers (
  19. follower_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  20. followee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  21. PRIMARY KEY(follower_id, followee_id)
  22. );

列表3

Golang API 實現

本項目使用的用 Go 實現的 GraphQL 語法解析器是 github.com/graphql-go/graphql。它包括一個查詢解析器,但不包括模式解析器。這要求開發者利用庫提供的結構使用 Go 構建 GraphQL 模式。這和 nodejs 實現 不同,後者提供了一個模式解析器並為數據獲取暴露了鉤子。因此 列表1 中的模式隻是作為指導使用,需要轉化為 Golang 代碼。然而,這個“限製”提供了與抽象級別對等的機會,並且了解模式如何和用於檢索數據的圖遍曆模型相關。列表4 顯示了 Comment 頂點類型的實現:


  1. var CommentType = graphql.NewObject(graphql.ObjectConfig{
  2. Name: "Comment",
  3. Fields: graphql.Fields{
  4. "id": &graphql.Field{
  5. Type: graphql.NewNonNull(graphql.ID),
  6. Resolve: func(p graphql.ResolveParams) (interface{}, error) {
  7. if comment, ok := p.Source.(*Comment); ok == true {
  8. return comment.ID, nil
  9. }
  10. return nil, nil
  11. },
  12. },
  13. "title": &graphql.Field{
  14. Type: graphql.NewNonNull(graphql.String),
  15. Resolve: func(p graphql.ResolveParams) (interface{}, error) {
  16. if comment, ok := p.Source.(*Comment); ok == true {
  17. return comment.Title, nil
  18. }
  19. return nil, nil
  20. },
  21. },
  22. "body": &graphql.Field{
  23. Type: graphql.NewNonNull(graphql.ID),
  24. Resolve: func(p graphql.ResolveParams) (interface{}, error) {
  25. if comment, ok := p.Source.(*Comment); ok == true {
  26. return comment.Body, nil
  27. }
  28. return nil, nil
  29. },
  30. },
  31. },
  32. })
  33. func init() {
  34. CommentType.AddFieldConfig("user", &graphql.Field{
  35. Type: UserType,
  36. Resolve: func(p graphql.ResolveParams) (interface{}, error) {
  37. if comment, ok := p.Source.(*Comment); ok == true {
  38. return GetUserByID(comment.UserID)
  39. }
  40. return nil, nil
  41. },
  42. })
  43. CommentType.AddFieldConfig("post", &graphql.Field{
  44. Type: PostType,
  45. Args: graphql.FieldConfigArgument{
  46. "id": &graphql.ArgumentConfig{
  47. Description: "Post ID",
  48. Type: graphql.NewNonNull(graphql.ID),
  49. },
  50. },
  51. Resolve: func(p graphql.ResolveParams) (interface{}, error) {
  52. i := p.Args["id"].(string)
  53. id, err := strconv.Atoi(i)
  54. if err != nil {
  55. return nil, err
  56. }
  57. return GetPostByID(id)
  58. },
  59. })
  60. }

列表4

正如 列表1 中的模式,Comment 類型是靜態定義的一個有三個屬性的結構體:idtitle 和 body。為了避免循環依賴,動態定義了 user 和 post 兩個其它屬性。

Go 並不適用於這種動態建模,它隻支持一些類型檢查,代碼中大部分變量都是 interface{} 類型,在使用之前都需要進行類型斷言。CommentType 是一個 graphql.Object 類型的變量,它的屬性是 graphql.Field 類型。因此,GraphQL DSL 和 Go 中使用的數據結構並沒有直接的轉換。

每個字段的 resolve 函數暴露了 Source 參數,它是表示遍曆時前一個節點的數據類型頂點。Comment 的所有屬性都有作為 source 的當前 CommentType 頂點。檢索idtitle 和 body 是一個直接屬性訪問,而檢索 user 和 post 要求圖遍曆,也需要數據庫查詢。由於它們非常簡單,這篇文章並沒有介紹這些 SQL 查詢,但在參考文獻部分列出的 github 倉庫中有。

普通場景下和 REST 的對比

在這一部分,我們會展示一個普通的博客文章呈現場景,並比較 REST 和 GraphQL 的實現。關注重點會放在入站/出站請求數量,因為這些是造成頁麵呈現延遲的最主要原因。

場景:呈現一個博客文章頁麵。它應該包含關於作者(email)、博客文章(標題、正文)、所有評論(標題、正文)以及評論人是否關注博客文章作者的信息。圖1 和 圖2 顯示了客戶端 SPA、API 服務器以及數據庫之間的交互,一個是 REST API、另一個對應是 GraphQL API。


  1. +------+ +------+ +--------+
  2. |client| |server| |database|
  3. +--+---+ +--+---+ +----+---+
  4. | GET /blogs/:id | |
  5. 1\. +-------------------------> SELECT * FROM blogs... |
  6. | +--------------------------->
  7. | <---------------------------+
  8. <-------------------------+ |
  9. | | |
  10. | GET /users/:id | |
  11. 2\. +-------------------------> SELECT * FROM users... |
  12. | +--------------------------->
  13. | <---------------------------+
  14. <-------------------------+ |
  15. | | |
  16. | GET /blogs/:id/comments | |
  17. 3\. +-------------------------> SELECT * FROM comments... |
  18. | +--------------------------->
  19. | <---------------------------+
  20. <-------------------------+ |
  21. | | |
  22. | GET /users/:id/followers| |
  23. 4\. +-------------------------> SELECT * FROM followers.. |
  24. | +--------------------------->
  25. | <---------------------------+
  26. <-------------------------+ |
  27. | | |
  28. + + +

圖1


  1. +------+ +------+ +--------+
  2. |client| |server| |database|
  3. +--+---+ +--+---+ +----+---+
  4. | GET /graphql | |
  5. 1\. +-------------------------> SELECT * FROM blogs... |
  6. | +--------------------------->
  7. | <---------------------------+
  8. | | |
  9. | | |
  10. | | |
  11. 2\. | | SELECT * FROM users... |
  12. | +--------------------------->
  13. | <---------------------------+
  14. | | |
  15. | | |
  16. | | |
  17. 3\. | | SELECT * FROM comments... |
  18. | +--------------------------->
  19. | <---------------------------+
  20. | | |
  21. | | |
  22. | | |
  23. 4\. | | SELECT * FROM followers.. |
  24. | +--------------------------->
  25. | <---------------------------+
  26. <-------------------------+ |
  27. | | |
  28. + + +

圖2

列表5 是一條用於獲取所有呈現博文所需數據的簡單 GraphQL 查詢。


  1. {
  2. user(id: 1) {
  3. email
  4. followers
  5. post(id: 1) {
  6. title
  7. body
  8. comments {
  9. id
  10. title
  11. user {
  12. id
  13. email
  14. }
  15. }
  16. }
  17. }
  18. }

列表5

對於這種情況,對數據庫的查詢次數是故意相同的,但是到 API 服務器的 HTTP 請求已經減少到隻有一個。我們認為在這種類型的應用程序中通過互聯網的 HTTP 請求是最昂貴的。

為了利用 GraphQL 的優勢,後端並不需要進行特別設計,從 REST 到 GraphQL 的轉換可以逐步完成。這使得可以測量性能提升和優化。從這一點,API 設計者可以開始優化(潛在的合並) SQL 查詢從而提高性能。緩存的機會在數據庫和 API 級別都大大增加。

SQL 之上的抽象(例如 ORM 層)通常會和 n+1 問題相抵觸。在 REST 示例的步驟 4 中,客戶端可能不得不在單獨的請求中為每個評論的作者請求關注狀態。這是因為在 REST 中沒有標準的方式來表達兩個以上資源之間的關係,而 GraphQL 旨在通過使用嵌套查詢來防止這類問題。這裏我們通過獲取用戶的所有關注者來作弊。我們向客戶提出了如何確定評論並關注了作者的用戶的邏輯。

另一個區別是獲取比客戶端所需更多的數據,以免破壞 REST 資源抽象。這對於用於解析和存儲不需要數據的帶寬消耗和電池壽命非常重要。

總結

GraphQL 是 REST 的一個可用替代方案,因為:

  • 盡管設計 API 更加困難,但該過程可以逐步完成。也是由於這個原因,從 REST 轉換到 GraphQL 非常容易,兩個流程可以沒有任何問題地共存。
  • 在網絡請求方麵更加高效,即使是類似本博客中的簡單實現。它還提供了更多查詢優化和結果緩存的機會。
  • 在用於解析結果的帶寬消耗和 CPU 周期方麵它更加高效,因為它隻返回呈現頁麵所需的數據。

REST 仍然非常有用,如果:

  • 你的 API 非常簡單,隻有少量的資源或者資源之間關係簡單。
  • 在你的組織中已經在使用 REST API,而且你已經配置好了所有工具,或者你的客戶希望獲取 REST API。
  • 你有複雜的 ACL(LCTT 譯注:Access Control List) 策略。在博客例子中,可能的功能是允許用戶良好地控製誰能查看他們的電子郵箱、博客、特定博客的評論、他們關注了誰,等等。優化數據獲取同時檢查複雜的業務規則可能會更加困難。

附錄1:圖數據庫和高效數據存儲

盡管將其應用領域數據想象為一個圖非常直觀,正如這篇博文介紹的那樣,但是支持這種接口的高效數據存儲問題仍然沒有解決。

近年來圖數據庫變得越來越流行。通過將 GraphQL 查詢轉換為特定的圖數據庫查詢語言從而延遲解決請求的複雜性似乎是一種可行的方案。

問題是和關係型數據庫相比,圖並不是一種高效的數據結構。圖中一個頂點可能有到任何其它頂點的連接,訪問模式比較難以預測因此提供了較少的優化機會。

例如緩存的問題,為了快速訪問需要將哪些頂點保存在內存中?通用緩存算法在圖遍曆場景中可能沒那麼高效。

數據庫分片問題:把數據庫切分為更小、沒有交叉的數據庫並保存到獨立的硬件。在學術上,最小切割的圖劃分問題已經得到了很好的理解,但可能是次優的,而且由於病態的最壞情況可能導致高度不平衡切割。

在關係型數據庫中,數據被建模為記錄(行或者元組)和列,表和數據庫名稱都隻是簡單的命名空間。大部分數據庫都是麵向行的,意味著每個記錄都是一個連續的內存塊,一個表中的所有記錄在磁盤上一個接一個地整齊地打包(通常按照某個關鍵列排序)。這非常高效,因為這是物理存儲最優的工作方式。HDD 最昂貴的操作是將磁頭移動到磁盤上的另一個扇區,因此最小化此類訪問非常重要。

很有可能如果應用程序對一條特定記錄感興趣,它需要獲取整條記錄,而不僅僅是記錄中的其中一列。也很有可能如果應用程序對一條記錄感興趣,它也會對該記錄周圍的記錄感興趣,例如全表掃描。這兩點使得關係型數據庫相當高效。然而,也是因為這個原因,關係型數據庫的最差使用場景就是總是隨機訪問所有數據。圖數據庫正是如此。

隨著支持更快隨機訪問的 SSD 驅動器的出現,更便宜的內存使得緩存大部分圖數據庫成為可能,更好的優化圖緩存和分區的技術,圖數據庫開始成為可選的存儲解決方案。大部分大公司也使用它:Facebook 有 Social Graph,Google 有 Knowledge Graph。

原文發布時間為:2017-05-19

本文來自雲棲社區合作夥伴“Linux中國”

最後更新:2017-05-19 12:04:24

  上一篇:go  《雲數據管理》 2.1 邏輯時間和Lamport時鍾
  下一篇:go  《HttpClient官方文檔》4.8 SPNEGO/Kerberos驗證