【Web 开发须知】WebSocket 开发指南

5,140
原文链接: click.aliyun.com

春节假期看了一下 websocket, 做了一篇笔记, 原文链接: oolap.com/websocket

    WebSocket 由来已久, 常用于 "服务器推" 场景。最近开始学习 WebSocket (从 tomcat examples 开始), 本文的主要目的是做学习笔记, 同时记录一份开发指南。

    本文示例代码见: github.com/hanyong/exe…

HTTP 协议简述

我们先来看看 HTTP。

一次 HTTP 请求过程如下:

客户端 服务器
1. 客户端建立到服务器的 TCP 连接
2. 客户端发送请求
3. 客户端等待响应
4. 服务器收到请求
5. 服务器发送响应
6. 客户端收到响应
7. 请求结束

TCP 连接是支持双向同时读写的全双工协议, 但是我们看传统的 HTTP 协议有几个问题:

  1. 请求过程是串行的, 客户端与服务器互相等待.
  2. 请求是单向的, 总是必须客户端先发起请求.

也就是说, 传统的 HTTP 1.0/1.1 协议没有充分利用 TCP 连接的能力.

  1. HTTP 协议是无状态的, 两个请求是从同一个 TCP 连接发过来, 还是从不同的 TCP 连接发过来, 对服务器来说应该是等价的.

    HTTP 协议这样的设计主要是简化了编程模型, 想一想传统的 CGI 脚本, 一个脚本只要能够接受输入, 产生输出, 就可以提供 web 服务。HTTP 协议缺少 ISO 7 层网络模型中的会话层, 动态 web 应用使用 cookie 来保存会话信息。HTTP/1.1 默认开启长连接来优化性能, 但 HTTP 连接和请求依然是无状态的。对传统提供静态内容服务, 或返回信息相对确定的 web 应用而言, 这样的设计并没有问题, 或者说虽然有一些不足, 但尚能忍受。无状态的设计也简化了 HTTP 测试, 日志回放也成为重要的 HTTP 服务测试手段之一。

websocket 协议简述

    直到 "服务器推" 场景的出现。服务器端信息随时可能变化, 我们希望将变化后最新的信息立即通知给客户端。传统的解决方案是客户端不断轮询服务器, 如每秒 1 次。这种轮询将产生许多额外的代价, 包括移动端流量收费, 并且编程模型也相对复杂。因此, 是时候开放 TCP 双向通信的能力了。我们可以重新写一个 TCP 服务器, 使用新的协议来通信。但也许是为了复用 HTTP 的 80 端口, 依附现有 HTTP 生态圈, 让 web 应用平滑升级, websocket 基于 HTTP 协议设计, 顾名思义就是基于 web HTTP 协议, 释放原生 TCP socket 的能力。所以 websocket 一开始还是按 HTTP 协议通信, 随后才转换成 websocket。

一个 websocket 连接的建立过程如下:

客户端 服务器
1. 客户端建立到服务器的 TCP 连接
2. 客户端请求将当前 TCP 连接用作 websocket
3. 服务器收到请求, 同意并确认将此 TCP 连接用作 websocket
4. 客户端收到确认, HTTP 协议通信结束
5. 双方使用 websocket 协议自由双向通信

websocket 可基于 HTTP 建立, 即 ws 协议, 也可基于 HTTPS 建立, 即 wss 协议, 果然是复用了 HTTP 的基础设施。

HTTP 与 websocket 客户端

HTTP 客户端发送完请求后才会监听响应, 收到一次响应后即结束。常见的 HTTP 客户端有:

  1. curl, 如 curl localhost:8080/http.
  2. 浏览器 js 客户端, 如 angularjs 的 $http 服务.

    $http.get("/http").then(function(resp) {
        vm.msg = resp.data;
    });
    
  3. 直接在浏览器输入 URL.

    回顾 "服务器推" 场景, websocket 与 HTTP 协议最大的不同在于服务器不必等待请求, 也不再使用 "请求", "响应" 这样的术语, 而改称为消息, 双方都可以随时互发消息。HTTP 客户端不会一直监听消息, 所以显然不能作为 websocket 客户端 (且不说协议是否兼容)。要使用 websocket, 客户端和服务器都需要改造。常见的 websocket 客户端有:

  1. 浏览器 js 客户端。感谢浏览器厂商, 现在的主流浏览器都支持 websocket 。
    参考: developer.mozilla.org/en-US/docs/…

    var uri = "ws://" + window.location.host + "/ws";
    vm.ws = new WebSocket(uri);
    vm.ws.onmessage = function(event) {
        vm.msg = event.data;
        $scope.$apply();
    }
    

websocket 服务端开发

    再来看服务端开发, java 定义了一套 javax.servlet-api, 一个 HttpServlet 就是一个 HTTP 服务。java websocket 并非基于 servlet-api 简单扩展, 而是新定义了一套 javax.websocket-api。一个 websocket 服务对应一个 Endpoint。与 ServletContext 对应, websocket-api 也定义了 WebSocketContainer, 而编程方式注册 websocket 的接口是继承自 WebSocketContainerServerContainer。一个 websocket 可以接受并管理多个连接, 因此可被视作一个 server。主流 servlet 容器都支持 websocket, 如 tomcat, jetty 等。看 ServerContainer api 文档, 可从 ServletContext attribute 找到 ServerContainer

    @Bean
    public ServerContainer serverContainer(ServletContext context) {
        return (ServerContainer) context.getAttribute(ServerContainer.class.getName());
    }

注册 Endpoint 关键需要两个东西, Endpoint 类和对应 URL 路径, 代码如下:

@org.springframework.context.annotation.Configuration
public class WsConfig implements ApplicationRunner {

    @Autowired
    protected ServerContainer serverContainer;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        ServerEndpointConfig sec = ServerEndpointConfig.Builder.create(Ws.class, "/ws").build();
        serverContainer.addEndpoint(sec);
    }

}

一个简单 servlet 示例如下:

public class Http extends HttpServlet {

    private static final long serialVersionUID = 1L;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String msg = "Hello Http";
        resp.setContentType(MimeTypeUtils.TEXT_PLAIN_VALUE);
        resp.getWriter().println(msg);
    }

}

一个简单 Endpoint 示例如下:

public class Ws extends Endpoint {

    @Override
    public void onOpen(Session session, EndpointConfig config) {
        String msg = "Hello WebSocket";
        Basic remote = session.getBasicRemote();
        try {
            remote.sendText(msg);
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

}

两者貌似相似, 其实有很大不同。

  1. doGet() 是处理一次请求, 输入参数 reqresp 一次有效, 本次请求返回即失效。
  2. onOpen() 是打开 websocket 会话, 输入参数 session 会话内一直有效, 可收发多次消息。 示例中会话打开后即发送第一条消息。
  3. Endpoint 是有状态的, 容器为每个会话创建一个 Endpoint 对象实例, 维护当前会话状态信息。 所以注册 Endpoint 必须使用类而不能使用对象, 且 Endpoint 类必须有无参构建函数。 而 Servlet 是无状态的, 可以使用 Servlet 实例注册, 多连接多线程均只有一个 Servlet 对象实例。
  4. websocket 连接是有状态的, 必须使用长连接, 一个连接天然就是一个会话 session。 HTTP 连接是无状态的, Servlet 借助 cookie 管理会话, 对是否长连接无感知。 注意: websocket 长连接与 HTTP 长连接有很大不同。 HTTP 长连接只是为了向同一服务器发送请求时复用已有的 TCP 连接, 优化性能, 发送请求带不同的 cookie 就可能关联不同的 HTTP session。 一个 websocket 长连接只为一个会话服务, 只能收发该会话的消息。 HTTP 长连接转化为 websocket 后, 就不能再用于发送 HTTP 请求。
  5. HTTP 请求是串行的, 一个 HTTP 长连接必须在上一个请求响应返回后, 才能继续发送请求。 websocket 双方可以自由收发消息, 不必等待, 刚收到的消息未必与刚发出去的消息对应。 收发消息的含义应该在建立 websocket 连接时便已经确认。
  6. 由于 websocket 收发消息的含义在建立 websocket 连接时便已经确认, 收发消息时可以省去很多头信息和参数, 包括标识会话的 cookie 信息, 有效节约带宽。

服务端代码结合前面的客户端代码, 即可测试 websocket。

使用 curl 测试 websocket

    前面提到的 websocket 客户端只有浏览器 js。回顾建立 websocket 连接的流程, 客户端只需要发一段 HTTP 请求, 那么是否可以使用 curl 建立 websocket 连接呢? 经测试是可以的, 所需参数信息如下, 并且 curl 也不会不停的接收响应和消息。

--no-buffer -H 'Connection: keep-alive, Upgrade' -H 'Upgrade: websocket' -v -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: websocket'

    curl 默认会缓冲区满或接收完响应才输出, 由于 websocket 响应 "永不结束", --no-buffer 禁用 curl 内部的缓冲区, 使其立即输出接收到的信息。其他参数为复制浏览器建立 websocket 时发送请求信息, 筛选出的必须信息。

使用 curl 测试 websocket, 结果如下:

$ curl --no-buffer -H 'Connection: keep-alive, Upgrade' -H 'Upgrade: websocket' -v -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: websocket' http://localhost:8080/ws | od -t c
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /ws HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> Connection: keep-alive, Upgrade
> Upgrade: websocket
> Sec-WebSocket-Version: 13
> Sec-WebSocket-Key: websocket
> 
< HTTP/1.1 101 
< Upgrade: websocket
< Connection: upgrade
< Sec-WebSocket-Accept: qVby4apnn2tTYtB1nPPVYUn68gY=
< Date: Tue, 31 Jan 2017 12:31:23 GMT
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0{ [17 bytes data]
0000000 201 017   H   e   l   l   o       W   e   b   S   o   c   k   e
100    32    0    32    0     0     26      0 --:--:--  0:00:01 --:--:--    26^C

    由于 websocket 消息含有二进制头部, 使用 od -t c 进行转义, 消息头部为 201 017 两个字节, 其中 017 = 15, 应该表示消息长度。消息长度使用了变长整数表示, 长度超过 127 时会使用多字节表示长度。我们看到消息体没有全部输出, 这是因为 od 命令页做了缓冲, 攒满一行才输出, 修改为 od -t c -w1 -v, 即一个字节一行, 即可避免这个问题, 但输出消息将被拆成很多行。
-v 表示显示重复行, 默认会压缩消除重复行。

    上述头信息中, Sec-WebSocket-Key 是用于测试服务器是否支持 websocket, 详情参考 tools.ietf.org/html/rfc645… 。经测试输入头使用任意值均可, 服务器返回正确的 Sec-WebSocket-Accept 信息表示它支持 websocket, 恰当的 websocket 客户端应该要校验这个值。

    使用 sendText() 时, websocket 自动添加了消息头信息, 以自动实现消息封帧和拆帧。websocket 还支持发送二进制消息或发送流式数据。测试发现一个 websocket 可以同时支持二进制和文本消息收发。但当正在发送流式消息时, 不能发送其他类型消息。

    websocket 发送流式二进制数据时, 是否可以作为原始的 TCP socket 使用呢? 即照搬所有数据, 不要加消息帧头部。测试代码如下:

public class Ws extends Endpoint {

    @Override
    public void onOpen(Session session, EndpointConfig config) {
        byte[] msg = { 'w', 's', };
        Basic remote = session.getBasicRemote();
        try {
            OutputStream out = remote.getSendStream();
            for (int i = 0; i < 1; ++i) {
                for (int j = 0; j < 4; ++j) {
                    out.write(msg[j % msg.length]);
                }
                out.flush();
            }
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

}

    其中外层循环表示写几次数据, 内层循环表示每次写数据长度。测试发现只写一次并且长度小于4时, 客户端收不到任何数据, bug OR 用法不对?

测试发现如下问题:

  1. 数据长度很小并且不调用 flush() 时, 客户端也始终收不到数据。 这也是与 servlet 不一样的地方, servlet 正常情况下不应该调用 flush(), servlet 方法返回后响应就会返回给客户端, 显式调用 flush() 会导致以 Chunked 方式立即返回部分内容。
  2. 流式发送数据依然会添加消息头部. od 输出如下:

    0000000 002
    0000001 004
    0000002   w
    0000003   s
    0000004   w
    0000005   s
    

    并且多次写数据的话, 每次写数据都会添加头部。写 3 次 od 输出如下:

    0000000 002
    0000001 004
    0000002   w
    0000003   s
    0000004   w
    0000005   s
    0000006  \0
    0000007 004
    0000010   w
    0000011   s
    0000012   w
    0000013   s
    0000014  \0
    0000015 004
    0000016   w
    0000017   s
    0000020   w
    0000021   s
    

    第一次头部不一样, 之后每次头部都是 \0 + 数据长度。

  3. 一次写数据很长时, 不用调用 flush() 也会发送数据到客户端 (缓冲区满?),
    并且发送数据拆为 N 段, 每段都会加消息头。

问题: 如何结束流式消息发送?

    由此可知, websocket 始终会加消息头进行分帧, 不能作为原始的 TCP socket 使用。想想也是, 不加消息头 websocket 也不能区分流式数据和分帧消息, 并且普通消息间还可以夹带 ping/pong 应用层心跳检测。因此 websocket 应该是一个支持消息封帧和应用层心跳检测的会话层协议。

在 spring 中使用 websocket

    spring 对 websocket 提供了很好的支持, 参考文档: docs.spring.io/spring/docs… .

    前面提到 java websocket-api 要求使用 Endpoint class 注册 websocket, 然后由 Servlet 容器为每个连接创建 Endpoint 对象实例, 这样就很难将 Endpoint 实例纳入 spring 容器中。spring 对 websocket 的处理与使用 DispatcherServlet 处理 HTTP 请求类似。spring 定义了 WebSocketHandler 接口处理 websocket 请求, 类似 HTTP 的 HttpRequestHandler。然后 spring 拦截所有托管的 websocket 请求, 分发到 WebSocketHandler 上。唯一的缺点是 WebSocketHandlerHttpRequestHandler 一样是无状态的单例, 不能直接保存单个会话状态, 然而这并没有关系。接下来我们就可以忘记 java websocket-api, 使用 spring websocket API 来编程了。

前面示例的 websocket, 使用 spring 重写如下:

public class SpringWs extends AbstractWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        super.afterConnectionEstablished(session);
        session.sendMessage(new TextMessage("Hello WebSocket"));
    }

}

注册 websocket 代码如下:

@Configuration
@EnableWebSocket
public class SpringWsConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new SpringWs(), "/ws");
    }

}

看一下 AbstractWebSocketHandler 方法定义, 我们发现少了流式数据收发处理, 但封装简化了 web 应用常用的消息收发处理。

websocket 心跳检测

    websocket 提供了应用层心跳检测, 由 ping/pong 消息组成。ping 表示 "你在吗?", pong 表示 "我在!"。ping/pong 对应 IP 的 ping 请求和 ping 响应, websocket 不使用请求响应模式, 所以都叫做消息。websocket 区分数据消息和控制消息, 因此只监听数据消息是不会收到控制消息的。ping/pong 属于控制消息。浏览器通常不支持控制 ping/pong 消息, 对 ping 直接回复 pong, 忽略 pong。java websocket-api 和 spring 都对发送 ping/pong 提供了直接支持, 默认忽略 pong。

websocket java 客户端

    websocket 一旦建立连接以后, 客户端与服务器是对等的, 都叫 Endpoint, 两端可以复用协议解析和消息监听的代码。因此 java websocket-api 也定义了客户端 API, 调用 WebSocketContainer.connectToServer() 即可建立客户端到服务器的连接。前面提到 ServerContainer 即是 WebSocketContainer, 客户端也可调用 ContainerProvider.getWebSocketContainer() 获取 WebSocketContainer
示例代码如下:

public class Client {

    public static void main(String[] args) throws Exception {
        WebSocketContainer container = ContainerProvider.getWebSocketContainer();
        URI path = new URI("ws://localhost:8080/ws");
        Session session = container.connectToServer(new Ws(), path);
        session.addMessageHandler(new javax.websocket.MessageHandler.Whole<String>() {
            @Override
            public void onMessage(String message) {
                System.out.println("Client get message: " + message);
            }
        });
        Thread.sleep(5000);
    }

}
  1. java websocket-api 客户端代码是注解导向的, Endpoint 类必须加 @ClientEndpoint 注解. onOpen() 方法也必须添加 @OnOpen 注解才会生效. Endpoint 类使用普通 POJO 类即可.
  2. 由于客户端可以控制每一个连接的创建过程, 可以使用 Endpoint 对象示例作为参数, 这样应该更容易与 spring 集成.
  3. Endpoint 类的最佳实践应该只用于在会话期间保存会话状态.
  4. websocket 客户端不阻塞进程(没有前台线程?), 因此示例程序添加 sleep 避免程序立即退出。
  5. websocket 客户端不依赖 servlet 容器, 普通应用也可以很容易的使用 websocket。

执行上述客户端并没有收到消息, 猜想是执行 addMessageHandler() 时已经收完消息了。
Endpoint 类添加消息处理:

    @javax.websocket.OnMessage
    public void onMessage(String message) {
        System.out.println("Endpoint onMessage: " + message);
    }

这回终于收到消息了, 同时出现如下异常:

Exception in thread "main" java.lang.IllegalStateException: A text message handler has already been configured
    at org.apache.tomcat.websocket.WsSession.doAddMessageHandler(WsSession.java:252)
    at org.apache.tomcat.websocket.WsSession.addMessageHandler(WsSession.java:213)
    at atest.Client.main(Client.java:16)

可知:

  1. 要想不遗漏消息, 应在 Endpoint 类上添加消息处理方法(使用注解), 而 addMessageHandler() 用于动态添加消息处理器。 去掉 Endpoint 类上的消息处理, 服务器延时发送消息, 客户端果然也能收到消息。 或者 websocket 会话应用层维护 ready 状态, 客户端初始化完成再告诉服务端 ready 。
  2. 一种消息类型 (如 String 文本消息) 只能设置一个消息处理器。

    spring 也对 websocket 客户端进行了简单封装, 并且复用了 WebSocketHandler 接口设计, 建立连接的核心类是 WebSocketConnectionManager,
示例代码如下:

public class SpringClient {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(SpringClient.class, SpringWs.class);
        app.setApplicationContextClass(AnnotationConfigApplicationContext.class);
        WebSocketConnectionManager conn = app.run(args).getBean(WebSocketConnectionManager.class);
        conn.start();
    }

    @Bean
    public WebSocketClient webSocketClient() {
        return new StandardWebSocketClient();
    }

    @Bean
    public WebSocketConnectionManager conn(WebSocketClient client, WebSocketHandler handler) {
        return new WebSocketConnectionManager(client, handler, "ws://localhost:8080/ws");
    }

}

    客户端与服务器使用了相同的 WebSocketHandler, 运行示例程序后客户端与服务器各自发出并收到一条相同的消息。注意这里客户端没有 sleep, 客户端优雅退出前已经收到消息。

    修改 WebSocketHandler 发送 ping 消息, 打印 pong 消息, 测试发现服务器和客户端都收到一条 pong 消息。可见 spring 也对 ping 消息做了处理, 自动回复 pong 消息。

    spring websocket API 整体感觉比 javax.websocket-api 更加简单一致。除了少了流式读写接口, 然而这并不重要。