前言
不蒜子(busuanzi) 是一个广受欢迎的免费网页计数服务,能够轻松实现网站的页面浏览量(PV)和独立访客数(UV)统计。许多基于Hexo的网站都在使用busuanzi来实现访问统计功能,我的博客当前使用的主题是Butterfly
,这个主题也是通过集成busuanzi计数器来实现网页计数。
然而,随着使用人数的不断增多,这个由作者为爱发电的免费服务开始出现稳定性问题,访问速度也变得起伏不定。
因此,我决定自己开发一个服务来代替busuanzi,为我的网站提供稳定的计数服务。
技术栈选择
我简单分析了一下,发现busuanzi这个网页计数服务实现起来并不复杂,主要是通过获取访问请求的来源地址来实现PV和UV的计数增长,因此服务的核心就是一个统计接口。
基于这个分析,技术栈的选择也相对明确:
接口开发:由于我最熟悉Java,所以选择使用SpringBoot3和Java17来实现这个接口。(只实现一个接口的话,用SpringBoot感觉有些重,后续有时间可以考虑用Go重写一遍)
数据存储:数据库选择很多,但考虑到服务需要记录的数据主要是网站的PV和UV,对于请求IP等信息并非必须存储,所以我选择使用Redis。Redis对于这类频繁操作的数据处理速度非常快,而且配置好Redis的持久化后,也不怎么需要担心数据丢失问题。如果还是不太放心,可以在增加一个定时任务,定时从Redis中读取数据,存储到关系型数据库当中去,但是我感觉没有必要。
服务分析
前端脚本
为了避免修改Butterfly
主题中集成busuanzi的代码(以免在后续主题升级时进行频繁修改),需要让自建服务的接口访问方式和数据显示方式与原版busuanzi完全一致。因此,首先需要分析当前busuanzi的具体工作流程。
访问官网,可以看到官网提示的信息,只需要两行代码即可搞定计数。
1 2
| <script async src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script> <span id="busuanzi_container_site_pv">本站总访问量<span id="busuanzi_value_site_pv"></span>次</span>
|
从这两行代码可以看出,busuanzi通过引入busuanzi.pure.mini.js
脚本来修改busuanzi_container_site_pv
标签的内容,从而实现计数显示。数据获取流程的核心就在这个脚本中。由于该脚本经过了代码压缩,我使用claude code
对其进行了脚本还原,以提高代码可读性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
|
var bszCaller, bszTag;
(function() { var interval, domReadyCallback, executeCallbacks, isReady = false, readyCallbacks = [];
ready = function(callback) { if (isReady || document.readyState === "interactive" || document.readyState === "complete") { return callback.call(document); } else { readyCallbacks.push(function() { return callback.call(this); }); } return this; };
executeCallbacks = function() { for (var i = 0, length = readyCallbacks.length; i < length; i++) { readyCallbacks[i].apply(document); } readyCallbacks = []; };
domReadyCallback = function() { if (!isReady) { isReady = true; executeCallbacks.call(window); if (document.removeEventListener) { document.removeEventListener("DOMContentLoaded", domReadyCallback, false); } else if (document.attachEvent) { document.detachEvent("onreadystatechange", domReadyCallback); if (window == window.top) { clearInterval(interval); interval = null; } } } };
if (document.addEventListener) { document.addEventListener("DOMContentLoaded", domReadyCallback, false); } else if (document.attachEvent) { document.attachEvent("onreadystatechange", function() { if (/loaded|complete/.test(document.readyState)) { domReadyCallback(); } });
if (window == window.top) { interval = setInterval(function() { try { if (!isReady) { document.documentElement.doScroll("left"); } } catch (error) { return; } domReadyCallback(); }, 5); } } })();
bszCaller = {
fetch: function(url, callback) { var callbackName = "BusuanziCallback_" + Math.floor(Math.random() * 1099511627776); window[callbackName] = this.evalCall(callback); url = url.replace("=BusuanziCallback", "=" + callbackName); var scriptTag = document.createElement("SCRIPT"); scriptTag.type = "text/javascript"; scriptTag.defer = true; scriptTag.src = url; scriptTag.referrerPolicy = "no-referrer-when-downgrade"; document.getElementsByTagName("HEAD")[0].appendChild(scriptTag); },
evalCall: function(callback) { return function(data) { ready(function() { try { callback(data); if (scriptTag && scriptTag.parentElement) { scriptTag.parentElement.removeChild(scriptTag); } } catch (error) { bszTag.hides(); } }); }; } };
bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback", function(data) { bszTag.texts(data); bszTag.shows(); });
bszTag = { bszs: ["site_pv", "page_pv", "site_uv"],
texts: function(data) { this.bszs.map(function(type) { var element = document.getElementById("busuanzi_value_" + type); if (element) { element.innerHTML = data[type]; } }); },
hides: function() { this.bszs.map(function(type) { var container = document.getElementById("busuanzi_container_" + type); if (container) { container.style.display = "none"; } }); },
shows: function() { this.bszs.map(function(type) { var container = document.getElementById("busuanzi_container_" + type); if (container) { container.style.display = "inline"; } }); } };
|
虽然对JavaScript不是很熟悉,但这个脚本逻辑相对清晰,不难读懂。以下是该脚本的核心工作流程:
- 等待浏览器DOM准备
- DOM准备完成后,发起JSONP请求
- 解析请求返回的标签数据,如果请求错误,数据异常就隐藏所有统计容器
- 获取到计数的数据后,更新携带有ID为
busuanzi_value_
或busuanzi_container_
统计标签的数据,然后修改容器的display
属性,从而实现容器和内容的显示
JSONP其实就是GET请求,主要是利用<script>
标签可以跨域加载资源的特性,将数据请求伪装成脚本加载,服务器返回的也不是纯数据,而是一个函数调用,从而绕过浏览器的同源策略限制。
前端操作通过这个简单的脚本即可实现,接下来分析后端接口的实现方式。
后端接口
网页计数服务的核心功能是记录URL的UV和PV数据。这些指标又分为站点级别和页面级别,理论上应该有四个数据(但busuanzi不计算页面UV,因此只有三个),这里按四个指标数据计算。下面逐一分析这些数据的计算方法。
PV
PV,即浏览量,根据定义也不难知道计算的方法。当前端访问接口的时候,站点浏览量进行加1,页面浏览量进行加1既可以获得这两个PV数据量。
那么后端接口如何获知是哪个页面发起的请求呢?回头查看busuanzi脚本并未发现页面的相关信息,因此需要观察busuanzi脚本发送的具体HTTP请求信息。
1 2 3 4 5 6 7 8 9 10 11 12
| curl ^"https://busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback_12592539439^" ^ -H ^"accept: */*^" ^ -H ^"accept-language: zh-CN,zh;q=0.9^" ^ -H ^"referer: https://yww52.com/^" ^ -H ^"sec-ch-ua: ^\^"Not;A=Brand^\^";v=^\^"99^\^", ^\^"Google Chrome^\^";v=^\^"139^\^", ^\^"Chromium^\^";v=^\^"139^\^"^" ^ -H ^"sec-ch-ua-mobile: ?0^" ^ -H ^"sec-ch-ua-platform: ^\^"Windows^\^"^" ^ -H ^"sec-fetch-dest: script^" ^ -H ^"sec-fetch-mode: no-cors^" ^ -H ^"sec-fetch-site: cross-site^" ^ -H ^"sec-fetch-storage-access: active^" ^ -H ^"user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36^"
|
经过仔细观察,容易发现请求头中的referer
字段记录了当前页面的信息。切换到不同页面时,该字段也会相应变为相应页面的URL。基于这个信息,就可以计算出所需的两个PV数据:
- site_pv(站点浏览量):从
referer
请求头中提取站点Host地址,在Redis中存储一个键值对,并对该值进行递增操作 - page_pv(页面浏览量):直接使用
referer
请求头信息作为键,在Redis中存储键值对,并对该值进行递增操作
UV
UV(独立访客数)的统计重点在于两个关键概念:每日
和独立访客
。
对于每日统计,除了存储UV值外,还需要维护一个当日访客集合。当新请求到达时,通过查询该集合可以判断当前访客今日是否已经访问过。若已访问,则UV数据保持不变;若为首次访问,则UV数据递增。
独立访客的识别相对复杂,因为Hexo博客是纯静态页面,难以直接区分不同访客。前面的请求脚本中也未发现访客标识相关数据,于是我推测访客信息应该记录在HTTP请求中。为了对标busuanzi的实现机制,需要进一步分析具体请求。最好重新打开浏览器或清理缓存,以观察UV增长时的请求响应信息。
1 2 3 4 5
| content-length 109 content-type application/json date Sun, 10 Aug 2025 07:55:37 GMT server nginx/1.14.1 set-cookie busuanziId=8CBB7C5BBCF54C86B90DAD230C7B5275; Path=/; httponly; secure; SameSite=None; Domain=busuanzi.ibruce.info; Secure
|
从响应请求头可以看出,busuanzi采用了浏览器Cookie机制来识别独立访客。服务端在响应中设置Cookie,浏览器会在后续请求中自动携带该Cookie。通过检测请求中是否包含Cookie,服务端可以判断是否为首次访问。基于这个机制,可以得出UV数据的计算方法:
- site_uv(站点每日独立访客数):从
referer
请求头获取站点Host地址。若请求不包含Cookie,则生成新Cookie并添加至当日站点访客集合,同时返回Cookie并将UV数据递增。若请求已携带Cookie,则不进行任何增长操作 - page_uv(页面每日独立访客数):使用
referer
请求头信息作为页面标识。若请求不包含Cookie,则生成新Cookie并添加至当日页面访客集合,同时返回Cookie并将UV数据递增。若请求已携带Cookie,则不进行任何增长操作
服务实现
接口实现
这里我先展示所有使用到的Redis的键和类型,方便后续查找修改。
host
表示站点地址,url
表示资源地址,date
表示日期。
数据名称 | Redis键名 | Redis类型 |
---|
site_pv(站点浏览量) | pv:{host} | string |
page_pv(页面浏览量) | pv:page:{url} | string |
site_uv(站点每日独立访客数) | uv:{host} | string |
每日站点访客数(缓存7天) | uvtime:{date}:{host} | set |
page_uv(页面每日独立访客数) | uv:page:{url} | string |
每日页面访客数(缓存7天) | uvtime:page:{date}:{url} | set |
对于Redis数据结构的选择,个人认为使用string和set这两种数据结构完全足够了,大部分博客不是什么高流量的网站,string结构的数据很好用而且也很准确,没有多大必要使用hyperloglog或者zset,特别是数据量少的情况。
关于具体的实现,服务分析当中已经写的很明白了,这个服务其实就是一个核心的请求接口,逻辑和代码我就不一一贴出来了,我已经上传到Github`上面了,感兴趣可以自行研究,附带一下仓库地址。
https://github.com/jaslli/busuanzi
优化部分
服务代码除了适配原有的busuanzi的JSONP
请求外,还自行进行了一些优化。以下是我进行优化的方向:
- 因为后端是自己编写的服务,所以不需要前端使用JSONP的方式进行跨域处理,所以直接使用
fetch
的GET请求即可。 - 原版busuanzi是使用了cookie进行用户管理,我改为了使用浏览器
localStorage
进行存储管理用户信息,这样数据更加可以精准。
如果需要进行修改,那么前端的请求脚本也需要修改,我根据原有的脚本进行了简单的优化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
| (function () { 'use strict';
// 配置项 const CONFIG = { // 统计项名称列表 STATISTICS: ["site_pv", "page_pv", "site_uv", "page_uv"], // API地址,请根据实际情况修改 API_URL: "https://xxxx.com/busuanzi/api", // localStorage中存储身份标识的key LOCALSTORAGE_KEY: "bsz-id", // busuanzi的元素容器和元素值前缀 ITEM_VALUE_PREFIX: "busuanzi_value_", ITEM_CONTAINER_PREFIX: "busuanzi_container_", // 请求超时时间(毫秒) REQUEST_TIMEOUT: 5000, // 重试次数 RETRY_COUNT: 3 };
/** * DOM Ready 检测函数 * * @param {Function} callback - DOM准备完成后执行的回调函数 */ function domReady(callback) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback, { once: true }); } else { // DOM已经准备完成,立即执行 callback(); } }
/** * 创建带超时的fetch请求 * * @param {string} url 请求URL * @param {object} options fetch选项 * @param {number} timeout 超时时间 * @returns {Promise} fetch Promise */ function fetchWithTimeout(url, options, timeout) { return Promise.race([ fetch(url, options), new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), timeout) ) ]); }
/** * 保存用户身份标识 * @param {string} userId - 用户身份标识 */ function saveUserId(userId) { try { if (userId && userId !== "") { localStorage.setItem(CONFIG.LOCALSTORAGE_KEY, userId); } } catch (error) { console.warn('无法保存到localStorage:', error); } }
/** * 获取或生成用户身份标识 * @returns {string|null} 用户身份标识 */ function getUserId() { try { return localStorage.getItem(CONFIG.LOCALSTORAGE_KEY); } catch (error) { console.warn('无法访问localStorage:', error); return null; } }
/** * 隐藏所有统计容器(出错时调用) */ function hideStatistics() { CONFIG.STATISTICS.forEach(item => { try { const containerElement = document.getElementById(CONFIG.ITEM_CONTAINER_PREFIX + item); if (containerElement) { containerElement.style.display = "none"; } } catch (error) { console.warn(`隐藏统计项 ${item} 时出错:`, error); } }); }
/** * 发送统计请求 * * @param {number} retryCount - 剩余重试次数 */ function fetchStatistics(retryCount = CONFIG.RETRY_COUNT) { // 构建请求头 const headers = new Headers(); headers.append("X-Referer", window.location.href);
const userId = getUserId(); if (userId) { headers.append("Authorization", userId); }
// 发送超时请求 fetchWithTimeout(CONFIG.API_URL, { method: "GET", headers: headers }, CONFIG.REQUEST_TIMEOUT) // 请求成功处理 .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
// 保存响应头中的身份标识 const bszId = response.headers.get("Authorization"); if (bszId) { saveUserId(bszId); }
return response.json(); }) // 成功数据处理 .then(data => { // 遍历每项统计数据,然后进行更改 CONFIG.STATISTICS.forEach(item => { try { // 更新统计数值 const valueElement = document.getElementById(CONFIG.ITEM_VALUE_PREFIX + item); if (valueElement) { valueElement.innerHTML = data[item] || 0; }
// 显示统计容器 const containerElement = document.getElementById(CONFIG.ITEM_CONTAINER_PREFIX + item); if (containerElement) { containerElement.style.display = "inline"; } } catch (error) { console.warn(`更新统计项 ${item} 时出错:`, error); } }); }) .catch(error => { console.error('busuanzi统计请求失败:', error);
// 重试机制 if (retryCount > 0) { console.log(`正在重试... 剩余重试次数: ${retryCount}`); setTimeout(() => { fetchStatistics(retryCount - 1); }, 1000); // 1秒后重试 } else { console.error('所有重试都失败,隐藏统计显示'); hideStatistics(); } }); }
// 等待DOM准备完成后初始化 domReady(fetchStatistics);
})();
|
服务部署
这次涉及的服务有两个,一个是SpringBoot
的Java服务,一个是Redis
服务,如果是要部署的话,就需要分开部署。
这里我使用了docker-compose
来进行部署,也可以更加方便,以下是三个所需的配置文件。
服务的Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| FROM eclipse-temurin:17-jre-alpine LABEL maintainer="yww@yww52.com"
ENV TZ=Asia/Shanghai RUN apk add tzdata \ && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ && echo $TZ > /etc/timezone \ && apk del tzdata \ && rm -rf /var/cache/apk/*
ENV JAVA_OPTS="-Xms512m -Xmx2048m -Xmn614m -Xss256k -XX:SurvivorRatio=6 -XX:+UseG1GC -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m" ENV APP_HOME=/app
RUN adduser -D appuser && \ mkdir -p $APP_HOME && \ chown -R appuser:appuser $APP_HOME USER appuser
WORKDIR $APP_HOME
COPY busuanzi-1.0.jar $APP_HOME/app.jar
EXPOSE 10010
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
redis的配置文件,这里的路径是容器内的路径,尽量不要修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| dir /data/redis bind 0.0.0.0 port 6379
# 日志文件存放 logfile "/data/log/redis.log"
# 启用AOF持久化 appendonly yes appendfilename "appendonly.aof" appendfsync everysec
# RDB持久化配置(可选) save 900 1 save 300 10 save 60 10000
# 日志级别 loglevel notice
|
服务编排文件docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| version: '3.8'
services: busuanzi-redis: image: redis:7-alpine container_name: busuanzi-redis ports: - "16379:6379" volumes: - /data/docker/busuanzi/data:/data/redis - /data/docker/busuanzi/log:/data/log - /data/docker/busuanzi/redis.conf:/usr/local/etc/redis/redis.conf command: redis-server /usr/local/etc/redis/redis.conf --appendonly yes restart: unless-stopped networks: - busuanzi-network
busuanzi: build: context: . dockerfile: Dockerfile container_name: busuanzi ports: - "10010:10010" depends_on: - busuanzi-redis restart: unless-stopped networks: - busuanzi-network
networks: busuanzi-network: driver: bridge
|
将上述三个配置文件和springboot打包生成的jar包放在一个文件夹中,然后执行docker-compose up -d
即可运行整个编排服务。
服务部署好后,自行使用nginx进行反向代理,推荐使用1panel,里面的网站部署使用了OpenResty
,直接创建一个反向代理即可。如果安装了1panel
,docker相关环境基本全部自动安装好,无需自己手动在安装环境,可以一步到位。
busuanzi数据迁移
不太想放弃原有的busuanzi数据,可以直接将原有的busuanzi数据迁移到服务部署的redis当中,我讲一下比较简单的一个流程。
- 获取当前站点的sitemap
- 然后根据sitemap获取到站点所有的页面URL
- 然后根据每个页面的URL去请求原有的busuanzi接口
- 将得到的数据设置到服务的Redis当中即可