目录

用AWS做一个免费的访客计数

介绍如何在在自己的静态网站中从头制作一个访客计数器,最后插入到Hugo模板中,使每个博客页面都获得计数功能。

结构设计

/images/code/vc005.png
  1. 用户访问静态站点(Gitpage)
  2. 静态站点服务返回预渲染页面和JS脚本
  3. 计数统计的脚本向AWS Lambda发送计数请求
  4. Lambda收到请求后,发起数据库请求
  5. 数据库返回结果到Lambda
  6. Lambda把结果返回给用户。最后页面JS收到计数结果,渲染到网页上。

在这个流程中,DynamoDB提供了数据存储的功能,负责存储每个页面的地址和访问量。 但是数据库的访问需要用户验证以及特定的接口。因此为了处理匿名用户的HTTP请求,添加了一个Lambda函数做中转。

Tip
这里用到的Lambda服务和DynamoDB服务,在个人小用量的情况下都是能免费使用的。 具体额度可以在AWS官网上查到 aws计算&数据库免费额度

数据库

在AWS控制台找到DynamoDB页面,简单创建一个新表,起个名字,表的主键设置为字符串型数据page,排序键不需要,其他都按默认设置即可。

/images/code/vc001.png
创建表

我们希望每个页面作为表的一行数据,url作为主键,保存访问量以及最后访问时间。
手动添加一行样例数据,这个网页版添加数据的操作很不友好,所以截图说明一下应该写成这个样子。 添加完成后可以在 -> 浏览项目 里确认自己的数据已经写入数据表

/images/code/vc002.png
添加样例行

Lambda

在AWS控制台找到Lambda页面,创建一个新函数,使用Python语言,架构选arm64更便宜,执行角色暂且选基本权限。在高级设置里选启用URL,不需要授权,开启CORS。具体如图

/images/code/vc006.png
Lambda设置1
/images/code/vc007.png
Lambda设置2

修改Lambda权限

在函数页面,找到配置->权限,点执行角色的名字,转到IAM页面。

在IAM里把这个执行角色的策略改成AWSLambdaDynamoDBExecutionRole, 然后再添加一条自定义策略,增加查询、更新数据的权限。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:UpdateItem"
            ],
            "Resource": "*"
        }
    ]
}

修改CROS限制

在函数页面,找到配置->函数 URL

这里暂时先把CORS允许源设置为*,在测试完成后,再改成自己的域名。

/images/code/vc008.png
CROS设置

代码

把写好的lambda函数代码贴进去保存,然后部署。

get_args函数:

  • 输入预处理,可对应HTTP请求和一般socket

lambda_handler 函数:

  • 接受一个字典数据{'page': xxx, 'action': [get|update]}
  • action == get时,返回page页面的访问量和最后一个时间戳
  • action == update时,page页面访问量+1,更新时间戳,然后返回page页面的新的访问量和上一个时间戳
 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
import json, boto3, time

client = boto3.client('dynamodb')
TableName = 'visitor_counter'

def get_args(event):
    if 'body' in event:  # for HTTP request
        args = json.loads(event['body'])
    else:  # for test purpose
        args = event
    return args

def lambda_handler(event, context):
    EMPTY_RESP = {
        'last_visit': {'N': 0},
        'visit': {'N': 0},
    }
    print('RECIVE', dict(event))
    args = get_args(event)

    key = {'page': {'S': args.get('page')}}
    action = args.get('action')
    data = {}

    if action is None:
        return {'error': 'Missing key: action'}

    if action == 'get':
        resp = client.get_item(
            TableName=TableName,
            Key=key
        )
        
        d = resp.get('Item', EMPTY_RESP)
        data = {
            'last': d['last_visit']['N'],
            'visit': d['visit']['N'],
        }

    if action == 'update':
        now = int(time.time())
        resp = client.update_item(
            TableName=TableName,
            Key=key,
            UpdateExpression = 'SET last_visit = :time ADD visit :inc',
            ExpressionAttributeValues = {':inc' : {'N': '1'}, ':time': {'N': str(now)}},
            ReturnValues="UPDATED_OLD"
        )

        d = resp.get('Attributes', EMPTY_RESP)
        data = {
            'last': d['last_visit']['N'],
            'visit': str(int(d['visit']['N']) + 1),
        }

    print('SEND', data)
    return data

测试

用浏览器控制台简单测试一下aws-lambda是否正常工作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var xmlhttp = new XMLHttpRequest();
var url = "https://xxxxxxxxxxx.lambda-url.ap-northeast-3.on.aws";
var data = {page: "www.example.com", action: "get"};

xmlhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
        console.log(this.responseText);
    }
};

xmlhttp.open("POST", url);
xmlhttp.send(JSON.stringify(data));

Hugo模板中插入代码片段

创建partial模板

新建一个partial模板,路径为/layouts/partials/my_vc.html
简单写两行,内容都交给脚本

1
2
<div id="visitCount"></div>
<script src="/js/visit_counter.js"></script>

插入posts模板

找到需要插入访问计数的模板文件,我这里是使用了Loveit模板,路径为themes/LoveIt/layouts/posts/single.html, 把这个文件复制到layouts/posts/single.html,然后在模板文件里找到合适的地方插入。 这里我选择在<div class="post-meta-line">这个节点里面,插入代码片段{{- partial "my_vc.html" . -}}

JS代码

根据partial模板中写的脚本对应的目录就是/static/js/visit_counter.js

Tip
vcSite改为你的域名
vcServer改为你的函数URL

 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
var CurrentPage=window.location.origin + window.location.pathname;
var vcSite="https://darkgoldbar.github.io";
var vcServer="https://xxxxxxxx.lambda-url.ap-northeast-3.on.aws/";
var vcResponse=null;

window.addEventListener('load', vcOnLoad);

function vcOnLoad() {
    if (window.location.origin == vcSite){
        if (vcCheck()) {
            vcRequest('get');
        } else {
            vcRequest('update');
        }
    }
}

function vcRequest(action) {
    let data = {page: CurrentPage, action: action};
    let xmlhttp = new XMLHttpRequest();
    let resp_data = null;

    xmlhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            resp_data = JSON.parse(this.responseText);
            vcRender(resp_data);
        } else if (this.readyState == 4 && this.status != 200) {
            console.log('visit counter API failed');
            vcResponse = this;
        }
    };

    xmlhttp.open("POST", vcServer);
    xmlhttp.send(JSON.stringify(data));
}

function vcCheck(){
    let visited = false;
    let cookie_key = 'last_visit:' + CurrentPage;
    let currentTimeStamp = new Date().getTime();
    let lastTimeStamp = new Number(localStorage.getItem(cookie_key));
    if (lastTimeStamp && ((currentTimeStamp - lastTimeStamp) < 24 * 60 * 60 * 1000)) {
        visited = true;
    }
    localStorage.setItem(cookie_key, currentTimeStamp);
    return visited
}

function vcRender(data) {
    // data = {last:"1675853136", visit:"6"}
    let vcnode = document.getElementById('visitCount');
    let d = new Date();
    d.setTime(Number(data.last + "000"));
    vcnode.innerHTML = '浏览次数: <span>' +data.visit+ '</span> 最后访问: <span>' +d.toISOString()+ '</span>'
}