SpringBoot

您所在的位置:网站首页 java如何实现心跳检测接口设置 SpringBoot

SpringBoot

2024-07-12 12:15| 来源: 网络整理| 查看: 265

在社交网站中,通常需要实时统计某个网站的在线人数,通过该指标来实时帮助运营人员更好的维护网站业务:

先说一下目前在市面上主流的做法再加上我自己查阅的资料总结:

创建一个session监听器,在用户登录时即创建一个session,监听器记录下来并且把count加一用户点击注销时把session给remove掉,count减一

说一下上面这种做法的弊端:

当用户关闭浏览器时并不会触发session监听,当下一次登录时仍然会让count加一或者在session过期时,session监听并不能做一个实时的响应去将在线数减一当用户在次登陆,由于cookie中含有的session_id不同而导致session监听器记录下session创建,而使count加一。对服务器性能影响较大,用户每次访问网站时,服务端都会创建一个session,并将该session与用户关联起来,这样会增加服务器的负担,特别是在高并发的时候,导致服务器压力过大容易被恶意攻击,攻击者不断发送ddox请求大量创建肉鸡用户,从而大量占据服务器资源,从而崩坏分布式环境下不好操作

在网上找了很多博客看,发现好多都是在瞎几把写,没看到什么好一点的方案,经过查阅资料,总结如下一个方案算是比较好的:

使用用户登录凭证:token机制+心跳机制实现

用户登录机制时序图如下

在这里插入图片描述

实现思路: 根据时序图的这套方案,用户如果60s内没有任何操作(不调用接口去传递token)则判定该用户为下线状态,当用户重新登陆或者再次操作网站则判定为在线状态,对用户的token进行续期。这其实是心跳机制思想的一种实现,类似于Redis集群中的哨兵对Master主观下线的过程:每10s对Master发送一个心跳包,10s内没有响应则说明Master已经下线了。这里采用的是60s作为一个生存值,如果60s内该用户没有在此页面(如果在此页面,前端会间隔10s发送一次心跳包对Token进行续期+60s过期时间)上执行任何操作,也就不会携带Token发送请求到后端接口中,那么就无法给map中的token过期时间续期,所以该用户就处于过期状态。

代码实现:

1.新建sp项目,导入如下pom.xml

4.0.0 com.cd springboot-Comprehensive business 1.0-SNAPSHOT org.springframework.boot spring-boot-starter-parent 2.1.5.RELEASE org.springframework.boot spring-boot-starter-web org.projectlombok lombok true org.springframework.boot spring-boot-starter-data-redis com.alibaba fastjson 1.2.33 io.jsonwebtoken jjwt 0.9.0 cn.hutool hutool-all 5.6.3 org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.4 com.alibaba druid 1.1.19 mysql mysql-connector-java runtime

2.编写配置文件

server.port=9999 spring.datasource.username=root spring.datasource.password=root spring.datasource.type=com.alibaba.druid.pool.DruidDataSource spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://192.168.84.135:3307/resource-manage?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai mybatis.type-aliases-package=com.cd.pojo #配置redis spring.redis.database=11 spring.redis.host=127.0.0.1 spring.redis.port=6379

3.定义一个类,用户统计用户的在线人数等操作

@Component public class OnlineCounter { /** * 每次打开此类是该属性只初始化一次 */ private static Map countMap = new ConcurrentHashMap(); /** * 当一个用户登录时,就往map中构建一个k-v键值对 * k- 用户名,v 当前时间+过期时间间隔,这里以60s为例子 * 如果用户在过期时间间隔内频繁对网站进行操作,那摩对应 * 她的登录凭证token的有效期也会一直续期,因此这里使用用户名作为k可以覆盖之前 * 用户登录的旧值,从而不会出现重复统计的情况 */ public void insertToken(String userName){ long currentTime = System.currentTimeMillis(); countMap.put(userName,currentTime+60*1000); } /** * 当用户注销登录时,将移除map中对应的键值对 * 避免当用户下线时,该计数器还错误的将该用户当作 * 在线用户进行统计 * @param userName */ public void deleteToken(String userName){ countMap.remove(userName); } /** * 统计用户在线的人数 * @return */ public Integer getOnlineCount(){ int onlineCount = 0; Set nameList = countMap.keySet(); long currentTime = System.currentTimeMillis(); for (String name : nameList) { Long value = (Long) countMap.get(name); if (value > currentTime){ // 说明该用户登录的令牌还没有过期 onlineCount++; } } return onlineCount; } }

4.一般在前后分离项目中,都是有统一返回数据格式的,以及一些项目通用配置

/** * 统一响应结果 * @param */ public class ResponseResult { /** * 状态码 */ private Integer code; /** * 提示信息,如果有错误时,前端可以获取该字段进行提示 */ private String msg; /** * 查询到的结果数据, */ private T data; public ResponseResult(Integer code, String msg) { this.code = code; this.msg = msg; } public ResponseResult(Integer code, T data) { this.code = code; this.data = data; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } public ResponseResult(Integer code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; } }

redis序列化配置

@Configuration public class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){ RedisTemplate redisTemplate = new RedisTemplate(); // key 序列化方式 redisTemplate.setKeySerializer(new StringRedisSerializer()); // value 序列化 redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // hash 类型 key序列化 redisTemplate.setHashKeySerializer(new StringRedisSerializer()); // hash 类型 value序列化方式 redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 注入连接工厂 redisTemplate.setConnectionFactory(redisConnectionFactory); // 让设置生效 redisTemplate.afterPropertiesSet(); return redisTemplate; } }

全局异常配置

package com.cd.exception; import com.cd.common.ResponseResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(RuntimeException.class) public ResponseResult handleRuntimeException(RuntimeException e) { logger.error(e.toString(), e); return new ResponseResult(400,e.getMessage()); } }

线程隔离工具类

package com.cd.util; import com.cd.pojo.User; import org.springframework.stereotype.Component; /** * 线程隔离,用于替代session */ @Component public class HostHolder { private ThreadLocal users = new ThreadLocal(); public void setUser(User user) { users.set(user); } public User getUser() { return users.get(); } public void clear() { users.remove(); } }

jwt工具类

package com.cd.util; import cn.hutool.core.lang.UUID; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; import java.util.Date; public class JwtUtil { //有效期为 public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时 //设置秘钥明文 public static final String JWT_KEY = "sangeng"; public static String getUUID(){ String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } /** * 生成jtw * @param subject token中要存放的数据(json格式) * @return */ public static String createJWT(String subject) { JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间 return builder.compact(); } /** * 生成jtw * @param subject token中要存放的数据(json格式) * @param ttlMillis token超时时间 * @return */ public static String createJWT(String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间 return builder.compact(); } private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; SecretKey secretKey = generalKey(); long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); if(ttlMillis==null){ ttlMillis=JwtUtil.JWT_TTL; } long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); return Jwts.builder() .setId(uuid) //唯一的ID .setSubject(subject) // 主题 可以是JSON数据 .setIssuer("sg") // 签发者 .setIssuedAt(now) // 签发时间 .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥 .setExpiration(expDate); } /** * 创建token * @param id * @param subject * @param ttlMillis * @return */ public static String createJWT(String id, String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间 return builder.compact(); } public static void main(String[] args) throws Exception { String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg"; Claims claims = parseJWT(token); System.out.println(claims); } /** * 生成加密后的秘钥 secretKey * @return */ public static SecretKey generalKey() { byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } /** * 解析 * * @param jwt * @return * @throws Exception */ public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }

有时候我们需要在响应流中设置返回数据,因此有如下工具类

package com.cd.util; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class WebUtils { /** * 将字符串渲染到客户端 * * @param response 渲染对象 * @param string 待渲染的字符串 * @return null */ public static String renderString(HttpServletResponse response, String string) { try { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(string); } catch (IOException e) { e.printStackTrace(); } return null; } } 我们这里可以使用springboot的拦截器来拦截需要登录后才能操作的接口,操作这些接口就代表的当前用户属于登录状态,因此需要给用户的登录凭证也就是token续期,对应的往map中添加用户的过期时间来进行覆盖之前的,这样就不会出现同一个用户出现重复统计的情况

配置拦截器

@Component public class LoginInteceptor implements HandlerInterceptor { @Autowired private OnlineCounter onlineCounter; @Autowired private RedisTemplate redisTemplate; @Autowired private HostHolder hostHolder; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.获取请求头中的token String token = request.getHeader("authorization"); if (StringUtils.isEmpty(token)){ ResponseResult responseResult = new ResponseResult(400,"未携带请求头信息,不合法"); String jsonStr = JSONUtil.toJsonStr(responseResult); WebUtils.renderString(response,jsonStr); return false; } User user =(User) redisTemplate.opsForValue().get(token); if (Objects.isNull(user)){ ResponseResult responseResult = new ResponseResult(403,"token过期,请重新登录"); String jsonStr = JSONUtil.toJsonStr(responseResult); WebUtils.renderString(response,jsonStr); return false; } // 当请求执行到此处,说明当前token是有效的,对token续期 redisTemplate.opsForValue().set(token,user,60, TimeUnit.SECONDS); // 在本次请求中持有当前用户,方便业务使用 hostHolder.setUser(user); // 覆盖之前的map统计时间,使用最新的token有效期时长 onlineCounter.insertToken(user.getName()); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 释放前挡用户,防止内存泄露 hostHolder.clear(); } }

使拦截器生效

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private LoginInteceptor loginInteceptor; /** * 配置拦截哪些请求 * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInteceptor) .excludePathPatterns("/login","/online"); // 不拦截这些资源 } }

数据库创建两个用户,这里直接展示类,数据库字段就不展示了,对象关系映射即可: 在这里插入图片描述

对应接口层如下:

@RestController public class HelloController { @Autowired private Userservice userservice; /** * 该接口需要登录后才能操作 * @return */ @RequestMapping("/user/list") public ResponseResult hello(){ return userservice.selectUserList(); } /** * 登录 * @param loginParam * @return */ @PostMapping("/login") public ResponseResult login(@RequestBody LoginParam loginParam){ return userservice.login(loginParam); } /** * 退出登录 * @param request * @return */ @PostMapping("/logout") public ResponseResult logout(HttpServletRequest request){ return userservice.logout(request); } /** * 获取当前在线人数 * 这个就相当于一个心跳检查机制 * 前端每间隔一定时间就请求一下该接口达到在线人数 * @return */ @PostMapping("/online") public ResponseResult getOnLineCount(){ return userservice.getOnLineCount(); } }

对应业务层

@Service public class UserviceImpl implements Userservice { private static final Logger logger = LoggerFactory.getLogger(UserviceImpl.class); @Autowired private UserMapper userMapper; @Autowired private RedisTemplate redisTemplate; @Autowired private OnlineCounter onlineCounter; /** * 用户登录 * @param loginParam * @return */ @Override public ResponseResult login(LoginParam loginParam) { String name = loginParam.getName(); User user = userMapper.selectByName(name); if (Objects.isNull(user)){ throw new RuntimeException("用户名或者密码不正确"); } String token = UUID.randomUUID().toString().replaceAll("-", ""); logger.info("当前账号对应的token是: {}",token); redisTemplate.opsForValue().set(token,user,60, TimeUnit.SECONDS); // 往map中添加一条用户记录 onlineCounter.insertToken(name); return new ResponseResult(200,"登录成功"); } /** * 退出登录 * 需要先有登录才能有退出 * @return */ @Override public ResponseResult logout(HttpServletRequest request) { String authorization = request.getHeader("authorization"); User user = (User) redisTemplate.opsForValue().get(authorization); redisTemplate.delete(authorization); onlineCounter.deleteToken(user.getName()); return new ResponseResult(200,"退出成功"); } /** * 需要登录才能操作 * 获取所有用户列表 * @return */ @Override public ResponseResult selectUserList() { List userList = userMapper.selectList(); return new ResponseResult(200,"获取列表成功",userList); } /** * 不需登录 * 获取当前在线人数 * @return */ @Override public ResponseResult getOnLineCount() { Integer onlineCount = onlineCounter.getOnlineCount(); return new ResponseResult(200,"ok",onlineCount); } }

测试:

未登录时去操作需要登录的接口或者token过期了: 在这里插入图片描述 这个时候网站的在线人数: 在这里插入图片描述 登录后: 在这里插入图片描述 这时候再去请求需要登录才能访问的接口: 在这里插入图片描述 可以看到成功访问了,并且该用户的token会一直续期

获取当前在线人数:

在这里插入图片描述

大功告成



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3