序言
不蒜子(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的具体工作流程。
访问官网,可以看到官网提示的信息,只需要两行代码即可搞定计数。
<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对其进行了脚本还原,以提高代码可读性。
/**
* Busuanzi 不蒜子统计脚本
* 用于网站PV/UV统计显示
*/
var bszCaller, bszTag;
// DOM Ready 功能实现
(function() {
var interval,
domReadyCallback,
executeCallbacks,
isReady = false,
readyCallbacks = [];
// 添加ready回调函数
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 = [];
};
// DOM准备完成后的处理
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;
}
}
}
};
// 绑定DOM Ready事件
if (document.addEventListener) {
// 现代浏览器
document.addEventListener("DOMContentLoaded", domReadyCallback, false);
} else if (document.attachEvent) {
// IE浏览器兼容
document.attachEvent("onreadystatechange", function() {
if (/loaded|complete/.test(document.readyState)) {
domReadyCallback();
}
});
// IE浏览器特殊处理
if (window == window.top) {
interval = setInterval(function() {
try {
if (!isReady) {
document.documentElement.doScroll("left");
}
} catch (error) {
return;
}
domReadyCallback();
}, 5);
}
}
})();
// JSONP 调用器对象
bszCaller = {
/**
* 发起JSONP请求获取统计数据
* @param {string} url - 请求URL
* @param {function} callback - 回调函数
*/
fetch: function(url, callback) {
// 生成随机回调函数名
var callbackName = "BusuanziCallback_" + Math.floor(Math.random() * 1099511627776);
// 在全局作用域中注册回调函数
window[callbackName] = this.evalCall(callback);
// 替换URL中的回调函数名
url = url.replace("=BusuanziCallback", "=" + callbackName);
// 创建script标签进行JSONP请求
var scriptTag = document.createElement("SCRIPT");
scriptTag.type = "text/javascript";
scriptTag.defer = true;
scriptTag.src = url;
scriptTag.referrerPolicy = "no-referrer-when-downgrade";
// 将script标签添加到head中
document.getElementsByTagName("HEAD")[0].appendChild(scriptTag);
},
/**
* 包装回调函数,添加错误处理
* @param {function} callback - 原始回调函数
* @returns {function} 包装后的回调函数
*/
evalCall: function(callback) {
return function(data) {
ready(function() {
try {
// 执行回调并清理script标签
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"],
/**
* 更新页面中的统计数字
* @param {object} data - 包含统计数据的对象
*/
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请求信息。
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增长时的请求响应信息。
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/whyneh/busuanzi
优化部分
服务代码除了适配原有的busuanzi的JSONP请求外,还自行进行了一些优化。以下是我进行优化的方向:
- 因为后端是自己编写的服务,所以不需要前端使用JSONP的方式进行跨域处理,所以直接使用
fetch的GET请求即可。 - 原版busuanzi是使用了cookie进行用户管理,我改为了使用浏览器
localStorage进行存储管理用户信息,这样数据更加可以精准。
如果需要进行修改,那么前端的请求脚本也需要修改,我根据原有的脚本进行了简单的优化。
(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
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 # 创建非root用户并设置权限 RUN adduser -D appuser && \ mkdir -p $APP_HOME && \ chown -R appuser:appuser $APP_HOME USER appuser # 设置工作目录和应用目录 WORKDIR $APP_HOME # 复制已打包的 JAR 文件 COPY busuanzi-1.0.jar $APP_HOME/app.jar # 暴露应用端口 EXPOSE 10010 # 启动命令(使用环境变量配置 JVM 参数) ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] -
redis的配置文件,这里的路径是容器内的路径,尽量不要修改
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.ymlversion: '3.8' services: # 这个名字修改了,下面依赖服务名也要修改,springboot配置文件Redis连接服务名也要修改 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 # Redis配置文件映射到宿主机 - /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当中即可