天天看點

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

  • 原文位址:Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part 1
  • 原文作者:Zach Schneider
  • 譯文出自:掘金翻譯計劃
  • 本文永久連結:github.com/xitu/gold-m…
  • 譯者:Xuyuey
  • 校對者:Fengziyin1234

不知道你是否和我一樣,在本文的标題中,至少有 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: ) {
    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
複制代碼
           

use

import

之後,我們首先為 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/)
    end

    @desc "Get a specific post"
    field :post, :post do
      arg(:id, non_null(:id))
      resolve(&Resolvers.PostResolver.show/)
    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/)
    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 希望錯誤消息是一個字元串,一個字元串數組,或一個帶有

field

message

鍵的關鍵字清單數組 —— 在我們的例子中,我們将每個字段的 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

代碼塊中的内容被執行,我們就不去設定目前使用者。

  • Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第一部分
  • Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第二部分
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改并 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。内容覆寫 Android、iOS、前端、後端、區塊鍊、産品、設計、人工智能等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微網誌、知乎專欄。

轉載于:https://juejin.im/post/5cce99d3518825405a198e52