程序员疯狂记事:如何利用众多技术栈构建一个 Web 应用程序?!
【CSDN 编者按】“Elixir、Phoenix、Absinthe、GraphQL、React和Apollo”——在这几个关键词中,有几个是身为开发者的你一直想玩但还没来得及玩的东西?本文中,作者决定深入研究并用所有这些技术构建一个Web应用程序!这是一个疯狂的想法,但“如果一个技术堆栈的最终度量标准是用户体验,那么这种技术组合则是一个巨大的成功”。
作者 | Zack Schneider
译者 | 苏本如,责编 | 郭芮
出品 | CSDN(ID:CSDNnews)
以下为译文:
“Elixir、Phoenix、Absinthe、GraphQL、React和Apollo”——你是否和我一样,在这几个关键词中有3~4个属于“我一直想玩但还没来得及玩的东西”的范畴?React除外,因为我几乎每天都在工作中使用它,并且对它的前台后台都非常熟悉。几年前我在一个项目中使用了Elixir,但是已经过去好一段时间了,但我从未在GraphQL环境中使用它。类似地,我在一个项目上做了少量的工作,这个项目使用了带有Node.js的GraphQL(作为后端)和Relay(作为前端),但我在这个项目中几乎没有机会触及GraphQL,而且我从未使用过Apollo。
我坚信真正掌握一项技术的最好方法是用它来构建一些东西,所以我决定深入研究并用所有这些技术一起构建一个Web应用程序。
如果你想跳到本文结尾,这里可以直接下载代码(https://github.com/schneidmaster/socializer),而这里有个现场演示(https://socializer-demo.herokuapp.com/)。(现场演示运行在免费的Heroku平台的一个称为Dyno的服务器实例上,访问时可能需要等待30秒左右的时间才能唤醒这个实例。)
术语定义
首先,让我们浏览一下上文中提到的组件术语,看看它们都可以做什么。
Elixir是一种服务器端编程语言。
Phoenix是最受欢迎的基于Elixir的Web服务器框架。(Ruby+Rails组合 vs. Elixir+Phoenix组合)。
GraphQL是一种用于API的查询语言。
Absinthe是用于实现GraphQL服务器的最流行的Elixir库。
Apollo是一个流行的JavaScript库,用于使用GraphQL API。(Apollo还有一个服务器端包,用于在node.js中实现一个GraphQL服务器,但在这里我实现了利用Apollo客户机来使用Elixir GraphQL服务器。)
React是一个流行的JavaScript框架,用于构建前端用户界面。(你可能已经知道这个了。)
我要构建什么?
我决定构建一个小型社交网络应用。它看起来功能不太多,这样花费的时间不需要太长。但实际上它也相当复杂,因为我需要面临的是保证所有的功能在一个真实的应用中正常工作的挑战。
我给我的社交网络应用取了个新颖的名字“Socializer(社交家)”。这个应用的主要的功能是允许用户发帖和对其他用户的帖子发表评论。Socializer还提供了一个聊天功能;用户可以发起一个私人会话,并且每个会话允许任意数量的用户(如群聊)。
选用Elixir的理由?
在过去的几年里,Elixir语言逐渐流行起来。它运行在Erlang虚拟机上,尽管你可以直接在Elixir文件中使用Erlang语法,但Elixir语言的设计目的是在保持Erlang的性能和容错性的同时,为开发人员提供更加友好的语法。Elixir语言是动态类型的,语法看上去与Ruby相似。然而,它比Ruby更实用,有许多不同的习惯用法和模式。
对我个人来讲,Elixir语言的主要吸引力在于Erlang虚拟机的性能。坦率地讲,这听上去有点荒谬。因为WhatsApp开发团队使用Erlang虚拟机已经可以使单个服务器支持多达200万个连接。一个Elixir/Phoenix服务器通常可以在不到1毫秒的时间内响应一个简单的请求;在服务器端日志中看到请求持续时间毫秒级的μ符号时让人激动不已。
Elixir语言还有其它优点。它支持容错;你可以将Erlang虚拟机看作一个节点集群,其中任何一个节点宕机都不会影响其它节点。这使得“代码热更新”成为可能,也就是说,你无需停止和重新启动应用程序,就可以部署新代码。在使用它的模式匹配和管道操作符中我也找到了很多乐趣。更让人耳目一新的是实现了强大功能的Elixir代码看上去像Ruby代码一样美观。我发现这一点让我在编写代码时保持思路清晰,从而减少了bug出现的次数。
选用GraphQL的理由?
对于传统的RESTful API,服务器端定义了它提供的资源和路由(通过API文档,或者通过一些自动化的文档工具(如Swagger)),使用者必须做出正确的调用顺序来获取他们想要的数据。
假设服务器上有一个posts endpoint(端点 - 在这里指资源路径)用来获取一个帖子(或列表),一个comments 端点用来获取对帖子的评论,以及一个users 端点用获取作者的名字和头像,那么阅读一篇帖子可能需要发出三个单独的请求来获取所需要的所有信息。(对于这样一个微不足道的例子,显然API方式能够大让你获取相关的数据,但也说明了这种方式的缺点(请求结构是由服务器决定的,而不是与每个请求和页面的实际需求相匹配)。GraphQL则反转了这一原则,客户端可以一次性地发送一个查询文档描述其需要的数据结构(这些数据可以储存在不同的表中),服务器则一次性地返回所有数据。
以我们的应用为例,一个帖子的查询文档可能像下面这样:
这个查询文档描述了客户端可能需要在帖子页面上呈现的所有信息:帖子ID、正文和时间;发帖用户的ID、名字和头像URL;所有评论的ID、正文和时间;以及评论用户的ID、名字和头像URL。这个文档结构灵活直观,非常适合构建接口,因为你只需要描述你所需的数据,而不需要对API返回的数据结构进行二次加工。
在GraphQL中还有另外两个关键概念:mutations(更改)和subscriptions(订阅)。mutations是对服务器上的数据进行更改的操作;它相当于RESTful API中的POST/PATCH/PUT操作。语法与查询非常相似;一个创建新帖子的mutation可能像下面这样:
记录的属性以参数形式提供,方括号里描述了mutation完成后需要返回的数据结构。(在本例中是新帖子的ID、正文和创建时间)。
Subscription(订阅)对于GraphQL是相当独特的;在RESTful API中没有对应的操作。它允许客户端告诉服务器,每当特定事件发生时,它希望从服务器接收实时更新。例如,如果我希望应用的首页每当有新帖子创建时都能实时更新,我就可以编写这样的subscription:
正如你可以直观地从上面代码看到,这个subscription告诉服务器在任何一个新的帖子创建时,向它发回一个实时更新,包括帖子的ID、正文和时间,以及作者的ID、名字和头像URL。Subscription通常由websockets支持;客户端负责建立并维持着和服务器端的socket连接,服务器端则在事件发生时向下发送一条消息给客户端。
最后要提一下:GraphQL有一个很棒的开发工具,叫做GraphiQL。它是一个带有实时编辑器的Web界面,你可以在其中编写查询、执行查询并查看结果。它包括自动补全和其他帮助性的功能,可以很容易地找到可用的查询和字段;当你在查询结构上进行迭代时,它特别有用。你可以在这里(https://brisk-hospitable-indianelephant.gigalixirapp.com/graphiql)试用我的Web应用程序的GraphiQL接口,尝试向它发送以下查询,以获取相关的帖子列表(以下是上面假设示例的稍微精简的版本):
选用Apollo的理由?
Apollo已经成为服务器端和客户端上最流行的GraphQL库之一。
我以前的GraphQL的经验是在2016年使用Relay的时候获得的,Relay是另一个客户端JavaScript库。老实说,我讨厌它。我被GraphQL查询的简单表现方式所吸引,但是Relay挺复杂,让我很难把精力集中在它身上;Relay文档中有很多行业术语,我发现它很难让我建立起一个能让我理解的知识基础。公平地说,我用的是Relay的1.0版本;现在的Relay库已经作了重大的简化(他们称之为Relay Modern),而且文档也改进了很多。
但是这里我想尝试一些新东西,Apollo之所以受到追捧,部分原因是它为构建一个GraphQL客户端应用程序提供了一种更简单的开发体验。
服务器端的构建
首先,开始构建我的应用程序的服务器端;如果没有数据可供使用,客户端就没有那么有趣了。我也很好奇GraphQL是否能够像它承诺的那样让我能够编写客户端查询,获取我所需的所有数据(而不是在客户端的实现完成后,需要返回服务器端进行更改)。
具体来说,我首先定义了我的应用程序的基本模型结构。大致如下:
看上去非常简单。Phoenix允许你编写类似于Rails中的数据库迁移程序。下面是一个用于创建用户表的迁移程序的例子:
你可以在这里(https://github.com/schneidmaster/socializer/tree/master/priv/repo/migrations)看到其他所有表的迁移程序。
接下来,我实现了model(模型)类。Phoenix为它的model使用了一个名为Ecto的库;你可以将Ecto视为类似于Rails的ActiveRecord库,但它与框架的耦合度较低。一个主要的区别是,Ecto的model没有任何实例方法。Model实例只是一个struct(就像一个带有预定义键的散列表);你在model上定义的方法是接受一个struct类型的“实例”,并以某种方式更改它并返回结果的类方法。这种方法在Elixir中是惯用做法。Elixir偏好函数编程,变量是不可变的。
以下是Post模型的代码:
首先,我们引入(import)了一些其他模块。在Elixir中,import指令被用来引入函数模块(类似于Ruby的include指令);use指令用来在指定的模块上使用__using__宏。宏是Elixir的元编程机制。alias指令为模块创建别名, 它的目的是减少输入,默认alias会取模块名字的最后一部分作为别名(如我可以用User来代替Socializer.User,而不需要在任何地方都要输入完整的模块名字)。
接下来是schema。 Ecto的model必须显式描述schema中的每个属性(这点与ActiveRecord不同,ActiveRecord可以对底层数据库表进行内省并为每个字段创建属性)。schema宏由前一节提到的中的use Ecto.Schema提供。
在schema之后,我定义了一些helper函数,用于从数据库中获取帖子。Ecto的Repo模块负责所有数据库查询;例如,Repo.get(Post, 123)将获取id为123的帖子信息。而search方法中的数据库查询语法由类顶部的import Ecto.Query提供。
最后,__MODULE__是当前模块(如Socializer.Post)的简写。
changeset方法是Ecto创建和更新记录的方法:从定义一个struct类型的Post模型(从现有的post或一个空的post获取)开始,然后调用cast函数执行属性的“强制转换”,再运行所有验证,最后将其插入数据库。
这就是我们的第一个模型。其余的模型可以在这里(https://github.com/schneidmaster/socializer/tree/master/lib/socializer)找到。
GraphQL schema
接下来要实现的是连接服务器的GraphQL组件。通常做法有两种:使用types文件或resolvers(解析器)。types文件使用类似DSL的语法声明可查询的对象、字段和关系。而解析器则是告诉服务器如何响应任何给定查询的“粘合剂”。
下面是帖子的types文件:
在use和import指令之后,我们首先简单地为GraphQL定义一个:post对象。该对象的ID、body和inserted_at字段将直接隐式地使用Post struct中的值。接下来,我们声明两个与帖子关联的查询字段:查询创建帖子的用户和针对帖子的评论。我将覆盖评论的关联查询assoc(:comments,…),以确保始终按照插入评论的时间顺序返回评论。这里我要注意的是,Absinthe能够透明地处理查询和字段名的大小写的翻译,因为Elixir的变量名和方法名通常采用蛇形命令法(即用下划线将单词连接起来),而GraphQL查询则采用驼峰命名法(一个单词首字母小写,后面单词首字母大写)。
接下来,我们将声明两个和帖子相关的根级查询: posts查询网站上的所有帖子,而post则按ID查询单个帖子。上面的types文件只是简单地声明了查询以及参数和返回类型;实际上的实现交给了解析器。
在查询之后,我们声明一个允许在站点上创建新帖子的mutation。与查询一样,types文件只是简单地声明了mutation的元数据,实际上的实现同样交给解析器处理。
最后,我们声明一个与帖子相关的subscription(订阅)::post_created,以便客户端在任何一个新帖子创建时接收到一个更新。这里的config用于设置订阅, trigger则告诉Absinthe什么mutation会激活订阅,而topic则允许区分不同的订阅响应。
在本例中,我们希望在有任何帖子变化时,客户端都要进行更新。而在其它场景,我们可能只希望在某些特定情况下客户端会受到提醒。例如,针对一个评论的订阅,客户端只想在特定帖子(而不是每个帖子)有了新评论时被提醒,因此它会提供一个post_id作为topic的参数。
虽然我已经将types文件分解为每个模型的单独文件,但值得注意的是,Absinthe要求将所有types文件组装到同一个Schema模块中。看起来像下面这样:
Resolvers(解析器)
正如我上面提到的,解析器是GraphQL服务器的“粘合剂”:它们提供了数据查询和修改(mutation)的逻辑。下面让我们看看post 解析器的代码:
最开始的两个方法处理上面定义的两个查询:加载所有帖子列表和加载一个特定的帖子。Absinthe期望每个解析器的方法返回一个元组:要么是:{:ok, requested_data},要么是{:error, some_error}(一般来说,这是Elixir方法的一种常见模式)。show方法中的case语句是Elixir模式匹配的一个很好的例子:如果Post.find方法返回空值,则返回错误元组;否则返回查询到的帖子。
接下来,我们看看create解析器。这个解析器包含创建新帖子的逻辑。这也是通过方法参数进行模式匹配的一个很好的例子:Elixir允许重载方法名,并将选择执行按照声明的模式匹配上的第一个实现方法。在本例中,如果传入的第三个参数是带有context键的一个map (键值对),而这个map中又包含带有current_user键的一个map,则第一个方法被调用;如果参数中没有身份认证令牌,则会转到第二个方法并返回错误。
最后,有一个简单的helper方法,在帖子属性无效(如body为空)时返回错误信息。Absinthe希望返回的错误消息要么是一个字符串、要么是一个字符串数组,要么是一个带有field和message键的关键字列表数组。在本例中,每个字段上的Ecto验证错误信息会被提取到这样的一个关键字列表中。
上下文/身份验证
上一节中提到了一个认证查询的概念。在本例中,我们简单地用authorization header(头信息)中的一个Bearer: token 来表示。在这个解析器中我们如何从token中获取current_user上下文呢?这里使用了一个定制的Plug来读取头信息并从中获取current_user。在Phoenix中,Plug作为管道的一部分,可以用来组合一系列的动作来完成一个请求。比如说你的plug可以组合起来解码JSON、添加CORS头信息,或处理请求的任何其他可组合部分。本例中的Plug如下所示:
前两个方法只是初始化工作,没有任何有趣的事情要做(其它场景下,初始化方法可能需要基于配置选项做一些工作),当这个plug被调用时,我们希望它做的只是在请求上下文中设置current user。
有意思的工作发生在build_context方法里。with语句是Elixir中模式匹配的另一个变体;它允许你执行一系列不对称的执行步骤并对执行结果进行操作。在本例中,我们获取授权header头信息;然后解码出身份认证token(使用Guardian库);再然后查找用户。如果以上步骤都成功了,那么我们将进入到with块,简单地返回包含current user的map(键值对)。如果任何一个步骤失败(第二步可能返回一个模式匹配失败的{:error, ...}元组;如果用户不存在,第三步可能返回空值),则else块将被执行,current user不会被设置。
服务器端的测试
现在所有的服务器端的代码都已写好,如何确保它能工作无误呢?这里有几个不同层面的测试需要考虑。
首先,我们应该对模型进行单元测试,以确认它们能否正确地验证,以及helper方法能否返回预期的结果。第二,我们应该对解析器进行单元测试,以确认它们能够处理不同的情况(成功和错误),返回正确的查询结果,或者执行了正确的变更。第三,我们应该编写一些完整的集成测试,向服务器发送一个查询,并期望它能返回正确的响应。这有助于我们掌握全局信息,并确保我们涵盖诸如身份认证逻辑之类的情况。第四,我们希望测试订阅功能,以确保在进行相关的变更发生时,它们能正确地发出提醒。
Elixir有一个名为ExUnit的简单的内置测试库。它包含简单的assert/refute 帮助程序和运行测试的句柄。在Phoenix中, 使用“case” 设置文件也是很常见的;这些文件包含在运行常见设置任务(如连接到数据库)的测试中。除了默认值之外,我发现有两个helper库(ex_spec和ex_machina)在我的测试中很有用。ex_spec添加了简单的describe和it宏,这使得测试语法多了一点友好,至少从我的Ruby背景来看是这样的。ex_machina提供的factory(工厂)库使得动态插入测试数据变得容易。
下面是我编写的factory源代码:
在将factory导入“case”设置文件后,就可以用非常直观的语法来写测试代码:
有了“case” 设置文件,Post模型的测试脚本就可以写成这样:
# 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
这个测试脚本看上去相当直观:对于每种情形,我们插入需要的测试数据,调用被测试的方法,并对结果做出断言。
接下来,我们来看一个解析器的测试脚本:
# 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
解析器测试也相当简单,它们也是一种单元测试,只需要在模型的上一层运行。我们插入任何设置数据,调用解析器,并期望返回正确的结果。
集成测试变得有点复杂。我们需要建立一个连接(可能有身份验证),并向它发送一个查询,以确保得到正确的结果。这个链接(https://tosbourn.com/testing-absinthe-exunit)中的文章对学习如何设置Absinthe的集成测试非常有帮助。
首先,我们创建一个集成测试需要的一些常见功能的helper文件:
上面有三种helper方法。第一个获取一个连接对象和一个用户,并通过为该用户添加一个带有身份验证token的头信息来对连接进行身份验证。第二个和第三个helper方法接受一个查询并返回JSON结构的数据,这个JSON数据用于包装一个GraphQL查询。
接下来我们看看测试脚本:
这个测试是使用posts端点来查询帖子列表。我们首先向数据库中插入一些帖子;编写查询;提交到连接的测试服务器;然后检查测试服务器的响应以确保测试数据按预期返回。
posts端点还有一个非常类似的测试来显示单个帖子的内容,但是为了简洁起见,我们将跳过它(如果有需要,你可以在这里查看所有集成测试脚本)。接下来让我们来看看创建帖子的集成测试脚本:
非常相似,但在请求方面有两个不同之处——我们在管道中加入了AbsintheHelpers.authenticate_conn(user) 来验证用户的token,并且调用了mutation_skeleton helper,而不是查询时的query_skeleton。
订阅测试怎么实现呢?我们需要增加一点设置来创建一个socket连接,然后在上面建立和执行订阅。这个链节中的文章对于理解订阅测试的设置非常有帮助。
首先,我们为订阅测试创建一个新的“case”设置文件。内容如下:
在导入一些通用的库之后,我们定义了一个setup步骤,在这一步插入一个新用户,并设置一个使用该用户的令牌进行身份验证的websocket。我们返回socket和这个新用户以供测试使用。
接下来,让我们看看测试脚本:
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
首先,我们编写一个订阅query,并将它推送到我们在测试设置期间构建的socket连接上。接下来,我们编写一个触发订阅(如创建一个新帖子)的mutation,并将其推送到socket连接上。最后,我们检查push的响应结果以断言我们被推送了一个关于新创建的帖子的更新。这里需要多一点的设置,但这为我们提供了一个很好的基于整个订阅生命周期的端到端测试。
客户端的构建
以上对服务器端的所有实现作了一个详尽的概述:它处理了在types文件定义,在解析器中实现查询,使用模型来进行查询和更改持久层的数据。接下来,让我们看看客户端如何构建的。
我从create-react-app开始起步,create-react-app非常适合自引导的React项目,它用合理的默认值和层次结构设置了一个“Hello World”React应用,并将许多配置抽象出来。
在我的应用中,我使用了React Router来实现路由;这将允许用户在帖子列表、单个帖子、聊天对话等功能之间导航。
我的应用程序的根组件如下所示:
这里有几个需要注意的地方:
util/apollo公开了一个createClient函数,该函数创建并返回一个Apollo客户机实例(下一步将详细介绍)。将这个实例包装在一个useRef中使得同一个客户机实例在应用程序的整个生命周期内(如跨页面重新渲染)可用。ApolloProvider HOC(高阶组件)使客户机实例在上下文中可供子组件/查询使用。BrowserRouter使用HTML5 history API在应用中导航时保持URL状态同步。Switch和Route组件值得进一步讨论。
React Router(路由器)是基于“动态”路由的概念构建的。大多数Web服务器框架使用“静态”路由,也就是说,一个URL正好匹配一个路由,并基于该路由渲染整个页面。而使用“动态”路由,路由会散布在整个应用程序中,并且同一URL可以匹配不止一个路由。这听起来很困惑,但一旦你掌握了它的窍门,它真的很好用。它使得构建具有不同组件的页面变得容易,这些组件对路由的不同部分做出反应。
例如,假设一个类似于Facebook Messenger的页面(Socializer的聊天界面与之相似),左侧始终显示对话列表,右侧仅显示选中的对话。动态路由就允许我像下面这样做:
如果路由以/chat开头(可能结尾有一个ID,即/chat/123),则根级的App将渲染Chat组件。而Chat组件先渲染左侧对话列表(应该始终可见),然后根据id渲染自己的路由来显示ID代表的会话(注意这个路由id后不带问号,所以:id参数是必须有的),否则将进入空状态。这是动态路由的强大功能。它允许你根据当前的URL逐步渲染不同的页面片段,同时将基于路由的关注点定位到相关组件。
即使使用动态路由,有时也只需要渲染一个路由(类似于传统的静态路由)。这就是引入Switch组件的原因。如果没有Switch组件,React Router将渲染与当前URL匹配的每个组件,因此在上面的Chat组件中,我们将同时获得会话和空状态消息。Switch组件告诉React Router只渲染与当前URL匹配的第一个路由,而忽略其余路由。
Apollo客户机
既然我们已经了解了这一点,那么让我们再深入一点研究Apollo客户机,特别是上面提到的createClient函数。util/apollo.js文件如下所示:
开始很简单,导入了一系列我们很快就会用到的依赖库,并根据当前环境为HTTP URL和websocket URL设置常量,分别指向生产环境的Gigalixir实例,和开发环境的localhost。
Apollo客户机实例要求你向它提供一个链接,实际上这是一个到你的GraphQL服务器的连接,Apollo客户机可以使用它来发出请求。有两种常见的链接:第一特别是是HTTP链接,它通过标准HTTP协议向GraphQL服务器发出请求。第二种是websocket链接 它建立并打开一个到GraphQL服务器的websocket连接并通过socket发送查询。在本例中,两者都需要。对于常规查询和更改,我们使用HTTP链接,而对于订阅,我们使用websocket链接。
Apollo提供了split方法,它可以让你根据选择的不同条件将查询路由到不同的链接。你可以把它看作一个三元表达式:如果这是一个订阅,则通过socket链接发送,否则通过http链接发送。
如果用户当前是登录状态,我们可能还需要为两个链接提供身份验证。当用户成功登录后,他们的身份验证令牌就被设置为一个token cookie(稍后将详细介绍)。在上一节中,当我们建立Phoenix websocket连接时,我们使用了这个token作为参数。 而在这里,我们使用setContext wrapper在通过HTTP链接的请求的授权头信息上设置这个token。
除了链接之外,Apollo客户机还需要一个缓存实例;GraphQL服务器自动缓存查询结果,以防止对相同数据的重复请求。简单的InMemoryCache对于大多数场景都能很好满足,它只需要将缓存的查询数据保持在本地浏览器中。
使用客户机来实现我们的第一个查询
好了,我们已经设置好了Apollo客户机实例,并通过ApolloProvider HOC保证了在整个应用程序中都能使用这个实例。接下来让我们看看如何用它来执行查询和更改。我们将从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
}
}
}
`;
// ...
组件以一系列的导入指令开始,然后编写获取帖子的查询。这里有两个查询:第一个查询是获取帖子列表(以及每个帖子的作者的信息),第二个是订阅查询,用于在任何新帖子创建时通知我们,这样我们就可以实时更新页面并使之保持最新状态。
现在我们来实现实际的组件。首先,我们渲染Apollo的<Query query={GET_POSTS}>来运行基查询。它为它的子查询提供了几个渲染属性:loading,error,data,和subscribeToMore。如果一个查询正在加载,那么页面上会出现一个简单的加载图标。如果查询出现了错误,那么页面上会出现一个通用的错误消息(ErrorMessage)来提醒用户。否则的话,就是查询成功了,Feed组件就会被渲染(通过data.posts,其中包含要渲染的帖子,与查询的结构匹配)。
subscribeToMore是一个用来实现订阅的Apollo helper,它只负责获取用户当前查看的集合中的新的内容。它应该在子组件的componentDidMount阶段调用,这是它作为一个prop传递给Feed的原因。而Feed则负责在Feed渲染后调用subscribeToNew。我们这里提供了subscribeToMore订阅查询和updateQuery回调方法,这样Apollo客户机将在收到创建新帖子的通知时调用该回调方法。当这种情况发生时,我们只需将新的帖子推到现有的帖子数组中,然后使用immer返回一个新对象,以便组件能正确地被渲染。
身份认证(和更改)
现在主页已经完成,它可以渲染一个帖子列表,并且可以随着新帖子的创建而实现变更。
那么新帖子是如何创建的呢?对于初学者,我们希望允许用户登录到一个帐户,这样我们就可以将他们与他们的帖子关联起来。这将要求我们编写一个mutation:我们需要向服务器发送一个email和password,并为用户返回一个新的身份验证令牌。接下来让我们从login页面开始:
Login组件的第一部分和查询组件类似,我们导入一系列依赖项,然后编写login mutation这个login mutation接受一个email和一个password,而我们则希望它返回经过身份认证的用户ID和用户的身份认证令牌。
在组件的主体中,我们首先从上下文获取当前的token和setAuth函数(稍后将详细介绍AuthContext)。我们还使用useState设置了一些本地状态,这样我们就可以存储用户的email、password,以及他们的身份是否无效的临时值,第三个临时值是为了方便在表单上显示错误状态的。最后,如果用户已经获得了一个身份验证令牌,则表明他们已经成功登录,因此我们将他们重定向到首页。
// 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组件渲染login表单。我们从react-helmet组件定义的头部Helmet开始。相对于Posts这个为渲染首页的一个子集的组件而言,这个Helmet组件是一个顶级页面,所以我们在这里赋给它一个浏览器标题和一些metadata(元数据)。接下来我们开始渲染 mutation组件,将mutation 查询的结果传递给它。如果返回的是个错误,就使用onError回调函数将login状态设置为无效,这样我们就可以在表单上显示一个出错信息。否则就将login函数作为第一个参数传递给它的子组件,由它来调用该mutation。第二个参数则和我们从Query组件中得到的值数组相同。如果data被填充,这意味着mutation已经成功执行,所以我们可以调用setAuth函数来存储认证令牌和用户ID。表单的其余部分是相当标准的React Bootstrap代码:我们渲染输入并在更改时更新状态值,如果用户试图登录但是认证无效,则会显示一条出错消息。
那么AuthContext在这里起什么作用呢?一旦用户通过了身份验证,我们需要以某种方式在客户端存储他们的身份认证令牌。GraphQL在这里没有真正的帮助,因为这是一个“先有鸡还是先有蛋”的问题, 这个问题就好比“我们需要有个身份认证令牌来验证请求,以便获得身份认证令牌”。我们当然可以引入Redux将令牌存储在本地,但是感觉有点小题大做,因为我们只需要存储一个值。这时候,AuthContext就可以用上了,我们可以使用React context API将令牌状态存储在应用程序的根中,并根据需要提供出去。
首先,让我们创建一个helper文件来创建和导出上下文:
然后我们将创建一个StateProvider高阶组件(HOC),我们将在应用程序的根中渲染它。它将负责保持和更新身份认证状态。
// 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创建了一个状态值。我们通过读取cookie来初始化这个状态,这样我们就可以让用户在跨页刷新时保持登录状态。然后我们实现setAuth函数。如果使用空值调用它,那么它将注销用户;否则,它将使用提供的token和userId 让用户登录系统。不论哪种情况,它都会更新本地状态和cookies。
身份认证和Apollo websocket链接存在一个重大挑战。我们要么使用一个令牌参数(如果认证通过),要么不使用令牌参数(如果用户已注销)来初始化websocket。但是,当身份认证状态更改时,我们需要重置websocket连接以适应这个更改。如果用户从注销状态变成登录状态,我们需要重置websocket连接以更新他的新令牌,这样他们才能接收像登录用户的聊天对话这样的活动的实时更新。如果他们从登录状态变成注销状态,我们也需要重置websocket连接以将令牌设置为空,这样他们就不会继续接收websocket更新。事实证明,这是非常困难的,没有一个现成的解决方案。所以我花了几个小时找到了有效的解决方案。最终手动写了一个重置socket的helper(如下):
首先它断开Phoenix socket连接,丢弃了现有的执行GraphQL更新的Phoenix channel,创建一个新的Phoenix channel(使用了Abisnthe安装时创建的默认channel同样的名字),然而把这个channel标记为尚未加入(这样Abisnthe在连接时就会重新加入这个channel),然后重新建立socket连接。 在文件的后面,Phoenix socket配置为在每次连接之前动态查找cookie中的令牌,因此当它重新连接时,将使用新的身份认证状态。对于这样一个常见的问题,没有好的解决方案,这点让我很沮丧。虽然最后通过一些手动操作,我终于让它很好地工作起来。
最后提一下,StateProvide中的useEffect用来调用refreshSocket Helper的。第二个参数[token]告诉React在每次token值更改时重新执行该函数。如果用户刚刚注销,我们还需要调用client.clearStore()以确保Apollo客户端不会继续缓存包含权限数据的查询,如这个刚刚注销的用户的对话或消息。
以上部分几乎涵盖了客户端的全部内容。你可以查看这些组件的其它内容获得更多关于查询、更改和订阅的示例。基本上它们的实现方式和我们上面谈到的大致相同。
客户端测试
现在我们来编写一些测试程序来测试我们的React代码。我们的应用程序内置了jest这个前端测试框架(create-react-app默认包括了它);jest是一个非常简单和直观的Javascript测试框架。它还包括一些高级功能,如快照测试,我们将在第一次测试中使用它。
我真的很喜欢用react-testing-library编写React测试程序。它提供了一个简单的API,鼓励你从用户的角度渲染和运行组件(而无须深入了解组件的实现细节)。另外,它的帮助工具(helper)可以巧妙地帮助确保你的组件是可访问的,因为如果节点通常不能以某种方式访问(正确地用文本标记等等),那么很难在DOM节点上获得与之交互的句柄。
我们将从加载组件的简单测试开始。组件只是渲染一些静态加载的HTML,因此没有真正需要测试的逻辑;我们只想确保HTML按照预期渲染。
当你调用.toMatchSnapshot()时,jest将在__snapshots__/Loading.test.js.snap下创建一个相对文件来记录当前状态。随后的测试将把输出与记录的快照进行比较,如果快照不匹配,测试将失败。快照文件如下所示:
在这种情况下,快照测试并没有那么有用,因为HTML永远不会改变。尽管它确实可以确认组件没有错误地渲染。对于更复杂的测试,快照测试可以非常有用,它能确保只有在你打算更改组件输出时才更改组件输出。例如,如果你正在重构组件内部的逻辑,但是希望输出不会更改,那么快照测试可以让你知道你是否犯了错误。
接下来,让我们来看一个Apollo连接组件的测试。这就是事情变得更加复杂的地方;组件希望在其上下文中有一个Apollo客户机,我们需要模拟查询以确保组件能正确地处理响应。
我们先从一些imports和mocks指令开始。Mock指令是为了防止Posts组件的订阅被注册,除非我们希望这样做。Apollo有关于模拟查询和变更的文档,但关于模拟订阅的文档不多,这点让我很沮丧。我经常遇到难以追踪的神秘的内部错误。当我只想让组件执行其初始查询(而不模拟它接收来自其订阅的更新)时,我一直无法弄清楚如何才能可靠地模拟查询。
不过,这确实给了一个很好的机会来讨论jest的Mock功能。它们对于类似的情况非常有用。我有一个Subscriber组件,它通常在它挂载后调用subscribeToNew,然后返回它的子组件:
所以在我的测试中,我只是模拟了这个组件的实现,以返回子组件而不调用subscribeToNew。
最后,我使用timekeeper冻结每个测试的时间。Posts 组件根据帖子的发布时间与当前时间的关系(例如“两天前”)渲染一些文本,因此我需要确保测试始终在“同一时间”运行,否则快照会随着时间的推移而定期失效。
我们的第一个测试是检查加载状态。我们必须将它包装在下面几个高端组件(HOC)中。包括MemoryRouter(它为任何React路由器的Links和Route提供模拟路由器),AuthContext.Provider组件(它提供身份验证状态)和来自Apollo的MockedProvider组件。由于我们要做的是获取即时快照并返回,因此实际上不需要模拟任何内容;即时快照会在Apollo有机会执行查询之前捕获加载状态。
// 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()方法等待加载完成。wait(() => ...)将简单地重试该函数,直到它不会报错。希望不会超过一秒钟。一旦文本出现,我们就对整个组件进行快照,以确保它是我们所期望的。
// 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模拟,以便它实际返回原始实现并订阅要更新的组件。我们还模拟一个POSTS_SUBSCRIPTION的查询来模拟一个接收新帖子的订阅。最后,与上一个测试类似,我们等待查询被解析(以及新帖子的文本显示出来),然后快照HTML。
客户端测试差不多要讲的就是这些。jest和react-testing-library非常强大,我们可以使用它们轻松地测试组件。测试Apollo有点麻烦,但是通过审慎合理地使用mocking,我们可以编写可靠的测试程序来测试所有的主要组件的状态情况。
服务器端渲染
客户端应用程序还有一个问题,即所有的HTML都在客户端渲染。从服务器返回的HTML只是一个空的index.html文件,带有一个<script> 标签来加载实际渲染所有内容的Javascript。这对于开发来说不错,但对于生产环境来说不是很好。例如,许多搜索引擎在运行JavaScript和索引客户端渲染的内容方面都做得不好。我们真正想要的是服务器返回页面的完全渲染的HTML,然后React可以接管客户端来处理后续的用户交互和路由。
这就引入了服务器端渲染(或SSR)的概念。事实上,我们并不提供静态HTML索引文件,而是将请求路由到node.js服务器。服务器渲染相应的组件(把任何查询解析到GraphQL端点),并返回相应的HTML输出和加载JavaScript的<script>标签。当JavaScript在客户机上加载时,它不是从头开始完全渲染,而是保留服务器提供的现有HTML并将其连接到匹配的React树。这种方法允许搜索引擎轻松索引服务器渲染的HTML,并为用户提供更快的体验。因为在页面内容可见之前,用户无需等待Javascript下载、执行和运行查询。
不幸的是,我发现SSR配置就像荒野中的杂草一样毫无章法。基本原理都是一样的,就是运行一个渲染组件的node.js服务器。但是实现就是五花八门,没有任何标准化的做法。我将应用程序的大部分配置从cra-ssr中提取出来(cra-ssr为使用create-react-app创建的应用程序提供了一个相当全面的SSR实现)。但我不想在这里探讨太深,因为cra-ssr的教程提供了一个详尽的介绍。我只想说SSR很棒,它让应用程序感觉加载非常快,但是让它工作有点困难。
结论和教训
谢谢你陪伴到现在!这篇文章涵盖的内容很多,但我期望的是真正地深入研究一个相当复杂的应用程序,彻底地运用这里提到的所有技术堆栈,并解决实际用例中出现的问题。如果你已经读到这里,希望你已经能够很好地理解所有的部分如何配合在一起工作。你可以在Github上浏览完整的代码库,或者尝试一下现场演示。
我的开发经历并没有彻底摆脱挫折和麻烦。其中一部分可以作为新框架或库的学习曲线。但我认为在某些领域,更好的文档或工具可以为我节省大量时间和减少痛苦。尤其是Apollo,我很难弄清楚当身份认证状态改变时如何让websocket重新初始化它的连接;这似乎是一个很常见的问题,应该在某个文档中记录下来,但是我什么都找不到。
同样地,我在测试订阅方面遇到了很多困难,最终放弃并使用了一个模拟。关于测试的文档对于基本测试的“快乐之路”很有帮助,但是当我开始研究更高级的用例时,我发现这些文档很肤浅。有时候我也会因为缺乏基本的API文档而感到困惑,这些缺失的API文档部分是关于Apollo,部分是关于Absinthe的客户端库。比如,在研究如何重置websocket连接时,我找不到任何关于Absinthe的socket实例或Apollo的link实例的API文档。 基本上我不得不通读GitHub上的所有源代码。我在Apollo的体验比几年前在Relay的体验要好得多。但是下一次我使用它的时候,我还是得打起精神,因为如果不想重走回头路的话,我需要花些时间来另辟蹊径。
尽管如此,总的来说,我愿意给这个技术堆栈打个高分,我真的很享受这个项目的工作。Elixir和Phoenix非常好用;Rails则有一个学习曲线,但是我真的很喜欢Elixir的一些语言特性,比如模式匹配和管道操作符。Elixir有很多新的想法(以及许多功能编程中久经测试的概念),可以很容易地编写富有表现力的、漂亮的代码。Absinthe使用起来很简单;它使用良好的文档进行了很好的实现,并提供了实现一个GraphQL服务器的所有合理的用例。总的来说,我发现GraphQL实现了它的基本承诺。它很容易查询每个页面所需的数据,也很容易通过订阅实现实时更新。我一直很喜欢使用React和React路由器,这一次也没什么不同。它们使得为前端构建复杂的、反应式的用户界面变得很容易。最后,我对总体结果非常满意。
作为一个用户,我的应用程序可以快速地加载和导航,能够实时地更新,并且客户端和服务端一直保持同步。如果一个技术堆栈的最终度量标准是用户体验,那么我会说这种技术组合是一个巨大的成功。
原文:https://schneider.dev/blog/elixir-phoenix-absinthe-graphql-react-apollo-absurdly-deep-dive/
本文为 CSDN 翻译,转载请注明来源出处。
【END】
作为码一代,想教码二代却无从下手:
听说少儿编程很火,可它有哪些好处呢?
孩子多大开始学习比较好呢?又该如何学习呢?
最新的编程教育政策又有哪些呢?
下面给大家介绍CSDN新成员:极客宝宝(ID:geek_baby)
戳他了解更多↓↓↓
热 文 推 荐
☞华为在欧洲申请多个商标;联想推出全球首款 5G 电脑;苹果推出新款 iPod Touch | 极客头条
☞从华为事件,看 Google Android 的独断专制!
☞服务迁移之路 | Spring Cloud向Service Mesh转变 | 技术干货
☞一文获取36个Python开源项目,平均Star 1667,精选自5000个项目