一、日志拆分
nginx 生成的日志 access.log
默认不会拆分,会越积累越多,导致大文件不易操作。而进行离线日志分析以及计算统计结果时,一般是计算昨天的日志。试想在这种场景下,是从 access.log
一个大文件读取方便?还是拆分的零散的文件方便?
1.1 如何拆分日志
视流量情况(流量越大日志文件积累的越快),可以按天、小时、分钟来拆分。例如,将 access.log
按天拆分到某个文件夹中,如下所示:
logs_by_day/access.2021-03-09.log
logs_by_day/access.2021-03-10.log
logs_by_day/access.2021-03-11.log
拆分日志的技术实现思路:
- nginx 依然是持续的写入
access.log
; - 启动定时任务,到凌晨 00:00 ,即将当前的
access.log
复制一份,并命名为access.2021-03-11.log
(昨天的日期); - 将当前的
access.log
内容清空。这样 nginx 持续写入access.log
,即新一天的日志;
1.2 定时任务
/**
通用的定时表达式规则:
* * * * * *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ │
│ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
│ │ │ │ └───── month (1 - 12)
│ │ │ └────────── day of month (1 - 31)
│ │ └─────────────── hour (0 - 23)
│ └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, OPTIONAL) **【注意】linux crontab 不支持秒**
*/
1.2.1 linux crontab
在 ~
目录进行如下实验:
- 创建一个空文件 a.txt;
- 执行
crontab -e
编辑; - 加入一行代码
* * * * * echo $(date) >> /Users/bgl/a.txt
,该代码会每分钟执行一次(注意文件目录),并保存退出; - 执行
crontab -l
可查看当前任务; - 观察文件
cat a.txt
,会发现日期每隔一分钟被写入 a.txt 文件中。
测试完成,记得删除 crontab 配置和 a.txt 文件(注:运行crontab -e
命令,把之前添加的第一行代码删除即可)。
注:linux crontab 不支持秒。
1.2.2 cron
库
二、日志分析
通过分析日志,得到统计结果,并写入数据库。
在技术方案设计之前,要考虑以下事项:
- 注意 url 参数设计,和存储数据结构的设计
- 一定要使用 **,日志文件可能很大,一次性打开可能占据很大内存
- 考虑通用性和扩展性(我们要做的是一个自定义事件统计服务,分渠道统计仅仅是自定义事件统计服务的一个场景,所以自定义事件统计服务需考虑通用性和扩展性用以满足很多场景)
2.1 技术方案设计
要做一个通用的自定义事件统计服务,而不仅仅是为了作品分渠道统计。
一个详细的技术方案,需包含以下技术点:
- 需求是什么
- 需要收集哪些信息
- 需要输出什么信息
- 如何做到
2.2 需求是什么
需要统计作品在各个渠道的pv。例如,一个 h5 页,一段时间之内的统计结果如下:
- 总 pv 100
- 渠道 A pv 30
- 渠道 B pv 20
- 渠道 C pv 50
2.3 收集统计数据
收集哪些数据
以一个作品 http://182.92.168.192:8082/p/85-8d14?channel=41
为例,需要收集
- 作品 id =>
85
- 作品渠道 id =>
41
根据 nginx 收集日志的服务,通过 http://localhost:8083/event.png?xxxx
可以上报统计数据。那么上报数据的格式应该是http://localhost:8083/event.png?workId=85&channelId=41
—— 但实际情况是不能这样做,因为这样做不具备通用使用场景,变成了定制化开发。
如何收集
要做一个通用的自定义事件统计服务,而不仅仅是为了作品分渠道统计。所以,不能按照分渠道统计的需求来设计参数。
按照通用的参数设计,可以定义四个参数(很多企业内部的事件统计服务,都是这么定义的):
- category
- action
- label
- value
参照上述通用参数设计,如果要实现分渠道统计,则URL设计如下:http://localhost:8083/event.png?category=h5&action=pv&label=85&value=41
,其中 85 是作品 ID,41 是渠道号。
如果再有新需求,有两个按钮“参与”和“不参与”,想统计一下用户点击了哪个按钮,则:
- 点击“参与”则发送
?category=h5&action=someButton&label=85&value=1
- 点击“不参与”则发送
?category=h5&action=someButton&label=85&value=0
通过这种方式很方便得实现了扩展。
2.4 计算结果
计算方式采用按天计算。
如何计算
采用离线计算方式。每天凌晨定时分析昨天的日志,计算出昨天的统计结果。
离线计算,要晚于拆分日志(凌晨 0:00),拆分完再计算。
- 根据日志文件名,得到昨天的日志(一个文件,或者多个文件);
- 逐行读取日志文件,累加统计结果(操作大文件,一定要用
stream
,否则可能导致内存爆满。readline
是 stream 的一个具体场景,即逐行读取); - 读取完毕,输出统计结果
数据结构
统计结果的格式如下。举例:eventKey
为 h5.pv.85.41
,可理解为H5作品ID为85在渠道41上的pv是30。通过这种方式,就可以计算出在各层级各粒度下的pv数据。
{
eventDate: '2021-03-21', // 统计结果的日期
eventKey: 'h5',
eventData: { pv: 10000 } // category=h5 的数据汇总
},
{
eventDate: '2021-03-21',
eventKey: 'h5.pv',
eventData: { pv: 8000 } // category=h5&action=pv 的数据汇总
},
{
eventDate: '2021-03-21',
eventKey: 'h5.pv.85',
eventData: { pv: 100 } // category=h5&action=pv&label=85 的数据汇总
},
{
eventDate: '2021-03-21',
eventKey: 'h5.pv.85.41',
eventData: { pv: 30 } // category=h5&action=pv&label=85&value=41 的数据汇总
},
{
eventDate: '2021-03-21',
eventKey: 'h5.pv.85.42',
eventData: { pv: 20 } // category=h5&action=pv&label=85&value=42 的数据汇总
},
{
eventDate: '2021-03-21',
eventKey: 'h5.pv.85.43',
eventData: { pv: 50 } // category=h5&action=pv&label=85&value=43 的数据汇总
}
获取结果
输入:
- 开始日期
- 结束日期
- 各个参数 category action label value 等
输出
- 日期范围之内的,所有统计数据
三、数据库存储
本次方案采用 mongodb 数据库,按照数据结构设计如下 schema:
mongoose.Schema(
{
eventKey: String,
eventData: {
pv: Number,
// uv: Number, // 后续可扩展 uv
},
eventDate: Date,
},
{ timestamps: true }
)
四、OpenAPI
OpenAPI 不是一个新词。相比 API 而言,OpenAPI 是提供了通用的第三方 API 服务(支持多方接入),而API只是针对特定场景。
但是 OpenAPI 也不是谁都可以接入,需要进行授权以及申请后才可以使用。针对这一特性可通过跨域限制来实现。
编写白名单中间件,示例代码如下:
module.exports = options => {
// To do: 后期支持从数据库取到跨域的域名
return async function whiteList(ctx, next) {
const { corsOrigin = '*' } = options;
// 非线上环境,无跨域限制
if (corsOrigin === '*') {
await next();
} else {
// 线上环境
const referer = ctx.request.header.referer || '';
const originArr = corsOrigin
.split(',')
.map(site => site.trim())
.filter(site => referer.indexOf(site) > -1);
if (originArr.length > 0) {
await next();
} else {
ctx.status = 401;
ctx.body = {
code: '40010',
message: '不在允许域名单中',
};
}
}
};
};