文章目录
简介
Github: https://github.com/TooTallNate/Java-WebSocket
一个 100% Java 编写的准系统 WebSocket 服务器和客户端实现。实现了底层类java.nio
,允许非阻塞事件驱动模型(类似于 Web 浏览器的WebSocket API)。
已实现的 WebSocket 协议版本为:
使用 Maven,请将此依赖项添加到您的 pom.xml:
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.3</version>
</dependency>
服务端示例
使用 Java-Websocket 与使用javascript websockets非常相似:您只需获取客户端或服务器类并通过放置应用程序逻辑来覆盖其抽象方法。
这些方法是
- onOpen
- onMessage
- onClose
- onError
- onStart (just for the server)
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
public class SimpleServer extends WebSocketServer {
public SimpleServer(InetSocketAddress address) {
super(address);
}
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
conn.send("Welcome to the server!"); //This method sends a message to the new client
broadcast( "new connection: " + handshake.getResourceDescriptor() ); //This method sends a message to all clients connected
System.out.println("new connection to " + conn.getRemoteSocketAddress());
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
System.out.println("closed " + conn.getRemoteSocketAddress() + " with exit code " + code + " additional info: " + reason);
}
@Override
public void onMessage(WebSocket conn, String message) {
System.out.println("received message from " + conn.getRemoteSocketAddress() + ": " + message);
}
@Override
public void onMessage( WebSocket conn, ByteBuffer message ) {
broadcast(message.array());
System.out.println("received ByteBuffer from " + conn.getRemoteSocketAddress());
}
@Override
public void onError(WebSocket conn, Exception ex) {
System.err.println("an error occurred on connection " + conn.getRemoteSocketAddress() + ":" + ex);
}
@Override
public void onStart() {
setConnectionLostTimeout(120)
System.out.println("server started successfully");
}
public static void main(String[] args) {
String host = "localhost";
int port = 8887;
WebSocketServer server = new SimpleServer(new InetSocketAddress(host, port));
server.run();
}
}
客户端示例
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.drafts.Draft;
import org.java_websocket.drafts.Draft_6455;
import org.java_websocket.handshake.ServerHandshake;
public class EmptyClient extends WebSocketClient {
public EmptyClient(URI serverUri, Draft draft) {
super(serverUri, draft);
}
public EmptyClient(URI serverURI) {
super(serverURI);
}
@Override
public void onOpen(ServerHandshake handshakedata) {
send("Hello, it is me. Mario :)");
System.out.println("new connection opened");
}
@Override
public void onClose(int code, String reason, boolean remote) {
System.out.println("closed with exit code " + code + " additional info: " + reason);
}
@Override
public void onMessage(String message) {
System.out.println("received message: " + message);
}
@Override
public void onMessage(ByteBuffer message) {
System.out.println("received ByteBuffer");
}
@Override
public void onError(Exception ex) {
System.err.println("an error occurred:" + ex);
}
public static void main(String[] args) throws URISyntaxException {
WebSocketClient client = new EmptyClient(new URI("ws://localhost:8887"));
client.connect();
}
}
连接的附加数据attachment
相当于扩展属性/字段,attachment将数据直接存储在 WebSocket 实例上。
例如,您可以使用它来跟踪不同的客户端或以简单的方式存储身份验证信息。
代码示例
public class ServerAttachmentExample extends WebSocketServer {
Integer index = 0;
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
conn.setAttachment(index); //Set the attachment to the current index
index++;
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
// Get the attachment of this connection as Integer
System.out.println(conn + " has left the room! ID: " + conn.<Integer>getAttachment());
}
原理
websocket.java
private Object attachment;
public <T> void setAttachment(T attachment) {
this.attachment = attachment;
}
自定义请求头
Map<String, String> httpHeaders = new HashMap<String, String>();
httpHeaders.put("Cookie", "username=nemo");
httpHeaders.put("Access-Control-Allow-Origin", "*");
c = new ExampleClient(new URI("ws://localhost:8887"), httpHeaders);
//Wer expect a successful connection
c.connectBlocking();
c.closeBlocking();
自定义响应头
可以解决跨域问题
public class ServerAdditionalHeaderExample extends WebSocketServer {
@Override
public ServerHandshakeBuilder onWebsocketHandshakeReceivedAsServer(WebSocket conn, Draft draft,
ClientHandshake request) throws InvalidDataException {
ServerHandshakeBuilder builder = super
.onWebsocketHandshakeReceivedAsServer(conn, draft, request);
builder.put("Access-Control-Allow-Origin", "*");
return builder;
}
获取响应头
public void onOpen( WebSocket conn, ClientHandshake handshake ) {
if (!handshake.hasFieldValue( "Cookie" )) {
return;
}
String cookie = handshake.getFieldValue( "Cookie" );
}
获取请求URL参数
public class LakerServer extends WebSocketServer {
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
// 如果请求为 ws://localhost:8887?roomid=1
// 则结果为 /?roomid=1
String queryString = handshake.getResourceDescriptor();
拒绝握手连接
应用场景:例如当一些安全check时,如果不符合规范则拒绝连接。
public class ServerRejectHandshakeExample extends WebSocketServer {
@Override
public ServerHandshakeBuilder onWebsocketHandshakeReceivedAsServer(WebSocket conn, Draft draft,
ClientHandshake request) throws InvalidDataException {
ServerHandshakeBuilder builder = super
.onWebsocketHandshakeReceivedAsServer(conn, draft, request);
//In this example we don't allow any resource descriptor ( "ws://localhost:8887/?roomid=1 will be rejected but ws://localhost:8887 is fine)
if (!request.getResourceDescriptor().equals("/")) {
throw new InvalidDataException(CloseFrame.POLICY_VALIDATION, "Not accepted!");
}
//If there are no cookies set reject it as well.
if (!request.hasFieldValue("Cookie")) {
throw new InvalidDataException(CloseFrame.POLICY_VALIDATION, "Not accepted!");
}
//If the cookie does not contain a specific value
if (!request.getFieldValue("Cookie").equals("username=nemo")) {
throw new InvalidDataException(CloseFrame.POLICY_VALIDATION, "Not accepted!");
}
//If there is a Origin Field, it has to be localhost:8887
if (request.hasFieldValue("Origin")) {
if (!request.getFieldValue("Origin").equals("localhost:8887")) {
throw new InvalidDataException(CloseFrame.POLICY_VALIDATION, "Not accepted!");
}
}
return builder;
}
抛出InvalidDataException
后,服务器将向客户端发送一个 HTTP 代码 404,从而导致关闭。
原理如下
WebSocketImpl.java
try {
response = wsl.onWebsocketHandshakeReceivedAsServer(this, d, handshake);
} catch (InvalidDataException e) {
log.trace("Closing due to wrong handshake. Possible handshake rejection", e);
// 如果收到的握手不正确,则关闭连接
closeConnectionDueToWrongHandshake(e);
- write(generateHttpResponseDueToError(404));
- flushAndClose(exception.getCloseCode(), exception.getMessage(), false);
return false;
} catch (RuntimeException e) {
log.error("Closing due to internal server error", e);
wsl.onWebsocketError(this, e);
// 如果 RuntimeException 出现服务器错误,则关闭连接
closeConnectionDueToInternalServerError(e);
- write(generateHttpResponseDueToError(500));
- flushAndClose(CloseFrame.NEVER_CONNECTED, exception.getMessage(), false);
return false;
}
启用SO_REUSEADDR
SO_REUSEADDR是一个套接字选项,用于设置在绑定套接字时是否允许地址复用。它可以在服务器端口被占用时,快速地重新启动服务器程序。
在TCP服务器程序中,当一个连接被关闭时,这个套接字在操作系统中会保留一段时间(通常为2-4分钟),以确保任何延迟的数据都能到达。在这段时间内,如果再次启动服务器程序并尝试使用相同的端口号,就会发生地址已在使用的错误。设置SO_REUSEADDR选项可以解决这个问题,允许在这段时间内重新绑定相同的地址和端口。
需要注意的是,如果在多个套接字上同时设置SO_REUSEADDR选项,则可能会导致套接字间发生地址冲突。因此,在使用该选项时需要小心,确保只有一个套接字在任何时候使用给定的地址和端口。
请记住,当您已经启动连接时,您不能启用/禁用 SO_REUSEADDR。
ChatServer s = new ChatServer( port );
s.setReuseAddr( true );
s.start();
ExampleClient c = new ExampleClient( new URI( "ws://localhost:8887" ) );
c.setReuseAddr( true );
c.connect();
启用TCP_NODELAY
TCP_NODELAY是一个可以在TCP套接字上设置的选项,用于禁用Nagle算法。Nagle算法是一种通过减少在网络上发送的小数据包数量来提高网络效率的特性。
当启用Nagle算法时,TCP协议栈会等待一些数据累积,然后再发送数据包,以减少网络开销。但是,这种延迟会对某些实时应用程序产生不良影响,例如在线游戏和实时视频流媒体。
通过设置TCP_NODELAY选项,可以告诉TCP协议栈立即发送数据,而不管数据包的大小如何。这可以减少延迟并提高实时应用程序的性能,但可能会增加网络负载。
启用后
- 优点:减少延迟并提高实时应用程序的性能
- 缺点:可能会增加网络负载
请记住,当您已经启动连接时,您不能启用/禁用 TCP_NODELAY。它只影响新连接。
ChatServer s = new ChatServer( port );
s.setTcpNoDelay( true );
s.start();
ExampleClient c = new ExampleClient( new URI( "ws://localhost:8887" ), new Draft_6455() );
c.setTcpNoDelay( true );
c.connect();
多端点支持Endpoint
没有对多个端点的内置支持,所以你必须自己实现它。
-
回调
onOpen
提供对握手数据的访问。 -
handshake.getResourceDescriptor()
您可以解析将从中获得完整 URI 的端点字符串。然后您可以根据端点字符串调用方法。
示例代码
import org.java_websocket.*;
import org.java_websocket.server.*;
import org.java_websocket.client.*;
import org.java_websocket.handshake.*;
import java.util.*;
import java.net.*;
interface Endpoint {
void onOpen(WebSocket socket);
// add other event handlers here
}
public class EndpointServer extends WebSocketServer {
private Map<String, Endpoint> endpoints = Collections.synchronizedMap(new HashMap<>());
public static void main(String... args) {
var server = new EndpointServer();
server.endpoints.put("/greeting", socket -> socket.send("Hello!"));
server.endpoints.put("/chat", socket -> socket.send("You have connected to chat"));
server.start();
var client = new Client("ws://localhost:" + server.getPort() + "/chat");
client.connect();
}
public void onStart() {
// ...
}
public void onOpen(WebSocket socket, ClientHandshake handshake) {
String path = URI.create(handshake.getResourceDescriptor()).getPath();
Endpoint endpoint = endpoints.get(path);
if(endpoint != null)
endpoint.onOpen(socket);
}
public void onMessage(WebSocket socket, String message) {
// ...
}
public void onClose(WebSocket socket, int code, String message, boolean remote) {
// ...
}
public void onError(WebSocket socket, Exception e) {
e.printStackTrace();
}
}
class Client extends WebSocketClient {
public Client(String uri) {
super(URI.create(uri));
}
public void onOpen(ServerHandshake handshake) {
}
public void onMessage(String message) {
System.out.println(this + " received message: " + message);
}
public void onClose(int code, String message, boolean remote) {
}
public void onError(Exception e) {
e.printStackTrace();
}
}
空闲检查/连接丢失检查
Idle Check或者Connection Lost Check
连接丢失检查是一种检测与另一个端点的连接是否丢失的功能,例如由于wifi或移动数据信号丢失。
为了检测丢失的连接,我们使用心跳实现。
检测以指定的时间间隔(例如:60 秒)运行,并对所有连接的端点执行以下操作:
- 如果端点最近没有发送 pong,则断开端点。端点被给予 1.5 倍的时间间隔来回复PONG。因此,如果间隔为 60 秒,则端点有 90 秒的响应时间。
- 向端点发送 ping。
检测是双向的,因此服务器可以检测到丢失的客户端,而客户端可以检测到与服务器的连接丢失。
端点应该在可行的情况下尽快用 Pong 帧响应 Ping 帧。
示例代码
// 设置间隔为120秒
server.setConnectionLostTimeout(120);
// 间隔小于或等于 0 的值会导致检查被停用。
server.setConnectionLostTimeout( 0 );
实现原理
// 实现原理 核心代码如下
long minimumPongTime;
synchronized (syncConnectionLost) {
minimumPongTime = (long) (System.nanoTime() - (connectionLostTimeout * 1.5));
}
for (WebSocket webSocket : connections) {
WebSocketImpl webSocketImpl = (WebSocketImpl) webSocket;
if (webSocketImpl.getLastPong() < minimumPongTime) {
// 如果最后一次收到PONG的时间差值 小于了 1.5倍的设置值
// 则关闭连接
log.trace("Closing connection due to no pong received: {}", webSocketImpl);
webSocketImpl.closeConnection(CloseFrame.ABNORMAL_CLOSE,
} else {
// 为客户端发送Ping
if (webSocketImpl.isOpen()) {
webSocketImpl.sendPing();
} else {
log.trace("Trying to ping a non open connection: {}", webSocketImpl);
}
}
}
验证,在websocketserver监听Pong,这里我们设置了5秒的检查间隔。
@Override
public void onWebsocketPong(WebSocket conn, Framedata f) {
logger.info("pong pong pong pong : {}", conn.getRemoteSocketAddress());
}
10:55:32.698 [WebSocketWorker-14] INFO c.c.f.t.LakerTracker - pong pong pong pong : /127.0.0.1:54125
10:55:37.694 [WebSocketWorker-14] INFO c.c.f.t.LakerTracker - pong pong pong pong : /127.0.0.1:54125
10:55:42.695 [WebSocketWorker-14] INFO c.c.f.t.LakerTracker - pong pong pong pong : /127.0.0.1:54125
10:55:47.795 [WebSocketWorker-14] INFO c.c.f.t.LakerTracker - pong pong pong pong : /127.0.0.1:54125
内部线程Thread
websocket客户端和服务器都使用 nio,尽管它们的体系结构完全不同。
- 客户端仅使用一个线程来执行读写操作和事件传递
- 服务器以多线程方式执行这些操作:
- 服务器有一个选择器线程 selector和一个或多个工作线程 worker。
- Selector thread执行所有 nio 操作:它注册通道,选择键以便有效地读取和写入通道。
- worker thread执行编码/解码或事件传递。
- Selector thread选择器线程和worker thread工作线程通过队列进行通信。有解码队列 decoding queue、写队列write queue和缓冲队列buffer queue。
服务器端循环解码流程为:
- 选择器线程从“缓冲队列”中取出一个未使用的缓冲区,将一个读就绪通道的数据放入其中,并放入解码队列中。
- 工作线程从该队列中取出 websocket 并消耗所有分配的缓冲区内容。
- 之后它将缓冲区放回缓冲区队列,以便可以重复使用。
可以通过工作线程的数量、缓冲区队列中缓冲区的数量和大小以及通道内部缓冲区的大小来控制性能。
WebSocketServer
可以使用带参数的构造函数的一种形式来调整工作线程的数量decodercount
。
public WebSocketServer(InetSocketAddress address) {
this(address, AVAILABLE_PROCESSORS, null);
}
public WebSocketServer(InetSocketAddress address, int decodercount) {
this(address, decodercount, null);
}
WebSocketClient 启动以下线程:
WebSocketTimer
- 丢失连接检测计时器WebSocketWriteThread-*
- 将消息写入另一个端点的线程WebSocketConnectReadThread-*
- 连接和读取来自另一个端点的消息的线程
WebSocketServer 启动以下线程:
WebSocketTimer
- 丢失连接检测计时器WebSocketWorker-*
- 解码传入消息的线程(线程数取决于您使用的解码器数量)WebSocketSelector-*
- 服务器选择器运行的线程(单线程)