无服务器网络插款
#javascript #react #serverless #websockets

我正在努力现代化一个将近十年前从均值(Mongo,Express,Angular,Nodejs)堆栈到使用NextJS的无服务器堆栈的应用程序。但是,一个引人注目的点是对Websocket的需要:我的应用程序使用WebSocket Connections向用户提供更新而无需进行轮询。它们对于任何同时与网站进行交互的应用程序都是理想的选择,重要的是要查看其他用户何时添加内容(例如发表评论)。

我尝试探索的第一个选项是Server-Sent Events或SSE。 SSE与Websocket相似,但仅是单向。对我来说,这已经足够了:我让客户使用REST端点(与无服务器)一起将信息发送到服务器;当他们没有明确要求时,我只需要一种向客户发送信息的机制即可。 (这只是轮询。)

SSE危险地接近为无服务器工作,我完成了在当地开发中进行游泳工作的an entire proof-of-concept chat application in NextJS。唯一的问题是:它依赖于保持an array of client objects in memory。关于无服务器功能的一件危险的事情是,这将在某些时间中起作用。因为有时无服务器功能在执行时会在同一计算机上运行,​​但这不是保证。无服务器功能是设计的短暂功能,因此内存存储在部署后不会扩展。

我想:很好 - 我只使用外部商店来跟踪客户。因此,我连接了Vercel KV,它只是Redis的便利包装版本,我可以在其中存储我的客户端并每次执行功能时都检索它们。没有汗水!一切都很好,直到我尝试将客户数组kude0 koode0收到:

Error [TypeError]: Converting circular structure to JSON.

哦。哦,赫赫。 oh。并非JavaScript中的每个对象都可以在JSON中表示 - 循环引用可以是一个功能,而不是错误。 SSE的客户是实际响应对象,无法将其简化为JSON,然后再补充水分。目前,这是一个艰难的现实,但是似乎没有办法逃避需要在实时通信时将事物保留在记忆中的过程的需求。

第三方Websocket提供商

Vercel,当然是seems to say the same thing,并提供了可以使实时通信成为可能的第三方提供商列表。他们没有阐明您必须使用第三方的原因,这是我对他们的文档的一个小问题。

现代jamstack在许多方面都很棒,但是它的缺点是,曾经是一个曾经是一个帐户和提供商(就我而言,Linode提供了我的VPS)的缺点。使用专业提供商的优点通常是他们会做得很好,或者使其特别容易使用,但缺点是必须管理单独的集成,并考虑这些成本的潜在含义。

所以我在这里告诉你:至少在2023年,功能作为服务的性质,从本质上讲,他们不能保证在同一台计算机上运行,​​这使得有必要达到实时,非播放通信的单独服务。

输入

我去了Ably,因为他们在2022年的Jamstack Conf上有一个展位,并且可能给了我贴纸或其他东西。 (您听到了吗?赞助您的本地技术会议!)他们有一个generous free tier,这是这个空间的绝对要求,像我这样的开发人员通常希望在没有赚钱的东西上尝试产品,然后(理想情况下)转化为在我们的职业生活中推荐 do 赚钱的产品,在这种产品中为更高级别的调用,计算时间或 有意义。 P>

与Ablobly集成是(相对)毫无痛苦的,尽管我想记录并建议避免他们的react hooks套餐,我发现我绊倒了我的帮助,尤其是因为它在写作时拥有an outstanding bug,这影响了我。但好消息是,在React中使用Aibly的标准客户并不那么棘手。

无服务器聊天应用

Here is our Serverless Chat app。您可以在多个选项卡中打开它,以查看操作中的聊天功能。和here's the project on Github如果您想直接潜入源代码。

让我们走过散发。这从示例NextJS样板开始,您可以使用命令来创建:

npx create-next-app <your-app-name>

概述

该应用将有两个主要组件:

  • 无服务器端点 - 接收到发布请求,并通过WebSocket广播
  • 客户端 - React应用程序处理所有有趣的UI并通过WebSocket接收广播

类型

在创建任何一个主要组件之前,让我们继续在项目的根部创建一个types.ts文件,我们可以共享服务器和客户端都需要的任何类型。

当然,实际创建事物的顺序与我在这里展示的方式完全不同:在正常开发过程中,您在工作时会发现常见的类型,而不是从一开始就发现。

// /types.ts
export type Message = {
  username: string;
  date: Date;
  text: string;
  // Adding a type will let us display notifications from the server about
  // connections and such differently than regular messages from users
  type: "message" | "notification";
};

无服务器端点

使用Vercel和Netlify等部署服务创建这些端点真的很容易,但是值得注意的是,他们的所作所为没有什么神奇的。在AWS中创建lambda来做同样的事情并不困难。但是他们打包了东西,以便您要做的就是在项目中正确的目录中创建一个文件,并且 - bam--您拥有一个可在本地测试的,可立即可部署的功能。我们可以停下来欣赏片刻,这有多容易吗?

// /pages/api/sendMessage.ts
import { NextApiRequest, NextApiResponse } from "next";
import { Message } from "../../types";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const { username, message } = req.body;
  if (req.method === "POST" && typeof username && typeof message === "string") {
    try {
      await broadcastMessage({ username: username, text: message });
      // You could send more information here if you want, but all that's really important is telling the client that their submission was successful
      res.status(200);
    } catch (error) {
      res.status(500).json({ error });
    }
  } else {
    res.status(400).json({ error: "Bad request" });
  }
};

// Function to broadcast a message to all connected clients
type BroadcastOptions = Partial<Message> & {
  date?: Date;
  type?: "message" | "notification";
};

export const broadcastMessage = async (
  message: Message,
) => {
  const defaultOptions = {
    date: new Date(),
    type: "message",
  };

  message = Object.assign({}, defaultOptions, message);

    // Do something to publish our message
};

NextJS请求/响应键入显然是特定于平台的,但是这里没有其他特别的东西。我们有一个函数,可以从帖子正文中获取一些信息,并调用专门广播尚未执行任何操作的消息的broadcastMessage函数。

客户端

我将尝试使客户端代码尽可能简单,同时仍将我们的前端的不同方面分为组件,以使其更容易在路上更新和自定义它们。

我们在这里有四个文件:

  • koude3:我们聊天应用的父页面
  • koude4:让用户放入用户名然后连接到chat
  • 的组件
  • koude5:显示聊天消息的组件
  • koude6:使用户可以输入并提交消息的组件

我已经剥离了任何Tailwind课程或任何类型的样式,以使其尽可能简单。

关于重要组成模式的简介

请注意一个重要的模式,但是:我的三个组件中的每个组件中的每个组件都接受可选的传入类名,以及对它们将返回的元素有效的任何属性。在考虑组件可重复使用性时,将哪些类和属性分开对组件的必不可少的类和属性,与特定于组件的特定调用的类和属性至关重要。

使我们的组件灵活和重复使用,尤其是在使用使用大量类的tailwind时,我们要进行以下操作:

  1. 破坏性基本道具:我们从传入道具中拔出基本道具,例如fooclassNameclassName是可选的,如果不提供,则默认为空字符串。
const MyComponent = ({ foo, className = '', ...rest}: MyComponentProps & React.HTMLProps<HTMLDivElement>) => {
  // ...
}
  1. 收集其余的:使用其余语法(...rest)将未明确破坏的任何未明确破坏性的道具收集到一个休息变量中。这样可以确保将任何其他传递给我们组件的任何其他道具都不会丢失。

  2. 扩展属性:最后,我们使用扩展语法({...rest})将这些剩余的道具添加到我们的组件中。这确保我们的组件可以接受任何HTML属性或自定义属性,而无需提前指定它们。

  3. 组合班级名称: className Prop是特殊的。我们将任何传入的className与组件的默认类串联。这允许在维护基本样式的同时进行外部自定义。

这是所有这些组合的:

// Boilerplate component that can set its own classes and attributes while also
// accepting both, optionally, from the outside
type MyComponentProps = {
  foo: string,
  className?: string
}

const MyComponent = ({ foo, className = '', ...rest}: MyComponentProps & React.HTMLProps<HTMLDivElement>) => {
  return (
    <div {...rest} className={`${className}`}>
      <h1>{foo}</h1>
    </div>
  )
}

我们将把所有应用程序的所有状态都保持在此级别。

// /src/app/page.tsx

// In Vercel, you need to declare your first component that's not able to be rendered on the server
"use client";

import { useEffect, useState, useCallback } from "react";
import { Message } from "../../types";
import Username from "./Username";
import ChatBox from "./ChatBox";
import ChatInput from "./ChatInput";

export default function Home() {
  const [messages, setMessages] = useState([] as Message[]);
  const [username, setUsername] = useState("");

  const addMessage = (message: Message) => {
    setMessages((prevMessages) =>
      [...prevMessages, message]
        // limit the history length by only ever keeping the most recent 50
        // messages, at most
        .splice(-50)
    );
  };

  const sendMessage = useCallback(
    async (text: string) => {
      await fetch("/api/sendMessage", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ username, message: text }),
      });
    },
    [username]
  );

  return (
    <main>
      <h1>Serverless Chat</h1>
      {!username && <Username setUsername={setUsername} />}
      {username && (
        <>
          <ChatBox messages={messages} />
          <ChatInput submit={sendMessage} />
        </>
      )}
    </main>
  );
}

用户名

// /src/app/Username.tsx
import { Dispatch, SetStateAction, useState } from "react";

type UsernameProps = {
  setUsername: Dispatch<SetStateAction<string>>;
  className?: string;
};

const Username = ({
  setUsername,
  className = "",
  ...rest
}: UsernameProps & React.HTMLProps<HTMLDivElement>) => {
  const [text, setText] = useState("");
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    // Let the user submit their username by hitting the enter key
    if (event.key === "Enter" && text.length > 0) {
      setUsername(text);
    }
  };
  return (
    <div {...rest}>
      <label>
        Username:
        <input
          autoFocus={true}
          type="text"
          name="name"
          value={text}
          onChange={(event) => setText(event.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="username"
        />
        <input
          type="submit"
          value="Connect ⚡️"
          disabled={!text}
          onClick={() => setUsername(text)}
        />
      </label>
    </div>
  );
};

export default Username;

聊天框

// /src/app/ChatBox.tsx
import { useEffect, useRef } from "react";
import { Message } from "../../types";
// this external dependency needs to be included in your package.json
// It just lets us format dates more easily than with JavaScript's somewhat
// boroque date formatting methods
import dayjs from "dayjs";

type ChatBoxProps = {
  messages: Message[];
};

const ChatBox = ({
  messages,
  ...rest
}: ChatBoxProps & React.HTMLProps<HTMLUListElement>) => {
  // create a ref to the messages container
  const messagesContainer = useRef<HTMLUListElement>(null);

  // Keep the chat scrolled to the bottom whenever there is an incoming message
  useEffect(() => {
    const messagesContainerRef = messagesContainer.current as HTMLUListElement;
    if (messagesContainerRef) {
      messagesContainerRef.scrollTop = messagesContainerRef.scrollHeight;
    }
  }, [messages]);

  return (
    <ul ref={messagesContainer} {...rest}>
      {messages.map(({ username, date, text, type }, i) => (
        <li key={i}>
          <span>
            {dayjs(date).format("HH:mm:ss")}
            {username.toLowerCase() === "server" && ":"}
          </span>
          {username.toLowerCase() !== "server" && (
            <span>
              {username}:
            </span>
          )}
          <span>
            {text}
          </span>
        </li>
      ))}
    </ul>
  );
};

export default ChatBox;

聊天输入

// /src/app/ChatInput.tsx
import { ReactHTMLElement, useCallback, useState } from "react";

type ChatInputProps = {
  submit: (text: string) => void;
};

const ChatInput = ({
  submit,
  className = "",
  ...rest
}: ChatInputProps & React.HTMLProps<HTMLDivElement>) => {
  const [text, setText] = useState("");
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && text.length > 0) {
      sendMessage(text);
    }
  };

  const sendMessage = useCallback(
    (text: string) => {
      submit(text);
      setText("");
    },
    [submit]
  );

  return (
    <div {...rest} className={`${className}`}>
      <input
        autoFocus={true}
        type="text"
        value={text}
        onChange={(event) => setText(event.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Your message"
      />
      <input
        type="button"
        value="Send"
        onClick={() => sendMessage(text)}
        disabled={!text}
      />
    </div>
  );
};

export default ChatInput;

添加

注册并创建一个应用程序

前往Ably并创建一个帐户,然后在仪表板上单击“创建新应用”。给您的应用程序一个名字,然后选择“探索”作为您的用例(在这种情况下这并不重要)。

然后,您将拥有一个带有标题“启动您的应用程序”的页面,该页面应该具有您的API密钥供您复制。这是您的专用密码,用于使用网页中的Albly服务。

用名称ABLY_API_KEY将其添加到您的.env文件中。 (当然,您可以选择任何想要的名称。稍后再检索时只需要使用相同的名称即可。)

ABLY_API_KEY=<your api key>

花点时间确保您的git回购(如果您有)将.env添加到其.gitignore文件中,以免您无意间将其投入到仓库上。您的.env文件旨在能够保存您的应用所需的API键和其他秘密。当您部署到Vercel,Netlify或任何其他环境时,它们都有一种设置环境变量的方法,以便您的应用程序可以在保持私密的同时使用它们。

从NPM安装适当的软件包

明显有一个可以在服务器和客户端上使用的包装。

npm i ably

修改无服务器功能

接下来,我们将为sendMessage.ts添加一些内容:

import Ably from "ably";
// Get our API key from our environment variables
const {
  ABLY_API_KEY = "",
} = process.env;

遵循documentation for Ably's npm package,我们将在broadcastMessage函数中添加以下功能:

  1. 使用我们的API键连接到API
  2. 获得特定的频道
  3. 将我们的消息发布到该频道
const ably = new Ably.Realtime.Promise(
  ABLY_API_KEY,
);

await ably.connection.once("connected");

// Get the channel we want to use -- note: we could name our channel anything
const channel = ably.channels.get("chat");

channel.publish("message", message);

这是上下文中具有这些更改的整个文件:

// /pages/api/sendMessage.ts

import { NextApiRequest, NextApiResponse } from "next";
import { Message } from "../../types";
import Ably from "ably";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const { username, message } = req.body;
  if (req.method === "POST" && typeof username && typeof message === "string") {
    try {
      await broadcastMessage({ username: username, text: message });
      res.status(200).json({ status: `Message sent: ${username}: ${message}` });
    } catch (error) {
      res.status(500).json({ error });
    }
  } else {
    res.status(400).json({ error: "Bad request" });
  }
};

const {
  ABLY_API_KEY = "",
} = process.env;

type BroadcastOptions = Partial<Message> & {
  date?: Date;
  type?: "message" | "notification";
};

// Function to broadcast a message to all connected clients
export const broadcastMessage = async (
  message: BroadcastOptions,
) => {
  const defaultOptions = {
    date: new Date(),
    type: "message",
  };

  message = Object.assign({}, defaultOptions, message);

  const ably = new Ably.Realtime.Promise(
    ABLY_API_KEY,
  );

  await ably.connection.once("connected");

  const channel = ably.channels.get("chat");

  // Note: Ably gives you the ability to specify different kinds of events, so a
  // channel can have all sorts of information traveling across it,
  // differentiated by key name. Here, we've just named the ones we're concerned
  // about "message". It could be anything.
  channel.publish("message", message);
};

创建一个端点来分发令牌

此时,我们需要添加一个小皱纹。要订阅我们发布的相同事件,这根本不复杂:我们将在一点点添加到客户的代码与我们上面所做的那样简单。但是,客户端代码还有一个额外的问题:我们如何保留API密钥秘密?

API密钥是一个永久的应用程序密码 - 我们可以在无服务器功能中安全地使用它,因为客户端不必能够看到该代码即可执行它。但是,客户端代码在根本上是不同的:我们将代码运送到客户端的浏览器中,然后使用浏览器编译和执行它。因此,其中的任何秘密都不是秘密。

为了解决此问题,除了使用API​​键-token authentication之外,还可以使我们还可以通过细粒度的访问控制来分发更多的短寿命令牌。我们将使用以相同的方式使用API​​键的方式来使用令牌,我们只需要从可以使用原始API键创建令牌的源获取它 - 换句话说,仅用于身份验证的单独的无服务器函数即可。

// Note where you put your auth file -- you'll need to use the path
// in your client code.
//
// /pages/api/ably/auth.ts
import { NextApiRequest, NextApiResponse } from "next";
import Ably from "ably/promises";

const { ABLY_API_KEY = "" } = process.env;
const rest = new Ably.Rest(ABLY_API_KEY);

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const tokenParams = {
    // This is just a unique identifier
    clientId: "chat-client",
  };

  try {
    const tokenRequest = await rest.auth.createTokenRequest(tokenParams);
    res.setHeader("Content-Type", "application/json");
    res.send(JSON.stringify(tokenRequest));
  } catch (err) {
    res.status(500).send("Error requesting token: " + JSON.stringify(err));
  }
};

所有这些都是分发令牌。

修改客户端

现在,我们可以回到我们的客户端反应代码并添加出色的SDK。

和以前一样,我们将要大量导入:

import Ably from "ably";

然后,在/src/app/page.tsx中的主要组件内,我们将创建一个状态变量,以跟踪客户端将使用的频道。

const [channel, setChannel] =
    useState<Ably.Types.RealtimeChannelPromise | null>(null);

现在,我们需要添加一个功能,该函数将在组件初始化时仅运行一次。这样做的钩子约定是一个useEffect钩,带有空数组作为其依赖性。

useEffect(() => {
  // this code just runs once, when the component initializes, because of the
  // empty array passed in for its dependencies
  return () => {
    // the function returns a function that will be run when a component is
    // unmounted, so that you can do any necessary cleanup
  }
}, [])

我们还可以在问题发生之前解决问题,并可以在我们的useEffect中使用异步功能,因为我们将在等待连接和其他根本上异步问题之类的事情。

不幸的是,您无法制作useEffect函数本身async,因为React期望useEffect的返回为undefined或用于清理的函数。如果useEffect是异步,它将返回Promise,这会使React的内部构成。

但是有一个聪明的解决方法:在useEffect中定义异步函数,然后立即调用。这与React的期望一起使用,同时允许我们使用异步功能。

useEffect(() => {
  const init = async () => {
    // you can use await in here
  }

  init();

  return () => {
    // cleanup
  }
}, [])

现在,在我们要创建的初始功能中,我们将:

  1. 创建一个适当的客户端对象,并告诉我们使用auth函数创建的auth url的路径来获取令牌
  2. 等待它的连接消息
  3. 向我们的用户发送一条消息,说他们已连接到聊天
  4. 获取我们要使用的特定渠道 - 它必须与我们在sendMessage.ts中指定的渠道相同
  5. 订阅该频道上的消息,并在消息进入时调用我们的内部addMessage方法

一张注意:当我们使用Aably的客户端并将其传递到我们的身份网址时,我们将获得一个非常好的功能:它可以自动为我们处理所有令牌。由于代币的关键特征是它们是短暂的,因此您必须在逻辑上有逻辑,这些逻辑在它们到期并获取新的令牌时可以处理。 Ably的客户为我们照顾了所有这些。

useEffect(() => {
  // Set this variable in the top-level scope so that we can use it to close the
  // connection in our clean-up function
  let ablyClient: Ably.Types.RealtimePromise;

  const init = async () => {
    // Create a new client, using the path to our serverless function that hands
    // out tokens
    ablyClient = new Ably.Realtime.Promise({
      authUrl: "/api/ably/auth",
    });

    await ablyClient.connection.once("connected");

    // This is purely a nicety to show users that they have connected, it uses
    // our own internal addMessage functionality and doesn't send anything to
    // Ably
    addMessage({
      username: "Server",
      text: "Connected to chat! ⚡️",
      type: "notification",
      date: new Date(),
    });

    const chatChannel = ablyClient.channels.get("chat");
    // Save a reference to the channel, BUT keep using the `chatChannel` variable in this function, since setChannel is NOT synchronous, and doesn't return a promise // CHECK THIS
    setChannel(chatChannel);

    // Incoming messages
    // We'll be listening for "message", which is the string we chose in
    // `sendMessage.ts`, but just as a reminder, it could be anything.
    await chatChannel.subscribe("message", (message: Ably.Types.Message) => {
      addMessage(message.data as Message);
    });
  };

  init();

  // Cleanup function
  return () => {
    if (channel) {
      channel.unsubscribe();
    }
    if (ablyClient) {
      ablyClient.close();
    }
  };
}, []);

这就是全部:这应该是您要获得工作概念的工作聊天证明,该概念可以用作外部Websockets提供商。

最后的想法

这只是Webockets的最简单用途 - 如果您查看my example,您可以看到我已经采取了一个额外的步骤,并使用了Ably的存在功能来跟踪谁进入或离开了聊天,以便用户可以知道他们正在与谁交谈。没有任何从根本上涉及的新概念,只是添加了一些新的状态来跟踪连接的用户并订阅了更多事件。

类似地,例如,只需使用指示键入的其他键发送消息时,就不需要任何新概念才能添加ui之类的niceties来显示。

我不喜欢在堆栈中添加第三方服务,但是我对Ably的文档和易于实施印象深刻。也许它会说服您在无服务器应用程序上尝试Websockets - 运行时间demo great 。 WebSockets还可以使您不得不投票端点以进行更新,这可能会为您的功能带来更大的收费,因为调用数量是收费的主要方式之一。

总而言之,与您的无服务器部署一起集成和Websocket非常容易,并且可以为用户功能带来巨大好处,同时节省您的钱。

最初出版在nateeagle.com