管理端要求统计网站每日、每小时的
- PV:浏览次数
- UV:独立访客(客户端数量)
- IP:访问的 IP 数量(公网 IP)
PS:UV 是客户机数量,IP 是指的公网 IP 数量,一个公网 IP 的局域网内可能有多个主机,所以 IP >= UV。
我们将这三个数据到先存到 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";
}
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
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();
}
当然,以上做法也同样适用于各模块统计