JavaWeb高级编程(十)—— 在应用程序中使用WebSocket进行交互

一、从Ajax到WebSocket的演变

1、问题:从服务器获取新数据到浏览器

        使用Ajax,游览器可以从服务器抓取新的数据,但浏览器并不知道新数据什么时候可用,而服务器知道。例如:当两个用户在Web应用程序中聊天时,只有服务器知道用户A给用户B发送了一条消息,而浏览器并不知道,只有当浏览器向服务器请求数据时才知道这条消息的存在。这对于Ajax来说,是一个难以解决的问题。

2、解决方案1:频繁轮询

        以一个固定的频率,通常是每秒一次,浏览器将发送Ajax请求到服务器查询新数据。如果浏览器有新的数据要发送到服务器,数据将被添加到轮询请求中一起发送到服务器;如果服务器有新的数据要发送到浏览器,它将回复一个含有新数据的响应;如果没有就返回空。

        缺点:有大量的请求被浪费了,并且服务器作出了大量无效的响应,再加上创建连接、发送和接收头和关闭连接的时间,大量处理器资源和网络资源被浪费在查找服务器是否有新的数据上面。

3、解决方案2:长轮询

        长轮询类似于频繁轮询,不过服务器只有在发送数据时才会响应浏览器,这样更加高效,因为减少了被浪费的计算和网络资源。

        缺点:如果浏览器在服务器响应之前有新的数据要发送,浏览器必须创建一个新的并行请求或者终止当前请求创建一个新的请求;TCP和HTTP规范都指定了连接超时,客户端和服务器必须周期性的关闭和重建连接,通常连接需要每60秒关闭一次;HTTP/1.1规范存在着强制的连接限制, 浏览器最多只允许同时创建两个到相同主机名的连接,如果一个连接长期连接到服务器等待数据推送,那么它将减少一半可用于从服务器获取资源的连接。

4、解决方案3:分块编码

        分块编码类似于长轮询,它利用了HTTP/1.1的特性,服务器可以在不声明内容长度的情况下响应请求。响应不使用Content-Length响应头时,可以使用Transfer-Encoding:chunked响应头,这将告诉浏览器响应对象将被”分块发送“。

        块的内容:一个用于表示块长度的数字、一系列表示块扩展的可选字符和一个CRLF(回车加换行)序列、块包含的数据和另一个CRLF。可以使用任意数目的块,它们可以不是连续的,可以使用任意的时间间隔,当收到块的长度为0时,表示响应结束。

        使用块编码解决问题的具体步骤是:在开始的时候创建一个连接,只用于接收服务器发送的事件,来自服务器的每个块都是一个新的事件,它们将触发JavaScript XMLHttpRequest对象的onreadystatechange事件处理器的调用。在需要的时候连接仍然需要刷新,当浏览器需要发送新数据到服务器时,它将使用第2个短生命周期请求。块编码主要解决了长轮询中的超时问题。

        缺点:浏览器只能创建两个连接的限制依然存在;旧浏览器在使用长轮询和块编码时,它们会一直在状态栏中显示页面仍在加载的消息。

5、解决方案4:WebSocket

        前提:所有的HTTP客户端都可以在请求中包含Connection:Upgrade请求头,为了表示客户端希望升级,在额外的Upgrade头中必须指定一个或多个协议的列表,这些协议必须是兼容HTTP/1.1的协议,如果服务器接受升级请求,那么它将返回状态码101,并在响应的Upgrade头中使用单个值:请求协议列表中服务器支持的第一个协议。

        HTTP升级的特性:在升级握手完成之后,它就不再使用HTTP连接,并且我们甚至可以使用一个持久的、全双工TCP套接字连接,这需要指定某些协议,因此就产生了WebSocket协议。

        注意:如果服务器上的特定资源只接受HTTP升级请求,当客户端在不请求升级的情况下连接到该资源,服务器将会返回一个426响应,表示必须使用升级。如果客户端使用服务器不支持的协议请求升级,服务器将返回400响应。以上两种情况,服务器在返回时,都可以在Upgrade头中添加服务器所支持的协议列表。

        WebSocket连接:它首先将使用非正常的HTTP请求以特定的模式访问一个URL,URL模式ws和wss分别对应HTTP和HTTPS。除了要添加Connection:Upgrade请求头之外,还要添加Connection:websocket请求头,它们将告诉服务器把连接升级为WebSocket协议。在握手完成之后,文本和二进制消息将可以同时在两个方向上进行发送,而不需要关闭和重建连接。

        注意:模式ws和模式wss并不是HTTP协议的一部分,特有的WebSocket模式主要用于通知浏览器和API是希望使用SSL/TLS(wss 443端口),还是希望使用不加密的方式(ws 80端口)进行连接。

        WebSocket连接成功的最大障碍是HTTP代理,它有可能无法处理HTTP升级请求。使用WebSocket最可靠的方式是一直使用SSL/TLS(wss),代理通常不会干涉SSL/TLS连接,而是让它自己运行。使用了这种策略之后,WebSocket几千可以在所有的环境中正常工作,而且它也是安全的。

二、了解WebSocket API

1、JavaScript客户端API

(1) 创建WebSocket对象

        第一个参数是希望连接的WebSocket服务器要求使用的URL,可选的第二个参数是字符串或者字符串数组,它定义了一个或多个客户端定义的协议,这些协议都是由自己实现的,不受WebSocket技术管理。 

(2) 使用WebSocket对象

① websocket对象的属性

    //属性readyState:表示当前WebSocket连接的状态,它的值是CONNECTION(0),OPOEN(1),CLOSING(2),CLOSED(3)中的一个
    if(connection1.readyState === WebSocket.OPEN){
        //do something
    }
    //属性binaryType:通常应该在实例化WebSocket对象之后就立即设置binaryType属性,并在连接剩下的时间内一直使用该类型
    connection1.binaryType = 'arraybuffer';
    //属性bufferdAmount:表示之前的send调用还有多少数据需要发送到服务器

 ② websocket对象的事件

    //4种不同的事件
    connection1.onopen = function (event) {  };//握手完成,readyState从CONNECTING变成OPEN时触发
    connection1.onclose = function (event) { //readyState从CLOSING变成CLOSED时触发
        //该事件3个有用的属性:wasClean、code、reason,可以使用这些属性向用户报告一些非正常关闭的信息
        if(!event.wasClean){
            console.log(event.code + ':' + event.reason);
        }
    };
    connection1.onerror = function (event) {
        //该事件中包含一个data属性,它包含的是错误对象,通常是一个字符串。
    };
    connection1.onmessage = function (event) {
        //该事件也包含了一个data属性。
        //如果消息是一个文本消息,该属性就是一个字符串
        //如果消息是一个二进制消息,它就是一个Blob数据并且WebSocket的binaryType属性将被设置为blob(默认)
        //如果消息是一个二进制消息,并且binaryType被设置为arraybuffer,那么该属性的值将是一个ArrayBuffer
    }

 ③ websocket对象的方法

    //两个方法:send()和close()
    //方法close:接受一个可选的关闭代码作为它的第一个参数(默认为1000),一个可选的字符串reason作为它的第二个参数(默认为空)
    connection1.close();
    //方法send:接受一个字符串、Blob、ArrayBuffer或者ArrayBufferView作为它的唯一参数,它是唯一可以使用bufferdAmount属性的地方
    connection1.send('');
    //示例:不存在等待数据时发送数据到服务器,每隔100毫秒刷新一次数据,如果缓存中仍有数据等待发送,它将等待下一个100毫秒
    var updatedModelData = [];
    connection1.onopen = function (event) { 
        var intervalId = window.setInterval(function () {
            if(connection1.readyState !== WebSocket.OPEN){//如果连接未处于打开状态,它就停止数据发送并清除间隔调用
                window.clearInterval(intervalId);
            }
            if(connection1.bufferedAmount === 0){
                connection1.send(updatedModelData);
            }
        },100);
    }

2、Java WebSocket API

⑴ 客户端API

@ClientEndpoint
public class WebSocketClientAPI extends HttpServlet{
    private Session session;
    private String param;

    /**
     * 客户端API
     * 它基于ContainerProvider类和WebSocketContainer、RemoteEndpoint和Session接口构建
     * ContainerProvider提供了一个静态的getWebSocketContainer方法,用于获取底层WebSocket客户端实现
     * WebSocketContainer提供了对所有WebSocket客户端特性的访问
     * 它提供了4个重载的connectToServer方法,它们都接受一个URI,用于连接远程终端和初始化握手
     * 握手完成时,connectToServer方法将返回一个Session,通过Session对象可以关闭会话(关闭WebSocket连接)或者发送消息到远程服务端
     *
     * 另外,WebSocket的Endpoint有3个方法onOpen、onClose、和onError,它们将在这些事件发生时调用,而@ClientEndpoint类可以有标注
     * 了@OnOpen 、@OnClose和@OnError的方法,可以指定一个或多个标注了@OnMessage的方法,用于从远程终端接收消息。
     *
     * 注意:关于打开、关闭和错误事件,一个终端只能有一个方法分别用于处理它们;不过,它最多可以有三个消息处理方法:只能有一个用于处理
     * 文本消息、一个用于处理二进制消息、一个用于处理pong消息。
     */
    public void initClient() throws ServletException{
        param = this.getInitParameter("param");
        String path = this.getServletContext().getContextPath() + "/serverWebSocket/" + param;
        URI uri = null;
        try {
            uri = new URI("ws","localhost:8080",path,null,null);
            WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer();
            webSocketContainer.connectToServer(this,uri);
        } catch (URISyntaxException | IOException | DeploymentException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void destroy() {
        try {
            this.session.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {

    }

    @OnMessage
    public void onMessage(InputStream in){

    }

    @OnClose
    public void onClose(CloseReason reason){

    }
}

⑵ 服务端API

@ServerEndpoint("/serverWebSocket/{param}")
public class WebSocketServletAPI {

    /**
     * 服务端API
     * 服务器API依赖于完整的客户端API,ServerContainer继承了WebSocketContainer,它添加了通过编程方式注册ServerEndpointConfig实例
     * 的方法和标注了@ServerEndpoint的类。在Servlet环境中,调用ServletContext.getAttribute("javax.websocket.server.ServerContainer")
     * 可以获得ServerContainer实例。如果是在独立运行的程序中,需要按照特定WebSocket实现的指令获得ServletContainer实例。不过,几乎在
     * 所有的Java EE用例中你都不需要获得ServerContainer,你只需要使用@ServerEndpoint标注服务器终端类即可,WebSocket实现可以扫描类
     * 的注解,并自动选择和注册服务器终端。容器将在每次收到WebSocket连接时创建对应终端类的实例,在连接关闭之后再销毁该实例。在使用@ServerEndpoint
     * 时,至少需要指定必须的value特性,它表示该终端可以做出响应的应用程序相对的URL,该URL路径必须以斜杠开头,并可以包含模板参数,
     * 比如:@ServerEndpoint("/serverWebSocket/{param}")
     * 然后,服务器终端中的所有@OnOpen 、@OnClose和@OnError或者@OnMessage方法都可以使用@PathParam("param")标注一个可选的额外参数
     *
     * 服务器终端中的事件处理方法将如同客户端终端中的事件处理方法一样工作,服务器和客户端的区别只在握手的时候。在握手完成,连接建立之后,
     * 服务器和客户端都将变成工作端点,并且是具有相同能力、责任完全对等的终端。
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("param") String param){

    }

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

    }

    @OnMessage
    public void onMessage(Session session, byte[] message){

    }
}

三、在集群中使用WebSocket进行通信

1、使用两个Servlet实例模拟简单的集群

        在一个标准的集群场景中,节点将通过某种方式通知其他节点它们的存在,通过将一个数据包发送到一个规定好的多播IP地址和端口上,它们将通过某种其他方式建立通信,例如TCP套接字。下面创建一个Servlet,将其映射两次,如下:



  
    clusterNode1
    com.mengfei.example1.ClusterNodeServlet
    
      nodeId
      1
    
  
  
    clusterNode1
    /clusterNode1
  

  
    clusterNode2
    com.mengfei.example1.ClusterNodeServlet
    
      nodeId
      2
    
  
  
    clusterNode2
    /clusterNode2
  

先创建一个实现序列化接口的集群消息POJO类,如下:


public class ClusterMessage implements Serializable
{
    private static final long serialVersionUID = -5309990726684354266L;
    private String nodeId;

    private String message;

    public ClusterMessage()
    {

    }

    public ClusterMessage(String nodeId, String message)
    {
        this.nodeId = nodeId;
        this.message = message;
    }

    public String getNodeId()
    {
        return nodeId;
    }

    public void setNodeId(String nodeId)
    {
        this.nodeId = nodeId;
    }

    public String getMessage()
    {
        return message;
    }

    public void setMessage(String message)
    {
        this.message = message;
    }
}

然后创建 ClusterNodeServlet类,如下

/**
 * author Alex
 * date 2018/12/23
 * description WebSocket通信的客户终端
 * 任何类都可以是终端,这里使用了Servlet,因为它简单方便,方法init将在第一个请求到达时调用,用于连接服务器端,
 * 方法destroy用于关闭连接,每次请求进入的时候,Servlet将会向集群发送关于它的信息,方法onMessage将接受来自
 * 其他集群节点回复的消息,而onClose将在连接异常关闭时打印出错误的消息。
 */
@ClientEndpoint
public class ClusterNodeServlet extends HttpServlet{
    private Session session;
    private String nodeId;

    @Override
    public void init() throws ServletException {
        nodeId = this.getInitParameter("nodeId");
        String path = this.getServletContext().getContextPath() + "/clusterNode/" + nodeId;
        try {
            URI uri = new URI("ws","localhost:8082",path,null,null);
            //获取WebSocket容器
            WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer();
            //通过WebSocket容器连接到服务端并获取session
            session = webSocketContainer.connectToServer(this, uri);
        } catch (URISyntaxException | DeploymentException | IOException e) {
            throw new ServletException("不能够连接到" + path ,e);
        }
    }

    @Override
    public void destroy() {
        try {
            this.session.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        ClusterMessage clusterMessage = new ClusterMessage(nodeId,"信息 - 请求IP:" +
        req.getRemoteAddr() + ",请求参数:" + req.getQueryString());
        //获取对象输出流ObjectOutputStream,它可以将对象的原始数据写入OutputStream,可以使用流的方式来实现对象的持久存储
        //如果是套接字流,则可以在另一个主机上使用ObjectInputStream进行对象重构
        try (OutputStream output = this.session.getBasicRemote().getSendStream();
             ObjectOutputStream outputStream = new ObjectOutputStream(output)) {
            outputStream.writeObject(clusterMessage);
        }
        resp.getWriter().append("OK");
    }

    @OnMessage
    public void onMessage(InputStream inputStream){
        //获取对象输入流ObjectInputStream,进行对象重构
        try (ObjectInputStream objectInputStream = new ObjectInputStream(inputStream)) {
            ClusterMessage message = (ClusterMessage)objectInputStream.readObject();
            System.out.println("信息 - 当前节点:" + nodeId + ",消息来自节点" +
                    message.getNodeId() + ",消息是:" + message.getMessage());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(CloseReason reason){
        CloseReason.CloseCode code = reason.getCloseCode();
        if(code != CloseReason.CloseCodes.NORMAL_CLOSURE){
            System.out.println("错误 - WebSocket关闭异常!");
        }
    }
}

再创建 ClusterNodeEndpoint类,如下:

/**
 * author Alex
 * date 2018/12/23
 * description WebSocket通信的服务终端
 * 服务端使用了Java序列化器发送和接收ClusterMessage,此时WebSocket消息必须是二进制的。Java序列化器比Json更快,
 * 所以最好使用Java序列化器,前提是两个终端都采用Java编写时才可以使用,如果只有一个节点是Java,那么就只能使用其
 * 他方式了,比如:Json
 * 
 * 服务器终端唯一的责任是将一个节点发送过来的信息再发送到所有其他节点中,并在其他节点加入或离开集群时进行广播。当
 * 然,在不同的需求中有不同的连接模型,可以用中央终端发送和接收消息,也可以让每个节点直接与其他节点互联。
 */
@ServerEndpoint("/clusterNode/{nodeId}")
public class ClusterNodeEndpoint {
    private static final List nodes = new ArrayList<>(2);

    @OnOpen
    public void onOpen(Session session, @PathParam("nodeId") String nodeId){
        System.out.println("信息 - 节点" + nodeId + "连接到集群");
        ClusterMessage message = new ClusterMessage(nodeId,"节点" + nodeId + "加入集群");
        try {
            byte[] bytes = ClusterNodeEndpoint.toByteArray(message);
            //有新的节点加入集群时进行广播
            for (Session node : ClusterNodeEndpoint.nodes){
                node.getBasicRemote().sendBinary(ByteBuffer.wrap(bytes));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        //将新的节点session加入集合
        ClusterNodeEndpoint.nodes.add(session);
    }

    @OnMessage
    public void onMessage(Session session,byte[] bytes){
        try {
            //有新的消息时,对除了它自己之外的所有节点进行广播
            for (Session node : ClusterNodeEndpoint.nodes){
                if(node != session){
                    node.getBasicRemote().sendBinary(ByteBuffer.wrap(bytes));
                }
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session,@PathParam("nodeId") String nodeId){
        System.out.println("信息 - 节点" + nodeId + "断开连接");
        //将断开连接的节点移除集合
        ClusterNodeEndpoint.nodes.remove(session);
        ClusterMessage message = new ClusterMessage(nodeId,"节点" + nodeId + "离开集群");
        try {
            byte[] bytes = ClusterNodeEndpoint.toByteArray(message);
            //有新的节点离开集群时也要进行广播
            for (Session node : ClusterNodeEndpoint.nodes){
                node.getBasicRemote().sendBinary(ByteBuffer.wrap(bytes));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 转换字节数组
     * @param message
     * @return
     * @throws IOException
     */
    private static byte[] toByteArray(ClusterMessage message) throws IOException{
        try (ByteArrayOutputStream output = new ByteArrayOutputStream();
             ObjectOutputStream outputStream = new ObjectOutputStream(output)) {
            outputStream.writeObject(message);
            return output.toByteArray();
        }
    }
}

2、测试模拟集群应用程序

① 启动tomcat,首先访问http://127.0.0.1:8082/clusterNode1

信息 - 节点1连接到集群 //此时是Servlet映射clusterNode1在进行初始化

② 再访问http://127.0.0.1:8082/clusterNode2

信息 - 节点2连接到集群 //此时是Servlet映射clusterNode2在进行初始化
信息 - 当前节点:1,消息来自节点2,消息是:节点2加入集群 //对已经加入的节点进行广播
信息 - 当前节点:1,消息来自节点2,消息是:信息 - 请求IP:127.0.0.1,请求参数:null //同上

③ 现在访问http://127.0.0.1:8082/clusterNode1?hello=websocket

//此时两个Servlet均已经初始化,现在只有信息广播,节点1发起的请求就对节点2进行广播
信息 - 当前节点:2,消息来自节点1,消息是:信息 - 请求IP:127.0.0.1,请求参数:hello=websocket

④ 最后再访问http://127.0.0.1:8082/clusterNode2?param=123

//同上
信息 - 当前节点:1,消息来自节点2,消息是:信息 - 请求IP:127.0.0.1,请求参数:param=123

 

 

你可能感兴趣的:(JavaWeb高级编程)