用发布订阅机制构建简单的事件驱动应用
在这篇文章里,我将一步步教你如何使用TypeScript创建一个基于事件驱动的Node.js应用程序。我们将从一个传统的应用程序开始,然后通过让服务通过发布/订阅模式来交流,逐步让服务变得松散耦合。
我们将看看如何在本地运行应用程序,以及如何将事件驱动应用部署到云上。
视频版
应用我们将要查看的应用程序是一个在线状态监控系统。我们有一份需要监控的网站清单和一个CronJob,用于检查每个网站是否可访问。我们的应用程序会在状态变化时发送通知。新添加的网站状态默认为未知
,直到CronJob检查过该网站的状态。这是我们希望在将应用程序改为事件驱动模式时需要调整的地方之一。
这里是在GitHub上的已完成应用程序的完整代码:https://github.com/encoredev/examples/tree/main/ts/uptime
建筑设计: 有关建筑设计的内容:在这里我们有两个架构图示,左边的图是我们当前的系统,右边的图是我们完成后的理想状态。
"看看这个建筑设计"
在我们当前的状态下,你可以看到我们有四个服务:frontend
,monitor
,site
和 slack
。填充的箭头表明 monitor
服务正在调用 site
和 slack
服务中的端点,monitor
服务对那两个服务有严格的依赖关系。我们还可以看到,monitor
和 site
服务都有自己的数据库。monitor
服务中有一个 CronJob,它每小时检查一次每个站点的状态。
所以,我们想在 site
服务中引入一个 site.added
主题,每当我们在 site
服务中添加了一个新站点时,就可以发布到该主题中。我们将通过 monitor
服务订阅 site.added
主题,并通过访问网站来检查其状态,以确保在添加了新站点时一切正常。
我们还想通过这种方式来解除 monitor
和 slack
服务之间的 硬性依赖,通过引入一个 uptime-transition
主题(Topic)。
在继续之前,我们先来谈谈为什么对我们的系统做这些改变很有意义。
-
通过进行这些更改,我们使服务之间更加松散地连接起来,这几乎总是件好事。
monitor
服务不再需要知道slack
服务的存在,而site
服务也不再受monitor
服务的影响,可以保持独立。 -
slack
服务现在可以离线而不会影响monitor
服务。当它重新上线时,它会从事件队列中读取并从上次停止的地方继续。因此,系统变得更加健壮。 - 现在
monitor
和slack
服务都依赖于一个抽象层,而不是直接依赖对方,这叫做依赖倒置原则。这样我们就可以在不改动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 操作的端点,如 add
、get
、delete
和 list
。我们特别关注 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 应用程序,你可以通过安装 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上的社区参与开发者聚会。
## 2024 Node.js 框架回顾 — Elysia / Hono / Nest / Encore — 你应该选哪个?Simon Johansson(Encore)・11月1日 #node #typescript #javascript #webdev
## 如何在 DigitalOcean 上使用 Docker 和 Encore 部署后端应用 · 2023年10月 # 开源 # JavaScript # Docker # Go
## TypeScript 和 Go:选择后端编程语言 Marcus Kohlberg 供 Encore ・ 2022年11月8日 标签:Go,TypeScript,JavaScript,Web开发
共同学习,写下你的评论
评论加载中...
作者其他优质文章