[譯] Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第一部分

徐二斤發表於2019-05-05

不知道你是否和我一樣,在本文的標題中,至少有 3 個或 4 個關鍵字屬於“我一直想玩,但還從未接觸過”的型別。React 是一個例外;在每天的工作中我都會用到它,對它已經非常熟悉了。在幾年前的一個專案中我用到了 Elixir,但那已經是很早以前的事情了,而且我從未在 GraphQL 的環境中是使用過它。同樣的,在另外一個專案中,我做了一小部分關於 GraphQL 的工作,該專案的後端使用的是 Node.js,前端使用的是 Relay,但我僅僅觸及了 GraphQL 的皮毛,而且到目前為止我沒有接觸過 Apollo。我堅信學習技術的最好方法就是用它們來構建一些東西,所以我決定深入研究並構建一個包含所有這些技術的 Web 應用程式。如果你想跳到最後,程式碼是在 GitHub 上,現場演示在這裡。(現場演示在免費的 Heroku dyno 上執行,所以當你訪問它時可能需要 30 秒左右才能喚醒。)

定義我們的術語

首先,讓我們來看看我在上面提到的那些元件,以及它們如何組合在一起。

  • Elixir 是一種服務端程式語言。
  • Phoenix 是 Elixir 最受歡迎的 Web 服務端框架。Ruby : Rails :: Elixir : Phoenix。
  • GraphQL 是一種用於 API 的查詢語言。
  • Absinthe 是最流行的 Elixir 庫,用於實現 GraphQL 伺服器。
  • Apollo 是一個流行的 JavaScript 庫,搭配 GraphQL API 使用。(Apollo 還有一個服務端軟體包,用於在 Node.js 中實現 GraphQL 伺服器,但我只使用了它的客戶端配合我搭建的 Elixir GraphQL 服務端。)
  • React 是一個流行的 JavaScript 框架,用於構建前端使用者介面。(這個你可能已經知道了。)

我在構建的是什麼?

我決定構建一個迷你的社交網路。看起來好像很簡單,可以在合理的時間內完成,但是它也足夠複雜,可以讓我遇到一切在真實場景下的應用程式中才會出現的挑戰。我的社交網路被我創造性地稱為 Socializer。使用者可以在其他使用者的帖子下面發帖和評論。Socializer 還有聊天功能; 使用者可以與其他使用者進行私人對話,每個對話可以有任意數量的使用者(即群聊)。

為什麼選擇 Elixir?

Elixir 在過去幾年中越來越流行。它在 Erlang VM 上執行,你可以直接在 Elixir 檔案中寫 Erlang 語法,但它旨在為開發人員提供更友好的語法,同時保持 Erlang 的速度和容錯能力。Elixir 是動態型別的,語法與 ruby 類似。但是它比 ruby 更具功能性,並且有很多不同的慣用語法和模式。

至少對於我而言,Elixir 的主要吸引力在於 Erlang VM 的效能。坦白的說這看起來很荒謬。但使用 Erlang 使得 WhatsApp 的團隊能夠和單個伺服器建立 200 萬個連線。一個 Elixir/Phoenix 伺服器通常可以在不到 1 毫秒的時間內提供簡單的請求;看到終端日誌中請求持續時間的 μ 符號真讓人興奮不已。

Elixir 還有其他好處。它的設計是容錯的;你可以將 Erlang VM 視為一個節點叢集,任何一個節點的當機都可以不影響其他節點。這也使“熱程式碼交換”成為可能,部署新程式碼時無需停止和重啟應用程式。我發現它的模式匹配(pattern matching)管道操作符(pipe operator)也非常有意思。令人耳目一新的是,它在編寫功能強大的程式碼時,近乎和 ruby 一樣給力,而且我發現它可以驅使我更清楚地思考程式碼,寫更少的 bug。

為什麼選擇 GraphQL?

使用傳統的 RESTful API,伺服器會事先定義好它可以提供的資源和路由(通過 API 文件,或者通過一些自動化生成 API 的工具,如 Swagger),使用者必須制定正確的呼叫順序來獲取他們想要的資料。如果服務端有一個帖子的 API 來獲取部落格的帖子,一個評論的 API 用於獲取帖子的評論,一個使用者資訊的 API 獲取使用者的姓名和圖片,使用者可能必須傳送三個單獨的請求,來獲取渲染一個檢視所必要的資訊。(對於這樣一個小案例,顯然 API 可能允許你一次性得到所有相關資料,但它也說明了傳統 RESTful API 的缺點 —— 請求結構由伺服器任意定義,而不能匹配每個使用者和頁面的動態需求)。GraphQL 反轉了這個原則 —— 客戶端先傳送一個描述所需資料的查詢文件(可能跨越表關係),然後伺服器在這個請求中返回所有需要的資料。拿我們的部落格舉例來說,一個帖子的查詢請求可能會是下面這樣:

query {
  post(id: 123) {
    id
    body
    createdAt
    user {
      id
      name
      avatarUrl
    }
    comments {
      id
      body
      createdAt
      user {
        id
        name
        avatarUrl
      }
    }
  }
}
複製程式碼

這個請求描述了渲染一個部落格帖子頁面時,使用者可能會用到的所有資訊:帖子的 ID、內容以及時間戳;釋出帖子的使用者的 ID、姓名和頭像 URL;帖子評論的 ID、內容和時間戳;以及提交每條評論的使用者的 ID,名稱和頭像 URL。結構非常直觀靈活;它非常適合構建介面,因為你可以只描述所需的資料,而不是痛苦地適應 API 提供的結構。

GraphQL 中還有兩個關鍵概念:mutation(變更)和 subscription(訂閱)。Mutation 是一種對伺服器上的資料進行更改的查詢; 它相當於 RESTful API 中的 POST/PATCH/PUT。語法與查詢非常相似; 建立帖子的 mutation 可能是下面這樣的:

mutation {
  createPost(body: $body) {
    id
    body
    createdAt
  }
}
複製程式碼

一條資料庫記錄的屬性通過引數提供,{} 裡的程式碼塊描述了一旦 mutation 完成需要返回的資料(在我們的例子中是新帖子的 ID、內容以及時間戳)。

一個 subscription 對於 GraphQL 是相當特別的;在 RESTful API 中並沒有一個直接和它對應的東西。它允許客戶端在特定事件發生時從伺服器接收實時更新。例如,如果我希望每次建立新帖子時都實時更新主頁,我可能會寫一個這樣的帖子 subscription:

subscription {
  postCreated {
    id
    body
    createdAt
    user {
      id
      name
      avatarUrl
    }
  }
}
複製程式碼

正如你想知道的那樣,這段程式碼告訴伺服器在建立新帖子時向我傳送實時更新,包括帖子的 ID、內容和時間戳,以及作者的 ID、姓名和頭像 URL。Subscription 通常由 websockets 支援;客戶端保持對伺服器開放的套接字,無論什麼時候只要事件發生,伺服器就會向客戶端傳送訊息。

最後一件事 —— GraphQL 有一個非常棒的開發工具,叫做 GraphiQL。它是一個帶有實時編輯器的 Web 介面,你可以在其中編寫查詢、執行查詢語句並檢視結果。它包括自動補全和其他語法糖,使你可以輕鬆找到可用的查詢語句和欄位; 當你在迭代查詢結構時,它表現的特別棒。你可以試試我的 web 應用程式的 GraphiQL 介面。試試向它傳送以下的查詢語句以獲取具有關聯資料的帖子列表(下面展示的例子是一個略微修剪的版本):

query {
  posts {
    id
    body
    insertedAt
    user {
      id
      name
    }
    comments {
      id
      body
      user {
        id
        name
      }
    }
  }
}
複製程式碼

為什麼選擇 Apollo?

Apollo 已經成為伺服器和客戶端上最受歡迎的 GraphQL 庫之一。上次使用 GraphQL 還是 2016 年時和 Relay 一起,Relay 是另外一個客戶端的 JavaScript 庫。實話說,我討厭它。我被 GraphQL 簡單易寫的查詢語句所吸引,相比較而言,Relay 讓我感覺非常複雜而且難以理解;它的文件裡有很多術語,我發現很難構建一個知識基礎讓我理解它。公平地說,那是 Relay 的 1.0 版本;他們已經做了很大的改動來簡化庫(他們稱之為 Relay Modern),文件也比過去好了很多。但是我想嘗試新的東西,Apollo 之所以這麼受歡迎,部分原因是它為構建 GraphQL 客戶端應用程式提供了相對簡單的開發體驗。

服務端

我們先來構建應用程式的服務端;沒有資料使用的話,客戶端就沒有那麼有意思了。我也很好奇 GraphQL 如何能夠實現在客戶端編寫查詢語句,然後拿到所有我需要的資料。(相比之前,在沒有 GraphQL 之前的實現方法中,你需要回來對服務端做一些改動)。

具體來說,我首先定義了應用程式的基本 model(模型)結構。在高層次抽象上,它看起來像這樣:

User
- Name
- Email
- Password hash

Post
- User ID
- Body

Comment
- User ID
- Post ID
- Body

Conversation
- Title (只是將參與者的名稱反規範化為字串)

ConversationUser(每一個 conversation 都可以有任意數量的 user)
- Conversation ID
- User ID

Message
- Conversation ID
- User ID
- Body
複製程式碼

萬幸這很簡單明瞭。Phoenix 允許你編寫與 Rails 非常相似的資料庫遷移。以下是建立 users 表的遷移,例如:

# socializer/priv/repo/migrations/20190414185306_create_users.exs
defmodule Socializer.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
      add :password_hash, :string

      timestamps()
    end

    create unique_index(:users, [:email])
  end
end
複製程式碼

你可以在這裡檢視所有其他表的遷移。

接下來,我實現了 model 類。Phoenix 使用一個名為 Ecto 的庫作為它的 model 的實現;你可以將 Ecto 看作與 ActiveRecord 類似的東西,但它與框架的耦合程度更低。一個主要區別是 Ecto model 沒有任何例項方法。Model 例項只是一個結構(就像帶有預定義鍵的雜湊);你在 model 上定義的方法都是類的方法,它們接受一個“例項”(結構),然後用某種方式更改這個例項,再返回結果。在 Elixir 中這是一種慣用方法; 它更偏好函數語言程式設計和不可變變數(不能二次賦值的變數)。

這是對 Post model 的分解:

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  use Ecto.Schema
  import Ecto.Changeset
  import Ecto.Query

  alias Socializer.{Repo, Comment, User}

  # ...
end
複製程式碼

首先,我們引入一些其他模組。在 Elixir 中,import 可以引入其它模組的功能(類似於 include ruby 中的 model);use 呼叫特定模組上的 __using__ 巨集。巨集是 Elixir 的超程式設計機制。alias 使得名稱空間模組可以通過它們的基本名稱被訪問到(所以我可以引用一個 User 而不是到處使用 Socializer.User 型別)。

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  schema "posts" do
    field :body, :string

    belongs_to :user, User
    has_many :comments, Comment

    timestamps()
  end

  # ...
end
複製程式碼

接下來,我們有了一個 schema(模式)。Ecto model 必須在 schema 中顯式描述 schema 中的每個屬性(不同於 ActiveRecord,例如,它會對底層資料庫表進行內省併為每個欄位建立屬性)。在上一節中我們使用 use Ecto.Schema 引入了 schema 巨集。

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  def all do
    Repo.all(from p in __MODULE__, order_by: [desc: p.id])
  end

  def find(id) do
    Repo.get(__MODULE__, id)
  end

  # ...
end
複製程式碼

接著,我定義了一些輔助函式來從資料庫中獲取帖子。在 Ecto model 的幫助下,Repo 模組用來處理所有資料庫查詢;例如,Repo.get(Post, 123) 會使用 ID 123 查詢對應的帖子。search 方法中的資料庫查詢語法由寫在類頂部的 import Ecto.Query 提供。最後,__MODULE__ 是對當前模組的簡寫(即 Socializer.Post)。

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  def create(attrs) do
    attrs
    |> changeset()
    |> Repo.insert()
  end

  def changeset(attrs) do
    %__MODULE__{}
    |> changeset(attrs)
  end

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> validate_required([:body, :user_id])
    |> foreign_key_constraint(:user_id)
  end
end
複製程式碼

Changeset 方法是 Ecto 提供的建立和更新記錄的方法:首先是一個 Post 結構(來自現有的帖子或者一個空結構),“強制轉換”(應用)已更改的屬性,進行必要的驗證,然後將其插入到資料庫中。

這是我們的第一個 model。你可以在這裡找到其它 model。

GraphQL schema

接下來,我連線了伺服器的 GraphQL 元件。這些元件通常可以分為兩類:type(型別)和 resolver(解析器)。在 type 檔案中,你使用類似 DSL 的語法來宣告可以查詢的物件、欄位和關係。Resolver 用來告訴伺服器如何響應任何給定查詢。

下面是帖子 type 檔案的示例:

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  use Absinthe.Schema.Notation
  use Absinthe.Ecto, repo: Socializer.Repo

  alias SocializerWeb.Resolvers

  @desc "A post on the site"
  object :post do
    field :id, :id
    field :body, :string
    field :inserted_at, :naive_datetime

    field :user, :user, resolve: assoc(:user)

    field :comments, list_of(:comment) do
      resolve(
        assoc(:comments, fn comments_query, _args, _context ->
          comments_query |> order_by(desc: :id)
        end)
      )
    end
  end

  # ...
end
複製程式碼

useimport 之後,我們首先為 GraphQL 簡單地定義了 :post 物件。欄位 ID、內容和 inserted_at 將直接使用 Post 結構中的值。接下來,我們宣告瞭一些可以在查詢帖子時使用到的關聯關係 —— 建立帖子的使用者和帖子上的評論。我重寫了評論的關聯關係只是為了確保我們可以得到按照插入順序返回的評論。注意啦:Absinthe 自動處理了請求和查詢欄位名稱的大小寫 —— Elixir 中使用 snake_case 對變數和方法命名,而 GraphQL 的查詢中使用的是 camelCase。

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_queries do
    @desc "Get all posts"
    field :posts, list_of(:post) do
      resolve(&Resolvers.PostResolver.list/3)
    end

    @desc "Get a specific post"
    field :post, :post do
      arg(:id, non_null(:id))
      resolve(&Resolvers.PostResolver.show/3)
    end
  end

  # ...
end
複製程式碼

接下來,我們將宣告一些涉及帖子的底層查詢。posts 允許查詢網站上的所有帖子,同時 post 可以按照 ID 返回單個帖子。Type 檔案只是簡單地宣告瞭查詢語句以及它的引數和返回值型別;實際的實現都被委託給了 resolver。

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_mutations do
    @desc "Create post"
    field :create_post, :post do
      arg(:body, non_null(:string))

      resolve(&Resolvers.PostResolver.create/3)
    end
  end

  # ...
end
複製程式碼

在查詢之後,我們宣告瞭一個允許在網站上建立新帖子的 mutation。與查詢一樣,type 檔案只是宣告有關 mutation 的後設資料,實際操作由 resolver 完成。

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_subscriptions do
    field :post_created, :post do
      config(fn _, _ ->
        {:ok, topic: "posts"}
      end)

      trigger(:create_post,
        topic: fn _ ->
          "posts"
        end
      )
    end
  end
end
複製程式碼

最後,我們宣告與帖子相關的 subscription,:post_created。這允許客戶端訂閱和接收建立新帖子的更新。config 用於配置 subscription,同時 trigger 會告訴 Absinthe 應該呼叫哪一個 mutation。topic 允許你可以細分這些 subscription 的響應 —— 在這個例子中,不管是什麼帖子的更新我們都希望通知客戶端,在另外一些例子中,我們只想要通知某些特定的更新。例如,下面是關於評論的 subscription —— 客戶端只想要知道關於某個特定帖子(而不是所有帖子)的新評論,因此它提供了一個帶 post_id 引數的 topic。

defmodule SocializerWeb.Schema.CommentTypes do
  # ...

  object :comment_subscriptions do
    field :comment_created, :comment do
      arg(:post_id, non_null(:id))

      config(fn args, _ ->
        {:ok, topic: args.post_id}
      end)

      trigger(:create_comment,
        topic: fn comment ->
          comment.post_id
        end
      )
    end
  end
end
複製程式碼

雖然我已經將和每個 model 相關的程式碼按照不同的功能寫在了不同的檔案裡,但值得注意的是,Absinthe 要求你在一個單獨的 Schema 模組中組裝所有型別的檔案。如下面所示:

defmodule SocializerWeb.Schema do
  use Absinthe.Schema
  import_types(Absinthe.Type.Custom)

  import_types(SocializerWeb.Schema.PostTypes)
  # ...other models' types

  query do
    import_fields(:post_queries)
    # ...other models' queries
  end

  mutation do
    import_fields(:post_mutations)
    # ...other models' mutations
  end

  subscription do
    import_fields(:post_subscriptions)
    # ...other models' subscriptions
  end
end
複製程式碼

Resolver(解析器)

正如我上面提到的,resolver 是 GraphQL 伺服器的“粘合劑” —— 它們包含為 query 提供資料的邏輯或應用 mutation 的邏輯。讓我們看一下 post 的 resolver:

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  alias Socializer.Post

  def list(_parent, _args, _resolutions) do
    {:ok, Post.all()}
  end

  def show(_parent, args, _resolutions) do
    case Post.find(args[:id]) do
      nil -> {:error, "Not found"}
      post -> {:ok, post}
    end
  end

  # ...
end
複製程式碼

前兩個方法處理上面定義的兩個查詢 —— 載入所有的帖子的查詢以及載入特定帖子的查詢。Absinthe 希望每個 resolver 方法都返回一個元組 —— {:ok, requested_data} 或者 {:error, some_error}(這是 Elixir 方法的常見模式)。show 方法中的 case 宣告是 Elixir 中一個很好的模式匹配的例子 —— 如果 Post.find 返回 nil,我們返回錯誤元組;否則,我們返回找到的帖子資料。

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  # ...

  def create(_parent, args, %{
        context: %{current_user: current_user}
      }) do
    args
    |> Map.put(:user_id, current_user.id)
    |> Post.create()
    |> case do
      {:ok, post} ->
        {:ok, post}

      {:error, changeset} ->
        {:error, extract_error_msg(changeset)}
    end
  end

  def create(_parent, _args, _resolutions) do
    {:error, "Unauthenticated"}
  end

  # ...
end
複製程式碼

接下來,我們有 create 的 resolver,其中包含建立新帖子的邏輯。這也是通過方法引數進行模式匹配的一個很好的例子 —— Elixir 允許你過載方法名稱並選擇第一個與宣告的模式匹配的方法。在這個例子中,如果第三個引數是帶有 context 鍵的對映,並且該對映中還包括一個帶有 current_user 鍵值對的對映,那麼就使用第一個方法;如果某個查詢沒有攜帶身份驗證資訊,它將匹配第二種方法並返回錯誤資訊。

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  # ...

  defp extract_error_msg(changeset) do
    changeset.errors
    |> Enum.map(fn {field, {error, _details}} ->
      [
        field: field,
        message: String.capitalize(error)
      ]
    end)
  end
end
複製程式碼

最後,如果 post 的屬性無效(例如,內容為空),我們有一個簡單的輔助方法來返回錯誤響應。Absinthe 希望錯誤訊息是一個字串,一個字串陣列,或一個帶有 fieldmessage 鍵的關鍵字列表陣列 —— 在我們的例子中,我們將每個欄位的 Ecto 驗證錯誤資訊提取到這樣的關鍵字列表中。

上下文(context)/認證(authentication)

我們在最後一節中來談談查詢認證的概念 —— 在我們的例子中,簡單地在請求頭裡的 authorization 屬性中用了一個 Bearer: token 做標記。我們如何利用這個 token 獲取 resolver 中 current_user 的上下文呢?可以使用自定義外掛(plug)讀取頭部然後查詢當前使用者。在 Phoenix 中,一個外掛是請求管道中的一部分 —— 你可能擁有解碼 JSON 的外掛,新增 CORS 頭的外掛,或者處理請求的任何其他可組合部分的外掛。我們的外掛如下所示:

# lib/socializer_web/context.ex
defmodule SocializerWeb.Context do
  @behaviour Plug

  import Plug.Conn

  alias Socializer.{Guardian, User}

  def init(opts), do: opts

  def call(conn, _) do
    context = build_context(conn)
    Absinthe.Plug.put_options(conn, context: context)
  end

  def build_context(conn) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, claim} <- Guardian.decode_and_verify(token),
         user when not is_nil(user) <- User.find(claim["sub"]) do
      %{current_user: user}
    else
      _ -> %{}
    end
  end
end
複製程式碼

前兩個方法只是按例行事 —— 在初始化方法中沒有什麼有趣的事情可做(在我們的例子中,我們可能會基於配置選項利用初始化函式做一些工作),在呼叫外掛方法中,我們只是想要在請求上下文中設定當前使用者的資訊。build_context 方法是最有趣的部分。with 宣告在 Elixir 中是另一種模式匹配的寫法;它允許你執行一系列不對稱步驟並根據上一步的結果執行操作。在我們的例子中,首先去獲得請求頭裡的 authorization 屬性值;然後解碼 authentication token(使用了 Guardian 庫);接著再去查詢使用者。如果所有步驟都成功了,那麼我們將進入 with 函式塊內部,返回一個包含當前使用者資訊的對映。如果任意一個步驟失敗(例如,假設模式匹配失敗第二步會返回一個 {:error, ...} 元組;假設使用者不存在第三步會返回一個 nil),然後 else 程式碼塊中的內容被執行,我們就不去設定當前使用者。



如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章