关于nginx:使用-Nginx-构建前端日志统计服务

13次阅读

共计 6246 个字符,预计需要花费 16 分钟才能阅读完成。

背景

之前的几篇文章都是对于之前提到的 低代码平台 的。

这个大的我的项目以 low code 为外围,囊括了编辑器前端、编辑器后端、C 端 H5、组件库、组件平台、后盾管理系统前端、后盾管理系统后盾、统计服务、自研 CLI 九大零碎。

明天就来说一下其中的 统计服务:目标次要是为了实现 H5 页面的分渠道统计(其实不仅仅是分渠道统计,外围是想做一个自定义事件统计服务,只是目前有分渠道统计的需要),查看每个渠道具体的 PV 状况。(具体会在 url 下面体现,会带上页面名称、id、渠道类型等)

先放一下整体流程图吧:

日志收集

常见的日志收集形式有手动埋点和主动埋点,这里咱们不关注于如何收集日志,而是如何将收集的日志的发送到服务器。

在常见的埋点计划中,通过图片来发送埋点申请是一种常常被驳回的,它有很多劣势:

  • 没有跨域
  • 体积小
  • 可能实现整个 HTTP 申请 + 响应(只管不须要响应内容)
  • 执行过程无阻塞

这里的计划就是在 nginx 上放一张 1px * 1px 的动态图片,而后通过拜访该图片(http://xxxx.png?env=xx&event=xxx), 并将埋点数据放在 query 参数上,以此将埋点数据落到 nginx 日志中。

iOS 上会限度 get 申请的 url 长度,但咱们这里实在场景发送的数据不会太多,所以目前临时采纳这种计划

这里简略论述一下为什么图片地址的 query key 要这么设计,如果单纯是为了统计渠道和作品,很有可能会把key 设计为 channelworkId 这种,但下面也说到了,咱们是想做一个自定义事件统计服务,那么就要思考字段的可扩展性,字段应更有通用语义。所以参考了很多统计服务的设计,这里采纳的字段为:

  • env
  • event
  • key
  • value

之后每次拜访页面,nginx就会自动记录日志到 access_log 中。

有了日志,上面咱们来看下如何来对其进行拆分。

日志拆分

为何要拆分日志

access.log日志默认不会拆分,会越积攒越多,零碎磁盘的空间会被耗费得越来越多,未来可能面临着日志写入失败、服务异样的问题。

日志文件内容过多,对于后续的问题排查和剖析也会变得很艰难。

所以日志的拆分是有必要也是必须的。

如何拆分日志

咱们这里拆分日志的外围思路是:将以后的 access.log 复制一份重命名为新的日志文件,之后清空老的日志文件。

视流量状况(流量越大日志文件积攒的越快),按天、小时、分钟来拆分。能够把 access.log 按天拆分到某个文件夹中。

log_by_day/2021-12-19.log
log_by_day/2021-12-20.log
log_by_day/2021-12-21.log

但下面的 复制 -> 清空 操作必定是要主动解决的,这里就须要启动定时工作,在每天固定的工夫(我这里是在每天凌晨 00:00)来解决。

定时工作

其实定时工作不仅在日志拆分的时候会用到,在前面的日志剖析和日志革除都会用到,这里先简略介绍一下,最终会整合拆分、剖析和革除。

linux中内置的 cron 过程就是来解决定时工作的。在 node 中咱们个别会用 node-schedulecron来解决定时工作。

这里应用的是 cron:

/**
    cron 定时规定 https://www.npmjs.com/package/cron
    *    *    *    *    *    *
    ┬    ┬    ┬    ┬    ┬    ┬
    │    │    │    │    │    │
    │    │    │    │    │    └ day of week (0 - 6) (Sun-Sat)
    │    │    │    │    └───── month (1 - 12)
    │    │    │    └────────── day of month (1 - 31)
    │    │    └─────────────── hour (0 - 23)
    │    └──────────────────── minute (0 - 59)
    └───────────────────────── second (0 - 59)
 */

具体应用形式就不开展阐明了。

编码

有了下面这些储备,上面我就来写一下这块代码,首先梳理下逻辑:

1️⃣ 读取源文件 access.log

2️⃣ 创立拆分后的文件夹(不存在时需主动创立)

3️⃣ 创立日志文件(天维度,不存在时需主动创立)

4️⃣ 拷贝源日志至新文件

5️⃣ 清空 access.log

/**
 * 拆分日志文件
 *
 * @param {*} accessLogPath
 */
function splitLogFile(accessLogPath) {const accessLogFile = path.join(accessLogPath, "access.log");

  const distFolder = path.join(accessLogPath, DIST_FOLDER_NAME);
  fse.ensureDirSync(distFolder);

  const distFile = path.join(distFolder, genYesterdayLogFileName());
  fse.ensureFileSync(distFile);
  fse.outputFileSync(distFile, ""); // 避免反复,先清空

  fse.copySync(accessLogFile, distFile);

  fse.outputFileSync(accessLogFile, "");
}

日志剖析

日志剖析就是读取上一步拆分好的文件,而后依照肯定规定去解决、落库。这里有一个很重要的点要提一下:node在解决大文件或者未知内存文件大小的时候千万不要应用 readFile,会冲破 V8 内存限度。正是思考到这种状况,所以这里读取日志文件的形式应该是:createReadStream 创立一个可读流交给 readline 逐行读取解决

readline

readline 模块提供了用于从可读流每次一行地读取数据的接口。能够应用以下形式拜访它:

const readline = require("readline");

readline 的应用也非常简单:创立一个接口实例,传入对应的参数:

const readStream = fs.createReadStream(logFile);
const rl = readline.createInterface({input: readStream,});

而后监听对应事件即可:

rl.on("line", (line) => {if (!line) return;

  // 获取 url query
  const query = getQueryFromLogLine(line);
  if (_.isEmpty(query)) return;

  // 累加逻辑
  // ...
});
rl.on("close", () => {
  // 逐行读取完结,存入数据库
  const result = eventData.getResult();
  resolve(result);
});

这里用到了 lineclose事件:

  • line事件:每当 input 流接管到行尾输出(\n、\r 或 \r\n)时,则会触发 ‘line’ 事件
  • close事件:个别在传输完结时会触发该事件

逐行剖析日志后果

理解了readline 的应用,上面让咱们来逐行对日志后果进行剖析吧。

首先来看下 access.log 中日志的格局:

咱们取其中一行来剖析:

127.0.0.1 - - [19/Feb/2021:15:22:06 +0800] "GET /event.png?env=h5&event=pv&key=24&value=2 HTTP/1.1" 200 5233 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36" "-"

咱们要拿到的就是 urlquery局部,也就是咱们在 h5 中自定义的数据。

通过正则匹配即可:

const reg = /GET\s\/event.png\?(.+?)\s/;
const matchResult = line.match(reg);
console.log("matchResult", matchResult);

const queryStr = matchResult[1];
console.log("queryStr", queryStr);

打印后果为:

queryStr可通过 node 中的 querystring.parse() 来解决:

const query = querystring.parse(queryStr);

console.log('query', query)
{
  env: 'h5',
  event: 'pv',
  key: '24',
  value: '2'
}

剩下的就是对数据做累加解决了。

但如何去做累加,咱们要想一下,最开始也说了是要去做分渠道统计,那么最终的后果应该能够清晰的看到两个数据:

  • 所有渠道的数据
  • 每个渠道独自的数据

只有这样的数据对于经营才是有价值的,数据的好坏也间接决定了前面在每个渠道投放的力度。

这里我参考了 Google Analytics中的多渠道漏斗的概念,由上到下分维度记录每个维度的数据,这样就能够清晰的晓得每个渠道的状况了。

具体实现也不麻烦,咱们先来看下刚刚从一条链接中失去的有用数据:

{
  env: 'h5',
  event: 'pv',
  key: '24',
  value: '2'
}

这里的 env 代表环境,这里统计的都是来源于 h5 页面,所以 envh5,然而为了扩大,所以设置了这个字段。

event示意事件名称,这里次要是统计访问量,所以为pv

key是作品 id。

value是渠道 code,目前次要有:1- 微信、2- 小红书、3- 抖音。

再来看下最终统计失去的后果吧:

{
  date: '2021-12-21',
  key: 'h5',
  value: {num: 1276}
}
{
  date: '2021-12-21',
  key: 'h5.pv',
  value: {num: 1000}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12',
  value: {num: 200}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.1',
  value: {num: 56}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.2',
  value: {num: 84}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.3',
  value: {num: 60}
}

这是截取了 2021-12-21 当天的数据,我给大家剖析一波:

1️⃣ h5:当天 h5 页面的自定义事件上报总数为 1276

2️⃣ h5.pv:其中 所有 pv(也就是 h5.pv)为 1000

3️⃣ h5.pv.12:作品 id 为 12 的 pv 一共有 200

4️⃣ h5.pv.12.1:作品 id 为 12 的在微信渠道的 pv 为 56

5️⃣ h5.pv.12.2:作品 id 为 12 的在小红书渠道的 pv 为 84

6️⃣ h5.pv.12.2:作品 id 为 12 的在抖音渠道的 pv 为 60

这样就能分明的失去某一天某个作品在某条渠道的拜访状况了,后续再以这些数据为撑持做成可视化报表,成果就高深莫测了。

统计后果入库

目前这部分数据是放在了 mongoDB 中,对于 node 中应用 mongoDB 就不开展说了,不相熟的能够参考我另外一篇文章 Koa2+MongoDB+JWT 实战 –Restful API 最佳实际

这里贴下 model 吧:

/**
 * @description event 数据模型
 */
const mongoose = require("../db/mongoose");

const schema = mongoose.Schema(
  {
    date: Date,
    key: String,
    value: {num: Number,},
  },
  {timestamps: true,}
);

const EventModel = mongoose.model("event_analytics_data", schema);

module.exports = EventModel;

日志删除

随着页面的继续拜访,日志文件会疾速减少,超过肯定工夫的日志文件存在的价值也不是很大,所以咱们要定期革除日志文件。

这个其实比较简单,遍历文件,因为文件名都是以日期命名的(格局:2021-12-14.log),所以只有判断工夫距离大于 90 天就删除日志文件。

贴一下外围实现:

// 读取日志文件
const fileNames = fse.readdirSync(distFolder);
fileNames.forEach((fileName) => {
  try {
    // fileName 格局 '2021-09-14.log'
    const dateStr = fileName.split(".")[0];
    const d = new Date(dateStr);
    const t = Date.now() - d.getTime();
    if (t / 1000 / 60 / 60 / 24 > 90) {
      // 工夫距离,大于 90 天,则删除日志文件
      const filePath = path.join(distFolder, fileName);
      fse.removeSync(filePath);
    }
  } catch (error) {console.error(` 日志文件格式谬误 ${fileName}`, error);
  }
});

定时工作整合

到这里,日志的拆分、剖析和革除都说完了,当初要用 cron 来对他们做整合了。

首先来创立定时工作:

function schedule(cronTime, onTick) {if (!cronTime) return;
  if (typeof onTick !== "function") return;

  // 创立定时工作
  const c = new CronJob(
    cronTime,
    onTick,
    null, // onComplete 何时进行工作
    true, // 初始化之后立即执行
    "Asia/Shanghai" // 时区
  );

  // 过程完结时,进行定时工作
  process.on("exit", () => c.stop());
}

而后每一阶段都在不同的工夫阶段去解决(定时拆分 -> 定时剖析 -> 定时删除)

定时拆分

function splitLogFileTiming() {
  const cronTime = "0 0 0 * * *"; // 每天的 00:00:00
  schedule(cronTime, () => splitLogFile(accessLogPath));
  console.log("定时拆分日志文件", cronTime);
}

定时剖析并入库

function analysisLogsTiming() {
  const cronTime = "0 0 3 * * *"; // 每天的 3:00:00,此时凌晨,访问量较少,服务器资源处于闲置状态
  schedule(cronTime, () => analysisLogsAndWriteDB(accessLogPath));
  console.log("定时剖析日志并入库", cronTime);
}

定时删除

function rmLogsTiming() {
  const cronTime = "0 0 4 * * *"; // 每天的 4:00:00,此时凌晨,访问量较少,服务器资源处于闲置状态
  schedule(cronTime, () => rmLogs(accessLogPath));
  console.log("定时删除过期日志文件", cronTime);
}

而后在利用入口按序调用即可:

// 定时拆分日志文件
splitLogFileTiming();
// 定时剖析日志并入库
analysisLogsTiming();
// 定时删除过期日志文件
rmLogsTiming();

总结

ok,到这里,一个繁难的统计服务就实现了。

正文完
 0