异步实现服务器推送消息(聊天功能示例)

 

优点:异步推送消息只要客户端发送异步请求就可以,不依赖客户端版本,不存在浏览器兼容问题。 

 

一、 主要讲解技术点,异步实现服务器推送消息

二、 项目示例,聊天会话功能,主要逻辑如下:

    由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 + '&nbsp;&nbsp;' + 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; background-color: darkgray;"></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登录的浏览器会自动收到回复,如下所示

 

双方消息显示内容和时间完全一直,角色互换。

 

功能正常运行

 

 

.

posted @ 2019-02-17 15:23  诚信天下  阅读(1732)  评论(0编辑  收藏  举报