前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >监听nginx日志实现博客访问计数

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

作者头像
呼延十
发布2019-07-01 17:15:11
1.1K0
发布2019-07-01 17:15:11
举报
文章被收录于专栏:呼延

前言

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

实现代码如下:

代码语言:javascript
复制
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.

代码如下:

代码语言:javascript
复制
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示例使用了下面的代码.

代码语言:javascript
复制
   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


本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 实现
    • 监听Nginx日志
      • 提供对外API
        • 前端实现
        • 需要注意的几个问题
        • 奇怪操作导致的坑
          • 单例Jedis导致的问题
            • 热度自动翻倍
              • ChangeLog
              相关产品与服务
              云数据库 Redis®
              腾讯云数据库 Redis®(TencentDB for Redis®)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档