前言
在 HEXO 开发笔记(6)自建主题:核心功能实现 中,已经记录过主题统计功能从 localStorage 迁移到卜算子,再迁移到自建 counter 的过程。当时的实现能够满足日常使用,但计数数据主要停留在内存中,服务重启后 UV 会重新开始,配置和数据库迁移也还比较粗糙。
这次整理 doratiger-counter,主要补上了 UV 持久化、数据库迁移和退出前同步,也调整了来源校验,避免字符串匹配放过不该接受的域名。下面记录这些改动,以及 DoraTiger 主题如何调用统计接口。
一、为什么不继续使用第三方统计
最早的统计方案是浏览器端 localStorage。它不需要后端,部署成本最低,但统计数据只存在当前浏览器中,换设备或清理浏览器数据后就无法连续累计。后来接入卜算子,站点和页面的统计可以集中保存,但计数脚本依赖外部服务,服务可用性、网络环境和返回格式都不是主题能够控制的。
我的博客只需要站点和文章的 PV/UV,用一个 Go 程序加一个 SQLite 数据库文件就可以实现。主题通过 HTTP API 读取计数,统计口径和升级方式由自己维护,出了问题也方便检查代码和数据库。
二、整体结构
doratiger-counter 目前由三层组成:
text12345浏览器中的 DoraTiger 主题 → GET /count?page=<path>&uid=<visitor-id> → Go HTTP 服务 → 内存计数器与访客集合 → SQLite(定时同步,退出时最后同步)
主题负责生成访问者标识和当前页面路径,服务端负责校验来源、递增计数并返回结果,数据库只负责保存可恢复的数据。这样划分以后,主题不需要知道数据库结构,服务端也不需要参与 Hexo 构建过程。
当前版本有两个接口:
http12GET /count?page=/posts/example/&uid=visitor-id Origin: https://blog.example.com
json123456{ "site_pv": 42, "page_pv": 3, "site_uv": 12, "page_uv": 2 }
page 是必填的页面键,uid 可选。没有 uid 时仍然会增加 PV,但不会增加 UV。另一个接口是 GET /health,只返回 {"status":"ok"},不要求请求带有站点来源,方便反向代理或监控系统做健康检查。
三、PV 与 UV 如何统计
3.1 PV 使用内存计数器
请求到达后,站点 PV 和当前页面 PV 都在内存中递增。服务每 30 秒把站点计数、页面计数和访客集合放进同一个事务写入 SQLite。收到 SIGTERM 或 Ctrl-C 时,HTTP 服务先停止接收新请求,再触发一次最后同步。
这种做法避免了每次页面访问都执行数据库写操作,适合个人站点的低到中等访问量。但它也意味着:如果进程被强制杀死,最近一次同步之后的计数可能丢失,最大窗口约为 30 秒。因此这套服务适合展示型统计,不适合财务、计费或审计场景。
3.2 UV 使用持久化摘要去重
主题第一次访问时在浏览器中生成一个 UUID,并通过 Cookie 保存一年。之后每次请求都把这个 UUID 作为 uid 发送给服务端。服务端不会保存原始 UUID,而是计算 SHA-256 摘要,再将摘要分别放进站点访客集合和页面访客集合中。
这样做有两个直接效果:同一个访客再次打开页面时,页面 PV 会增加,但页面 UV 不会重复增加;服务重启后,访客集合可以从数据库恢复,站点 UV 不会从零开始。摘要仍然是可以关联的假名标识,所以它不是“完全匿名数据”,部署时仍然应该在隐私说明中告知访客用途。
四、来源限制与 CORS
统计接口不是登录接口,但至少可以减少普通网页和脚本的误调用。配置 allowed_origins 后,服务会解析 Origin 或缺失时使用的 Referer,只按规范化后的主机名精确匹配允许列表:
toml1234[counter] site_key = 'dtc_site' allowed_origins = ['blog.example.com'] enable_cors = true
这里有两个容易混淆的边界。第一,example.com 和 example.com.evil.test 不能按字符串包含关系判断,否则恶意后缀也可能通过。第二,Origin/Referer 可以被直接构造 HTTP 客户端伪造,所以它只能作为来源限制,不能当作身份认证,更不能用于授权、计费或其他安全决策。
开启 CORS 后,服务只会为通过白名单校验的来源返回 Access-Control-Allow-Origin,并带上 Vary: Origin。如果统计服务和博客由同一个反向代理提供,通常不需要打开宽泛的跨域策略。
五、数据库迁移与恢复
数据库使用单调递增的 schema version。初始版本包含四类数据:页面 PV、站点 PV、站点访客摘要和页面访客摘要。启动时先检查数据库版本,再执行只增加表结构的迁移;如果发现数据库版本高于当前程序,服务会拒绝启动,避免旧程序误操作新数据。
对于早期只有 page_stats 和 site_stats 两张表的数据库,当前迁移会保留原有 PV,再补齐访客表。服务启动时将已有数据加载到内存,后续请求继续从原来的数值上递增。升级服务前仍建议同时备份 counter.db、counter.db-wal 和 counter.db-shm 文件。
六、主题中的接入方式
主题配置只需要指定统计类型和 API 地址:
yaml123456statistics: enable: true type: counter counter: api: https://counter.example.com/count uv: true
footer.pug 在文章页和普通页面中渲染统计占位符,浏览器脚本读取或创建 dtc_uid,再把 location.pathname 和访客标识拼到 API 请求中。请求成功时显示服务端返回的 site_uv、page_uv;请求失败或没有配置 API 时,则退回到 localStorage 的本地方案。
这个 fallback 很重要:统计服务短暂不可用时,主题不会因为一个附加功能失败而影响文章阅读。但两种方案的统计口径不同,localStorage 只知道当前浏览器访问过哪些路径,不能与服务端的站点总量直接比较,所以它更适合作为临时占位,而不是长期数据源。
七、部署边界与当前限制
服务可以直接运行,也可以通过 Docker 部署。生产环境建议让它监听内网地址,由 Caddy、Nginx 等反向代理提供 HTTPS,并限制后端端口的可访问范围。数据库目录需要持久化挂载,容器更新时不能把 /app/data 一并丢弃。
当前版本刻意保持简单,也保留了几个明确限制:
- 只支持
SQLite,面向单实例和个人站点; - 计数先写内存,再定时同步,异常退出存在短暂数据窗口;
- 访客摘要和页面访客集合会随访客量增长,暂不适合大规模多租户部署;
- 不提供后台管理页面、历史报表和数据导出接口;
- 来源白名单不是认证机制,不能防止有意伪造请求。
目前我仍按单实例维护。外部数据库、后台报表和多实例协调暂时没有实际需求,后面根据访问量再考虑。
八、总结
整理之后,统计服务可以从数据库恢复访客集合,升级时处理旧表结构,正常退出前也会同步内存计数。日常使用仍需备份数据库,并注意异常退出可能丢失尚未同步的数据。对我目前的博客来说,这些已经够用了。
参考
支付宝
微信