Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Encore/第14章

第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 端点

bash
mkdir monitor
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);
  });
});
bash
encore test

四、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
sql
SELECT * FROM checks;

此时看仪表盘 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 服务:订阅并通知

bash
mkdir 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 行。

十一、常见误区

  1. 本地苦等 Cron 触发——本地不执行,手动 curl /check-all
  2. import { Site }import { site } 混淆——前者是类型(可直接 import),后者是服务客户端(必须走 ~encore/clients)。
  3. 每次巡检都发事件——没做翻转判断,Slack 被刷屏。
  4. getPreviousMeasurement 首检默认值写成 false——每个新站点第一次巡检都会误报"恢复"。
  5. Webhook URL 写死在代码里——走 secret()
  6. 订阅 handler 不幂等——at-least-once 下重复通知;给通知加去重键(练习 3)。

十二、实践练习

  1. site.add 加 URL 校验(IsURL,第 4 章),并把重复 URL 的唯一约束错误包成 alreadyExists
  2. 把巡检频率改成每 10 分钟,并新增"连续 3 次 down 才算宕机"的防抖逻辑(提示:查最近 3 条 checks)。
  3. 给 Slack 通知做幂等:notify_log 表记录 (site_id, up, checked_at) 唯一键,重试不重发。
  4. 新增邮件通知服务订阅同一主题——体会"加消费者不改发布方"。
  5. encore gen client 生成 TS 客户端,写一个 Node 脚本每分钟拉 /status 打印彩色状态表(预习第 15 章)。

十三、总结

  1. 三服务两库一 Cron 一主题,约 200 行业务代码构成完整事件驱动系统。
  2. monitor→site 的调用、check 的调用链、发布订阅链路全部自动进 trace 与架构图。
  3. 状态翻转才发事件是监控/对账类系统的标准模式;首检默认值是易错细节。
  4. knex 接管查询、Encore 接管迁移与连接,是 ORM 集成的轻量形态。
  5. secret + 私有 API + 订阅 handler 的组合,是"对外集成类服务"(Slack/短信/邮件)的通用骨架。

请继续阅读:第15章:测试与前端集成


原始资料引用



本章目录
一、架构蓝图二、初始化三、monitor 服务(一):ping 端点四、site 服务:站点 CRUD(knex 版)五、monitor 服务(二):记录巡检 + Cron六、状态端点(前端用)七、Pub/Sub:宕机/恢复事件八、slack 服务:订阅并通知九、部署十、收尾清单:这个项目没写的东西十一、常见误区十二、实践练习十三、总结原始资料引用Related Documents
苏ICP备2025204887号-2