管理端要求统计网站每日、每小时的

  • PV:浏览次数
  • UV:独立访客(客户端数量)
  • IP:访问的 IP 数量(公网 IP)

PS:UV 是客户机数量,IP 是指的公网 IP 数量,一个公网 IP 的局域网内可能有多个主机,所以 IP >= UV。

统计uv比pv mysql 有效uv统计方法_ide

我们将这三个数据到先存到 redis 中,然后每天落库一次。

public abstract class WebsiteRedisRole {
	// PV 的 redis key(String 结构)
    public static final String PREFIX_KEY_PV= "website:pv";
	// UV 的 redis key(String 结构)
    public static final String PREFIX_KEY_UV="website:uv";
	// IP 的 redis key(Set 结构,保存所有 IP)
    public static final String PREFIX_KEY_IPS = "website:ips";
}

统计uv比pv mysql 有效uv统计方法_统计uv比pv mysql_02

1.统计 PV

统计 PV 其实很简单,写一个拦截器,只要有任何请求进来,就将 redis 中 保存的 PV++

@Component
public class PvInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private WebsiteService websiteService;

    // 只要有请求进来,就将 PV++
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler){	
		// redis key 为 website:pv
        this.websiteService.insertVisit(WebsiteRedisRole.PREFIX_KEY_PV);
        return true;
    }
}

WebsiteService#insertVisit()

@Autowired
private StringRedisTemplate redisTemplate;

@Override
public void insertVisit(String key){
    // 判断 redis 中是否存在当前 key
    if(!redisTemplate.hasKey(key)){
        // 不存在就初始化为 0
        redisTemplate.opsForValue().set(key,"0");
    }
    // count++,然后重新放回 redis
    Integer count = Integer.parseInt(this.redisTemplate.opsForValue().get(key));
    count++;
    this.redisTemplate.opsForValue().set(key,count.toString());
}

2.统计 UV

统计 UV 相较 PV 更复杂一点,为了标识每一个主机,我们通过 cookie 给每个主机都发放一个唯一 ID

@Component
public class UvInterceptor extends HandlerInterceptorAdapter {
    
    public static final String VISIT_COOKIE_NAME = "XUSM_VISIT_ID" // cookie 名
    
    @Autowired
    private WebsiteService websiteService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
        // 获取请求中携带的 visitId
        String visitId = CookieUtils.getCookieValue(request, VISIT_COOKIE_NAME);
        // 如果用户机中已经有 visitId 了,那么说明今天已经记录过该客户机的访问了
        if(StringUtils.isNotBlank(visitId)){
            THREAD_LOCAL.set(CookieUtils.getCookieValue(request, VISIT_COOKIE_NAME));
            return true;
        }

        // 如果该用户机还没有 visitId
        // 那么,通过 UUID 生成一个随机 IDD
        String cookieValue = UUIDUtils.getUUID32();
       
	   // 将 visitId 通过 cookie 发送到用户机
        // 注:由于我们是每日都统计 UV,所以这个 cookie 的过期时间应该是当日的 23:59:59
        CookieUtils.setCookie(request,response,VISIT_COOKIE_NAME,cookieValue,
                              CookieDeathUtils.getCookieDeath());

        // UV++(redis key 为 website:uv)
        this.websiteService.insertVisit(WebsiteRedisRole.PREFIX_KEY_UV);

        return true;
    }

}

Service 的方法跟 PV 一样,只不过 redis Key 为 website:uv

@Override
public void insertVisit(String key){
    if(!redisTemplate.hasKey(key)){
        redisTemplate.opsForValue().set(key,"0");
    }
    Integer count = Integer.parseInt(this.redisTemplate.opsForValue().get(key));
    count++;
    this.redisTemplate.opsForValue().set(key,count.toString());
}

注意

上面也说了所有用户机保存 visitId 的 cookie 的过期时间应该为 24:59:59,所以我单独写了一个工具类来计算过期时间

public class CookieDeathUtils {

    public static int getCookieDeath(){
        // 获取当前时间戳
        long now = Calendar.getInstance().getTimeInMillis(); 
        
        // 通过 Calendar 手动设置时间
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.HOUR_OF_DAY,23);
        calendar.set(Calendar.MINUTE,59);
        calendar.set(Calendar.SECOND,59);
        
        // 获取当日 23:59:59 的时间戳
        long death = calendar.getTimeInMillis();
        
        // 计算过期时间
        // 注意:cookie 过期时间的单位为秒
        int cookieMaxAge = (int) ((death-now)/1000);

        return cookieMaxAge;
    }
}

3.统计IP

也是写一个 拦截器,获取每次请求的 IP

@Component
public class IpInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private WebsiteService websiteService;

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
        // 获取请求的 IP
        String remoteAddr = IpAddrUtils.getIpAddr(request);
        if(StringUtils.isNotBlank(remoteAddr)){
            // 如果 redis 中已经保存了该 ip,那么就不记录
            if( !websiteService.hasRomteIp(remoteAddr)){
                // 如果 redis 中还没记录该 IP,将该 IP 放入 redis 的 Set 中
                this.websiteService.insertRomteIp(remoteAddr);
            }
        }
        return true;
    }
}

Service 中的方法

@Override
public Boolean hasRomteIp(String addr) {
    // 判断 key 为 website:ips 的 Set 中是否已经有该 IP
    return redisTemplate.opsForSet().isMember(WebsiteRedisRole.PREFIX_KEY_IPS,addr);
}

@Override
public void insertRomteIp(String addr) {
    // 将 ip 放入 key 为 website:ips 的 Set 中
    redisTemplate.opsForSet().add(WebsiteRedisRole.PREFIX_KEY_IPS,addr);
}

注意

这里再特别注意一点,服务在部署到云服务器后一般会使用 nginx 来做反向代理,所以,直接 request.getRemoteAddr() 是无法拿到远程客户机的 IP 的。解决方案如下:

1)配置 nginx

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  • Host:保存客户端真实的域名和端口号
  • X-Real-IP:保存客户端真实的IP
  • X-Forwarded-For:这个 Header 和 X-Real-IP 类似,但它在多层代理时会包含真实客户端及中间每个代理服务器的 IP

统计uv比pv mysql 有效uv统计方法_IP_03

PS:关于 XFF 头可以参考这篇文章

2)通过 XFF 头去获取真实 IP

public class IpAddrUtils {

    public static String getIpAddr(HttpServletRequest request){
        String ipAddress = request.getHeader("x-forwarded-for");
        if(ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("Proxy-Client-IP");
        }
        if(ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("WL-Proxy-Client-IP");
        }
        if(ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
            if(ipAddress.equals("127.0.0.1") || ipAddress.equals("0:0:0:0:0:0:0:1")){
                //根据网卡取本机配置的IP
                InetAddress inet=null;
                try {
                    inet = InetAddress.getLocalHost();
                } catch (UnknownHostException e) {
                    e.printStackTrace();
                }
                ipAddress= inet.getHostAddress();
            }
        }
        // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
        if(ipAddress!=null && ipAddress.length()>15){ //"***.***.***.***".length() = 15
            if(ipAddress.indexOf(",")>0){
                ipAddress = ipAddress.substring(0,ipAddress.indexOf(","));
            }
        }
        return ipAddress;
    }
}

按日落库

上面说了 PV、UV、IP 变化的逻辑,当一天完了后,我们需要将 redis 中的数据保存到数据库。这里我们借助 Quartz 来实现定时任务

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

1)QuartzJobBean

@Component
public class WebsiteTask extends QuartzJobBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private WebsiteService websiteService;

    private static final String KEY = "website:*";

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        // 模糊匹配,将所有 website:* 的 key 都查找出来
        Set<String> keys = this.redisTemplate.keys(KEY);
        if(CollectionUtils.isEmpty(keys)){
            return;
        }
        // 将 redis 中 这些 key 对应的数据落库
        this.websiteService.insertAllToDb(keys);
        // 删除当前这些 key
        keys.forEach(k-> this.redisTemplate.delete(k));
    }
}

websiteService#insertAllToDb()

@Override
public void insertAllToDb(Set<String> keys) {
        
        // 将 redis 这些 key 的保存的数据保存到 map 中
        Map<String,Integer> map = new HashMap<>();
        keys.forEach(k ->{
            // 如果 key 是 website:ips,即保存所有 ip 的 Set
            // 那么,落库时保存的是 Set 的 size
            if(StringUtils.equals(WebsiteRedisRole.PREFIX_KEY_IPS,k)){
                map.put(k,this.redisTemplate.opsForSet().size(k).intValue());
            // 其余 key 保存的是 String,则取出值并转 int 就行
            }else {
                map.put(k,Integer.parseInt(redisTemplate.opsForValue().get(k)));
            }
        });

        // 构建 WebSite 对象
        Website website = new Website();
        website.setWebPv(map.get(WebsiteRedisRole.PREFIX_KEY_PV));
        website.setWebUv(map.get(WebsiteRedisRole.PREFIX_KEY_UV));
        website.setWebIp(map.get(WebsiteRedisRole.PREFIX_KEY_IPS));
	   
		// 设置当前当前数据的时间 
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
        String format = simpleDateFormat.format(new Date());
        try {
            website.setSchedule(simpleDateFormat.parse(format));
        } catch (ParseException e) {
            e.printStackTrace();
        }
	
        // 落库
        this.websiteDao.insertSelective(website);
}

2)定时执行

@Bean
/**
 * 创建 Job
 */
public JobDetail websiteTask(){
    return JobBuilder.newJob(WebsiteTask.class).withIdentity("websiteTask").storeDurably().build();
}

@Bean
/**
 * 设置执行时间(cron 表达式)
 */
public Trigger websiteTaskTrigger(){
    return TriggerBuilder.newTrigger().forJob(websiteTask())
            .withIdentity("websiteTask") // 任务
            .withSchedule(CronScheduleBuilder.cronSchedule("0 59 23 * * ? *")) // cron 表达式
            .build();
}

当然,以上做法也同样适用于各模块统计

统计uv比pv mysql 有效uv统计方法_IP_04