在线时长统计方式——模拟心跳


在业务场景中不免有时候会需要进行在线时长的统计,本文采用模拟心跳的方式实现对用户在线时长的统计:后端进行用户访问数据信息的记录,然后由前端做埋点来定时调用心跳接口


I. 项目结构概述

  • module层:服务实现层,调用dao层中实现的各种数据库操作方法实现服务功能
    • controller层:定义控制器结构MonitorController
    • request层:定义请求体结构HeartBeatRequest
    • service层:实现各种服务功能,定义MonitorServiceMonitorServiceImpl
  • dao层:数据库操作实现层,定义了UserDailyOnlineServiceTbResourceAccVisitService来实现对应的数据库的CRUD
  • enums层:枚举类,定义里ActionTypeEnumResourceTypeEnum
  • 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借口即可记录用户的在线时长


以上就是本文的全部内容,如果有疑问或者纰漏欢迎大家评论以及交流!

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值