文章目录
参考牛客网高级项目教程
狂神说Redis教程笔记
功能需求
-
1.拥有管理员权限的管理员可以统计对社区网站的独立访客
- 没有登录的游客也统计在内,因此,使用ip去重统计
- 使用redis的HyperLogLog数据类型
- -性能好、储存空间小,无需非常精确(因为含有没登录的游客,统计出访问量大小即可)
-
2.管理员可以统计出日活跃用户
- 因为是对已登录用户状态的统计-要求精度
- 因此使用BitMap数据结构比较合适
- 以天为单位,每天只要访问过一次,就定位活跃状态
-
3.开发出记录、查询和显示的网页
一、dao层定义redis数据的key
- 通过日期定位到key,因此,要根据字符串类型的日期来拼接key
- 要查询指定区间的数据-可以用redis的两个数据类型的合并功能,
- 要使用区间的开始和结束时间拼接key来接收合并的结果
- 合并时,要拿出区间中每天的时间拼接出单日的key集合
private static final String PREFIX_UV = "uv"; // 独立访客
private static final String PREFIX_DAU = "dau"; // 日活跃用户
/**
* 定义单日独立访客UV的key
* 通过日期定位到key
*/
public static String getUV(String date) {
return PREFIX_UV + SPLIT + date;
}
/**
* 定义区间UV的key
* 查询一段时间的UV-合并处理
*/
public static String getUV(String startDate, String endDate) {
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}
/**
* 定义单日活跃用户DAU的key
* 同样通过日期定位到key
*/
public static String getDAU(String date) {
return PREFIX_DAU + SPLIT + date;
}
/**
* 定义区间活跃用户DAU的key
* 查询一段时间的DAU-合并处理
*/
public static String getDAU(String startDate, String endDate) {
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
二、Service层处理业务逻辑
1. 对独立访客UV的记录与查询
记录
- 将指定IP记入UV-最后统计不同的IP数目
new SimpleDateFormat("yyyyMMdd")
- 创建指定日期格式的日期转换器实例
- 注意,本网站统计只精缺到天,不紧缺到时分秒,否则key很难取到
format(new Date())
- 日期类型转为指定日期格式字符串类型
@Service
public class DataService {
/**创建指定日期格式的日期转换器实例 */
private SimpleDateFormat sf = new SimpleDateFormat("yyyyMMdd");
@Autowired
RedisTemplate redisTemplate;
/**
* 对UV的记录
* @param ip 将指定IP记入UV-最后统计不同的IP数目
*/
public void addUV(String ip) {
String UVKey = RedisKeyUtil.getUV(sf.format(new Date()));
redisTemplate.opsForHyperLogLog().add(UVKey, ip);
}
}
查询
- 支持区间查询,当查询一天日期时,区间长度设为1即可
Calendar类
date类
和simpleDateFormat类
能够格式化并创建一个日期对象了,- 但是我们如何才能设置和获取日期数据的特定部分呢,比如说小时,日,或者分钟?
- 我们又如何在日期的这些部分加上或者减去值呢? 答案是使用Calendar类。Date中有很多方法都已经废了!
- Calendar类的功能要比Date类强大很多,而且在实现方式上也比Date类要复杂一些。
- Calendar类是一个抽象类,在实际使用时实现特定的子类的对象,创建对象的过程对程序员来说是透明
的,只需要使用getInstance方法创建即可。
Calendar.getInstance()
- 实例化操作日期的Calendar类
-
默认是当前日期
-
也可以创建指定日期的Calendar对象
Calendar类对象字段类型
日期字段全局静态变量:
对日期的获取与设置
- 注意:Calender的月份是从0开始的,但日期和年份是从1开始的
- 注意月份的获取,会比当前时间少1,获取后要加1
- 因此,设置月份的时候,都减1,例如设置6,参数填5
setTime(data)
- 传入日期格式数据
getTime(data)
- 获取data日期
对日期的修改-add
calendar.set(1999, 3 - 1, 30);
System.out.println(calendar.get(Calendar.MONTH) + 1); // 3
//把c1对象的日期加上10,也就是c1也就表示为10天后的日期,其它所有的数值会被重新计算
calendar.add(Calendar.DATE, 10);
System.out.println(calendar.get(Calendar.DATE)); // 9
System.out.println(calendar.get(Calendar.MONTH) + 1); // 4
//把c1对象的日期减去10,也就是c1也就表示为10天前的日期,其它所有的数值会被重新计算
calendar.add(Calendar.DATE, -10);
System.out.println(calendar.get(Calendar.DATE)); // 30
System.out.println(calendar.get(Calendar.MONTH) + 1);// 3
3
9
4
30
3
HyperLogLog合并查询
- 整理该日期范围内的key,放进List集合中
opsForHyperLogLog().union(UVKey, keyList.toArray())
- 将整理后的key集合转为数组-数据合并到一个UVKey中去
.before(date)
- 在date日期之前
-
Date日期类的API
/**
* 对UV的查询
* 支持区间查询,当查询一天日期时,区间长度设为1即可
* @param start 查询区间起始时间
* @param end 查询区间结束时间
*/
public long calculateUV(Date start, Date end) {
String UVKey = RedisKeyUtil.getUV(sf.format(start), sf.format(end));
if (start == null || end == null) { // 先判空
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance(); // 实例化操作日期的Calendar实例
calendar.setTime(start); // 设置日期
String key;
while (calendar.getTime().before(end)) {
// 获取区间时间的每一天的时间,设置为key
key = RedisKeyUtil.getUV(sf.format(calendar.getTime()));
keyList.add(key);
calendar.add(Calendar.DATE, 1); // 每次天数加1
}
// 合并这些数据
redisTemplate.opsForHyperLogLog().union(UVKey, keyList.toArray());
// 返回合并后的key的统计结果
return redisTemplate.opsForHyperLogLog().size(UVKey);
}
2. 对日活跃用户的记录与查询
记录
-
使用BitMap数据结构
-
以用户id为位数, 对改位设置为true
/**
* 对DAU的记录
* 以用户id为位数, 对改位设置为true
* @param userid 日活跃用户的id
*/
public void addDAU(int userid) {
String DAUKey = RedisKeyUtil.getDAU(sf.format(new Date()));
redisTemplate.opsForValue().setBit(DAUKey, userid, true);
}
查询
-
合并key的数据,用OR运算,只要在区间内的时间,有一天活跃,都是活跃用户
-
传参,是每个key的byte数组,故,就是keyList的二维数组表示形式
redisConnection.bitOp(RedisStringCommands.BitOperation.OR, DAUKey.getBytes(), keyList.toArray(new byte[0][0]));
-
注意:集合中装的是key的byte数组
/**
* 对DAU的查询
*/
public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
// 注意:集合中装的是key的byte数组形式
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAU(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE, 1);
}
// 进行OR运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAU(df.format(start), df.format(end));
if(!keyList.isEmpty()) {
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), keyList.toArray(new byte[0][0]));
}
return connection.bitCount(redisKey.getBytes());
}
});
}
三、拦截器中记录访问数据
- 因为每次请求中都要记录UV,DAU,故,可以定义拦截器在请求初去记录这些数据
1. 拦截器定义
request.getRemoteAddr()
- 获取ip地址
- 从请求体中获取ip地址
- 记录活跃用户DAU时,注意先判断是否为登录用户
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
HostHolder hostHolder;
@Autowired
DataService dataService;
// 在controller处理请求前拦截记录
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 记录UV
String ip = request.getRemoteAddr(); // 从请求体中获取ip地址
dataService.addUV(ip);
// 记录DAU
User user = hostHolder.getUser();
if (user != null) { // 边界判断
dataService.addDAU(user.getId());
}
return true;
}
}
2. 拦截器配置
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
四、Controller处理查询请求
1. 显示管理员统计数据页面请求
- 支持get,post请求
- 直接访问是get请求
- 其他网页转发,能够接收其他post请求页面的转发
/**
* 显示统计页面
*/
@RequestMapping(value = "/data", method = {RequestMethod.GET, RequestMethod.POST})
public String getDataPage() {
return "/site/admin/data";
}
2. 查询UV
@DateTimeFormat(pattern = "yyyy-MM-dd")
- 指明参数接收日期的格式
- 为了能在页面显示出选中的起始时间,要将用户提交的数据再传给模板
forward:/data
- 转发
- 当前页只处理前一部分,后面的模板页面渲染交给
"/data"
请求处理 - 但服务器只与当前的url通讯,不认识转发的"/data"请求
/**
* 统计UV
* 要指明接收日期的格式
*/
@RequestMapping(value = "/data/uv", method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd")Date end, Model model) {
long uv = dataService.calculateUV(start, end);
model.addAttribute("uvResult", uv);
// 为了能在页面显示出选中的起始时间,要将用户提交的数据再传给模板
model.addAttribute("uvStartDate", start);
model.addAttribute("uvEndDate", end);
return "forward:/admin/data";
}
3. 查询DAU
/**
* 统计DAU
*/
@RequestMapping(path = "/data/dau", method = RequestMethod.POST)
public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
long dau = dataService.calculateDAU(start, end);
model.addAttribute("dauResult", dau);
model.addAttribute("dauStartDate", start);
model.addAttribute("dauEndDate", end);
return "forward:/admin/data";
}
五、处理模板
1. 渲染UV统计表单
- 统计提交表单
- 返回后,将提交的日期数据默认显示出来
<form class="form-inline mt-3" method="post" th:action="@{/admin/data/uv}">
<input type="date" class="form-control" required name="start" th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}"/>
<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}"/>
<button type="submit" class="btn btn-primary ml-3">开始统计</button>
</form>
- 统计结果显示
<li class="list-group-item d-flex justify-content-between align-items-center">
统计结果
<span class="badge badge-primary badge-danger font-size-14" th:text="${uvResult}">0</span>
</li>
2. 渲染统计DAU表单页面
<!-- 活跃用户 -->
<div class="container pl-5 pr-5 pt-3 pb-3 mt-4">
<h6 class="mt-3"><b class="square"></b> 活跃用户</h6>
<form class="form-inline mt-3" method="post" th:action="@{/admin/data/dau}">
<input type="date" class="form-control" required name="start" th:value="${#dates.format(dauStartDate,'yyyy-MM-dd')}"/>
<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(dauEndDate,'yyyy-MM-dd')}"/>
<button type="submit" class="btn btn-primary ml-3">开始统计</button>
</form>
<ul class="list-group mt-3 mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center">
统计结果
<span class="badge badge-primary badge-danger font-size-14" th:text="${dauResult}">0</span>
</li>
</ul>
六、权限设置
-
权限分配:只有管理员才能访问统计网页的请求
.antMatchers( "/discuss/delete", "/admin/**" ) .hasAnyAuthority(AUTHORITY_ADMIN)
测试结果:
-
非管理员没有权限访问
-
统计独立访客UV
-
统计活跃用户
七、遇到的bug及处理方式
1. 日期格式
问题产生:
private SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
- 之前key中的日期格式设置成功
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
- 结果统计结果全部为0,即统计不出值
问题解决:
-
日期的格式决定redis中key的定义
-
本项目中只需要统计每日的UV、DAU,key也就是序列化到日,故日期格式精确的到日就可
private SimpleDateFormat sf = new SimpleDateFormat("yyyyMMdd");
2. 合并中出现空指针错误
问题产生:
redisTemplate.opsForHyperLogLog().union(UVKey, keyList.toArray());
- 如果页面用户输入的时间起始时间大于终止时间,会取不到key,keyList的size为0,
toArray()
会报错
问题解决
- 作边界判断,注意不是判断
keyList != null
,实例化就一定不为null,但要判断集合元素是否为空
if(!keyList.isEmpty()) {
redisTemplate.opsForHyperLogLog().union(UVKey, keyList.toArray());
}
3. 日期计算出现问题
问题产生:
while (calendar.getTime().before(end)) {
}
- 如果起始时间和终止时间相等时,循环不进行,这样无法查询一天的结果
问题解决:
- 边界条件要包含和终止时间相等的情况
- 故,不在end之后,都满足条件
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAU(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE, 1);
}
4. BitMap数据格式
- 注意:对BitMap的or合并操作,是对位进行操作
- 故,要将String类型的key转为Byte字符数组
- 因此,统计keyList的集合中也要放key的字符数组
List<byte[]> keyList = new ArrayList<>();
if(!keyList.isEmpty()) {
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), keyList.toArray(new byte[0][0]));
}