在业务场景中不免有时候会需要进行在线时长的统计,本文采用模拟心跳的方式实现对用户在线时长的统计:后端进行用户访问数据信息的记录,然后由前端做埋点来定时调用心跳接口
I. 项目结构概述
- module层:服务实现层,调用dao层中实现的各种数据库操作方法实现服务功能
- controller层:定义控制器结构
MonitorController
- request层:定义请求体结构
HeartBeatRequest
- service层:实现各种服务功能,定义
MonitorService
和MonitorServiceImpl
- controller层:定义控制器结构
- dao层:数据库操作实现层,定义了
UserDailyOnlineService
和TbResourceAccVisitService
来实现对应的数据库的CRUD
- enums层:枚举类,定义里
ActionTypeEnum
和ResourceTypeEnum
- model层:定义
HeartBeatDTO
II. 心跳接口的具体实现
以下以一个例子作为范例讲解实现:如果用户两次操作在一分钟内进行则不进行心跳;反之则记录心跳
1. controller实现
MonitorController
中先将HTML接口定义出来,代码如下:
@RestController
@Api(tags = "在线时长统计控制器")
public class MonitorController extends BaseController {
@Autowired
private MonitorService monitorService;
@ApiOperation("心跳接口")
@PostMapping("/monitor/heart-beat")
public JsonResult<Void> heartBeat(@RequestBody @Valid HeartBeatRequest request) {
monitorService.heartBeat(request, UserInfo.getUserInfo());
return okReturn(ReturnCodeEnum.S0002);
}
其中,方法UserInfo.getUserInfo()
用于获取用户信息,其响应体为对象UserInfo
其中可能包含姓名、年龄、职务、办公地点等信息,视具体情况而论
2. request层请求体结构
请求体HeartBeatRequest
包含了HTML请求的内容以及需要存储进数据库的用户操作信息等
@ApiModel(value = "心跳接口请求体")
@Data
@NoArgsConstructors
@AllArgsConstructors
public class HeartBeatRequest {
@ApiModelProperty(value = "应用Id")
private String appClientId;
@ApiModelProperty(value = "操作类型")
private ActionTypeEnum action;
@ApiModelProperty(value = "操作对象类型")
private ResourceTypeEnum resourceType;
@ApiModelProperty(value = "唯一标识")
private String distinctId;
public static TbResourceAccVisit converter2Po(HeartBeatRequest request) {
TbResourceAccVisit visit = new TbResourceAccVisit();
BeanUtils.copyProperties(request, visit);
if (request.getResource() != null) {
visit.setResourceType(request.getResourceType().toString());
}
if (request.getAction() != null) {
visit.setAction(request.getActionType().toString());
}
}
}
其中,各对象类型视具体情况而定;静态方法converter2Po
是用来将请求体转化为Po对象的方法,便于外部调用
3. model层
定义HeartBeatDTO
数据传输对象结构如下
@Data
@NoargsConstructor
public class HeartBeatDTO {
private HeartBeatRequest request;
private UserInfo userInfo;
/**
* 内部上报
*/
private Boolean innerReport = false;
private LocalDateTime receiveTime;
public HeartBeatDTO(HeartBeatRequest request, UserInfo userInfo) {
this.request = request;
this.userInfo = userInfo;
this.receiveTime = LocalDateTime.now();
}
}
4. dao层数据库操作层实现
- UserDailyOnlineDaoServiceImpl:
@Service
public class UserDailyOnlineDaoServiceImpl extends ServiceImpl<UserDailyOnlineMapper, UserDailyOnline> implements UserDailyOnlineDaoService {
@Override
public UserDailyOnline getLatestByUserAndDay(String loginName, localDate onlineDate) {
LambdaQueryWrapper<UserDailyOnline> wrapper= new LambdaQueryWrapper<>();
wrapper.eq(UserDailyOnline::getLoginName, loginName);
wrapper.eq(UserDailyOnline::getOnlineDate, onlineDate);
wrapper.orderByDesc(UserDailyOnline::getCreateTime);
wrapper.last(" limit 1");
return this.getOne(wrapper);
}
- TbResourceAccVisitServiceImpl:
@Service
public class TbResourceAccVisitServiceImpl extends ServiceImpl>TbResourceAccVisitMapper, TbResourceAccVisit> implements TbResourceAccVisitService {
@Override
public TbResourceAccVisit getByUniqueKey(String resourceType, String distinctId) {
LambdaQueryWrapper<TbResourceAccVisit> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TbResourceAccVisit::getResourceType, resourceType);
warpper.eq(TbResourceAccVisit::getDistinctId, distinctId);
return this.getOne(wrapper);
}
@Override
public void increaseByUniqueKey(String resourceType, String distinctId) {
this.baseMapper.increaseByUniqueKey(resourceType, distinctId);
}
}
- TbResourceAccVisitMapper:
@Mapper
public interface TbResourceAccVisitMapper extends BaseMapper<TbResourceAccVisit> {
void increaseByUniqueKey(String resourceType, String distinctId);
}
- TbResourceAccVisitMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//OTD Mapper 3.0//EN" "gttp://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bydquto.mapper.TbResourceAccVisitMapper">
<update id="increaseByUniqueKey">
update tb_resource_acc_visit
set frequency = frequency + 1
where resource_type = #{resourceType} and distinct_id = #{distinctId}
</update>
</mapper>
5. service层实现
首先进行变量初始化以及bean实例注入
private static Logger logger = LoggerFactory.getLogger(MonitorServiceImpl.class);
private LinkedBlockingQueue<HeartBeatDTO> bQueue = new LinkedBlockingQueue<>(1000);
private Cache<String, LocalDateTime> cache = Caffeine.newBuilder().initialCapcity(100).maximumSize(3000).expireAfterWrite(24, TimeUnit.HOURS).build();
@Autowired
private UserDailyOnlineDaoService userDailyOnlineDaoService;
@Autowired
private TbResourceAccVisitService resourceAccVisitService;
其中LinkedBlockingQueue
是给予链表实现的阻塞队列,它是线程安全的,用于将心跳消息逐条入队,依次处理并避免出现数据不同步问题。Caffeine是一个基于Java8
开发的提供了近乎最佳命中率的高性能的缓存库。接下来是心跳信息日志的记录
@Override
public void heartBeat(HeartBeatRequest request, UserInfo userInfo) {
heartBeat(request, userInfo, false);
}
@Override
public void heartBeat(HeartBeatRequest request, UserInfo userInfo, boolean innerReport) {
HeartBeatDTO heartBeatDto = new HeartBeatDTO(request, userInfo);
heartBeatDto.setInnerReport(innerReport);
boolean offer = bQueue.offer(heartBeatDTO);
if (!offer) {
logger.error("heartBeat queue full.user:{}", userInfo.getLoginName());
}
}
接下来是时长具体计算统计部分,首先是重写线程类Thread
@PostConstruct
private void startConsumer() {
ConsumerThreadA thread = new ConsumerThreadA();
thread.setName("heartBeat-consumer");
thread.setDaemon(true);
thread.start();
}
class ConsumerThreadA extends Thread {
@Override
public void run() {
while (true) {
try {
heartBeatAction();
} catch (Exceprion e) {
logger.error("heartBeatAction error {}", e);
}
}
}
}
private void heartBeatAction() throws InterruptedException {
HeartBeatDTO massage = bQueue.take();
logger.info("heartBeatAction message {}", message);
// 根据用户动作类型上报信息
ActionTypeEnum action = message.getRequest().getAction();
if (action != null && ActionTypeEnum.VIEW.equals(action) && BooleanUtil.isTrue(message.getInnerReport())) {
reportViewInfo(message.getRequest());
return;
}
String loginName = message.getRequest().getloginName();
// 查看缓存中该工号上次访问时间,相差一分钟以内则不计算
LocalDateTime lastUpdateTimeCache = cache.getIfPresent(loginName);
if (lastUpdateTimeCache != null && !biggerThan1MinInterval(lastUpdateTimeCache, message.getReceiveTime())) {
logger.info("heartBeatAction cache hit {}", message);
return;
}
cache.put(loginName, message.getReceiveTime());
UserDailyOnline userDailyOnline = userDailyOnlineDaoService.getLatestByUserAndDay(loginName, localDate.now());
if (need2CreateOnlineInterval(userDailyOnline, message.getReceiveTime())) {
userDailyOnlineDaoService.save(UserDailyOnline.build(loginName, message.getReceiveTime(), LocalDate.now()));
} else {
long millis = Duration.between(userDailyOnline.getUpdateTime(), message.getRecieveTime.toMillis();
if (millis > Constant.ONE_MINUTES) {
userDailyOnline.setUpdateTime(mseeage.getReceiveTime());
userDailyOnlineDaoService.updateById(userDailyOnline);
}
}
logger.info("heartBeatAction end!");
}
其中注释@PostConstruct
的功能是:在进程启动之后,会生成一个ConsumerThreadA
并且执行他的.start()
方法,以上为心跳接口的service层核心实现,下面给出service层实现的整体代码,包括自定义的一些工具函数
@Service
@Transactional(rollbackFor = Exception.calss)
public class MonitorServiceImpl implements MonitorService {
private static Logger logger = LoggerFactory.getLogger(MonitorServiceImpl.class);
private LinkedBlockingQueue<HeartBeatDTO> bQueue = new LinkedBlockingQueue<>(1000);
private Cache<String, LocalDateTime> cache = Caffeine.newBuilder().initialCapcity(100).maximumSize(3000).expireAfterWrite(24, TimeUnit.HOURS).build();
@Autowired
private UserDailyOnlineDaoService userDailyOnlineDaoService;
@Autowired
private TbResourceAccVisitService resourceAccVisitService;
@PostConstruct
private void startConsumer() {
ConsumerThreadA thread = new ConsumerThreadA();
thread.setName("heartBeat-consumer");
thread.setDaemon(true);
thread.start();
}
@Override
public void heartBeat(HeartBeatRequest request, UserInfo userInfo) {
heartBeat(request, userInfo, false);
}
@Override
public void heartBeat(HeartBeatRequest request, UserInfo userInfo, boolean innerReport) {
HeartBeatDTO heartBeatDto = new HeartBeatDTO(request, userInfo);
heartBeatDto.setInnerReport(innerReport);
boolean offer = bQueue.offer(heartBeatDTO);
if (!offer) {
logger.error("heartBeat queue full.user:{}", userInfo.getLoginName());
}
}
class ConsumerThreadA extends Thread {
@Override
public void run() {
while (true) {
try {
heartBeatAction();
} catch (Exceprion e) {
logger.error("heartBeatAction error {}", e);
}
}
}
}
private void heartBeatAction() throws InterruptedException {
HeartBeatDTO massage = bQueue.take();
logger.info("heartBeatAction message {}", message);
// 根据用户动作类型上报信息
ActionTypeEnum action = message.getRequest().getAction();
if (action != null && ActionTypeEnum.VIEW.equals(action) && BooleanUtil.isTrue(message.getInnerReport())) {
reportViewInfo(message.getRequest());
return;
}
String loginName = message.getRequest().getloginName();
// 查看缓存中该工号上次访问时间,相差一分钟以内则不计算
LocalDateTime lastUpdateTimeCache = cache.getIfPresent(loginName);
if (lastUpdateTimeCache != null && !biggerThan1MinInterval(lastUpdateTimeCache, message.getReceiveTime())) {
logger.info("heartBeatAction cache hit {}", message);
return;
}
cache.put(loginName, message.getReceiveTime());
UserDailyOnline userDailyOnline = userDailyOnlineDaoService.getLatestByUserAndDay(loginName, localDate.now());
if (need2CreateOnlineInterval(userDailyOnline, message.getReceiveTime())) {
userDailyOnlineDaoService.save(UserDailyOnline.build(loginName, message.getReceiveTime(), LocalDate.now()));
} else {
long millis = Duration.between(userDailyOnline.getUpdateTime(), message.getRecieveTime.toMillis();
if (millis > Constant.ONE_MINUTES) {
userDailyOnline.setUpdateTime(mseeage.getReceiveTime());
userDailyOnlineDaoService.updateById(userDailyOnline);
}
}
logger.info("heartBeatAction end!");
}
private void reportViewInfo(HeartBeatRequest request) {
if (request.getResourceType() || StringUtils.isEmpty(request.getDistinctId())) {
logger.error("reportViewInfo invalid message:{}", request);
return;
}
TbResourcevisitPo = resourceAccVisit.getByUniqueKey(request,getResourceType().toString(), request.getDistinctId());
// 第一次访问
if (visitPo == null) {
TbResourceAccVisit newVisitPo = HeartBeatRequest.converter2Po(request);
newVisitPo.setFrequency(1L);
resourceAccVisitService.save(newVisitPo);
} else {
resourceAccVisitService.increaseByUniqueKey(request.getResourceType.toString(), request.getDistinctId());
}
}
private boolean biggerThan1MinInerval(LocalDateTime t1, LocalDateTime t2) {
return Duration.between(t1, t2).toMillis() > Constant.ONE_MONUTES;
}
private boolean need2CreateOnlineInterval(UserDailyOnline lastUserDailyOnline, LocalDateTime receiveTime) {
if (lastUserDailyOnline) {
return true;
}
long millis = Duration.between(lastUserDailyOnline.getUpdateTime(), receiveTime).toMillis();
return millis > Constant.TEN_MINUTES;
}
}
最后,由前端的同事进行埋点,三五分钟掉一次heartBeat
借口即可记录用户的在线时长
以上就是本文的全部内容,如果有疑问或者纰漏欢迎大家评论以及交流!