一、日志拆分

nginx 生成的日志 access.log 默认不会拆分,会越积累越多,导致大文件不易操作。而进行离线日志分析以及计算统计结果时,一般是计算昨天的日志。试想在这种场景下,是从 access.log 一个大文件读取方便?还是拆分的零散的文件方便?

1.1 如何拆分日志

视流量情况(流量越大日志文件积累的越快),可以按天、小时、分钟来拆分。例如,将 access.log 按天拆分到某个文件夹中,如下所示:

  1. logs_by_day/access.2021-03-09.log
  2. logs_by_day/access.2021-03-10.log
  3. 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 定时任务

  1. /**
  2. 通用的定时表达式规则:
  3. * * * * * *
  4. ┬ ┬ ┬ ┬ ┬ ┬
  5. │ │ │ │ │ │
  6. │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
  7. │ │ │ │ └───── month (1 - 12)
  8. │ │ │ └────────── day of month (1 - 31)
  9. │ │ └─────────────── hour (0 - 23)
  10. │ └──────────────────── minute (0 - 59)
  11. └───────────────────────── second (0 - 59, OPTIONAL) **【注意】linux crontab 不支持秒**
  12. */

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

npm 依赖包 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 的一个具体场景,即逐行读取);
  • 读取完毕,输出统计结果

数据结构

统计结果的格式如下。举例:eventKeyh5.pv.85.41,可理解为H5作品ID为85在渠道41上的pv是30。通过这种方式,就可以计算出在各层级各粒度下的pv数据

  1. {
  2. eventDate: '2021-03-21', // 统计结果的日期
  3. eventKey: 'h5',
  4. eventData: { pv: 10000 } // category=h5 的数据汇总
  5. },
  6. {
  7. eventDate: '2021-03-21',
  8. eventKey: 'h5.pv',
  9. eventData: { pv: 8000 } // category=h5&action=pv 的数据汇总
  10. },
  11. {
  12. eventDate: '2021-03-21',
  13. eventKey: 'h5.pv.85',
  14. eventData: { pv: 100 } // category=h5&action=pv&label=85 的数据汇总
  15. },
  16. {
  17. eventDate: '2021-03-21',
  18. eventKey: 'h5.pv.85.41',
  19. eventData: { pv: 30 } // category=h5&action=pv&label=85&value=41 的数据汇总
  20. },
  21. {
  22. eventDate: '2021-03-21',
  23. eventKey: 'h5.pv.85.42',
  24. eventData: { pv: 20 } // category=h5&action=pv&label=85&value=42 的数据汇总
  25. },
  26. {
  27. eventDate: '2021-03-21',
  28. eventKey: 'h5.pv.85.43',
  29. eventData: { pv: 50 } // category=h5&action=pv&label=85&value=43 的数据汇总
  30. }

获取结果

输入:

  • 开始日期
  • 结束日期
  • 各个参数 category action label value 等

输出

  • 日期范围之内的,所有统计数据

三、数据库存储

本次方案采用 mongodb 数据库,按照数据结构设计如下 schema:

  1. mongoose.Schema(
  2. {
  3. eventKey: String,
  4. eventData: {
  5. pv: Number,
  6. // uv: Number, // 后续可扩展 uv
  7. },
  8. eventDate: Date,
  9. },
  10. { timestamps: true }
  11. )

四、OpenAPI

OpenAPI 不是一个新词。相比 API 而言,OpenAPI 是提供了通用的第三方 API 服务(支持多方接入),而API只是针对特定场景。

但是 OpenAPI 也不是谁都可以接入,需要进行授权以及申请后才可以使用。针对这一特性可通过跨域限制来实现。

编写白名单中间件,示例代码如下:

  1. module.exports = options => {
  2. // To do: 后期支持从数据库取到跨域的域名
  3. return async function whiteList(ctx, next) {
  4. const { corsOrigin = '*' } = options;
  5. // 非线上环境,无跨域限制
  6. if (corsOrigin === '*') {
  7. await next();
  8. } else {
  9. // 线上环境
  10. const referer = ctx.request.header.referer || '';
  11. const originArr = corsOrigin
  12. .split(',')
  13. .map(site => site.trim())
  14. .filter(site => referer.indexOf(site) > -1);
  15. if (originArr.length > 0) {
  16. await next();
  17. } else {
  18. ctx.status = 401;
  19. ctx.body = {
  20. code: '40010',
  21. message: '不在允许域名单中',
  22. };
  23. }
  24. }
  25. };
  26. };