(转)异步实现服务器推送消息(聊天功能示例)
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="width: 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="width: 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登录的浏览器会自动收到回复,如下所示
双方消息显示内容和时间完全一直,角色互换。
功能正常运行