Bootstrap

WebSocket

websocket 概述

一、是什么

WebSocket,是一种网络传输协议,位于OSI模型的应用层。可在单个TCP连接上进行全双工通信,能更好的节省服务器资源和带宽并达到实时通迅

客户端和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输

在这里插入图片描述
从上图可见,websocket服务器与客户端通过握手连接,连接成功后,两者都能主动的向对方发送或接受数据

而在websocket出现之前,开发实时web应用的方式为轮询

不停地向服务器发送 HTTP 请求,问有没有数据,有数据的话服务器就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现“实时通信”的效果

轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU资源

二、特点

全双工

通信允许数据在两个方向上同时传输,它在能力上相当于两个单工通信方式的结合

例如指 A→B 的同时 B→A ,是瞬时同步的

二进制帧

采用了二进制帧结构,语法、语义与 HTTP 完全不兼容,相比http/2,WebSocket更侧重于“实时通信”,而HTTP/2 更侧重于提高传输效率,所以两者的帧结构也有很大的区别

不像 HTTP/2 那样定义流,也就不存在多路复用、优先级等特性

自身就是全双工,也不需要服务器推送

协议名

引入ws和wss分别代表明文和密文的websocket协议,且默认端口使用80或443,几乎与http一致

ws://www.chrono.comws://www.chrono.com:8080/srvwss://www.chrono.com:445/im?user_id=xxx握手

WebSocket也要有一个握手过程,然后才能正式收发数据

客户端发送数据格式如下:

GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Origin: http://example.comSec-WebSocket-Protocol: chat, superchatSec-WebSocket-Version: 13
Connection:必须设置Upgrade,表示客户端希望连接升级(ws)
Upgrade:必须设置Websocket,表示希望升级到Websocket协议
Sec-WebSocket-Key:客户端发送的一个 base64 编码的密文,用于简单的认证秘钥。要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept应答,否则客户端会抛出错误,并关闭连接
Sec-WebSocket-Version :表示支持的Websocket版本
服务端返回的数据格式:

HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Sec-WebSocket-Protocol: chat
HTTP/1.1 101 Switching Protocols:表示服务端接受 WebSocket 协议的客户端连接(切换协议)
Sec-WebSocket-Accep:验证客户端请求报文,同样也是为了防止误连接。具体做法是把请求头里“Sec-WebSocket-Key”的值,加上一个专用的 UUID,再计算摘要
优点

较少的控制开销:数据包头部协议较小,不同于http每次请求需要携带完整的头部
更强的实时性:相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少
保持创连接状态:创建通信后,可省略状态信息,不同于HTTP每次请求需要携带身份验证
更好的二进制支持:定义了二进制帧,更好处理二进制内容
支持扩展:用户可以扩展websocket协议、实现部分自定义的子协议
更好的压缩效果:Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率

三、应用场景

基于websocket的实时通信的特点,经常能看到其广泛的应用场景,下方列举如:

  • 提示推送
  • 弹幕
  • 媒体聊天
  • 协同编辑
  • 基于位置的应用

案列一 基于netty实现

一、需求

实现一个简单的服务端主动向客户端发送数据,同时客户端也可以主动发送数据给服务端

1)Http协议是无状态的,浏览器和服务器间的请求响应一次,下一次会重新创建连接.
2)要求:实现基于webSocket的长连接的全双工的交互
3)改变Http协议多次请求的约束,实现长连接了,服务器可以发送消息给浏览器
4)客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知

二、流程图分析

在这里插入图片描述

三、代码实现

事先初始化好一个springboot的项目

①、引入依赖

<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.6</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.st.websocket</groupId>
    <artifactId>st-websocket</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>st-websocket</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.43.Final</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

②、springboot启动完成的监听类AppInitializer

用于项目启动后紧接着启动我们的netty服务端

package com.st.websocket.netty;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/12
 * @描述:
 */
@Component
@Order(2) // 控制类执行的顺序越小越靠前
public class AppInitializer implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        System.out.println("----------------启动完成------------------");
        //创建两个线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup(); //8个NioEventLoop
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup, workerGroup);
            serverBootstrap.channel(NioServerSocketChannel.class);
            serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
            serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    //因为基于http协议,使用http的编码和解码器
                    pipeline.addLast(new HttpServerCodec());
                    //是以块方式写,添加ChunkedWriteHandler处理器
                    pipeline.addLast(new ChunkedWriteHandler());
                    /*
                    说明
                    1. http数据在传输过程中是分段, HttpObjectAggregator ,就是可以将多个段聚合
                    2. 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
                     */
                    pipeline.addLast(new HttpObjectAggregator(8192));
                    /*
                    说明
                    1. 对应websocket ,它的数据是以 帧(frame) 形式传递
                    2. 可以看到WebSocketFrame 下面有六个子类
                    3. 浏览器请求时 ws://localhost:7000/hello 表示请求的uri
                    4. WebSocketServerProtocolHandler 核心功能是将 http协议升级为 ws协议 , 保持长连接
                    5. 是通过一个 状态码 101
                     */
                    pipeline.addLast(new WebSocketServerProtocolHandler("/hello2"));

                    //自定义的handler ,处理业务逻辑
                    pipeline.addLast(new MyTextWebSocketFrameHandler());
                }
            });

            //启动服务器
            ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
            channelFuture.channel().closeFuture().sync();

        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }



}

③、MyTextWebSocketFrameHandler

对应处理的Handler

package com.st.websocket.netty;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import javax.servlet.ServletContext;
import java.time.LocalDateTime;

//这里 TextWebSocketFrame 类型,表示一个文本帧(frame)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>  {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        System.out.println("服务器收到消息 " + msg.text());
        //回复消息
        ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
    }

    //当web客户端连接后, 触发方法
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        //id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
        System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
        System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
        ServletContext servletContext = MyServletUtil.servletContext;
        System.out.println("存进去==》"+ctx);
        servletContext.setAttribute("key",ctx);//实际中我们不能写死的,而应该是一个动态的业务唯一标识

    }


    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {

        System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("异常发生 " + cause.getMessage());
        ctx.close(); //关闭连接
    }


}

④、ServletUtil

用于获取ServletContext的工具类供其他地方使用

package com.st.websocket.netty;

import lombok.Data;
import org.springframework.stereotype.Component;
import org.springframework.web.context.support.WebApplicationObjectSupport;

import javax.servlet.ServletContext;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/12
 * @描述:
 */
@Data
@Component
public class MyServletUtil extends WebApplicationObjectSupport {

    public static ServletContext servletContext;

    public MyServletUtil() {
    }

    @Override
    protected void initServletContext(ServletContext servletContext) {
        super.initServletContext(servletContext);
        MyServletUtil.servletContext=servletContext;
        System.out.println("servletContext====>"+servletContext);
    }


}

⑤、客户端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<script>
    var socket;
    //判断当前浏览器是否支持websocket
    if(window.WebSocket) {
        //go on
        socket = new WebSocket("ws://localhost:7000/hello2");
        //相当于channelReado, ev 收到服务器端回送的消息
        socket.onmessage = function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = rt.value + "\n" + ev.data;
        }

        //相当于连接开启(感知到连接开启)
        socket.onopen = function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = "连接开启了.."
        }

        socket.onmessage= function (ev) {
            var rt = document.getElementById("responseText");
            rt.value = rt.value + "\n" + ev.data;
        }

        //相当于连接关闭(感知到连接关闭)
        socket.onclose = function (ev) {

            var rt = document.getElementById("responseText");
            rt.value = rt.value + "\n" + "连接关闭了.."
        }
    } else {
        alert("当前浏览器不支持websocket")
    }

    //发送消息到服务器
    function send(message) {
        if(!window.socket) { //先判断socket是否创建好
            return;
        }
        if(socket.readyState == WebSocket.OPEN) {
            //通过socket 发送消息
            socket.send(message)
        } else {
            alert("连接没有开启");
        }
    }
</script>
    <form onsubmit="return false">
        <textarea name="message" style="height: 300px; width: 300px"></textarea>
        <input type="button" value="发生消息" onclick="send(this.form.message.value)">
        <textarea id="responseText" style="height: 300px; width: 300px"></textarea>
        <input type="button" value="清空内容" onclick="document.getElementById('responseText').value=''">
    </form>
</body>
</html>

四、测试

一、启动主程序

在这里插入图片描述

二、打开客户端

在这里插入图片描述
在这里插入图片描述

三、服务端向客户端发送数据

在这里插入图片描述

四、客户端向服务端发送数据

在这里插入图片描述

案列二 基于java websocket 实现

这里的需求,流程图不在赘述,直接开打 也是基于上一个的工程添加一个包继续编写的

一、代码实现

①、引入依赖

 		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

②、编写配置类WebSocketConfig

package com.st.websocket.websocket.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/13
 * @描述: 开启WebSocket支持
 */
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

③、端点配置类

package com.st.websocket.websocket;

import org.springframework.stereotype.Component;

import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/13
 * @描述:  订单通知
 */
@Component
@ServerEndpoint("/web_socket/order_notification/{merchantId}")
public class OrderNotificationWebSocket {

//    static final ConcurrentHashMap<String, List<WebSocketClient>> webSocketClientMap= new ConcurrentHashMap<>();
    static final ConcurrentHashMap<String, WebSocketClient> webSocketClientMap= new ConcurrentHashMap<>();

    /**
     * 连接建立成功时触发,绑定参数
     * @param session 与某个客户端的连接会话,需要通过它来给客户端发送数据
     * @param merchantId 商户ID
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("merchantId") String merchantId){

        WebSocketClient client = new WebSocketClient();
        client.setSession(session);
        client.setUri(session.getRequestURI().toString());
        webSocketClientMap.put(merchantId, client);
        System.out.println("已经建立了连接==>"+merchantId);
        buildConnectOk(client);
    }


    public void buildConnectOk(WebSocketClient client){
        try {
            client.getSession().getBasicRemote().sendText("已连接,请放马过来吧!");
        } catch (IOException e) {
            e.printStackTrace();
            System.err.println("马儿跑了。。。┭┮﹏┭┮");
        }

    }

    /**
     * 连接关闭时触发,注意不能向客户端发送消息了
     * @param merchantId
     */
    @OnClose
    public void onClose(@PathParam("merchantId") String merchantId){
        webSocketClientMap.remove(merchantId);
        System.out.println("已经关闭了连接==>"+merchantId);
    }

    /**
     * 通信发生错误时触发
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        System.out.println("发生错误");
        error.printStackTrace();
    }

    /**
     * 向客户端发送消息
     * @param merchantId
     * @param message
     */
    public static void sendMessage(String merchantId,String message){
        try {
          WebSocketClient webSocketClient = webSocketClientMap.get(merchantId);
            if(webSocketClient!=null){
                webSocketClient.getSession().getBasicRemote().sendText(message);
            }
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
    }



}

④、客户端类

package com.st.websocket.websocket;

import lombok.Data;

import javax.websocket.Session;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/13
 * @描述:
 */
@Data
public class WebSocketClient {

    // 与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session session;

    //连接的uri
    private String uri;

}

⑤、controller

package com.st.websocket.websocket.controller;

import com.st.websocket.websocket.OrderNotificationWebSocket;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * @创建人: 放生
 * @创建时间: 2022/4/13
 * @描述:
 */
@RestController
public class OrderNotificationWebSocketController {

    @GetMapping("/sendOrderNum")
    public void test(@RequestParam(value = "merchantId") String merchantId){
        OrderNotificationWebSocket.sendMessage(merchantId,"有新订单啦");
    }

}

⑥、客户端页面

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebSocket</title>
</head>
<body>
<input id="url" type="text" size="60" value="ws://localhost:8080/web_socket/order_notification/M666666" />
<button onclick="openWebSocket()">打开WebSocket连接</button>
<button onclick="closeWebSocket()">关闭WebSocket连接</button>

<div id="message"></div>
</body>
<script type="text/javascript">
       var websocket = null;
   function openWebSocket() {
      var url = document.getElementById('url').value.trim();
      //判断当前浏览器是否支持WebSocket
            if ('WebSocket' in window) {
            websocket = new WebSocket(url);
      } else {
            alert('当前浏览器 Not support websocket')
      }
      //连接发生错误的回调方法
            websocket.onerror = function() {
            setMessageInnerHTML("WebSocket连接发生错误");
      };
      //连接成功建立的回调方法
            websocket.onopen = function() {
            setMessageInnerHTML("WebSocket连接成功");
      }
      //接收到消息的回调方法
            websocket.onmessage = function(event) {
            setMessageInnerHTML(event.data);
      }
      //连接关闭的回调方法
            websocket.onclose = function() {
            setMessageInnerHTML("WebSocket连接关闭");
      }
    }
    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
        window.onbeforeunload = function() {
        closeWebSocket();
    }
    //将消息显示在网页上
        function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }
    //关闭WebSocket连接
        function closeWebSocket() {
        websocket.close();
    }
  </script>
</html>

⑦、包结构

在这里插入图片描述

二、测试

  1. 启动主程序
  2. 开启客户端
    在这里插入图片描述

在这里插入图片描述

待优化

  • java websocket 的实现,也可以实现鉴权,并不是所有的客户端过来我都要连接,可以鉴权通过则连接,不通过则无权限,网上有很多实现的案列。

  • 以上两个案列都是Channel都是基于session存储的,只不过一个是基于上下文,一个是基于map,但是如果有这样一种场景,我服务端部署了多台实列,分布在不同的物理机上,第一次建立连接的时候是在A机器上,然后保存了对应的Channel在A机器的session中,第二次客户端发送请求的时候打到了B机器上那么B 是没有之前存的Channel的,则无法响应,或者说虽然服务端部署了多个实列在多个物理机上,只要A宕机,也还有其他的节点存活,但是对于这次建立的会话仍然是不可用的。
    网上也有很多的思路,比如利用mq,netty 集群,通过rocketmq等MQ 推送到所有netty服务端 channel 共享无非是要那个通道都可以发送消息向客户端 MQ广播+ 多Netty ,Netty收到MQ消息后,如果本地存储有该channel,就发送,没有存储就忽略,不需要做channel的共享

;