WebSocket基础知识

WebSocket的生命周期

Java Websocket API中的WebSocket生命周期

WebSocket端点的四个生命周期事件
+ 打开事件:此事件发生在端点上建立新连接时并且在任何其他事件发生之前
+ 消息事件:此事件接收WebSocket对话中另一端发送的消息。它可以发生在WebSocket端点接收了打开了事件之后并且在接收关闭事件关闭连接之前的任意时刻。
+ 错误事件:此事件在WebSocket连接或者端点发生错误时产生
+ 关闭事件:此事件表示WebSocket端点的连接目前正在部分地关闭,它可以由参与连接的任意一个端点发出

注解式端点事件处理

服务器端点需要使用一个类级别注解@ServerEndpoint,客户端端点则需要@ClientEndpoint注解。对于注解式端点来说,为了拦截不同的生命周期事件,我们需要利用方法级注解:@OnOpen,@OnMessage,@OnError和@OnClose。

  • @OnOpen 此注解用于注解式端点的方法,指示当此端点建立新的连接时调用此方法。需要一个方法来处理打开事件的主要原因是,使得开发人员能够设置在WebScoket对话中的信息,你可能希望为准备数据库而执行一些花费昂贵的必要操作,例如在处理事件的方法中打开数据库连接。此事件伴随着三部分信息:WebSocket Session对象,用于表示已经建立好的连接;配置对象(EndpointConfig的实例),包含了用来配置端点的信息;一组路径参数,用于打开阶段握手时WebSocket端点匹配入URL。
@OnOpen
public void init(Session session, EndpointConfig config){
    // initialization code
}
  • @OnMessage 此注解允许你装饰你希望处理入站消息的方法。Java WebSocket API中的消息事件伴随的信息是Session对象,EndpointConfig对象,打开阶段握手中从匹配入站URI过程中获取的路径参数以及最重要的消息本身。连接上的消息将以3种基本形式抵达:文本消息二进制消息Pong消息
// 文本消息处理方法
@OnMessage
public void handleTextMessage(String textmessage{
    // process the textMessage here
}

// 文本消息高级选项:分批接收文本
@OnMessage
public void catchDocumentPart(String text, boolean isLast){
    // process the textMessage here
}

// 二进制消息处理方法
@OnMessage
public void processBinary(byte[] messageData, Session session) {
    // process binary data here
}

// 二进制消息高级选项:分批接收二进制数据
@OnMessage
public void processVideoFragment(byte[] partialData, boolean isLast){
    if (!isLast){
        // there is more to come;
    } else {
        // now we have the whole message;
    }
}

// 使用java.io.InputStream来处理二进制消息
@OnMessage
public void handleBinary(InputStream is){
    // read
}

// 同理使用java.io.Reader处理文本消息

// Pong消息
@OnMessage
public void processPong(PongMessage message){
    // process pong 
}

事实上,处理消息还有更多的选项:你甚至可以让WebSocket实现把入站消息转换成自己选择的对象。

WebSocket应用一般是异步的双向消息。换言之,典型应用并不总是立即响应入站消息。尽管如此,在一些场景下你希望立刻响应入站消息。因此,通过@OnMessage注解的此类方法上有一个额外选项:方法可以有返回类型或者返回为空。当使用@OnMessage注解的方法有返回类型时,WebSocket实现立即将返回值作为消息返回给刚刚在方法中处理的消息的发送者。

  • @OnError 此注解可以用来注解WebSocket端点的方法,使其可以处理WebSocket实现处理入站消息时发生的任何错误。
@Error
public void errorHandler(Throwable t){
    // log error here
}
  • @OnClose 可以用来注解多种不同类型的方法来处理关闭事件。伴随关闭事件的信息是关闭信息,与建立连接的打开阶段握手相关联的任意一个路径参数,以及一些描述连接关闭原因的信息。
@OnClose
public void goodbye(CloseReason cr){
    // log the reason for posterity
    // close database connection
}

以下是完整示例

import java.io.*;
import java.io.IOException;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint("/lights")
public class LifecycleEndpoint {
    private static String START_TIME = "Start Time";
    private Session session;

    @OnOpen
    public void whenOpening(Session session){
        this.session = session;
    session.getUserProperties().put(START_TIME, System.currentTimeMillis());
    this.sendMessage("3:Just opened");
    }

    @OnMessage
    public void whenGettingAMessage(String message){
        if (message.indexOf("xxx") != -1){
        throw new IllegalArgumentException("xxx not allowed !!");
    } else if (message.indexOf("close") != -1){
        try {
            this.sendMessage("1:Server closing after "+this.getConnectionSeconds()+"s");
        session.close();
        } catch (IOException ioe){
            System.out.println("Error closing session "+ioe.getMessage());
        }
        return; 
    }
    this.sendMessage("3:Just processed a message");
    }

    @OnError
    public void whenSomethingGoesWrong(Throwable t){
        this.sendMessage("2:Error:"+t.getMessage());
    }

    @OnClose
    public void whenClosing(){
        System.out.println("Goodbye !");
    }

    void sendMessage(String message){
        try {
        session.getBasicRemote().sendText(message);
    } catch (Throwable ioe){
        System.out.println("Error sending message "+ioe.getMessage());
    }
    }

    int getConnectionSeconds(){
        long millis = System.currentTimeMillis()-((Long)this.session.getUserProperties().get(START_TIME));
    return (int)millis/1000;
    }
}

编程式端点生命周期

生命周期事件

事件 端点方法
打开 public abstract void onOpen(Session session, EndpointConfig config)
错误 public void onError(Session session, Throwable thr)
关闭 public void onClose(Session session, CloseReason cr)

处理消息

为了处理入站消息,需要提供MessageHandler实现。
+ 对于文本消息,使用MessageHandler.Whole
+ 对于二进制消息,使用MessageHandler.Whole

一旦你实现了上述一个或者两个接口来定义希望消费者消息的方式,你需要做的所有事情是通过调用

session.addMessageHandler(myMessageHandler handler)

在第一个消息到达之前的某一时刻注册你的消息处理程序到代表你有兴趣侦听的连接的Session对象上。通常,端点将在onOpen()方法中添加其消息处理程序,因此可以确保不遗漏任何消息

以下是编程式的实现

import java.io.IOException;
import javax.websocket.CloseReason;
import javax.websocket.Endpoint;
import javax.websocket.EndpointConfig;
import javax.websocket.MessageHandler;
import javax.websocket.Session;

public class ProgrammaticLifecycleEndpoint extends Endpoint {
    private static String START_TIME = "Start Time";
    private Session session;

    @Override
    public void onOpen(Session session, EndpointConfig config){
        this.session = session;
    final Session mySession = session;
    this.session.addMessageHandler(new MessageHandler.Whole<String>(){
        @Override
        public void onMessage(String message){
            if (message.indexOf("xxx") != -1){
            throw new IllegalArgumentException("xxx not allowed !");
        } else if (message.indexOf("close") != -1){
            try {
                sendMessage("1:Server closing after "+getConnectionSecondes()+"s");
            mySession.close();
            } catch (IOException e){
                System.out.println("Error closing session "+e.getMessage());
            }
            return;
        }
        sendMessage("3:Just processed a message");
        }
    });
    session.getUserProperties().put(START_TIME, System.currentTimeMillis());
    this.sendMessage("3:Just opened");
    }

    @Override
    public void onClose(Session session, CloseReason reason){
        System.out.println("Goodbye !");
    }

    @Override
    public void onError(Session session, Throwable thr){
        this.sendMessage("2:Error:"+thr.getMessage());
    }

    void sendMessage(String message){
        try {
        session.getBasicRemote().sendText(message);
    } catch (IOException e){
        System.out.println("Error sending message "+message);
    }
    }

    int getConnectionSeconds(){
        long millis = System.currentTimeMillis()-((Long)this.session.getUserProperties().get(START_TIME));
    return (int)millis/1000;
    }

}

实例数目及线程机制

上述实现Lifecycle时,将会话存储为实例变量的原因是,我们可以使用它来阐述WebSocket端点生命周期中的一个更重要的问题。如果你重新启动Lifecycle应用,但是这一次打开第二个浏览器窗口到同样的首页,你将看到两组交通信号灯。假如你开始按下任意一个浏览器窗口的生命周期按钮,那么将看到每组信号灯都可以是不同的状态。这是因为每个浏览器窗口对Lifecycle WebSocket端点来说都充当一个独立的客户端,并且WebSocket实现为每个连接的客户端使用不同的LifecycleEndpoint实例。
这意味着对于每一个WebSocket端点(不管是注解式还是编程式)定义来说,WebSocket容器在每次有新的客户端连接时会实例化端点的一个新的实例。这样做的结果是每个WebSocket端点实例仅能够永远看到同样的会话实例:此实例表示从唯一的客户端连接到那个端点实例的唯一连接。

WebSocket实现也为你提供了另外一个重要的保证:同一个会话(或者是连接)中不允许两个事件线程同时调用一个端点实例。这可能听起来很抽象,但是这意味着端点实例永远不会在某时被WebSocket实现的一个以上的线程调用。它意味着如果客户端发送多条消息,WebSocket实现必须调用端点每次处理一条消息。知道这一点特别重要,因为这意味着你永远不需要担心为端点实例的并发访问进行编程。这也是Java WebSocket编程模型与Java Servlet编程模型的关键差异,Java Servlet实例可能被多个线程同时调用,每个线程用于处理不同客户端的请求/响应交互。这意味着WebSocket编程明显更加容易。

消息通信基础

消息通信概述

发送消息

RemoteEndpoint接口和它的子类(RemoteEndpoint.Basic和RemoteEndpoint.Async)提供了发送消息的所有方法。

public void sendPing(ByteBuffer applicationData){
    throws IOException, IllegalArgumentException
}

发送字符串消息

RemoteEndpoint.Basic API提供了3中发送字符串的方法
+ 最简单的方法

// RemoteEndpoint.Basic 发送文本消息
public void sendText(String text) throws IOException
  • 由于WebSocket消息通常表现为一些高层级的对象形式(序列化成String以便发送),因此Java WebSocket API也提供了一种使用Write API发送String消息的方式
// RemoteEndpoint.Basic 发送文本消息到流
public Write getSendStream() throws IOException
  • WebSocket协议允许把大的WebSocket消息分解成多个小片段
// RemoteEndpoint以片段形式发送文本消息
public void sendText(String partialMessage, boolean isLast) throws IOException

发送二进制消息

有RemoteEndpoint.Basic接口同步发送消息,也有RemoteEndpoint.Async接口异步发送消息,这里介绍第一种,同样是3种方式

public void sendBinary(ByteBuffer data) throws IOException
// 分片段发送
public void sendBinary(ByteBuffer partialByte, boolean isLast) throws IOException

最后,可以得到一个用来写入二进制消息数据的java.io.OutputStream的引用。这非常有用,特别是当使用直接将数据对象写入Java I/O的API时。在完成消息写入后需要关闭输入流。一旦关闭输入流,消息就会被发送。

public OutputStream getSendStream() throws IOException

发送Java对象消息

可以使用RemoteEndpoint.Basic发送任意Java对象消息

public void sendObject(Object data) throws IOException, EncodeException
  • 传一个Java基本类型(或者其等值装箱类),则WebSocket实现会把数据转换成一个标准的Java字符串(就是使用toString()方法)
  • 传入的是其它对象,那么需要为WebSocket实现提供一个javax.websocket.Encoder接口的实现。在Encoder家族中,最通用的接口是javax.websocket.Encoder.Text,T就是你想发送的对象的类型。
public String encode(T object) throws EncodeException
  • 如果想把对象编码成WebSocket二进制消息,可以实现Encoder.Binary接口。
  • 如果想把对象编码成Java I/O流,可以实现Encoder.CharacterStream或者Encoder.BinaryStream

每次使用RemoteEndpoint.Basic的sendObject方法发送T类型的对象时,WebSocket实现都会调用相应的编码器。发送给远程端点的实际上是encode()方法返回的字符串。如果你的编码器无法把指定对象转换成字符串,很可能会抛出EncodeException异常。在这种情形下,EncodeException将会传播给RemoteEndpoint.Basic的sendObject()方法

在端点上配置编码器
  • 对于注解式端点,所有需要做的就是了类级别的WebSocket注解上声明Encoder类。一旦这样做,每当你从这个注解式端点上获取一个RemoteEndpoint引用时,都可以直接传一个Apple对象给sendObject()方法,WebSocket实现会使用MyAppleEncoder吧Apple对象编码成WebSocket消息。
@ServerEndpoint(value = "/fruit_trees", encoders = {MyAppleEncoder.class})
  • 对于编程式端点,需要创建EndpointConfig对象,在创建该对象时可以配置编码器类。
List<Class<? extends Encoder>> encoders = new ArrayList<>();
encoders.add(MyAppleEncoder.class);
ClientEndpointConfig config = ClientEndpointConfig.Builder.create().encoders(encoders).build();

编码器接口类型

编码器接口 转换 主要方法
Encoder.Text T转换成String String encode(T Object) throws EncodeException
Encoder.TextStream T转换成Writer void encode(T object, Writer writer) throws EncodeException, IOException
Encoder.Binary T转换成ByteBuffer ByteBuffer encode(T object) throws EncodeException
Encoder.BinaryStream T转换成OutputStream void encode(T object, OutputStream os) throws EncodeException, IOException

接收WebSocket消息

在注解式端点中接收WebSocket消息

// 用@OnMessage处理文本消息
@OnMessage
public void handleTextMessages(String textMessage){
    return "I got this "+textMessage + "!";
}

// 用@OnMessage处理到达的文本消息片段
@OnMessage
public void handlePartial(Sting textMessage, boolan isLast)

// 用@OnMessage处理二进制消息
@OnMessage
public String handleBinaryMessages(byte[] messageData){
    return "I got "+messageData.length+" bytes of data !";
}

// 用@OnMessage处理Pong消息
public String handlePongMessages(PongMessage pongMessage){
    return "I got a pong message carrying "+pongMessage.getApplicationData().length+" bytes of data !";
}

// 用@OnMessage处理Java对象消息,同时必须提供一个Decoder接口的实现。
@OnMessage
public void addToBasket(Orange orange){
    this.bag.addShoppingItem(orange);
    this.cost = this.cost + orange.getPrice();
}

// Decoder.Text<Orange>接口的decode()方法签名
public Orange decode(String rawMessage) throws DecodeEexception

/* 
 * Decoder.Text<Orange>接口的willDeocde()方法签名,在WebSocket实现中,该方法先于decode()方法被调用,
 * 这是为了使你有一个跳过解码消息的机会,例如当消息格式明显不正确时
 *
 */
public boolean willDecode(String s)

为什么不侦听入站Ping消息?答案是Java WebSocket API没有提供这样的方法。WebSocket实现被要求以最快的速度回复连接中入站的任何Ping消息,Pong消息包含的数据与Ping消息相同,因此不另外写代码侦听Ping消息。

接收方法参数类型

参数类型 处理的消息类型 示例
String 文本消息 public void handle(String message)
String, boolean 文本消息片段 public void handle(String parialMessage, boolean isLast)
Reader 文本消息流 public void handle(Reader message)
byte[] 二进制消息 public void handle(byte[] data)
ByteBuffer 二进制消息 public void handle(ByteBuffer data)
byte[], boolean 二进制消除片段 public void handle(byte[] partialData, boolean isLast)
ByteBuffer, boolean 二进制消息片段 public void handle(ByteBuffer partialData, boolean isLast)
PongMessage Pong消息 public void handle(PongMessage message)

解码器接口

解码器接口 转换 主要解码方法
Decoder.Text String转换成T T decode(String raw) throws DecodeException
Decoder.TextStream Reader转换成T T decode(Reader raw) throws DecodeException
Decoder.Binar ByteBuffer转化成T T decode(ByteBuffer raw) throws DecodeException
Decoder.BinaryStream InputStream转换成T T decode(InputStream raw) throws DecodeException

在你的WebSocket端点中,应始终包含错误处理方法。除了处理入站消息的错误之外,端点上的其他WebSocket方法(如打开事件处理方法)产生的运行时异常也会被传递到这里。如果没有错误处理方法,你可能不知道消息是否已经到达过

Java WebSocket API提供了一个方便,为Java基本类型和它的等价类提供了内置文本解码器。WebSocket实现采取的途径就是使用基本类型或者等价类转换成其等价类,使用单个字符串参数的构造函数生成等价类。

@OnMessage
public void doCount(Interger message){
    // process Integer
}

Java对象消息的传递选项

传递选项 解码器 示例
Java基本类型及其等价类的文本消息 自动 @OnMessage public void handleTransferCode(Double d)
自定义Java对象的文本或者二进制消息 开发人员提供 @OnMessage public void handleObject(CustomObject o)

注解了@OnMessage的方法提供了返回值,返回值的类型决定了WebSocket实现要寄回给消息发送者的WebSocket消息的类型。为了能回应一个文本消息,返回类型为String;为了回应一个二进制消息,返回类型应为byte[]或者ByteBuffer。这意味着在注解式端点中,以下代码都是有效的消息处理方法

@OnMessage
public String echo(String message){...}

@OnMessage
public Integer processAndConfirm(byte[] uplaod){...}

@OnMessage
public boolean purchase(String item){...}

在注解式端点上,Java WebSocket实现为了能够将入站消息分配到正确的消息处理方法上,它设置了一个非常严格的限制:每个注解式端点最多只有一个消息处理方法处理每种本地WebSocket消息类型(即文本消息,二进制消息和Pong消息)

严正声明:从这里开始,不再更新编程式,只更新注解式

综合应用

DrawingObject类

public class DrawingObject {

    public static String MESSAGE_NAME = "DrawingObject";
    private Shape shape;
    private Point center;
    private int radius = 0;
    private Color color;

    public DrawingObject(Shape shape, Point center, int radius, Color color){}

    // getter ...

    public void draw(Graphics g){}
}

DrawingClient类

@ClientEndpoint (
    decoders = {DrawingDecoder.class},
    encoders = {DrawingEncoder.class}
)
public class DrawingClient {
    private Session session;
    private DrawingWindow window;

    /*
     * 创建客户端端点时必须传入一个DrawingWindow对象,客户端端点被构造好后,把DrawingWindow引用
     * 保存为一个私有实例变量供后续使用。同时通过@ClientEndpoint注解中配置解码器
     */
    public DrawingClient(DrawingWindow window){}

    /*
     * 当建立与服务器的WebSocket连接时,这个方法将传入的Session对象保存为私有实例变量供后续使用
     */
    @OnOpen
    public void init(Session session){}

    /*
     * 因为这个WebSocket端点为DrawingObject配置了解码器,所以能够把DrawingObject对象作为drawingChanged
     * 方法的一个参数。因此,在这种情况下,当收到WebSocket消息时,会把WebSocket消息转换成DrawingObject对象,
     * 这个方法会被调用。
     */
    @OnMessage
    public void drawingChanged(DrawingObject drawingObject){}

    /*
     * 通过调用RemoteEndpoint的sendObject()方法,给DrawingBoard应用的服务端发送了一个DrawingObject实例。
     * 在这背后,WebSocket实现会使用在类级别@ClientEndpoint注解中提供的解码器,也就是DrawingEncoder实例。
     * 为了把DrawingObject实例转换成WebSocket消息,在运行时会调用DrawingEncoder的encode方法
     */
    public void notifyServerDrawingChanged(DrawingObject drawingObject){
        try {
        this.session.getBasicRemote().sendObject(drawingObject);
    } catch (IOException ioe){
        System.out.println("Error: IO "+ioe.getMessage());
    } catch (EncodeException ee){
        System.out.println("Error encoding object :"+ee.getObject());
    }
    }

    /*
     * 在DrawingClient中显式地处理在解码入站消息时产生的这种错误。当然,在真正的应用中,
     * 这样的错误处理只是简单地打印输出错误信息,但handleError()方法会告诉你在代码中如何区分这些错误
     */
    @OnError
    public void handleError(Throwable thw){
        if (thw instanceof DecodeException){
        System.out.println("Error decoding incoming message : "+((DecodeException)thw).getText());
    } else {
        System.out.println("Client WebSocket error : "+thw.getMessage());
    }
    }

    /*
     * 该方法实现的核心是WebSocketContainer类的connectToServer()方法,WebSocketContainer类用于客户端
     * 端点的实例发布到提供的URL上
     */
    public static DrawingClient connect(DrawingWindow window, String path){}

    public void disconnect();
}

接下来是编码类实现

public class DrawingEncoder implements Encoder.Text<DrawingObject>{
    @Override
    public void init(EndpointConfig config){}

    @Override
    public void destroy(){}

    @Override
    public String encode(DrawingObject drawingObject) throws EncodeException {
        // 
    }

}

解码类实现

public class DrawingDecoder implements Decoder.Text<DrawingObject>{

    @Override
    public void init(EndpointConfig config){}

    @Override
    public void destroy(){}

    @Override
    public DrawingObject decode(String s) throws DecodeException {
        //
    }

    /*
     * 该方法负责对文本消息作初步的检查,判断这些消息自己是否能解码。如果willDecode()方法
     * 没有返回true,WebSocket实现就不会调用decode()方法,消息也不会以DrawingObject形式被传递。
     */
    @Override
    public boolean willDecode(String s){
        return s.startsWith(DrawingObject.MESSAGE_NAME);
    }

}

我们看到解码器和编码器接口定义了一些它自己的生命周期。当每个编码器实例在准备服务和完成服务时,都会调用init(EndpointConfig config)和destroy()方法。如果你实现的编码器需要初始化或者清理很昂贵的资源时,这些方法就很有用。就像端点自身一样,WebSocket实例会为每个对等连接创建一个编码器实例。因此,在这个DrawingEncoder中,生命周期方法的实现是空的,因为不需要任何资源。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章