[译] 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索