WebSocket介绍

WebSocket介绍  

一、为什么需要 WebSocket? 

       初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?

       答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。

       举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。

      这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用"轮询":每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。

      轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。

 

二、简介                                      

     WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。

    它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

其他特点包括:

(1)建立在 TCP 协议之上,服务器端的实现比较容易。

(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

(3)数据格式比较轻量,性能开销小,通信高效。

(4)可以发送文本,也可以发送二进制数据。

(5)没有同源限制,客户端可以与任意服务器通信。

(6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

 

 三.WebSocket 的作用                 

        其实上面已经讲了它的优点了,不过最近看知乎看到一段有关WebSocket挺有意义的,所以复制来。

      在讲Websocket之前,我就顺带着讲下 long poll 和 ajax轮询 的原理。
     首先是 ajax轮询 ,ajax轮询 的原理非常简单,让浏览器隔个几秒就发送一次请求,询问服务器是否有新信息。
场景再现:
客户端:啦啦啦,有没有新信息(Request)
服务端:没有(Response)
客户端:啦啦啦,有没有新信息(Request)
服务端:没有。。(Response)
客户端:啦啦啦,有没有新信息(Request)
服务端:你好烦啊,没有啊。。(Response)
客户端:啦啦啦,有没有新消息(Request)
服务端:好啦好啦,有啦给你。(Response)
客户端:啦啦啦,有没有新消息(Request)
服务端:。。。。。没。。。。没。。。没有(Response) ---- loop

long poll 
long poll 其实原理跟 ajax轮询 差不多,都是采用轮询的方式,不过采取的是阻塞模型(一直打电话,没收到就不挂电话),也就是说,客户端发起连接后,如果没消息,就一直不返回Response给客户端。直到有消息才返回,返回完之后,客户端再次建立连接,周而复始。
场景再现
客户端:啦啦啦,有没有新信息,没有的话就等有了才返回给我吧(Request)
服务端:额。。 等待到有消息的时候。。来 给你(Response)
客户端:啦啦啦,有没有新信息,没有的话就等有了才返回给我吧(Request) -loop

从上面可以看出其实这两种方式,都是在不断地建立HTTP连接,然后等待服务端处理,可以体现HTTP协议的另外一个特点,被动性
何为被动性呢,其实就是,服务端不能主动联系客户端,只能有客户端发起。
简单地说就是,服务器是一个很懒的冰箱(这是个梗)(不会、不能主动发起连接),但是上司有命令,如果有客户来,不管多么累都要好好接待。

说完这个,我们再来说一说上面的缺陷(原谅我废话这么多吧OAQ)
从上面很容易看出来,不管怎么样,上面这两种都是非常消耗资源的。
ajax轮询 需要服务器有很快的处理速度和资源。(速度)
long poll 需要有很高的并发,也就是说同时接待客户的能力。(场地大小)

 

     通过上面这个例子,我们可以看出,这两种方式都不是最好的方式,需要很多资源。
一种需要更快的速度,一种需要更多的'电话'。这两种都会导致'电话'的需求越来越高。
哦对了,忘记说了HTTP还是一个无状态协议。(感谢评论区的各位指出OAQ)
通俗的说就是,服务器因为每天要接待太多客户了,是个健忘鬼,你一挂电话,他就把你的东西全忘光了,把你的东西全丢掉了。你第二次还得再告诉服务器一遍。

所以在这种情况下出现了,Websocket出现了。
他解决了HTTP的这几个难题。
首先,被动性,当服务器完成协议升级后(HTTP->Websocket),服务端就可以主动推送信息给客户端啦。
所以上面的情景可以做如下修改。
客户端:啦啦啦,我要建立Websocket协议,需要的服务:chat,Websocket协议版本:17(HTTP Request)
服务端:ok,确认,已升级为Websocket协议(HTTP Protocols Switched)
客户端:麻烦你有信息的时候推送给我噢。。
服务端:ok,有的时候会告诉你的。
服务端:balabalabalabala
服务端:balabalabalabala
服务端:哈哈哈哈哈啊哈哈哈哈
服务端:笑死我了哈哈哈哈哈哈哈

就变成了这样,只需要经过一次HTTP请求,就可以做到源源不断的信息传送了。(在程序设计中,这种设计叫做回调,即:你有信息了再来通知我,而不是我傻乎乎的每次跑来问你)
这样的协议解决了上面同步有延迟,而且还非常消耗资源的这种情况。
那么为什么他会解决服务器上消耗资源的问题呢?
其实我们所用的程序是要经过两层代理的,即HTTP协议在Nginx等服务器的解析下,然后再传送给相应的Handler(PHP等)来处理。
简单地说,我们有一个非常快速的接线员(Nginx),他负责把问题转交给相应的客服(Handler)
本身接线员基本上速度是足够的,但是每次都卡在客服(Handler)了,老有客服处理速度太慢。,导致客服不够。
Websocket就解决了这样一个难题,建立后,可以直接跟接线员建立持久连接,有信息的时候客服想办法通知接线员,然后接线员在统一转交给客户。
这样就可以解决客服处理速度过慢的问题了。

同时,在传统的方式上,要不断的建立,关闭HTTP协议,由于HTTP是非状态性的,每次都要重新传输identity info(鉴别信息),来告诉服务端你是谁。
虽然接线员很快速,但是每次都要听这么一堆,效率也会有所下降的,同时还得不断把这些信息转交给客服,不但浪费客服的处理时间,而且还会在网路传输中消耗过多的流量/时间。
但是Websocket只需要一次HTTP握手,所以说整个通讯过程是建立在一次连接/状态中,也就避免了HTTP的非状态性,服务端会一直知道你的信息,直到你关闭请求,这样就解决了接线员要反复解析HTTP协议,还要查看identity info的信息。
同时由客户主动询问,转换为服务器(推送)有信息的时候就发送(当然客户端还是等主动发送信息过来的。。),没有信息的时候就交给接线员(Nginx),不需要占用本身速度就慢的客服(Handler)
 
 

实现游戏公告功能

实现功能:游戏管理里发布游戏公告,其它游戏玩家页面能够马上接受到游戏公告信息。

下面直接上代码案例,这里主要展示关键代码,底部有源码。

一、案例

1、pom.xml文件

主要是添加springBoot和webSocket相关jar包,和一些辅助工具jar包(注意我采用的是springBoot2.1.0版本

 pom.xml

 

2、WebSocketConfig

 这个是websocket配置中心,配置一些核心配置。

复制代码
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
//注解用于开启使用STOMP协议来传输基于代理(MessageBroker)的消息,这时候控制器(controller)开始支持@MessageMapping,就像是使用@requestMapping一样。
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


    /**
     * 注册端点,发布或者订阅消息的时候需要连接此端点
     * setAllowedOrigins 非必须,*表示允许其他域进行连接
     * withSockJS  表示开始sockejs支持
     */
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/endpoint-websocket").setAllowedOrigins("*").withSockJS();
    }

    /**
     * 配置消息代理(中介)
     * enableSimpleBroker 服务端推送给客户端的路径前缀
     * setApplicationDestinationPrefixes  客户端发送数据给服务器端的一个前缀
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        registry.enableSimpleBroker("/topic");
        registry.setApplicationDestinationPrefixes("/app");

    }
}
复制代码

 

3、GameInfoController

  管理员发布公告消息对应的接口

复制代码
/*
 *模拟游戏公告
 */
@Controller
public class GameInfoController {

  //@MessageMapping和@RequestMapping功能类似,用于设置URL映射地址,浏览器向服务器发起请求,需要通过该地址。
  //如果服务器接受到了消息,就会对订阅了@SendTo括号中的地址传送消息。
    @MessageMapping("/gonggao/chat")
    @SendTo("/topic/game_chat")
    public OutMessage gameInfo(InMessage message){

        return new OutMessage(message.getContent());
    }
}
复制代码

 

4、管理员页面和用户页面

 admin页面和user页面唯一的区别就是管理员多一个发送公告的权限,其它都一样,user1和user2完全一样。

(1)admin.html

 admin.html

(2)user1.html

 user1.html

 (3)user2.html

 user2.html

 

5.app.js

  这个是客户端连接websocket的核心,通过html的点击事件来完成。

复制代码
var stompClient = null;

//这个方法仅仅是用来改变样式,不是核心
function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    }
    else {
        $("#conversation").hide();
    }
    $("#notice").html("");
}

//1、建立连接(先连接服务端配置文件中的基站,建立连接,然后订阅服务器目录消息
function connect() {
    //1、连接SockJS的endpoint是“endpoint-websocket”,与后台代码中注册的endpoint要一样。
    var socket = new SockJS('/endpoint-websocket');

    //2、用stom进行包装,规范协议
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {

    //3、建立通讯
        setConnected(true);
        console.log('Connected: ' + frame);

    //4、通过stompClient.subscribe()订阅服务器的目标是'/topic/game_chat'发送过来的地址,与@SendTo中的地址对应。
        stompClient.subscribe('/topic/game_chat', function (result) {
            console.info(result)
            showContent(JSON.parse(result.body));
        });
    });
}

//2、关闭连接
function disconnect() {
    if (stompClient !== null) {
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
}

//3、游戏管理员发送公告信息(这个也是游戏用户所没有功能,其它都一样)
function sendName() {
    //1、通过stompClient.send 向/app/gonggao/chat 目标 发送消息,这个是在控制器的@messageMapping 中定义的。(/app为前缀,配置里配置)
    stompClient.send("/app/gonggao/chat", {}, JSON.stringify({'content': $("#content").val()}));
}

//4、订阅的消息显示在客户端指定位置
function showContent(body) {
    $("#notice").append("<tr><td>" + body.content + "</td> <td>"+new Date(body.time).toLocaleString()+"</td></tr>");
}


$(function () {
    $("form").on('submit', function (e) {
        e.preventDefault();
    });
    $( "#connect" ).click(function() { connect(); });
    $( "#disconnect" ).click(function() { disconnect(); });
    $( "#send" ).click(function() { sendName(); });
});
复制代码

 

6、查看运行结果

 

7、小总结

  首先很明显看的出,websocket最大的优点,就是可以服务端主动向客户端发送消息,而此前http只能是客户端向服务端发送请求。

   gitHub源码:https://github.com/yudiandemingzi/spring-boot-websocket-study

 

实现一对一聊天功能

功能介绍:实现A和B单独聊天功能,即A发消息给B只能B接收,同样B向A发消息只能A接收。

本篇博客是在上一遍基础上搭建,上一篇博客地址:【WebSocket】---实现游戏公告功能。底部有源码。

先看演示效果:

一、案例解析

1、PTPContoller

复制代码
/**
 * 功能描述:简单版单人聊天
 * 这里没有用到@SendTo("/topic/game_chat")来指定订阅地址,而是通过SimpMessagingTemplate来指定
 */
@Controller
public class PTPContoller {
    @Autowired
    private WebSocketService ws;

    @MessageMapping("/ptp/single/chat")
    public void singleChat(InMessage message) {
        ws.sendChatMessage(message);
    }
}
复制代码

这里和前面的公告消息,最大的区别就是接口上没有通过@SendTo("/topic/game_chat")来发送消息。

(1)@SendTo和SimpMessagingTemplate区别

       spring websocket基于注解的@SendTo和@SendToUser虽然方便,但是有局限性,例如我这样子的需求,我想手动的把消息推送给某个人,或者特定一组人,怎么办,

  @SendTo只能推送给所有人(它是一个具体地址,一点都不灵活),@SendToUser只能推送给请求消息的那个人,这时,我们可以利用SimpMessagingTemplate这个类

  SimpMessagingTemplate有俩个推送的方法

convertAndSend(destination, payload);            //将消息广播到特定订阅路径中,类似@SendTo 
convertAndSendToUser(user, destination, payload);//将消息推送到固定的用户订阅路径中,类似@SendToUser

 

2、WebSocketService

复制代码
import com.jincou.websocket.model.InMessage;
import com.jincou.websocket.model.OutMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

/**
 * 功能描述:简单消息模板,用来推送消息
 */
@Service
public class WebSocketService {

    @Autowired
    private SimpMessagingTemplate template;

    /**
     * 简单点对点聊天室
     */
    public void sendChatMessage(InMessage message) {
        //可以看出template最大的灵活就是我们可以获取前端传来的参数来指定订阅地址
        //前面参数是订阅地址,后面参数是消息信息
        template.convertAndSend("/chat/single/"+message.getTo(),
                new OutMessage(message.getFrom()+" 发送:"+ message.getContent()));
    }
复制代码

 

3、app.js

其它地方和公告的app.js一样,只有下面两点做了一点修改

复制代码
function connect() {
    var from = $("#from").val();
    var socket = new SockJS('/endpoint-websocket');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        //1、通过+from就可以灵活的用当前用户的某一信息来指定该用户订阅地址。
        stompClient.subscribe('/chat/single/'+from, function (result) {
            showContent(JSON.parse(result.body));
        });
    });
}

function sendName() {
     //2、这里出了发送content信息外,还发送了发送者用户信息,和接受者的信息
    stompClient.send("/app/ptp/single/chat", {}, JSON.stringify({'content': $("#content").val(), 'to':$("#to").val(), 'from':$("#from").val()}));
}
复制代码

 

4、user.html

其它地方也和之前公告的一样,下面是修改的地方

复制代码
  <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <input type="text" id="from" class="form-control" placeholder="我是">
                    <input type="text" id="to" class="form-control" placeholder="发送给谁">
                    
                    <input type="text" id="content" class="form-control" placeholder="请输入...">
          
                    
                </div>
                <button id="send" class="btn btn-default" type="submit">发送</button>
            </form>
        </div>
复制代码

 

5、再把整个思路缕一缕

以 A 向 B 发送消息为例

(1)form输入框输入:“A”,to输入框输入 “B” 点击“Connect”建立websocket连接

(2)那么的 A 用户的订阅地址就是'/chat/single/A'

(3)前端在“content”按钮中输入“你今天吃鸡了吗?”,再点击“发送”按钮

(4)后台通过接受处理就成了:

template.convertAndSend("/chat/single/B",new OutMessage(" A 发送:你今天吃鸡了吗?"));

那么 B 向 A 发送性质一模一样。就可以实现一对一聊天。

 

实现定时推送比特币交易信息

 

实现功能:跟虚拟币交易所一样,时时更新当前比特币的价格,最高价,最低价,买一价等等......

提示:(1)本篇博客是在上一遍基础上搭建,上一篇博客地址:【WebSocket】---实现游戏公告功能

         (2)底部有相关源码

先看效果演示

当前的信息就是虚拟币交易所最新BTC的数据信息。

我们看到每隔1秒都会更新一次最新的比特币当前信息。(截止到我发这篇博客时,比特币当前价格:6473美元左右)

一、案例解析

 

1、如何调用虚拟币的接口

你想获得BTC最新的价格信息,你首先的有它的相关接口,不然如何获取数据,我是在阿里云上购买的。

具体步骤:

 (1)登陆阿里云-->云市场-->股票行情于汇率

 (2)有很多企业都有相关接口有股票也有虚拟币

 (3)我选的一家名字叫:实时加密货币行情+推送

  网址:https://market.aliyun.com/products/57000002/cmapi029361.html?spm=5176.730005.productlist.d_cmapi029361.xtd4I4

 (4)对于接口都有相关说明,按照它的说明就可以获取json数据。同时也可以在线调试。

 

2、通过定时任务时时向客户端发送消息 

因为需要服务端隔一定时间向客户端发送消息,所有服务端用定时任务再好不过了。

复制代码
/**
 * //要启动定时任务记得在启动类上添加下面两个注解
 * @ComponentScan(basePackages="com.jincou.websocket")
 * @EnableScheduling
 * 功能描述:股票推送,这里只需通过定时任务向客服端发送消息
 */
@Component
public class CoinSchedule {
    @Autowired
    private WebSocketService ws;
    
    //代表每一秒执行一次任务
    @Scheduled(fixedRate=1000)
    public void coinInfo(){
        ws.sendCoinInfo();
    }
}
复制代码

  

3、WebSocketService类

消息模版工具类,用来推送消息用的。

复制代码
/**
 * 功能描述:简单消息模板,用来推送消息
 */
@Service
public class WebSocketService {

    @Autowired
    private SimpMessagingTemplate template;
 
    /**
     * 功能描述:Coin版本,虚拟币信息推送
     */
    public void sendCoinInfo() {

        //CoinService.getStockInfo()已经把json数据转为实体对象
        CoinResult coinResult = CoinService.getStockInfo();
        
  String msgTpl = "虚拟币名称: %s ;代码: %s; 现价格: %s元 ;买一价: %s ; 买一量: %s ; 买二价: %s ; 卖二量: %s;";
        CoinResult.Obj  obj=coinResult.getObj();
        if (null != obj) {
            //将 %s 替换成实际值
            String msg = String.format(msgTpl, obj.getName(), obj.getSecurityCode(), obj.getNow(),
                    obj.getBid1(), obj.getBid1Volume(), obj.getAsk1(), obj.getAsk1Volume());

            //前面参数是订阅地址,后面参数是消息信息(也就是比特币时时消息)
            template.convertAndSend("/topic/coin_info",new OutMessage(msg));
        }
    }
}
复制代码

 

4、CoinService调用接口,并把json格式数据赋值给对象

这个是最关键的一步,主要做的事:去调远程接口获取数据后,将数据封装到自己所写的bean实体中。

复制代码
import java.util.HashMap;
import java.util.Map;
import com.jincou.websocket.model.CoinResult;
import com.jincou.websocket.utils.HttpUtils;
import com.jincou.websocket.utils.JsonUtils;
import org.apache.http.HttpResponse;
import org.apache.http.util.EntityUtils;

/**
 * 功能描述:接口服务,调用虚拟币行情接口
 */
public class CoinService {

    public static CoinResult getStockInfo(){
         String host = "http://alirm-gbdc.konpn.com";
            String path = "/query/gbdc";
            String method = "GET";
            String appcode = "你的AppCode";
            Map<String, String> headers = new HashMap<String, String>();
            //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
            headers.put("Authorization", "APPCODE " + appcode);
            Map<String, String> querys = new HashMap<String, String>();
            //BTC代表返回比特币相关信息,如果这里传入ETH那就代表返回以太坊信息
            querys.put("symbol", "BTC");

        try {
            //返回连接信息,如果里面带有200,说明连接接口成功
            HttpResponse response = HttpUtils.doGet(host, path, method, headers, querys);

            //将response的body信息转为字符串
            String responseText=EntityUtils.toString(response.getEntity());

            //上面部分只要根据你购买的api接口说明操作就可以,下面才是你需要处理的
            
            //将json格式的字符串(根据一定规则)赋值给实体对象(JsonUtils是自己的一个工具类)
            CoinResult coinResult = JsonUtils.objectFromJson(responseText, CoinResult.class);

            System.out.println("控制台打印虚拟币当前信息=======================================");
            System.out.println(coinResult.toString());
            return coinResult;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}
复制代码

 

5、json格式如何封装到实体

这步主要讲,将json格式字符串通过工具类封装到实体对象需要满足的规则:

CoinResult coinResult = JsonUtils.objectFromJson(responseText, CoinResult.class); //看这步所需要满足的规则

(1)先看接口的json格式

复制代码
{"Code":0,"Msg":"",
    "Obj":{
    "B1":271.100,     --买一
    "B1V":129,        --买一量
    "B2":0,           --买二
    "B2V":0,
    "B3":0,           --买三
    "B3V":0,
    "B4":0,           --买四
    "B4V":0,        
    "B5":0,           --买五
    "B5V":0,
    "S1":271.150,    --卖一
    "S1V":20,        --卖一量
    "S2":0,          --卖二
    "S2V":0,
    "S3":0,          --卖三
    "S3V":0,
    "S4":0,          --卖四
    "S4V":0,
    "S5":0,          --卖五
    "S5V":0,
    "ZT":280.85,       --涨停价
    "DT":259.19,       --跌停价
    "O":270.39,        --今开
    "H":271.69,        --最高
    "L":270.14,        --最低
    "YC":270.55,       --昨收
    "A":35513202100.0, --交易额
    "V":130972,        --交易量
    "P":271.14,        --当前价
    "Tick":1529911046, --标准时间戳
    "N":"比特币",       --品种名
    "M":"",            --市场
    "S":"BTC",         --品种代码
    "C":""             --编号
    }
}
复制代码

(2)在看我的实体对象属性

复制代码
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class CoinResult {

    //状态码,0代表成功
    @JsonProperty("Code")
    private int Code;
    //具体数据(注意这里json用{表示,所有代表对象
    @JsonProperty("Obj")
    private Obj obj;

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)

    public static class Obj {

        //虚拟币代码
        @JsonProperty("S")
        private String securityCode;

        //虚拟币名称
        @JsonProperty("N")
        private String name;

        //现在价格
        @JsonProperty("P")
        private double now;

        //最高价格
        @JsonProperty("H")
        private double high;

        //最低价格
        @JsonProperty("L")
        private double low;

        //买一价
        @JsonProperty("B1")
        private double bid1;

        //买一量
        @JsonProperty("B1V")
        private int bid1Volume;

        //卖一价
        @JsonProperty("S1")
        private double ask1;

        //卖一量
        @JsonProperty("S1V")
        private double ask1Volume;
        
        //已成交价,这个接口没有提供,只要记住{}代表是对象,【】代表是结合那就需要集合接受:如下
        //private List<Transaction> transactions;
    }
}
复制代码

总结规则:

(1)json中的名字和实体中属性名一定要一致才能赋值。

(2)如果只要有一个你名字一致而数据类型不一样,那么就会整体赋值失败返回null。比如这里B1价,它明明是double,如你你用int接收,那么就会返回null。

(3)json格式中的数据如果是{},那么可以用对象来接收,好比这的"Obj":{...},如果是{[],[]},那就需要List<对象>来接收

 

 6、看前端

 前端没啥好说的只需要订阅:/topic/coin_info 这个地址就可以接收服务端时时发来的消息了。

  gitHub源码https://github.com/yudiandemingzi/spring-boot-websocket-study

 

多人聊天系统

功能说明:多人聊天系统,主要功能点:

    1、当你登陆成功后,可以看到所有在线用户(实际开发可以通过redis实现,我这边仅仅用map集合)

    2、实现群聊功能,我发送消息,大家都可以看到。

先看案例效果:

      这里面有关在线人数有个bug,就是在线用户会被覆盖,lisi登陆的话,zhangsan在线信息就丢来,xiaoxiao登陆,lisi就丢来,这主要原因是因为我放的是普通集合,所以在线用户数据是无法共享

所以只能显示最后显示的用户,如果放到redis就不会有这个问题。

一、案例说明

1、UserChatController

复制代码
@Controller
public class UserChatController {

    @Autowired
    private WebSocketService ws;

    /**
     * 1、登陆时,模拟数据库的用户信息
     */
    //模拟数据库用户的数据
    public static Map<String, String> userMap = new HashMap<String, String>();
    static{
        userMap.put("zhangsan", "123");
        userMap.put("lisi", "456");
        userMap.put("wangwu", "789");
        userMap.put("zhaoliu", "000");
        userMap.put("xiaoxiao", "666");
    }

    /**
     *2、 模拟用户在线进行页面跳转的时候,判断是否在线
     * (这个实际开发中肯定存在redis或者session中,这样数据才能共享)
     * 这里只是简单的做个模拟,所以暂且用普通map吧
     */
    public static Map<String, User> onlineUser = new HashMap<>();
    static{
        //key值一般是每个用户的sessionID(这里表示admin用户一开始就在线)
        onlineUser.put("123",new User("admin","888"));
    }
    
    
    /**
     *3、 功能描述:用户登录接口
     */
    @RequestMapping(value="login", method=RequestMethod.POST)
    public String userLogin( @RequestParam(value="username", required=true)String username, 
            @RequestParam(value="pwd",required=true) String pwd, HttpSession session) {

        //判断是否正确
        String password = userMap.get(username);
        if (pwd.equals(password)) {
            User user = new User(username, pwd);
            String sessionId = session.getId();

            //用户登陆成功就把该用户放到在线用户中...
            onlineUser.put(sessionId, user);
            //跳到群聊页面
            return "redirect:/group/chat.html";
        } else {
            return "redirect:/group/error.html";
        }
        
    }
    
    /**
     *4、 功能描述:用于定时给客户端推送在线用户
     */
    @Scheduled(fixedRate = 2000)
    public void onlineUser() {
        ws.sendOnlineUser(onlineUser);
    }
    
    /**
     *5、 功能描述 群聊天接口
     * message 消息体
     * headerAccessor 消息头访问器,通过这个获取sessionId
     */
    @MessageMapping("/group/chat")
    public void topicChat(InMessage message, SimpMessageHeaderAccessor headerAccessor){
        //这个sessionId是在HttpHandShakeIntecepter拦截器中放入的
        String sessionId = headerAccessor.getSessionAttributes().get("sessionId").toString();
        //通过sessionID获得在线用户信息
        User user = onlineUser.get(sessionId);
        message.setFrom(user.getUsername());
        ws.sendTopicChat(message);
        
    }    
}
复制代码

 

2、握手请求的拦截器

复制代码
/**
 * WebSocket握手请求的拦截器. 检查握手请求和响应, 对WebSocketHandler传递属性
 * 可以通过这个类的方法获取resuest,和response
 */
public class HttpHandShakeIntecepter implements HandshakeInterceptor{


    //在握手之前执行该方法, 继续握手返回true, 中断握手返回false. 通过attributes参数设置WebSocketSession的属性
    //这个项目只在WebSocketSession这里存入sessionID
    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
            ServerHttpResponse response, WebSocketHandler wsHandler,
            Map<String, Object> attributes) throws Exception {

        System.out.println("【握手拦截器】beforeHandshake");

        if(request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest)request;
            HttpSession session =  servletRequest.getServletRequest().getSession();
            String sessionId = session.getId();
            System.out.println("【握手拦截器】beforeHandshake sessionId="+sessionId);
            //这里将sessionId放入SessionAttributes中,
            attributes.put("sessionId", sessionId);
        }
        
        return true;
    }
 
    //在握手之后执行该方法. 无论是否握手成功都指明了响应状态码和相应头(这个项目没有用到该方法)
    @Override
    public void afterHandshake(ServerHttpRequest request,
            ServerHttpResponse response, WebSocketHandler wsHandler,
            Exception exception) {
        System.out.println("【握手拦截器】afterHandshake");
        
        if(request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest)request;
            HttpSession session =  servletRequest.getServletRequest().getSession();
            String sessionId = session.getId();
            System.out.println("【握手拦截器】afterHandshake sessionId="+sessionId);
        }
    }
}
复制代码

 

3、频道拦截器

复制代码
/** 
 * 功能描述:频道拦截器 ,类似管道,可以获取消息的一些meta数据
 */
public class SocketChannelIntecepter extends ChannelInterceptorAdapter{

    /**
     * 在完成发送之后进行调用,不管是否有异常发生,一般用于资源清理
     */
    @Override
    public void afterSendCompletion(Message<?> message, MessageChannel channel,
            boolean sent, Exception ex) {
        System.out.println("SocketChannelIntecepter->afterSendCompletion");
        super.afterSendCompletion(message, channel, sent, ex);
    }

    
    /**
     * 在消息被实际发送到频道之前调用
     */
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        System.out.println("SocketChannelIntecepter->preSend");
        
        return super.preSend(message, channel);
    }

    /**
     * 发送消息调用后立即调用
     */
    @Override
    public void postSend(Message<?> message, MessageChannel channel,
            boolean sent) {
        System.out.println("SocketChannelIntecepter->postSend");
        
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);//消息头访问器
        
        if (headerAccessor.getCommand() == null ) return ;// 避免非stomp消息类型,例如心跳检测
        
        String sessionId = headerAccessor.getSessionAttributes().get("sessionId").toString();
        System.out.println("SocketChannelIntecepter -> sessionId = "+sessionId);
        
        switch (headerAccessor.getCommand()) {
        case CONNECT:
            connect(sessionId);
            break;
        case DISCONNECT:
            disconnect(sessionId);
            break;
        case SUBSCRIBE:    
            break;
        
        case UNSUBSCRIBE:
            break;
        default:
            break;
        }
    }

    /**
     * 连接成功
     */
    private void connect(String sessionId){
        System.out.println("connect sessionId="+sessionId);
    }

    /**
     * 断开连接
     */
    private void disconnect(String sessionId){
        System.out.println("disconnect sessionId="+sessionId);
        //用户下线操作
        UserChatController.onlineUser.remove(sessionId);
    }
    
}
复制代码

 

4、修改webSocket配置类

    既然写了两个拦截器,那么肯定需要在配置信息里去配置它们。

复制代码
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


    /**
     *配置基站
     */
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/endpoint-websocket").addInterceptors(new HttpHandShakeIntecepter()).setAllowedOrigins("*").withSockJS();
    }

    /**
     * 配置消息代理(中介)
     * enableSimpleBroker 服务端推送给客户端的路径前缀
     * setApplicationDestinationPrefixes  客户端发送数据给服务器端的一个前缀
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        registry.enableSimpleBroker("/topic","/chat");
        registry.setApplicationDestinationPrefixes("/app");

    }

  
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors( new SocketChannelIntecepter());
    }

    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        registration.interceptors( new SocketChannelIntecepter());
    }

}
复制代码

 

5、app.js

   登陆页面和群聊页面就不细聊,贴上代码就好。

   index.html

 index.html

   chat.html

 chat.html

  app.js

复制代码
var stompClient = null;

//一加载就会调用该方法 function connect() { var socket = new SockJS('/endpoint-websocket'); stompClient = Stomp.over(socket); stompClient.connect({}, function (frame) { setConnected(true); console.log('Connected: ' + frame); //订阅群聊消息 stompClient.subscribe('/topic/chat', function (result) { showContent(JSON.parse(result.body)); }); //订阅在线用户消息 stompClient.subscribe('/topic/onlineuser', function (result) { showOnlieUser(JSON.parse(result.body)); }); }); } //断开连接 function disconnect() { if (stompClient !== null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } //发送聊天记录 function sendContent() { stompClient.send("/app/group/chat", {}, JSON.stringify({'content': $("#content").val()})); } //显示聊天记录 function showContent(body) { $("#record").append("<tr><td>" + body.content + "</td> <td>"+new Date(body.time).toLocaleTimeString()+"</td></tr>"); } //显示实时在线用户 function showOnlieUser(body) { $("#online").html("<tr><td>" + body.content + "</td> <td>"+new Date(body.time).toLocaleTimeString()+"</td></tr>"); } $(function () { connect();//自动上线 $("form").on('submit', function (e) { e.preventDefault(); }); $( "#disconnect" ).click(function() { disconnect(); }); $( "#send" ).click(function() { sendContent(); }); });
复制代码

   gitHub源码https://github.com/yudiandemingzi/spring-boot-websocket-study

posted @ 2022-02-18 20:45  hanease  阅读(771)  评论(0编辑  收藏  举报