成人国产在线小视频_日韩寡妇人妻调教在线播放_色成人www永久在线观看_2018国产精品久久_亚洲欧美高清在线30p_亚洲少妇综合一区_黄色在线播放国产_亚洲另类技巧小说校园_国产主播xx日韩_a级毛片在线免费

資訊專欄INFORMATION COLUMN

[譯] Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo

Cympros / 2677人閱讀

摘要:對(duì)于每個(gè)案例,我們插入所需要的測(cè)試數(shù)據(jù),調(diào)用需要測(cè)試的函數(shù)并對(duì)結(jié)果作出斷言。我們將這個(gè)套接字和用戶返回以供我們其他的測(cè)試使用。


原文地址:Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part 2

原文作者:Zach Schneider

譯文出自:掘金翻譯計(jì)劃

本文永久鏈接:github.com/xitu/gold-m…

譯者:Fengziyin1234

校對(duì)者:Xuyuey, portandbridge

如果你沒有看過本系列文章的第一部分,建議你先去看第一部分:

Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實(shí)踐 —— 第一部分

Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實(shí)踐 —— 第二部分

測(cè)試 —— 服務(wù)器端

現(xiàn)在我們已經(jīng)完成了所有的代碼部分,那我們?nèi)绾未_保我的代碼總能正常的工作呢?我們需要對(duì)下面幾種不同的層次進(jìn)行測(cè)試。首先,我們需要對(duì) model 層進(jìn)行單元測(cè)試 —— 這些 model 是否能正確的驗(yàn)證(數(shù)據(jù))?這些 model 的 helper 函數(shù)是否能返回預(yù)期的結(jié)果?第二,我們需要對(duì) resolver 層進(jìn)行單元測(cè)試 —— resolver 是否能處理不同的(成功和失?。┑那闆r?是否能返回正確的結(jié)果或者根據(jù)結(jié)果作出正確的數(shù)據(jù)庫更新?第三,我們應(yīng)該編寫一些完整的 integration test(集成測(cè)試),例如發(fā)送向服務(wù)器一個(gè)查詢請(qǐng)求并期待返回正確的結(jié)果。這可以讓我們更好地從全局上把控我們的應(yīng)用,并且確保這些測(cè)試涵蓋認(rèn)證邏輯等案例。第四,我們希望對(duì)我們的 subscription 層進(jìn)行測(cè)試 —— 當(dāng)相關(guān)的變化發(fā)生時(shí),它們可否可以正確地通知套接字。

Elixir 有一個(gè)非常基本的內(nèi)置測(cè)試庫,叫做 ExUnit。ExUnit 包括簡單的 assert/refute 函數(shù),也可以幫助你運(yùn)行你的測(cè)試。在 Phoenix 中建立一系列 “case” support 文件的方法也很常見。這些文件在測(cè)試中被引用,用于運(yùn)行常見的初始化任務(wù),例如連接數(shù)據(jù)庫。此外,在我的測(cè)試中,我發(fā)現(xiàn) ex_spec 和 ex_machina 這兩個(gè)庫非常有幫助。ex_spec 加入了簡單的 describeit,對(duì)于有 ruby 相關(guān)背景的我來說,ex_spec 可以讓編寫測(cè)試所用的語法更加的友好。ex_machina 提供了函數(shù)工廠(factory),這些函數(shù)工廠可以讓動(dòng)態(tài)插入測(cè)試數(shù)據(jù)變得更簡單。

我創(chuàng)建的函數(shù)工廠長這樣:

# test/support/factories.ex
defmodule Socializer.Factory do
  use ExMachina.Ecto, repo: Socializer.Repo

  def user_factory do
    %Socializer.User{
      name: Faker.Name.name(),
      email: Faker.Internet.email(),
      password: "password",
      password_hash: Bcrypt.hash_pwd_salt("password")
    }
  end

  def post_factory do
    %Socializer.Post{
      body: Faker.Lorem.paragraph(),
      user: build(:user)
    }
  end

  # ...factories for other models
end

在環(huán)境的搭建中導(dǎo)入函數(shù)工廠后,你就可以在測(cè)試案例中使用一些非常直觀的語法了:

# Insert a user
user = insert(:user)

# Insert a user with a specific name
user_named = insert(:user, name: "John Smith")

# Insert a post for the user
post = insert(:post, user: user)

在搭建完成后,你的 Post model 長這樣:

# test/socializer/post_test.exs
defmodule Socializer.PostTest do
  use SocializerWeb.ConnCase

  alias Socializer.Post

  describe "#all" do
    it "finds all posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      results = Post.all()
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#find" do
    it "finds post" do
      post = insert(:post)
      found = Post.find(post.id)
      assert found.id == post.id
    end
  end

  describe "#create" do
    it "creates post" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      {:ok, post} = Post.create(valid_attrs)
      assert post.body == "New discussion"
    end
  end

  describe "#changeset" do
    it "validates with correct attributes" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      changeset = Post.changeset(%Post{}, valid_attrs)
      assert changeset.valid");end

    it "does not validate with missing attrs" do
      changeset =
        Post.changeset(
          %Post{},
          %{}
        )

      refute changeset.valid");end
  end
end

這個(gè)測(cè)試案例很直觀。對(duì)于每個(gè)案例,我們插入所需要的測(cè)試數(shù)據(jù),調(diào)用需要測(cè)試的函數(shù)并對(duì)結(jié)果作出斷言(assertion)。

接下來,讓我們一起看一下下面這個(gè) resolver 的測(cè)試案例:

# test/socializer_web/resolvers/post_resolver_test.exs
defmodule SocializerWeb.PostResolverTest do
  use SocializerWeb.ConnCase

  alias SocializerWeb.Resolvers.PostResolver

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      {:ok, results} = PostResolver.list(nil, nil, nil)
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#show" do
    it "returns specific post" do
      post = insert(:post)
      {:ok, found} = PostResolver.show(nil, %{id: post.id}, nil)
      assert found.id == post.id
    end

    it "returns not found when post does not exist" do
      {:error, error} = PostResolver.show(nil, %{id: 1}, nil)
      assert error == "Not found"
    end
  end

  describe "#create" do
    it "creates valid post with authenticated user" do
      user = insert(:user)

      {:ok, post} =
        PostResolver.create(nil, %{body: "Hello"}, %{
          context: %{current_user: user}
        })

      assert post.body == "Hello"
      assert post.user_id == user.id
    end

    it "returns error for missing params" do
      user = insert(:user)

      {:error, error} =
        PostResolver.create(nil, %{}, %{
          context: %{current_user: user}
        })

      assert error == [[field: :body, message: "Can"t be blank"]]
    end

    it "returns error for unauthenticated user" do
      {:error, error} = PostResolver.create(nil, %{body: "Hello"}, nil)

      assert error == "Unauthenticated"
    end
  end
end

對(duì)于 resolver 的測(cè)試也相當(dāng)?shù)暮唵?—— 它們也是單元測(cè)試,運(yùn)行于 model 之上的一層。這里我們插入任意的測(cè)試數(shù)據(jù),調(diào)用所測(cè)試的 resolver,然后期待正確的結(jié)果被返回。

集成測(cè)試有一點(diǎn)點(diǎn)小復(fù)雜。我們首先需要建立和服務(wù)器端的連接(可能需要認(rèn)證),接著發(fā)送一個(gè)查詢語句并且確保我們得到正確的結(jié)果。我找到了這篇帖子,它對(duì)學(xué)習(xí)如何為 Absinthe 構(gòu)建集成測(cè)試非常有幫助。

首先,我們建立一個(gè) helper 文件,這個(gè)文件將包含一些進(jìn)行集成測(cè)試所需要的常見功能:

# test/support/absinthe_helpers.ex
defmodule Socializer.AbsintheHelpers do
  alias Socializer.Guardian

  def authenticate_conn(conn, user) do
    {:ok, token, _claims} = Guardian.encode_and_sign(user)
    Plug.Conn.put_req_header(conn, "authorization", "Bearer #{token}")
  end

  def query_skeleton(query, query_name) do
    %{
      "operationName" => "#{query_name}",
      "query" => "query #{query_name} #{query}",
      "variables" => "{}"
    }
  end

  def mutation_skeleton(query) do
    %{
      "operationName" => "",
      "query" => "mutation #{query}",
      "variables" => ""
    }
  end
end

這個(gè)文件里包括了三個(gè) helper 函數(shù)。第一個(gè)函數(shù)接受一個(gè)連接對(duì)象和一個(gè)用戶對(duì)象作為參數(shù),通過在 HTTP 的 header 中加入已認(rèn)證的用戶 token 來認(rèn)證連接。第二個(gè)和第三個(gè)函數(shù)都接受一個(gè)查詢語句作為參數(shù),當(dāng)你通過網(wǎng)絡(luò)連接發(fā)送查詢語句給服務(wù)器時(shí),這兩個(gè)函數(shù)會(huì)返回一個(gè)包含該查詢語句結(jié)果在內(nèi)的 JSON 結(jié)構(gòu)對(duì)象。

然后回到測(cè)試本身:

# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  use SocializerWeb.ConnCase
  alias Socializer.AbsintheHelpers

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)

      query = """
      {
        posts {
          id
          body
        }
      }
      """

      res =
        build_conn()
        |> post("/graphiql", AbsintheHelpers.query_skeleton(query, "posts"))

      posts = json_response(res, 200)["data"]["posts"]
      assert List.first(posts)["id"] == to_string(post_b.id)
      assert List.last(posts)["id"] == to_string(post_a.id)
    end
  end

  # ...
end

這個(gè)測(cè)試案例,通過查詢來得到一組帖子信息的方式來測(cè)試我們的終端。我們首先在數(shù)據(jù)庫中插入一些帖子的記錄,然后寫一個(gè)查詢語句,接著通過 POST 方法將語句發(fā)送給服務(wù)器,最后檢查服務(wù)器的回復(fù),確保返回的結(jié)果符合預(yù)期。

這里還有一個(gè)非常相似的案例,測(cè)試是否能查詢得到單個(gè)帖子信息。這里我們就不再贅述(如果你想了解所有的集成測(cè)試,你可以查看這里)。下面讓我們看一下為創(chuàng)建帖子的 Mutation 所做的的集成測(cè)試。

# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  # ...

  describe "#create" do
    it "creates post" do
      user = insert(:user)

      mutation = """
      {
        createPost(body: "A few thoughts") {
          body
          user {
            id
          }
        }
      }
      """

      res =
        build_conn()
        |> AbsintheHelpers.authenticate_conn(user)
        |> post("/graphiql", AbsintheHelpers.mutation_skeleton(mutation))

      post = json_response(res, 200)["data"]["createPost"]
      assert post["body"] == "A few thoughts"
      assert post["user"]["id"] == to_string(user.id)
    end
  end
end

非常相似,只有兩點(diǎn)不同 —— 這次我們是通過 AbsintheHelpers.authenticate_conn(user) 將用戶的 token 加入頭字段的方式來建立連接,并且我們調(diào)用的是 mutation_skeleton,而非之前的 query_skeleton

那對(duì)于 subscription 的測(cè)試呢?對(duì)于 subscription 的測(cè)試也需要通過一些基本的搭建,來建立一個(gè)套接字連接,然后就可以建立并測(cè)試我們的 subscription。我找到了這篇文章,它對(duì)我們理解如何為 subscription 構(gòu)建測(cè)試非常有幫助。

首先,我們建立一個(gè)新的 case 文件來為 subscription 的測(cè)試做基本的搭建。代碼長這樣:

# test/support/subscription_case.ex
defmodule SocializerWeb.SubscriptionCase do
  use ExUnit.CaseTemplate

  alias Socializer.Guardian

  using do
    quote do
      use SocializerWeb.ChannelCase
      use Absinthe.Phoenix.SubscriptionTest, schema: SocializerWeb.Schema
      use ExSpec
      import Socializer.Factory

      setup do
        user = insert(:user)

        # When connecting to a socket, if you pass a token we will set the context"s `current_user`
        params = %{
          "token" => sign_auth_token(user)
        }

        {:ok, socket} = Phoenix.ChannelTest.connect(SocializerWeb.AbsintheSocket, params)
        {:ok, socket} = Absinthe.Phoenix.SubscriptionTest.join_absinthe(socket)

        {:ok, socket: socket, user: user}
      end

      defp sign_auth_token(user) do
        {:ok, token, _claims} = Guardian.encode_and_sign(user)
        token
      end
    end
  end
end

在一些常見的導(dǎo)入后,我們定義一個(gè) setup 的步驟。這一步會(huì)插入一個(gè)新的用戶,并通過這個(gè)用戶的 token 來建立一個(gè) websocket 連接。我們將這個(gè)套接字和用戶返回以供我們其他的測(cè)試使用。

下一步,讓我們一起來看一看測(cè)試本身:

defmodule SocializerWeb.PostSubscriptionsTest do
  use SocializerWeb.SubscriptionCase

  describe "Post subscription" do
    it "updates on new post", %{socket: socket} do
      # Query to establish the subscription.
      subscription_query = """
        subscription {
          postCreated {
            id
            body
          }
        }
      """

      # Push the query onto the socket.
      ref = push_doc(socket, subscription_query)

      # Assert that the subscription was successfully created.
      assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

      # Query to create a new post to invoke the subscription.
      create_post_mutation = """
        mutation CreatePost {
          createPost(body: "Big discussion") {
            id
            body
          }
        }
      """

      # Push the mutation onto the socket.
      ref =
        push_doc(
          socket,
          create_post_mutation
        )

      # Assert that the mutation successfully created the post.
      assert_reply(ref, :ok, reply)
      data = reply.data["createPost"]
      assert data["body"] == "Big discussion"

      # Assert that the subscription notified us of the new post.
      assert_push("subscription:data", push)
      data = push.result.data["postCreated"]
      assert data["body"] == "Big discussion"
    end
  end
end

首先,我們先寫一個(gè) subscription 的查詢語句,并且推送到我們?cè)谏弦徊揭呀?jīng)建立好的套接字上。接著,我們寫一個(gè)會(huì)觸發(fā) subscription 的 mutation 語句(例如,創(chuàng)建一個(gè)新帖子)并推送到套接字上。最后,我們檢查 push 的回復(fù),并斷言一個(gè)帖子的被新建的更新信息將被推送給我們。這其中設(shè)計(jì)了更多的前期搭建,但這也讓我們對(duì) subscription 的生命周期的建立的更好的集成測(cè)試。

客戶端

以上就是對(duì)服務(wù)端所發(fā)生的一切的大致的描述 —— 服務(wù)器通過在 types 中定義,在 resolvers 中實(shí)現(xiàn),在 model 查詢和固化(persist)數(shù)據(jù)的方法來處理 GraphQL 查詢語句。接下來,讓我們一起來看一看客戶端是如何建立的。

我們首先使用 create-react-app,這是從 0 到 1 搭建 React 項(xiàng)目的好方法 —— 它會(huì)搭建一個(gè) “hello world” React 應(yīng)用,包含默認(rèn)的設(shè)定和結(jié)構(gòu),并且簡化了大量配置。

這里我使用了 React Router 來實(shí)現(xiàn)應(yīng)用的路由;它將允許用戶在帖子列表頁面、單一帖子頁面和聊天頁面等進(jìn)行瀏覽。我們的應(yīng)用的根組件應(yīng)該長這樣:

// client/src/App.js
import React, { useRef } from "react";
import { ApolloProvider } from "react-apollo";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import { createClient } from "util/apollo";
import { Meta, Nav } from "components";
import { Chat, Home, Login, Post, Signup } from "pages";

const App = () => {
  const client = useRef(createClient());

  return (
    
      
        
        

幾個(gè)值得注意的點(diǎn) —— util/apollo 這里對(duì)外輸出了一個(gè) createClient 函數(shù)。這個(gè)函數(shù)會(huì)創(chuàng)建并返回一個(gè) Apollo 客戶端的實(shí)例(我們將在下文中進(jìn)行著重地介紹)。將 createClient 包裝在 useRef 中,就能讓該實(shí)例在應(yīng)用的生命周期內(nèi)(即,所有的 rerenders)中均可使用。ApolloProvider 這個(gè)高階組件會(huì)使 client 可以在所有子組件/查詢的 context 中使用。在我們?yōu)g覽該應(yīng)用的過程中,BrowserRouter 使用 HTML5 的 history API 來保持 URL 的狀態(tài)同步。

這里的 SwitchRoute 需要多帶帶進(jìn)行討論。React Router 是圍繞動(dòng)態(tài)路由的概念建立的。大部分的網(wǎng)站使用靜態(tài)路由,也就是說你的 URL 將匹配唯一的路由,并且根據(jù)所匹配的路由來渲染一整個(gè)頁面。使用動(dòng)態(tài)路由,路由將被分布到整個(gè)應(yīng)用中,一個(gè) URL 可以匹配多個(gè)路由。這聽起來可能有些令人困惑,但事實(shí)上,當(dāng)你掌握了它以后,你會(huì)覺得它非常棒。它可以輕松地構(gòu)建一個(gè)包含不同組件頁面,這些組件可以對(duì)路由的不同部分做出反應(yīng)。例如,想象一個(gè)類似臉書的 messenger 的頁面(Socializer 的聊天界面也非常相似)—— 左邊是對(duì)話的列表,右邊是所選擇的對(duì)話。動(dòng)態(tài)路由允許我這樣表達(dá):

const App = () => {
  return (
    // ...
    "/chat/:id"); component={Chat} />
    // ...
  );
};

const Chat = () => {
  return (
    
); };

如果路徑以 /chat 開頭(可能以 ID 結(jié)尾,例如,/chat/123),根層次的 App 會(huì)渲染 Chat 組件。Chat 會(huì)渲染對(duì)話列表欄(對(duì)話列表欄總是可見的),然后會(huì)渲染它的路由,如果路徑有 ID,則顯示一個(gè) Conversation 組件,否則就會(huì)顯示 EmptyState(請(qǐng)注意,如果缺少了 ");,那么 :id 參數(shù)就不再是可選參數(shù))。這就是動(dòng)態(tài)路由的力量 —— 它讓你可以基于當(dāng)前的 URL 漸進(jìn)地渲染界面的不同組件,將基于路徑的問題本地化到相關(guān)的組件中。

即使使用了動(dòng)態(tài)路由,有時(shí)你也只想要渲染一條路徑(類似于傳統(tǒng)的靜態(tài)路由)。這時(shí) Switch 組件就登上了舞臺(tái)。如果沒有 Switch,React Router 會(huì)渲染每一個(gè)匹配當(dāng)前 URL 的組件,那么在上面的 Chat 組件中,我們就會(huì)既有 Conversation 組件,又有 EmptyState 組件。Switch 會(huì)告訴 React Router,讓它只渲染第一個(gè)匹配當(dāng)前 URL 的路由并忽視掉其它的。

Apollo 客戶端

現(xiàn)在,讓我們更進(jìn)一步,深入了解一下 Apollo 的客戶端 —— 特別是上文已經(jīng)提及的 createClient 函數(shù)。util/apollo.js 文件長這樣:

// client/src/util.apollo.js
import ApolloClient from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import * as AbsintheSocket from "@absinthe/socket";
import { createAbsintheSocketLink } from "@absinthe/socket-apollo-link";
import { Socket as PhoenixSocket } from "phoenix";
import { createHttpLink } from "apollo-link-http";
import { hasSubscription } from "@jumpn/utils-graphql";
import { split } from "apollo-link";
import { setContext } from "apollo-link-context";
import Cookies from "js-cookie";

const HTTP_URI =
  process.env.NODE_ENV === "production"
    ");"https://brisk-hospitable-indianelephant.gigalixirapp.com"
    : "http://localhost:4000";

const WS_URI =
  process.env.NODE_ENV === "production"
    ");"wss://brisk-hospitable-indianelephant.gigalixirapp.com/socket"
    : "ws://localhost:4000/socket";

// ...

開始很簡單,導(dǎo)入一堆我們接下來需要用到的依賴,并且根據(jù)當(dāng)前的環(huán)境,將 HTTP URL 和 websocket URL 設(shè)置為常量 —— 在 production 環(huán)境中指向我的 Gigalixir 實(shí)例,在 development 環(huán)境中指向 localhost。

// client/src/util.apollo.js
// ...

export const createClient = () => {
  // Create the basic HTTP link.
  const httpLink = createHttpLink({ uri: HTTP_URI });

  // Create an Absinthe socket wrapped around a standard
  // Phoenix websocket connection.
  const absintheSocket = AbsintheSocket.create(
    new PhoenixSocket(WS_URI, {
      params: () => {
        if (Cookies.get("token")) {
          return { token: Cookies.get("token") };
        } else {
          return {};
        }
      },
    }),
  );

  // Use the Absinthe helper to create a websocket link around
  // the socket.
  const socketLink = createAbsintheSocketLink(absintheSocket);

  // ...
});

Apollo 的客戶端要求你提供一個(gè)鏈接 —— 本質(zhì)上說,就是你的 Apollo 客戶端所請(qǐng)求的 GraphQL 服務(wù)器的連接。通常有兩種類型的鏈接 —— HTTP 鏈接,通過標(biāo)準(zhǔn)的 HTTP 來向 GraphQL 服務(wù)器發(fā)送請(qǐng)求,和 websocket 鏈接,開放一個(gè) websocket 連接并通過套接字來發(fā)送請(qǐng)求。在我們的例子中,我們兩種都使用了。對(duì)于通常的 query 和 mutation,我們將使用 HTTP 鏈接,對(duì)于 subscription,我們將使用 websocket 鏈接。

// client/src/util.apollo.js
export const createClient = () => {
  //...

  // Split traffic based on type -- queries and mutations go
  // through the HTTP link, subscriptions go through the
  // websocket link.
  const splitLink = split(
    (operation) => hasSubscription(operation.query),
    socketLink,
    httpLink,
  );

  // Add a wrapper to set the auth token (if any) to the
  // authorization header on HTTP requests.
  const authLink = setContext((_, { headers }) => {
    // Get the authentication token from the cookie if it exists.
    const token = Cookies.get("token");

    // Return the headers to the context so httpLink can read them.
    return {
      headers: {
        ...headers,
        authorization: token ");`Bearer ${token}` : "",
      },
    };
  });

  const link = authLink.concat(splitLink);

  // ...
};

Apollo 提供了 split 函數(shù),它可以讓你根據(jù)你選擇的標(biāo)準(zhǔn),將不同的查詢請(qǐng)求路由到不同的鏈接上 —— 你可以把它想成一個(gè)三項(xiàng)式:如果請(qǐng)求有 subscription,就通過套接字鏈接來發(fā)送,其他情況(Query 或者 Mutation)則使用 HTTP 鏈接傳送。

如果用戶已經(jīng)登陸,我們可能還需要給兩個(gè)鏈接都提供認(rèn)證。當(dāng)用戶登陸以后,我們將其認(rèn)證令牌設(shè)置到 token 的 cookie 中(下文會(huì)詳細(xì)介紹)。與 Phoenix 建立 websocket 連接時(shí),我們使用token 作為參數(shù),在 HTTP 鏈接中,這里我們使用 setContext 包裝器,將token 設(shè)置在請(qǐng)求的頭字段中。

// client/src/util.apollo.js
export const createClient = () => {
  // ...

  return new ApolloClient({
    cache: new InMemoryCache(),
    link,
  });
});

如上所示,除了鏈接以外,一個(gè) Apollo 的客戶端還需要一個(gè)緩存的實(shí)例。GraphQL 會(huì)自動(dòng)緩存請(qǐng)求的結(jié)果來避免對(duì)相同的數(shù)據(jù)進(jìn)行重復(fù)請(qǐng)求。基本的 InMemoryCache 已經(jīng)可以適用大部分的用戶案例了 —— 它就是將查詢的數(shù)據(jù)存在瀏覽器的本地狀態(tài)中。

客戶端的使用 —— 我們的第一個(gè)請(qǐng)求

好噠,我們已經(jīng)搭建好了 Apollo 的客戶端實(shí)例,并且通過 ApolloProvider 的高階函數(shù)讓這個(gè)實(shí)例在整個(gè)應(yīng)用中都可用。現(xiàn)在讓我們來看一看如何運(yùn)行 query 和 mutation。我們從 Posts 組件開始,Posts 組件將在我們的首頁渲染一個(gè)帖子的列表。

// client/src/components/Posts.js
import React, { Fragment } from "react";
import { Query } from "react-apollo";
import gql from "graphql-tag";
import produce from "immer";
import { ErrorMessage, Feed, Loading } from "components";

export const GET_POSTS = gql`
  {
    posts {
      id
      body
      insertedAt
      user {
        id
        name
        gravatarMd5
      }
    }
  }
`;

export const POSTS_SUBSCRIPTION = gql`
  subscription onPostCreated {
    postCreated {
      id
      body
      insertedAt
      user {
        id
        name
        gravatarMd5
      }
    }
  }
`;

// ...

首先是各種庫的引入,接著我們需要為我們想要渲染的帖子寫一些查詢。這里有兩個(gè) —— 首先是一個(gè)基礎(chǔ)的獲取帖子列表的 query(也包括帖子作者的信息),然后是一個(gè) subscription,用來告知我們新帖子的出現(xiàn),讓我們可以實(shí)時(shí)地更新屏幕,保證我們的列表處于最新。

// client/src/components/Posts.js
// ...

const Posts = () => {
  return (
    
      

Feed

{({ loading, error, data, subscribeToMore }) => { if (loading) return ; if (error) return ; return ( subscribeToMore({ document: POSTS_SUBSCRIPTION, updateQuery: (prev, { subscriptionData }) => { if (!subscriptionData.data) return prev; const newPost = subscriptionData.data.postCreated; return produce(prev, (next) => { next.posts.unshift(newPost); }); }, }) } /> ); }}
); };

現(xiàn)在我們將實(shí)現(xiàn)真正的組件部分。首先,執(zhí)行基本的查詢,我們先渲染 Apollo 的 。它給它的子組件提供了一些渲染的 props —— loadingerror,datasubscribeToMore。如果查詢正在加載,我們就渲染一個(gè)簡單的加載圖片。如果有錯(cuò)誤存在,我們渲染一個(gè)通用的 ErrorMessage 組件給用戶。否則,就說明查詢成果,我們就渲染一個(gè) Feed 組件(data.posts 中包含著需要渲染的帖子,結(jié)構(gòu)和 query 中的結(jié)構(gòu)一致)。

subscribeToMore 是一個(gè) Apollo 幫助函數(shù),用于實(shí)現(xiàn)一個(gè)只需要從用戶正在瀏覽的集合中獲取新數(shù)據(jù)的 subscription。它應(yīng)該在子組件的 componentDidMount 階段被渲染,這也是它被作為 props 傳遞給 Feed 的原因 —— 一旦 Feed 被渲染,Feed 負(fù)責(zé)調(diào)用 subscribeToNew。我們給 subscribeToMore 提供了我們的 subscription 查詢和一個(gè) updateQuery 的回調(diào)函數(shù),該函數(shù)會(huì)在 Apollo 接收到新帖子被建立的通知時(shí)被調(diào)用。當(dāng)那發(fā)生時(shí),我們只需要簡單將新帖子推入我們當(dāng)前的帖子數(shù)組,使用 immer 可以返回一個(gè)新數(shù)組來確保組件可以正確地渲染。

認(rèn)證(和 mutation)

現(xiàn)在我們已經(jīng)有了一個(gè)帶帖子列表的首頁啦,這個(gè)首頁還可以實(shí)時(shí)的對(duì)新建的帖子進(jìn)行響應(yīng) —— 那我們應(yīng)該如何新建帖子呢?首先,我們需要允許用戶用他們的賬戶登陸,那么我們就可以把他的賬戶和帖子聯(lián)系起來。我們需要為此寫一個(gè) mutation —— 我們需要將電子郵件和密碼發(fā)送到服務(wù)器,服務(wù)器會(huì)發(fā)送一個(gè)新的認(rèn)證該用戶的令牌。我們從登陸頁面開始:

// client/src/pages/Login.js
import React, { Fragment, useContext, useState } from "react";
import { Mutation } from "react-apollo";
import { Button, Col, Container, Form, Row } from "react-bootstrap";
import Helmet from "react-helmet";
import gql from "graphql-tag";
import { Redirect } from "react-router-dom";
import renderIf from "render-if";
import { AuthContext } from "util/context";

export const LOGIN = gql`
  mutation Login($email: String!, $password: String!) {
    authenticate(email: $email, password: $password) {
      id
      token
    }
  }
`;

第一部分和 query 組件十分相似 —— 我們導(dǎo)入需要的依賴文件,然后完成登陸的 mutation。這個(gè) mutation 接受電子郵件和密碼作為參數(shù),然后我們希望得到認(rèn)證用戶的 ID 和他們的認(rèn)證令牌。

// client/src/pages/Login.js
// ...

const Login = () => {
  const { token, setAuth } = useContext(AuthContext);
  const [isInvalid, setIsInvalid] = useState(false);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  if (token) {
    return <Redirect to="/" />;
  }

  // ...
};

在組件中,我們首先去從 context 中獲取當(dāng)前的 token 和一個(gè)叫 setAuth 的函數(shù)(我們會(huì)在下文中介紹 setAuth)。我們也需要使用 useState 來設(shè)置一些本地的狀態(tài),那樣我們就可以為用戶的電子郵件,密碼以及他們的證書是否有效來存儲(chǔ)臨時(shí)值(這樣我們就可以在表單中顯示錯(cuò)誤狀態(tài))。最后,如果用戶已經(jīng)有了認(rèn)證令牌,說明他們已經(jīng)登陸,那么我們就直接讓他們跳轉(zhuǎn)去首頁。

// client/src/pages/Login.js
// ...

const Login = () => {
  // ...

  return (
    
      
        Socializer | Log in
        
      
       setIsInvalid(true)}>
        {(login, { data, loading, error }) => {
          if (data) {
            const {
              authenticate: { id, token },
            } = data;
            setAuth({ id, token });
          }

          return (
            
              
                
                  
{ e.preventDefault(); login({ variables: { email, password } }); }} > Email address { setEmail(e.target.value); setIsInvalid(false); }} isInvalid={isInvalid} /> {renderIf(error)( Email or password is invalid , )} Password { setPassword(e.target.value); setIsInvalid(false); }} isInvalid={isInvalid} />

這里的代碼看起來很洋氣,但是不要懵 —— 這里大部分的代碼只是為表單做一個(gè) Bootstrap 組件。我們從一個(gè)叫做 Helmet(react-helmet) 組件開始 —— 這是一個(gè)頂層的表單組件(相較而言,Posts 組件只是 Home 頁面渲染的一個(gè)子組件),所以我們希望給他一個(gè)瀏覽器標(biāo)題和一些 metadata。下一步我們來渲染 Mutation 組件,將我們的 mutation 語句傳遞給他。如果 mutation 返回一個(gè)錯(cuò)誤,我們使用 onError 回調(diào)函數(shù)來將狀態(tài)設(shè)為無效,來將錯(cuò)誤顯示在表單中。Mutation 將一個(gè)函數(shù)傳將會(huì)遞給調(diào)用他的子組件(這里是 login),第二個(gè)參數(shù)是和我們從 Query 組件中得到的一樣的數(shù)組。如果 data 存在,那就意味著 mutation 被成功執(zhí)行,那么我們就可以將我們的認(rèn)證令牌和用戶 ID 通過 setAuth 函數(shù)來儲(chǔ)存起來。剩余的部分就是很標(biāo)準(zhǔn)的 React 組件啦 —— 我們渲染 input 并在變化時(shí)更新 state 值,在用戶試圖登陸,而郵件密碼卻無效時(shí)顯示錯(cuò)誤信息。

AuthContext 是干嘛的呢?當(dāng)用戶被成功認(rèn)證后,我們需要將他們的認(rèn)證令牌以某種方式存儲(chǔ)在客戶端。這里 GraphQL 并不能幫上忙,因?yàn)檫@就像是個(gè)雞生蛋問題 —— 發(fā)出請(qǐng)求才能獲取認(rèn)證令牌,而認(rèn)證這個(gè)請(qǐng)求本身就要用到認(rèn)證令牌。我們可以用 Redux 在本地狀態(tài)中來存儲(chǔ)令牌,但如果我只需要儲(chǔ)存這一個(gè)值時(shí),感覺這樣做就太過于復(fù)雜了。我們可以使用 React 的 context API 來將 token 儲(chǔ)存在我們應(yīng)用的根目錄,在需要時(shí)調(diào)用即可。

首先,讓我們建立一個(gè)幫助函數(shù)來幫我們建立和導(dǎo)出 context:

// client/src/util/context.js
import { createContext } from "react";

export const AuthContext = createContext(null);

接下來我們來新建一個(gè) StateProvider 高階函數(shù),這個(gè)函數(shù)會(huì)在應(yīng)用的根組件被渲染 —— 它將幫助我們保存和更新認(rèn)證狀態(tài)。

// client/src/containers/StateProvider.js
import React, { useEffect, useState } from "react";
import { withApollo } from "react-apollo";
import Cookies from "js-cookie";
import { refreshSocket } from "util/apollo";
import { AuthContext } from "util/context";

const StateProvider = ({ client, socket, children }) => {
  const [token, setToken] = useState(Cookies.get("token"));
  const [userId, setUserId] = useState(Cookies.get("userId"));

  // If the token changed (i.e. the user logged in
  // or out), clear the Apollo store and refresh the
  // websocket connection.
  useEffect(() => {
    if (!token) client.clearStore();
    if (socket) refreshSocket(socket);
  }, [token]);

  const setAuth = (data) => {
    if (data) {
      const { id, token } = data;
      Cookies.set("token", token);
      Cookies.set("userId", id);
      setToken(token);
      setUserId(id);
    } else {
      Cookies.remove("token");
      Cookies.remove("userId");
      setToken(null);
      setUserId(null);
    }
  };

  return (
    <AuthContext.Provider value={{ token, userId, setAuth }}>
      {children}
    AuthContext.Provider>
  );
};

export default withApollo(StateProvider);

這里有很多東西。首先,我們?yōu)檎J(rèn)證用戶的 tokenuserId 建立 state。我們通過讀 cookie 來初始化 state,那樣我們就可以在頁面刷新后保證用戶的登陸狀態(tài)。接下來我們實(shí)現(xiàn)了我們的 setAuth 函數(shù)。用 null 來調(diào)用該函數(shù)會(huì)將用戶登出;否則就使用提供的 tokenuserId來讓用戶登陸。不管哪種方法,這個(gè)函數(shù)都會(huì)更新本地的 state 和 cookie。

在同時(shí)使用認(rèn)證和 Apollo websocket link 時(shí)存在一個(gè)很大的難題。我們?cè)诔跏蓟?websocket 時(shí),如果用戶被認(rèn)證,我們就使用令牌,反之,如果用戶登出,則不是用令牌。但是當(dāng)認(rèn)證狀態(tài)發(fā)生變化時(shí),我們需要根據(jù)狀態(tài)重置 websocket 連接來。如果用戶是先登出再登入,我們需要用戶新的令牌來重置 websocket,這樣他們就可以實(shí)時(shí)地接受到需要登陸的活動(dòng)的更新,比如說一個(gè)聊天對(duì)話。如果用戶是先登入再登出,我們則需要將 websocket 重置成未經(jīng)驗(yàn)證狀態(tài),那么他們就不再會(huì)實(shí)時(shí)地接受到他們已經(jīng)登出的賬戶的更新。事實(shí)證明這真的很難 —— 因?yàn)闆]有一個(gè)詳細(xì)記錄的下的解決方案,這花了我好幾個(gè)小時(shí)才解決。我最終手動(dòng)地為套接字實(shí)現(xiàn)了一個(gè)重置函數(shù):

// client/src/util.apollo.js
export const refreshSocket = (socket) => {
  socket.phoenixSocket.disconnect();
  socket.phoenixSocket.channels[0].leave();
  socket.channel = socket.phoenixSocket.channel("__absinthe__:control");
  socket.channelJoinCreated = false;
  socket.phoenixSocket.connect();
};

這個(gè)會(huì)斷開 Phoenix 套接字,將當(dāng)前存在的 Phoenix 頻道留給 GraphQL 更新,創(chuàng)建一個(gè)新的 Phoenix 頻道(和 Abisnthe 創(chuàng)建的默認(rèn)頻道一個(gè)名字),并將這個(gè)頻道標(biāo)記為連接(那樣 Absinthe 會(huì)在連接時(shí)將它重新加入),接著重新連接套接字。在文件中,Phoenix 套接字被配置為在每次連接前動(dòng)態(tài)的在 cookie 中查找令牌,那樣每當(dāng)它重聯(lián)時(shí),它將會(huì)使用新的認(rèn)證狀態(tài)。讓我崩潰的是,對(duì)這樣一個(gè)看著很普通的問題,卻并沒有一個(gè)好的解決方法,當(dāng)然,通過一些手動(dòng)的努力,它工作得還不錯(cuò)。

最后,在我們的 StateProvider 中使用的 useEffect 是調(diào)用 refreshSocket 的地方。第二個(gè)參數(shù) [token]告訴了 React 在每次 token 值變化時(shí),去重新評(píng)估該函數(shù)。如果用戶只是登出,我們也要執(zhí)行 client.clearStore() 函數(shù)來確保 Apollo 客戶端不會(huì)繼續(xù)緩存包含著需要權(quán)限才能得到的數(shù)據(jù)的查詢結(jié)果,比如說用戶的對(duì)話或者消息。

這就大概是客戶端的全部了。你可以查看余下的組件來得到更多的關(guān)于 query,mutation 和 subscription 的例子,當(dāng)然,它們的模式都和我們所提到的大體一致。

測(cè)試 —— 客戶端

讓我們來寫一些測(cè)試,來覆蓋我們的 React 代碼。我們的應(yīng)用內(nèi)置了 jest(create-react-app 默認(rèn)包括它);jest 是針對(duì) JavaScript 的一個(gè)非常簡單和直觀的測(cè)試運(yùn)行器。它也包括了一些高級(jí)功能,比如快照測(cè)試。我們將在我們的第一個(gè)測(cè)試案例里使用它。

我非常喜歡使用 react-testing-library 來寫 React 的測(cè)試案例 —— 它提供了一個(gè)非常簡單的 API,可以幫助你從一個(gè)用戶的角度來渲染和測(cè)試表單(而無需在意組件的具體實(shí)現(xiàn))。此外,它的幫助函數(shù)可以在一定程度上的幫助你確保組件的可讀性,因?yàn)槿绻愕?DOM 節(jié)點(diǎn)很難訪問,那么你也很難通過直接操控 DOM 節(jié)點(diǎn)來與之交互(例如給文本提供正確的標(biāo)簽等等)。

我們首先開始為 Loading 組件寫一個(gè)簡單的測(cè)試。該組件只是渲染一些靜態(tài)的 HTML,所以并沒有什么邏輯需要測(cè)試;我們只是想確保 HTML 按照我們的預(yù)期來渲染。

// client/src/components/Loading.test.js
import React from "react";
import { render } from "react-testing-library";
import Loading from "./Loading";

describe("Loading", () => {
  it("renders correctly", () => {
    const { container } = render(<Loading />);
    expect(container.firstChild).toMatchSnapshot();
  });
});

當(dāng)你調(diào)用 .toMatchSnapshot() 時(shí),jest 將會(huì)在 __snapshots__/Loading.test.js.snap 的相對(duì)路徑下建立一個(gè)文件,來記錄當(dāng)前的狀態(tài)。隨后的測(cè)試會(huì)比較輸出和我們所記錄的快照(snapshot),如果與快照不匹配則測(cè)試失敗??煺瘴募L這樣:

// client/src/components/__snapshots__/Loading.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Loading renders correctly 1`] = `
Loading...
`
;

在這個(gè)例子中,因?yàn)?HTML 永遠(yuǎn)不會(huì)改變,所以這個(gè)快照測(cè)試并不是那么有效 —— 當(dāng)然它達(dá)到了確認(rèn)該組件是否渲染成功沒有任何錯(cuò)誤的目的。在更高級(jí)的測(cè)試案例中,快照測(cè)試在確保組件只會(huì)在你想改變它的時(shí)候才會(huì)改變時(shí)非常的有效 —— 比如說,如果你在優(yōu)化組件內(nèi)的邏輯,但并不希望組件的輸出改變時(shí),一個(gè)快照測(cè)將會(huì)告訴你,你是否犯了錯(cuò)誤。

下一步,讓我們一起來看一個(gè)對(duì)與 Apollo 連接的組件的測(cè)試。從這里開始,會(huì)變得有些復(fù)雜;組件會(huì)期待在它的上下文中有 Apollo 的客戶端,我們需要模擬一個(gè) query 查詢語句來確保組件正確地處理響應(yīng)。

// client/src/components/Posts.test.js
import React from "react";
import { render, wait } from "react-testing-library";
import { MockedProvider } from "react-apollo/test-utils";
import { MemoryRouter } from "react-router-dom";
import tk from "timekeeper";
import { Subscriber } from "containers";
import { AuthContext } from "util/context";
import Posts, { GET_POSTS, POSTS_SUBSCRIPTION } from "./Posts";

jest.mock("containers/Subscriber", () =>
  jest.fn().mockImplementation(({ children }) => children),
);

describe("Posts", () => {
  beforeEach(() => {
    tk.freeze("2019-04-20");
  });

  afterEach(() => {
    tk.reset();
  });

  // ...
});

首先是一些導(dǎo)入和模擬。這里的模擬是避免 Posts 組件地 subscription 在我們所不希望地情況下被

文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。

轉(zhuǎn)載請(qǐng)注明本文地址:http://systransis.cn/yun/6830.html

相關(guān)文章

  • 前端速查表大全,分享一些技術(shù)工具的簡明教程

    摘要:這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平時(shí)的備忘錄,需要用到的時(shí)候可以及時(shí)查閱。 這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平...

    xiaochao 評(píng)論0 收藏0
  • 前端速查表大全,分享一些技術(shù)工具的簡明教程

    摘要:這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平時(shí)的備忘錄,需要用到的時(shí)候可以及時(shí)查閱。 這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平...

    avwu 評(píng)論0 收藏0
  • 前端速查表大全,分享一些技術(shù)工具的簡明教程

    摘要:這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平時(shí)的備忘錄,需要用到的時(shí)候可以及時(shí)查閱。 這個(gè)速查表主要是分享互聯(lián)網(wǎng)上一些比較常用的工具和技術(shù)常用內(nèi)容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個(gè)列表簡單收集了常用的工具,可以收藏用于平...

    chunquedong 評(píng)論0 收藏0
  • GraphQL 技術(shù)棧揭秘

    摘要:關(guān)注業(yè)務(wù),而不是技術(shù)將數(shù)據(jù)需求放在它們所屬的客戶端。技術(shù)棧中的每一部分都起著作用技術(shù)棧中所有部分之間的協(xié)作可以借助緩存來完成?,F(xiàn)在,我們來看看另一個(gè)貫穿整個(gè)技術(shù)棧的功能的例子。你可以認(rèn)為是首個(gè)內(nèi)置細(xì)粒度查看的技術(shù)。 本文整理自2017年 GraphQL 峰會(huì)上的演講,詳述緩存、追蹤、模式拼接和 GraphQL 未來發(fā)展等有關(guān)話題。 Facebook 開源 GraphQL 至今已兩年有余...

    zzbo 評(píng)論0 收藏0
  • 全棧 React + GraphQL 教程(一)

    摘要:然而,盡管使用有諸多好處,但邁出第一步可能并不容易。為了簡化初始教程,我們今天只構(gòu)建一個(gè)簡單的列表視圖。是我們將在本教程系列中使用的客戶端的名稱。我們將列表組件命名為。在本教程的其余部分中,你將了解到我們構(gòu)建一個(gè)真正的通信應(yīng)用的基礎(chǔ)。 首發(fā)于眾成翻譯 Part 1——前端:使用 Apollo 聲明式地請(qǐng)求和 mock 數(shù)據(jù) showImg(http://p0.qhimg.com/t0...

    luxixing 評(píng)論0 收藏0

發(fā)表評(píng)論

0條評(píng)論

Cympros

|高級(jí)講師

TA的文章

閱讀更多
最新活動(dòng)
閱讀需要支付1元查看
<