距离上次写博客已经又过去很长一段时间了,这段时间里我一直在做老板的一个项目,而这个项目呢,包括后端、PC客户端和Web管理后台三个部分。其中的PC客户端,因为需要和后端双向通信,所以再用HTTP就不那么和谐了,技术选型的时候考察了下,最后选定了WebSocket。所以这几天打算更新几篇关于Java WebSocket的文章。

WebSocket简介

今天上第一篇,先简单说说WebSocket。这货应当算是早有耳闻的,当年本科毕设的时候也需要类似的双向通信,当时也考虑过HTTP长连接,或者类似Comet这种技术(直接走Socket通信就不在考虑范围内了,毕竟太底层了)。

回到WebSocket本身,它是H5中的一项核心技术,实现了客户端和服务端建立起TCP连接之后全双工地通信,连接会一直保活,直到其中一方决定终止连接(也可能因为长时间空闲或者某些非正常因素导致连接断开),这就避免了每次通信开始前重新建立连接的昂贵代价,也避免了每次获取到许多重复数据的低效率,更实现了服务器主动将数据推送给客户端的特性。

WebSocket协议主要使用2种帧:控制帧和数据帧。其中控制帧用于执行一些协议内部功能逻辑,又分为3种:Close、Ping和Pong,Close即请求关闭连接,Ping-Pong看起来天生一对,常被用于心跳检测;数据帧携带了应用程序数据,主要分为2大类:Text和Binary,顾名思义,前者携带文本消息,后者携带二进制消息,另外注意数据帧可以是完整的(Whole),也可以是部分的(Partial)。

Java EE 7开始提供的WebSocket相关的API,可以参考JSR 356

实现Echo服务端

要实现WebSocket服务端(以一个简单的Echo服务为例),有两种方式,一种是注解式,另一种是编程式(似曾相识啊……)。

注解式类似这样

1
2
3
4
5
6
7
@ServerEndpoint("/annotation/echo")
public class AnnotationEchoServer {
@OnMessage
public String echo(String message) {
return message;
}
}

这里用到2个注解,@ServerEndpoint注解在类上,它告诉Web容器,这个类是一个服务器“端点”(即WebSocket通信的一端),连接它的URI是“/echo”;而@OnMessage注解在方法上,表明这个方法将处理OnMessage这个WebSocket事件(具体的在下文“生命周期”中展开),即这个方法将处理这个端点的入站消息,处理的方法是将收到的消息原封不动的再发送回去(即Echo),

而编程式写法则是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ProgrammaticEchoServerConfig implements ServerApplicationConfig {
@Override
public Set<Class<?>> getAnnotatedEndpointClasses(Set<Class<?>> arg0) {
return arg0;
}
@Override
public Set<ServerEndpointConfig> getEndpointConfigs(Set<Class<? extends Endpoint>> arg0) {
Set<ServerEndpointConfig> configs = new HashSet<>();
ServerEndpointConfig config = ServerEndpointConfig.Builder
.create(ProgrammaticEchoServer.class, "/programmatic/echo").build();
configs.add(config);
return configs;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ProgrammaticEchoServer extends Endpoint {
private Session session;
@Override
public void onOpen(Session session, EndpointConfig endpointConfig) {
this.session = session;
session.addMessageHandler(String.class, message -> {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
});
}
}

代码要长了不少……这里涉及2个类,配置类ProgrammaticEchoServerConfig实现了ServerApplicationConfig接口,接口中的2个方法分别是Web容器启动时扫描到的注解式端点和编程式端点的类的实例,这里我们只关心编程式端点,并为其指定了URI——“/programmatic/echo”;

另一个类是端点的实现类ProgrammaticEchoServer,它继承自Endpoint,有一个抽象方法onOpen用于处理客户端连接事件,我们需要在每一个连接上的Session添加MessageHandler(即说明如何处理收到的各类消息),这里处理的方式和注解式端点中的处理是一样的。要通过已知的Session向会话的另一方发送消息,需要RemoteEndpoint对象,这个对象又分为两种,Basic和Async——一个是同步的,一个是异步的。

实现Echo客户端

仅有服务端当然是不能建立通讯的,我们还要看看客户端怎么做,功能上嘛:实现当连接建立的时候向服务端发送本机系统时间,并接收服务端的Echo,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import javax.websocket.ClientEndpoint;
import javax.websocket.CloseReason;
import javax.websocket.ContainerProvider;
import javax.websocket.DeploymentException;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.SendHandler;
import javax.websocket.SendResult;
import javax.websocket.Session;
import javax.websocket.WebSocketContainer;
@ClientEndpoint
public class EchoClient {
private Session session;
@OnOpen
public void onOpen(Session session) {
this.session = session;
System.out.println("Server Connect: " + session.getId());
}
@OnMessage
public void onMessage(String message) {
System.out.println("Client receive: " + message);
}
public void connectServer() throws DeploymentException, IOException, URISyntaxException {
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
container.connectToServer(this, new URI("ws://localhost:8080/websocket-server/programmatic/echo"));
}
public void sendMessage() {
String message = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).format(LocalDateTime.now());
this.session.getAsyncRemote().sendText(message, new SendHandler() {
public void onResult(SendResult result) {
if (result.isOK()) {
System.out.println("Client send: " + message);
} else {
System.out.println("Client Error: " + result.getException().getMessage());
}
}
});
}
public static void main(String[] args) throws Exception {
EchoClient echoClient = new EchoClient();
echoClient.connectServer();
echoClient.sendMessage();
}
}

特别注意,JSR 356提供的只是WebSocket的接口,并没有具体的实现,因此如果遇到这样的错误并不奇怪

Exception in thread “main” java.lang.RuntimeException: Could not find an implementation class.
at javax.websocket.ContainerProvider.getWebSocketContainer(ContainerProvider.java:73)

解决的方法是引入包含JSR 356实现的jar包,Jetty、Glassfish等Web容器均提供了类似实现。

WebSocket连接的建立过程

WebSocket连接的建立过程仍然是基于一次特殊的HTTP请求/响应,这个过程称为“握手”,我们可以通过Charles或其他同类抓包工具简单看一下这个过程。客户端的请求(部分)和服务端的响应(部分)分别如下:

1
2
3
4
5
GET /websocket-server/annotation/echo HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: S16Xokvx3+n8xHbqfV5FOA==
Upgrade: websocket

1
2
3
4
5
HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: Q5dJ+WYxjdu4hkPWZasBIXhwxh4=
Sec-WebSocket-Extensions: permessage-deflate

可以看到客户端仍然是发起了一个HTTP请求,只不过在Header中要求升级(Upgrade)到WebSocket协议,这个请求头在HTTP升级HTTPS时也会见到;服务端则给出了101状态码,该状态码表示协议切换成功;至于其他Header,表示的是这次连接关于安全性、子协议、扩展的方面的参数。

WebSocket生命周期

与其说WebSocket的生命周期,不如认为是WebSocket连接存续期间可能发生的事件,分为打开、消息、错误、关闭4种。

“打开”永远是第一个生命周期事件,它用于指示到另一端的连接已经建立;打开事件之后,参与通信的双方就可以互相发送消息了,双方的每一次对话都会触发“消息”事件;对话过程中可能发生各种错误,此时将触发“错误”事件,错误可能是致命的(导致连接关闭),也可能只是导致这次会话不能被正确处理;最后,当一端决定结束会话,将产生“关闭”事件。

这些事件可以一对一地映射到Java API中,对应注解式写法是以下4个注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ServerEndpoint("/annotation/echo")
public class AnnotationEchoServer {
@OnOpen
public void onOpen(Session session, EndpointConfig config) {
}
@OnMessage
public void onMessage(Session session, String message) {
}
@OnError
public void onError(Session session, Throwable throwable) {
}
@OnClose
public void onClose(Session session, CloseReason reason) {
}
}

而编程式写法则是这么3个方法

1
2
3
4
5
6
7
8
9
public abstract class Endpoint {
public abstract void onOpen(Session session, EndpointConfig config);
public void onClose(Session session, CloseReason closeReason) {
}
public void onError(Session session, Throwable thr) {
}
}

其中的@OnOpen注解和onOpen方法等,在前边已经有出现过。另外注意注解式写法中,4个方法的参数类型、顺序等都不是唯一的,具体用法可以参考API文档

此外,WebSocket编程模型与HTTP(Servlet)还有一点重大差异,那就是WebSocket实现内部提供了“同一个连接不会在同一时间被多个事件线程调用”的保证,这是不是就意味着我们在开发的时候不需要考虑这方面的并发了?