优点:异步推送消息只要客户端发送异步请求就可以,不依赖客户端版本,不存在浏览器兼容问题。
一、 主要讲解技术点,异步实现服务器推送消息
二、 项目示例,聊天会话功能,主要逻辑如下:
由Logan向 Charles 发送消息,如果Charles在线,则直接发送,否则存储为离线消息。
Charles 登录后向服务端发请求获取消息,首先查询离线消息,如果有消息直接返回。没有消息则等待。
由于长时间没有消息推送,等待会超时,所以设置超时异常通知,超时则返回空内容到客户端,由客户端再次发送获取消息请求,解决超时问题。
建议先复制项目到本地工程,边测试边理解。
项目示例如下:
1. 新建Maven项目 async-push
2. pom.xml
4.0.0 com.java async-push 1.0.0 org.springframework.boot spring-boot-starter-parent 2.0.5.RELEASE org.springframework.boot spring-boot-starter-web org.springframework.cloud spring-cloud-starter-oauth2 2.0.0.RELEASE org.springframework springloaded 1.2.8.RELEASE provided org.springframework.boot spring-boot-devtools provided ${project.artifactId} org.apache.maven.plugins maven-compiler-plugin 1.8 1.8 UTF-8 org.springframework.boot spring-boot-maven-plugin repackage
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 * */@SpringBootApplicationpublic 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 * */@Componentpublic class MessagePool { private Map>> messagePool = new HashMap<>(); public void put(String targetId, DeferredResult
> result) { messagePool.put(targetId, result); } public DeferredResult
> 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 * */@Componentpublic class OfflineMessagePool { private Map> offlineMessagePool = new HashMap<>(); /** * 增加一条待发送消息 * * @param targetId 发送目标ID * @param message 推送消息体 */ public void add(String targetId, PushMessageVo message) { List list = offlineMessagePool.get(targetId); if (null == list) { list = new ArrayList<>(); offlineMessagePool.put(targetId, list); } list.add(message); } /** * 获取所有待发送消息 * * @param targetId 发送目标ID * @return 发送目标对应的所有待发送消息 */ public List get(String targetId) { List 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 * */@RestControllerpublic 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 MapsentMessage(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
> deferredResult = messagePool.get(sendMessage.getTargetId()); // 如果未上线,存到离线消息池中 if (null == deferredResult) { offlineMessagePool.add(sendMessage.getTargetId(), pushMessage); } // 直接推送消息给目标ID else { List list = new ArrayList<>(); list.add(pushMessage); deferredResult.setResult(list); } Map result = new HashMap<>(); result.put("success", true); result.put("sendTime", format.format(pushMessage.getSendTime())); return result; } @GetMapping("/getMessage") public DeferredResult
> getMessage(Principal principal) { DeferredResult
> result = new DeferredResult<>(); // 先取出未推送的离线消息 List 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 * */@ControllerAdvicepublic class ControllerExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(ControllerExceptionHandler.class); @ResponseBody @ExceptionHandler(AsyncRequestTimeoutException.class) public ListhandleAsyncRequestTimeoutException(AsyncRequestTimeoutException e) { logger.info("处理异步超时异常"); // 异步超时返回一个空集合,由前端继续发请求 List 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 * */@Configurationpublic 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 * */@Configurationpublic 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 * */@Componentpublic 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
登录 用户自定义登录页面
15. main.html
首页 发送给:
16. js/jquery-3.3.1.min.js 可在官网下载
17. 运行 AsyncPushStarter.java , 启动测试
浏览器输入首页
地址栏自动跳转到登录页面,如下:
输入如下信息:
User:Logan
Password:123456
单击【登录】按钮,自动跳转到首页。
输入信息,发送给 Charles
换用其它浏览器,输入
自动跳转到登录页面,如下:
输入如下信息
User:Charles
Password:123456
用户名一定要是 Charles,否则收不到来自Logen的消息
单击【登录】按钮,自动跳转到首页。
自动接收来自Logan的离线消息。
输入内容回复,在Logan登录的浏览器会自动收到回复,如下所示
双方消息显示内容和时间完全一直,角色互换。
功能正常运行
.