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

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

定義咱們的術語

首先,讓咱們來看看我在上面提到的那些組件,以及它們如何組合在一塊兒。前端

  • 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 還有聊天功能; 用戶能夠與其餘用戶進行私人對話,每一個對話能夠有任意數量的用戶(即羣聊)。react

爲何選擇 Elixir?

Elixir 在過去幾年中愈來愈流行。它在 Erlang VM 上運行,你能夠直接在 Elixir 文件中寫 Erlang 語法,但它旨在爲開發人員提供更友好的語法,同時保持 Erlang 的速度和容錯能力。Elixir 是動態類型的,語法與 ruby 相似。可是它比 ruby 更具功能性,而且有不少不一樣的慣用語法和模式。android

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

Elixir 還有其餘好處。它的設計是容錯的;你能夠將 Erlang VM 視爲一個節點集羣,任何一個節點的宕機均可以不影響其餘節點。這也使「熱代碼交換」成爲可能,部署新代碼時無需中止和重啓應用程序。我發現它的模式匹配(pattern matching)管道操做符(pipe operator)也很是有意思。使人耳目一新的是,它在編寫功能強大的代碼時,近乎和 ruby 同樣給力,並且我發現它能夠驅使我更清楚地思考代碼,寫更少的 bug。git

爲何選擇 GraphQL?

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

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 提供的結構。web

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索