第14章:实战二 —— Uptime 监控系统
约 6 分钟 · 更新于 2026-09-02
官方 Uptime Monitor 教程的完整中文版(仅 TypeScript)。这是理解 Encore 微服务能力的最佳案例:3 个服务、2 个数据库、1 个 Cron、1 个 Pub/Sub 主题——站点宕机/恢复时发 Slack 通知,官方标称 30 分钟完成。
一、架构蓝图
text
┌──────────┐ 跨服务调用 ┌──────────┐
│ monitor │ ───────────▶ │ site │ site 服务:被监控站点 CRUD(knex + Postgres)
│ 服务 │ └──────────┘
│ │
Cron ─▶│ checkAll │──▶ ping ──▶ 结果写 monitor DB
│ │
│ 状态翻转时 publish
└────┬─────┘
▼
Topic: uptime-transition
│ subscribe
┌────▼─────┐
│ slack │──▶ Slack Webhook 通知
└──────────┘
复习地图:跨服务调用(第 6 章)、knex 集成(第 5 章)、Cron(第 8 章)、状态翻转发事件(第 7 章模式一)、secret(第 8 章)。
二、初始化
用官方起点分支(内含一个现成前端页面):
bash
encore app create uptime --example=github.com/encoredev/example-app-uptime/tree/starting-point-ts
cd uptime
三、monitor 服务(一):ping 端点
typescript
// monitor/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("monitor");
typescript
// monitor/ping.ts
import { api } from "encore.dev/api";
export interface PingParams { url: string; }
export interface PingResponse { up: boolean; }
// 检查站点是否存活
export const ping = api<PingParams, PingResponse>(
{ expose: true, path: "/ping/:url", method: "GET" },
async ({ url }) => {
// 允许省略协议,默认补 https://
if (!url.startsWith("http:") && !url.startsWith("https:")) {
url = "https://" + url;
}
try {
const resp = await fetch(url, { method: "GET" });
const up = resp.status >= 200 && resp.status < 300;
return { up };
} catch (err) {
return { up: false };
}
},
);
bash
encore run
curl 'http://localhost:4000/ping/google.com' # → {"up": true}
参数化测试(test.each 一次表驱动多用例):
typescript
// monitor/ping.test.ts
import { describe, expect, test } from "vitest";
import { ping } from "./ping";
describe("ping", () => {
test.each([
{ site: "google.com", expected: true },
{ site: "https://encore.dev", expected: true },
{ site: "https://not-a-real-site.xyz", expected: false },
{ site: "invalid://scheme", expected: false },
])("should verify $site", async ({ site, expected }) => {
const resp = await ping({ url: site });
expect(resp.up).toBe(expected);
});
});
四、site 服务:站点 CRUD(knex 版)
bash
mkdir site site/migrations
npm i knex pg
typescript
// site/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("site");
sql
-- site/migrations/1_create_tables.up.sql
CREATE TABLE site (
id SERIAL PRIMARY KEY,
url TEXT NOT NULL UNIQUE
);
typescript
// site/site.ts
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import knex from "knex";
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;
},
);
// 查询单个站点
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("site not found"));
},
);
// 删除站点
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[];
}
// 列出全部站点
export const list = api(
{ expose: true, method: "GET", path: "/site" },
async (): Promise<ListResponse> => {
const sites = await Sites().select();
return { sites };
},
);
const SiteDB = new SQLDatabase("site", {
migrations: "./migrations",
});
// knex 直连 Encore 管理的数据库
const orm = knex({
client: "pg",
connection: SiteDB.connectionString,
});
const Sites = () => orm<Site>("site");
bash
curl -X POST 'http://localhost:4000/site' -d '{"url": "https://encore.dev"}'
# → {"id": 1, "url": "https://encore.dev"}
五、monitor 服务(二):记录巡检 + Cron
monitor 有自己的数据库存巡检历史:
bash
mkdir monitor/migrations
sql
-- monitor/migrations/1_create_tables.up.sql
CREATE TABLE checks (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
up BOOLEAN NOT NULL,
checked_at TIMESTAMP WITH TIME ZONE NOT NULL
);
typescript
// monitor/check.ts
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { CronJob } from "encore.dev/cron";
import { ping } from "./ping";
import { site } from "~encore/clients"; // ★ 跨服务调用
import { Site } from "../site/site"; // 只 import 类型,不 import 实现
export const MonitorDB = new SQLDatabase("monitor", {
migrations: "./migrations",
});
// 巡检单个站点
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);
},
);
async function doCheck(site: Site): Promise<{ up: boolean }> {
const { up } = await ping({ url: site.url });
await MonitorDB.exec`
INSERT INTO checks (site_id, up, checked_at)
VALUES (${site.id}, ${up}, NOW())
`;
return { up };
}
// 巡检全部站点
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));
},
);
// 每小时全量巡检(本地不执行,手动 curl /check-all 触发)
const cronJob = new CronJob("check-all", {
title: "Check all sites",
every: "1h",
endpoint: checkAll,
});
验证:
bash
curl -X POST 'http://localhost:4000/check/1'
encore db shell monitor
此时看仪表盘 Encore Flow:monitor → site 的依赖箭头已自动出现;Tracing 里 /check/1 的调用链是 check → site.get → ping → INSERT——四层一屏看全。
六、状态端点(前端用)
typescript
// monitor/status.ts
import { api } from "encore.dev/api";
import { MonitorDB } from "./check";
interface SiteStatus {
id: number;
up: boolean;
checkedAt: string;
}
interface StatusResponse {
sites: SiteStatus[];
}
// 每个站点的最新状态
export const status = api(
{ expose: true, path: "/status", method: "GET" },
async (): Promise<StatusResponse> => {
const rows = await MonitorDB.query`
SELECT DISTINCT ON (site_id) site_id, up, checked_at
FROM checks
ORDER BY site_id, checked_at DESC
`;
const results: SiteStatus[] = [];
for await (const row of rows) {
results.push({ id: row.site_id, up: row.up, checkedAt: row.checked_at });
}
return { sites: results };
},
);
起点分支自带的前端此时在 http://localhost:4000/ 可见站点状态列表(DISTINCT ON 是 Postgres 取每组最新一条的惯用法)。
七、Pub/Sub:宕机/恢复事件
只在状态翻转时发事件(第 7 章模式一)。修改 monitor/check.ts:
typescript
import { Topic } from "encore.dev/pubsub";
export interface TransitionEvent {
site: Site;
up: boolean;
}
// 状态翻转主题(宕机↔恢复)
export const TransitionTopic = new Topic<TransitionEvent>("uptime-transition", {
deliveryGuarantee: "at-least-once",
});
// 上一次巡检结果(无记录视为 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;
}
async function doCheck(site: Site): Promise<{ up: boolean }> {
const { up } = await ping({ url: site.url });
// 与上次不同才发布
const wasUp = await getPreviousMeasurement(site.id);
if (up !== wasUp) {
await TransitionTopic.publish({ site, up });
}
await MonitorDB.exec`
INSERT INTO checks (site_id, up, checked_at)
VALUES (${site.id}, ${up}, NOW())
`;
return { up };
}
八、slack 服务:订阅并通知
typescript
// slack/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("slack");
typescript
// slack/slack.ts
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import log from "encore.dev/log";
import { Subscription } from "encore.dev/pubsub";
import { TransitionTopic } from "../monitor/check";
export interface NotifyParams {
text: string;
}
// 发送 Slack 通知(不写 expose = 私有 API)
export const notify = api<NotifyParams>({}, async ({ text }) => {
const url = webhookURL();
if (!url) {
log.info("no slack webhook url defined, skipping slack notification");
return;
}
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (resp.status >= 400) {
const body = await resp.text();
throw new Error(`slack notification failed: ${resp.status}: ${body}`);
}
});
// 密钥:值不进代码库
const webhookURL = secret("SlackWebhookURL");
// 订阅状态翻转事件
const _ = new Subscription(TransitionTopic, "slack-notification", {
handler: async (event) => {
const text = `*${event.site.url} is ${event.up ? "back up." : "down!"}*`;
await notify({ text });
},
});
配置 Slack Incoming Webhook 后设置密钥:
bash
encore secret set --type dev,local,pr SlackWebhookURL
# 生产另设:encore secret set --type prod SlackWebhookURL
私有 API 本地也能直接调(调试用):
bash
curl 'http://localhost:4000/slack.notify' -d '{"text": "Testing Slack webhook"}'
端到端验证:添加一个必挂的站点(如 https://not-a-real-site.xyz)→ curl -X POST localhost:4000/check-all → Slack 收到 "down!" 消息;仪表盘 trace 里能看到 checkAll → doCheck → publish → subscription handler → fetch(slack) 的完整异步链路。
九、部署
json
// infra-config.json(自托管,两个库都要配)
{
"$schema": "https://encore.dev/schemas/infra.schema.json",
"sql_servers": [
{
"host": "my-db-host:5432",
"databases": {
"monitor": { "username": "my-db-owner", "password": {"$env": "DB_PASSWORD"} },
"site": { "username": "my-db-owner", "password": {"$env": "DB_PASSWORD"} }
}
}
]
}
bash
encore build docker uptime:v1.0
# 或
git push encore # Encore Cloud:staging-$APP_ID.encr.app
注意自托管还需要给 Pub/Sub 与 Cron 提供实现(云消息服务配置进 infra-config,第 16 章);Encore Cloud 则全托管。
十、收尾清单:这个项目没写的东西
对照传统实现盘点一下不存在的代码:服务发现/HTTP 客户端封装(~encore/clients 代劳)、消息队列客户端与重试(Topic/Subscription 代劳)、调度器部署(CronJob 代劳)、配置中心对接(secret 代劳)、埋点(trace 自动)、API 文档(自动)。三个服务的业务代码合计约 200 行。
十一、常见误区
- 本地苦等 Cron 触发——本地不执行,手动 curl /check-all。
- import { Site } 与 import { site } 混淆——前者是类型(可直接 import),后者是服务客户端(必须走 ~encore/clients)。
- 每次巡检都发事件——没做翻转判断,Slack 被刷屏。
- getPreviousMeasurement 首检默认值写成 false——每个新站点第一次巡检都会误报"恢复"。
- Webhook URL 写死在代码里——走 secret()。
- 订阅 handler 不幂等——at-least-once 下重复通知;给通知加去重键(练习 3)。
十二、实践练习
- 给 site.add 加 URL 校验(IsURL,第 4 章),并把重复 URL 的唯一约束错误包成 alreadyExists。
- 把巡检频率改成每 10 分钟,并新增"连续 3 次 down 才算宕机"的防抖逻辑(提示:查最近 3 条 checks)。
- 给 Slack 通知做幂等:notify_log 表记录 (site_id, up, checked_at) 唯一键,重试不重发。
- 新增邮件通知服务订阅同一主题——体会"加消费者不改发布方"。
- 用 encore gen client 生成 TS 客户端,写一个 Node 脚本每分钟拉 /status 打印彩色状态表(预习第 15 章)。
十三、总结
- 三服务两库一 Cron 一主题,约 200 行业务代码构成完整事件驱动系统。
- monitor→site 的调用、check 的调用链、发布订阅链路全部自动进 trace 与架构图。
- 状态翻转才发事件是监控/对账类系统的标准模式;首检默认值是易错细节。
- knex 接管查询、Encore 接管迁移与连接,是 ORM 集成的轻量形态。
- secret + 私有 API + 订阅 handler 的组合,是"对外集成类服务"(Slack/短信/邮件)的通用骨架。
请继续阅读:第15章:测试与前端集成。
原始资料引用