JavaEE实现WebSocket(一)入门
什么是WebSocket?
WebSocket协议根据RFC6455为Web应用提供了一个重要的新功能:客户端和服务器的全双工交互。这个新功能是继Java Applet、XMLHttpRequest、Flash、ActiveXObject等诸多提升互联网交互响应能力的技术之后的又一次努力尝试的结果。
在HTTP协议的初始握手阶段,会请求协议升级(Protocol Upgrade),如果服务器同意,则服务器会返回101表示认可。如果握手成功,则他们不再使用HTTP连接,我们可以使用TCP socket连接,来使客户端和服务器可以继续交互下去。
是否应该使用WebSocket?
一个典型的使用契机就是,当您的Web应用需要频繁交互而且不能有太低的交互延迟时,你就应该使用WebSocket。如一些财务、游戏等应用。
从最初开始——一个简单的聊天室
JavaEE对于WebSocket也提供了足够的支持。这里我们不需要关注协议交互的具体内容,JavaEE以及JavaScript的API已经为我们做好了底层的支持,所以仅需要调用这些API即可。
我们从最简单的WebSocket交互开始,制作一个基于JavaEE的简单聊天室。基本的JavaEE交互方法这里不做过多说明,此处仅描述整个WebSocket交互流程。
【服务器端】
假定您已经了解了JavaEE中的Servlet的定义方法。如下:
@WebServlet(
name = "chatRoomServlet",
urlPatterns = "/chatroom"
)
public class WsServlet extends HttpServlet
如本项目中的WsServlet,此处的头文件用@WebServlet标注了一个Servlet的配置,并且指定了名称及Url匹配。
而对于WebSocket服务端的定义方法,也是使用注解完成。如下:
@ServerEndpoint("/chatroom/{roomId}/{nickName}")
public class ChatRoomServer
{
@OnOpen
public void onOpen(Session session, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
}
@OnMessage
public void onMessage(Session session, String message, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
}
@OnClose
public void onClose(Session session, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
}
}
这里使用@ServerEndpoint作为WebSocket的交互服务器注解。括号内的内容标注了Url匹配的格式。其中“{}”内的内容为客户端传入的变量。
由上图可见,一个WebSocket服务器具有三个处理方法:
Ø OnOpen 用于处理当连接打开时的相应处理逻辑。
Ø OnMessage 用于处理消息交互的应用处理逻辑。
Ø OnClose 用于处理连接关闭的应用处理逻辑。
服务器端的知识先告一段落,下面对应着已了解到的服务器端知识,介绍一下客户端JavaScript方面的内容。
【客户端】
在页面上JavaScript,要开启WebSocket交互,首先要建立WebSocket连接。建立WebSocket连接的方法如下图,使用JavaScript提供的WebSocketAPI建立。
var server;
try {
server = new WebSocket('ws://' + window.location.host +
'<c:url value="/chatroom/${roomId}/${nickName}"></c:url>');
} catch(error) {
modalErrorBody.text(error);
return;
}
上图可能对某些童鞋理解起来有些麻烦,下面举出一个简单例子。
这里构建了一个WebSocket,在构造函数内传入了以“ws://”开头的一个URL字符串。WebSocket类似于HTTP,使用ws(80端口)或wss(433端口)作为起始。接下来是具体的URL描述。如上代码所示,这里的url为“/chatroom/${roomId}/${nickName}”。对应于服务端的@ServerEndPoint后的Url匹配格式。如此,客户端便与服务器建立了WebSocket连接。
如同服务器端一样,客户端的API也需提供onopen、onmessage、onclose的处理。
//WebSocket的onopen
server.onopen = function(event) {
appendText('WebSocket OnOpen');
};
//WebSocket的onclose
server.onclose = function(event) {
if(!event.wasClean || event.code != 1000) {
appendText('WebSocket交互异常关闭,代码: ' + event.code + ',原因:' + event.reason);
}else{
appendText('WebSocket关闭');
}
};
//WebSocket的onerror
server.onerror = function(event) {
appendText('WebSocket交互错误: ' + event.data );
};
//WebSocket的onmessage
server.onmessage = function(event) {
var message = JSON.parse(event.data);
appendText(message.action);
};
当建立连接完成后,服务器和客户端会几乎同时处理OnOpen,来进行一些必要的连接开启后的逻辑处理。客户端基本为一些打印内容。接下来介绍一下服务器端的OnOpen处理方式。
【服务器端】
首先,为了服务器端可以记录聊天室的一些房间信息,以及房间内的人员信息。这里用一个Map rooms保存此类信息。
private static Map<Long, Room> rooms = new Hashtable<>();
接下来在OnOpen中加入处理逻辑,如下:
@OnOpen
public void onOpen(Session session, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
//中文转码,解决乱码问题
String nickname = "";
try
{
nickname = new String(unDecName.getBytes("ISO-8859-1"), "utf-8");
} catch (UnsupportedEncodingException e)
{
e.printStackTrace();
}
System.out.println("OnOpen -> "+roomId+", nickname:"+nickname);
/*
* 获取请求中对应的房间,如果房间不存在,则新建一个房间,并初始化其中的visitors集合。
* 这里的visitors集合使用了线程安全的CopyOnWriteArraySet。
*/
Room room = rooms.get(roomId);
if(room == null)
{
room = new Room();
room.visitors = new CopyOnWriteArraySet<Session>();
room.visitors.add(session);
rooms.put(roomId, room);
}else{
if(room.visitors == null)
{
room.visitors = new CopyOnWriteArraySet<Session>();
}
room.visitors.add(session);
}
//Java8的时间处理
LocalDateTime ldt = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(TIME_PATTEN);
String timeStr = ldt.format(formatter);
//遍历当前房间内的所有聊天者客户端的Session,并广播消息
for(Session clientSession:room.visitors)
{
sendJsonMessage(clientSession, room, new TextMessage("[系统] "+timeStr+" :"+nickname+"来了,大家欢迎他。"));
}
}
内容较多,这里捡重点看。我们使用之前的Maprooms保存了有人参与的聊天室信息。onOpen传入的Session为此时客户端用户的Session。我们将此Session保存到一个聊天室实例room中,并将room加入rooms内(如果有必要的话)。此Session就是以后与此客户端通信的关键。
接下来,我们使用for循环,遍历一下当前聊天室已有的全部来访者(Session集合)。为每一个Session发送消息,告诉他们,有新来访者加入聊天室。
sendJsonMessage方法不需要过多阐述。此处使用Session的getBasicRemote()方法获取到远程客户端的WebSocket连接,并使用sendText向远程客户端发送字符串内容。如下:
/**
* 发送JSON消息给客户端
* @param session
* @param room
* @param message
*/
private void sendJsonMessage(Session session, Room room, Message message)
{
try
{
//这里sendText内的内容是使用jackson将对象自动映射为json字符串
session.getBasicRemote().sendText(ChatRoomServer.mapper.writeValueAsString(message));
}
catch(IOException e)
{
e.printStackTrace();
}
}
sendText中的内容使用了jackson包的自动映射功能,将message对象自动映射为json字符串而已。
如此,当有新的聊天者登录后,老客户端也会收到服务器的通知,如图:
接下来构建聊天交互。
聊天交互是从客户端发起的,多个客户端之间通过服务器转发的内容交互方式。我们首先来看聊天内容如何发送到WebSocket服务器上。
【客户端】
客户端的操作代码非常简单,如下所示:
send = function(text){
if(server != null) {
//WebSocket的send方法,此方法的调用,会触发Web服务器的OnMessage处理
server.send(text);
} else {
appendText("发送失败");
}
}
sendBtn.click(function(){
var sendText = contentArea.val();
//清空textarea
contentArea.val("");
//将内容发送给服务器
send(sendText);
});
当发送按钮按下时,触发JS点击响应,获取输入内容区的内容,将内容发送给服务器。注意,这里使用的方法是server.send(text)。此处的API send中的内容可以是字符串,二进制字节数组等内容。此时,服务器会获得一个OnMessage调用。下面转入服务器端说明。
【服务器端】
服务器端代码如下:
@OnMessage
public void onMessage(Session session, String message, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
String nickname = "";
try
{
nickname = new String(unDecName.getBytes("ISO-8859-1"), "utf-8");
} catch (UnsupportedEncodingException e)
{
e.printStackTrace();
}
System.out.println("OnMessage -> "+roomId+", nickname:"+nickname+", message:"+message);
LocalDateTime ldt = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(TIME_PATTEN);
String timeStr = ldt.format(formatter);
Room room = rooms.get(roomId);
if(room != null && room.visitors != null &&!room.visitors.isEmpty())
{
for(Session clientSession:room.visitors)
{
sendJsonMessage(clientSession, room, new TextMessage("["+nickname+"] "+timeStr+" :"+message));
}
}
}
此处,之前客户端send的内容,会传入OnMessage的message参数内。我们可以使用之前通知各Session的方法,将内容转发给各个客户端。
客户端的处理和之前一样,依然是进行server.onmessage处理。如之前代码所示,将内容加入到聊天区域中。
效果见下图:
尬聊一番过后,发现话不投机,那么直接关闭聊天窗口。此时服务器会收到WebSocket的OnClose调用。服务器端的处理代码如下
@OnClose
public void onClose(Session session, @PathParam("roomId") long roomId,
@PathParam("nickName") String unDecName)
{
String nickname = "";
try
{
nickname = new String(unDecName.getBytes("ISO-8859-1"), "utf-8");
} catch (UnsupportedEncodingException e)
{
e.printStackTrace();
}
System.out.println("OnClose -> "+roomId+", nickname:"+nickname);
LocalDateTime ldt = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(TIME_PATTEN);
String timeStr = ldt.format(formatter);
Room room = rooms.get(roomId);
if(room != null && room.visitors != null &&!room.visitors.isEmpty())
{
//先移除本次关闭的Session
quitroom(room, session, nickname);
//再通知其他人的Session
for(Session clientSession:room.visitors)
{
sendJsonMessage(clientSession, room, new TextMessage("[系统] "+timeStr+" :"+nickname+"离开了聊天室。"));
}
}
}
服务器先将已关闭的客户端的Session去掉,再通知其他人此人的离开。注意,这里的quitroom代码如下,这里使用了synchronized保证了room内visitors的安全。
public synchronized void quitroom(Room room, Session session, String nickname)
{
Iterator<Session> iters = room.visitors.iterator();
while(iters.hasNext())
{
Session tSession = iters.next();
if(tSession.equals(session))
{
room.visitors.remove(tSession);
System.out.println("remove client session for user "+nickname);
}
}
}
至此,当有人离开,服务器的OnClose就会被调用一次。显示在界面上如下图所示:
小结
这里使用WebSocket结合JavaEE打造了最简单易懂的一个聊天室实例。大家可以点击此下载源码作为参考,通过一步一步学习掌握WebSocket的核心用法。
如果转载,请注明出处!