https://www.cnblogs.com/jonban/p/10391339.html
优点:异步推送消息只要客户端发送异步请求就可以,不依赖客户端版本,不存在浏览器兼容问题。
一、 主要讲解技术点,异步实现服务器推送消息
二、 项目示例,聊天会话功能,主要逻辑如下:
由Logan向 Charles 发送消息,如果Charles在线,则直接发送,否则存储为离线消息。
Charles 登录后向服务端发请求获取消息,首先查询离线消息,如果有消息直接返回。没有消息则等待。
由于长时间没有消息推送,等待会超时,所以设置超时异常通知,超时则返回空内容到客户端,由客户端再次发送获取消息请求,解决超时问题。
建议先复制项目到本地工程,边测试边理解。
项目示例如下:
1. 新建Maven项目 async-push
2. pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.java</groupId>
<artifactId>async-push</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.5.RELEASE</version>
</parent>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
<!-- 热部署 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>springloaded</artifactId>
<version>1.2.8.RELEASE</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
3. AsyncPushStarter.java
package com.java;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 主启动类
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@SpringBootApplication
public class AsyncPushStarter {
public static void main(String[] args) {
SpringApplication.run(AsyncPushStarter.class, args);
}
}
4. SendMessageVo.java
package com.java.vo;
/**
* 发送消息封装体
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
public class SendMessageVo {
/**
* 发送目标ID
*/
private String targetId;
/**
* 发送消息内容
*/
private String content;
public String getTargetId() {
return targetId;
}
public void setTargetId(String targetId) {
this.targetId = targetId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
@Override
public String toString() {
return "SendMessageVo [targetId=" + targetId + ", content=" + content + "]";
}
}
5. PushMessageVo.java
package com.java.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 推送消息封装体
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
public class PushMessageVo {
/**
* 发送人ID,即消息来源
*/
private String srcId;
/**
* 发送消息内容
*/
private String content;
/**
* 发送时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date sendTime;
public String getSrcId() {
return srcId;
}
public void setSrcId(String srcId) {
this.srcId = srcId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Date getSendTime() {
return sendTime;
}
public void setSendTime(Date sendTime) {
this.sendTime = sendTime;
}
@Override
public String toString() {
return "PushMessageVo [srcId=" + srcId + ", content=" + content + ", sendTime=" + sendTime + "]";
}
}
6. MessagePool.java
package com.java.pool;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.async.DeferredResult;
import com.java.vo.PushMessageVo;
/**
* 消息池,存放所有消息
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@Component
public class MessagePool {
private Map<String, DeferredResult<List<PushMessageVo>>> messagePool = new HashMap<>();
public void put(String targetId, DeferredResult<List<PushMessageVo>> result) {
messagePool.put(targetId, result);
}
public DeferredResult<List<PushMessageVo>> get(String targetId) {
return messagePool.get(targetId);
}
}
7. OfflineMessagePool.java
package com.java.pool;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.java.vo.PushMessageVo;
/**
* 离线消息池
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@Component
public class OfflineMessagePool {
private Map<String, List<PushMessageVo>> offlineMessagePool = new HashMap<>();
/**
* 增加一条待发送消息
*
* @param targetId 发送目标ID
* @param message 推送消息体
*/
public void add(String targetId, PushMessageVo message) {
List<PushMessageVo> list = offlineMessagePool.get(targetId);
if (null == list) {
list = new ArrayList<>();
offlineMessagePool.put(targetId, list);
}
list.add(message);
}
/**
* 获取所有待发送消息
*
* @param targetId 发送目标ID
* @return 发送目标对应的所有待发送消息
*/
public List<PushMessageVo> get(String targetId) {
List<PushMessageVo> list = offlineMessagePool.get(targetId);
// 如果存在,则移除后返回
if (null != list) {
offlineMessagePool.remove(targetId);
}
return list;
}
}
8. MessageController.java
package com.java.controller;
import java.security.Principal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import com.java.pool.MessagePool;
import com.java.pool.OfflineMessagePool;
import com.java.vo.PushMessageVo;
import com.java.vo.SendMessageVo;
/**
* 发送接收消息接口类
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@RestController
public class MessageController {
private static final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Autowired
private MessagePool messagePool;
@Autowired
private OfflineMessagePool offlineMessagePool;
@PostMapping("/sentMessage")
public Map<String, Object> sentMessage(Principal principal, SendMessageVo sendMessage) {
PushMessageVo pushMessage = new PushMessageVo();
pushMessage.setSrcId(principal.getName());
pushMessage.setContent(sendMessage.getContent());
pushMessage.setSendTime(new Date());
System.out.println(sendMessage);
System.out.println(pushMessage);
DeferredResult<List<PushMessageVo>> deferredResult = messagePool.get(sendMessage.getTargetId());
// 如果未上线,存到离线消息池中
if (null == deferredResult) {
offlineMessagePool.add(sendMessage.getTargetId(), pushMessage);
}
// 直接推送消息给目标ID
else {
List<PushMessageVo> list = new ArrayList<>();
list.add(pushMessage);
deferredResult.setResult(list);
}
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("sendTime", format.format(pushMessage.getSendTime()));
return result;
}
@GetMapping("/getMessage")
public DeferredResult<List<PushMessageVo>> getMessage(Principal principal) {
DeferredResult<List<PushMessageVo>> result = new DeferredResult<>();
// 先取出未推送的离线消息
List<PushMessageVo> list = offlineMessagePool.get(principal.getName());
// 如果有离线消息,直接返回
if (null != list) {
result.setResult(list);
}
// 否则等待接收新消息
else {
messagePool.put(principal.getName(), result);
}
return result;
}
}
9. ControllerExceptionHandler.java
package com.java.advice;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import com.java.vo.PushMessageVo;
/**
* 捕获异步超时异常,并进行处理
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@ControllerAdvice
public class ControllerExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(ControllerExceptionHandler.class);
@ResponseBody
@ExceptionHandler(AsyncRequestTimeoutException.class)
public List<PushMessageVo> handleAsyncRequestTimeoutException(AsyncRequestTimeoutException e) {
logger.info("处理异步超时异常");
// 异步超时返回一个空集合,由前端继续发请求
List<PushMessageVo> list = new ArrayList<>();
return list;
}
}
下面是安全登录相关配置
10. ApplicationContextConfig.java
package com.java.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 配置文件类
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@Configuration
public class ApplicationContextConfig {
/**
* 配置密码编码器,Spring Security 5.X必须配置,否则登录时报空指针异常
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
11. LoginConfig.java
package com.java.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/**
* 登录相关配置
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@Configuration
public class LoginConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 设置不需要授权的请求
.antMatchers("/js/*", "/login.html").permitAll()
// 其它任何请求都需要验证权限
.anyRequest().authenticated()
// 设置自定义表单登录页面
.and().formLogin().loginPage("/login.html")
// 设置登录验证请求地址为自定义登录页配置action ("/login/form")
.loginProcessingUrl("/login/form")
// 设置默认登录成功跳转页面
.defaultSuccessUrl("/main.html")
// 暂时停用csrf,否则会影响验证
.and().csrf().disable();
}
}
12. SecurityUserDetailsService.java
package com.java.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* UserDetailsService实现类
*
* @author Logan
* @createDate 2019-02-17
* @version 1.0.0
*
*/
@Component
public class SecurityUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 数据库存储密码为加密后的密文(明文为123456)
String password = passwordEncoder.encode("123456");
System.out.println("username: " + username);
System.out.println("password: " + password);
// 模拟查询数据库,获取属于Admin和Normal角色的用户
User user = new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("Admin,Normal"));
return user;
}
}
13. 静态资源文件如下
static/login.html
static/main.html
static/js/jquery-3.3.1.min.js

14. login.html
<!DOCTYPE html>
<html>
<head>
<title>登录</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
</head>
<body>
<!--登录框-->
<div align="center">
<h2>用户自定义登录页面</h2>
<fieldset style=" 300px;">
<legend>登录框</legend>
<form action="/login/form" method="post">
<table>
<tr>
<th>用户名:</th>
<td><input name="username" value="Logan" /> </td>
</tr>
<tr>
<th>密码:</th>
<td><input type="password" name="password" value="123456" /> </td>
</tr>
<tr>
<th></th>
<td></td>
</tr>
<tr>
<td colspan="2" align="center"><button type="submit">登录</button></td>
</tr>
</table>
</form>
</fieldset>
</div>
</body>
</html>
15. main.html
<!DOCTYPE html>
<html>
<head>
<title>首页</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<script type="text/javascript" src="js/jquery-3.3.1.min.js"></script>
<style>
body,
div {
margin: 0;
padding: 0;
}
</style>
<script>
$(function() {
getMessage();
$("#content").keydown(function(event) {
if(event.keyCode == 13) {
sendMessage();
}
});
});
function getMessage() {
$.get("/getMessage", function(data) {
for(var i = 0; i < data.length; i++) {
var msg = data[i];
/* 设置发送目标为消息来源的人,方便回复消息 */
$("#targetId").val(msg.srcId);
showMessage(msg.srcId, msg.sendTime, msg.content);
}
getMessage();
});
}
function sendMessage() {
var targetId = $("#targetId").val().trim();
if(!targetId) {
alert("未填写消息接收人!");
$("#targetId").focus();
return;
}
/*消息内容不做任何处理,只要不为空就发送*/
var content = $("#content").html();
if(!content) {
$("#content").focus();
return;
}
/*发送消息*/
$.post("/sentMessage", {
targetId: targetId,
content: content
}, function(data) {
if(data.success) {
$("#content").empty();
showMessage("我", data.sendTime, content);
}
});
}
function showMessage(srcId, sendTime, content) {
var title = '<span style="color: green;">' + srcId + ' ' + sendTime + '</span>';
var content = '<div style="padding-left: 10px;">' + content + '</div>';
$("#showMessage").append(title).append(content).append("<br />");
/* 设置滚动条自动翻滚 */
$("#showMessage").scrollTop($("#showMessage")[0].scrollHeight);
}
</script>
</head>
<body>
<div align="center">
<div style="margin: 30px 0px;">
发送给:<input id="targetId" name="targetId" value="Charles" placeholder="消息接收人" />
</div>
<!--消息框-->
<div style=" 600px;height: 500px;position: relative;">
<!--消息展示框-->
<div id="showMessage" style="border: cornflowerblue solid 2px;height: 300px;text-align: left;overflow: auto;">
</div>
<!--隔离条-->
<div style="height: 5px; "></div>
<!--消息发送框-->
<div id="content" contenteditable="true" style="border: cornflowerblue solid 2px;height: 150px;text-align: left;">
</div>
<!--发送按钮-->
<div style="position: absolute;bottom: 0px; right: 10px;">
<button onclick="sendMessage()">发送</button>
</div>
</div>
</div>
</body>
</html>
16. js/jquery-3.3.1.min.js 可在官网下载
https://code.jquery.com/jquery-3.3.1.min.js
http://code.jquery.com/jquery-3.3.1.min.js
17. 运行 AsyncPushStarter.java , 启动测试
浏览器输入首页 http://localhost:8080/main.html
地址栏自动跳转到登录页面,如下:

输入如下信息:
User:Logan
Password:123456
单击【登录】按钮,自动跳转到首页。

输入信息,发送给 Charles
换用其它浏览器,输入 http://localhost:8080/main.html
自动跳转到登录页面,如下:

输入如下信息
User:Charles
Password:123456
用户名一定要是 Charles,否则收不到来自Logen的消息
单击【登录】按钮,自动跳转到首页。

自动接收来自Logan的离线消息。
输入内容回复,在Logan登录的浏览器会自动收到回复,如下所示

双方消息显示内容和时间完全一直,角色互换。
功能正常运行
