提到服务端数据推送,你可以一下子就想到了Websocket,WebSocket是一种全新的协议,随着HTML5草案的不断完善,越来越多的现代浏览器开始全面支持WebSocket技术了,它将TCP的Socket(套接字)应用在了webpage上,从而使通信双方建立起一个保持在活动状态连接通道。
但你可能不知道,HTML5中有一个轻量的替代Websocket的方案:SSE(Server-Sent Events)。
WebSocket 和 SSE 都是传统请求-响应 Web 架构的替代方案,但它们不是完全冲突的技术。
几乎所有现代浏览器都支持 WebSocket 协议,包括移动浏览器。然而Microsoft IE 和 Edge不支持SSE
但这并不妨碍我们使用SSE,毕竟用IE的人还有几个呢?如果是内部使用,为什么不使用更简单的SSE呢?
Websocket做聊天室可以阅读SpringBoot入门建站全系列(二十七)WebSocket做简单的聊天室,本篇讲述如何使用SSE做聊天室。
代码可以在SpringBoot组件化构建https://www.pomit.cn/java/spring/springboot.html中的WebSSE组件中查看,并下载。
**如果大家正在寻找一个java的学习环境,或者在开发中遇到困难,可以<a
href="https://jq.qq.com/?_wv=1027&k=52sgH1J"
target="_blank">
加入我们的java学习圈,点击即可加入
</a>
,共同学习,节约学习时间,减少很多在学习中遇到的难题。**
本文假设你已经引入spring-boot-starter-web。已经是个SpringBoot项目了,如果不会搭建,可以打开这篇文章看一看《SpringBoot入门建站全系列(一)项目建立》。
在Springboot项目中使用SSE,是不需要额外引入依赖的,只需要把spring-boot-starter-web引入即可。也不需要额外的配置。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
无需额外配置。
WebSSE是不需要像websocket那样繁杂的配置,它和普通http使用同一个接口,不需要额外配置端口,所以可以把sse维持接口和普通接口写在一起,下面就是聊天室用到的所有服务端接口。
WebSseController :
package com.cff.springbootwork.websse.web;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.cff.springbootwork.websse.dto.MessageDTO;
import com.cff.springbootwork.websse.dto.MessageDTO.Type;
import com.cff.springbootwork.websse.dto.ResultModel;
import com.cff.springbootwork.websse.memory.Chater;
import com.cff.springbootwork.websse.memory.WebSSEUser;
@RestController
@RequestMapping("/im")
public class WebSseController {
protected Logger logger = LoggerFactory.getLogger(getClass());
@RequestMapping(value = "/send")
public ResultModel send(@RequestBody MessageDTO<String> messageDTO, HttpServletRequest request) {
logger.info("收到发往用户[{}]的文本请求;", messageDTO.getTargetUserName());
Object userName = request.getSession().getAttribute("userName");
if (userName == null)
return ResultModel.error("无用户");
messageDTO.setFromUserName((String) userName);
messageDTO.setMessageType(Type.TYPE_TEXT.getMessageType());
Chater chater = WebSSEUser.getChater(messageDTO.getTargetUserName());
chater.addMsg(messageDTO);
return ResultModel.ok();
}
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter to(HttpServletRequest request) {
String userName = (String) request.getSession().getAttribute("userName");
// 超时时间设置为3分钟
SseEmitter sseEmitter = new SseEmitter(180000L);
Chater chater = WebSSEUser.getChater(userName);
sseEmitter.onTimeout(() -> chater.setSseEmitter(null));
sseEmitter.onCompletion(() -> System.out.println("完成!!!"));
chater.setSseEmitter(sseEmitter);
return sseEmitter;
}
@RequestMapping(value = "/setUser")
public ResultModel setUser(@RequestParam("userName") String userName, HttpServletRequest request) {
logger.info("设置用户[{}]", userName);
request.getSession().setAttribute("userName", userName);
Chater chater = new Chater();
chater.setUserName(userName);
WebSSEUser.add(userName, chater);
return ResultModel.ok();
}
@RequestMapping(value = "/user")
public ResultModel user(HttpServletRequest request) {
Object userName = request.getSession().getAttribute("userName");
if (userName == null)
return ResultModel.error("无用户");
return ResultModel.ok(userName);
}
@RequestMapping(value = "/userList")
public ResultModel userList() {
return ResultModel.ok(WebSSEUser.getUserList());
}
@RequestMapping(value = "/fileUpload")
public ResultModel fileUpload(@RequestParam("userName") String userName, @RequestParam MultipartFile[] myfiles,
HttpServletRequest request) {
logger.info("收到发往用户[{}]的文件上传请求;文件数量:{}", userName, myfiles.length);
int count = 0;
for (MultipartFile myfile : myfiles) {
if (myfile.isEmpty()) {
count++;
}
logger.info("文件原名:{};文件类型:", myfile.getOriginalFilename(), myfile.getContentType());
try (ByteArrayOutputStream swapStream = new ByteArrayOutputStream();
InputStream is = myfile.getInputStream();) {
byte[] buff = new byte[100]; // buff用于存放循环读取的临时数据
int rc = 0;
while ((rc = is.read(buff, 0, 100)) > 0) {
swapStream.write(buff, 0, rc);
}
byte[] in_b = swapStream.toByteArray(); // in_b为转换之后的结果
logger.info("正在发送文件: ");
MessageDTO<ByteBuffer> messageDTO = new MessageDTO<>();
messageDTO.setFromUserName(userName);
messageDTO.setMessage(ByteBuffer.wrap(in_b, 0, in_b.length));
messageDTO.setMessageType(Type.TYPE_BYTE.getMessageType());
Chater chater = WebSSEUser.getChater(messageDTO.getTargetUserName());
chater.addMsg(messageDTO);
} catch (IOException e) {
logger.error("文件原名:{}", myfile.getOriginalFilename(), e);
e.printStackTrace();
count++;
continue;
}
}
return ResultModel.ok(count);
}
}
这里,
/send
接口就是发送消息的,这个发送接口时普通的http接口,通过参数判断发送给谁,然后从内存中获取目标用户,调用chater.addMsg(messageDTO);
发送消息,chater.addMsg(messageDTO);
中有发送逻辑。/subscribe
接口是sse服务端的重点,它标记了一个接口是长连接提供text/event-stream
响应类型,并返回SpringMVC提供的SseEmitter对象,这个时候我们需要保存下这个SseEmitter,和用户对应上。使用SseEmitter对象,可以不断从服务端发消息给客户端。/setUser
和/user
、/userList
接口,只是为了模拟登录和获取用户信息而已。/fileUpload
是文件上传接口,普通的http而已。我自定义了一个Chater对象,保存用户信息和SseEmitter对象,这样就可以通过Chater对象送消息了。
这里,使用sseEmitter.send(msgItem);
来发送消息。
Chater:
package com.cff.springbootwork.websse.memory;
import java.io.IOException;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.cff.springbootwork.websse.dto.MessageDTO;
public class Chater {
private String userName;
private SseEmitter sseEmitter;
private Queue<MessageDTO<?>> msgList = new ConcurrentLinkedQueue<>();
public void addMsg(MessageDTO<?> msg) {
msgList.add(msg);
while (!msgList.isEmpty()) {
MessageDTO<?> msgItem = msgList.poll();
try {
sseEmitter.send(msgItem);
} catch (IOException e) {
e.printStackTrace();
}
}
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public SseEmitter getSseEmitter() {
return sseEmitter;
}
public void setSseEmitter(SseEmitter sseEmitter) {
this.sseEmitter = sseEmitter;
}
}
使用一个简单map来存储用户和Chater的对应。
WebSSEUser:
package com.cff.springbootwork.websse.memory;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class WebSSEUser {
private static Map<String, Chater> userChaterMap = new ConcurrentHashMap<>();
public static void add(String userName, Chater chater) {
userChaterMap.put(userName, chater);
}
/**
* 根据昵称拿Chater
*
* @param nickName
* @return
*/
public static Chater getChater(String userName) {
return userChaterMap.get(userName);
}
/**
* 移除失效的Chater
*
* @param Chater
*/
public static void removeUser(String userName) {
userChaterMap.remove(userName);
}
public static Set<String> getUserList() {
return userChaterMap.keySet();
}
}
消息实体MessageDTO:
package com.cff.springbootwork.websse.dto;
public class MessageDTO<T> {
private String fromUserName;
private String targetUserName;
private T message;
private String messageType;
public String getFromUserName() {
return fromUserName;
}
public void setFromUserName(String fromUserName) {
this.fromUserName = fromUserName;
}
public T getMessage() {
return message;
}
public void setMessage(T message) {
this.message = message;
}
public String getMessageType() {
return messageType;
}
public void setMessageType(String messageType) {
this.messageType = messageType;
}
public String getTargetUserName() {
return targetUserName;
}
public void setTargetUserName(String targetUserName) {
this.targetUserName = targetUserName;
}
public static enum Type {
TYPE_NEW("0000"), TYPE_TEXT("1000"), TYPE_BYTE("1001");
private String messageType;
Type(String messageType) {
this.messageType = messageType;
}
public String getMessageType() {
return messageType;
}
public void setMessageType(String messageType) {
this.messageType = messageType;
}
}
}
普通的返回实体ResultModel:
package com.cff.springbootwork.websse.dto;
/**
* @author cff
*/
public class ResultModel {
private String errorCode;
private String message;
private Object data;
public ResultModel() {
}
public ResultModel(String errorCode, String message) {
this.errorCode = errorCode;
this.message = message;
}
public ResultModel(String errorCode, String message, Object data) {
this.errorCode = errorCode;
this.message = message;
this.data = data;
}
public String geterrorCode() {
return errorCode;
}
public void seterrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public static ResultModel ok() {
return new ResultModel("0000","成功");
}
public static ResultModel ok(Object data) {
return new ResultModel("0000","成功", data);
}
public static ResultModel error() {
return new ResultModel("1111","失败");
}
public static ResultModel error(String msg) {
return new ResultModel("1111","失败", msg);
}
public static ResultModel error(String msg, Object data) {
return new ResultModel("1111", msg, data);
}
}
为了实现我们的简单聊天功能,我们需要前端进行配合。
chat.html实现了简单的聊天室,支持文字、表情、文件等:
该html需要很多js配合,下面贴出html和websse.js,其他js都是很普遍的js,如果需要我发送,加入群聊向群主索要。
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>品茗IT-WebSocket测试</title>
<!-- CSS -->
<link href="https://lib.baomitu.com/material-design-icons/3.0.1/iconfont/material-icons.min.css" rel="stylesheet">
<link href="https://lib.baomitu.com/materialize/0.100.2/css/materialize.min.css" type="text/css" rel="stylesheet" media="screen,projection"/>
<style>
body { text-align:left; margin:0; font:normal 12px Verdana, Arial;
background:#FFEEFF } form { margin:0; font:normal 12px Verdana,
Arial; } table,input { font:normal 12px Verdana, Arial; }
a:link,a:visited{ text-decoration:none; color:#333333; } a:hover{
text-decoration:none; color:#FF6600 } #main { width:400px;
position:absolute; left:600px; top:100px; background:#EFEFFF;
text-align:left; filter:Alpha(opacity=90) } #ChatHead {
text-align:right; padding:3px; border:1px solid #003399;
background:#DCDCFF; font-size:20px; color:#3366FF; cursor:move; }
#ChatHead a:link,#ChatHead a:visited, { font-size:14px;
font-weight:bold; padding:0 3px } #ChatBody { border:1px solid
#003399; border-top:none; padding:2px; } #ChatContent {
height:200px; padding:6px; overflow-y:scroll; word-break: break-all
}#ChatBtn { border-top:1px solid #003399; padding:2px }
</style>
</head>
<script type="text/javascript">
var curUser=null;
var chatUser = null;
var imgName = null;
var fileImgSize = 0;
function gs(d) {
var t = document.getElementById(d);
if (t) {
return t.style;
} else {
return null;
}
}
function gs2(d, a) {
if (d.currentStyle) {
var curVal = d.currentStyle[a]
} else {
var curVal = document.defaultView
.getComputedStyle(d, null)[a]
}
return curVal;
}
function ChatHidden() {
gs("ChatBody").display = "none";
}
function ChatShow() {
gs("ChatBody").display = "";
}
function ChatClose() {
gs("main").display = "none";
}
function ChatNew(userId) {
gs("main").display = "";
chatUser = userId;
$("#ChatUsers").html(chatUser);
$('.emotion').qqFace({
id : 'facebox',
assign:'saytext',
path: './img/arclist/' //表情存放的路径
});
}
function ChatClear(obj) {
$("#ChatContent").html("");
}
function ChatRead() {
if(document.getElementById(chatUser)){
document.getElementById(chatUser).setAttribute('src', './img/users.png');
}
}
function ChatSend(obj) {
var o = obj.ChatValue;
var msg = replace_em(o.value);
if (o.value.length > 0) {
$("#ChatContent").append(
"<p align=\"right\"><strong>" + curUser + "(我) :</strong>" + msg
+ "</p>");
var number = $("#ChatContent").scrollTop();
number += 16;
$("#ChatContent").scrollTop(number);
var json={"fromUserName":curUser,"targetUserName":chatUser,"message":o.value,"messageType":"1000"};
// encodeURI(o.value)
console.log(json);
send(JSON.stringify(json));
o.value = '';
}
var img = obj.ChatFile;
if (img.value.length > 0){
$("#ChatContent").append(
"<p align=\"right\"><strong>" + nickName + "(我) :</strong>" + img.value
+ "</p><br/>");
imgName = nickName+'(我)';
fileImgSize = img.files.length;
//alert(fileImgSize);
$.ajaxFileUpload({
//处理文件上传操作的服务器端地址(可以传参数,已亲测可用)
url:'im/fileUpload?userName='+muserId,
secureuri:true, //是否启用安全提交,默认为false
fileElementId:'ChatFile', //文件选择框的id属性
dataType:'text', //服务器返回的格式,可以是json或xml等
success:function(data, status){ //服务器响应成功时的处理函数
//$("#ChatContent").append("<p align=\"right\">" + data + "</p><br/>");
},
error:function(data, status, e){ //服务器响应失败时的处理函数
$("#ChatContent").append('<p align=\"right\">图片上传失败,请重试!!</p><br/>');
imgName = msgUser;
}
});
}
}
if (document.getElementById) {
(function() {
if (window.opera) {
document.write("<input type='hidden' id='Q' value=' '>");
}
var n = 500;
var dragok = false;
var y, x, d, dy, dx;
function move(e) {
if (!e)
e = window.event;
if (dragok) {
d.style.left = dx + e.clientX - x + "px";
d.style.top = dy + e.clientY - y + "px";
return false;
}
}
function down(e) {
if (!e)
e = window.event;
var temp = (typeof e.target != "undefined") ? e.target
: e.srcElement;
if (temp.tagName != "HTML" | "BODY"
&& temp.className != "dragclass") {
temp = (typeof temp.parentNode != "undefined") ? temp.parentNode
: temp.parentElement;
}
if ('TR' == temp.tagName) {
temp = (typeof temp.parentNode != "undefined") ? temp.parentNode
: temp.parentElement;
temp = (typeof temp.parentNode != "undefined") ? temp.parentNode
: temp.parentElement;
temp = (typeof temp.parentNode != "undefined") ? temp.parentNode
: temp.parentElement;
}
if (temp.className == "dragclass") {
if (window.opera) {
document.getElementById("Q").focus();
}
dragok = true;
temp.style.zIndex = n++;
d = temp;
dx = parseInt(gs2(temp, "left")) | 0;
dy = parseInt(gs2(temp, "top")) | 0;
x = e.clientX;
y = e.clientY;
document.onmousemove = move;
return false;
}
}
function up() {
dragok = false;
document.onmousemove = null;
}
document.onmousedown = down;
document.onmouseup = up;
})();
}
function toIndex(){
window.location.href= contextPath + "/index.jsp";
}
</script>
<body>
<div id="main" class="dragclass" onclick="ChatRead()" style="left: 400px; top: 200px;">
<div id="ChatUsers" style="width:100px; padding:3px; font-size:15px;float:left; display:inline"></div>
<div id="ChatHead">
<a href="#" onclick="ChatHidden();">-</a> <a href="#"
onclick="ChatShow();">+</a> <a href="#" onclick="ChatClose();">x</a>
</div>
<div id="ChatBody">
<div id="ChatContent"></div>
<div id="ChatBtn">
<form action="" name="chat" method="post">
<textarea name="ChatValue" id="saytext" rows="3" style="width: 350px"></textarea>
<input name="Submit" type="button" value="发送"
onclick="ChatSend(this.form);" />
<input name="ClearMsg" type="button" value="清空记录"
onclick="ChatClear(this.form);" />
<input type="button" class="emotion" value="表情">
<input id="ChatFile" type="file" name="myfiles" multiple>
</form>
</div>
</div>
</div>
<div id="modalAddUser" class="modal modal-fixed-footer" style="max-width:400px;max-height:400px">
<div class="modal-content">
<h4>生成用户名</h4>
<div class="row center">
<input class="browser-default searchInput" placeholder="请输入用户名" style="margin-top:50px;margin-left:20px;max-width:300px" id="catoryAddText" type="text" >
</div>
<div class="row center">
<a class="waves-effect waves-light btn" id="userAddBtn" style="color:white;"><i class="material-icons" style="font-size:1.1rem">添用户</i></a>
</div>
</div>
<div class="modal-footer">
<a href="#!" class=" modal-action modal-close waves-effect waves-green btn-flat">关闭</a>
</div>
</div>
<div align="left" style="margin-top: 50px;margin-left: 20px;">
<p>欢迎您,
<span id="userName">匿名用户</span>
</p>
<a id="addUser" class="btn waves-effect waves-light white cyan-text" style="border-radius: 40px;">添加用户</a>
<p id="content"></p>
</div>
<script src="https://lib.baomitu.com/jquery/3.3.0/jquery.min.js"></script>
<script src="https://lib.baomitu.com/materialize/0.100.2/js/materialize.min.js"></script>
<script src="./js/websse.js"></script>
<script src="./js/ajaxfileupload.js"></script>
<script src="./js/jquery-browser.js"></script>
<script src="./js/jquery.qqFace.js"></script>
<script>
function getUser(){
$.ajax({
type : "get",
url : "im/user",
dataType : "json",
data : {} ,
success : function(data) {
if(data.errorCode == "0000"){
$("#userName").html(data.data);
curUser = data.data;
}
},
error : function(XMLHttpRequest, textStatus, errorThrown) {
alert(errorThrown);
}
});
}
function addUser(userName){
$.ajax({
type : "post",
url : "im/setUser",
dataType : "json",
data : {"userName":userName} ,
success : function(data) {
if(data.errorCode == "0000"){
$("#userName").html(userName);
curUser = data.data;
}
},
error : function(XMLHttpRequest, textStatus, errorThrown) {
alert(errorThrown);
}
});
}
function userList(){
$.ajax({
type : "get",
url : "im/userList",
dataType : "json",
data : {} ,
success : function(data) {
if(data.errorCode == "0000"){
var content = "";
for(var i =0;i<data.data.length;i++){
var userId = data.data[i];
content += "<img src=\"./img/msgget.gif\" id=\""
+ userId
+ "\" alt=\"\" style=\"cursor: pointer\" width='40px' "
+ "onclick=\"ChatNew('"+userId+"')\" />"
+ userId
+ "<br><br>";
}
$("#content").append(content);
}
},
error : function(XMLHttpRequest, textStatus, errorThrown) {
alert(errorThrown);
}
});
}
$(function () {
$('.modal').modal({
dismissible: true, // 点击模态外面模态消失关闭
opacity: 0.1, // 相对于背景的不透明度
in_duration: 300, // 显示特效的时间
out_duration: 200, // 消失特效时间
starting_top: '80%', // 启动时的样式属性
ending_top: '20%', // 结束时的样式属性
ready: function(modal, trigger) { // 模态加载完成触发事件
},
complete: function() {
} // 关闭时触发的事件
});
getUser();
$("#addUser").click(function() {
$('#modalAddUser').modal('open');
});
$("#userAddBtn").click(function() {
var catory = $('#catoryAddText').val();
addUser(catory);
});
userList();
var url = "/im/subscribe";
//alert("url:"+url);
if (!url) {
return;
}
console.log(url);
var es = new EventSource(url);
es.addEventListener("message", function(e){
decode(e);
},false);
ChatClose();
});
</script>
</body>
<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?e553ae2bb23494dee9b6f43415a1ce5a";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</html>
这个html需要websse.js配合:
function replace_em(str){
str = str.replace(/\[em_([0-9]*)\]/g,'<img src="../../img/arclist/$1.gif" border="0" />');
return str;
}
function send(jsondata) {
$.ajax({
type : "post",
url : "im/send",
dataType : "json",
contentType : 'application/json',
data : jsondata,
success : function(data) {
if(data.errorCode == "0000"){
}
},
error : function(XMLHttpRequest, textStatus, errorThrown) {
alert(errorThrown);
}
});
}
function decode(event) {
if(typeof(event.data)=="string"){
var dataAll = event.data;
var jsonData = JSON.parse(dataAll);
console.log(jsonData);
var msgType = jsonData.messageType;
if(msgType == "0000"){
$("#ChatContent").append("<strong>系统消息:</strong>" + jsonData.message + "<br>");
}else{
var data = jsonData.message;
var userId = jsonData.fromUserName;
var msg = jsonData.message;
var result = replace_em(msg);
if(document.getElementById(userId)){
document.getElementById(userId).setAttribute('src', './img/msgget.gif');
var number = $("#ChatContent").scrollTop();
//var number = $("#ChatContent").height();
number += 15;
$("#ChatContent").scrollTop(number);
$("#ChatContent").append("<strong>"+userId+" :</strong>" + result + "<br>");
}else{
var content = "<img src=\"./img/msgget.gif\" id=\""
+ userId
+ "\" alt=\"\" style=\"cursor: pointer\" width='40px' "
+ "onclick=\"ChatNew()\" />"
+ userId
+ "<br><br>";
$("#content").append(content);
$("#ChatContent").append("<strong>"+userId+" :</strong>" + result + "<br>");
}
}
}else{
var reader = new FileReader();
reader.onload = function(event){
if(event.target.readyState == FileReader.DONE){
var url = event.target.result;
if (imgName != msgUser){
$("#ChatContent").append("<p align=\"right\"><strong>"+imgName+" :</strong>"+"<img src = "+url+" width='100px'/></p><br>");
}else{
$("#ChatContent").append("<strong>"+imgName+" :</strong>"+"<img src = "+url+" width='100px'/><br>");
}
if (fileImgSize != 0){
fileImgSize = fileImgSize - 1;
}else{
imgName = msgUser;
}
}
}
reader.readAsDataURL(event.data);
}
}
聊天室界面如下:
在这里插入图片描述