Java Web高级编程(四)
WebSocket
一、WebSocket的产生
用户希望Web页面可以进行交互,用于解决这个问题的技术是JavaScript,现在Web上有许多的可用的JavaScript框架,在使用极少的JavaScript的情况下就可以创建出丰富的单页面Web——Ajax技术(异步JavaScript和XML)。
在采用了Ajax之后,浏览器中的Web应用程序可以与服务器端的组件进行通信,而不需要改变浏览器页面或者刷新。这个通信过程不需要用户知道,并且它可以用于向服务器发送新数据或者从服务器获得新数据。
但是,浏览器只可以从服务器专区新的数据,但是浏览器并不知道数据什么时候使用,只有服务器知道什么时候有新数据发送到浏览器,而浏览器并不知道。
解决方法1,频繁轮询
频繁轮询服务器获取新数据,以一个固定的频率,通常是每秒一次,浏览器将发送Ajax请求到服务器查询新数据。如果浏览器有新的数据发送到服务器,数据将被添加到轮询请求中一同发送给浏览器(但是大量请求会被浪费)。
解决方法2,长轮询
服务器只有在发送数据时才会响应浏览器(如果浏览器在服务器响应之前有新数据要发送,浏览器就必须要创建一个新的并行请求,或者终止当前的请求;TCP和HTTP规定了连接超时的情况;HTTP存在着强制的连接限制)。
解决方法3,分块编码
服务器可以在不声明内容长度的情况下响应请求。在响应中,每个块的开头一次是:一个用于表示块长度的数字、一系列表示块扩展的可选字符和一个CRLF(回车换行)序列。接着是块包含的数据和另一个CRLF。浏览器将创建一个连接到“下游端点”的长生命连接,并且服务器将使用该连接以块的方式向浏览器发送更新。
解决方法4,Applet和Adobe Flash
创建连接到服务器的普通TCP套接字连接,当浏览器有了新的数据要发送到服务器,它将由浏览器插件暴露出的JavaScript DOM函数调用Java或Flash方法,然后该方法吧数据转发到服务器上。
解决方法5,WebSocket
WebSocket连接首先将使用非正常的HTTP请求以特定的模式访问一个URL,WebSocket是持久的全双工通信协议。在握手完成之后,文本和二进制消息将可以同时在两个方向上进行发送,而不需要关闭和重新连接。
WebSocket的优点:
- 连接端口在80(ws)和433(wss),所以不会被防火墙阻塞。
- 使用HTTP握手,可以自然地集成到网络浏览器和HTTP服务器上。
- 使用ping和pong保持WebSocket一直处于活跃状态。
- 当消息启动和它的内容到达时,服务器和客户端都可以知道。
- WebSocket在关闭连接时会发送特殊的关闭消息。
- 可以支持跨区域连接。
二、WebSocket API
WebSocket并不只是在浏览器和服务器的通信,两个以任何框架编写、支持WebSocket的应用程序都可以创建WebSocket连接进行通信。
WebSocket的Java API包含在javax.websocket中,并指定了一组类和接口包含所有的常见功能。
客户端API
客户端API基于ContainerProvider类和WebSocketContainer、RemoteEndpoint和Session接口构建。
WebSocketContainer提供了对所有WebSocket客户端特性的访问,而ContainerProvider类听了静态的getWebSocketContainer方法用来获取底层WebSocket客户端的实现。
WebSocketContainer提供了4个重载的connectToServer方法,它们都将接受一个URI,用于连接远程终端和初始化握手。
- 标注了@ClientEndpoint的任意类型的POJO
- 标注了@ClientEndpoint的任意类型的POJO的Class<?>
- Endpoint类的实例或者一个Class<? extends EndPoint>。
当握手完成是,connectToServer方法将返回一个Session。
其中WebSocket的Endpoint有3个方法,onOpen、onClose和onError,它们将在这些时间发生时进行调用。
而@ClientEndpoint类标注了@onOpen、@onClose和@onError的方法。
- @OnOpen方法可以有:一个可选的Session参数,一个可选的EndpointConfig参数。
- @OnClose方法可以有:一个可选的Session参数,一个可选的CloseReason参数。
- @OnError方法可以有:一个可选的Session参数,一个可选的Throwable参数。
- @OnMessage方法可以有:一个可选的Session参数,其它参数的组合。
这是一个WebSocket创建多人游戏的服务器终端代码:
public class TicTacToeServer { private static Map<Long, Game> games = new Hashtable<>(); private static ObjectMapper mapper = new ObjectMapper(); @OnOpen public void onOpen(Session session, @PathParam("gameId") long gameId, @PathParam("username") String username) { try { TicTacToeGame ticTacToeGame = TicTacToeGame.getActiveGame(gameId); if(ticTacToeGame != null) { session.close(new CloseReason( CloseReason.CloseCodes.UNEXPECTED_CONDITION, "This game has already started." )); } List<String> actions = session.getRequestParameterMap().get("action"); if(actions != null && actions.size() == 1) { String action = actions.get(0); if("start".equalsIgnoreCase(action)) { Game game = new Game(); game.gameId = gameId; game.player1 = session; TicTacToeServer.games.put(gameId, game); } else if("join".equalsIgnoreCase(action)) { Game game = TicTacToeServer.games.get(gameId); game.player2 = session; game.ticTacToeGame = TicTacToeGame.startGame(gameId, username); this.sendJsonMessage(game.player1, game, new GameStartedMessage(game.ticTacToeGame)); this.sendJsonMessage(game.player2, game, new GameStartedMessage(game.ticTacToeGame)); } } } catch(IOException e) { e.printStackTrace(); try { session.close(new CloseReason( CloseReason.CloseCodes.UNEXPECTED_CONDITION, e.toString() )); } catch(IOException ignore) { } } } @OnMessage public void onMessage(Session session, String message, @PathParam("gameId") long gameId) { Game game = TicTacToeServer.games.get(gameId); boolean isPlayer1 = session == game.player1; try { Move move = TicTacToeServer.mapper.readValue(message, Move.class); game.ticTacToeGame.move( isPlayer1 ? TicTacToeGame.Player.PLAYER1 : TicTacToeGame.Player.PLAYER2, move.getRow(), move.getColumn() ); this.sendJsonMessage((isPlayer1 ? game.player2 : game.player1), game, new OpponentMadeMoveMessage(move)); if(game.ticTacToeGame.isOver()) { if(game.ticTacToeGame.isDraw()) { this.sendJsonMessage(game.player1, game, new GameIsDrawMessage()); this.sendJsonMessage(game.player2, game, new GameIsDrawMessage()); } else { boolean wasPlayer1 = game.ticTacToeGame.getWinner() == TicTacToeGame.Player.PLAYER1; this.sendJsonMessage(game.player1, game, new GameOverMessage(wasPlayer1)); this.sendJsonMessage(game.player2, game, new GameOverMessage(!wasPlayer1)); } game.player1.close(); game.player2.close(); } } catch(IOException e) { this.handleException(e, game); } } @OnClose public void onClose(Session session, @PathParam("gameId") long gameId) { Game game = TicTacToeServer.games.get(gameId); if(game == null) return; boolean isPlayer1 = session == game.player1; if(game.ticTacToeGame == null) { TicTacToeGame.removeQueuedGame(game.gameId); } else if(!game.ticTacToeGame.isOver()) { game.ticTacToeGame.forfeit(isPlayer1 ? TicTacToeGame.Player.PLAYER1 : TicTacToeGame.Player.PLAYER2); Session opponent = (isPlayer1 ? game.player2 : game.player1); this.sendJsonMessage(opponent, game, new GameForfeitedMessage()); try { opponent.close(); } catch(IOException e) { e.printStackTrace(); } } }
服务器API
服务器API依赖于完整的客户端API,它只添加了少数的类和接口,ServerContainer集成了WebSocketContainer,在Servlet环境中调用ServletContext.getAttribute("javax.websocket.server.ServerCOntainer")可以获得ServerContainer实例,在独立运行的应用程序中,需要按照特定的WebSocket实现的指令获取ServerContainer实例。
不过,其实可以使用@ServerEndPoint标注服务器终端类即可,WebSocket实现可以扫描类的注解,并自动选择和注册服务器终端,容器在每次收到WebSocket连接时创建对应终端的实例,在连接关闭之后在销毁实例。
在使用@ServerEndPoint,至少需要制定必须的value特性目标是该终端可以做出像的应用程序相对应的URL:
@ServerEndpoint("/ticTacToe/{gameId}/{username}")
如果应用程序部署到的地址为:http://www.example.org/app,那么该服务器终端会响应地址:ws://www.example.org/app/ticTacToe/1/andre等,然后服务器终端中所有的@OnOpen、@OnClose、@OnError和@OnMessage方法都可以只用@PathParam(“{gameId}/{username}”)标注出一个可选的额外参数,并且其内容为改参数的值(1/andre)。
服务器终端中的时间处理方法将和客户端中的时间处理方法一样工作,区别只存在于握手阶段,之后并没有服务器和客户端的差别。