写在前面,IE对websocket支持的不好,IE11偶尔会断开连接,IE8会出现连不上的现象
业务功能:
项目中有一个踢除重复登录账号的功能。A先登录了admin账号,B后登录了admin账号,那么A将会收到提醒"账号已在其他地点登录",并自动登出。
方案A:
项目内原有的解决方案是以拦截器的方式,检测是否存在相同的账号信息,如果存在则提示用户并登出。
弊端是在B登录后,A必须发出请求,此时拦截器才能进行检测,假设A长时间没有操作,那么将无法进行检测,也就无法做到实时性的提示用户账户已在其他地点登录。
方案B:
之前由于工期问题,解决方案改为每个账户登录成功后,都会在一个公共页面中使用定时器去后台查询账号登录状态,如果登录状态标识为过期,则提示用户并退出。
用户A登录成功后,会在redis中存储用户的信息,其中包含用户的名称、允许登录状态标识、sessionId等。当B在登录成功后,会检测redis中是否存在相同的用户信息,如果存在,判断sessionId是否相同,不同的话表示已有其他用户登录该账户,那么把这条记录中的登录状态标识为过期,并把自己的信息存入redis中,当前端的轮询定时器查到用户A的账户登录状态标识为过期,弹出提示信息,然后自动登出。
弊端:需要前端不断的去后台请求,存在消耗服务器资源的问题,假设几十万用户同时发出请求可想而知服务器面临的资源压力多么紧张。
方案C:
利用websocket双向通讯的特点,一次建立通讯便可以保持长连接,服务器可以主动向客户端发送消息,解决了A和B两种方案的弊端(实时性、节省服务端资源开销)。
①集成websocket
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.1.0.RELEASE</version>
</dependency>
②配置websocket
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
/**
* 注册一个STOMP的endpoint,并指定使用SockJS协议
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/endpointWisely").setAllowedOrigins("*").withSockJS();
}
/**
* 配置消息代理(Message Broker)
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//点对点应配置一个/user消息代理,广播式应配置一个/topic消息代理
registry.enableSimpleBroker("/topic","/user");
//点对点使用的订阅前缀(客户端订阅路径上会体现出来),不设置的话,默认也是/user/
registry.setUserDestinationPrefix("/user");
}
}
③前端代码
这个界面是登录成功后跳转的界面,sessionId从后台传到前台的,用于区分不同的用户
connect方法是与后台建立连接,并通过地址“/user/xxxxx/queue/getResponse“订阅广播,当后台有消息推送到该地址时,在回调中将会收到消息。
/endpointWisely是后台配置的通道地址
为什么是/user开头,因为websocket中定义群发是/topic,点对点通信默认为/user,你也可以自定义为其他字符,/queue/getResponse为自定义的地址,前端和后台保持一致才能进行通信
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>首页</title>
<script src="/jquery-1.11.3.min.js"></script>
<script src="/sockjs.min.js"></script>
<script src="/stomp.min.js"></script>
<script>
$(function(){
connect();
});
var stompClient = null;
function disConnect() {
if (stompClient != null) {
stompClient.disconnect();
}
}
function connect() {
var sessionId = $('#sessionIdHidden').val();
var socket = new SockJS("/endpointWisely");
stompClient = Stomp.over(socket);
stompClient.connect({},function(frame){
stompClient.subscribe("/user/"+sessionId+"/queue/getResponse", function(response){
if (response.body == 'logout') {
disConnect();
alert('当前账号已在其他地点登陆');
location.href = '/login/toLogin';
}
})
})
}
</script>
</head>
<body>
<input type="hidden" id="sessionIdHidden" value="${user.sessionId}"/>
欢迎回来${user.username}!
</body>
</html>
④后台代码
账户检验通过后,会从redis中查找是否存在已登录账号,注意redis的key的组成 “LOGIN-” + 用户名 + "-" + sessionId,利用sessionId来区分是否存在已登录,如果存在,则给对应的客户端主动推送消息。
这里要注意的是如何能够找到对应的客户端,关键点就是sessionId,因为在前端代码中,每次登录成功后,会把后台拿到的sessionId传到前端,前端利用sessionId组装成一个url与后台websocket建立了连接,这样后端只要有相同的sessionId,就可以与前端进行通信了。
simpMessagingTemplate.convertAndSendToUser(sessionId, "/queue/getResponse", "logout");
“logout“为推送的内容,前端获取该值后完成提示用户、登出操作即可。
@Autowired
RedisTemplate redisTemplate;
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
/**
* 登陆
* @param user
* @return
*/
@RequestMapping(value = "/loginSubmit", method = RequestMethod.POST)
public String loginSubmit(User user, HttpServletRequest request, Model model) {
if (!"admin".equals(user.getUsername())) {
return "用户不存在";
}
if (!"admin".equals(user.getPassword())) {
return "密码错误";
}
String searchKey = "LOGIN-" + user.getUsername() + "-" + "*";
String loginKey = "LOGIN-" + user.getUsername() + "-" + request.getRequestedSessionId();
if (redisTemplate.opsForValue().get(loginKey) == null) {
// 存储当前用户信息
HashMap<String,Object> loginInfoMap = new HashMap<>(16);
loginInfoMap.put("loginTime", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
loginInfoMap.put("userName", user.getUsername());
loginInfoMap.put("allowLogin", true);
redisTemplate.opsForValue().set(loginKey, loginInfoMap);
// 查询是否存在其他已登陆用户
Set loginSet = redisTemplate.keys(searchKey);
if (!loginSet.isEmpty()) {
for (Object key: loginSet) {
if (!key.equals(loginKey)) {
// 删除key
redisTemplate.delete(key);
// 推送消息到客户端
String sessionId = String.valueOf(key).split("-")[2];
simpMessagingTemplate.convertAndSendToUser(sessionId, "/queue/getResponse", "logout");
}
}
}
}
user.setSessionId(request.getRequestedSessionId());
model.addAttribute("user", user);
return "hello";
}
nginx需要配置一下才能够支持websocket的通讯,http、https都可以
proxy_read_timeout 设置超时,避免websocket短时间内因为没有通讯就自动关闭
location /c/ {
proxy_pass http://127.0.0.1:8002;
# WebScoket Support
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
......
}
参考文献
https://blog.csdn.net/ouyzc/article/details/79884688
https://www.zhihu.com/question/20215561