以前看到别人的博客的浏览人数xxx
,很是羡慕,自己也想搞一个.
但是由于我的博客项目,是基于Jekyll的,是一个静态的站点,也就是说没有普通Web项目的后端
部分.
如果是普通的web项目,那么只需要在每次访问的时候在service里面进行计数即可.
Jekyll实现的博客项目还有一种更加受欢迎的做法,就是在前端完成这些,当用户加载页面的时候,前端去请求某一个API,然后进行计数并且返回一个热度值
.
我的JS又写的不太好,所以我决定通过分析Nginx来实现.
博客站点的所有请求都会经过Nginx进行访问,而Nginx是有日志记录的,主要包含以下几个信息:
分析需求发现,我们想要实现某篇文章的热度统计,以上几个信息就够了.
nginx日志在默认情况下,会无限追加至/var/log/nginx/access.log
中,那么我们可以通过监听文件来实现.
这块没有使用一些现成的实现,自己瞎写的.主要思路是:
incr
操作,比较方便.实现代码如下:
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访问过此页面
,这个我没有做.
这块比较简单,一个简单的接口就可以,接口内部读取redis.
代码如下:
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>
标签即可.
这里是在编码的时候就能想到的几个问题:
因为url会自动编码,而在参数中传递的字段又不会,所以在写入的时候需要进行一次解码.
url是大小写敏感的,但是作为参数传递的时候是大小写不敏感的..所以需要注意.
在我的灵机一动之下,初始版本的代码中获取jedis示例使用了下面的代码.
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
启动.
完.
2019-05-10 完成
以上皆为个人所思所得,如有错误欢迎评论区指正。
欢迎转载,烦请署名并保留原文链接。
联系邮箱:huyanshi2580@gmail.com