前言
以前看到别人的博客的浏览人数xxx
,很是羡慕,自己也想搞一个.
但是由于我的博客项目,是基于Jekyll的,是一个静态的站点,也就是说没有普通Web项目的后端
部分.
如果是普通的web项目,那么只需要在每次访问的时候在service里面进行计数即可.
Jekyll实现的博客项目还有一种更加受欢迎的做法,就是在前端完成这些,当用户加载页面的时候,前端去请求某一个API,然后进行计数并且返回一个热度值
.
我的JS又写的不太好,所以我决定通过分析Nginx来实现.
实现
博客站点的所有请求都会经过Nginx进行访问,而Nginx是有日志记录的,主要包含以下几个信息:
- 访问来源的Ip
- 被访问页面
- 访问来源网址
- 请求的类型返回值等等信息.
分析需求发现,我们想要实现某篇文章的热度统计,以上几个信息就够了.
监听Nginx日志
nginx日志在默认情况下,会无限追加至/var/log/nginx/access.log
中,那么我们可以通过监听文件来实现.
这块没有使用一些现成的实现,自己瞎写的.主要思路是:
- 记录当前文件的大小.
- 每隔10秒读一次文件的大小并且判断是否有新内容.
- 如果有新内容,则读取新内容,并将其解析,使用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;
@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); } }
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;
@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>
标签即可.
需要注意的几个问题
这里是在编码的时候就能想到的几个问题:
- 对url的编解码,需要保证写入和读取的key相同.
因为url会自动编码,而在参数中传递的字段又不会,所以在写入的时候需要进行一次解码.
- 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
更多学习笔记见个人博客——>呼延十