介绍AnyCable的JavaScript和TypeScript客户端

373 阅读8分钟

作者。 Vladimir Dementyev, Evil Martians的首席后端工程师

AnyCable在过去的五年里一直专注于服务器端的性能(是的,我们刚刚打🖐)。然而,所有的实时_电缆_应用都由两部分组成:服务器和客户端。而今天,我们将集中在前端:让我介绍一下AnyCable的JavaScript SDK

在我们的库中没有写一行JavaScript代码,我们是如何生存的呢?从第一天开始,我们就把赌注押在与Action Cable的兼容性上,包括官方的@rails/actioncable npm包。这是我们的卖点之一:从Action Cable切换到AnyCable,不需要对客户端进行修改。

与AnyCable不同,它的Rails对应程序自2015年以来几乎没有发展。对客户端库最重要的改变是将CoffeeScript重写为现代JavaScript。我们达到了完美的程度吗?我很怀疑。(考虑到前端生态系统不断变化的性质,我甚至不相信这是可能的)。)

我一直在思考制作一个替代的客户端实现的想法,以更好地满足AnyCable的需求,有一段时间了。我甚至在v1.0版本的公告帖子中预告了一些想象中的API。现在我已经准备好揭开全貌了。这里有一个小小的目录,可以帮助你浏览这篇文章。

他们都有动机

那么,是什么鼓励我重新发明轮子来建立AnyCable客户端库?

官方库的设计只考虑到一个用例。Basecamp。它完全满足了项目的需求(我猜)。而且它缺乏可扩展性。我怎么能使用不同的传输方式?我怎样才能改变重新连接的策略?支持其他序列化格式呢?我在AnyCable的新功能上做得越多,我就越经常问自己这些问题,而唯一的答案就是猴子打补丁。

我拿起笔和纸(真的,见下文),开始思考完美的架构,以解决所有当前和潜在的问题。

Architecture sketch (not-so-perfect-yet)

架构草图(还不是那么完美

我的主要目标是将_通道_的概念抽象化。通道应该是纯逻辑的抽象,对底层的通信机制一无所知。

反过来,通信_堆栈_可以分成_传输_(我们如何发送和接收字节)、编码器(我们如何序列化消息)和_协议_(我们如何构建消息,它们的模式)。这三个部分中的任何一个的底层实现都可以被改变,而不会破坏其他部分(除非接口被满足)。

这样的设计开启了很多新的机会:从使用二进制序列化格式(就像我们在AnyCable Pro中使用的那样)到切换到一个新的协议(比如,Action Cable v2)而不改变业务逻辑(通道)。我们甚至可以通过实现对应的传输来增加对长轮询的支持(是的,WebSockets仍然不适合所有人)。

此外,我们可以轻松地支持不同的平台,如Node.js和React Native。

可扩展性和互操作性并不是建立AnyCable JS SDK的唯一原因。我还想改善开发者的体验。做到这一点的一个方法是提供类型。

类型、类型、类型

近年来,在动态语言中添加静态类型已经变得非常流行(流行到我们现在有Ruby的类型!)。TypeScript是增长速度第二快的语言(根据最近JetBrains的调查)!很明显,在2021年,没有TS支持的新JS库不应该存在。

由于我已经有几年没有认真地用JS编程了,我决定不全身心地投入TypeScript,而是用普通的JavaScript写源代码,在*.d.ts ,在.js 。这对我来说很有效,因为我想让编写使用该库的代码更容易,而不是库本身。

而且我认为我达到了目的:由于TypeScript的表达能力(这让我很惊讶),我们可以为通道定义引入额外的严格性。

AnyCable TypeScript

使用Apollo Studio与AnyCable

例如,我们可以定义所需的(和可接受的)参数以及传入信息的格式。

import { Channel } from "@anycable/web";

type Params = {
  roomId: string | number;
};

type TypingMessage = {
  type: "typing";
  username: string;
};

type ChatMessage = {
  type: "message";
  username: string;
  userId: string;
};

type Message = TypingMessage | ChatMessage;

export class ChatChannel extends Channel<Params, Message> {
  static identifier = "ChatChannel";
}

// Without parameters, it would raise a type error and won't compile
let channel = new ChatChannel({ roomId: "2021" });

channel.on("message", (msg) => {
  // Here compiler knows the type of the msg
  if (msg.type === "typing") {
    // Now, compiler knows that msg is a TypingMessage and not ChatMessage
  }
});

我发现这些微小的补充在开发经验方面非常有益(或者,也许,我只是对TypeScript太兴奋了)。

连接,重新连接,以及一点Mathematica的内容

我在开头提到,Rails Action Cable库几乎没有得到任何新的功能。这是事实,但这并不意味着错误没有得到修复。

我从许多开发者(包括DHH)那里听到的一个特别的Action Cable问题是应用程序重启时的 "雷鸣般的人群"(也被称为_连接雪崩_):当服务器重启时,所有的连接都被关闭,然后客户端试图重新连接(这是客户端库的责任)。而如果所有的客户端都在差不多的时间内尝试重新连接,这可能会导致应用程序的负载高峰,甚至崩溃。

在Rails 7之前,官方的Action Cable客户端所做的正是我们刚刚描述的:让客户端在一个确定的时间内重新连接(因此,几乎是同时)。

值得庆幸的是,这个问题已经解决了:现在,客户端使用了带抖动的指数退避。

AnyCable在服务器端部分地解决了这个问题:在部署过程中,你不需要重启WebSocket服务器;客户端保持连接。

总之,由于AnyCable JS同时针对AnyCable和Action Cable应用程序,我们需要实现一些_智能的_重新连接机制。我所说的 "智能 "是指_被证明_是有效的。我们怎样才能证明它呢?当然是通过一些数学运算,或者甚至是 数学!

为了进行比较,我采用了三种实现方式(Rails 5、Rails 7和Logux),并根据AWS这篇博文的思路添加了一个自定义的实现方式。为了更好地理解其中的差别,我决定创建一个交互式可视化。下面的图表显示了连续尝试的重新连接延迟的分布,即从连接丢失到第N次尝试的时间。

评估重新连接策略

评估重新连接策略

现在你可以清楚地看到老式Rails方法的问题:重新连接发生在同一时间范围内。AnyCable和Rails 7版本看起来非常相似,尽管我试图通过增加一个额外的随机偏差来增加传播。最后的公式看起来是这样的(欢迎来到学术编程语言的世界)。

anyBackoff[attempt_, minDelay_, backoffRate_, jitterWeight_, maxDelay_] := Module[
  {fun},
  fun = Function[
    {a, md, br, jw, mx},
    left := 2 * md * (br^a);
    right := 2 * md * (br^(a + 1));
    t := Min[mx, left + (right - left) * Random[Real, 1.0]];
    dv := 2 *(Random[Real, 1.0] - 0.5) * jw;
    d := t * (1 + dv);
    If[a === 0, d, fun[a -1, md, br, jw, mx] + d]
  ];
  fun[attempt, minDelay, backoffRate, jitterWeight, maxDelay]
]

jitterWeight ,负责增加额外的随机性,目的是根据你的需要进行配置。当它被设置为0时,该函数的行为几乎与Rails 7中的指数反推相同。当它等于1时,我们得到AWS帖子中的_Full Jitter_。

功能和期货

目前,AnyCable JS客户端提供了与Action Cable JSON协议的完全兼容。这意味着你今天就可以开始在Rails Action Cable中使用它,不需要迁移到AnyCable。由于Action Cable的兼容模式,你只需改变一行代码就可以做到这一点。

- import { createConsumer } from "@rails/actioncable";
+ import { createConsumer } from "@anycable/web";

 // createConsumer accepts all the options available to createCable
 export default createConsumer();

它还包括对AnyCable Pro功能的内置支持,如Msgpack和Protobuf序列化。

// cable.js
import { createCable } from "@anycable/web";
import { MsgpackEncoder } from "@anycable/msgpack-encoder";

export default createCable({
  protocol: "actioncable-v1-msgpack",
  encoder: new MsgpackEncoder(),
});

随着库的发展,我们计划进一步改善开发者的体验(更好的日志、工具化等),并增加新的功能。例如,_CrossTabSocket_传输:在同一浏览器的多个标签之间共享WebSocket的能力,以减少连接的数量。

最后,拥有我们自己的客户端库是开始研究AnyCable协议的初步步骤,该协议是actioncable-v1-json 的修订版。


我希望这个简短的介绍足以证明拥有一个与AnyCable兼容的手工滚动的前端库的好处,它可以与AnyCable或普通的vanilla Rails一起使用。它也为更多的性能和开发者友好的AnyCable Pro打开了大门。请到AnyCable的网站上了解更多关于专业版的信息,并记住我们仍然提供免费的早期访问商业功能,至少到夏末为止。

当然,也可以在GitHub上自由地挖掘AnyCable JS代码:它是免费的、开源的,并将永远保持这种状态。