diff --git a/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md b/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md index c350bc2fae8..a6dd921f443 100644 --- a/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md +++ b/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md @@ -2,23 +2,23 @@ > * 原文作者:[Zach Schneider](https://www.github.com/schneidmaster) > * 译文出自:[掘金翻译计划](https://github.com/xitu/gold-miner) > * 本文永久链接:[https://github.com/xitu/gold-miner/blob/master/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md](https://github.com/xitu/gold-miner/blob/master/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md) -> * 译者: -> * 校对者: +> * 译者:[Fengziyin1234](https://github.com/Fengziyin1234) +> * 校对者:[Xuyuey](https://github.com/Xuyuey), [portandbridge](https://github.com/portandbridge) -# Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part -2 +# Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎疯狂的深度实践 —— 第二部分(测试相关部分) 如果你没有看过本系列文章的第一部分,建议你先去看第一部分: - [Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part 1](https://github.com/xitu/gold-miner/blob/master/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-1.md) - [Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part 2](https://github.com/xitu/gold-miner/blob/master/TODO1/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive-2.md) -### Testing — server side +## 测试 —— 服务器端 -Now that we’ve gotten all that code written, how do we make sure it keeps working? There’s a few different layers to think about testing here. First, we should unit test the models — do they validate correctly and do their helper methods return what’s expected? Second, we should also unit test the resolvers — do they handle different cases (success and error) and return the correct data or apply the correct change? Third, we should write some complete integration tests, sending a query to the server and expecting the response to be correct. This helps us keep a handle on the big picture, and also ensures we cover cases like the authentication logic. Fourth, we will want tests on our subscriptions — do they correctly notify the socket when relevant changes are made? +现在我们已经完成了所有的代码部分,那我们如何确保我的代码总能正常的工作呢?我们需要对下面几种不同的层次进行测试。首先,我们需要对 model 层进行单元测试 —— 这些 model 是否能正确的验证(数据)?这些 model 的 helper 函数是否能返回预期的结果?第二,我们需要对 resolver 层进行单元测试 —— resolver 是否能处理不同的(成功和失败)的情况?是否能返回正确的结果或者根据结果作出正确的数据库更新?第三,我们应该编写一些完整的 integration test(集成测试),例如发送向服务器一个查询请求并期待返回正确的结果。这可以让我们更好地从全局上把控我们的应用,并且确保这些测试涵盖认证逻辑等案例。第四,我们希望对我们的 subscription 层进行测试 —— 当相关的变化发生时,它们可否可以正确地通知套接字。 -Elixir has a basic built-in testing library called ExUnit. It contains simple `assert`/`refute` helpers and handles running your tests. It’s also common in Phoenix to set up “case” support files; these are included in tests to run common setup tasks like connecting to the database. Beyond the defaults, there are two helper libraries that I found helpful in my tests — [ex_spec](https://hexdocs.pm/ex_spec/readme.html) and [ex_machina](https://hexdocs.pm/ex_machina/readme.html). ex_spec adds simple `describe` and `it` macros that make the testing syntax feel a little friendlier, at least from my ruby background. ex_machina provides factories which make it easy to dynamically insert test data. +Elixir 有一个非常基本的内置测试库,叫做 ExUnit。ExUnit 包括简单的 `assert`/`refute` 函数,也可以帮助你运行你的测试。在 Phoenix 中建立一系列 “case” support 文件的方法也很常见。这些文件在测试中被引用,用于运行常见的初始化任务,例如连接数据库。此外,在我的测试中,我发现 [ex_spec](https://hexdocs.pm/ex_spec/readme.html) 和 [ex_machina](https://hexdocs.pm/ex_machina/readme.html) 这两个库非常有帮助。ex_spec 加入了简单的 `describe` 和 `it`,对于有 ruby 相关背景的我来说,ex_spec 可以让编写测试所用的语法更加的友好。ex_machina 提供了函数工厂(factory),这些函数工厂可以让动态插入测试数据变得更简单。 -My factories look like this: +我创建的函数工厂长这样: ```elixir # test/support/factories.ex @@ -45,7 +45,7 @@ defmodule Socializer.Factory do end ``` -And after importing the factory into the case setup, it can be used in tests with a very intuitive syntax: +在环境的搭建中导入函数工厂后,你就可以在测试案例中使用一些非常直观的语法了: ```elixir # Insert a user @@ -58,7 +58,7 @@ user_named = insert(:user, name: "John Smith") post = insert(:post, user: user) ``` -With the setup out of the way, here’s what the `Post` model test looks like: +在搭建完成后,你的 `Post` model 长这样: ```elixir # test/socializer/post_test.exs @@ -116,9 +116,9 @@ defmodule Socializer.PostTest do end ``` -Hopefully this test is fairly intuitive. For each case, we insert any needed test data, invoke the method being tested, and make assertions about the results. +这个测试案例很直观。对于每个案例,我们插入所需要的测试数据,调用需要测试的函数并对结果作出断言(assertion)。 -Next, let’s look at a resolver test: +接下来,让我们一起看一下下面这个 resolver 的测试案例: ```elixir # test/socializer_web/resolvers/post_resolver_test.exs @@ -184,11 +184,11 @@ defmodule SocializerWeb.PostResolverTest do end ``` -The resolver tests are also fairly simple — they’re also unit tests, just operating at one layer up from the models. We insert any setup data, invoke the resolver, and expect the correct result to be returned. +对于 resolver 的测试也相当的简单 —— 它们也是单元测试,运行于 model 之上的一层。这里我们插入任意的测试数据,调用所测试的 resolver,然后期待正确的结果被返回。 -The integration tests get a little more complicated. We need to set up a connection (possibly with authentication) and send it a query to ensure we get the correct result. I found [this post](https://tosbourn.com/testing-absinthe-exunit) extremely helpful in learning how to set up integration tests for Absinthe. +集成测试有一点点小复杂。我们首先需要建立和服务器端的连接(可能需要认证),接着发送一个查询语句并且确保我们得到正确的结果。我找到了[这篇帖子](https://tosbourn.com/testing-absinthe-exunit),它对学习如何为 Absinthe 构建集成测试非常有帮助。 -First, we create a helper file with some of the common functionality that the integration tests will need: +首先,我们建立一个 helper 文件,这个文件将包含一些进行集成测试所需要的常见功能: ```elixir # test/support/absinthe_helpers.ex @@ -218,9 +218,9 @@ defmodule Socializer.AbsintheHelpers do end ``` -There are three helper methods. The first takes a connection object and a user, and authenticates the connection by adding a header with an authentication token for the user. The second and third accept a query and return the JSON structure used to wrap a GraphQL query when sending it over the network. +这个文件里包括了三个 helper 函数。第一个函数接受一个连接对象和一个用户对象作为参数,通过在 HTTP 的 header 中加入已认证的用户 token 来认证连接。第二个和第三个函数都接受一个查询语句作为参数,当你通过网络连接发送查询语句给服务器时,这两个函数会返回一个包含该查询语句结果在内的 JSON 结构对象。 -Onward to the test itself: +然后回到测试本身: ```elixir # test/socializer_web/integration/post_resolver_test.exs @@ -256,9 +256,9 @@ defmodule SocializerWeb.Integration.PostResolverTest do end ``` -This test exercises the endpoint to query for a list of posts. We start by inserting some posts into the database; write the query; post it to the connection; and check the response to make sure our test data was returned as expected. +这个测试案例,通过查询来得到一组帖子信息的方式来测试我们的终端。我们首先在数据库中插入一些帖子的记录,然后写一个查询语句,接着通过 POST 方法将语句发送给服务器,最后检查服务器的回复,确保返回的结果符合预期。 -There’s a very similar test for the endpoint to show a single post, but we’ll skip it for brevity (you can look through all of the integration tests [here](https://github.com/schneidmaster/socializer/tree/master/test/socializer_web/integration) if you want). Let’s look at the integration test for the post creation mutation: +这里还有一个非常相似的案例,测试是否能查询得到单个帖子信息。这里我们就不再赘述(如果你想了解所有的集成测试,你可以查看[这里](https://github.com/schneidmaster/socializer/tree/master/test/socializer_web/integration))。下面让我们看一下为创建帖子的 Mutation 所做的的集成测试。 ```elixir # test/socializer_web/integration/post_resolver_test.exs @@ -293,11 +293,11 @@ defmodule SocializerWeb.Integration.PostResolverTest do end ``` -Pretty similar, but two differences around the request — we pipe the connection through `AbsintheHelpers.authenticate_conn(user)` to add the user’s authentication token, and we invoke the `mutation_skeleton` helper instead of `query_skeleton`. +非常相似,只有两点不同 —— 这次我们是通过 `AbsintheHelpers.authenticate_conn(user)` 将用户的 token 加入头字段的方式来建立连接,并且我们调用的是 `mutation_skeleton`,而非之前的 `query_skeleton`。 -How about subscription tests? These also involve a bit of setup to create a socket connection where we can establish and exercise the subscription. I found [this article](https://www.smoothterminal.com/articles/building-a-forum-elixir-graphql-backend-with-absinthe) extremely helpful for understanding the setup process for a subscription test. +那对于 subscription 的测试呢?对于 subscription 的测试也需要通过一些基本的搭建,来建立一个套接字连接,然后就可以建立并测试我们的 subscription。我找到了[这篇文章](https://www.smoothterminal.com/articles/building-a-forum-elixir-graphql-backend-with-absinthe),它对我们理解如何为 subscription 构建测试非常有帮助。 -First, we create a new “case” to do the setup for subscription tests. It looks like this: +首先,我们建立一个新的 case 文件来为 subscription 的测试做基本的搭建。代码长这样: ```elixir # test/support/subscription_case.ex @@ -336,9 +336,9 @@ defmodule SocializerWeb.SubscriptionCase do end ``` -After the common imports, we define a `setup` step which inserts a new user and sets up a websocket authenticated with the user’s token. We return the socket and the user for our tests to use. +在一些常见的导入后,我们定义一个 `setup` 的步骤。这一步会插入一个新的用户,并通过这个用户的 token 来建立一个 websocket 连接。我们将这个套接字和用户返回以供我们其他的测试使用。 -Next, let’s have a look at the test itself: +下一步,让我们一起来看一看测试本身: ```elixir defmodule SocializerWeb.PostSubscriptionsTest do @@ -393,15 +393,15 @@ defmodule SocializerWeb.PostSubscriptionsTest do end ``` -First, we write a subscription query, and push it onto the socket that we constructed during the test setup. Next, we write a mutation that’s expected to trigger the subscription (i.e. creating a new post) and push that onto the socket. Finally, we check the `push` response to assert that we were pushed an update about the newly created post. A bit more setup involved, but this gives us a nice end-to-end test on the lifecycle of a subscription. +首先,我们先写一个 subscription 的查询语句,并且推送到我们在上一步已经建立好的套接字上。接着,我们写一个会触发 subscription 的 mutation 语句(例如,创建一个新帖子)并推送到套接字上。最后,我们检查 `push` 的回复,并断言一个帖子的被新建的更新信息将被推送给我们。这其中设计了更多的前期搭建,但这也让我们对 subscription 的生命周期的建立的更好的集成测试。 -### The client +## 客户端 -Whew! That’s a pretty decent outline of what’s happening on the server side — it handles queries as defined in the types, implemented in the resolvers, using the models to query and persist data. Next, let’s take a look at how the client is built. +以上就是对服务端所发生的一切的大致的描述 —— 服务器通过在 types 中定义,在 resolvers 中实现,在 model 查询和固化(persist)数据的方法来处理 GraphQL 查询语句。接下来,让我们一起来看一看客户端是如何建立的。 -I started out with [create-react-app](https://facebook.github.io/create-react-app), which is great for bootstrapping React projects — it sets up a “hello world” React app with sound defaults and structure, and abstracts away a lot of the configuration. +我们首先使用 [create-react-app](https://facebook.github.io/create-react-app),这是从 0 到 1 搭建 React 项目的好方法 —— 它会搭建一个 “hello world” React 应用,包含默认的设定和结构,并且简化了大量配置。 -I’m using [React Router](https://reacttraining.com/react-router) for the routing in my application; this will allow users to navigate between a list of posts, a single post, a chat conversation, etc. The root component of my application looks something like this: +这里我使用了 [React Router](https://reacttraining.com/react-router) 来实现应用的路由;它将允许用户在帖子列表页面、单一帖子页面和聊天页面等进行浏览。我们的应用的根组件应该长这样: ```javascript // client/src/App.js @@ -434,9 +434,9 @@ const App = () => { }; ``` -A few pieces here — `util/apollo` exposes a `createClient` function that creates and returns an Apollo client instance (more on that next). Wrapping it in a `useRef` makes the same client instance available for the lifetime of the application (i.e. across rerenders). The `ApolloProvider` HOC makes the client available in context for child components/queries. The `BrowserRouter` uses the HTML5 history API to keep the URL state in sync as we navigate around the application. +几个值得注意的点 —— `util/apollo` 这里对外输出了一个 `createClient` 函数。这个函数会创建并返回一个 Apollo 客户端的实例(我们将在下文中进行着重地介绍)。将 `createClient` 包装在 `useRef` 中,就能让该实例在应用的生命周期内(即,所有的 rerenders)中均可使用。`ApolloProvider` 这个高阶组件会使 client 可以在所有子组件/查询的 context 中使用。在我们浏览该应用的过程中,`BrowserRouter` 使用 HTML5 的 history API 来保持 URL 的状态同步。 -The `Switch` and `Route` components merit their own discussion. React Router is built around the concept of “dynamic” routing. Most web server frameworks use “static” routing, which is to say that your URL matches exactly one route and renders an entire page based on that route. With “dynamic” routing, routes are sprinkled throughout the application, and more than one route can match the URL. This sounds confusing, but it’s actually **really** nice once you get the hang of it. It makes it easy to build screens that have different components which react to different parts of the route. For example, imagine a messenger screen similar to Facebook messenger (Socializer’s chat interface is similar) — the left side always shows a list of conversations, and the right side shows a conversation only if one is selected. Dynamic routing lets me express that like so: +这里的 `Switch` 和 `Route` 需要单独进行讨论。React Router 是围绕**动态**路由的概念建立的。大部分的网站使用**静态**路由,也就是说你的 URL 将匹配唯一的路由,并且根据所匹配的路由来渲染一整个页面。使用**动态**路由,路由将被分布到整个应用中,一个 URL 可以匹配多个路由。这听起来可能有些令人困惑,但事实上,当你掌握了它以后,你会觉得它**非常**棒。它可以轻松地构建一个包含不同组件页面,这些组件可以对路由的不同部分做出反应。例如,想象一个类似脸书的 messenger 的页面(Socializer 的聊天界面也非常相似)—— 左边是对话的列表,右边是所选择的对话。动态路由允许我这样表达: ```javascript const App = () => { @@ -461,13 +461,13 @@ const Chat = () => { }; ``` -The root-level `App` renders the `Chat` component if the route starts with `/chat` (possibly with an ID on the end, i.e. `/chat/123`). The `Chat` component renders the sidebar (which should always be visible) and then renders its own routes, which show a `Conversation` if the route has an ID (note the lack of a `?` so the `:id` parameter is not optional) and otherwise falls through to an empty state. This is the power of dynamic routing — it lets you progressively render different pieces of the interface based on the current URL, while localizing route-based concerns to the relevant component. +如果路径以 `/chat` 开头(可能以 ID 结尾,例如,`/chat/123`),根层次的 `App` 会渲染 `Chat` 组件。`Chat` 会渲染对话列表栏(对话列表栏总是可见的),然后会渲染它的路由,如果路径有 ID,则显示一个 `Conversation` 组件,否则就会显示 `EmptyState`(请注意,如果缺少了 `?`,那么 `:id` 参数就不再是可选参数)。这就是动态路由的力量 —— 它让你可以基于当前的 URL 渐进地渲染界面的不同组件,将基于路径的问题本地化到相关的组件中。 -Even with dynamic routing, sometimes you want to render exactly one route (similar to a traditional static router). That’s where the `Switch` component comes in. Without the `Switch`, React Router will render **every** component that matches the current URL, so in our `Chat` component above we would get both the conversation and the empty state message. `Switch` tells React Router to only render the first route matching the current URL, and ignore the rest. +即使使用了动态路由,有时你也只想要渲染一条路径(类似于传统的静态路由)。这时 `Switch` 组件就登上了舞台。如果没有 `Switch`,React Router 会渲染**每一个**匹配当前 URL 的组件,那么在上面的 `Chat` 组件中,我们就会既有 `Conversation` 组件,又有 `EmptyState` 组件。`Switch` 会告诉 React Router,让它只渲染第一个匹配当前 URL 的路由并忽视掉其它的。 -### The Apollo client +## Apollo 客户端 -Now that we’ve got that down, let’s dive a bit deeper into the Apollo client — specifically the `createClient` function referenced above. The `util/apollo.js` file looks like this: +现在,让我们更进一步,深入了解一下 Apollo 的客户端 —— 特别是上文已经提及的 `createClient` 函数。`util/apollo.js` 文件长这样: ```javascript // client/src/util.apollo.js @@ -495,7 +495,7 @@ const WS_URI = // ... ``` -Starting out pretty simple. Import a bunch of dependencies that we’ll need shortly, and set constants for the HTTP URL and the websocket URL based on the current environment — pointed at my Gigalixir instance in production, or at localhost in development. +开始很简单,导入一堆我们接下来需要用到的依赖,并且根据当前的环境,将 HTTP URL 和 websocket URL 设置为常量 —— 在 production 环境中指向我的 Gigalixir 实例,在 development 环境中指向 localhost。 ```javascript // client/src/util.apollo.js @@ -527,7 +527,7 @@ export const createClient = () => { }); ``` -An Apollo client instance requires you to provide it with a link — essentially, a connection to your GraphQL server which the Apollo client can use to make requests. There are two common kinds of links — the HTTP link, which makes requests to the GraphQL server over standard HTTP, and the websocket link, which opens a websocket connection to the server and sends queries over the socket. In our case, we actually want **both**. For regular queries and mutations, we’ll use the HTTP link and for subscriptions we’ll use the websocket link. +Apollo 的客户端要求你提供一个链接 —— 本质上说,就是你的 Apollo 客户端所请求的 GraphQL 服务器的连接。通常有两种类型的链接 —— HTTP 链接,通过标准的 HTTP 来向 GraphQL 服务器发送请求,和 websocket 链接,开放一个 websocket 连接并通过套接字来发送请求。在我们的例子中,我们两种都使用了。对于通常的 query 和 mutation,我们将使用 HTTP 链接,对于 subscription,我们将使用 websocket 链接。 ```javascript // client/src/util.apollo.js @@ -564,9 +564,9 @@ export const createClient = () => { }; ``` -Apollo provides the `split` method which lets you route queries to different links based on the criteria of your choice — you can think of it like a ternary: if the query has a subscription, then send it through the socket link, else send it through the HTTP link. +Apollo 提供了 `split` 函数,它可以让你根据你选择的标准,将不同的查询请求路由到不同的链接上 —— 你可以把它想成一个三项式:如果请求有 subscription,就通过套接字链接来发送,其他情况(Query 或者 Mutation)则使用 HTTP 链接传送。 -We also might need to provide authentication for both of our links, if the user is currently logged in. When they log in, we’re going to set their authentication token to a `token` cookie (more on that in a bit). We used the `token` as a parameter when establishing the Phoenix websocket connection in the previous section, and here we use the `setContext` wrapper to set the `token` on the authorization header of requests over the HTTP link. +如果用户已经登陆,我们可能还需要给两个链接都提供认证。当用户登陆以后,我们将其认证令牌设置到 `token` 的 cookie 中(下文会详细介绍)。与 Phoenix 建立 websocket 连接时,我们使用`token` 作为参数,在 HTTP 链接中,这里我们使用 `setContext` 包装器,将`token` 设置在请求的头字段中。 ```javascript // client/src/util.apollo.js @@ -580,11 +580,11 @@ export const createClient = () => { }); ``` -And that’s just about it. In addition to the link, an Apollo client also needs a cache instance; GraphQL automatically caches the results of queries to prevent duplicate requests for the same data. The basic `InMemoryCache` is ideal for most use cases — it just keeps the cached query data in local browser state. +如上所示,除了链接以外,一个 Apollo 的客户端还需要一个缓存的实例。GraphQL 会自动缓存请求的结果来避免对相同的数据进行重复请求。基本的 `InMemoryCache` 已经可以适用大部分的用户案例了 —— 它就是将查询的数据存在浏览器的本地状态中。 -### Using the client — our first query +## 客户端的使用 —— 我们的第一个请求 -Great, so we’ve set up the Apollo client instance, and made it available throughout the application via the `ApolloProvider` HOC. Now let’s take a look at how it’s used to run queries and mutations. We’ll start with the `Posts` component, which is used to render the feed of posts on the homepage of the application. +好哒,我们已经搭建好了 Apollo 的客户端实例,并且通过 `ApolloProvider` 的高阶函数让这个实例在整个应用中都可用。现在让我们来看一看如何运行 query 和 mutation。我们从 `Posts` 组件开始,`Posts` 组件将在我们的首页渲染一个帖子的列表。 ```javascript // client/src/components/Posts.js @@ -627,7 +627,7 @@ export const POSTS_SUBSCRIPTION = gql` // ... ``` -We start with our imports, and then write the queries we need to render the posts. There are two — the first is a basic query to fetch the list of posts (along with information about the user who wrote each post), and the second is a subscription query to notify us of any new posts, so we can live-update the screen and keep the feed up to date. +首先是各种库的引入,接着我们需要为我们想要渲染的帖子写一些查询。这里有两个 —— 首先是一个基础的获取帖子列表的 query(也包括帖子作者的信息),然后是一个 subscription,用来告知我们新帖子的出现,让我们可以实时地更新屏幕,保证我们的列表处于最新。 ```javascript // client/src/components/Posts.js @@ -667,13 +667,13 @@ const Posts = () => { }; ``` -Now we’ll implement the actual component. First, to run the base query, we render Apollo’s ``. It provides several render props to its child — `loading`, `error`, `data`, and `subscribeToMore`. If the query is loading, we just render a simple loading spinner. If there was an error, we render a generic `ErrorMessage` for the user. Otherwise, the query was successful, so we can render a `Feed` component (passing through the `data.posts` which contains the posts to be rendered, matching the structure of the query). +现在我们将实现真正的组件部分。首先,执行基本的查询,我们先渲染 Apollo 的 ``。它给它的子组件提供了一些渲染的 props —— `loading`,`error`,`data` 和 `subscribeToMore`。如果查询正在加载,我们就渲染一个简单的加载图片。如果有错误存在,我们渲染一个通用的 `ErrorMessage` 组件给用户。否则,就说明查询成果,我们就渲染一个 `Feed` 组件(`data.posts` 中包含着需要渲染的帖子,结构和 query 中的结构一致)。 -`subscribeToMore` is an Apollo helper for implementing a subscription that is only responsible for fetching new items in the collection that the user is currently viewing. It’s supposed to be invoked at the `componentDidMount` phase of the child component, which is why it’s passed through as a prop to `Feed` — `Feed` is responsible for calling `subscribeToNew` once the `Feed` has rendered. We provide `subscribeToMore` our subscription query and an `updateQuery` callback, which Apollo will invoke when it receives notice that a new post has been created. When that happens, we simply push the new post onto the existing array of posts, using [immer](https://github.com/immerjs/immer) to return a new object so the component correctly rerenders. +`subscribeToMore` 是一个 Apollo 帮助函数,用于实现一个只需要从用户正在浏览的集合中获取新数据的 subscription。它应该在子组件的 `componentDidMount` 阶段被渲染,这也是它被作为 props 传递给 `Feed` 的原因 —— 一旦 `Feed` 被渲染,`Feed` 负责调用 `subscribeToNew`。我们给 `subscribeToMore` 提供了我们的 subscription 查询和一个 `updateQuery` 的回调函数,该函数会在 Apollo 接收到新帖子被建立的通知时被调用。当那发生时,我们只需要简单将新帖子推入我们当前的帖子数组,使用 [immer](https://github.com/immerjs/immer) 可以返回一个新数组来确保组件可以正确地渲染。 -### Authentication (and mutations) +## 认证(和 mutation) -So now we’ve got a homepage that can render a list of posts, and can respond in realtime to new posts being created — how do new posts get created? For starters, we’ll want to allow users to sign in to an account, so we can associate them with their posts. This will require us to write a mutation — we need to send an email and password to the server, and get back a new authentication token for the user. Let’s get started with the login screen: +现在我们已经有了一个带帖子列表的首页啦,这个首页还可以实时的对新建的帖子进行响应 —— 那我们应该如何新建帖子呢?首先,我们需要允许用户用他们的账户登陆,那么我们就可以把他的账户和帖子联系起来。我们需要为此写一个 mutation —— 我们需要将电子邮件和密码发送到服务器,服务器会发送一个新的认证该用户的令牌。我们从登陆页面开始: ```javascript // client/src/pages/Login.js @@ -696,7 +696,7 @@ export const LOGIN = gql` `; ``` -The first section is similar to the query component — we import our dependencies and then write the login mutation. It accepts an email and a password, and we want to get back the ID of the authenticated user and their authentication token. +第一部分和 query 组件十分相似 —— 我们导入需要的依赖文件,然后完成登陆的 mutation。这个 mutation 接受电子邮件和密码作为参数,然后我们希望得到认证用户的 ID 和他们的认证令牌。 ```javascript // client/src/pages/Login.js @@ -716,7 +716,7 @@ const Login = () => { }; ``` -In the component body, we first fetch the current `token` and a `setAuth` function from context (more on this `AuthContext` in a second). We also set some local state using `useState`, so we can store temporary values for the user’s email, password, and whether their credentials are invalid (so we can show an error state on the form). Finally, if the user already has an auth token, they’re already logged in so we can just redirect them to the homepage. +在组件中,我们首先去从 context 中获取当前的 `token` 和一个叫 `setAuth` 的函数(我们会在下文中介绍 `setAuth`)。我们也需要使用 `useState` 来设置一些本地的状态,那样我们就可以为用户的电子邮件,密码以及他们的证书是否有效来存储临时值(这样我们就可以在表单中显示错误状态)。最后,如果用户已经有了认证令牌,说明他们已经登陆,那么我们就直接让他们跳转去首页。 ```javascript // client/src/pages/Login.js @@ -801,11 +801,11 @@ const Login = () => { export default Login; ``` -There’s a decent bit here, but don’t get overwhelmed — most of it is just rendering the Bootstrap components for the form. We start with a `Helmet` from [react-helmet](https://github.com/nfl/react-helmet) — this component is a top-level page (compared to `Posts` which is rendered as a child of the `Home` page) so we want to give it a browser title and some metadata. Next we render the `Mutation`, passing it our mutation query from above. If the mutation returns an error, we use the `onError` callback to set the state to invalid, so we can show an error in the form. The mutation passes a function to its child (named `login` here) which will invoke the mutation, and the second argument is the same array of values that we’d get from a `Query`. If `data` is populated, that means the mutation has successfully executed, so we can store our auth token and user ID with the `setAuth` function. The rest of the form is pretty standard React Bootstrap — we render inputs and update the state values on change, showing an error message if the user attempted a login but their credentials were invalid. +这里的代码看起来很洋气,但是不要懵 —— 这里大部分的代码只是为表单做一个 Bootstrap 组件。我们从一个叫做 `Helmet`([react-helmet](https://github.com/nfl/react-helmet)) 组件开始 —— 这是一个顶层的表单组件(相较而言,`Posts` 组件只是 `Home` 页面渲染的一个子组件),所以我们希望给他一个浏览器标题和一些 metadata。下一步我们来渲染 `Mutation` 组件,将我们的 mutation 语句传递给他。如果 mutation 返回一个错误,我们使用 `onError` 回调函数来将状态设为无效,来将错误显示在表单中。Mutation 将一个函数传将会递给调用他的子组件(这里是 `login`),第二个参数是和我们从 `Query` 组件中得到的一样的数组。如果 `data` 存在,那就意味着 mutation 被成功执行,那么我们就可以将我们的认证令牌和用户 ID 通过 `setAuth` 函数来储存起来。剩余的部分就是很标准的 React 组件啦 —— 我们渲染 input 并在变化时更新 state 值,在用户试图登陆,而邮件密码却无效时显示错误信息。 -What about that `AuthContext`? Once the user has authenticated, we need to somehow store their auth token on the client side. GraphQL wouldn’t really help here because it’s a chicken-and-the-egg problem — I need to have the auth token in order to authenticate the request in order to get the auth token. We could wire up Redux to store the token in local state, but that feels like overkill when we only need to store one value. Instead, we can just use the React context API to store the token in state at the root of our application, and make it available as needed. +那 `AuthContext` 是干嘛的呢?当用户被成功认证后,我们需要将他们的认证令牌以某种方式存储在客户端。这里 GraphQL 并不能帮上忙,因为这就像是个鸡生蛋问题 —— 发出请求才能获取认证令牌,而认证这个请求本身就要用到认证令牌。我们可以用 Redux 在本地状态中来存储令牌,但如果我只需要储存这一个值时,感觉这样做就太过于复杂了。我们可以使用 React 的 context API 来将 token 储存在我们应用的根目录,在需要时调用即可。 -First, let’s create a helper file which creates and exports the context: +首先,让我们建立一个帮助函数来帮我们建立和导出 context: ```javascript // client/src/util/context.js @@ -814,7 +814,7 @@ import { createContext } from "react"; export const AuthContext = createContext(null); ``` -And then we’ll create a `StateProvider` HOC which we’ll render at the root of the application — it will be responsible for keeping and updating the authentication state. +接下来我们来新建一个 `StateProvider` 高阶函数,这个函数会在应用的根组件被渲染 —— 它将帮助我们保存和更新认证状态。 ```javascript // client/src/containers/StateProvider.js @@ -861,9 +861,9 @@ const StateProvider = ({ client, socket, children }) => { export default withApollo(StateProvider); ``` -There’s a lot of stuff going on here. First, we create state for both the `token` and the `userId` of the authenticated user. We initialize that state by reading cookies, so we can keep the user logged in across page refreshes. Then we implement our `setAuth` function. If it’s invoked with `null` then it logs the user out; otherwise it logs the user in with the provided `token` and `userId`. Either way, it updates both the local state and the cookies. +这里有很多东西。首先,我们为认证用户的 `token` 和 `userId` 建立 state。我们通过读 cookie 来初始化 state,那样我们就可以在页面刷新后保证用户的登陆状态。接下来我们实现了我们的 `setAuth` 函数。用 `null` 来调用该函数会将用户登出;否则就使用提供的 `token` 和 `userId`来让用户登陆。不管哪种方法,这个函数都会更新本地的 state 和 cookie。 -There’s a major challenge with authentication and the Apollo websocket link. We initialize the websocket using either a token parameter if authenticated, or no token if the user is signed out. But when the authentication state changes, we need to reset the websocket connection to match. If the user starts out logged out and then logs in, we need to reset the websocket to be authenticated with their new token, so they can receive live updates for logged-in activities like chat conversations. If they start out logged in and then log out, we need to reset the websocket to be unauthenticated, so they don’t continue to receive websocket updates for an account that they’re no logger logged into. This actually turned out to be really hard — there is no well-documented solution and it took me a couple of hours to find something that worked. I ended up manually implementing a reset helper for the socket: +在同时使用认证和 Apollo websocket link 时存在一个很大的难题。我们在初始化 websocket 时,如果用户被认证,我们就使用令牌,反之,如果用户登出,则不是用令牌。但是当认证状态发生变化时,我们需要根据状态重置 websocket 连接来。如果用户是先登出再登入,我们需要用户新的令牌来重置 websocket,这样他们就可以实时地接受到需要登陆的活动的更新,比如说一个聊天对话。如果用户是先登入再登出,我们则需要将 websocket 重置成未经验证状态,那么他们就不再会实时地接受到他们已经登出的账户的更新。事实证明这真的很难 —— 因为没有一个详细记录的下的解决方案,这花了我好几个小时才解决。我最终手动地为套接字实现了一个重置函数: ```javascript // client/src/util.apollo.js @@ -876,19 +876,19 @@ export const refreshSocket = (socket) => { }; ``` -It disconnects the Phoenix socket, leaves the existing Phoenix channel for GraphQL updates, creates a new Phoenix channel (with the same name as the default channel that Abisnthe creates on setup), marks that the channel has not been joined yet (so Absinthe rejoins the channel on connect), and then reconnects the socket. Farther up in the file, the Phoenix socket is configured to dynamically look up the token in the cookies before each connection, so when it reconnects it will use the new authentication state. I found it frustrating that there was no good solution for what seems like a common problem, but with some manual effort I got it working well. +这个会断开 Phoenix 套接字,将当前存在的 Phoenix 频道留给 GraphQL 更新,创建一个新的 Phoenix 频道(和 Abisnthe 创建的默认频道一个名字),并将这个频道标记为连接(那样 Absinthe 会在连接时将它重新加入),接着重新连接套接字。在文件中,Phoenix 套接字被配置为在每次连接前动态的在 cookie 中查找令牌,那样每当它重联时,它将会使用新的认证状态。让我崩溃的是,对这样一个看着很普通的问题,却并没有一个好的解决方法,当然,通过一些手动的努力,它工作得还不错。 -Finally, the `useEffect` in our `StateProvider` is what invokes the `refreshSocket` helper. The second argument, `[token]`, tells React to reevaluate the function every time the `token` value changes. If the user has just logged out, we also call `client.clearStore()` to make sure that the Apollo client does not continue caching queries that contain privileged data, like the user’s conversations or messages. +最后,在我们的 `StateProvider` 中使用的 `useEffect` 是调用 `refreshSocket` 的地方。第二个参数 `[token]`告诉了 React 在每次 `token` 值变化时,去重新评估该函数。如果用户只是登出,我们也要执行 `client.clearStore()` 函数来确保 Apollo 客户端不会继续缓存包含着需要权限才能得到的数据的查询结果,比如说用户的对话或者消息。 -And that’s pretty much all there is for the client. You can look through the rest of the [components](https://github.com/schneidmaster/socializer/tree/master/client/src) for more examples of queries, mutations, and subscriptions, but the patterns are largely the same as what we’ve walked through so far. +这就大概是客户端的全部了。你可以查看余下的[组件](https://github.com/schneidmaster/socializer/tree/master/client/src)来得到更多的关于 query,mutation 和 subscription 的例子,当然,它们的模式都和我们所提到的大体一致。 -### Testing — client side +## 测试 —— 客户端 -Let’s write some tests to cover our React code. Our app comes with [jest](https://jestjs.io) built in (create-react-app includes it by default); jest is a fairly simple and intuitive test runner for JavaScript. It also includes some advanced features like snapshot testing, which we’ll put to use in our first test. +让我们来写一些测试,来覆盖我们的 React 代码。我们的应用内置了 [jest](https://jestjs.io)(create-react-app 默认包括它);jest 是针对 JavaScript 的一个非常简单和直观的测试运行器。它也包括了一些高级功能,比如快照测试。我们将在我们的第一个测试案例里使用它。 -I’ve come to really enjoy writing React tests with [react-testing-library](https://testing-library.com/react) — it provides a simple API which encourages you to render and exercise components from the perspective of a user (without delving into the implementation details of components). Also, its helpers subtly push you to ensure your components are accessible, as it’s hard to get a handle on a DOM node to interact with it if the node is not generally accessible in some fashion (correctly labeled with text, etc.). +我非常喜欢使用 [react-testing-library](https://testing-library.com/react) 来写 React 的测试案例 —— 它提供了一个非常简单的 API,可以帮助你从一个用户的角度来渲染和测试表单(而无需在意组件的具体实现)。此外,它的帮助函数可以在一定程度上的帮助你确保组件的可读性,因为如果你的 DOM 节点很难访问,那么你也很难通过直接操控 DOM 节点来与之交互(例如给文本提供正确的标签等等)。 -We’ll start with a simple test of our `Loading` component. The component just renders some static loading HTML so there’s not really any logic to test; we just want to make sure the HTML renders as expected. +我们首先开始为 `Loading` 组件写一个简单的测试。该组件只是渲染一些静态的 HTML,所以并没有什么逻辑需要测试;我们只是想确保 HTML 按照我们的预期来渲染。 ```javascript // client/src/components/Loading.test.js @@ -904,7 +904,7 @@ describe("Loading", () => { }); ``` -When you invoke `.toMatchSnapshot()`, jest will create a relative file under `__snapshots__/Loading.test.js.snap` to record the current state. Subsequent test runs will compare the output against the recorded snapshot, and fail the test if the snapshot doesn’t match. The snapshot file looks like this: +当你调用 `.toMatchSnapshot()` 时,jest 将会在 `__snapshots__/Loading.test.js.snap` 的相对路径下建立一个文件,来记录当前的状态。随后的测试会比较输出和我们所记录的快照(snapshot),如果与快照不匹配则测试失败。快照文件长这样: ```javascript // client/src/components/__snapshots__/Loading.test.js.snap @@ -928,9 +928,9 @@ exports[`Loading renders correctly 1`] = ` `; ``` -In this case, the snapshot test is not really all that useful, since the HTML never changes — though it does serve to confirm that the component renders without error. In more advanced cases, snapshot tests can be very useful to ensure that you only change component output when you intend to do so — for example, if you’re refactoring the logic inside your component but expecting the output not to change, a snapshot test will let you know if you’ve made a mistake. +在这个例子中,因为 HTML 永远不会改变,所以这个快照测试并不是那么有效 —— 当然它达到了确认该组件是否渲染成功没有任何错误的目的。在更高级的测试案例中,快照测试在确保组件只会在你想改变它的时候才会改变时非常的有效 —— 比如说,如果你在优化组件内的逻辑,但并不希望组件的输出改变时,一个快照测将会告诉你,你是否犯了错误。 -Next, let’s have a look at a test for an Apollo-connected component. This is where things get a bit more complicated; the component expects to have an Apollo client in its context, and we need to mock out the queries to ensure the component correctly handles responses. +下一步,让我们一起来看一个对与 Apollo 连接的组件的测试。从这里开始,会变得有些复杂;组件会期待在它的上下文中有 Apollo 的客户端,我们需要模拟一个 query 查询语句来确保组件正确地处理响应。 ```javascript // client/src/components/Posts.test.js @@ -960,9 +960,9 @@ describe("Posts", () => { }); ``` -We start out with some imports and mocks. The mock is to prevent the `Posts` component’s subscription from being registered unless we want it to. This was an area where I had a lot of frustration — Apollo has documentation for mocking queries and mutations, but not much by way of mocking subscriptions, and I frequently encountered cryptic, internal errors that were hard to track down. I never was able to figure out how to reliably mock the queries when I just wanted the component to execute its initial query (and **not** mock it out to receive an update from its subscription). +首先是一些导入和模拟。这里的模拟是避免 `Posts` 组件地 subscription 在我们所不希望地情况下被注册。在这里我很崩溃 —— Apollo 有关于有模拟 query 和 mutation 的文档,但是并没有很多关于模拟 subscription 文档,并且我还会经常遇到各种神秘的,内部的,十分难解决的问题。当我只是想要组件执行它初始的 query 查询时(而不是模拟收到来自它的 subscription 的更新),我完全没能想到一种可靠的方法来模拟 query 查询。 -This does give a good opportunity to discuss mocks in jest though — they’re extremely useful for cases like this. I have a `Subscriber` component which normally invokes the `subscribeToNew` prop once it mounts and then returns its children: +但这确实也给了一个来讨论 jest 的好机会 —— 这样的案例非常有效。我有一个 `Subscriber` 组件,通常在装载(mount)时会调用 `subscribeToNew`,然后返回它的子组件: ```javascript // client/src/containers/Subscriber.js @@ -979,9 +979,9 @@ const Subscriber = ({ subscribeToNew, children }) => { export default Subscriber; ``` -So in my test, I’m just mocking out the implementation of this component to return the children without invoking `subscribeToNew`. +所以,在我的测试中,我只需要模拟这个组件的实现来返回子组件,而无需真正地调用 `subscribeToNew`。 -Finally, I’m using `timekeeper` to freeze the time around each test — the `Posts` component renders some text based on the relation of the post time to the current time (e.g. “two days ago”), so I need to ensure the test always runs at the “same” time, or else the snapshots will regularly fail as time progresses. +最后,我是用了 `timekeeper` 来固定每一个测试案例的时间 —— `Posts` 根据帖子发布时间和当前时间(例如,两天以前)渲染了一些文本,那么我需要确保这个测试总是在“相同”的时间运行,否则快照测试就会因为时间推移而失败。 ```javascript // client/src/components/Posts.test.js @@ -1007,7 +1007,7 @@ describe("Posts", () => { }); ``` -Our first test checks the loading state. We have to wrap it in a few HOCs — `MemoryRouter` which provides a simulated router for any React Router `Link`s and `Route`s; `AuthContext.Provider` which provides the authentication state; and `MockedProvider` from Apollo. Since we’re taking an immediate snapshot and returning, we don’t actually need to mock anything; the immediate snapshot will just capture the loading state before Apollo has had a chance to execute the query. +我们的第一个测试检查了加载的的状态。我们必须把它包裹在几个高阶函数里 —— `MemoryRouter`,给 React Router 的 `Link` 和 `Route` 提供了一个模拟的路由;`AuthContext.Provider`,提供了认证的状态,和 Apollo 的 `MockedProvider`。因为我们已拍了一个即时的快照并返回,我们事实上不需要模拟任何事情;一个即时的快照会在 Apollo 有机会执行 query 查询之前捕捉到加载的状态。 ```javascript // client/src/components/Posts.test.js @@ -1057,7 +1057,7 @@ describe("Posts", () => { }); ``` -For this test, we want to snapshot the screen once loading has finished and the posts are being displayed. For this, we have to make our test `async`, and then use react-testing-library’s `wait` to await the load to finish. `wait(() => ...)` will simply retry the function until it doesn’t error — which hopefully shouldn’t be more than a fraction of a second. Once the text has appeared, we snapshot the whole component to make sure it’s what we expect. +对于这个测试,我们希望一旦加载结束帖子被显示出来,就立刻快照。为了达到这个,我们必须让测试 `async`,然后使用 react-testing-library 的 `wait` 来 await 加载状态的结束。`wait(() => ...)` 将会简单的重试这个函数直到结果不再错误 —— 通常情况下不会超过 0.1 秒。一旦文本显现出来,我们就立刻对整个组件快照以确保那是我们所期待的结果。 ```javascript // client/src/components/Posts.test.js @@ -1131,25 +1131,25 @@ describe("Posts", () => { }); ``` -Finally, we’ll test out the subscription, to make sure the component rerenders as expected when it receives a new post. For this case, we need to update the `Subscription` mock so that it actually returns the original implementation and subscribes the component to updates. We also mock a `POSTS_SUBSCRIPTION` query to simulate the subscription receiving a new post. Finally, similar to the last test, we wait for the queries to resolve (and the text from the new post to appear) and then snapshot the HTML. +最后,我们将会来测试 subscription,来确保当组件收到一个新的帖子时,它能够按照所期待地结果进行正确地渲染。在这个测试案例中,我们需要更新 `Subscription` 的模拟,以便它实际地返回原始的实现,并为组件订阅所发生的变化(新建帖子)。我们同时模拟了一个叫 `POSTS_SUBSCRIPTION` 地查询来模拟 subscription 接收到一个新的帖子。最后,同上面的测试一样,我们等待查询语句的结束(并且新帖子的文本出现)并对 HTML 进行快照。 -And that’s pretty much it. jest and react-testing-library are very powerful and make it easy to exercise our components. Testing Apollo is a bit of trouble, but with some judicious use of mocking I was able to write a pretty solid test that exercises all of the major component state cases. +以上就差不多是全部的内容了。jest 和 react-testing-library 都非常的强大,它们使我们对组件的测试变得简单。测试 Apollo 有一点点困难,但是通过明智地使用模拟数据,我们也能够写出一些非常完整的测试来测试所有主要组件的状态。 -### Server-side rendering +## 服务器端渲染 -There’s one more problem with our client application — all of the HTML is rendered on the client side. The HTML returned from the server is just an empty `index.html` file with a `