我的技术学习物语果然有问题
(最后更新 )

自建busuanzi网站计数服务

自建busuanzi网站计数服务

序言

不蒜子(busuanzi) 是一个广受欢迎的免费网页计数服务,能够轻松实现网站的页面浏览量(PV)和独立访客数(UV)统计。许多基于Hexo的网站都在使用busuanzi来实现访问统计功能,我的博客当前使用的主题是Butterfly,这个主题也是通过集成busuanzi计数器来实现网页计数。

然而,随着使用人数的不断增多,这个由作者为爱发电的免费服务开始出现稳定性问题,访问速度也变得起伏不定。

因此,我决定自己开发一个服务来代替busuanzi,为我的网站提供稳定的计数服务。

技术栈选择

我简单分析了一下,发现busuanzi这个网页计数服务实现起来并不复杂,主要是通过获取访问请求的来源地址来实现PV和UV的计数增长,因此服务的核心就是一个统计接口。

基于这个分析,技术栈的选择也相对明确:

  1. 接口开发:由于我最熟悉Java,所以选择使用SpringBoot3和Java17来实现这个接口。(只实现一个接口的话,用SpringBoot感觉有些重,后续有时间可以考虑用Go重写一遍)

  2. 数据存储:数据库选择很多,但考虑到服务需要记录的数据主要是网站的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不是很熟悉,但这个脚本逻辑相对清晰,不难读懂。以下是该脚本的核心工作流程:

  1. 等待浏览器DOM准备
  2. DOM准备完成后,发起JSONP请求
  3. 解析请求返回的标签数据,如果请求错误,数据异常就隐藏所有统计容器
  4. 获取到计数的数据后,更新携带有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数据:

  1. site_pv(站点浏览量):从referer请求头中提取站点Host地址,在Redis中存储一个键值对,并对该值进行递增操作
  2. 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数据的计算方法:

  1. site_uv(站点每日独立访客数):从referer请求头获取站点Host地址。若请求不包含Cookie,则生成新Cookie并添加至当日站点访客集合,同时返回Cookie并将UV数据递增。若请求已携带Cookie,则不进行任何增长操作
  2. 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请求外,还自行进行了一些优化。以下是我进行优化的方向:

  1. 因为后端是自己编写的服务,所以不需要前端使用JSONP的方式进行跨域处理,所以直接使用fetch的GET请求即可。
  2. 原版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来进行部署,也可以更加方便,以下是三个所需的配置文件。

  1. 服务的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"]
  2. 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
  1. 服务编排文件docker-compose.yml

    version: '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当中,我讲一下比较简单的一个流程。

  1. 获取当前站点的sitemap
  2. 然后根据sitemap获取到站点所有的页面URL
  3. 然后根据每个页面的URL去请求原有的busuanzi接口
  4. 将得到的数据设置到服务的Redis当中即可