更新:2016/01/14,补充基于HTTP流实现comet在服务端一个利用阻塞队列的例子。
1. 为什么需要WebSocket:
回答这个首先需要知道一些历史,我们知道HTTP是客户端向服务器请求获取数据的普遍方式,但是它是一种被动性的通信机制。request=response,服务器只有在接受到客户端请求的时候才可能向客户端发送数据。但有时候服务器需要告诉客户端有新的数据应该接受,比如消息,即时聊天等功能,也就是说我们应该需要一种全双工的通信方式,而HTTP的被动性使得这一问题很难解决。
你可能会问webSocket很新啊,在这之前是如果实现类似的功能的呢?下面介绍几种技术用来在在“单连接的HTTP中模拟全双工”的:
解决方案一:频繁轮询:
解决方案二:长轮询:
HTTP是 被动性的,因此想要减少连接数量只能增长连接的时间否则你无法及时返回新的数据。客户端发起一个超时时间较长(比如20秒)的请求,服务器在没有数据的时候并不立即返回,而是以某种方式阻塞(使用阻塞队列神马的),当有数据的时候在返回,或者在时间超时时返回无数据。
补充:HTTP流实现
除了长轮询,还可以使用基于HTTP流的方式,服务端采用阻塞队列的时间:使用AsyncContext异步处理写个例子:
使用一个简单的生产者-消费者模式:
<span style="white-space:pre"> </span>AsyncContext asyncContext = req.startAsync();
asyncContext.setTimeout(0); //默认是Servlet的超时时间,默认30,设为0或负数表示notimeout
/* 略,可以设置AsyncListener等等 */
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
asyncContext.start(new Runnable() {
@Override
public void run() {
try {
while (true) {
String msg = queue.take();
PrintWriter out;
(out = asyncContext.getResponse().getWriter()).println(msg);
out.flush();
}
} catch (Exception e) {
new RuntimeException(e);
}
asyncContext.complete(); //任务完成调用onComplete,通知,调用回调函数
}
});
new Thread(new DataHandler(queue)).start();
生产者代码:
private static class DataHandler implements Runnable {
private final BlockingQueue<String> queue;
public DataHandler(BlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
queue.put("new msg!");
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
logger.error(e);
}
}
}
}
在浏览器(chrome)地址栏请求,一般默认是notimeout,因此超时时间就是我在服务端设置的时间(如果不设置,AsyncContext的默认时间是ServletContext的超时时间,如果设置为0或负数,就是notimeout)。
这就是一个长时间保持的HTTP连接,在服务器方法超时或方法返回之前HTTP流不会关闭,通过HTTP流向客户端周期性地发送数据,客户端响应数据送达事件(而不是完成事件)来做出处理。
解决方案三:分块编码:
这个方案可能是为了解决浏览器长时间等待而创建的,一直等待响应对浏览器来说并不友好,但我觉得这个方案有些奇葩,分块的思想大致是这样的,既然我发现有很多时间都是在等待,那我有数据的时候就不一次性返回给客户端了,把它分割分几次返回给客户端,这样看上去浏览不就是一直在请求和获取数据吗。。。这个方法并没有什么本质上的改观,如果某些时候需要返回的数据大量产生呢,那我们是不是需要动态维护块的大小适应数据流,显然这是一件不简单的事情,又需要额外的成本。解决方案四:Applet和Adobe Flash:
2. WekSocket简介:
那么如何从一个HTTP连接机制中的通信环境发起一个WebSocket连接呢?
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Accept:看我加密了你发来的随机码,你认识我了吧!
那WebSocket到底有什么好处呢?
3. Java中的WebSocket:
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
3.1 WebSocket API:
4. 基于Spring webSocket开发消息系统:
4.1 服务端实现:
@ServerEndpoint(value = "/message/{userId}", configurator = SpringConfigurator.class)
public class MessageServer implements DisposableBean {
private static Map<Long, Session> userSessions = new ConcurrentHashMap<>();
private static ObjectMapper mapper = new ObjectMapper();
private static Logger logger = LogManager.getLogger();
@Inject
private MessageService messageService;
@Override
public void destroy() throws Exception {
userSessions.clear();
userSessions = null;
}
/**
* Get unread messages by userId when session opened.
*
* @param session session get from WebSocketContain.connectToServer
* @param userId user's ID
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") long userId) {
logger.debug("open session: userId:" + userId + " sessionId" + session.getId());
List<BMessageEntity> unReadMessages =
this.messageService.getMessageByIsRead(userId, BMessageState.UNREAD);
if(unReadMessages.size() > 0) {
this.sendJsonMessage(session, userId, unReadMessages);
}
userSessions.put(userId, session);
}
/**
* client should send new BMessageEntity to this server, and opMessage() save data to dataBase
*
* @param session session get from WebSocketContain.connectToServer
* @param byteBuffer accept messages
*/
@OnMessage
public void onMessage(Session session, ByteBuffer byteBuffer,
@PathParam("userId") long userId) {
CharBuffer charBuffer = Charset.forName("utf8").decode(byteBuffer);
String message = charBuffer.toString();
try {
MessageJson messageJson = mapper.readValue(message, MessageJson.class);
long replyUserId = messageJson.getReplyUserId();
//save new message
BMessageEntity messageEntity = this.messageService.saveMessage(userId, replyUserId, messageJson.getMessage());
if(messageEntity != null) {
//send
Session replySession = userSessions.get(replyUserId);
if(replySession != null) {
this.sendJsonMessage(replySession, userId, messageEntity);
}
}
} catch (IOException e) {
logger.error(e);
}
}
@OnError
public void onError(Session session, Throwable e) {
logger.error("sessionId:" + session.getId() + " " + e);
}
@OnClose
public void onClose(Session session, @PathParam("userId") long userId) {
Session session1 = userSessions.get(userId);
if(session1 != null) {
try {
if(session1.isOpen())
session1.close();
} catch (IOException e) {
logger.error(e);
} finally {
userSessions.remove(userId);
}
}
}
private void sendJsonMessage(Session session, long userId, Object object) {
try {
session.getBasicRemote()
.sendText(MessageServer.mapper.writeValueAsString(object));
} catch (IOException e) {
this.handleException(e, userId);
}
}
private void handleException(Throwable throwable, long userId) {
try(Session session = userSessions.get(userId)) {
session.close(new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, throwable.toString()));
} catch (IOException e) {
logger.error(e);
} finally {
userSessions.remove(userId);
}
}
/**
* custom configurator, TODO collect some require, then use this configurator
*/
public static class EndpointConfigurator extends SpringConfigurator {
}
}
@ServerEndpoint(value = "/message/{userId}", configurator = SpringConfigurator.class)
private static Map<Long, Session> userSessions = new ConcurrentHashMap<>();
private static ObjectMapper mapper = new ObjectMapper();
@OnOpen
public void onOpen(Session session, @PathParam("userId") long userId) {
logger.debug("open session: userId:" + userId + " sessionId" + session.getId());
List<BMessageEntity> unReadMessages =
this.messageService.getMessageByIsRead(userId, BMessageState.UNREAD);
if(unReadMessages.size() > 0) {
this.sendJsonMessage(session, userId, unReadMessages);
}
userSessions.put(userId, session);
}
接受到消息后,保存消息到数据库,然后在map中查找对方的session,如果存在的话(对方有可能不在线哦),将这条消息发送给他。
@OnMessage
public void onMessage(Session session, ByteBuffer byteBuffer,
@PathParam("userId") long userId) {
CharBuffer charBuffer = Charset.forName("utf8").decode(byteBuffer);
String message = charBuffer.toString();
try {
MessageJson messageJson = mapper.readValue(message, MessageJson.class);
long replyUserId = messageJson.getReplyUserId();
//save new message
BMessageEntity messageEntity = this.messageService.saveMessage(userId, replyUserId, messageJson.getMessage());
if(messageEntity != null) {
//send
Session replySession = userSessions.get(replyUserId);
if(replySession != null) {
this.sendJsonMessage(replySession, userId, messageEntity);
}
}
} catch (IOException e) {
logger.error(e);
}
}
@Bean
public MessageServer messageServer() {
return new MessageServer();
}
这样也很省资源呢,但是注意线程安全。
4.2 客户端实现:
我这里使用了两个Servlet实例来模拟两个客户端,实现@ClientEndpoint,注意@ClientEndpoint并不会有websocket实例化,因此我们可以放心的在servlet上直接添加:@ClientEndpoint
public class ClientServlet extends HttpServlet {
private static Logger logger = LogManager.getLogger();
private static ObjectMapper mapper = new ObjectMapper();
private Session session;
private long userId;
private static ObjectMapper objectMapper = new ObjectMapper();
@Override
public void init() throws ServletException {
userId = Long.valueOf(this.getInitParameter("userId"));
String path = this.getServletContext().getContextPath() + "/message/" +
userId;
logger.debug(path);
try {
URI uri = new URI("ws", "localhost:8080", path, null, null);
this.session = ContainerProvider.getWebSocketContainer()
.connectToServer(this, uri);
logger.debug(session.getId());
} catch (IOException | URISyntaxException | DeploymentException e) {
throw new ServletException("Cannot connect to " + 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 {
MessageJson messageJson = new MessageJson();
messageJson.setMessage(req.getParameter("message"));
messageJson.setReplyUserId(Long.valueOf(req.getParameter("replyUserId")));
try(OutputStream outputStream = this.session.getBasicRemote().getSendStream()) {
mapper.writeValue(outputStream, messageJson);
outputStream.flush();
}
resp.getWriter().append("OK");
}
@OnMessage
public void onMessage(String message) {
System.out.println(message);
}
@OnClose
public void onClose(CloseReason reason) {
CloseReason.CloseCode code = reason.getCloseCode();
}
}
<servlet>
<servlet-name>Client1</servlet-name>
<servlet-class>com.yjh.cg.site.server.ClientServlet</servlet-class>
<init-param>
<param-name>userId</param-name>
<param-value>1</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>Client1</servlet-name>
<url-pattern>/client1</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>Client2</servlet-name>
<servlet-class>com.yjh.cg.site.server.ClientServlet</servlet-class>
<init-param>
<param-name>userId</param-name>
<param-value>2</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>Client2</servlet-name>
<url-pattern>/client2</url-pattern>
</servlet-mapping>
<span style="white-space:pre"> </span> URI uri = new URI("ws", "localhost:8080", path, null, null);
this.session = ContainerProvider.getWebSocketContainer()
.connectToServer(this, uri);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
MessageJson messageJson = new MessageJson();
messageJson.setMessage(req.getParameter("message"));
messageJson.setReplyUserId(Long.valueOf(req.getParameter("replyUserId")));
try(OutputStream outputStream = this.session.getBasicRemote().getSendStream()) {
mapper.writeValue(outputStream, messageJson);
outputStream.flush();
}
resp.getWriter().append("OK");
}