1. EtherealYz 到底是个什么项目
先交代一下背景。我断断续续维护了一个个人项目,代号叫 EtherealYz,名字很直白:Ethereal 取自"空灵、轻盈、像以太一样渗透在空气里",Yz 是我自己的字母缩写,这项目就是我的个人数字工作台。
说白了,它不是一个能一句话讲清楚的传统软件,而是一套把散落在各种终端、看板、笔记、自动化脚本里的信息,统一收敛到一个自托管仪表盘的组合方案。我把它拆成了三个部分:
- 一个轻量的数据采集端,负责从服务器、本地开发环境、第三方 API 里抓取指标和事件流;
- 一个中间层,做数据清洗、聚合、缓存,顺便把对外暴露的接口全部收敛到这里;
- 一个前端展示层,把所有数据用极简风格渲染成大屏、小组件和日报推送。
我做它的起因很简单:那段时间我同时维护三个服务、一个家庭 NAS、两个内容站,每天要开五六种后台页面去查状态。不同系统有不同 UI、不同账号、不同刷新频率,信息割裂得让人崩溃。我试着用过现成的开源仪表盘,比如 Grafana、Dashy、Homepage,但总有不顺手的地方。Grafana 侧重监控指标,不适合展示内容型信息;Dashy 和 Homepage 又偏向书签导航,数据接入能力弱。我需要的是一个"什么都能接一点、接完还能自由排版、且足够轻"的东西。
于是 EtherealYz 就从"想要一个自己的控制台"这个朴素需求开始,慢慢长到了现在这个规模。这篇文章我不打算做那种从零教写代码的教程,而是想用项目复盘的方式,讲讲它从混乱到收敛的过程中,我做了哪些核心设计决策、踩了哪些坑、哪些经验是可以直接迁移到你自己项目里的。
如果你正在纠结"自己的服务太多、信息太散、想搞一个统一入口但不知道从哪下手",这篇内容应该能帮上忙。哪怕你完全不想用我的方案,里面关于数据接入、接口设计、部署排错的思路,也能套用到很多自托管项目上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 起名背后的产品定位,以及它和现成方案的差异
很多人觉得项目名只是个代号,无所谓。但 EtherealYz 这个名字其实是我产品定位的浓缩,也直接影响了我后续的每一次技术选型。
2.1 "Ethereal" 意味着什么
我当时给这个项目定了几条原则,全部围绕"空灵"这个词展开:
- 轻:整体运行时内存占用控制在 200MB 以内,不接受动不动就吃 1GB 内存的组件。
- 透明:所有数据流转过程要可视、可查,任何一个数据源断了,系统要能明确告诉我断在哪一步。
- 不打扰:界面默认只显示必要信息,次要内容折叠,避免一打开就是一个满屏数字的"监控机房"。
- 可拆解:任何一个模块坏了,不影响其他模块运行,整个系统可以部分降级。
这套原则直接否决了一批重量级方案。比如 Grafana,它本身很强大,但插件体系、数据源配置、告警规则、面板模型都带有一整套学习成本,对"家庭级"和"个人开发者级"的使用场景来说过于笨重。Dashy 又过于静态,它的定位是"导航页",没法处理 API 动态数据。
我要的是介于两者之间的一种东西:有自取数据的能力,又不像监控平台那样强调指标、告警、时间序列那一套。EtherealYz 的定位更偏向"信息聚合与呈现",而不是"监控"。
2.2 和技术选型的关系
因为定位是"信息聚合",所以我的技术选型也跟传统监控系统不一样。EtherealYz 整体分三层,每层都有明确界限:
| 层级 | 职责 | 选型 |
|---|---|---|
| 采集层 | 对接各种数据源,做格式归一化 | Go 编写独立二进制,支持 HTTP 轮询、Webhook、读取本地文件 |
| 中间层 | 数据校验、聚合、缓存、对外 API | Node.js + Fastify + SQLite,单进程即可跑 |
| 展示层 | 界面渲染、组件交互、用户配置 | Vue 3 + Vite,桌面端和移动端都用同一套响应式布局 |
选 Go 做采集层,是因为采集器经常要部署在多台服务器上,Go 交叉编译方便,扔一个二进制上去就能跑,不需要目标机器装运行时。选 Node.js 做中间层,是考虑到后续如果要写复杂的聚合逻辑或接入新数据源时,JavaScript 的人体工学更好,迭代速度快。SQLite 是整个系统的数据底座,因为个人项目数据量不大,没必要上 PostgreSQL,SQLite 单文件备份也省心。
我见过很多人一上来就 PostgreSQL + Redis + ClickHouse 全家桶,结果自己只有几台机器,数据量每天几千条,纯属给自己找运维负担。EtherealYz 的原则是:数据量没到百万级之前,不要引入需要单独守护进程的存储组件。
3. 第一版架构的致命问题:我把"采集"做成了定时任务
这里我想展开讲讲第一版的设计失误,因为它直接决定了后面整体的重构方向,而且这个坑我相信很多人也踩过。
3.1 最初的定时轮询模型
EtherealYz 一开始的采集层,就是一组 Crontab 定时任务。每个数据源一个脚本,每 5 分钟跑一次,把结果写到 SQLite 里。展示层再轮询中间层接口,每 30 秒刷新一次页面数据。
看起来没问题对吧?实际上跑了两周就裂开了。问题出在数据源的异构性和任务的相互依赖上。
我有几个数据源是链式的:
- 源 A 是某个第三方 API,每天凌晨 3 点更新数据;
- 源 B 需要基于源 A 当天最新数据做二次计算;
- 源 C 则需要把源 A 和源 B 的结果合并,生成一份日报推送。
Crontab 只保证"到点执行",不保证"执行时前一个任务已经成功完成"。于是我经常遇到这样的情况:B 任务跑了,但 A 任务因为网络超时失败了,B 拿到的是昨天的旧数据,计算结果全是错的。更麻烦的是,C 又在 B 的半成品上继续加工,最后推送给我的日报里,指标对不上。
3.2 数据缺失时的静默失败
第二个致命问题是,定时任务失败后没有任何记录。
脚本里我确实写了 try catch 和日志输出,但是日志分散在各个服务器的 /var/log 目录里,我没有统一的日志收集,所以一个任务失败后,唯一的感知路径就是"我看板上的数据不对了"。有时候是几小时后才发现。
有一个印象特别深的例子:某个数据源偶尔会返回一个空数组。我的采集脚本没做校验,直接把这个空数据写进了数据库。结果展示层发现昨天的数据"消失了",显示成空白。我花了一整天排查,最后才发现是采集脚本的问题,不是展示层的问题。
3.3 重构方向:任务编排 + 数据血缘
那次之后,我做了一次大的重构,核心是两点:
第一,所有采集任务必须显式声明依赖关系。 谁先跑、谁后跑、谁依赖谁的结果,这些不再是隐含在脚本里的顺序,而是写在一个 JSON 配置文件里。中间层读配置后,按拓扑顺序调度。上游失败,下游自动跳过,并且标记为 blocked,不会再用旧数据硬算。
第二,给每一条数据都加上血缘信息。 数据库表里新增了两列:source_task 和 window_time。source_task 记录这条数据由哪个采集任务产生,window_time 记录这个数据对应的时间窗口。这样排查问题时,可以沿着一条数据倒推:它是什么时候、被哪个任务、从哪个源采集来的,是不是预期的数据。
这两点改完效果立竿见影。之前我大概每周都要手动修一次数据,重构之后基本就再没出现过"数据悄悄丢失"的情况。
4. 中间层接口设计:为什么我不用 GraphQL
在中间层设计阶段,我和一个朋友争论过接口方案。他推荐 GraphQL,理由是前端组件多、数据结构复杂,GraphQL 可以按需查询,避免一次性拉一堆没用的大 JSON。
我最后选了 REST,但做了一些补充设计。原因是 EtherealYz 的前端展示层和后端是同一个仓库独立部署的,访问者只有我自己和少数几个人,没有多端消费方。GraphQL 的优势主要体现在大量客户端、多团队协作的场景,对单个用户的自托管系统来说是负优化。
4.1 我采用的 REST 约定
我设计了一套很实用的 REST 惯例,分享出来供参考:
code复制GET /api/v1/sources -- 列出所有数据源及最近状态
GET /api/v1/sources/:id/run -- 触发某个数据源手动采集
GET /api/v1/widgets -- 获取所有自定义组件的数据
GET /api/v1/events?from=&to= -- 按时间范围查询事件流
POST /api/v1/notify -- 手动触发一次通知推送
每个接口返回的 JSON 都有一个统一信封结构:
json复制{
"code": 0,
"data": {},
"meta": {
"cache": true,
"duration": 12.3,
"updated_at": "2024-05-18T09:30:00+08:00"
}
}
code 非 0 时表示异常,data 里会有详细错误信息。meta 字段很重要,它告诉前端这数据是不是命中缓存的、后端处理耗时多久、数据是什么时候更新的,这样加载状态的展示可以做得非常细致。
4.2 缓存策略
中间层的缓存我用了最简单的方案:内存缓存 + TTL。某个接口被请求后,如果它在配置的 TTL 范围内,直接返回内存里的结果,不再向后端数据源发起调用。
TTL 根据数据源类型区分:
- 实时性要求高的(比如服务器在线状态):TTL = 10 秒
- 变化频率中等的(比如 RSS 聚合):TTL = 60 秒
- 低频数据(比如每日统计数据):TTL = 5 分钟
我特意不引入 Redis,因为单机场景下内存 Map 足够用,Redis 多一个进程就多一个维护点。有时候"解决问题的最简单方案"就是最好的方案。
5. 前端展示层:不是做界面设计,是在做信息密度控制
前端是我花时间最多的部分,因为 EtherealYz 的核心价值不在后端,而在于数据的呈现方式。
5.1 组件化设计的核心是"安全降级"
每个数据小组件都坚持一个原则:展示层绝不依赖数据层的完整性。
什么意思?比如一个"服务器状态"组件,它可能同时依赖三个数据源:CPU 信息、磁盘信息、运行时间。某个时刻磁盘信息源挂了,这个组件不能整个白屏,而是要局部降级:
- 显示 CPU 信息和运行时间;
- 磁盘区域显示一个灰色横条,提示"数据源不可用";
- 组件右上角显示一个小圆点,颜色标红,说明该组件存在异常。
这个设计看似简单,实际上需要组件模型本身就支持"分段数据加载"。我在 Vue 组件里给每个数据块单独设计了 loading、ready、error 三态,而不是整个组件一个大 loading。
5.2 布局系统:Flexbox 胜过拖拽
第一版我实现了拖拽布局,用的开源库叫 gridstack.js。功能确实强大,但实际体验很糟糕,因为拖来拖去后,用户想要的是"排列紧凑、清晰整齐",拖拽布局往往越拖越乱。
后来我回归了简单的方案:固定列栅格 + 可配置的分区模板。
布局配置就是一个 JSON:
json复制{
"rows": [
{
"cols": [
{ "widget": "server-status", "span": 2 },
{ "widget": "rss-reader", "span": 2 }
]
},
{
"cols": [
{ "widget": "daily-report", "span": 4 }
]
}
]
}
系统内置了三套布局模板:六宫格、三列流式、顶部概览 + 底部详情。用户可以在模板基础上微调,但不可以自由拖拽。这个约束反而让界面长期保持整洁。如果后续有人贡献新的布局模板,直接加到配置里就行。
5.3 移动端适配:PWA 优先
EtherealYz 的主界面是给大屏看的,但我也经常在手机上查看状态。方案不是单独做一个移动端页面,而是用 PWA 方案绕过应用商店。
核心做法:
- 整个前端通过 Vite 构建成静态文件,用 Nginx 托管;
- 写一个
manifest.json,配置应用名、图标、主题色; - 注册 Service Worker,把静态资源缓存到本地;
- 手机上用 Chrome 打开后选择"添加到主屏幕",就得到一个全屏 App 的体验。
PWA 的好处是不需要维护两套前端,设备适配交 CSS 媒体查询。缺点也有:iOS 上 Service Worker 的更新策略比较坑,经常出现"旧的缓存文件用了一周才更新"的情况。这里我踩了一个很典型的坑,后面专门讲。
6. 踩坑实录:PWA 更新缓存那点事
在讲具体坑之前,先说结论:如果你的 Service Worker 缓存策略写错了,用户会在你发布新版本之后依然看到旧界面,而且这个过程中不会有任何报错。 这是最隐蔽的坑之一,因为它不会让系统坏掉,只是"看起来没更新"。
6.1 问题现象
某次我改了一个前端组件的样式,重新构建部署后,在电脑上直接访问域名,看到的是新样式。但手机上的 PWA 入口打开后,还是旧界面。我一开始以为是手机浏览器缓存,清了浏览器缓存再试,没用。
6.2 排查链路
排查过程大概是这样的:
第一步,我先确认新版本静态文件在服务器上确实已更新。用 curl 请求 JS 文件,内容是新版本,排除服务器部署问题。
第二步,在手机 Chrome 的开发者工具里远程调试,查看 Service Worker 状态,发现服务器上注册的 Service Worker 版本还是 3 天前的,新版本根本没有被激活。
第三步,查看 Service Worker 脚本,发现我的 install 事件里用了 cache.addAll 把构建产物的 URL 列表写死了。这本身没错,问题出在 activate 事件里,我用了 caches.delete 来清理旧缓存,但删除范围只覆盖了 CACHE_NAME 固定前缀。如果新 Service Worker 没成功 activate,旧缓存就永远不被清理。
第四步,进一步分析为什么新 Service Worker 没 activate。原来 Service Worker 的更新机制是:浏览器发现新的 sw.js 文件后,会下载并尝试安装,但旧的 Service Worker 仍然控制着页面。只有当页面完全关闭后再打开,新的才会接管。而 PWA 模式下,用户直接从主屏幕图标进入时,如果那个 WebView 进程一直保留在后台,浏览器不会主动"刷新"它。
6.3 解决方案
我换了一套更可靠的策略:放弃自定义缓存清理逻辑,改走"网络优先,缓存兜底"的路线。
在 fetch 事件里:
javascript复制self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
// 只缓存同源静态资源
if (event.request.url.startsWith(self.location.origin)) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, copy);
});
}
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
});
网络请求永远走真实网络,失败时才回退到缓存。同时,在 sw.js 文件开头加了一行版本号:
javascript复制const CACHE_STATIC = 'ethereal-yz-static-cache-v20240518';
每次前端发版时,我把这个版本号改掉。浏览器检测到 sw.js 内容变化,就会安装新版 Service Worker。安装后自动清理旧缓存:
javascript复制self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
keys.forEach((key) => {
if (key !== CACHE_STATIC) caches.delete(key);
});
})
);
});
这套逻辑不一定适用于所有项目,如果你的业务场景是"完全离线优先"或"弱网环境优先",缓存策略得反过来。但对我们这种个人仪表盘场景,"网络优先、缓存兜底"已经足够好用了。
7. 部署细节:从裸机 Nginx 到 Docker Compose
EtherealYz 的部署形态我前后变过两次,从最早的"手动在服务器上跑进程",到现在的"一套 Docker Compose 全搞定"。
7.1 中间层服务的 Systemd 管理阶段
最早,采集器和中间层都是直接在服务器上跑的,用 Systemd 管理进程。好处是直观,systemctl status ethereal-middle 就能看状态。坏处是升级麻烦:每次更新都要手动 git pull,然后 npm install、npm run build、systemctl restart,步骤多且容易漏。
后来我把采集器、中间层、前端静态文件全部容器化,用 Docker Compose 编排。这是自托管项目里性价比最高的部署方式,因为 Compose 文件本身就是一份文档,别人拉下来一条命令就能起。
7.2 最终 Compose 结构
我的 docker-compose.yml 简化后长这样:
yaml复制services:
collector:
image: etherealyz/collector:latest
volumes:
- ./data/collector:/data
environment:
- CONFIG_PATH=/data/config.json
network_mode: host
restart: unless-stopped
middle:
image: etherealyz/middle:latest
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./data/middle:/data
depends_on:
- collector
restart: unless-stopped
web:
image: etherealyz/web:latest
ports:
- "127.0.0.1:8081:80"
depends_on:
- middle
restart: unless-stopped
这里有个容易被忽略的细节:中间层和 Web 服务都只绑定到 127.0.0.1,不直接对公网暴露端口。对外只开放 80/443 端口,由宿主机上的 Nginx 反向代理到内部的 8080 / 8081。这样做的好处是:即使前端被攻击,攻击者也只能碰到 Nginx,碰不到中间层的业务接口。
7.3 Nginx 反向代理配置的关键点
反向代理配置里最容易被忽略的是 WebSocket 支持和超时时间设置。EtherealYz 中间层有一个推送通道,用于给前端实时推送事件流,用的就是 WebSocket。Nginx 默认配置不转发 Upgrade 头,导致 WebSocket 一直握手失败。
需要在 server 块里加这些:
nginx复制server {
listen 443 ssl http2;
server_name ethereal.yourdomain.com;
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
}
}
proxy_read_timeout 3600s 能防止长连接被 Nginx 在 60 秒默认值内掐断。如果没有这个配置,WebSocket 每 60 秒就会断开重连,前端会出现频繁的 reconnecting 提示。
8. 数据源接入的通用模式与避坑经验
EtherealYz 支持的"数据源"五花八门:有 HTTP API、有 RSS、有数据库直连、有本地文件监控、有第三方 Webhook。经过这么多数据源接入的实践,我总结了一套通用模式,可以适配几乎任何外部数据源。
8.1 采集器的标准化流程
每个采集器都遵循同样的五步流程:
- 认证:从本地配置密钥文件里读取 API Key / 用户名密码,不在代码里硬编码;
- 请求:按数据源类型选择合适的协议(HTTP / WebSocket / SQL 查询 / 读文件);
- 校验:对返回结果做格式校验,判断是"空数据"还是"合法空数据";
- 归一化:把数据源特有格式转换成 EtherealYz 内部统一的 JSON Schema;
- 上报:把归一化结果 POST 到中间层的
/api/v1/ingest接口。
第 3 步的校验是初学者最容易忽视的。很多数据源在发生错误时不会返回 HTTP 5xx,而是返回 HTTP 200 加一个 {"error": "..."} 的 JSON。如果采集器只看状态码,这就会被当作合法数据入库。我在校验层做了一个白名单机制:明确列出哪些字段是必须存在的,不满足则丢弃并生成告警日志。
8.2 幂等性设计:重复数据不可怕
因为采集器可能因为网络超时而重试,中间层要保证:同一个数据源、同一个时间窗口的数据,重复提交不会产生重复记录。
具体做法是给每个数据源定义了一个唯一键。例如,RSS 数据源用(feed_url, entry_id) 作为唯一键;服务器 Ping 数据源用 (host, timestamp_rounded_5min) 作为唯一键。中间层在入库前执行 INSERT ... ON CONFLICT DO UPDATE,这样重复提交只会更新记录,不会插入新行。
在 SQLite 里这个操作也支持得很好:
sql复制INSERT INTO events (source, unique_key, payload, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(source, unique_key)
DO UPDATE SET payload = excluded.payload, created_at = excluded.created_at;
为了让这个方案生效,建表时就必须把联合唯一索引建好:
sql复制CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
unique_key TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_source_unique ON events(source, unique_key);
8.3 头部缓存与限流控制
接入第三方 API 时,踩过最多的坑就是限流。不是说你被限流了系统才报警,而是你没被限流前,根本不知道它有这个限制。某天某个采集任务连续失败了几小时,一查日志发现是 HTTP 429 Too Many Requests。
后来我给采集器统一加了一个"智能限流感知"逻辑:
- 每次请求前先查看该数据源最近 10 分钟内请求次数;
- 如果接近该数据源的限制阈值(从配置里读取),自动降低该源的采集频率;
- 收到 429 响应时,记录一个
backoff_until时间戳,在到期前不再请求该源。
这个逻辑不需要额外依赖,就是采集器里一个全局 Map,每个数据源维护自己的请求计数和冷却时间。
9. 信息推送模块:被低估的"日报"功能
EtherealYz 的增量价值,其实不在仪表盘本身,而在主动推送。
最开始我把系统做成一个"不看就不会知道"的被动工具。后来发现,真正有用的信息必须主动触达用户。于是我加了一个通知模块,每天固定时间把当天的关键信息汇总成一条推送,发到 Telegram Bot / 企业微信机器人 / 邮件。内容不是全量数据,而是"直接可读的摘要":
- 服务器今天有没有异常告警?有,几个?
- RSS 里有没有标记为重要关键词的新文章?
- 某个第三方服务的配额还剩多少?
- 每日一图(来自我的壁纸采集器);
- 明天的日程提醒。
9.1 通知模板的进化
第一版通知内容是一大段纯文本,把所有信息堆一起。看着很全,但阅读体验差。后来我改成"分组 + 状态行"的结构,每条信息一行,最多不超过 10 行:
code复制[EtherealYz 日报 05-18]
服务器:
- web-01 在线,负载 0.2
- web-02 离线超过 30 分钟(!)
订阅:
- 《Rust 异步编程模式》更新
- 《Vim 插件推荐》更新
配额:
- 每日 API 用量 42% / 100%()
新增书签:0
待办事项:2 个待处理
这个格式现在用了很久,是读起来最舒服的结构。
9.2 如何避免推送轰炸
推送做多了容易成为噪音。我加了一套"优先级 + 合并窗口"机制:
- 低优先级事件(RSS 更新、每日汇总)统一在每天固定时间推送;
- 高优先级事件(服务离线、数据源失效)立刻推送;
- 同一事件在 30 分钟内重复触发时,合并成一条,不重复轰炸。
比如:web-02 服务器在 30 分钟内连续断了 5 次,通知里只显示一次"web-02 离线",附带离线次数和最近一次离线时长。这样既保证及时性,又不制造恐慌。
10. 这套系统后来的走向:插件化改造与配置收敛
到这一步,EtherealYz 作为个人项目已经能稳定运行了。但它还不算"可对外交付"的东西,因为所有配置都写死在项目里,换一台机器部署需要改太多地方。
所以最近我花了大量时间做两件事:插件化改造和全量配置收敛。
10.1 插件化改造
以前每个数据源采集逻辑都写在主程序里,增加一个新数据源就要改主程序代码、重新编译、重新部署。改成插件化之后,每个采集器是一个独立的可执行文件或脚本,主程序通过标准输入输出跟它通信。
插件接口很简单,就两个约定:
- stdin 输入一个 JSON 配置,包含该插件的采集目标、频率、认证信息;
- stdout 输出一个 JSON 结果,结构遵循 EtherealYz 数据 Schema。
这样做的好处是:新加一个数据源,只需要写一个单独的小脚本,放到插件的 plugins/ 目录下,不需要改动主程序。某天某个插件出了问题,也只是它自己崩了,不会影响其他插件。
这也意味着社区贡献变得非常简单:任何人用 Python、Go、Shell 写的小插件,只要符合这个 stdin/stdout 协议,都能挂到 EtherealYz 上。
10.2 配置收敛到一个文件
以前各种配置分散在环境变量、JSON 文件、数据库表里。找配置要靠记忆,非常痛苦。我把所有配置统一到一个 ethereal.yaml 文件里,结构长这样:
yaml复制server:
host: 0.0.0.0
port: 8080
collectors:
- name: rss
type: rss
schedule: "*/5 * * * *"
config:
feeds:
- https://example.com/feed.xml
keywords: [rust, vue, 自托管]
notify: low
- name: server-status
type: http-ping
schedule: "*/1 * * * *"
config:
targets:
- https://status.example.com/ping
notify: high
notify:
telegram:
enabled: true
bot_token: "${TELEGRAM_BOT_TOKEN}"
email:
enabled: true
smtp_host: smtp.example.com
注意这里 ${TELEGRAM_BOT_TOKEN} 的写法。config 里存的不是真实密钥,而是环境变量引用。程序启动时先解析 YAML,然后做一次环境变量替换。这样配置文件本身可以提交到 Git 仓库,密钥不泄露。
10.3 对"配置即代码"的体会
做完这两件事,EtherealYz 才算真正从"我的玩具"变成了"可以分享给别人用的工具"。任何一个人在任意一台 Linux 服务器上,只要把 ethereal.yaml 改好,再执行 docker compose up -d,几分钟就能跑起一套属于他自己的仪表盘。
我在实际使用中最大的感受是,好的工具不是功能越多越好,而是边界越清晰越好。EtherealYz 每个模块都只做一件事,但组合起来,能覆盖"信息采集、聚合、展示、通知"整条链路。这种模块之间松耦合、模块内部高内聚的设计,才是它能持续演进不崩盘的原因。
最后分享一个实操小技巧:如果看完这些,你想自己搭一套类似的系统,不要一上来就复刻 EtherealYz 的全部功能。先只接一个数据源,比如把服务器负载和 RSS 聚合跑通,把它用起来。用熟了之后,你会自然知道下一步最该加什么模块。个人项目的演进,最忌讳的是反复推翻重来,最有效的路径是"小步快跑、持续使用、看到问题再改"。毕竟工具是为使用场景服务的,用得顺手、看得明白,比什么架构理念都值钱。
