若是你沒有看過本系列文章的第一部分,建議你先去看第一部分:javascript
如今咱們已經完成了全部的代碼部分,那咱們如何確保個人代碼總能正常的工做呢?咱們須要對下面幾種不一樣的層次進行測試。首先,咱們須要對 model 層進行單元測試 —— 這些 model 是否能正確的驗證(數據)?這些 model 的 helper 函數是否能返回預期的結果?第二,咱們須要對 resolver 層進行單元測試 —— resolver 是否能處理不一樣的(成功和失敗)的狀況?是否能返回正確的結果或者根據結果做出正確的數據庫更新?第三,咱們應該編寫一些完整的 integration test(集成測試),例如發送向服務器一個查詢請求並期待返回正確的結果。這可讓咱們更好地從全局上把控咱們的應用,而且確保這些測試涵蓋認證邏輯等案例。第四,咱們但願對咱們的 subscription 層進行測試 —— 當相關的變化發生時,它們能否能夠正確地通知套接字。html
Elixir 有一個很是基本的內置測試庫,叫作 ExUnit。ExUnit 包括簡單的 assert
/refute
函數,也能夠幫助你運行你的測試。在 Phoenix 中創建一系列 「case」 support 文件的方法也很常見。這些文件在測試中被引用,用於運行常見的初始化任務,例如鏈接數據庫。此外,在個人測試中,我發現 ex_spec 和 ex_machina 這兩個庫很是有幫助。ex_spec 加入了簡單的 describe
和 it
,對於有 ruby 相關背景的我來講,ex_spec 可讓編寫測試所用的語法更加的友好。ex_machina 提供了函數工廠(factory),這些函數工廠可讓動態插入測試數據變得更簡單。前端
我建立的函數工廠長這樣:java
# 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
複製代碼
在環境的搭建中導入函數工廠後,你就能夠在測試案例中使用一些很是直觀的語法了:react
# 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 長這樣:android
# 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
複製代碼
這個測試案例很直觀。對於每一個案例,咱們插入所須要的測試數據,調用須要測試的函數並對結果做出斷言(assertion)。ios
接下來,讓咱們一塊兒看一下下面這個 resolver 的測試案例:git
# 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
複製代碼
對於 resolver 的測試也至關的簡單 —— 它們也是單元測試,運行於 model 之上的一層。這裏咱們插入任意的測試數據,調用所測試的 resolver,而後期待正確的結果被返回。github
集成測試有一點點小複雜。咱們首先須要創建和服務器端的鏈接(可能須要認證),接着發送一個查詢語句而且確保咱們獲得正確的結果。我找到了這篇帖子,它對學習如何爲 Absinthe 構建集成測試很是有幫助。web
首先,咱們創建一個 helper 文件,這個文件將包含一些進行集成測試所須要的常見功能:
# 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
複製代碼
這個文件裏包括了三個 helper 函數。第一個函數接受一個鏈接對象和一個用戶對象做爲參數,經過在 HTTP 的 header 中加入已認證的用戶 token 來認證鏈接。第二個和第三個函數都接受一個查詢語句做爲參數,當你經過網絡鏈接發送查詢語句給服務器時,這兩個函數會返回一個包含該查詢語句結果在內的 JSON 結構對象。
而後回到測試自己:
# 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
複製代碼
這個測試案例,經過查詢來獲得一組帖子信息的方式來測試咱們的終端。咱們首先在數據庫中插入一些帖子的記錄,而後寫一個查詢語句,接着經過 POST 方法將語句發送給服務器,最後檢查服務器的回覆,確保返回的結果符合預期。
這裏還有一個很是類似的案例,測試是否能查詢獲得單個帖子信息。這裏咱們就再也不贅述(若是你想了解全部的集成測試,你能夠查看這裏)。下面讓咱們看一下爲建立帖子的 Mutation 所作的的集成測試。
# 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
複製代碼
很是類似,只有兩點不一樣 —— 此次咱們是經過 AbsintheHelpers.authenticate_conn(user)
將用戶的 token 加入頭字段的方式來創建鏈接,而且咱們調用的是 mutation_skeleton
,而非以前的 query_skeleton
。
那對於 subscription 的測試呢?對於 subscription 的測試也須要經過一些基本的搭建,來創建一個套接字鏈接,而後就能夠創建並測試咱們的 subscription。我找到了這篇文章,它對咱們理解如何爲 subscription 構建測試很是有幫助。
首先,咱們創建一個新的 case 文件來爲 subscription 的測試作基本的搭建。代碼長這樣:
# 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
複製代碼
在一些常見的導入後,咱們定義一個 setup
的步驟。這一步會插入一個新的用戶,並經過這個用戶的 token 來創建一個 websocket 鏈接。咱們將這個套接字和用戶返回以供咱們其餘的測試使用。
下一步,讓咱們一塊兒來看一看測試自己:
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
複製代碼
首先,咱們先寫一個 subscription 的查詢語句,而且推送到咱們在上一步已經創建好的套接字上。接着,咱們寫一個會觸發 subscription 的 mutation 語句(例如,建立一個新帖子)並推送到套接字上。最後,咱們檢查 push
的回覆,並斷言一個帖子的被新建的更新信息將被推送給咱們。這其中設計了更多的前期搭建,但這也讓咱們對 subscription 的生命週期的創建的更好的集成測試。
以上就是對服務端所發生的一切的大體的描述 —— 服務器經過在 types 中定義,在 resolvers 中實現,在 model 查詢和固化(persist)數據的方法來處理 GraphQL 查詢語句。接下來,讓咱們一塊兒來看一看客戶端是如何創建的。
咱們首先使用 create-react-app,這是從 0 到 1 搭建 React 項目的好方法 —— 它會搭建一個 「hello world」 React 應用,包含默認的設定和結構,而且簡化了大量配置。
這裏我使用了 React Router 來實現應用的路由;它將容許用戶在帖子列表頁面、單一帖子頁面和聊天頁面等進行瀏覽。咱們的應用的根組件應該長這樣:
// 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 (
<ApolloProvider client={client.current}>
<BrowserRouter>
<Meta />
<Nav />
<Switch>
<Route path="/login" component={Login} />
<Route path="/signup" component={Signup} />
<Route path="/posts/:id" component={Post} />
<Route path="/chat/:id?" component={Chat} />
<Route component={Home} />
</Switch>
</BrowserRouter>
</ApolloProvider>
);
};
複製代碼
幾個值得注意的點 —— util/apollo
這裏對外輸出了一個 createClient
函數。這個函數會建立並返回一個 Apollo 客戶端的實例(咱們將在下文中進行着重地介紹)。將 createClient
包裝在 useRef
中,就能讓該實例在應用的生命週期內(即,全部的 rerenders)中都可使用。ApolloProvider
這個高階組件會使 client 能夠在全部子組件/查詢的 context 中使用。在咱們瀏覽該應用的過程當中,BrowserRouter
使用 HTML5 的 history API 來保持 URL 的狀態同步。
這裏的 Switch
和 Route
須要單獨進行討論。React Router 是圍繞動態路由的概念創建的。大部分的網站使用靜態路由,也就是說你的 URL 將匹配惟一的路由,而且根據所匹配的路由來渲染一整個頁面。使用動態路由,路由將被分佈到整個應用中,一個 URL 能夠匹配多個路由。這聽起來可能有些使人困惑,但事實上,當你掌握了它之後,你會以爲它很是棒。它能夠輕鬆地構建一個包含不一樣組件頁面,這些組件能夠對路由的不一樣部分作出反應。例如,想象一個相似臉書的 messenger 的頁面(Socializer 的聊天界面也很是類似)—— 左邊是對話的列表,右邊是所選擇的對話。動態路由容許我這樣表達:
const App = () => {
return (
// ...
<Route path="/chat/:id?" component={Chat} />
// ...
);
};
const Chat = () => {
return (
<div>
<ChatSidebar />
<Switch>
<Route path="/chat/:id" component={Conversation} />
<Route component={EmptyState} />
</Switch>
</div>
);
};
複製代碼
若是路徑以 /chat
開頭(可能以 ID 結尾,例如,/chat/123
),根層次的 App
會渲染 Chat
組件。Chat
會渲染對話列表欄(對話列表欄老是可見的),而後會渲染它的路由,若是路徑有 ID,則顯示一個 Conversation
組件,不然就會顯示 EmptyState
(請注意,若是缺乏了 ?
,那麼 :id
參數就再也不是可選參數)。這就是動態路由的力量 —— 它讓你能夠基於當前的 URL 漸進地渲染界面的不一樣組件,將基於路徑的問題本地化到相關的組件中。
即便使用了動態路由,有時你也只想要渲染一條路徑(相似於傳統的靜態路由)。這時 Switch
組件就登上了舞臺。若是沒有 Switch
,React Router 會渲染每個匹配當前 URL 的組件,那麼在上面的 Chat
組件中,咱們就會既有 Conversation
組件,又有 EmptyState
組件。Switch
會告訴 React Router,讓它只渲染第一個匹配當前 URL 的路由並忽視掉其它的。
如今,讓咱們更進一步,深刻了解一下 Apollo 的客戶端 —— 特別是上文已經說起的 createClient
函數。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";
// ...
複製代碼
開始很簡單,導入一堆咱們接下來須要用到的依賴,而且根據當前的環境,將 HTTP URL 和 websocket URL 設置爲常量 —— 在 production 環境中指向個人 Gigalixir 實例,在 development 環境中指向 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 的客戶端要求你提供一個連接 —— 本質上說,就是你的 Apollo 客戶端所請求的 GraphQL 服務器的鏈接。一般有兩種類型的連接 —— HTTP 連接,經過標準的 HTTP 來向 GraphQL 服務器發送請求,和 websocket 連接,開放一個 websocket 鏈接並經過套接字來發送請求。在咱們的例子中,咱們兩種都使用了。對於一般的 query 和 mutation,咱們將使用 HTTP 連接,對於 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
函數,它可讓你根據你選擇的標準,將不一樣的查詢請求路由到不一樣的連接上 —— 你能夠把它想成一個三項式:若是請求有 subscription,就經過套接字連接來發送,其餘狀況(Query 或者 Mutation)則使用 HTTP 連接傳送。
若是用戶已經登錄,咱們可能還須要給兩個連接都提供認證。當用戶登錄之後,咱們將其認證令牌設置到 token
的 cookie 中(下文會詳細介紹)。與 Phoenix 創建 websocket 鏈接時,咱們使用token
做爲參數,在 HTTP 連接中,這裏咱們使用 setContext
包裝器,將token
設置在請求的頭字段中。
// client/src/util.apollo.js
export const createClient = () => {
// ...
return new ApolloClient({
cache: new InMemoryCache(),
link,
});
});
複製代碼
如上所示,除了連接之外,一個 Apollo 的客戶端還須要一個緩存的實例。GraphQL 會自動緩存請求的結果來避免對相同的數據進行重複請求。基本的 InMemoryCache
已經能夠適用大部分的用戶案例了 —— 它就是將查詢的數據存在瀏覽器的本地狀態中。
好噠,咱們已經搭建好了 Apollo 的客戶端實例,而且經過 ApolloProvider
的高階函數讓這個實例在整個應用中均可用。如今讓咱們來看一看如何運行 query 和 mutation。咱們從 Posts
組件開始,Posts
組件將在咱們的首頁渲染一個帖子的列表。
// 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 } } } `;
// ...
複製代碼
首先是各類庫的引入,接着咱們須要爲咱們想要渲染的帖子寫一些查詢。這裏有兩個 —— 首先是一個基礎的獲取帖子列表的 query(也包括帖子做者的信息),而後是一個 subscription,用來告知咱們新帖子的出現,讓咱們能夠實時地更新屏幕,保證咱們的列表處於最新。
// client/src/components/Posts.js
// ...
const Posts = () => {
return (
<Fragment>
<h4>Feed</h4>
<Query query={GET_POSTS}>
{({ loading, error, data, subscribeToMore }) => {
if (loading) return <Loading />;
if (error) return <ErrorMessage message={error.message} />;
return (
<Feed
feedType="post"
items={data.posts}
subscribeToNew={() =>
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);
});
},
})
}
/>
);
}}
</Query>
</Fragment>
);
};
複製代碼
如今咱們將實現真正的組件部分。首先,執行基本的查詢,咱們先渲染 Apollo 的 <Query query={GET_POSTS}>
。它給它的子組件提供了一些渲染的 props —— loading
,error
,data
和 subscribeToMore
。若是查詢正在加載,咱們就渲染一個簡單的加載圖片。若是有錯誤存在,咱們渲染一個通用的 ErrorMessage
組件給用戶。不然,就說明查詢成果,咱們就渲染一個 Feed
組件(data.posts
中包含着須要渲染的帖子,結構和 query 中的結構一致)。
subscribeToMore
是一個 Apollo 幫助函數,用於實現一個只須要從用戶正在瀏覽的集合中獲取新數據的 subscription。它應該在子組件的 componentDidMount
階段被渲染,這也是它被做爲 props 傳遞給 Feed
的緣由 —— 一旦 Feed
被渲染,Feed
負責調用 subscribeToNew
。咱們給 subscribeToMore
提供了咱們的 subscription 查詢和一個 updateQuery
的回調函數,該函數會在 Apollo 接收到新帖子被創建的通知時被調用。當那發生時,咱們只須要簡單將新帖子推入咱們當前的帖子數組,使用 immer 能夠返回一個新數組來確保組件能夠正確地渲染。
如今咱們已經有了一個帶帖子列表的首頁啦,這個首頁還能夠實時的對新建的帖子進行響應 —— 那咱們應該如何新建帖子呢?首先,咱們須要容許用戶用他們的帳戶登錄,那麼咱們就能夠把他的帳戶和帖子聯繫起來。咱們須要爲此寫一個 mutation —— 咱們須要將電子郵件和密碼發送到服務器,服務器會發送一個新的認證該用戶的令牌。咱們從登錄頁面開始:
// 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 組件十分類似 —— 咱們導入須要的依賴文件,而後完成登錄的 mutation。這個 mutation 接受電子郵件和密碼做爲參數,而後咱們但願獲得認證用戶的 ID 和他們的認證令牌。
// 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 中獲取當前的 token
和一個叫 setAuth
的函數(咱們會在下文中介紹 setAuth
)。咱們也須要使用 useState
來設置一些本地的狀態,那樣咱們就能夠爲用戶的電子郵件,密碼以及他們的證書是否有效來存儲臨時值(這樣咱們就能夠在表單中顯示錯誤狀態)。最後,若是用戶已經有了認證令牌,說明他們已經登錄,那麼咱們就直接讓他們跳轉去首頁。
// client/src/pages/Login.js
// ...
const Login = () => {
// ...
return (
<Fragment>
<Helmet>
<title>Socializer | Log in</title>
<meta property="og:title" content="Socializer | Log in" />
</Helmet>
<Mutation mutation={LOGIN} onError={() => setIsInvalid(true)}>
{(login, { data, loading, error }) => {
if (data) {
const {
authenticate: { id, token },
} = data;
setAuth({ id, token });
}
return (
<Container>
<Row>
<Col md={6} xs={12}>
<Form
data-testid="login-form"
onSubmit={(e) => {
e.preventDefault();
login({ variables: { email, password } });
}}
>
<Form.Group controlId="formEmail">
<Form.Label>Email address</Form.Label>
<Form.Control
type="email"
placeholder="you@gmail.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setIsInvalid(false);
}}
isInvalid={isInvalid}
/>
{renderIf(error)(
<Form.Control.Feedback type="invalid">
Email or password is invalid
</Form.Control.Feedback>,
)}
</Form.Group>
<Form.Group controlId="formPassword">
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
placeholder="Password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setIsInvalid(false);
}}
isInvalid={isInvalid}
/>
</Form.Group>
<Button variant="primary" type="submit" disabled={loading}>
{loading ? "Logging in..." : "Log in"}
</Button>
</Form>
</Col>
</Row>
</Container>
);
}}
</Mutation>
</Fragment>
);
};
export default Login;
複製代碼
這裏的代碼看起來很洋氣,可是不要懵 —— 這裏大部分的代碼只是爲表單作一個 Bootstrap 組件。咱們從一個叫作 Helmet
(react-helmet) 組件開始 —— 這是一個頂層的表單組件(相較而言,Posts
組件只是 Home
頁面渲染的一個子組件),因此咱們但願給他一個瀏覽器標題和一些 metadata。下一步咱們來渲染 Mutation
組件,將咱們的 mutation 語句傳遞給他。若是 mutation 返回一個錯誤,咱們使用 onError
回調函數來將狀態設爲無效,來將錯誤顯示在表單中。Mutation 將一個函數傳將會遞給調用他的子組件(這裏是 login
),第二個參數是和咱們從 Query
組件中獲得的同樣的數組。若是 data
存在,那就意味着 mutation 被成功執行,那麼咱們就能夠將咱們的認證令牌和用戶 ID 經過 setAuth
函數來儲存起來。剩餘的部分就是很標準的 React 組件啦 —— 咱們渲染 input 並在變化時更新 state 值,在用戶試圖登錄,而郵件密碼卻無效時顯示錯誤信息。
那 AuthContext
是幹嗎的呢?當用戶被成功認證後,咱們須要將他們的認證令牌以某種方式存儲在客戶端。這裏 GraphQL 並不能幫上忙,由於這就像是個雞生蛋問題 —— 發出請求才能獲取認證令牌,而認證這個請求自己就要用到認證令牌。咱們能夠用 Redux 在本地狀態中來存儲令牌,但若是我只須要儲存這一個值時,感受這樣作就太過於複雜了。咱們可使用 React 的 context API 來將 token 儲存在咱們應用的根目錄,在須要時調用便可。
首先,讓咱們創建一個幫助函數來幫咱們創建和導出 context:
// client/src/util/context.js
import { createContext } from "react";
export const AuthContext = createContext(null);
複製代碼
接下來咱們來新建一個 StateProvider
高階函數,這個函數會在應用的根組件被渲染 —— 它將幫助咱們保存和更新認證狀態。
// 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); 複製代碼
這裏有不少東西。首先,咱們爲認證用戶的 token
和 userId
創建 state。咱們經過讀 cookie 來初始化 state,那樣咱們就能夠在頁面刷新後保證用戶的登錄狀態。接下來咱們實現了咱們的 setAuth
函數。用 null
來調用該函數會將用戶登出;不然就使用提供的 token
和 userId
來讓用戶登錄。無論哪一種方法,這個函數都會更新本地的 state 和 cookie。
在同時使用認證和 Apollo websocket link 時存在一個很大的難題。咱們在初始化 websocket 時,若是用戶被認證,咱們就使用令牌,反之,若是用戶登出,則不是用令牌。可是當認證狀態發生變化時,咱們須要根據狀態重置 websocket 鏈接來。若是用戶是先登出再登入,咱們須要用戶新的令牌來重置 websocket,這樣他們就能夠實時地接受到須要登錄的活動的更新,好比說一個聊天對話。若是用戶是先登入再登出,咱們則須要將 websocket 重置成未經驗證狀態,那麼他們就再也不會實時地接受到他們已經登出的帳戶的更新。事實證實這真的很難 —— 由於沒有一個詳細記錄的下的解決方案,這花了我好幾個小時才解決。我最終手動地爲套接字實現了一個重置函數:
// 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();
};
複製代碼
這個會斷開 Phoenix 套接字,將當前存在的 Phoenix 頻道留給 GraphQL 更新,建立一個新的 Phoenix 頻道(和 Abisnthe 建立的默認頻道一個名字),並將這個頻道標記爲鏈接(那樣 Absinthe 會在鏈接時將它從新加入),接着從新鏈接套接字。在文件中,Phoenix 套接字被配置爲在每次鏈接前動態的在 cookie 中查找令牌,那樣每當它重聯時,它將會使用新的認證狀態。讓我崩潰的是,對這樣一個看着很普通的問題,卻並無一個好的解決方法,固然,經過一些手動的努力,它工做得還不錯。
最後,在咱們的 StateProvider
中使用的 useEffect
是調用 refreshSocket
的地方。第二個參數 [token]
告訴了 React 在每次 token
值變化時,去從新評估該函數。若是用戶只是登出,咱們也要執行 client.clearStore()
函數來確保 Apollo 客戶端不會繼續緩存包含着須要權限才能獲得的數據的查詢結果,好比說用戶的對話或者消息。
這就大概是客戶端的所有了。你能夠查看餘下的組件來獲得更多的關於 query,mutation 和 subscription 的例子,固然,它們的模式都和咱們所提到的大致一致。
讓咱們來寫一些測試,來覆蓋咱們的 React 代碼。咱們的應用內置了 jest(create-react-app 默認包括它);jest 是針對 JavaScript 的一個很是簡單和直觀的測試運行器。它也包括了一些高級功能,好比快照測試。咱們將在咱們的第一個測試案例裏使用它。
我很是喜歡使用 react-testing-library 來寫 React 的測試案例 —— 它提供了一個很是簡單的 API,能夠幫助你從一個用戶的角度來渲染和測試表單(而無需在乎組件的具體實現)。此外,它的幫助函數能夠在必定程度上的幫助你確保組件的可讀性,由於若是你的 DOM 節點很難訪問,那麼你也很難經過直接操控 DOM 節點來與之交互(例如給文本提供正確的標籤等等)。
咱們首先開始爲 Loading
組件寫一個簡單的測試。該組件只是渲染一些靜態的 HTML,因此並無什麼邏輯須要測試;咱們只是想確保 HTML 按照咱們的預期來渲染。
// 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(); }); }); 複製代碼
當你調用 .toMatchSnapshot()
時,jest 將會在 __snapshots__/Loading.test.js.snap
的相對路徑下創建一個文件,來記錄當前的狀態。隨後的測試會比較輸出和咱們所記錄的快照(snapshot),若是與快照不匹配則測試失敗。快照文件長這樣:
// client/src/components/__snapshots__/Loading.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Loading renders correctly 1`] = ` <div class="d-flex justify-content-center" > <div class="spinner-border" role="status" > <span class="sr-only" > Loading... </span> </div> </div> `;
複製代碼
在這個例子中,由於 HTML 永遠不會改變,因此這個快照測試並非那麼有效 —— 固然它達到了確認該組件是否渲染成功沒有任何錯誤的目的。在更高級的測試案例中,快照測試在確保組件只會在你想改變它的時候纔會改變時很是的有效 —— 好比說,若是你在優化組件內的邏輯,但並不但願組件的輸出改變時,一個快照測將會告訴你,你是否犯了錯誤。
下一步,讓咱們一塊兒來看一個對與 Apollo 鏈接的組件的測試。從這裏開始,會變得有些複雜;組件會期待在它的上下文中有 Apollo 的客戶端,咱們須要模擬一個 query 查詢語句來確保組件正確地處理響應。
// 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();
});
// ...
});
複製代碼
首先是一些導入和模擬。這裏的模擬是避免 Posts
組件地 subscription 在咱們所不但願地狀況下被註冊。在這裏我很崩潰 —— Apollo 有關於有模擬 query 和 mutation 的文檔,可是並無不少關於模擬 subscription 文檔,而且我還會常常遇到各類神祕的,內部的,十分難解決的問題。當我只是想要組件執行它初始的 query 查詢時(而不是模擬收到來自它的 subscription 的更新),我徹底沒能想到一種可靠的方法來模擬 query 查詢。
但這確實也給了一個來討論 jest 的好機會 —— 這樣的案例很是有效。我有一個 Subscriber
組件,一般在裝載(mount)時會調用 subscribeToNew
,而後返回它的子組件:
// client/src/containers/Subscriber.js
import { useEffect } from "react";
const Subscriber = ({ subscribeToNew, children }) => {
useEffect(() => {
subscribeToNew();
}, []);
return children;
};
export default Subscriber;
複製代碼
因此,在個人測試中,我只須要模擬這個組件的實現來返回子組件,而無需真正地調用 subscribeToNew
。
最後,我是用了 timekeeper
來固定每個測試案例的時間 —— Posts
根據帖子發佈時間和當前時間(例如,兩天之前)渲染了一些文本,那麼我須要確保這個測試老是在「相同」的時間運行,不然快照測試就會由於時間推移而失敗。
// client/src/components/Posts.test.js
// ...
describe("Posts", () => {
// ...
it("renders correctly when loading", () => {
const { container } = render(
<MemoryRouter> <AuthContext.Provider value={{}}> <MockedProvider mocks={[]} addTypename={false}> <Posts /> </MockedProvider> </AuthContext.Provider> </MemoryRouter>, ); expect(container).toMatchSnapshot(); }); // ... }); 複製代碼
咱們的第一個測試檢查了加載的的狀態。咱們必須把它包裹在幾個高階函數裏 —— MemoryRouter
,給 React Router 的 Link
和 Route
提供了一個模擬的路由;AuthContext.Provider
,提供了認證的狀態,和 Apollo 的 MockedProvider
。由於咱們已拍了一個即時的快照並返回,咱們事實上不須要模擬任何事情;一個即時的快照會在 Apollo 有機會執行 query 查詢以前捕捉到加載的狀態。
// client/src/components/Posts.test.js
// ...
describe("Posts", () => {
// ...
it("renders correctly when loaded", async () => {
const mocks = [
{
request: {
query: GET_POSTS,
},
result: {
data: {
posts: [
{
id: 1,
body: "Thoughts",
insertedAt: "2019-04-18T00:00:00",
user: {
id: 1,
name: "John Smith",
gravatarMd5: "abc",
},
},
],
},
},
},
];
const { container, getByText } = render(
<MemoryRouter> <AuthContext.Provider value={{}}> <MockedProvider mocks={mocks} addTypename={false}> <Posts /> </MockedProvider> </AuthContext.Provider> </MemoryRouter>, ); await wait(() => getByText("Thoughts")); expect(container).toMatchSnapshot(); }); // ... }); 複製代碼
對於這個測試,咱們但願一旦加載結束帖子被顯示出來,就馬上快照。爲了達到這個,咱們必須讓測試 async
,而後使用 react-testing-library 的 wait
來 await 加載狀態的結束。wait(() => ...)
將會簡單的重試這個函數直到結果再也不錯誤 —— 一般狀況下不會超過 0.1 秒。一旦文本顯現出來,咱們就馬上對整個組件快照以確保那是咱們所期待的結果。
// client/src/components/Posts.test.js
// ...
describe("Posts", () => {
// ...
it("renders correctly after created post", async () => {
Subscriber.mockImplementation((props) => {
const { default: ActualSubscriber } = jest.requireActual(
"containers/Subscriber",
);
return <ActualSubscriber {...props} />;
});
const mocks = [
{
request: {
query: GET_POSTS,
},
result: {
data: {
posts: [
{
id: 1,
body: "Thoughts",
insertedAt: "2019-04-18T00:00:00",
user: {
id: 1,
name: "John Smith",
gravatarMd5: "abc",
},
},
],
},
},
},
{
request: {
query: POSTS_SUBSCRIPTION,
},
result: {
data: {
postCreated: {
id: 2,
body: "Opinions",
insertedAt: "2019-04-19T00:00:00",
user: {
id: 2,
name: "Jane Thompson",
gravatarMd5: "def",
},
},
},
},
},
];
const { container, getByText } = render(
<MemoryRouter>
<AuthContext.Provider value={{}}>
<MockedProvider mocks={mocks} addTypename={false}>
<Posts />
</MockedProvider>
</AuthContext.Provider>
</MemoryRouter>,
);
await wait(() => getByText("Opinions"));
expect(container).toMatchSnapshot();
});
});
複製代碼
最後,咱們將會來測試 subscription,來確保當組件收到一個新的帖子時,它可以按照所期待地結果進行正確地渲染。在這個測試案例中,咱們須要更新 Subscription
的模擬,以便它實際地返回原始的實現,併爲組件訂閱所發生的變化(新建帖子)。咱們同時模擬了一個叫 POSTS_SUBSCRIPTION
地查詢來模擬 subscription 接收到一個新的帖子。最後,同上面的測試同樣,咱們等待查詢語句的結束(而且新帖子的文本出現)並對 HTML 進行快照。
以上就差很少是所有的內容了。jest 和 react-testing-library 都很是的強大,它們使咱們對組件的測試變得簡單。測試 Apollo 有一點點困難,可是經過明智地使用模擬數據,咱們也可以寫出一些很是完整的測試來測試全部主要組件的狀態。
如今咱們的客戶端只有一個問題了 —— 全部的 HTML 都是在客戶端被渲染的。從服務器返回的 HTML 只是一個空的 index.html
文件和一個 <script>
標籤,所載入的 JavaScript 渲染了所有的內容。在開發模式下,這樣能夠,但這樣對生產環境並很差 —— 例如說,不少的搜索引擎並不擅長運行 JavaScript 來根據客戶端渲染的內容構建索引(index)。咱們真正但願的是服務器能返回該頁面的徹底渲染的 HTML,而後 React 能夠接管客戶端,處理用戶的加護的路由。
這裏,服務器端渲染(SSR)的概念被引入進來。本質上來講,相比於提供靜態的 HTML 索引文件,咱們將請求路由到 Node.js 服務器端。服務器渲染組件(解析對 GraphQL 端點的任何查詢)而且返回輸出的 HTML,和 <script>
標籤來加載 JavaScript。當 JavaScript 在客戶端加載,它會使用 hydrate
函數而不是從頭開始渲染 —— 意味着它會保存已存在的,服務器端提供的 HTML 並將它和相匹配的 React 樹聯繫起來。這種方法將容許搜索引擎簡單的索引服務器渲染的 HTML,而且由於用戶再也不須要在頁面可視以前等待 JavaScript 文件的下載,執行和進行查詢,這也會爲用戶提供了一個更快的體驗。
不幸的是,我發現配置 SSR 真正的並無一個通用的方法 —— 他們的基礎是相同的(都是運行一個能夠渲染組件的 Node.js 服務器)可是存在一些不一樣的實現,而且沒有任何實現被標準化。個人應用的大部分的配置都來自於 cra-ssr,它爲 create-react-app 搭建的應用用提供了很是易於理解的 SSR 的實現。由於 cra-ssr 的教程提供至關完善的介紹,我不會在這裏作更加深刻的剖析。我是想說,SSR 很棒而且使得應用加載的很是快,儘管實現它確實有點點困難。
感謝你們看到這裏!這裏內容超多,由於我想要真正地深刻一個複雜的應用,來從頭至尾地練習全部的技術,而且來解決一些在現實世界中真正遇到的問題。若是你已經讀到這裏了,但願你能對如何將這全部的技術用在一塊兒有了一些不錯的理解。你能夠在 Github 上看到完整版的代碼。或者試用這個在線演示。這個演示是部署在免費版的 Heroku dyno 上的,因此在你訪問的時候,可能會須要 30 秒來喚醒服務器。若是你有任何問題,能夠在演示下面的評論裏留言,我會盡個人可能來回答。
我部署的體驗也充滿了挫折和問題。有些是意料之中,包括一些新的框架和庫的學習曲線 —— 但也有一些地方,若是有更好的文檔和工具,能夠節省我不少的時間,讓我不那麼頭疼。特別是 Apollo,我在理解若是讓 websocket 在認證變化後從新初始化它的鏈接上遇到了一大堆問題;一般狀況下這些都應該在文檔裏寫下來,可是顯然我啥也找不到。類似的,我在測試 subscriptions 時也遇到不少問題,而且最終不得不放棄轉而使用 mock 測試。測試的文檔對於基本的測試來講是很是夠的,可是我發現當我想要寫更高級的測試案例時,文檔太過於淺顯。我怕也常常由於缺乏 API 的文檔而感到困惑,主要是 Apollo 和 Absinthe 客戶端庫的一部分文檔。例如說,當我研究若是重置 websocket 鏈接時,我找不到任何 Absinthe socket 實例和 Apollo link 實例的文檔。我惟一能作的就是把 GitHub 上面的源代碼從頭至尾讀一遍。我使用 Apollo 的體驗比起幾年前使用 Relay 的體驗要好不少 —— 可是下一次我使用它時,我不得不接受,若是我想要另闢蹊徑的話,就須要花更多的時間來破解改造代碼的事實。
總而言之,我給這套技術棧很高的評分,並且我很是的喜歡這個項目。Elixir 和 Phoenix 用起來讓人耳目一新;若是你來自 Rails,會有一些學習的曲線,可是我真的很是喜歡 Elixir 的一些語言特色,例如模式匹配和通道運算符(pipe operator)。Elixir 有不少新穎的想法(以及來許多來自函數式編程,通過實戰考驗的概念),讓編寫有意義的,好看的代碼這件事變得十分簡單。Absinthe 的使用就像是一陣春風拂面;它實現的很好,文檔極佳,幾乎涵蓋了全部實現 GraphQL 服務器的合理用例,而且從整體上來講,我發現 GraphQL 的核心概念也被很好地傳遞。查詢每個頁面我須要的數據十分簡單,經過 subscription 實現實時地更新也很是容易。我一直都很是喜歡使用 React 和 React Router,這一次也不例外 —— 它們使得構建複雜,交互的前端用戶界面變得簡單。最後,我十分滿意總體的結果 —— 做爲一名用戶,應用的加載和瀏覽很是快,全部的東西都是實時的因此能夠一直保持同步。若是說對技術棧的終極衡量標準是用戶的體驗,那這個組合必定是一個巨大的成功。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。