WebSocket入门

一、相关知识铺垫

1.1 OSI 7层模型

互联网的实现分成好几层,每一层都有自己的功能,就像建筑物一样,每一层都靠下一层支持。

OSI模型就是这样的一个分层,它是一个由国际标准化组织提出的概念模型,试图提供一个使各种不同的计算机和网络在世界范围内实现互联的标准框架

OSI模型:

应用层,定义了用于在网络中进行通信和传输数据的接口;(Http协议位于该层)

表示层,定义不同系统中数据的传输格式,编码和解码规范等;

会话层,管理用户的会话,控制用户间逻辑连接的建立和中断;

传输层,管理着网络中端到端的数据传输;(Tcp协议位于该层)

网络层,定义网络设备间如何传输数据;(IP位于该层)

链路层,将上面的网络层的数据包封装成数据帧,便于物理层传输;

物理层,这一层主要就是传输这些二进制数据。

TCP/IP四层模型:

TCP/IP和OSI模型组并不能精确的匹配,但是我们可以尽可能的参考OSI模型并在其中找到TCP/IP的对应位置。

如上图所示,通常人们认为OSI模型最上面三层(应用层、表示层、会话层)在TCP/IP中是一个应用层

应用层,提供用户和应用程序之间的接口;

传输层,负责数据在网络中传输时的可靠性、流量控制和校正;

网络层,控制流量的流动和路由、寻址,确保数据快速准确地发送,还负责在其目的地重新组装数据包;

网络接口层,涉及计算机连接互联网的基础设施,负责同一网络上两个设备之间的数据传输,将IP数据报封装成网络传输的帧;

两者区别:

OSI是一个完整的、完善的宏观模型,他包括了硬件层(物理层),当然也包含了很多上面图中没有列出的协议(比如DNS解析协议等);

而 TCP/IP 模型,更加侧重的是互联网通信核心(也是就是围绕TCP/IP协议展开的一系列通信协议)的分层,因此它不包括物理层,以及其他一些不相干的协议;

其次,之所以说他是参考模型,是因为他本身也是OSI模型中的一部分,因此参考OSI模型对其分层。

1.2 TCP

TCP/IP协议是全球计算机及网络设备都在使用的一种常用的分组交换网络分层协议集,是互联网标准通信的基础。

提供点对点的链接机制,将数据应该如何封装、定址、传输、路由以及在目的地如何接收,都加以标准化

主要特点如下:

  • 面向连接的传输层协议:在使用TCP协议之前,应用程序必须先建立TCP连接。在传输数据完毕后,必须释放已经建立的TCP连接。这种连接方式类似于打电话,通话前需要建立连接,通话结束后需要释放连接。

  • 点对点通信:每一条TCP连接只能有两个端点,实现的是点对点的通信。

  • 提供可靠的数据传输服务:TCP通过序号确认、超时重传和数据包重新排序等机制确保数据的可靠传输。

  • 全双工通信:TCP允许通信双方的应用进程在任何时候都能发送数据。TCP连接的两端都设有发送缓存和接收缓存,用来临时存放双向通信的数据。

  • 面向字节流:TCP中的“流”指的是流入到进程或从进程流出的字节序列。虽然应用程序和TCP的交互是一次一个数据块,但TCP把应用程序交下来的数据看成是一连串的无结构的字节流。

建立起一个TCP连接需要经过“三次握手”:

  • 第一次握手:客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;
  • 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  • 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),客户端和服务器进入ESTABLISHED状态,完成三次握手。

握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。

理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接都将被一直保持下去。断开连接时服务器和客户端均可以主动发起断开TCP连接的请求。

1.3 HTTP协议

基础概念:

HTTP(Hyper Text Transformer Protocol,超文本传输协议)是一种通信协议,是建立在TCP协议之上的一种应用协议,它允许将超文本标记语言(HTML)文档从Web服务器传送到客户端的浏览器。

HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接。从建立连接到关闭连接的过程称为“一次连接”。

由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”。

要保持客户端程序的在线状态,需要不断地向服务器发起连接请求,通常情况下即使不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,

服务器在收到该请求后对客户端进行回复,表明知道客户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。

问题1:短连接

通常一个网页可能会有很多组成部分,除了文本内容,还会有诸如:js、css、图片等静态资源,有时还会异步发起AJAX请求。只有所有的资源都加载完毕后,我们看到网页完整的内容。

然而,一个网页中,可能引入了几十个js、css文件,上百张图片,如果每请求一个资源,就创建一个连接,然后关闭,代价实在太大了。

基于此背景,我们希望连接能够在短时间内得到复用,在加载同一个网页中的内容时,尽量的复用连接,这就是HTTP协议中keep-alive属性的作用。

如图,可以看到请求 header 中有一行 Connection : keep-alive

我们知道 HTTP 协议采用 请求-应答 模式,当使用普通模式,即非 Keep-Alive 模式时,对于每个请求客户和服务器都要新建一个连接,完成之后立即断开连接;

当使用 Keep-Alive 模式(又称持久连接、连接重用)时,Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep- Alive 功能避免了建立或者重新建立连接。

  • http 1.0 中默认是关闭的,需要在 http 头加入 Connection: Keep-Alive,才能启用 Keep-Alive;
  • http 1.1 中默认启用 Keep-Alive,如果加入 Connection: close,才关闭。

keep-alive 是通知服务器,在这个 HTTP Request/Responset 结束后,不要立即断开 TCP 连接,后面的HTTP Request仍然可以通过这个TCP连接继续传送。

只是个建议,服务器可能不支持,也可能忽略掉这个建议。也可能因为时间太久而直接断开TCP连接,所以,keep-alive只是客户端建议的一种复用TCP连接的方式,至于服务器支持不支持,就由不得客户端了。

目前大部分浏览器都是用 http 1.1 协议,也就是说默认都会发起 Keep-Alive 的连接请求了,所以是否能完成一个完整的 Keep- Alive 连接就看服务器设置情况

Nginx 中设置 Keep-Alive(服务端)

Tomcat 中设置 Keep-Alive(服务端)

问题2:请求-响应模型

服务器端要想主动的push消息给客户端,这是不可能的,但我可以使用ajax轮询、long poll 技术造一个服务端给客户端主动push消息的假象。

  • ajax轮询:原理非常简单,让浏览器隔个几秒就发送一次请求,询问服务器是否有新信息。但这样会大大增加了服务端的负载,并且存在延迟;
  • long poll:客户端发起一个请求连接,这个连接会阻塞住,直到服务端有了消息,才会response给客户端,既想阻塞,又想高并发,几乎不可能;

二. HTTP 与 WebSocket:

上文中说到HTTP1.1已经支持了长连接,为什么还要引入WebSocket作为补充呢。

WebSocket 是 HTML5 一种新的协议,它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯,它建立在 TCP 之上,同 HTTP 一样通过 TCP 来传输数据。

TCP是全双工的,但HTTP 1.1在TCP基础上实现的是半双工通信,又因为HTTP是请求-响应模型,这使得服务端不具备主动推送数据资源给客户端的能力。

  • 单工:指消息只能单方向传输的工作方式,发送端和接收端的身份是固定的,发送端只能发送信息,不能接收信息;接收端只能接收信息,不能发送信息,数据信号仅从一端传送到另一端;(遥控器)
  • 半双工:指数据可以沿两个方向传送,但同一时刻一个信道只允许单方向传送,因此又被称为双向交替通信;(对讲机)
  • 全双工:指在通信的任意时刻,线路上可以同时存在A到B和B到A的双向信号传输;(电话)

基于请求-响应模型如果我们需要服务端的消息数据,就必须先向服务端发送对应的查询请求,因此对于实时的数据交互业务,需要客户端定时向服务器发起查询请求,这样的做法实在不够优雅。

基于实时数据交互的场景诉求,更平等的全双工通信WebSocket诞生了。

上图对比可以看出,相对于传统 HTTP 每次请求-应答都需要客户端与服务端建立连接的模式,WebSocket 是类似 Socket 的 TCP 长连接的通讯模式,一旦 WebSocket 连接建立后,后续数据都以帧序列的形式传输。

在客户端断开 WebSocket 连接或 Server 端断掉连接前,不需要客户端和服务端重新发起连接请求。

在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实时性优势明显。

二者联系:

  • 都是一样基于TCP协议的应用层网络传输协议。
  • WebSocket在建立握手时,数据是通过HTTP传输的。但是建立之后,在真正传输时候是不需要HTTP协议的。

二者区别:

  • http 链接分为长链接、短链接,短链接是发送一个请求,返回一个响应,长链接是在一定周期内保持链接。但是 websocket 只需连接一次就可以保持长链接,不需要的时候可以手动断开。
  • http 通信中,客户端是主动的,服务端是被动的。但是 websocket,服务端可以主动向客户端推送数据。
  • WebSocket支持文本和二进制这两种格式的数据,而且定义了二进制帧,相对比HTTP,可以更容易处理二进制内容。
  • WebSocket协议是全双工类型的,服务器可以随时主动给客户端推送数据。相对于HTTP短轮询操作需要等待客户端请求服务端才能响应,实时性明显更高。即使和Comet等长轮询来比较,其也能在短时间内传递更多的数据。

链接过程:

我们再通过客户端和服务端交互的报文看一下 WebSocket 通讯与传统 HTTP 的不同:

WebSocket 客户端连接服务端端口,执行双方握手过程,客户端发送数据格式类似于下面的内容

可以看到,客户端发起的 WebSocket 连接报文类似传统 HTTP 报文,”Upgrade:websocket”参数值表明这是 WebSocket 升级请求,

“Sec-WebSocket-Key”是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”应答,

否则客户端会抛出“Error during WebSocket handshake”错误,并关闭连接。

服务端收到报文后返回的数据格式类似于如下内容:

“Sec-WebSocket-Accept”的值是服务端采用与客户端一致的密钥计算出来后返回客户端的,“HTTP/1.1 101 Switching Protocols”表示服务端接受 WebSocket 协议的客户端连接,

经过这样的请求-响应处理后,客户端与服务端的 WebSocket 连接握手成功, 后续就可以进行 TCP 通讯了。

报文内容

说明

Connection: Upgrade标识该HTTP请求是一个协议升级请求
Upgrade: websocket协议升级为 WebSocket 协议
Sec-WebSocket-Version: xxx客户端支持 WebSocket 的版本
Sec-WebSocket-Key: xxx

客户端采用 base64 编码的24位随机字符序列,服务器接受客户端 HTTP 协议升级的证明。

要求服务端响应一个对应加密的 Sec-WebSocket-Accept 头信息作为应答

Sec-WebSocket-Extensions: xxx协议扩展类型

三. WebSocket:

3.1 介绍:

WebSocket 是一种网络传输协议,在2008年诞生,2011年成为了国际标准,基于它的WebSocket API也被W3C定为标准,目前所有浏览器都已经支持该协议了。

WebSocket 可实现在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。它使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。

在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

3.2 链接示意图:

3.3 客户端实现:

实现 WebSocket 的web浏览器将通过 WebSocket 对象公开所有必要的客户端功能,以下 API 用于创建 WebSocket 对象:

// url格式:ws://ip地址:端口号/资源名称

var ws = new WebSocket(url);

WebSocket事件:

事件

事件处理程序

描述

openWebSocket对象.onopen建立连接时触发
messageWebSocket对象.onmessage客户端接收服务器端数据时触发
errorWebSocket对象.onerror通信发生错误时触发
closeWebSocket对象.onclose连接关闭时触发

WebSocket方法:

方法

描述

send()使用连接发送数据

客户端 Endpoint 示例:

     var socket = new WebSocket(url);

       //打开事件

       socket.onopen = function () {

           // 以下代码省略...

       };

       //获得消息事件

       socket.onmessage = function (msg) {

           // 以下代码省略...  

       };

       //关闭事件

       socket.onclose = function () {

           // 以下代码省略... 

       };

       //发生了错误事件

       socket.onerror = function () {

           // 以下代码省略... 

       }

3.4 服务端实现:

Tomcat的 7.0.5 版本开始支持WebSocket,并且实现了 Java WebSocket规范(JSR356)。

Java WebSocket 应用由一系列的 WebSocketEndpoint 组成。

Endpoint 是一个java对象,代表 WebSocket 链接的一端,对于服务器端可以视为处理具体 WebSocket 消息的接口。就像 Servlet 与 http 请求一样。

我们可以通过两种方式定义 Endpoint:

  • 编程式,继承类 javax.websocket.Endpoint 并实现其方法。
  • 注解式,定义一个POJO,并添加 @ServerEndpoint 相关注解。

Endpoint 实例在 WebSocket 握手时创建,并在客户端与服务端链接过程中有效,最后在链接关闭时结束。

在 Endpoint 接口中明确定义了与其生命周期相关的方法,规范实现者确保生命周期的各个阶段调用实例的相关方法。生命周期方法如下:

方法

含义描述

注解

onClose当会话关闭时调用@OnClouse
onOpen当开启一个新的会话时调用,该方法是客户端与服务端握手成功后调用的方法@OnOpen
onError当连接过程中异常时调用@OnError

服务端如何接收客户端发送的数据?

通过 session 添加 MessageHandler 消息处理器来接收消息,当采用注解方式定义 Endpoint 时,我们可以通过 @OnMessage 注解指定接收消息的方法。

服务端如何推送数据给客户端?

发送消息由 RemoteEndpoint 完成,其实例由 session 维护,根据使用情况我们可以通过 Session.getBasicRemote 获取同步消息发送的实例,然后调用其 sendXxx() 方法发送消息

还可以通过 Session.getAsyncRemote 获取异步消息发送实例。

服务端 Endpoint 示例:

@ServerEndpoint("/example")

public class EchoEndpoint {

     @OnOpen

     public void onOpen(Session session) throws IOException {

          // 以下代码省略...

     }

  

     @OnMessage

     public String onMessage(String message, Session session) {

          // session.getBasicRemote().sendText(message); 

          // session.getAsyncRemote().sendText(message);

          // 以下代码省略...

     }

     @OnError

     public void onError(Session session, Throwable t) {

          // 以下代码省略...

     }

  

     @OnClose

     public void onClose(Session session, CloseReason reason) {

          // 以下代码省略...

     }

}

@ServerEndpoint:主要是将目前的类定义成一个websocket服务器端, 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端

但只添加 @ServerEndpoint 是不够的,即使添加 @Component 注解将其注入到spring容器,spring也只会认为它是个普通的 bean。

那么怎样才能让 spring 容器知道它是个 websocket 的终端呢?

ServerEndpointExporter 是 Spring 官方提供的标准实现,会自动扫描带有 @ServerEndpoint 注解声明的 Websocket Endpoint(端点),注册成为 Websocket bean。

@Configuration

public class WebSocketConfig {

    /**

     * bean注册:会自动扫描带有@ServerEndpoint注解声明的Websocket Endpoint(端点),注册成为Websocket bean。

     */

    @Bean

    public ServerEndpointExporter serverEndpointExporter() {

        return new ServerEndpointExporter();

    }

}

四. demo:

简单实现一个实时聊天的功能,A发送消息后B成功接收消息

服务端代码:

Endpoint:

@Component

@Slf4j

@ServerEndpoint("/websocket/{sid}")

public class WebSocketServer {

    /**

     * 静态变量,用来记录当前在线连接数,线程安全的类。

     */

    private static AtomicInteger onlineSessionClientCount = new AtomicInteger(0);

    /**

     * 存放所有在线的客户端

     */

    private static Map<String, Session> onlineSessionClientMap = new ConcurrentHashMap<>();

    /**

     * 连接sid和连接会话

     */

    private String sid;

    /**

     * 连接建立成功调用的方法。由前端<code>new WebSocket</code>触发

     *

     * @param sid     每次页面建立连接时传入到服务端的id,比如用户id等。可以自定义。

     * @param session 与某个客户端的连接会话,需要通过它来给客户端发送消息

     */

    @OnOpen

    public void onOpen(@PathParam("sid") String sid, Session session) {

        /**

         * session.getId():当前session会话会自动生成一个id,从0开始累加的。

         */

        log.info("连接建立中 ==> session_id = {}, sid = {}", session.getId(), sid);

        //加入 Map中。将页面的sid和session绑定或者session.getId()与session

        onlineSessionClientMap.put(sid, session);

        //在线数加1

        onlineSessionClientCount.incrementAndGet();

        this.sid = sid;

        sendToOne(sid, "连接成功");

        log.info("连接建立成功,当前在线数为:{} ==> 开始监听新连接:session_id = {}, sid = {},。", onlineSessionClientCount, session.getId(), sid);

    }

    /**

     * 连接关闭调用的方法。由前端<code>socket.close()</code>触发

     *

     * @param sid

     * @param session

     */

    @OnClose

    public void onClose(@PathParam("sid") String sid, Session session) {

        onlineSessionClientMap.remove(sid);

        //在线数减1

        onlineSessionClientCount.decrementAndGet();

        log.info("连接关闭成功,当前在线数为:{} ==> 关闭该连接信息:session_id = {}, sid = {},。", onlineSessionClientCount, session.getId(), sid);

    }

    /**

     * 收到客户端消息后调用的方法。由前端<code>socket.send</code>触发

     * * 当服务端执行toSession.getAsyncRemote().sendText(xxx)后,前端的socket.onmessage得到监听。

     *

     * @param message

     * @param session

     */

    @OnMessage

    public void onMessage(String message, Session session) {

        /**

         * html界面传递来得数据格式,可以自定义.

         * {"sid":"user-1","message":"hello websocket"}

         */

        JSONObject jsonObject = JSON.parseObject(message);

        String toSid = jsonObject.getString("sid");

        String msg = jsonObject.getString("message");

        log.info("服务端收到客户端消息 ==> fromSid = {}, toSid = {}, message = {}", sid, toSid, message);

        /**

         * 模拟约定:如果未指定sid信息,则群发,否则就单独发送

         */

        if (toSid == null || toSid == "" || "".equalsIgnoreCase(toSid)) {

            sendToAll(msg);

        else {

            sendToOne(toSid, msg);

        }

    }

    /**

     * 发生错误调用的方法

     *

     * @param session

     * @param error

     */

    @OnError

    public void onError(Session session, Throwable error) {

        log.error("WebSocket发生错误,错误信息为:" + error.getMessage());

        error.printStackTrace();

    }

    /**

     * 群发消息

     *

     * @param message 消息

     */

    private void sendToAll(String message) {

        // 遍历在线map集合

        onlineSessionClientMap.forEach((onlineSid, toSession) -> {

            // 排除掉自己

            if (!sid.equalsIgnoreCase(onlineSid)) {

                log.info("服务端给客户端群发消息 ==> sid = {}, toSid = {}, message = {}", sid, onlineSid, message);

                toSession.getAsyncRemote().sendText(message);

            }

        });

    }

    /**

     * 指定发送消息

     *

     * @param toSid

     * @param message

     */

    private void sendToOne(String toSid, String message) {

        // 通过sid查询map中是否存在

        Session toSession = onlineSessionClientMap.get(toSid);

        if (toSession == null) {

            log.error("服务端给客户端发送消息 ==> toSid = {} 不存在, message = {}", toSid, message);

            return;

        }

        // 异步发送

        log.info("服务端给客户端发送消息 ==> toSid = {}, message = {}", toSid, message);

        toSession.getAsyncRemote().sendText(message);

    }

}

配置类:

@Configuration

public class WebSocketConfig {

    /**

     * bean注册:会自动扫描带有@ServerEndpoint注解声明的Websocket Endpoint(端点),注册成为Websocket bean。

     */

    @Bean

    public ServerEndpointExporter serverEndpointExporter() {

        return new ServerEndpointExporter();

    }

}

controller:

@Controller

@RequestMapping("/demo")

public class DemoController {

    /**

     * 跳转到websocketDemo.html页面,携带自定义的cid信息。

     http://localhost:8081/demo/toWebSocketDemo/A

     *

     * @param cid

     * @param model

     * @return

     */

    @GetMapping("/toWebSocketDemo/{cid}")

    public String toWebSocketDemo(@PathVariable String cid, Model model) {

        model.addAttribute("cid", cid);

        return "websocketDemo";

    }

}

服务端代码:

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head>

    <meta charset="UTF-8">

    <title>666666</title>

</head>

<body>

传递来的数据值cid:

<input type="text" th:value="${cid}" id="cid"/>

<p>【toUserId】:

<div><input id="toUserId" name="toUserId" type="text" value="user-1"></div>

<p>【toUserId】:

<div><input id="contentText" name="contentText" type="text" value="hello websocket"></div>

<p>【操作】:

<div>

    <button type="button" onclick="sendMessage()">发送消息</button>

</div>

</body>

<script type="text/javascript">

    var socket;

    if (typeof (WebSocket) == "undefined") {

        console.log("您的浏览器不支持WebSocket");

    else {

        console.log("您的浏览器支持WebSocket");

        //实现化WebSocket对象,指定要连接的服务器地址与端口  建立连接

        var cid = document.getElementById("cid").value;

        console.log("cid-->" + cid);

        var reqUrl = "http://localhost:8081/websocket/" + cid;

        socket = new WebSocket(reqUrl.replace("http""ws"));

        //打开事件

        socket.onopen = function () {

            console.log("Socket 已打开");

            //socket.send("这是来自客户端的消息" + location.href + new Date());

        };

        //获得消息事件

        socket.onmessage = function (msg) {

            console.log("onmessage--" + msg.data);

            //发现消息进入    开始处理前端触发逻辑

        };

        //关闭事件

        socket.onclose = function () {

            console.log("Socket已关闭");

        };

        //发生了错误事件

        socket.onerror = function () {

            alert("Socket发生了错误");

            //此时可以尝试刷新页面

        }

    }

    function sendMessage() {

        if (typeof (WebSocket) == "undefined") {

            console.log("您的浏览器不支持WebSocket");

        else {

            // console.log("您的浏览器支持WebSocket");

            var toUserId = document.getElementById('toUserId').value;

            var contentText = document.getElementById('contentText').value;

            var msg = '{"sid":"' + toUserId + '","message":"' + contentText + '"}';

            console.log(msg);

            socket.send(msg);

        }

    }

</script>

</html>

五、参考文档

https://zhuanlan.zhihu.com/p/656062885

https://zhuanlan.zhihu.com/p/160910342

https://www.cnblogs.com/jingzh/p/17753736.html

The Road to WebSockets | WebSocket.org

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值