摘要:對(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 加入了簡單的 describe 和 it,對(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)同步。
這里的 Switch 和 Route 需要多帶帶進(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 的
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 (
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)證用戶的 token 和 userId 建立 state。我們通過讀 cookie 來初始化 state,那樣我們就可以在頁面刷新后保證用戶的登陸狀態(tài)。接下來我們實(shí)現(xiàn)了我們的 setAuth 函數(shù)。用 null 來調(diào)用該函數(shù)會(huì)將用戶登出;否則就使用提供的 token 和 userId來讓用戶登陸。不管哪種方法,這個(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
摘要:這個(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è)列表簡單收集了常用的工具,可以收藏用于平...
摘要:這個(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è)列表簡單收集了常用的工具,可以收藏用于平...
摘要:這個(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è)列表簡單收集了常用的工具,可以收藏用于平...
摘要:關(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 至今已兩年有余...
摘要:然而,盡管使用有諸多好處,但邁出第一步可能并不容易。為了簡化初始教程,我們今天只構(gòu)建一個(gè)簡單的列表視圖。是我們將在本教程系列中使用的客戶端的名稱。我們將列表組件命名為。在本教程的其余部分中,你將了解到我們構(gòu)建一個(gè)真正的通信應(yīng)用的基礎(chǔ)。 首發(fā)于眾成翻譯 Part 1——前端:使用 Apollo 聲明式地請(qǐng)求和 mock 數(shù)據(jù) showImg(http://p0.qhimg.com/t0...
閱讀 2340·2023-04-26 00:01
閱讀 815·2021-10-27 14:13
閱讀 1857·2021-09-02 15:11
閱讀 3395·2019-08-29 12:52
閱讀 546·2019-08-26 12:00
閱讀 2578·2019-08-26 10:57
閱讀 3420·2019-08-26 10:32
閱讀 2862·2019-08-23 18:29