
看到项目名 DDDDDDDDDDDD 的时候大多数人的第一反应都是你是不是键盘卡了说实话把这一串 D 敲进仓库名之后我自己也盯着屏幕愣了几秒。但后来我发现这名字意外地能用因为它恰好可以被拆成 12 个 D 开头的关键词每个都对应我在这个项目里真正用到的技术或实践。这篇文章就聊聊这个叫 DDDDDDDDDDDD 的自部署个人数据面板是怎么从零搭起来的以及我为什么宁可偶尔被同事嘲笑也要坚持这套“全 D 阵容”。先说一下项目本身。它解决的是我自己的一个具体痛点指标太多散落在各个地方。写文章的阅读数据在一个后台服务器监控在另一个面板小项目的 issue 又躺在别的平台想做小结的时候得同时开六七个页面来回切换。所以我做了一个自托管的数据面板把不同数据源的指标汇总到一个页面里用图表展示出来。整个项目不算大单机部署所有数据都留在自己手里跑在一台 2C4G 的小服务器上。如果你也想搭一个类似的个人数据面板或者想了解 Deno、Drizzle、DuckDB、Docker、D3.js 这一套组合怎么配合使用这篇文章应该能给你一些参考。1. 项目概述一个“键盘卡住”命名的自部署数据面板1.1 面板到底做了什么功能与边界这个面板的功能可以概括成三件事定时抓取、统一存储、图表展示。抓取这步我写了几组 collector按各自的时间节奏去拉数据。比如阅读数每 6 小时拉一次服务器指标每 5 分钟拉一次issue 数量每天拉一次。拉回来的数据统一落进 SQLite一张表存指标名一张表存时间序列结构非常简单。展示层是一个纯静态页面浏览器加载后通过 fetch 请求 Deno 服务端的查询接口拿到聚合好的数据再用 D3.js 画成折线图、柱状图和热力图。这里有一个重要的设计取舍不做实时只做定时同步。个人面板这个场景里实时更新的价值很低反而会让架构复杂很多。如果引入实时推送就得维护 WebSocket 或者 Server-Sent Events还要考虑前端断线重连这些都是纯成本。用定时任务加轮询服务端逻辑简单前端也只需要在页面加载时拉一次数据最多加个手动刷新按钮。这种“够用就好”的边界感是这个项目能在一周内从零跑起来的关键。1.2 12 个 D项目名的隐藏架构图名字里的 12 个 D 并不是随机的它其实是一张架构图。我把整个项目用到的关键技术、流程和理念按字母整理了一遍结果正好凑出了 12 个 D 开头的东西。序号D 词在项目里的角色1Dashboard最终产品形态一个自托管的可视化面板2DDD领域驱动设计的轻量实践用来划分模块边界3Deno服务端运行时负责 API 和定时任务4Drizzle给 SQLite 配的轻量 ORM管理表结构和迁移5DuckDB分析引擎跑批量聚合查询6Docker容器化交付保证本地和服务器环境一致7DroneCI/CD 流水线提交代码后自动测试、构建、发布8D3.js前端数据可视化绘制所有图表9Datadog可观测性记录日志和关键指标10Debug排查问题的固定方法论11Deploy部署策略一条命令完成更新12Docs文档沉淀把运行手册写清楚说实话一开始我并没有凑齐 12 个的野心。是项目做到一半某天看仓库名的时候突然发现咦技术栈里已经没剩下几个非 D 的东西了干脆把缺口补齐。后来我发现这个命名方式有个额外的好处每当我看到 DDDDDDDDDDDD 这个名字就等于把整个项目的架构在脑子里过了一遍。名字成了记忆锚点这个价值比强行玩梗重要得多。2. 核心技术选型Deno、Drizzle、DuckDB 与 D3.js 怎么配合2.1 Deno Drizzle没有 node_modules 的服务端服务端我选了 Deno。选择它的原因很朴素原生支持 TypeScript不需要额外的编译步骤自带权限模型自带格式化器和测试器。写一个小项目我实在不想再搭一整套 Node 工程化的链路而 Deno 开箱即用的程度刚刚好。入口文件就是一个普通的 TypeScript 文件直接起一个 HTTP 服务// main.ts Deno.serve({ port: 8080 }, async (req) { const url new URL(req.url); if (url.pathname /api/metrics) { const rows await queryAggregatedMetrics(); return Response.json({ data: rows }); } return new Response(not found, { status: 404 }); });注意这里没有任何 import 依赖框架的部分Deno 内置了Deno.serve直接就是标准的 Request 和 Response 对象。运行的时候加上权限参数deno run --allow-net --allow-read --allow-write main.ts权限模型刚上手会觉得烦多跑几次缺什么权限它就报什么错习惯了之后反而觉得安全。特别是容器部署的时候只给必要的权限暴露面小很多。数据访问层用了 Drizzle。选它的核心原因有两点一是轻二是 SQL 味足够浓。我写的 schema 本质上就是一张表一个对象一眼能看明白// schema.ts import { sqliteTable, text, integer, real } from drizzle-orm/sqlite-core; export const metrics sqliteTable(metrics, { id: integer(id).primaryKey({ autoIncrement: true }), key: text(key).notNull(), value: real(value).notNull(), recordedAt: integer(recorded_at, { mode: timestamp }).notNull(), });表结构变更用 drizzle-kit 生成迁移文件deno run -A npm:drizzle-kit generate之前我也考虑过 Prisma但这项目里用 Prisma 属于杀鸡用牛刀schema 文件、生成客户端、额外的引擎依赖结构太重。Drizzle 更接近写 SQL 本身查数据的时候心智负担很小。这里有一个实际经验在 Deno 环境里用 Drizzle连接 SQLite 时别选 better-sqlite3 这类原生依赖尽量走libsql/client的 WASM 版本否则后面会遇到deno compile的问题这个我放到最后的坑位里详细讲。2.2 DuckDB SQLite一份数据两种读法这个项目里最让朋友觉得奇怪的部分就是为什么同时用 SQLite 和 DuckDB 两个数据库。事情是这样的SQLite 负责承接写入collector 们高频地把原始数据塞进来插入和更新都很轻量。但面板要展示的往往是聚合结果比如“最近 30 天的每日阅读趋势”“过去一周的接口错误率”。这种分析型查询如果在 SQLite 上直接跑数据量一上来聚合大表的成本就会变得不可忽略。我的方案是把两件事分开每天凌晨由一个定时任务把 SQLite 里的原始数据导出成 Parquet 文件然后让 DuckDB 直接在这些 Parquet 上跑 SQL 聚合把结果集写成 JSON 缓存。面板的查询接口读的是这份聚合 JSON而不是当场查 SQLite。-- 在 DuckDB 里按天聚合指标 SELECT key, strftime(date_trunc(day, recorded_at), %Y-%m-%d) AS day, avg(value) AS avg_value FROM read_parquet(metrics.parquet) GROUP BY key, day ORDER BY day;这个架构的好处是两个数据库各干各擅长的事。SQLite 作为操作系统里的一个文件备份就是拷贝文件完全可控。DuckDB 作为分析引擎对列存和聚合查询的优化远好于 SQLite而且它可以直接读 Parquet省掉导入导出的环节。实际跑下来本来在 SQLite 里要两三秒的周级聚合在 DuckDB 上几乎秒出结果。代价是多了一步导出但这步很简单放在一个 cron 里执行没有任何运维负担。2.3 D3.js 画图自学可视化的一点心得前端可视化选了 D3.js。市面上图表库很多ECharts、Chart.js 都很成熟上手比 D3 快得多。但 D3 给我的是另一个东西完全的控制力。它不是一个封装好的图表组件库而是一套数据到 DOM 的绑定工具所以我画折线图时xs 轴的范围、y 轴的刻度间距、曲线的插值方式全部都是自己说了算。一个基础折线图的核心代码大概长这样const svg d3.select(#chart); const x d3.scaleTime() .domain(d3.extent(data, d d.date)) .range([0, width]); const y d3.scaleLinear() .domain([0, d3.max(data, d d.value)]) .range([height, 0]); svg.append(path) .datum(data) .attr(d, d3.line() .x(d x(d.date)) .y(d y(d.value)));第一次看 D3 的代码会有点懵因为scaleTime、scaleLinear、line这些概念都是全新的。我的学习心得是别想着直接画复杂图先把“比例尺”这个概念彻底搞明白。D3 的一切都建立在比例尺上把数据域映射到像素域理解了这个剩下的就是从官方示例里改。我画折线图、柱状图、热力图都是先找一个官方 example读懂它的比例尺和数据绑定方式再改造成自己的数据。2.4 DDD 的轻量实践小项目也要有边界提到 DDD很多人第一反应是“一堆聚合根和事件风暴”。那是重方案。我在这个项目里只借用了 DDD 的两个基本思想限界上下文和领域模型。我把项目划分成三个上下文数据接入collector、指标分析analytics、展示dashboard。它们之间的依赖方向是单向的collector 只能写数据analytics 只读数据dashboard 只读聚合结果。谁也不能越界去改别人的表。这样划分之后最直接的好处是改代码的时候不用小心翼翼因为每个模块的职责清楚改 collector 的抓取逻辑不会影响图表渲染。领域模型方面我只保留了核心概念Metric指标、DataSource数据源、Snapshot快照。所有业务代码都围绕这三个概念展开没有额外发明更多抽象。小项目做 DDD最忌讳的是为未来的需求做过度设计。我见过太多人在一个 3000 行的项目里建了十几个领域对象最后自己都搞不清谁依赖谁。轻量实践的度是模型能自然表达业务语义模块边界能防止随意改动这就够了。3. 交付链路实操Docker 镜像、Drone 流水线、一键 Deploy3.1 Dockerfile 与镜像瘦身实践整个服务最终是跑在 Docker 容器里的。容器化带来的最大好处是环境一致性我本地跑得通服务器上就一定跑得通再也不会出现“我机器上好好的”这种问题。Dockerfile 我用了多阶段构建。Deno 支持用deno compile把 TypeScript 源码编译成单个可执行文件这一步很适合放进 builder 阶段# 构建阶段 FROM denoland/deno:debian-2.1.4 AS build WORKDIR /app COPY deno.json deno.lock ./ RUN deno cache main.ts COPY . . RUN deno compile \ --allow-net --allow-read --allow-write \ --output /app/server \ main.ts # 运行阶段 FROM debian:bookworm-slim RUN apt-get update \ apt-get install -y ca-certificates \ rm -rf /var/lib/apt/lists/* COPY --frombuild /app/server /usr/local/bin/server EXPOSE 8080 CMD [server]这样最终运行的镜像里只有一个可执行文件加系统底层库没有任何源码和依赖文件镜像体积从包含全部依赖的 1.2GB 降到了 180MB 左右。对于个人项目来说部署时省下的时间虽然不多但镜像越小拉取越快出问题的面也越小。另外运行阶段用 Debian 而不是 Alpine是为了避免一些动态链接库的兼容性问题这个坑在 Go 和 Deno 编译后的二进制上都可能遇到与其踩完再改不如一开始就选稳妥的基础镜像。3.2 Drone CI 流水线容器即构建环境CI/CD 选了 Drone一个容器原生的持续集成平台配置文件长这样kind: pipeline type: docker name: default steps: - name: test image: denoland/deno:debian-2.1.4 commands: - deno fmt --check - deno lint - deno test - name: publish image: plugins/docker settings: repo: registry.example.cn/me/dddd tags: - ${DRONE_COMMIT_SHA} - latest username: from_secret: registry_username password: from_secret: registry_password选择 Drone 而不是直接用 GitHub Actions原因有两个。第一我本身有自建的代码托管服务Drone 可以完全自托管构建记录、构建缓存、密钥管理都放在自己手里。第二Drone 的每一个 step 都是一个 Docker 容器构建环境就是声明式的不存在“本地 build 过了 CI 里却挂了”的灵异问题。配置里 test 这一步直接用 deno 镜像镜像里有什么环境就是什么环境干净利落。secret 在 Drone 里需要先在仓库设置里配置然后在配置文件中用from_secret引用不能把密码明文写在仓库文件里。我第一次搭的时候直接把密码写在 YAML 里结果 Drone 会拒绝加载包含明文 secret 的配置文件这一点值得提前说明。3.3 Deploy 策略从提交到上线部署环节的思路很简单每次提交触发流水线构建出带 commit hash 标签的镜像然后推送到私有仓库服务器上跑一个容器镜像 tag 改成最新的即可。我用的 docker-compose 文件services: app: image: registry.example.cn/me/dddd:latest restart: unless-stopped ports: - 127.0.0.1:8080:8080 volumes: - ./data:/app/data这里有一个细节我只把容器端口绑定到 127.0.0.1外面再放一个 Caddy 做反向代理和 HTTPS。这样应用本身不直接暴露公网反向代理层统一处理证书和域名。服务器上更新时只需要拉最新镜像再重建容器docker compose pull docker compose up -d数据目录通过 volume 挂载到宿主机SQLite 文件不会因为容器重建而丢失。备份也简单定时把整个 data 目录压缩上传到对象存储就行。这套部署方式虽然不够“云原生”但对一个个人项目来说稳定性、可控性和可维护性都是达标的。4. 工程规范Datadog 接入、Debug 流程、Docs 沉淀4.1 Datadog个人项目要不要上可观测性这个项目量级很小按常理不应该上 APM 这类重型可观测方案。但我在这个项目上用 Datadog 有两个实际诉求一是想随时知道定时任务有没有跑挂二是想看看服务器的基础资源曲线。所以我在 compose 里加了一个 datadog-agent 容器agent: image: gcr.io/datadoghq/agent:7 environment: DD_API_KEY: ${DD_API_KEY} DD_SITE: datadoghq.com DD_LOGS_ENABLED: true volumes: - /var/run/docker.sock:/var/run/docker.sock实际用下来我最看重的是日志和容器的存活监控。面板的 API 日志和 collector 的输出都会通过 agent 收集如果某个定时任务连续几次失败我可以在 Datadog 里配置一个简单的异常检测告警直接推送到手机。这个项目我并没有接入分布式链路追踪因为单服务架构没有跨服务调用链强行上 trace 纯属浪费。这里也要说句公道话Datadog 的计费方式对个人项目不算友好免费额度有限。如果预算敏感完全可以用 Prometheus Grafana 或者 Loki 组合替代都是成熟的方案。我在这套项目里选择 Datadog 更多是出于熟悉程度和“D”主题的执念。可观测性的价值不在于用了什么工具而在于出问题时你能用最短的时间定位到根因。4.2 Debug我的问题定位三板斧个人项目最难受的时刻不是功能做不出来而是服务跑着跑着突然数据不对但你不知道是哪一环出了问题。我在这个项目里总结出三板斧先看权限、再看日志、最后复现。第一板斧是针对 Deno 的。权限错误出现的频率远超预期每次新增一个文件读取或者网络请求都可能触发PermissionDenied。这里我的建议是不要把权限参数一刀切地全放开而是先按报错提示补齐必要的权限确认稳定后再收敛。--allow-all虽然省事但会让权限模型形同虚设。第二板斧是结构化日志。我从一开始就给所有 collector 加了统一格式的 JSON 日志每行一条包含时间、数据源、抓取条数、耗时、状态。出问题时直接在日志里按数据源过滤一眼就能看到是哪次抓取失败了。{ts:2025-01-02T03:00:00Z,source:rss,count:42,duration_ms:310,status:ok}第三板斧是写最小复现脚本。比如 DuckDB 聚合结果和 SQLite 对不上我不会直接在项目里翻代码而是先导出一小段数据写一个 20 行的独立脚本跑一遍 SQL把问题从“项目代码的 bug”缩小成“SQL 本身的问题”或者“数据类型的问题”。这一步能节省大量瞎猜的时间。4.3 DocsREADME 当运行手册来写很多个人项目的 README 只有一句“fork and run”这个坏习惯我在这个项目里改了。因为项目里有多个数据源接入、数据库迁移、备份恢复这些操作三个月后我大概率会忘掉细节与其到时候翻代码回忆不如把 README 写成一份实打实的运行手册。我在 README 里放了这几块架构说明、本地开发启动方式、数据源接入清单、数据库迁移步骤、备份恢复命令、常见问题索引。每块都不长但保证照着做能跑通。文档里还放了一段 ASCII 架构图标出数据从 collector 到 SQLite 到 DuckDB 再到前端页面的流转路径。这比 2000 字的架构说明有效得多因为看图的人 10 秒就能建立起全局认知。写文档这件事我的经验是把它当代码的一部分来维护。每次改了数据接入方式或者部署流程顺手更新 README成本几乎为零。等真正需要查文档的时候你会发现它比任何人的微信语音都靠谱。5. 常见问题排查与踩坑实录5.1 高频问题速查表项目从搭建到上线我积累了一张问题排查表现在团队里同事遇到类似问题也会先翻这张表。这里整理成表格分享出来现象大概率原因处理方式容器启动直接退出忘记加 --allow-read 权限补权限参数或改用 deno compile 固化权限Deno 里 import better-sqlite3 报错原生模块与 Deno 运行时兼容性换成 libsql/client 的 WASM 版本DuckDB 聚合结果比 SQLite 多Parquet 重复导出、时间字段类型不一致检查导出任务是否幂等统一时间戳存储前端图表日期整体偏移一天JavaScript Date 月份从 0 开始或 UTC 时区解析用 d3.timeParse 指定格式统一按 UTC 存储Drone 构建时 secret 一直无效明文写在 YAML 里被拦截在仓库设置中配置 secret用 from_secret 引用编译好的镜像在服务器上跑不起来构建机和运行机架构不一致arm64/amd64buildx 指定 --platform linux/amd64Datadog agent 内存占用过高开启了容器全量日志采集关闭 collect_all只采集目标容器这张表最有价值的是“大概率原因”这一列它代表的是我踩过之后形成的直觉。问题出现时先按表里的方向查大多数时候都能直接命中。5.2 三个让我印象最深的坑第一个坑是deno compile和原生模块的冲突。项目最开始数据库访问用了 better-sqlite3本地跑得好好的一旦执行deno compile生成可执行文件启动就报错提示找不到原生绑定。查了半天才意识到 Deno 在编译为单文件时对原生模块的支持有限。最后我把所有数据库访问全部切换到 libsql 的 WASM 版本这个问题才彻底消失。这给我的教训是只要计划用deno compile做交付从一开始就不要碰需要原生编译的 npm 依赖。第二个坑是时区问题。SQLite 里存的是本地时间DuckDB 聚合时按日分组前端又用 JavaScript 的 Date 解析三层各带一次时区转换最后图表上的曲线和真实数据错开了大半天。这个问题的根因是存储层没有统一使用 UTC。修复方式很粗暴collector 写入前统一把时间转换成 UTC 时间戳所有查询和展示也都按 UTC 处理等到展示层需要本地化显示时再做一次转换。想避免这类问题唯一的原则就是“存储统一展示转换”任何中间环节都别碰时区。第三个坑是镜像架构。我本地电脑是 ARM 架构构建出来的镜像推上去之后x86 的服务器直接报 exec format error。这个问题排查起来特别迷惑因为镜像能正常推送到仓库docker compose 也能正常拉取但容器一启动就退出。解决方案是在 Drone 流水线里用 buildx 显式指定--platform linux/amd64让 CI 始终构建目标平台的镜像。从那以后我养成一个习惯在流水线里显式标明平台而不是依赖构建机的默认值。5.3 回到这副“键盘残骸”如果现在有人问我这个项目最大的收获是什么我可能会说不是技术栈本身而是把一个想法从头拼完整过程。刚开始面对 DDDDDDDDDDDD 这个名字我自己也觉得像键盘坏了但等 12 个 D 都各就各位之后我发现项目里的每个选择都能对上一个清晰的理由这种“每个零件都清楚为什么在这”的感觉是写一次性脚本的时候永远体会不到的。我是真心推荐你做一个小项目的时候也试试这种命名方法。不一定是 D也可以是你的名字缩写、一个喜欢的关键词重要的是让名字成为一个索引看到它的时候能想起整个项目的结构。最后再分享一个小技巧当一个个人项目能在一分钟内完成部署、三分钟内找到问题日志、不靠记忆也能知道所有模块的职责时这个项目就算真正落地了。技术指标写得再漂亮都不如自己长期维护得顺手来得实在。