WebSocket初探【转】
众所周知,socket是编写网络通信应用的基本技术,网络数据交换大多直接或间接通过socket进行。对于直接使用socket的客户端与服务端,一旦连接被建立则均可主动向对方传送数据,而对于使用更上层的HTTP/HTTPS协议的应用,由于它们是非连接协议,所以通常只能由客户端主动向服务端发送请求才能获得服务端的响应并取得相关的数据。而当前越来越多的应用希望能够及时获取服务端提供的数据,甚至希望能够达到接近实时的数据交换(例如很多网站提供的在线客户系统)。为达到此目的,通常采用的技术主要有轮询、长轮询、流等,而伴随着HTML5的出现,相对更优异的WebSocket方案也应运而生。
一、 非WebSocket方案简介
1. 轮询
轮询是由客户端定时向服务端发起查询数据的请求的一种实现方式。早期的轮询是通过不断自动刷新页面而实现的(在那个基本是IE统治浏览器的时代,那不断刷新页面产生的噪声就难以让人忍受),后来随着技术的发展,特别是Ajax技术的出现,实现了无刷新更新数据。但本质上这些方式均是客户端定时轮询服务端,这种方式的最显著的缺点是如果客户端数量庞大并且定时轮询间隔较短服务端将承受响应这些客户端海量请求的巨大的压力。
2. 长轮询
在数据更新不够频繁的情况下,使用轮询方法获取数据时客户端经常会得到没有数据的响应,显然这样的轮询是一个浪费网络资源的无效的轮询。长轮询则是针对普通轮询的这种缺陷的一种改进方案,其具体实现方式是如果当前请求没有数据可以返回,则继续保持当前请求的网络连接状态,直到服务端有数据可以返回或者连接超时。长轮询通过这种方式减少了客户端与服务端交互的次数,避免了一些无谓的网络连接。但是如果数据变更较为频繁,则长轮询方式与普通轮询在性能上并无显著差异。同时,增加连接的等待时间,往往意味着并发性能的下降。
3. 流
所谓流是指客户端在页面之下向服务端发起一个长连接请求,服务端收到这个请求后响应它并不断更新连接状态,以确保这个连接在客户端与服务端之间一直有效。服务端可以通过这个连接将数据主动推送到客户端。显然,这种方案实现起来相对比较麻烦,而且可能被防火墙阻断。
二、 WebSocket简介
1. WebSocket协议简介
WebSocket是为解决客户端与服务端实时通信而产生的技术。其本质是先通过HTTP/HTTPS协议进行握手后创建一个用于交换数据的TCP连接,此后服务端与客户端通过此TCP连接进行实时通信。
WebSocket规范当前还没有正式版本,草案变化也较为迅速。Tomcat7(本文中的例程来自7.0.42)当前支持RFC 6455(http://tools.ietf.org/html/rfc6455)定义的WebSocket,而RFC 6455目前还未冻结,将来可能会修复一些Bug,甚至协议本身也可能会产生一些变化。
RFC6455定义的WebSocket协议由握手和数据传输两个部分组成。
来自客户端的握手信息类似如下:
public class EchoMessage extends WebSocketServlet { private static final long serialVersionUID = 1L; private volatile int byteBufSize; private volatile int charBufSize; @Override public void init() throws ServletException { super.init(); byteBufSize = getInitParameterIntValue("byteBufferMaxSize", 2097152); charBufSize = getInitParameterIntValue("charBufferMaxSize", 2097152); } public int getInitParameterIntValue(String name, int defaultValue) { String val = this.getInitParameter(name); int result; if(null != val) { try { result = Integer.parseInt(val); }catch (Exception x) { result = defaultValue; } } else { result = defaultValue; } return result; } // 创建Inbound实例,WebSocketServlet子类必须实现的方法 @Override protected StreamInbound createWebSocketInbound(String subProtocol, HttpServletRequest request) { return new EchoMessageInbound(byteBufSize,charBufSize); } // MessageInbound子类,完成收到WebSocket消息后的逻辑处理 private static final class EchoMessageInbound extends MessageInbound { public EchoMessageInbound(int byteBufferMaxSize, int charBufferMaxSize) { super(); setByteBufferMaxSize(byteBufferMaxSize); setCharBufferMaxSize(charBufferMaxSize); } // 二进制消息响应 @Override protected void onBinaryMessage(ByteBuffer message) throws IOException { getWsOutbound().writeBinaryMessage(message); } // 文本消息响应 @Override protected void onTextMessage(CharBuffer message) throws IOException { // 将收到的消息发回客户端 getWsOutbound().writeTextMessage(message); } } }
注:完整代码参见apache-tomcat-7.0.42\webapps\examples\WEB-INF\classes\websocket\echo\EchoMessage.java。
2. chat例程
chat例程实现了通过网页进行群聊的功能。每个打开的聊天网页都可以收到所有在线者发出的消息,同时,每个在线者也都可以(也只可以)向其它所有人发送消息。也就是说,chat实例演示了如何通过WebSocket实现对所有在线客户端的广播。
图3
chat例程客户端核心代码如下,可以看到其实现方式与echo例程形式上稍有变化,本质依旧是对WebSocket事件进行响应与处理。
public class ChatWebSocketServlet extends WebSocketServlet { private static final long serialVersionUID = 1L; private static final String GUEST_PREFIX = "Guest"; private final AtomicInteger connectionIds = new AtomicInteger(0); private final Set<ChatMessageInbound> connections = new CopyOnWriteArraySet<ChatMessageInbound>(); // 创建Inbound实例,WebSocketServlet子类必须实现的方法 @Override protected StreamInbound createWebSocketInbound(String subProtocol, HttpServletRequest request) { return new ChatMessageInbound(connectionIds.incrementAndGet()); } // MessageInbound子类,完成收到WebSocket消息后的逻辑处理 private final class ChatMessageInbound extends MessageInbound { private final String nickname; private ChatMessageInbound(int id) { this.nickname = GUEST_PREFIX + id; } // Open事件 @Override protected void onOpen(WsOutbound outbound) { connections.add(this); String message = String.format("* %s %s", nickname, "has joined."); broadcast(message); } // Close事件 @Override protected void onClose(int status) { connections.remove(this); String message = String.format("* %s %s", nickname, "has disconnected."); broadcast(message); } // 二进制消息事件 @Override protected void onBinaryMessage(ByteBuffer message) throws IOException { throw new UnsupportedOperationException( "Binary message not supported."); } // 文本消息事件 @Override protected void onTextMessage(CharBuffer message) throws IOException { // Never trust the client String filteredMessage = String.format("%s: %s", nickname, HTMLFilter.filter(message.toString())); broadcast(filteredMessage); } // 向所有已连接的客户端发送文本消息(广播) private void broadcast(String message) { for (ChatMessageInbound connection : connections) { try { CharBuffer buffer = CharBuffer.wrap(message); connection.getWsOutbound().writeTextMessage(buffer); } catch (IOException ignore) { // Ignore } } } }
注:完整代码参见apache-tomcat-7.0.42\webapps\examples\WEB-INF\classes\websocket\echo\ChatWebSocketServlet.java。
通过上述例程可以看到WebSocket广播实际上是通过遍历所有连接并通过每个连接向相应的客户端发送消息实现的。
四、 WebSocket实战
实时向在线用户推送通知是一个WebSocket应用的简单场景,后台提交通知信息以后,所在在线用户均应很快收到这个通知。通过上述例程了解WebSocket后,可以尝试编写一个实现这个需求的WebSocket应用。
首先编写一个用户的Sample页面,该页面没有实质的内容,但是在收到后台发出的通知时要在右下角通过弹窗显示通知的内容。其代码如下:package net.yanzhijun.example; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; import org.apache.catalina.websocket.StreamInbound; import org.apache.catalina.websocket.WebSocketServlet; public class NotifyWebSocketServlet extends WebSocketServlet { private static final long serialVersionUID = 1L; @Override protected StreamInbound createWebSocketInbound(String subProtocol, HttpServletRequest request) { ServletContext application = this.getServletContext(); return new NofityMessageInbound(application); } }
与Tomcat给出的示例代码不同的是,在NotifyWebSocketServlet中并未将继承于MessageInbound的NofityMessageInbound作为一个内嵌类。前述示例代码中发送消息和接收消息都是在同一组客户端页面和服务端响应Servlet间进行的,而当前需要实现是在一个页面中提交通知,而在其它用户的页面上显示通知信息,因此需要将所有客户端与服务端的连接存储一个全局域中,故而NofityMessageInbound将不只在当前Servlet中被使用,所以有必要将其独立出来。
NofityMessageInbound的完整代码如下:package net.yanzhijun.example; import java.nio.CharBuffer; import java.nio.ByteBuffer; import java.io.IOException; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import javax.servlet.ServletContext; import org.apache.catalina.websocket.WsOutbound; import org.apache.catalina.websocket.MessageInbound; public class NofityMessageInbound extends MessageInbound { private ServletContext application; private Set<NofityMessageInbound> connections = null; public NofityMessageInbound(ServletContext application) { this.application = application; connections = (Set<NofityMessageInbound>)application.getAttribute("connections"); if(connections == null) { connections = new CopyOnWriteArraySet<NofityMessageInbound>(); } } @Override protected void onOpen(WsOutbound outbound) { connections.add(this); application.setAttribute("connections", connections); } @Override protected void onClose(int status) { connections.remove(this); application.setAttribute("connections", connections); } @Override protected void onBinaryMessage(ByteBuffer message) throws IOException { throw new UnsupportedOperationException( "message not supported."); } @Override protected void onTextMessage(CharBuffer message) throws IOException { throw new UnsupportedOperationException( "message not supported."); } }
后台发送通知的页面实现的相当简单,只是一个表单提交一条通知信息。
package net.yanzhijun.example; import java.io.PrintWriter; import java.nio.CharBuffer; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.io.IOException; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class PushMessageServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { doPost(request, response); } @Override public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); PrintWriter out = response.getWriter(); String message = request.getParameter("message"); if(message == null || message.length() == 0) { out.println("The message is empty!"); return; } // 广播消息 broadcast(message); out.println("Send success!"); } // 将参数中的消息发送至所有在线客户端 private void broadcast(String message) { ServletContext application=this.getServletContext(); Set<NofityMessageInbound> connections = (Set<NofityMessageInbound>)application.getAttribute("connections"); if(connections == null){ return; } for (NofityMessageInbound connection : connections) { try { CharBuffer buffer = CharBuffer.wrap(message); connection.getWsOutbound().writeTextMessage(buffer); } catch (IOException ignore) { // Ignore } } } }
编译相关文件并完成部署,尝试在后台发送消息,可以看到用户界面右下角出现的弹窗中显示了后台所提交的内容。
五、 WebSocket总结
通过以上例程和实例可以看出,从开发角度使用WebSocket相当容易,基本只需要创建WebSocket实例并对关心的事件进行处理就可以了;从应用角度WebSocket提供了优异的性能,图 5是来自websocket.org的性能测试图表(http://www.websocket.org/quantum.html),可以看到当并发和负载增加时轮询与WebSocket的差异。