1. 简介
webSocket是HTML5新增的协议,是一个持久化的协议。它的目的是在浏览器和服务器之间建立一个不受限的双向通信通道。例如:服务器可以在任意时刻向浏览器发送消息。
webSocket的出现,让浏览器和服务器之间可以建立无限制的全双工通信,任何一方都可以主动发消息给对方。wesocket并不是全新的协议,而是利用HTTP协议来建立连接的。
2. webSocket与Http
1. webSocket是一个持久化的协议,HTTP是不支持持久连接的(长连接,循环连接的不算)
2. HTTP有 1.1
和 1.0
之说,也就是所谓的 keep-alive
,把多个HTTP请求合并为一个,但是 Websocket
其实是一个新协议,跟HTTP协议基本没有关系,只是为了兼容现有浏览器的握手规范而已,也就是说它是HTTP协议上的一种补充
3. 在HTTP中,一个请求request只能有一个响应response,而且这个response也是被动的,不能主动发起。
4. Websocket只需要一次HTTP握手,所以说整个通讯过程是建立在一次连接/状态中,也就避免了HTTP的非状态性,服务端会一直知道你的信息,直到你关闭请求
5. webSocket连接必须由浏览器发起,因为请求协议是一个标准的HTTP请求。
浏览器向服务器发起请求,请求头带了一个Upgrade,Upgrade映射的是webSocket,服务器接收到该请求头之后,将请求转换成weSocket请求,状态码改成101,然后再给客户端响应数据。
wesocket请求和普通HTTP请求有几点不同:
1)Get请求的地址不是类似 http:// 而是以 ws:// 开头的地址
2)请求头 Connection:Upgrade和请求头Upgrade:wesocket 表示这个链接将要被转换成websocket链接
3)Sec-WebSocket-Key是用于标识这个链接,是一个BASE64编码的秘文,要求服务端响应一个对应加密的Sec-WebSocket-Accept头信息作为应答
4)Sec-WebSocket-Version指定了WebSocket的协议版本
5)HTTP 101 状态码表明服务器已经识别并切换为webSocket协议,Sec-WebSocket-Accept是服务端与客户端一致的秘钥计算出来的信息。
3. Tomcat的webSocket
tomcat7.0.5版本开始支持WebSocket,并且实现了Java WebSocket规范(JSR356),而在7.0.5版本之前(7.0.2之后)则采用自定义API,即WebSocketService来实现。
Java WebSocket应用由一系列的WebSocketEndpoint组成。Endpoint是一个java对象,代表webSocket链接的一端,对于服务端,我们可以视为处理具体WebSocket消息的接口,就像Servlet与HTTP请求一样。
3.1 定义Endpoint的方式
1)编程式:继承类javax.websocket.Endpoint并实现其方法
2)注解式:定义一个类,在类上添加@ServerEndpoint注解,表明该类是一个webSocket类。
@ServerEndpoint(value="/websocket")
public class ChatSocket {
}
Endpoint实例在webSocket握手时创建,并在客户端与服务端链接过程中有效,最后在连接关闭时结束。在endpoint接口中明确定义了与其生命周期相关的方法,规范实现者确保生命周期的各阶段调用实例的相关方法。
tomcat中EndPoint类的定义:
package javax.websocket;
public abstract class Endpoint {
public abstract void onOpen(Session session, EndpointConfig config);
public void onClose(Session session, CloseReason closeReason) {
// NO-OP by default
}
public void onError(Session session, Throwable throwable) {
// NO-OP by default
}
}
生命周期方法如下:
方法 | 含义描述 | 注解 |
onOpen | 当开启一个新的会话的时候调用,该方法是客户端与服务端握手成功后调用的方法 | @OnOpen |
onClose | 当会话关闭时调用 | @OnClose |
onError | 当连接过程中异常时调用 | @OnError |
我们发现,endpoint中没有接受消息的方法,因为webSocket接收消息采用的是消息处理器MessageHandler来接收消息。
在编程式方式中,我们可以为每一个会话(webSocket 中的session)添加MessageHandler消息处理器来接收消息;
当采用注解方式定义Endpoint时,通过@OnMessage注解指定接收消息的方式,当客户端有消息发送过来的时候,就会自动触发该方法,我们可以在onMessage这个方法中处理消息。
发送消息则由RemoteEndpoint完成,其实例由session维护,根据使用情况,我们可以通过Session.getBasicRemote获取同步消息发送的实例,然后调用RemoteEndpoint的sendXxx()方法就可以发送消息,可以通过Session.getAsyncRemote获取异步消息发送实例。
4. webSocket的demo案例
4.1 业务需求
实现一个简易的聊天室功能
4.2 实现流程时序图
4.3 传递消息的格式
4.3.1 客户端——>服务端
{"fromName":"张三",“toName”:"李四","content":"干嘛了?"}
4.3.2 服务端——>客户端
1)如果type为User,则说明返回的是用户列表
{"data":"张三,李四,王五","toName":"","fromName":"","type":"user"}
2)如果type为message,则返回的是消息内容
{"data":"你好","toName":"张三","fromName":"李四","type":"message"}
4.4 准备工作
1)创建项目,导入依赖
2)登录页面login.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
<script src="js/jquery-1.8.2.js"></script>
</head>
<body>
<form name="loginForm">
用户名:<input type="text" name="username" id="username"/>
密码:<input type="password" name="password" id="password" /> <br/>
<input type="button" value="登录" onclick="login()"/>
</form>
</body>
<script type="text/javascript">
function login() {
$.ajax({
type:'post',
url:'/chartRoom/login',
dataType:'json',
data:{
username:$("#username").val(),
password:$("#password").val()
},
success:function(data){
if(data.success){
window.location.href="chat.jsp";
}else{
alert(data.message);
}
}
});
}
</script>
</html>
3)定义登录Servlet
简单实现,获取页面传递的用户名和密码,只要传递的聊天室密码是123456,则认为正确,允许登录。
package com.bjc.servlet;
import com.alibaba.fastjson.JSON;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@WebServlet(name="loginServlet",urlPatterns = "/login")
public class LoginServlet extends HttpServlet {
private static final String PASSWORD = "123456";
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doPost(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 设置响应字符集
resp.setCharacterEncoding("UTF-8");
// 1. 接收页面传递的参数
String username = req.getParameter("username");
String password = req.getParameter("password");
Map<String,Object> restltMap = new HashMap<>();
// 2. 判定用户名密码是否正确
if(PASSWORD.equals(password)){ // 3. 如果正确,给客户端响应登录成功的信息
restltMap.put("success",true);
restltMap.put("message","登录成功!");
// 保存用户信息到session
req.getSession().setAttribute("username",username);
} else { // 4. 如果不正确,响应登录失败的信息
restltMap.put("success",false);
restltMap.put("message","登录失败,用户名或密码错误!");
}
// 将数据以json格式响应给前端
resp.getWriter().write(JSON.toJSONString(restltMap));
}
}
4.5 OnOpen测试
4.5.1 页面准备
chat.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
<link rel="stylesheet" type="text/css" href="css/main.css"/>
<script src="js/jquery-1.8.2.js"></script>
</head>
<body onload="startWebSocket(self);" style="TEXT-ALIGN: center;">
<h2 style="background-color: greenyellow">用户 ${username}</h2>
<div id="contentBody" class="contentBody">
<div class="leftDiv">
<div class="msgShow">
nihao s
</div>
<div clas="bottomDiv">
<div class="textArea">
<textarea name="content" id="content" ></textarea>
<input type="button" value="发送" id="btnSend" />
</div>
</div>
</div>
<div class="rightDiv">
<div class="up">
<div style="border-bottom: 1px solid #000;">好友列表</div>
<div id="userList"></div>
</div>
<div class="down">
<div style="border-bottom: 1px solid #000;">系统广播</div>
<div id="broadcastList"></div>
</div>
</div>
</div>
</body>
<script type="text/javascript">
<%
String name = session.getAttribute("username") + "";
%>
var self = "<%=name%>";
var ws;
function startWebSocket(self) {
var url = "ws://localhost:8080/chartRoom/chatWeSocket"
if ("WebSocket" in window) {
ws = new WebSocket(url);
} else if ("MozWebSocket" in window) {
ws = new MozWebSocket(url);
} else {
alert("浏览器版本过低,请升级您的浏览器");
}
// 监听消息,有消息传递,会触发此方法
ws.onmessage = function(evt){
var _data = evt.data;
console.log("data >> " + _data);
var o = JSON.parse(_data);
if(o.type == 'message'){ // 如果后端响应的是消息,在页面展示
setMessageInnerHTML(o,self);
} else if(o.type == 'user'){ // 如果客户端相应的是用户列表,在界面显示用户列表
var userArray = o.data.split(",");
$("#userList").empty();
$("#userList").append('<li><input type="radio" name="toUser" value="All">广播</li>');
$.each(userArray,function(n,value){
if(value != self && value != 'admin'){
$("#userList").append('<li><input type="radio" name="toUser" value="'+value+'" />' + value + '</li>');
$("#broadcastList").append('<li>您的好友 '+value+' 上线啦!</li>');
}
});
}
// 关闭连接的时候触发
ws.onclose = function(evt){
$("#username").html("用户" + self + "离线了");
}
ws.onopen = function(evt){
$("#username").html("用户" + self + "上线了");
}
}
}
function setMessageInnerHTML(o,self){
console.log("o = " + o);
}
$("#btnSend").click(function () {
if (ws.readyState == WebSocket.OPEN) {
var message = "{\"name\":\"" + $("#txtName").val() + "\",\"message\":\"" + $("#txtInput").val() + "\"}";
ws.send(message);
}
else {
$("#msg").text("Connection is Closed!");
}
});
</script>
</html>
css文件main.css
.contentBody {
display: flex;
justify-content: space-between;
border:1px solid #000;
margin: auto;
padding: 10px;
width: 800px;
height: 500px;
}
.leftDiv {
border:1px solid #000;
margin: auto;
padding: 10px;
width: 500px;
height: 450px;
}
.rightDiv {
border:1px solid #000;
margin: auto;
padding: 10px;
width: 250px;
height: 450px;
}
.up {
border:1px solid #000;
margin: auto;
padding: 10px;
height: 250px;
}
.down {
border:1px solid #000;
margin: auto;
padding: 10px;
height: 150px;
}
.msgShow {
width: 500px;
height: 350px;
}
.bottomDiv {
width: 500px;
height: 180px;
}
.textArea {
margin-bottom: 50px;
padding: 0;
width: 100%;
height: 200px;
}
#content {
width: 100%;
height: 100px;
}
4.5.2 后端代码实现
1)MessageUtil工具类
该工具类用于处理后台返回前台的数据
package com.bjc.util;
import com.alibaba.fastjson.JSON;
import java.util.HashMap;
import java.util.Map;
public class MessageUtil {
public final static String TYPE = "type";
public final static String DATA = "data";
public final static String FROM_NAME = "fromName";
public final static String TO_NAME = "toName";
public final static String TYPE_MESSAGE = "message";
public final static String TYPE_USER = "user";
// 组装消息,然后返回一个json格式的消息数据
public static String getContent(String type,String fromName,String toName,String content) {
Map<String,Object> userMap = new HashMap<>();
userMap.put(MessageUtil.TYPE,type);
userMap.put(MessageUtil.DATA,content);
userMap.put(MessageUtil.FROM_NAME,fromName);
userMap.put(MessageUtil.TO_NAME,toName);
String jsonMsg = JSON.toJSONString(userMap);
return jsonMsg;
}
}
2)HTTPSessionConfigurator
该类是一个配置类,继承自ServerEndpointConfig.Configurator,在@ServerEndpoint注解上需要使用到
package com.bjc.webSocket;
import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
public class HTTPSessionConfigurator extends ServerEndpointConfig.Configurator {
/**
在客户端与webSocket在进行第一次握手的过程,获取HttpSession
*/
@Override
public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
// 1. 拿到HTTPSession
HttpSession httpSession = (HttpSession)request.getHttpSession();
// 2. 将HTTPSession存储在ServerEndpointConfig对象中
config.getUserProperties().put(HttpSession.class.getName(),httpSession);
}
}
3)webSocket逻辑处理类
ChatSocket.java
package com.bjc.webSocket;
import com.bjc.util.MessageUtil;
import javax.servlet.http.HttpSession;
import javax.websocket.EndpointConfig;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@ServerEndpoint(value="/chatWeSocket",configurator = HTTPSessionConfigurator.class)
public class ChatSocket {
// 记录websocket的会话对象session
private Session session;
// 记录当前用户的Servlet的HttpSession
private HttpSession httpSession;
/**
* map中存放的是key为HTTPSession,值为Endpoint的实例
* 因为Endpoint的实例是在第一次握手的时候创建的,且每一个客户端都拥有一个Endpoint的实例对象
* 而我们的ChatSocket实际上是实现了ChatSocket的类,所以,ChatSocket就是一个Endpoint
* 该map可以用于记录当前登录用户的HttpSession信息,及对应的Endpoint实例信息。
*/
private static Map<HttpSession,ChatSocket> onLineUsers = new HashMap<>();
// 记录当前登录用户数
private static int onLineCount = 0;
/**
* 表示只要请求的是 chatWeSocket ,且是第一次请求就会触发该方法
* 这里的config就是HTTPSessionConfigurator类中modifyHandshake方法的形参ServerEndpointConfig
*/
@OnOpen
public void onOpen(Session session, EndpointConfig config){
// 1. 记录webSocket的会话信息对象session
this.session = session;
// 2. 获取当前登录用户的HttpSession信息
HttpSession httpSession = (HttpSession)config.getUserProperties().get(HttpSession.class.getName());
this.httpSession = httpSession;
System.out.println("当前登录用户:" + httpSession.getAttribute("username") + ", Endpoint : " +hashCode() + " , 在线用户数:" + onLineUsers.size());
// 3. 记录当前登录用户信息,及对应的Endpoint实例
if(httpSession.getAttribute("username") != null){
onLineUsers.put(httpSession,this);
}
// 4. 获取当前所有登录用户
String names = getNames();
// 5.组装消息
String messages = MessageUtil.getContent(MessageUtil.TYPE_USER, "", "", names);
// 6. 通过广播的形式发送消息
/*
发送单挑消息
发送消息则由RemoteEndpoint完成,其实例由session维护,根据使用情况,
我们可以通过Session.getBasicRemote获取同步消息发送的实例,
然后调用RemoteEndpoint的sendXxx()方法就可以发送消息
* */
// session.getBasicRemote().sendText(message);
// 广播就是给所有用户发送信息,我们在onLineUsers中有存储所有在线用户的endpoint的实例信息
broadcastAllUsers(messages);
// 7. 记录当前用户登录数
incrCount();
}
// 广播的形式发送消息
private void broadcastAllUsers(String messages) {
for(HttpSession s : onLineUsers.keySet()){
ChatSocket chatSocket = onLineUsers.get(s);
try {
chatSocket.session.getBasicRemote().sendText(messages);
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 获取在线用户
private String getNames() {
StringBuilder names = new StringBuilder();
if(onLineUsers.size() > 0){
for(HttpSession h : onLineUsers.keySet()){
String username = (String)h.getAttribute("username");
if(names.length() == 0){
names.append(username);
} else {
names.append("," + username);
}
}
}
return names.toString();
}
public int getOnLineCount(){
return onLineCount;
}
public synchronized void incrCount(){
onLineCount ++;
}
public synchronized void decrCount(){
onLineCount --;
}
}
4.5.3 测试结果
在谷歌浏览器和360浏览器分别输入:http://localhost:8080/chartRoom/,输入用户名和密码,之后进入聊天界面,如图:
4.6 OnMessage
4.6.1 定义onMessage事件方法
/**
* @param message 客户端传递过来的消息内容
* @param session 当前会话对象
*/
@OnMessage
public void onMessage(String message,Session session){
System.out.println("onMessage: name = " + httpSession.getAttribute("username") + " message内容:" + message );
// 1. 获取客户端消息内容并解析
Map<String,String> messageMap = JSON.parseObject(message, Map.class);
String fromName = messageMap.get("fromName"); // 发送人
String toName = messageMap.get("toName"); // 接收人
String content = messageMap.get("content"); // 发送内容
// 2. 判定是否存在接收人
if(null == toName || toName.isEmpty()){
return;
}
// 组装消息内容
String msgContent = MessageUtil.getContent(MessageUtil.TYPE_MESSAGE, fromName, toName, message);
System.out.println("服务端给客户端发送消息,消息内容:" + msgContent);
if("All".equals(toName)){ // 3. 如果接收人是广播(All),如果是,则说明发送广播消息
broadcastAllUsers(msgContent);
} else { // 4. 如果不是广播,则给指定的用户推送消息
singlePushMsg(msgContent,fromName,toName);
}
}
// 给指定用户发送消息
private void singlePushMsg(String msgContent,String fromName,String toName) {
// 定义标记字段,标记toName是否在线
boolean isOnline = false;
// 定义toName对应的EndPoint实例对象
ChatSocket chatSocket2 = null;
// 定义fromName对应的EndPoint实例对象
ChatSocket chatSocket4 = null;
// 1. 判断当前接收人是否在线
for(HttpSession h : onLineUsers.keySet()){
if(toName.equals(h.getAttribute("username"))){
isOnline = true;
chatSocket2 = onLineUsers.get(h);
}
if(fromName.equals(h.getAttribute("username"))){
chatSocket4 = onLineUsers.get(h);
}
}
// 2. 如果存在,发送消息
if(isOnline){
try {
// 分别给发送人和接收人发送消息
chatSocket2.session.getBasicRemote().sendText(msgContent);
chatSocket4.session.getBasicRemote().sendText(msgContent);
} catch (IOException e) {
e.printStackTrace();
}
}
}
4.6.2 前台代码
1)发送消息的js
$("#btnSend").click(function () {
var content = $("#content").val();
if(!content){
alert("请输入内容!");
return;
}
var message = {};
message.fromName = self;
message.toName = $("input:radio:checked").val() ? $("input:radio:checked").val() : "All";
message.content = content;
var msg = JSON.stringify(message);
console.log(msg);
ws.send(msg);
});
2)显示消失的js
function setMessageInnerHTML(o,self){
var data = JSON.parse(o.data);
if(self == data.fromName){
$("#msgShow").append("<div style='margin-left: 10px;text-align: left'> "+ data.fromName +" 说: "+data.content+"</div>");
} else {
$("#msgShow").append("<div style='margin-right: 10px;text-align: right'> "+ data.fromName +" 说: "+data.content+"</div>");
}
}
4.6.3 onMessage测试
分别打开三个浏览器,输入网址http://localhost:8080/chartRoom
用户:张三、李四、王五 如图:
登录进去之后,首先张三广播一条消息,如图:
发现,都可以收到该消息,广播成功,然后张三给李四发一条消息,如图:
发现,李四成功的收到了张三放松的私信,王五并没有收到。
4.7 onClose与onError
/**
* wesocket关闭连接的处理函数
* @param session
* @param closeReason
*/
@OnClose
public void onClose(Session session, CloseReason closeReason) {
// 关闭之后,在线用户数减1
decrCount();
System.out.println("客户端关闭了一个链接,当前在线人数:" + getOnLineCount());
}
/**
* websocket出现异常的处理函数
* @param session
* @param throwable
*/
@OnError
public void onError(Session session, Throwable throwable) {
throwable.printStackTrace();
System.out.println("服务异常");
}
完整的demo工程的GitHub上,链接:https://github.com/zoudmbean/demoRepo.git