一、引言
1. 问题引入
Hypertext Transfer Protocol (HTTP) 协议
一种无状态的、应用层的、以请求/应答方式运行的协议,它使用可扩展的语义和自描述消息格式,与基于网络的超文本信息系统灵活的互动.
因为http 通信只能由客户端发起,服务器返回查询结果,HTTP 协议做不到服务器主动向客户端推送信息,
服务器有连续的状态变化,客户端要获知就非常麻烦。
我们只能使用轮询:每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。
轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开);
2. 消息推送常见方式
常见的消息推送发送:轮询,长轮询,SSE,WebSocket
轮询方式
SSE(server-sent event):服务器发送事件
- SSE在服务器和客户端之间打开一个单向通道
- 服务端响应的不再是一次性的数据包,而是text/event-stream类型的数据流信息
- 服务器有数据变更时将数据流式传输到客户端
二、WebSocket
1. WebSocket介绍
WebSocket 是一种网络通信协议
。RFC6455 定义了它的通信标准。
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
HTTP 协议是一种无状态的、无连接的、单向的应用层协议。它采用了请求/响应模型。通信请求只能由客户端发起,服务端对请求做出应答处理。
这种通信模型有一个弊端:HTTP 协议无法实现服务器主动向客户端发起消息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。大多数 Web 应用程序将通过频繁的异步 AJAX 请求实现长轮询。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。
http协议:
websocket协议:
2. websocket协议
本协议有两部分:握手和数据传输。
握手是基于http协议的。
来自客户端的握手看起来像如下形式:
GET ws://localhost/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Version: 13
来自服务器的握手看起来像如下形式:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate
字段说明:
头名称 | 说明 |
---|---|
Connection:Upgrade | 标识该HTTP请求是一个协议升级请求 |
Upgrade: WebSocket | 协议升级为WebSocket协议 |
Sec-WebSocket-Version: 13 | 客户端支持WebSocket的版本 |
Sec-WebSocket-Key: | 客户端采用base64编码的24位随机字符序列,服务器接受客户端HTTP协议升级的证明。要求服务端响应一个对应加密的Sec-WebSocket-Accept头信息作为应答 |
Sec-WebSocket-Extensions | 协议扩展类型 |
3. 客户端(浏览器)实现
3.1 websocket对象
实现 WebSockets 的 Web 浏览器将通过 WebSocket 对象公开所有必需的客户端功能(主要指支持 Html5 的浏览器)。
以下 API 用于创建 WebSocket 对象:
javascript
var ws = new WebSocket(url);
参数url格式说明: ws://ip地址:端口号/资源名称
3.2 websocket事件
WebSocket 对象的相关事件
事件 | 事件处理程序 | 描述 |
---|---|---|
open | websocket对象.onopen | 连接建立时触发 |
message | websocket对象.onmessage | 客户端接收服务端数据时触发 |
error | websocket对象.onerror | 通信发生错误时触发 |
close | websocket对象.onclose | 连接关闭时触发 |
3.3 WebSocket方法
WebSocket 对象的相关方法:
方法 | 描述 |
---|---|
send() | 使用连接发送数据 |
4. 服务端实现
Tomcat的7.0.5 版本开始支持WebSocket,并且实现了Java WebSocket规范(JSR356)。
Java WebSocket应用由一系列的WebSocketEndpoint组成。Endpoint
是一个java对象,代表WebSocket链接的一端,对于服务端,我们可以视为处理具体WebSocket消息的接口, 就像Servlet之与http请求一样。
我们可以通过两种方式定义Endpoint:
- 第一种是编程式, 即继承类 javax.websocket.Endpoint并实现其方法。
- 第二种是注解式, 即定义一个POJO, 并添加 @ServerEndpoint相关注解。
Endpoint实例在WebSocket握手时创建,并在客户端与服务端链接过程中有效,最后在链接关闭时结束。在Endpoint接口中明确定义了与其生命周期相关的方法, 规范实现者确保生命周期的各个阶段调用实例的相关方法。生命周期方法如下:
方法 | 含义描述 |
---|---|
onClose | 当会话关闭时调用。 |
onOpen | 当开启一个新的会话时调用, 该方法是客户端与服务端握手成功后调用的方法。 |
onError | 当连接过程中异常时调用。 |
服务端如何接收客户端发送的数据呢?
-
编程式
通过添加 MessageHandler 消息处理器来接收消息
-
注解式
在定义Endpoint时,通过@OnMessage注解指定接收消息的方法
服务端如何推送数据给客户端呢?
发送消息则由 RemoteEndpoint 完成, 其实例由 Session (websocket.Session) 维护。发送消息有2种方式发送消息通过session.getBasicRemote 获取同步消息发送的实例 , 然后调用其 sendXxx()方法发送消息通过session.getAsyncRemote 获取异步消息发送实例,然后调用其 sendXxx() 方法发送消息
服务端代码:
三、基于WebSocket的网页聊天室
完整项目地址
1. 需求
通过 websocket 实现一个简易的聊天室功能 。
1). 登陆聊天室
2). 登陆之后,进入聊天界面进行聊天
登陆成功后,呈现出以后的效果:
当我们想和李四聊天时就可以点击 好友列表
中的 李四
,效果如下:
接下来就可以进行聊天了,张三
的界面如下:
李四
的界面如下:
2. 实现流程
3. 消息格式
张三
给李四
发消息
-
客户端 --> 服务端
{"toName":"李四","message":"你好"}
-
服务端 --> 客户端
推送给某一个的消息格式:{"isSystem":false,"fromName":"张三","message":"你好"}
系统消息格式:{"isSystem":true,"fromName":null,"message":["李四","王五"]}
4. 功能实现
4.1 创建项目,导入相关jar包的坐标
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.17</version>
<relativePath/>
</parent>
<groupId>com.lxx</groupId>
<artifactId>lxx_chat</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lxx_chat</name>
<description>lxx_chat</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.78</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.2 引入静态资源文件
4.3 引入公共资源
pojo类
java
package com.lxx.pojo;
import lombok.Data;
//用于登陆响应回给浏览器的数据
@Data
public class Result {
private boolean flag;
private String message;
}
java
package com.lxx.pojo;
import lombok.Data;
@Data
public class User {
private String userId;
private String username;
private String password;
}
java
package com.lxx.ws.pojo;
import lombok.Data;
//浏览器发送给服务器的websocket数据
@Data
public class Message {
private String toName;
private String message;
}
java
package com.lxx.ws.pojo;
import lombok.Data;
//服务器发送给浏览器的websocket数据
@Data
public class ResultMessage {
private boolean isSystem;
private String fromName;
private Object message;//如果是系统消息是数组
}
MessageUtils工具类
java
package com.lxx.utils;
import com.alibaba.fastjson.JSON;
import com.lxx.ws.pojo.ResultMessage;
//用来封装消息的工具类
public class MessageUtils {
public static String getMessage(boolean isSystemMessage, String fromName, Object message) {
ResultMessage result = new ResultMessage();
result.setSystem(isSystemMessage);
result.setMessage(message);
if (fromName != null) {
result.setFromName(fromName);
}
return JSON.toJSONString(result);
}
}
4.4 登陆功能实现
login.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<title>登录</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="keywords"
content="Transparent Sign In Form Responsive Widget,Login form widgets, Sign up Web forms , Login signup Responsive web form,Flat Pricing table,Flat Drop downs,Registration Forms,News letter Forms,Elements"/>
<script type="application/x-javascript">
addEventListener("load", function () {
setTimeout(hideURLbar, 0);
}, false);
function hideURLbar() {
window.scrollTo(0, 1);
}
</script>
<link rel="icon" href="img/chat.ico" type="image/x-icon"/>
<link rel="stylesheet" href="css/font-awesome.css"/> <!-- Font-Awesome-Icons-CSS -->
<link rel="stylesheet" href="css/login.css" type="text/css" media="all"/> <!-- Style-CSS -->
</head>
<body class="background">
<div class="header-w3l">
<h1>畅聊</h1>
</div>
<div class="main-content-agile" id="app">
<div class="sub-main-w3">
<h2>登录</h2>
<form id="loginForm">
<div class="icon1">
<input placeholder="用户名" id="username" v-model="user.username" type="text"/>
</div>
<div class="icon2">
<input placeholder="密码" id="password" v-model="user.password" type="password"/>
</div>
<div class="clear"></div>
<input type="button" id="btn1" @click="login" value="登录"/>
<div class="icon1">
<span id="err_msg" style="color: red; ">{{errMessage}}</span>
</div>
</form>
</div>
</div>
<div class="footer">
<p>xxx教育科技有限公司 版权所有Copyright 2006-2019 All Rights Reserved </p>
</div>
<script src="js/vue.js"></script>
<script src="js/axios-0.18.0.js"></script>
<script>
new Vue({
el: "#app",
data() {
return {
errMessage: "",
user: {
username: "",
password: ""
}
}
},
methods: {
login() {
axios.post("user/login", this.user).then(res => {
//判断登陆是否成功
if (res.data.flag) {
location.href = "main.html"
} else {
this.errMessage = res.data.message;
}
});
}
}
});
</script>
</body>
</html>
UserController:进行登陆逻辑处理
java
package com.lxx.controller;
import com.lxx.pojo.Result;
import com.lxx.pojo.User;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpSession;
@RestController
@RequestMapping("user")
public class UserController {
/**
* 登陆
*
* @param user 提交的用户数据,包含用户名和密码
* @param session
* @return
*/
@PostMapping("/login")
public Result login(@RequestBody User user, HttpSession session) {
Result result = new Result();
if (user != null && "123".equals(user.getPassword())) {
result.setFlag(true);
//将数据存储到session对象中
session.setAttribute("user", user.getUsername());
} else {
result.setFlag(false);
result.setMessage("登陆失败");
}
return result;
}
}
4.5 获取当前登录的用户名
main.html:页面加载完毕后,发送请求获取当前登录的用户名
js
await axios.get("user/getUsername").then(res => {
this.username = res.data;
});
UserController
在UserController中添加一个getUsername方法,用来从session中获取当前登录的用户名并响应回给浏览器
java
/**
* 获取用户名
*
* @param session
* @return
*/
@GetMapping("/getUsername")
public String getUsername(HttpSession session) {
String username = (String) session.getAttribute("user");
return username;
}
4.6 聊天室功能
客户端实现
在main.html页面实现前端代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0, minimum-scale=1.0, maximum-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta content="yes" name="apple-mobile-web-app-capable">
<meta content="yes" name="apple-touch-fullscreen">
<meta name="full-screen" content="yes">
<meta content="default" name="apple-mobile-web-app-status-bar-style">
<meta name="screen-orientation" content="portrait">
<meta name="browsermode" content="application">
<meta name="msapplication-tap-highlight" content="no">
<meta name="x5-orientation" content="portrait">
<meta name="x5-fullscreen" content="true">
<meta name="x5-page-mode" content="app">
<base target="_blank">
<title>聊天室</title>
<link href="css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
<link rel="stylesheet" href="css/chat.css">
</head>
<body>
<img style="width:100%;height:100%" src="img/chat_bg.jpg">
<div class="abs cover contaniner" id="app">
<div class="abs cover pnl">
<div class="top pnl-head" style="padding: 20px ; color: white;">
<div id="userName">
用户:{{username}}
<span style='float: right;color: green' v-if="isOnline">在线</span>
<span style='float: right;color: red' v-else>离线</span>
</div>
<div id="chatMes" v-show="chatMes" style="text-align: center;color: #6fbdf3;font-family: 新宋体">
正在和 <font face="楷体">{{toName}}</font> 聊天
</div>
</div>
<!--聊天区开始-->
<div class="abs cover pnl-body" id="pnlBody">
<div class="abs cover pnl-left" id="initBackground" style="background-color: white; width: 100%">
<div class="abs cover pnl-left" id="chatArea" v-show="isShowChat">
<div class="abs cover pnl-msgs scroll" id="show">
<div class="pnl-list" id="hists"><!-- 历史消息 --></div>
<div class="pnl-list" id="msgs" v-for="message in historyMessage">
<!-- 消息这展示区域 -->
<div class="msg guest" v-if="message.toName">
<div class="msg-right">
<div class="msg-host headDefault"></div>
<div class="msg-ball">{{message.message}}</div>
</div>
</div>
<div class="msg robot" v-else>
<div class="msg-left" worker="">
<div class="msg-host photo"
style="background-image: url(img/avatar/Member002.jpg)"></div>
<div class="msg-ball">{{message.message}}</div>
</div>
</div>
</div>
</div>
<div class="abs bottom pnl-text">
<div class="abs cover pnl-input">
<textarea class="scroll" id="context_text" @keyup.enter="submit" wrap="hard"
placeholder="在此输入文字信息..."
v-model="sendMessage.message"></textarea>
<div class="abs atcom-pnl scroll hide" id="atcomPnl">
<ul class="atcom" id="atcom"></ul>
</div>
</div>
<div class="abs br pnl-btn" id="submit" @click="submit"
style="background-color: rgb(32, 196, 202); color: rgb(255, 255, 255);">
发送
</div>
<div class="pnl-support" id="copyright"><a href="http://www.itcast.cn">传智播客,版本所有</a>
</div>
</div>
</div>
<!--聊天区 结束-->
<div class="abs right pnl-right">
<div class="slider-container hide"></div>
<div class="pnl-right-content">
<div class="pnl-tabs">
<div class="tab-btn active" id="hot-tab">好友列表</div>
</div>
<div class="pnl-hot">
<ul class="rel-list unselect">
<li class="rel-item" v-for="friend in friendsList"><a @click='showChat(friend)'>{{friend}}</a>
</li>
</ul>
</div>
</div>
<div class="pnl-right-content">
<div class="pnl-tabs">
<div class="tab-btn active">系统广播</div>
</div>
<div class="pnl-hot">
<ul class="rel-list unselect" id="broadcastList">
<li class="rel-item" style="color: #9d9d9d;font-family: 宋体"
v-for="name in systemMessages">您的好友
{{name}} 已上线
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="js/vue.js"></script>
<script src="js/axios-0.18.0.js"></script>
<script>
let ws;
new Vue({
el: "#app",
data() {
return {
isShowChat: false,
chatMes: false,
isOnline: true,
username: "",
toName: "",
sendMessage: {
toName: "",
message: ""
},
inputMessage: "",
historyMessage: [
/*{toName: "李四", message: "你好,张三"},
{toName: "李四", message: "在吗"},
{toName: "李四", message: "怎么不说话"},
{fromName: "张三", message: "你好,李四"}*/
],
friendsList: [
/* "李四",
"王五"*/
],
systemMessages: [
/*"张三",
"李四"*/
]
}
},
created() {
this.init();
},
methods: {
async init() {
await axios.get("user/getUsername").then(res => {
this.username = res.data;
});
//创建webSocket对象
ws = new WebSocket("ws://localhost/chat");
//给ws绑定事件
ws.onopen = this.onopen;
//接收到服务端推送的消息后触发
ws.onmessage = this.onMessage;
ws.onclose = this.onClose;
},
showChat(name) {
this.toName = name;
//清除聊天区的数据
let history = sessionStorage.getItem(this.toName);
if (!history) {
this.historyMessage = [];
} else {
this.historyMessage = JSON.parse(history);
}
//展示聊天对话框
this.isShowChat = true;
//显示"正在和谁聊天"
this.chatMes = true;
},
submit() {
this.sendMessage.toName = this.toName;
this.historyMessage.push(JSON.parse(JSON.stringify(this.sendMessage)));
sessionStorage.setItem(this.toName, JSON.stringify(this.historyMessage));
ws.send(JSON.stringify(this.sendMessage));
this.sendMessage.message = "";
},
onOpen() {
this.isOnline = true;
},
onClose() {
this.isOnline = false;
},
onMessage(evt) {
//获取服务端推送过来的消息
var dataStr = evt.data;
//将dataStr 转换为json对象
var res = JSON.parse(dataStr);
//判断是否是系统消息
if (res.system) {
//系统消息 好友列表展示
var names = res.message;
this.friendsList = [];
this.systemMessages = [];
for (let i = 0; i < names.length; i++) {
if (names[i] != this.username) {
this.friendsList.push(names[i]);
this.systemMessages.push(names[i]);
}
}
} else {
//非系统消息
var history = sessionStorage.getItem(res.fromName);
if (!history) {
this.historyMessage = [res];
} else {
this.historyMessage.push(res);
}
sessionStorage.setItem(res.fromName, JSON.stringify(this.historyMessage));
}
}
}
});
</script>
</body>
</html>
服务端代码实现
WebSocketConfig 类实现
java
package com.lxx.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
//注入ServerEndpointExporter,自动注册使用@ServerEndpoint注解的
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
GetHttpSessionConfig 类实现
java
package com.lxx.config;
import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
public class GetHttpSessionConfig extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
HttpSession httpSession = (HttpSession) request.getHttpSession();
config.getUserProperties().put(HttpSession.class.getName(),httpSession);
}
}
ChatEndpoint 类实现
java
package com.lxx.ws;
import com.alibaba.fastjson.JSON;
import com.lxx.config.GetHttpSessionConfig;
import com.lxx.utils.MessageUtils;
import com.lxx.ws.pojo.Message;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpSession;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfig.class)
@Component
public class ChatEndpoint {
//用来存储每一个客户端对象对应的Session对象
private static final Map<String, Session> onlineUsers = new ConcurrentHashMap<>();
private HttpSession httpSession;
/**
* 建立websocket连接后,被调用
*
* @param session
*/
@OnOpen
public void onOpen(Session session, EndpointConfig config) {
//1,将websocket.Session进行保存
this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
String user = (String) this.httpSession.getAttribute("user");
onlineUsers.put(user, session);
//2,广播消息。需要将登陆的所有的用户推送给所有的用户
String message = MessageUtils.getMessage(true, null, getFriends());
broadcastAllUsers(message);
}
public Set getFriends() {
Set<String> set = onlineUsers.keySet();
return set;
}
private void broadcastAllUsers(String message) {
try {
//遍历map集合
Set<Map.Entry<String, Session>> entries = onlineUsers.entrySet();
for (Map.Entry<String, Session> entry : entries) {
//获取到所有用户对应的session对象
Session session = entry.getValue();
//发送消息
session.getBasicRemote().sendText(message);
}
} catch (Exception e) {
//记录日志
}
}
/**
* 浏览器发送消息到服务端,该方法被调用
* <p>
* 张三 --> 李四
* 客户端 --> 服务端 {"toName":"李四","message":"你好"}
* 服务端 --> 客户端 {"isSystem":false,"fromName":"张三","message":"你好"}
*
* @param message
*/
@OnMessage
public void onMessage(String message) {
try {
//将消息推送给指定的用户
// {"toName":"李四","message":"你好"}
Message msg = JSON.parseObject(message, Message.class);
//获取 消息接收方的用户名
// "李四"
String toName = msg.getToName();
// "你好"
String sendMsg = msg.getMessage();
//获取消息接收方用户对象的websocket.Session对象
Session session = onlineUsers.get(toName);
// 发送消息给接收方
// "张三"
String user = (String) this.httpSession.getAttribute("user");
// {"isSystem":false,"fromName":"张三","message":"你好"}
String msg1 = MessageUtils.getMessage(false, user, sendMsg);
session.getBasicRemote().sendText(msg1);
} catch (Exception e) {
//记录日志
}
}
/**
* 断开 websocket 连接时被调用
*
* @param session
*/
@OnClose
public void onClose(Session session) {
//1,从onlineUsers中剔除当前用户的session对象
String user = (String) this.httpSession.getAttribute("user");
onlineUsers.remove(user);
//2,通知其他所有的用户,当前用户下线了
String message = MessageUtils.getMessage(true, null, getFriends());
broadcastAllUsers(message);
}
}