为了账号安全,请及时绑定邮箱和手机立即绑定

用发布订阅机制构建简单的事件驱动应用

在这篇文章里,我将一步步教你如何使用TypeScript创建一个基于事件驱动的Node.js应用程序。我们将从一个传统的应用程序开始,然后通过让服务通过发布/订阅模式来交流,逐步让服务变得松散耦合。

我们将看看如何在本地运行应用程序,以及如何将事件驱动应用部署到云上。

视频版

应用

我们将要查看的应用程序是一个在线状态监控系统。我们有一份需要监控的网站清单和一个CronJob,用于检查每个网站是否可访问。我们的应用程序会在状态变化时发送通知。新添加的网站状态默认为未知,直到CronJob检查过该网站的状态。这是我们希望在将应用程序改为事件驱动模式时需要调整的地方之一。

可用性应用

这里是在GitHub上的已完成应用程序的完整代码:https://github.com/encoredev/examples/tree/main/ts/uptime

建筑设计: 有关建筑设计的内容:

在这里我们有两个架构图示,左边的图是我们当前的系统,右边的图是我们完成后的理想状态。

建筑设计 "看看这个建筑设计"

在我们当前的状态下,你可以看到我们有四个服务:frontendmonitorsiteslack。填充的箭头表明 monitor 服务正在调用 siteslack 服务中的端点,monitor 服务对那两个服务有严格的依赖关系。我们还可以看到,monitorsite 服务都有自己的数据库。monitor 服务中有一个 CronJob,它每小时检查一次每个站点的状态。

所以,我们想在 site 服务中引入一个 site.added 主题,每当我们在 site 服务中添加了一个新站点时,就可以发布到该主题中。我们将通过 monitor 服务订阅 site.added 主题,并通过访问网站来检查其状态,以确保在添加了新站点时一切正常。

我们还想通过这种方式来解除 monitorslack 服务之间的 硬性依赖,通过引入一个 uptime-transition 主题(Topic)。

好处

在继续之前,我们先来谈谈为什么对我们的系统做这些改变很有意义。

  • 通过进行这些更改,我们使服务之间更加松散地连接起来,这几乎总是件好事。monitor 服务不再需要知道 slack 服务的存在,而 site 服务也不再受 monitor 服务的影响,可以保持独立。

  • slack 服务现在可以离线而不会影响 monitor 服务。当它重新上线时,它会从事件队列中读取并从上次停止的地方继续。因此,系统变得更加健壮。

  • 现在monitorslack服务都依赖于一个抽象层,而不是直接依赖对方,这叫做依赖倒置原则。这样我们就可以在不改动monitor服务的前提下,替换或添加其他如discord或邮件这样的通知渠道,增加句子的连贯性和自然流畅度。
坑陷

但是让系统变成事件驱动的并不总是个好主意。这里有一些需要注意的地方:

  • 这并不是非此即彼的做法!只需在合适的地方使用这种发布-订阅模式。不必对此太过于死板。别太死板了……喂喂喂 🐶

  • 仅仅用事件替换传统的 API 调用是不够的。在处理异步队列的过程中,有一些重要的概念需要掌握,特别是 最终一致性幂等性。花时间去理解这些概念。相信我,你以后一定会感谢我的。

  • 事件驱动的系统更复杂。你需要构建更多的组件,并且有额外的基础设施需求。使用正确的工具非常重要。如果没有合适的工具,调试问题和管理环境会变得相当令人沮丧。

这就是为什么我们将使用Encore.ts 来构建我们的事件驱动应用。Encore.ts 是一个开源框架,专门设计用于使用 TypeScript 更轻松地构建健壮且类型安全的分布式系统,就像我们今天要构建的事件驱动后端一样,Encore.ts 也使开发过程更加简单。它还内置了许多有用的工具,使开发过程更加顺畅,比如我们稍后会看到的本地开发仪表板。

现在,我们来看一些代码。

添加我们的 Pub/Sub 订阅主题

从代码的角度来看,在使用Encore时,一个服务在Encore中只是你仓库里的一个文件夹。在开发事件驱动的应用程序时,你可能会创建许多服务,因此,创建新服务需要简单。这也是Encore适合这种类型应用程序的原因之一。

我们先来看看 site 服务。

import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import knex from "knex";

// Site 描述了一个被监控的站点。
export interface Site {
  id: number;
  url: string;
}

export interface AddParams {
  url: string;
}

// 添加一个新的站点到监控列表中。
export const add = api(
  { expose: true, method: "POST", path: "/site" },
  async (params: AddParams): Promise<Site> => {
    const site = (await Sites().insert({ url: params.url }, "*"))[0];
    return site;
  },
);

// 根据 id 获取站点信息。
export const get = api(
  { expose: true, method: "GET", path: "/site/:id", auth: false },
  async ({ id }: { id: number }): Promise<Site> => {
    const site = await Sites().where("id", id).first();
    return site ?? Promise.reject(new Error("站点未找到"));
  },
);

// 根据 id 删除站点。
export const del = api(
  { expose: true, method: "DELETE", path: "/site/:id" },
  async ({ id }: { id: number }): Promise<void> => {
    await Sites().where("id", id).delete();
  },
);

export interface ListResponse {
  sites: Site[]; // Sites 表示监控站点的列表
}

// 列出监控站点。
export const list = api(
  { expose: true, method: "GET", path: "/site" },
  async (): Promise<ListResponse> => {
    const sites = await Sites().select();
    return { sites };
  },
);

// 定义一个名为 'site' 的数据库,使用位于 "./migrations" 文件夹中的数据库迁移。
// Encore 会自动配置、迁移并连接到数据库。
const SiteDB = new SQLDatabase("site", {
  migrations: "./migrations",
});

const orm = knex({
  client: "pg",
  connection: SiteDB.connectionString,
});

const Sites = () => orm<Site>("site");

点击全屏模式, 点击退出全屏

此服务包含一些 CRUD 操作的端点,如 addgetdeletelist。我们特别关注 add 端点,因为当新的站点被添加时,我们希望发布一个事件。让我们通过添加我们的 site.added 主题来开始使应用程序成为事件驱动的架构。我们通过调用 Topic 类并指定所需参数来实现这一点,指定将在此主题上发布的类型(在这种情况下为 Site 类型),并指定传递保证级别。

    import { Topic } from "encore.dev/pubsub";

    export const SiteAddedTopic = new Topic<Site>("site.added", {
      deliveryGuarantee: "至少一次交付保证",
    });

进入全屏;退出全屏

现在,我们可以在 add 端点里让 SiteAddedTopic 对象发布消息。

// 导出一个添加站点的函数
export const add = api(
  { expose: true, method: "POST", path: "/site" },
  async (params: 参数): Promise<Site> => {
    // 将站点插入数据库
    const site = (await Sites().insert({ url: params.url }, "*"))[0];
    // 发布站点已添加的主题
    await SiteAddedTopic.publish(site);
    // 返回站点信息
    return site;
  },
);

进入全屏 退出全屏

当你使用 Encore 的类型安全的 Pub/Sub 时,如果你用错误的参数向一个 Topic 发布消息,编译时就会出错,因为你在使用 Encore 的类型安全 Pub/Sub 🤯

我们的架构图现在看起来是这样的,如下所示:

架构示意图

我们正在向 site.added 发布消息,但还没有让 monitor 服务订阅该主题,所以我们现在来解决这个问题,让它也能订阅 site.added

添加订阅人

那么,打开monitor服务。

    import { api } from "encore.dev/api";
    import { SQLDatabase } from "encore.dev/storage/sqldb";
    import { Site } from "../site/site";
    import { ping } from "./ping";
    import { site, slack } from "~encore/clients";
    import { CronJob } from "encore.dev/cron"; // 检查单个站点。

    // 检查单个站点。
    export const check = api(
      { expose: true, method: "POST", path: "/check/:siteID" },
      async (p: { siteID: number }): Promise<{ up: boolean }> => {
        const s = await site.get({ id: p.siteID });
        return doCheck(s);
      },
    );

    // 检查所有站点。
    export const checkAll = api(
      { expose: true, method: "POST", path: "/check-all" },
      async (): Promise<void> => {
        const sites = await site.list();
        await Promise.all(sites.sites.map(doCheck));
      },
    );

    async function doCheck(site: Site): Promise<{ up: boolean }> {
      const { up } = await ping({ url: site.url });

      const wasUp = await getPreviousMeasurement(site.id);
      if (up !== wasUp) {
        const text = `*${site.url} 是 ${up ? "已恢复!" : "已离线!"}*`;
        await slack.notify({ text });
      }

      await MonitorDB.exec`
          INSERT INTO checks (site_id, up, checked_at)
          VALUES (${site.id}, ${up ? "在线" : "离线"}, NOW())
      `;
      return { up };
    }

    async function getPreviousMeasurement(siteID: number): Promise<boolean> {
      const row = await MonitorDB.queryRow`
          SELECT up
          FROM checks
          WHERE site_id = ${siteID}
          ORDER BY checked_at DESC
          LIMIT 1
      `;
      return row?.up ?? true;
    }

    const cronJob = new CronJob("check-all", {
      title: "定时任务",
      every: "1h",
      endpoint: checkAll,
    });

    export const MonitorDB = new SQLDatabase("monitor", {
      migrations: "./migrations",
    });

全屏模式, 退出全屏

这里我们有一个 check 端点,用于检查并更新单个站点的状态,但不会更新。在 API 处理器中,我们调用 site 服务中的 get 端点来获取站点信息。通过 encore/clients 文件夹导入该服务后,我们就可以像调用普通函数一样调用端点,具有完整的类型安全保证。在底层,这些函数调用实际上会转换为实际的 HTTP 请求,从而生成追踪数据和日志。

看起来doCheck函数就是在每次添加新站点时我们想要调用的函数,所以让我们添加相应的Pub/Sub订阅处理程序。

import { Subscription } from "encore.dev/pubsub";

const _ = new Subscription(SiteAddedTopic, "check-site", {
  handler: doCheck,
});

进入全屏 或 退出全屏

为了做到这一点,我们调用 Subscription 类,传入我们想要订阅的话题和订阅名。在配置对象中,我们只需要指定处理器,即每次有新事件时都会调用的函数。doCheck 函数接受一个 Site 参数,所以这里不需要做其他额外工作。

当地基础设施

所以,这个是怎么工作的?Encore 自带自动化的本地基础设施。当你在本地启动 Encore 应用程序时使用 encore run,Encore 会自动在你的电脑上搭建所有必要的基础设施,涵盖数据库和 Pub/Sub。因此,你不需要手动编写 YAML,配置 Docker Compose,或其他工具,比如 LocalStack,来搭建你的开发环境。

本地发展看板

encore还自带了一个内置开发控制台。当你启动encore应用时,开发控制台可以在port localhost:9400访问。在这里,你可以像使用Postman一样调用你的端点。每次调用应用都会生成一个追踪,你可以检查这个追踪来查看API请求、数据库调用和Pub/Sub消息。

Encore 在构建事件驱动应用时,能够轻松实现本地调试追踪,这也是它成为绝佳选择的另一个原因。

本地开发仪表板还包括一个带有自动 API 文档的服务目录。顺便提一下,前面那张漂亮的架构图就是 Encore Flow,这也是内置在开发仪表板中的。它会随着您的开发实时更新,准确反映系统状态。😎

部署过程

所以,我们如何部署这个应用程序呢?你可以通过encore build命令构建你的应用,从而获得一个可以部署到任何地方的Docker镜像。你需要提供一个运行时配置,以指定应用程序如何连接到基础设施,比如Pub/Sub和数据库。如果你不想手动处理这些配置,你可以使用Encore的云平台,该平台能在你的AWS或GCP账户里自动部署所需的基础设施,并自带CI/CD功能,你只需要推送代码就能完成部署。平台还自带监控、追踪和自动预览环境,你可以在一个专为每个代码拉取请求设置的临时环境中进行测试。

自自己运行 Uptime 应用程序:

如果你想自己玩一玩 Uptime 应用程序,你可以通过安装 Encore 然后在终端中运行 encore app create 来轻松做到。选择 TypeScript,然后在模板列表中选择 Uptime 应用程序。你需要安装 Docker desktop,因为这需要本地创建数据库。一旦你检出了代码,你也可以查看 slack 服务使用的 uptime-transition 主题。

收尾

intendent note: Adjusted the translation based on the expert suggestions to better reflect a casual tone and context, using "收尾" which means concluding or finishing something, fitting well with "Wrapping up".

  • ⭐️ 给项目在 GitHub 上点个星标来支持 Encore 项目。

  • 看看 Encore 的 示例仓库,里面有许多可以直接部署的应用程序。

  • 如果你想分享你的作品或有任何问题,可以加入Encore在Discord上的社区参与开发者聚会。
更多相关帖子

Encore ## 2024 Node.js 框架回顾 — Elysia / Hono / Nest / Encore — 你应该选哪个?Simon Johansson(Encore)・11月1日 #node #typescript #javascript #webdev

Encore ## 如何在 DigitalOcean 上使用 Docker 和 Encore 部署后端应用 · 2023年10月 # 开源 # JavaScript # Docker # Go

Encore ## TypeScript 和 Go:选择后端编程语言 Marcus Kohlberg 供 Encore ・ 2022年11月8日 标签:Go,TypeScript,JavaScript,Web开发

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消