生产中的耐用品

2020-11-14 09:07:09

几周前,Cloudflare发布了耐用对象:一种真正的无服务器存储和状态方法,这在Linc引起了我们的兴趣。我们已经是Cloudflare Workers的主要支持者(毕竟Workers是我们推荐的部署目标),所以我们总是对该平台获得哪些新功能感兴趣。但是,这一节听起来非常适合用比我们想象中需要的少得多的代码/基础设施来解决特别恼人的用户体验问题(参见最后的比较部分)。

从历史上看..。没有办法控制哪个实例收到了请求,[因此]也没有办法强制两个客户端与同一个工作进程对话。持久对象改变了这一点:与同一主题相关的请求可以被转发到相同的对象,然后该对象可以在它们之间进行协调,而不需要触及存储。

这是件大事。而且,经过几天的修修补补,我们已经能够成功地将我们的解决方案部署到生产中!在这篇文章中,我们将讨论我们解决的问题,对象是如何持久工作的,我们将浏览代码以及它使用它们是什么样子,然后看看它为我们节省了多少工作。

注意:耐用对象是有限的测试版,不推荐用于生产(目前还没有)。然而,我们的用例不会超出任何测试版的限制,我们正在使用它来逐步增强我们现有的解决方案,所以我们已经感到安全地将其部署到生产中了!

虽然LINC是一个专注于自动化部署和预览每个提交的产品,但日常使用的很大一部分是将提交从源代码构建到FAB中,而查看该过程的日志是该产品的核心部分:

当您刚开始设置时,或者当您的构建中发生了一些变化或出错时,您可能会发现自己在仔细阅读这些日志来查找和修复您的问题。因此,当它们正在建造时,能够观看它们的现场直播,是减少反馈循环的一个有用的工具(当然,Linc在减少反馈循环方面做得很好!)。但事实证明,这是一个令人恼火的难以实现的功能。

对于初学者来说,大多数情况下,构建会在任何客户端查看之前启动(但并非总是在配置更改后重新启动构建时)。在这种情况下,您需要在某个地方累积部分构建日志,以便它准备将其发送到第一个要连接的客户端。而且,多个客户端可能正在观看相同的构建,也可能随时断开/重新连接。这并不难解决,但它就是复杂到让人难以捉摸。

到目前为止,我们采取了一种务实的方法:使用GraphQL订阅每隔几秒钟定期刷新整个“构建”记录(包括日志)。这实际上重复使用了我们现有的实时更新基础设施,这些基础设施几乎支持应用程序中的每一个视图:

因此,虽然更新是从数据库事件触发的,但以这种方式使用GraphQL订阅服务器为我们提供了一个重要的保证:客户端始终可以获得他们感兴趣的数据的完整快照。换句话说,对数据库中记录的任何更改都会导致客户端从服务器获得任何受影响查询的完整更新--不需要管理或合并客户端上的更新,只需用最新的更新替换其本地缓存即可。它以一些冗余的数据传输为代价,使完全实时的用户界面更易于实现。

作为一种普遍的模式,它对我们来说非常有效。但是要逐行发送当前构建的每个日志,这样的方式太重了。不仅仅是在发送超过需要的数据方面,我们绝对会在迪纳摩表上大打出手,即使目前没有人在看日志。因此,我们采取了一个折中方案:使用现有的基础设施,每隔10秒将日志刷新到数据库。大多数构建需要2到4分钟,所以这只是少量额外的写入,对于权宜之计来说感觉还可以。我们已经有了构建专用日志流解决方案的开放门票,但大量的额外基础设施让我们望而却步。那是在耐用物品出现之前。

注:阅读这篇介绍性的博客文章以了解背景是值得的,但这是我对TL;DR:

持久对象是将无服务器函数定义为JS类实例的一种方式。外部HTTP请求不能直接访问它们,而是由普通(即无状态)Worker使用它们的命名空间(通常是它们的类名)和ID创建/访问实例。对于给定的命名空间&;id,世界上的某个地方只有一个实例,它可以存储数据。实例通过正常的HTTP请求/响应与Worker通信,并支持WebSocket。

如果你觉得这有点困惑,你并不孤单!直到我读了一个例子,我才真正得到它,所以我在下面的解决方案部分复制并注释了我们的工作者和目标代码。

我希望它不会引起太大争议,但我不是耐用品这个名字的狂热粉丝。它让人联想到一个非常以数据为中心的模型,在这个模型中,它听起来像是一种……。也许可以定义在云中神奇运行的类,以及其中的实例……。当他们闲置的时候,或许可以把它们冷冻起来,然后储存起来?它将其呈现为一种类型的数据库,而实际情况更接近于Worker/Serverless模型,只是使用了每个Worker的存储。

话虽如此,但使用按员工存储的员工这个短语大大淡化了这个概念有多么惊人的变革性,所以我觉得Cloudflare希望它听起来像一个全新的东西。但从概念上讲,你可能会更好地将这些人想象为实实在在的演员,或者,正如我们在内部创造的那样,他们是有状态的员工。

在文档中,很多人都在关注这样一个事实,即一个对象使用`Controler.storage`拥有自己的键值存储。但是,只有在拥有对象的活动实例时,该API才可用,而实现该实例实际上是一种伪装的完整状态。

每个对象都有一个全局唯一的标识符。那个物体一次只存在于世界上的一个地方。在世界任何地方运行的任何知道该对象ID的工作人员都可以向其发送消息。所有这些信息最终都被送到了同一个地方。

再加上每个对象实例都支持WebSockets这一事实,突然之间,您就可以定义一个系统,在该系统中,只要两个端点共享某种密钥,它们就可以直接传递消息(就像两台计算机不能直接通信,但都可以与TURN服务器通信时,WebRTC连接是可能的)。事实证明,Linc将实时构建日志流传输到浏览器,这正是我们所需要的。

我们已经有了一个理想的共享密钥:正在构建的当前提交的git SHA。我们实际上使用的是提交的`tree_id`,而不是Commit sha,因为`tree_id`只是底层代码的散列,而不是它的历史或提交消息(顺便说一句:这就是Linc在合并最新PR时可以立即发布的方式,而不需要等待新的构建)。

普通的Cloudflare工作器,它接收来自客户端/构建器的请求,并将它们连接到适当的对象实例。

持久对象本身,它维护当前客户端的列表、日志的历史记录,并将新的日志条目从构建器广播到每个客户端。

Builder是我们的AWS集群中运行`npm的机器,它在源代码上运行fab:build`。它不需要太多更改,但现在将日志发送给工人,并将其保存到迪纳摩。我们有时将其称为构建服务器,或者在代码中仅称为服务器。

客户端是我们正常的Reaction应用程序,它需要将工作人员的实时日志与GraphQL中的现有数据进行协调。

公平地说,这是我在读完指南后最困惑的一篇文章。感觉上您应该只需要部署对象本身并直接与其通信,但一旦Worker层符合整体情况,它就变得非常有意义:它是客户端(与其最近的边缘位置通信)与对象实例(它们只在全球一个地方运行)通信的唯一方式。

--代码:language-js--Export Default{async Fetch(Requestenv){//将密钥转换为对象ID。const id=env.MyObjectNamespace.idFromName(';some-fixed-value';)//连接到该实例,必要时启动。const实例=等待env.MyObjectNamespace.get(Id)://将当前的http请求转发给它,返回instance.fetch(Request.)}。

请注意,此Worker对所有请求(`';某些固定值';`)使用单个键,这意味着_EVERY_REQUEST将被定向到_Single Object Instance_。这几乎肯定不是您在生产中想要的,但在入门时很方便(特别是如果您更改了一到两次`某些固定值‘,这样您就可以确保从上次部署时得到一个新实例)。

我们的Worker实际上并不复杂,但它解析路由以找到`tree_id‘,从而将特定构建的所有请求定向到一个共享实例:

--code:language-js--EXPORT DEFAULT{async FETCH(REQUEST,Env){const{pathname}=new URL(request.url)://Pro提示:将对路由的解析放在对象上的静态方法中,这样您就可以在两个地方使用它://(注意:Worker和对象必须在同一个文件中才能共享助手函数):Const route=DurableBuildLog.toRouteParams(Pathname);If(!route.Match)return NotFound()*//。的生成日志是否为(route.client){1,如果(!await clientHasAccess(route.sitename,request.headers)){1,3,3,3,2}是否返回NoAuthorized()//验证生成服务器是否确实是我们的服务器之一。*if(route.server){95if(!serverIsAuthentic(request.headers)){438,reurn NoAuthorized()*}**//找到我们的tree_id的do,const ObjectID=env.BuildObjects.idFromName(route.tree_id),const Object=await env.BuildObjects.get(Objectd)://传递我们的请求并返回。*//您可以使用`wrangler Tail`来跟踪这些内容,但请注意:它只记录来自Worker内部的日志,而不是对象实例。*console.log(Response)返回响应},}。

就是这样!我们在这里做日志记录、路由解析和认证/授权,否则我们只会将请求传递给从当前提交的`tree_id`派生的实例ID。

一旦你开始考虑一个持久的对象,作为一个有状态的工作者,我可以根据它的ID突然出现在世界任何地方,它开始变得更容易想象用例。对于我们来说,我们只需要在客户端和构建服务器连接到我们时创建WebSocket连接,然后在事件传入时进行分派。

--代码:Language-js--导出类DurableBuildLog{//静态方法,这样我们就可以从对象内部和外部调用它,静态toRouteParams(路径名){*Const Match=pathname.Match(**/^\/((client)|(server))\/([\w-]+)\/([a-f0-9]{15})\/ws$/*)if(!Match)返回{Match:False}*Const[_,__,Client,Server,Sitename,Tree_id]=Match返回{Match:True,Client,Server,Sitename,tree_id}{Match:true,client,server,sitename,tree_id}。

请注意,这里假设";worker和我们的";对象是在同一个文件中定义的(仅供参考:这是演示的工作方式,而不是指南)。使用同一个文件意味着Worker和Object都可以使用`DurableBuildLog.toRouteParams`来解析路由,这让我觉得有足够的理由将它们放在一起。

但这让部署有点混乱--实际上有三个步骤(我是从演示中的Publish.sh文件了解到这一点的):

将脚本作为普通工作程序进行部署,但要在其中定义do类并‘export`’。它还不会奏效,我们只需要在下一步奏效之前把它出版一次。

从Object创建命名空间:有效地告诉Cloudflare:嘿,`DurableBuildLog`是一个持久的对象定义,请为它创建一个命名空间,并给我ID。

在您的Worker绑定中使用DO命名空间再次部署脚本。这将告诉Cloudflare在`env`参数上向Worker注入`BuildObjects`,并将其连接起来。

值得庆幸的是,在第一次更新之后,您只需执行步骤3即可更新您的Worker代码和目标代码。同样,对我来说,这是一个很好的理由来放置你的工作人员和对象,但这仍然是早期的测试版DX,我相信它会改进的。

--code:language-js--构造函数(state,env){//如果我们需要这些日志持久化,我们可以使用state.storage,但我们不会。//所以我们将只使用实例变量并接受它的限制(见下文)。对于this.server=null,this.clients=[].setthis.resetLogs(),}Reset Logs=()=>;{this.logs=。

这里,`state.storage`是文档所说的API,我们认为我们需要,但我们到目前为止还没有。事实证明,这些实例的内存存储空间很小,所以我们可以使用`this.logs=[]`来存储到目前为止的构建日志。由于我们的Builder在构建过程中打开了WS连接,因此无论是客户端还是构建器首先到达对象,其效果都是内存中的实例变量似乎在构建期间被保留。

注意:内存存储遵循与普通工作者相同的逐出规则。但在我们的例子中,如果对象实例在构建期间被逐出,我们的体验将降级为GraphQL后备。在实践中,由于我们的构建时间很短,到目前为止我们还可以,但这是我们可以通过更具弹性的实现来重新考虑的问题。

--code:language-js--//所有新客户端的入口点/构建服务器异步获取(请求){2,//请参见下面的handleErrors说明,请等待this.handleErrors(Request,async()=>;{Const{pathname}=new URL(request.url)=#const{Match,Client,Server}=DurableBuildLog.toRouteParams(路径名)=DurableBuildLog.toRouteParams(路径名))。,{Status:404,})//我们预计到目前为止的唯一请求是WebSocket连接。如果(request.headers.get(';升级)!==';WebSocket;){将返回新的响应(';预期的WebSocket';),则会返回新的响应(';预期的WebSocket';)//如果(Request.headers.get(';升级)!==';WebSocket;){将返回新的响应。,{status:400})}//备注:const[Client_ws,our_ws]=new WebSocketPair()在下面爆炸//因为`WebSocketPair`是一个秘密的Rust对象,还没有被设置为可迭代。*//这是一个漏洞,已被报告。*const Pair=new WebSocketPair();const[Client_ws,our_ws]=[Pair[0],Pair[1]]://接受我们的WebSocket端。这告诉运行时,我们将在JavaScript中终止//WebSocket,而不是将其发送到其他地方。*@our_ws.Accept()//如果这是一个服务器连接,如果(Server){*if(this.server)this.server.lose(1000,#39;您被踢到),则取消之前的任何连接,恢复//清除日志,并连接事件处理程序。如果(Server){*if(this.server)this.server.lose(1000,#39;您被踢了),则取消这一连接。server=our_ws,#xOur_ws.addEventList.list)连接到我们的_ws.addEventList.lose(1000,#39;你被踢了##;);如果这是一个服务器连接,则取消之前的任何连接,删除//清除日志,并连接事件处理程序。,this.serverMessage)和我们的_ws.addEventListener(';Close&39;,this.closeServer)和//广播({meta:';服务器已连接)//对于客户端连接,发送到目前为止的日志,并将它们添加到客户端列表中。如果(客户端){0;const Initial_Payload=[1],则将日志添加到客户端列表中。,VERSION},...this.logs,]是否会返回新的响应(空,{状态:101,Web套接字:Client_ws}){(this.SafeSend(...Initial_payload)(Our_Ws)){_this.clients.ush(Our_Ws)*})。

注意:这花了很长时间才纠正过来,主要是因为调试很麻烦(这里的控制台日志不能到达工作进程上的‘wrangler Tail’日志,尽管它们在同一个文件中)。聊天演示中的handleErrors助手绝对是一个小的冠军代码。这会捕获异常并发送带有错误消息的有效WebSocket数据包,而不是让请求失败。如果没有它,我不可能让这一切进行下去,但再说一次,这是非常早进入的代码,我相信DX的故事会有很大改善。

假设请求有效,此方法将创建WebSocketPair(这是Cloudflare发明的Fetch API的非标准扩展),将一端保留在内存中,并将另一端作为响应的一部分向下发送。一旦建立了连接,您就可以向下插入任何您想要的数据。简单得很。

--code:language-js--//当服务器向我们发送事件时,将其广播给当前连接的客户端//并将其保存(在内存中),以供以后连接到服务器的任何客户端使用。serverMessage(Event){n让msg尝试{_msg=JSON.parse(event.data)*}Catch(E){_msg={error:E}**}nthis.Broadcast(Msg)。

至于向客户端广播,唯一稍微复杂的是处理WebSocket中的断开连接并不总是事件,有时`.send`调用会失败:

顺便说一句:像`this.clients.filter(...)`这样的代码总是让我紧张,因为它不是ThreadSafe。但是,由于JS是单线程的,并且您的实例保证只在全球的一个地方运行,所以它很好。接受这一点可以简化您的代码。很多。

几个小的实用函数被省略了,包括一个我们在部署时查找-替换的版本常量,同样是为了让我100%知道代码已经在DO上更新了。但除此之外,这就是所有耐久的目标!

Build Server端的变化非常小。我们使用WebSocket-Node使用共享密钥连接到`wss://live-build-logs.lincbot.com/server/${sitename}/${tree_id}/ws`,然后在创建日志时推送每一行日志,以及一些元数据,如`start_cmd`或`append_log`的`MessageType`,知道它将被广播给任何正在观看的客户端。当然,如果没有客户端连接,对象实例就会出现,在内存中积累日志,然后关闭,高兴地知道它已经准备好了,即使它从未被要求做任何事情。

但真正的胜利是什么?我们不需要更改现有的日志记录:我们仍然刷新到Dynamo并每10秒触发一次GraphQL。因此,如果持久对象不可用,或者我们的实现中断,我们现有的后备将继续工作。

最后一步是将在构建日志WS上发生的事件与来自GraphQL订阅的事件进行协调,以显示在用户界面中。这最终会涉及到一些复杂的问题,但这完全与我们的应用程序的结构以及我们过去对数据分区所做的选择有关,所以我在此不再赘述。

我要提到的一件事是,我们探索了两种替代方案:第一种是使用DO中的事件来更新本地GraphQL缓存,第二种是保持GraphQL缓存不变并合并Reaction组件树中的数据。我们之所以选择后者,是因为我们认为DO实时构建日志是一种渐进的增强--直到我们在生产中运行它们一段时间后,我们才希望有任何代码触及我们的图形真理源。

.