监听nginx日志实现博客访问计数

前言

以前看到别人的博客的浏览人数xxx,很是羡慕,自己也想搞一个.

但是由于我的博客项目,是基于Jekyll的,是一个静态的站点,也就是说没有普通Web项目的后端部分.

如果是普通的web项目,那么只需要在每次访问的时候在service里面进行计数即可.

Jekyll实现的博客项目还有一种更加受欢迎的做法,就是在前端完成这些,当用户加载页面的时候,前端去请求某一个API,然后进行计数并且返回一个热度值.

我的JS又写的不太好,所以我决定通过分析Nginx来实现.

实现

博客站点的所有请求都会经过Nginx进行访问,而Nginx是有日志记录的,主要包含以下几个信息:

  1. 访问来源的Ip
  2. 被访问页面
  3. 访问来源网址
  4. 请求的类型返回值等等信息.

分析需求发现,我们想要实现某篇文章的热度统计,以上几个信息就够了.

监听Nginx日志

nginx日志在默认情况下,会无限追加至/var/log/nginx/access.log中,那么我们可以通过监听文件来实现.

这块没有使用一些现成的实现,自己瞎写的.主要思路是:

  1. 记录当前文件的大小.
  2. 每隔10秒读一次文件的大小并且判断是否有新内容.
  3. 如果有新内容,则读取新内容,并将其解析,使用redis的string类型来存储访问量.因为redis的string类型也支持incr操作,比较方便.

实现代码如下:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;

import javax.annotation.PostConstruct;
import java.io.File;
import java.io.RandomAccessFile;
import java.net.URLDecoder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Created by pfliu on 2019/05/07.
*/
@Component
public class NginxLogListener {

@Value("${nginx.log.path}")
private String fileName;

@Value("${redis.url}")
private String redisUrl;

private final static Logger logger = LoggerFactory.getLogger(NginxLogListener.class);

private static long lastFileSize;
private static String LAST_FILE_SIZE_KEY = "last_file_size_key";
private static String LOG_REGIX = "([^ ]*) ([^ ]*) ([^ ]*) (\\[.*\\]) (\\\".*?\\\") (-|[0-9]*) (-|[0-9]*) (\\\".*?\\\") (\\\".*?\\\")";

@PostConstruct
public void listen() {
final File logFile = new File(fileName);
Jedis jedis = new Jedis(redisUrl);
logger.info(" execute the default constructor");

lastFileSize = Long.valueOf(jedis.get(LAST_FILE_SIZE_KEY) == null ? "0" : jedis.get(LAST_FILE_SIZE_KEY));

ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);

executorService.scheduleWithFixedDelay(() -> {

try {
Thread.currentThread().setName("right-thread");
long len = logFile.length();
if (len < lastFileSize) {
lastFileSize = 0;
} else if (len > lastFileSize) {
//指定文件可读可写
RandomAccessFile randomFile = new RandomAccessFile(logFile, "rw");
randomFile.seek(lastFileSize);//移动文件指针位置

String tmp = "";
while ((tmp = randomFile.readLine()) != null) {
// 文件有更新的时候读取全部更新
String log = new String(tmp.getBytes("utf-8"));
parseLog(log, jedis);
logger.info("new log:" + log);
}
lastFileSize = randomFile.length();
jedis.set(LAST_FILE_SIZE_KEY, lastFileSize + "");
randomFile.close();
}

} catch (Exception e) {
logger.error(" read file error,now = {}", LocalDateTime.now().toString(), e);

} finally {
}
}, 0, 10, TimeUnit.SECONDS);

}

// 解析一条日志
private void parseLog(String log, Jedis jedis) {
try {
Pattern p = Pattern.compile(LOG_REGIX);
Matcher m = p.matcher(log);

while (m.find()) {
// 使用正则表达式进行匹配,之后逐一拿到需要的字段
String ip = m.group(1);
String page = m.group(5).replace("\"GET ", "").replace("HTTP/1.1\"", "").trim();
if (page.startsWith("/") && page.endsWith("/")) {
logger.info("current thread :" + Thread.currentThread().getName());
logger.info("save : ip = {}, page = {}", ip, page);
jedis.incr(LocalDate.now().toString());
jedis.incr(decode(page).toLowerCase());
}
}
} catch (Exception e) {
logger.error("parse error, log={}.", log, e);
}
}

//对url中进行解码,url会将中文变成GBK编码
public String decode(String s) {
try {

return URLDecoder.decode(s, "utf-8");
} catch (Exception e) {
logger.error("decode error.s = {}, e= {}", s, e.getMessage(), e);
}
return "decode-wrong";
}

}

其中主要的代码在parseLog方法中,在redis中对当前页面的key进行一次incr操作,同时对当前日期的key进行加1操作,这样可以顺便统计今天的访问量.

这里还可以使用redis的hyperLogLog数据结构,对每个页面进行唯一ip的统计,可以统计拿到多少ip访问过此页面,这个我没有做.

提供对外API

这块比较简单,一个简单的接口就可以,接口内部读取redis.

代码如下:

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
import com.alibaba.fastjson.JSONObject;
import com.huyan.lucenedemo.util.JedisCli;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;

/**
* Created by pfliu on 2019/05/08.
*/
@RestController
public class AccessController {

private final static Logger logger = LoggerFactory.getLogger(AccessController.class);

private static JSONObject zeroJson = new JSONObject();

static {
zeroJson.put("num", "0");
}


@Value("${redis.url}")
private String redisUrl;

@GetMapping("/count")
public String access(@RequestParam("s") String s, @RequestParam("callback") String callback) {

try {
Jedis jedis = JedisCli.getJedis(redisUrl);
JSONObject o = new JSONObject();
String c = jedis.get(s.trim().toLowerCase());
c = null == c ? "0" : c;
o.put("num", c);
return callback + "(" + o.toJSONString() + ")";
} catch (NumberFormatException e) {
logger.error("get count from {} error", s, e);
e.printStackTrace();
}
return callback + "(" + zeroJson.toJSONString() + ")";
}
}

前端实现

和以往一样,前端的代码都是凑活实现了功能,这里就不贴了.

实现思路是:在每个页面启动的时候请求刚才提供的API.将返回值写入某个<span>标签即可.

需要注意的几个问题

这里是在编码的时候就能想到的几个问题:

  1. 对url的编解码,需要保证写入和读取的key相同.

因为url会自动编码,而在参数中传递的字段又不会,所以在写入的时候需要进行一次解码.

  1. url中的大小写

url是大小写敏感的,但是作为参数传递的时候是大小写不敏感的..所以需要注意.

奇怪操作导致的坑

单例Jedis导致的问题

在我的灵机一动之下,初始版本的代码中获取jedis示例使用了下面的代码.

1
2
3
4
5
6
7
8
public static  Jedis jedis;

public static Jedis getJedis(String url){
if (null == jedis) {
jedis = new Jedis(url);
}
return jedis;
}

算是实现一个伪单例吧,核心思想也是不要建立那么多的jedis对象.

然后在线上出现了,获取的热度值为OK的问题.

开始我以为是写入错误,检查之后发现redis中的值都没有问题.后来根据这个”OK”才想到的,因为在redis中set命令的返回值就是OK.所以我觉得可能是,写入和读取都是用同一个jedis实例,而在使用的时候并没有进行加锁等操作来保证线程安全,因此在读取的时候正好拿到了其他线程在写入的返回值.通过将jedis获取方法修改成读取使用同一个对象,写入每次使用一个对象解决了这个问题.

热度自动翻倍

看起来这不是个bug,多好的事啊,哈哈哈.

我在测试时候发现,我请求一次,会被服务器记录三遍.

经过观察,是在服务器上没有kill掉老的服务,而重新起了新的服务导致的,解决方法是编写了启动脚本,在脚本中会kill掉老的服务然后启动新服务,通过执行脚本而不是直接java -jar启动.

完.





ChangeLog

2019-05-10 完成

以上皆为个人所思所得,如有错误欢迎评论区指正。

欢迎转载,烦请署名并保留原文链接。

联系邮箱:huyanshi2580@gmail.com

更多学习笔记见个人博客——>呼延十