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

Posted1 by 呼延十 on May 10, 2019 Hot:

前言

以前看到别人的博客的浏览人数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操作,比较方便.

实现代码如下:

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.

代码如下:

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示例使用了下面的代码.

   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

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