网页计数, 给静态博客加个赞

近几年静态博客越来越流行,除了因为出现了很多生成工具以外,最关键的因素是因为免费。对于国内的博主来说,更关键的是可以免备案,甚至可以不注册域名。然而,静态博客有个致命伤就是无法进行网页计数、用户评论以及邮件订阅等功能。这对于传统的 WordPress 就完全不是问题。所以,出现了很多第三方的插件服务可以快速集成到静态博客中。但是这些第三方插件服务问题也不少,以网页计数的功能来说,大部分免费的网页计数服务都只是提供简单的网页点击计数,即使同一个用户刷新一下页面,计数也同样自增。很明显,这种计数功能只能提供某种虚假的繁荣而已。Google Analytics 虽然支持会话计数、用户计数,但仅仅只用于统计,不提供页面展示,如果通过其提供的 API 再开发,工作量可能比直接开发一个计数服务的工作量还大。

对于程序员来说,计数问题应该是最简单的编程问题。既然手上有云主机可以用,不妨自己实现一个。可虑到个人博客的实际流量,同时尽可能的节省各类资源与成本,数据库采用单机版的SQLite内嵌数据库。

1. 功能需求

既然要做项目那就按项目的流程来做。首先,确认一下功能需求。

  • 网页点击计数,虽然作为阅读数的展示有很大水分,不过具有一定参考价值
  • 网页会话计数,用户每次会话计数,作为文章阅读数比较合适
  • 网页用户计数,统计网页实际访问用户数
  • 文章点赞计数,同一用户只能点赞一次,或取消点赞
  • 计数初始值,可以设置自定义计数初始值
  • 证书自动化,自动化完成证书签发
  • 网页五星评价

看了一下一些 WordPress 站点还有五星评价功能,不妨也加个接口支持一下。

2. 计数原理

以上需求的计数原理分别是:

  • 网页点击计数,单页面加载触发计数,计数关键信息, 网页地址:URL
  • 网页会话计数,单页面新会话访问触发计数,计数关键信息, 网页地址与会话标记:URLSESSION
  • 网页用户计数,单页面新用户访问触发计数,计数关键信息, 网页地址与用户标记:URLUSER
  • 文章点赞计数,用户触发点赞键计数,计数关键信息, 网页地址与用户标记:URLUSER
  • 网页五星评价,用户触发评价计数, 计数关键信息, 网页地址与用户标记以及评价值:URLUSERRate

3. 接口定义

这些年做前后端接口定义,都是通过 ProtoBuf 协议进行定义,再将 ProtoBuf 定义的接口转化成 HTTP API 接口,将 ProtoBuf 消息转化成 Json 消息。程序员应该都不陌生,简单贴一下定义的接口:

syntax = "proto3";

import "options/options.proto";

package hits;

service Counter {
    option (options.service) = {
        version: "v1"
    };

    //Hit       触发点击计数、会话计数、用户计数
    rpc Hit(HitReq) returns (HitResp);
    //HitGet    只读点击计数、会话计数、用户计数
    rpc HitGet(HitGetReq) returns (HitGetResp);

    //ThumbUp   触发点赞计数
    rpc ThumbUp(ThumbUpReq) returns (ThumbUpResp);
    //ThumbGet  只读点赞计数
    rpc ThumbGet(ThumbGetReq) returns (ThumbGetResp);

    //Rate      五星评价计数
    rpc Rate(RateReq) returns (RateResp);
    //RateGet   只读五星评价
    rpc RateGet(RateGetReq) returns (RateGetResp);
}

message HitReq {
    string session_storage = 1; //会话存储 代表 Session 标记
    string persist_storage = 2; //持久存储 代表 User 标记
    string url = 3;
    int64 init_hits = 4;
    int64 init_ssns = 5;
    int64 init_uids = 6;
}

message HitResp {
    int64 hits = 1;
    int64 ssns = 2;
    int64 uids = 3;
    string session_storage = 4;
    string persist_storage = 5;
}

...

鉴于篇幅关系,只贴一个消息定义用于说明,完整定义,请参考项目仓库. 转化成为 HTTP API 接口后,就是这样:

  • HTTP 请求
uri: /v1/hits.Counter/Hit 
method: POST
Origin: "YourOrigin"
Referer: "YourURL"
Content-Type: application/json; charset=utf-8
data:
    {
        url: "",
        sessionStorage: "",
        persistStorage: "",
        initHits: 0,
        initSsns: 0,
        initUids: 0,
    }

在请求头的部分增加了 OriginReferer 头,是因为这两个头的信息对于网页计数来说比较关键,Origin 会用在跨域请求的验证阶段,Referer 则可以简化请求体中的 URL。如果 URL 字段没有填写的话就可以使用 Referer 字段代替。当然前提是 Referer Police 的策略设置正确。相关 Referer Police 的问题,放在6. 常见问题中说明。

  • HTTP 响应
http status: 200
http response data:
{
    "hits":"9",
    "ssns":"13",
    "uids":"1",
    "sessionStorage":"MTU5NDQzMDk5NnxoY1RhSFZBZ1NSMWFhbkpFUUZPcUV3VUtKOFBJWVJHdVVwcXBHWFY1d0UzZmpaU0Q3Njg2TmVLTmw0c2lZaE5JZWV3WXlJdFZhUFdEbzZUR2xKTFpxNUQ4TjR1SDVNNkhtSGVKWlY1MTBOS3ZlZHJCQjZSX1M2cklSbm4yNGVHZ09TRT18pOwHfdrgcI6zbhsMgKP7StfhOHVBfSa9yNY98cboZP4=","persistStorage":"MTU5NDM3NzEzMXxna3JOU1BhTk5FWVZfQm1CU01RaDNLb0pIdGhfQnZyV0F6THdVbHdrM0FjUGFrSlRoakVWeE1DdUF6T0dxM2QxY1VZZ01GSTl1dGhVa0tpVW51OXhRR3g0dU1ZODFDUVdLUnpueXdkU25hOENkX0k1TzJ4Mm9TNVNnVzZqR1hKOW43QT18yvUZPmys-LSUEt1H-UD6Weox0dkpDBSZ7clYDw0Rt0A="
}

为了防止 sessionpersist被篡改,字段值需要进行加密处理。

4. 后端镜像

实现这样一组接口,对于后端程序员而言是非常简单的。这里我使用了两个代码生成工具,分别利用 ProtoBuf 定义文件生成 GRPC 转 HTTP 的框架代码,以及 YAML 定义数据库生成 ORM 代码。这两个工具,我会陆续写相关博客并开源出来,并在网站与微信公众号上发布。欢迎感兴趣的同学持续关注与订阅。

生成完框架代码后,直接实现业务逻辑即可。这里就不详细说明了。目前整个后端服务直接以容器镜像的方式发布出来,供需要的同学使用。因为使用了内嵌数据库 SQLite 所以使用起来非常方便。

首先,介绍一下镜像的用法:

$: docker run liujianping/hits:latest -h
Usage:
  main [command]

Available Commands:
  help        Help about any command
  server      server command

Flags:
  -a, --address string    http server listen address (default ":8080")
  -e, --email string      autocert register email address
  -h, --help              help for main
  -H, --hostname string   product instance (FQDN) hostname
  -o, --origins strings   origins allowed
      --prod              running on production mode
  -v, --verbose int       glog verbose
      --version           version for main
  -w, --work-dir string   service working directory (default ".")

Use "main [command] --help" for more information about a command.

服务默认情况下运行在开发模式下,切换到生产模式,增加--prod参数, 生产模式下,服务默认启动 80/443 两标准端口进行监听。标准端口必须对外开放,否则无法处理证书自动化签发。

参数说明:

  • -w 参数, 设置服务工作路径,服务启动后,会在该目录下创建 hits.db 文件,存放 SQLite 数据。在生产模式下,还会创建 certs 子目录,用于 https 的证书自动化。
  • -H 参数, 用于生产模式,设置主机访问域名,同时会自动化处理该域名主机的证书签发。
  • -o 参数, 跨域请求支所支持的来源域名,如: -o=gitdig.com,www.gitdig.com
  • -v 参数, 服务运行日志级别
  • 开发模式
$: docker run -d liujianping/hits:latest server http -v 5

访问路径: http://localhost:8080

  • 生产模式
$: docker run -d  --network host -v ./hits:/app/hits liujianping/hits:latest server http  --prod -H api.gitdig.com -o www.gitdig.com -w /app/hits

生产模式下必须设置-o 参数,否则服务无法处理跨域请求。

5. 前端实现

目前计数功能与点赞功能的实现,我都是直接写在网站代码里。下一篇打算拆出来做成一个第三方的插件,方便需要的同学集成。因为我的博客是通过 GatsbyJS 框架搭建,所以可以通过 React 的组件进行开发,贴一下简单的计数器组件的实现:

import React, { Component } from "react";
import axios from "axios";

export class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            got: false,
            data: {
                hits: 0,
                ssns: 0,
                uids: 0,
                sessionStorage: "",
                persistStorage: "",
            },
        }
    }
    componentDidMount() {
        axios.defaults.baseURL = "http://localhost:8080";
        axios.defaults.withCredentials = true
        const { readonly, url, initHits, initSsns, initUids } = this.props;
        if (readonly) {
            let persist = localStorage.getItem("_hits_p")
            axios.post("/v1/hits.Counter/Get", {
                url: url,
                initHits: initHits === null ? 0 : initHits,
                initSsns: initSsns === null ? 0 : initSsns,
                initUids: initUids === null ? 0 : initUids,
            }).then(
                (response) => {
                    this.setState({ got: true, data: response.data })
                }
            )
        } else {
            let session = sessionStorage.getItem("_hits_s")
            let persist = localStorage.getItem("_hits_p")
            axios.post("/v1/hits.Counter/Hit", {
                persistStorage: persist === "undefined" ? "": persist,
                sessionStorage: session === "undefined" ? "": session,
                url: url,
                initHits: initHits === null ? 0 : initHits,
                initSsns: initSsns === null ? 0 : initSsns,
                initUids: initUids === null ? 0 : initUids,
            }).then(
                (response) => {
                    this.setState({ got: true, data: response.data })
                    sessionStorage.setItem("_hits_s", response.data.sessionStorage)
                    localStorage.setItem("_hits_p", response.data.persistStorage)
                }
            )
        }
        return this.state
    }

    render() {
        const { hits, ssns, uids } = this.state.data;
        const { hit, session, user, className} = this.props;
        if (!this.state.got) {
            return <></>
        }
        return (
            <>
                {
                    hit &&
                    <span className={className}>{hits}</span>                    
                }
                {
                    session &&
                    <span className={className}>{ssns}</span>
                }
                {
                    user &&
                    <span className={className}>{uids}</span>
                }                
            </>
        )
    }
}

在前端实现上,主要通过 sessionStorage 与 localStorage 来存储服务端返回的标记信息。

6. 常见问题

问:Referer 头在请求头中没有出现?

referer 头没有出现的原因,是当前网页设置的 referrer-policy 决定的。为保证referer可以传递网页地址,请在网页元数据中添加以下代码:

<meta name="referrer" content="no-referrer-when-downgrade" />   

问:计数数据如何导出?

服务数据存储采用的是SQLite,存储路径在服务的工作目录下的hits.db文件。你可以直接取得该文件,利用 SQLite 官方提供的命令行工具进行访问导出。

$: sqlite3 hits.db .dump > hits.sql

问:如何验证服务正常?

在生产模式下,服务提供一个状态检查的访问地址 https//your.domain/ping, 在浏览器中访问改地址,正常情况下会返回pong的响应,说明一切正常。

问:为什么生产模式下容器的网络模式是主机(host)共享模式

计数服务会统计来源机器的真实IP地址,在非主机(host)共享的网络模式下,服务拿到的 IP 地址是容器的虚拟地址,所以必须将容器的网络模式设置为主机(host)共享模式。

问:没有云主机怎么办?

没有云主机的话,目前暂时无法使用该服务。你可以在该页面下留言,或是发邮件联系我,我可以将你的域名加到我提供的服务中。如果很多人需要的话,我会考虑将该服务做成一个免费的服务,不过需要朋友们的赞助支持。

7. 项目仓库

项目地址: gitdigg/hits.

阅读