项目展示与地址
近段时间学习了SSM框架,就想利用这还算流行的SSM框架写个项目练练手,碰巧在网上看到了关于WebSocket
的一些知识,于是就有了实现一个在线聊天室的想法。现在大体上的功能已经完成了。然后我买了一个云服务器,将整个项目部署了上去。项目地址:EasyChat(建议使用Chrome浏览,其他浏览器可能有兼容问题)。github地址:https://github.com/MccreeFei/EasyChat。整体的运行效果我还是挺满意的,后续有时间会追加一些其他功能。目前整个项目效果图如下面的gif图所示:
因为对自己的定位是后端程序员,所以前端也就没怎么花心思,其中登录注册版块是在网上下载的html
模板,然后自己写了一些js
、ajax
与后台交互,主页面模仿的一个用Nodejs
实现聊天室的博客。
WebSocket与Stomp
要了解WebSocket
就必须要知道为什么需要WebSocket
。我们都知道Http
协议,但是Http有一个缺陷就是:通信只能由客户端发起,也就是说,如果你想实现在线聊天功能,只能客户端不断地发送请求给服务器询问有没有新消息到达。可想而知,这种方式是非常占资源,且效率是非常低的。因此就有了WebSocke
t的诞生。WebSocket
是一种在单个TCP
连接上进行全双工通讯的协议,通信可以从任意一端发起。WebSocket
在Http
协议中完成握手,握手成功后升级到WebSocket协议。WebSocket
可以传输文本以及二进制数据,但并未规范发送数据的格式。因此在WebSocket
协议之上可以使用高层次的文本协议比如Stomp
。
Stomp
(Streaming Text Orientated Message Protocol)是流文本定向消息协议,定义了消息发送的规范,它的frame
中有connect
、subscribe
、send
等命令,connect
就是请求建立连接,其中头部有accept-version
表示接受的stomp
协议版本等,subscribe
是订阅监听某一个地址的消息,常见头部有destination
表示监听地址,send
是发送消息到目标地址,常见头部有destination
表示发送地址。关于Stomp
的更多详细内容可以去看官方文档。
Spring
对基于Stomp
的WebSocket
提供了非常好的支持,只需要在项目中引入spring-messaging
与spring-websocket
模块。然后根据官方文档配置一下即可使用,配置代码如下所示:
/**
* Spring websocket配置类
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
/**
* 定义接收/websocket时采用wensocket连接,添加HttpSessionHandshakeInterceptor 是为了websocket握手前将httpsession中的属性
* 添加到websocket session中,withSockJS添加对sockJS的支持
*/
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) {
stompEndpointRegistry.addEndpoint("/websocket").addInterceptors(new HttpSessionHandshakeInterceptor()).withSockJS();
}
/**
* 配置消息代理,以/app为头的url将会先经过MessageMapping
* /topic直接进入消息代理
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
}
}
另外补充一点的是:基于WebSocket
添加了对sockJS
的支持,sockJS
会对那些客户端不支持WebSocket
的浏览器更换策略比如长轮询,这样不支持WebSocket
的浏览器也能享受服务器的服务,就是效率不高罢了。
登录注册模块
登录与注册都采用了ajax
技术,都是发送json
数据到后台,后台查询然后返回json
数据给前台做出响应。
登录
返回登录json
的消息,我封装了一个实体ReplayLoginMessage
。项目添加了jakson-databind
模块依赖并且mvc配置了MappingJackson2HttpMessageConverter
,后端返回对象将会直接转化成json
数据传递到前端。MappingJackson2HttpMessageConverter
并不需要显示配置,添加<mvc: annoation driven/>
注解支持将会添加基础的mvc注解支持和基础的MessageConverter
。
/**
* 反馈前端ajax登录的信息实体
*/
public class ReplyLoginMessage {
public static final Integer USER_NAME_NOT_EXIST = 1; //当前登录的用户名尚未注册
public static final Integer USER_PASSWORD_WRONG = 2; //登录密码错误
public static final Integer USER_NAME_OR_PASSWORD_NULL = 3; //用户姓名或密码未填写
private boolean successed; //登录是否成功
private Integer errStatus; //错误原因
public ReplyLoginMessage(boolean successed) {
this.successed = successed;
}
public ReplyLoginMessage(boolean successed, Integer errStatus) {
this.successed = successed;
this.errStatus = errStatus;
}
public boolean isSuccessed() {
return successed;
}
public void setSuccessed(boolean successed) {
this.successed = successed;
}
public Integer getErrStatus() {
return errStatus;
}
public void setErrStatus(Integer errStatus) {
this.errStatus = errStatus;
}
}
后端接收json
数据只需要添加@RequestBody
注解。只有后台返回登录的消息为true
时,前端才会用js提交表单,提交表单后后台还需要再进行一次账号密码的验证。为什么要两次验证呢,我是想要用户登录错误时能够及时反馈用户出错的原因,是因为该用户还没有注册还是密码错误,这就需要ajax
,那么为什么提交表单后还要再验证呢,是防止客户用第三方软件直接模仿表单提交。
/**
* 反馈前端ajax登录的消息
*/
@RequestMapping(value = "/reply/login", method = RequestMethod.POST)
@ResponseBody
public ReplyLoginMessage replayLoginMessage(@RequestBody User user) {
if (user.getName() == null || user.getName().trim().equals("") || user.getPassword() == null || user.getPassword().equals("")) {
return new ReplyLoginMessage(false, ReplyLoginMessage.USER_NAME_OR_PASSWORD_NULL);
}
boolean isExist = userService.isExistUser(user.getName());
if (!isExist) {
return new ReplyLoginMessage(false, ReplyLoginMessage.USER_NAME_NOT_EXIST);
}
User res = userService.validateUserPassword(user.getName(), user.getPassword());
if (res == null) {
return new ReplyLoginMessage(false, ReplyLoginMessage.USER_PASSWORD_WRONG);
}
return new ReplyLoginMessage(true);
}
注册
同样用于返回前端注册消息,封装注册消息实体类。
/**
* 反馈前端ajax注册的消息实体
*/
public class ReplyRegistMessage {
public static final Integer USER_NAME_EXIST = 1; //注册名已经存在
private boolean successed; //是否注册成功
private Integer errStatus; //错误原因
public ReplyRegistMessage(boolean isSuccessed) {
this.successed = isSuccessed;
}
public ReplyRegistMessage(boolean isSuccessed, Integer errStatus) {
this.successed = isSuccessed;
this.errStatus = errStatus;
}
public boolean isSuccessed() {
return successed;
}
public void setSuccessed(boolean successed) {
successed = successed;
}
public Integer getErrStatus() {
return errStatus;
}
public void setErrStatus(Integer errStatus) {
this.errStatus = errStatus;
}
}
后台接收注册人的账号密码消息,插入到数据库完成注册。
/**
* 反馈前端ajax注册的消息
*/
@RequestMapping(value = "/reply/regist", method = RequestMethod.POST)
@ResponseBody
public ReplyRegistMessage replyRegistMessage(@RequestBody User user) {
boolean isExist = userService.isExistUser(user.getName());
if (isExist) {
return new ReplyRegistMessage(false, ReplyRegistMessage.USER_NAME_EXIST);
}
if (user.getPassword() != null) {
userService.insertUser(user.getName(), user.getPassword());
}
return new ReplyRegistMessage(true);
}
实时聊天模块
订阅地址
要想监听哪个地址的消息就必须先订阅这个地址。我按照项目的需求划分了这几个地址:
-
/app/chat/participants
:以/app
为前缀的地址首先会被MessageMapping
或者SubscribeMapping
处理,这个地址的目的就是客户登进聊天室时能够立即知晓目前聊天室的人数,它是交给后台SubscribeMapping
注解方法处理的,后台将直接返回当前在线人数,然后前台得到数据后显示。 -
/topic/login
:订阅新用户登录消息的地址,当有新用户登录时,后台将会向这个地址发送新用户的信息,前台显示新用户上线消息并将在线人数加1。、 -
/topic/chat/message
:订阅接收聊天消息的地址,每当有用户发送消息,后台就会向这个地址发送这个消息,前台显示出这个消息。 -
/topic/logout
: 订阅用户离线消息的地址,每当有用户关闭浏览器,后台监听到然后向这个地址发送用户离线消息。
前端相应代码:
/**
* 客户端连接服务端websocket
* 并且订阅一系列频道,用来接收不同种类的消息
* /app/chat/participants :当前在线人数的消息,只会接收一次
* /topic/login : 新登录用户的消息
* /topic/chat/message : 聊天内容消息
* /topic/logout : 用户离线的消息
* 服务器发回json实例 {"userName":"chris","sendDate":1494664021793,"content":"hello","messageType":"text"}
* messageType分为:text与image
*/
function connect() {
var socket = new SockJS($("#websocketUrl").val().trim());
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
stompClient.subscribe("/app/chat/participants", function(message) {
showActiveUserNumber(message.body);
var user = "系统消息";
var date = null;
var msg = $("#myName").val() + "加入聊天!";
showNewMessage(user, date, msg);
});
stompClient.subscribe("/topic/login", function(message) {
showNewUser(message.body);
});
stompClient.subscribe("/topic/chat/message", function(message) {
var json = JSON.parse(message.body);
var messageType = json.messageType;
var user = json.userName;
var date = json.sendDate;
var msg = json.content;
if (messageType == "text") {
showNewMessage(user, date, msg);
} else if (messageType == "image") {
showNewImage(user, date, msg);
}
})stompClient.subscribe("/topic/logout", function(message) {
showUserLogout(message.body);
})});
}
后台处理相关代码:
/**
* 登录进入聊天室
*/
@RequestMapping(value = "/chat", method = RequestMethod.POST)
public String loginIntoChatRoom(User user, HttpServletRequest request) {
user = userService.validateUserPassword(user.getName(), user.getPassword());
if (user == null) {
return "login";
}
user.setLoginDate(new Date());
user.setPassword(null); //设空防止泄露给其他用户
HttpSession session = request.getSession();
session.setAttribute("user", user);
messagingTemplate.convertAndSend(SUBSCRIBE_LOGIN_URI, user);
participantRepository.add(user.getName(), user);
logger.info(user.getLoginDate() + ", " + user.getName() + " login.");
return "chatroom";
}
/**
* 返回当前在线人数 * @return
*/
@SubscribeMapping("/chat/participants")
public Long getActiveUserNumber() {
return Long.valueOf(participantRepository.getActiveSessions().values().size());
}
发送文本消息
前端监听监听键盘事件,如果是Enter键那么向发送消息地址发送输入框中的消息。发送Emoji
图片也是相当于发送文本的,用户选择哪个Emoji
图片只是将[EMOJI:'id']
形式的文本插入输入框当做文本信息发送。服务端并不解析,只是将相同的文本发送到相应的地址,由前端接收到时用正则表达式匹配整个文本的内容然后替换成p标签。相关代码如下所示:
/**
* 发送输入框中的信息
*/
function sendMessage() {
var content = $("#messageInput").val();
if (content.trim().length != 0) {
$("#messageInput").val('');
stompClient.send("/app/chat/message", {}, JSON.stringify({'userName' :$("#myName").val(), 'content' :content }))
;
}
}
/**
* 正则表达式显示消息中的emoji图片
* 返回添加emoji图片标签后的消息
*/
function showEmoji(message) {
var result = message, regrex = /\[EMOJI:\d +\]/g, match;
while (match = regrex.exec(message)) {
var emojiIndex = match[0].slice(7, -1);
var emojiUrl = $("#emojiBaseUri").val().trim() + emojiIndex + ".gif";
result = result.replace(match[0], '');
}
return result;
}
后台只是转发文本信息:
/**
* 接收并且转发消息
*/
@MessageMapping("/chat/message")
public void receiveMessage(Message message) {
message.setSendDate(new Date());
message.setMessageType("text");
logger.info(message.getSendDate() + "," + message.getUserName() + " send a message:" + message.getContent());
messagingTemplate.convertAndSend(SUBSCRIBE_MESSAGE_URI, message);
}
发送图片
发送图片我是采用的ajax
提交表单的形式提交图片,form
标签中需要添加enctype="multipart/form-data"
属性,让浏览器知道怎么编码文件。另前端ajax
中用FormData
提交表单内容,通过FormData
对象可以组装一组用 XMLHttpRequest
发送请求的键/值对,达到提交表单功能。后端用MutipartFile
来接受文件,然后组合图片名称转存到服务器的相应目录下,服务器再将图片地址发送给各个用户来显示图片。相应代码如下所示:
/**
* 上传图片发送
*/
$("#sendImage").bind("change",function () {
if (this.files.length != 0) {
$.ajax({url:$("#uploadUrl").val(), type:'POST', cache:false, data:
new FormData($('#sendImageForm')[0]), processData:false, contentType:false }).done(function(res) {
console.log(res);
}).fail(function(res) {
console.log(res);
});
}
});
/**
* 接收转发图片
*/
@RequestMapping(value = "/upload/image", method = RequestMethod.POST)
@ResponseBody
public String handleUploadImage(HttpServletRequest request, @RequestParam("image") MultipartFile imageFile, @RequestParam("userName") String userName) {
if (!imageFile.isEmpty()) {
String imageName = userName + "_" + imageFile.getOriginalFilename();
String path = request.getServletContext().getRealPath(IMAGE_PREFIX) + imageName;
File localImageFile = new File(path);
try {
imageFile.transferTo(localImageFile); //文件转存到服务器文件中
Message message = new Message();
message.setMessageType("image");
message.setUserName(userName);
message.setSendDate(new Date());
message.setContent(request.getContextPath() + IMAGE_PREFIX + imageName);
messagingTemplate.convertAndSend(SUBSCRIBE_MESSAGE_URI, message);
} catch (IOException e) {
logger.error("图片上传失败:" + e.getMessage());
return "upload false";
}
}
return "upload success";
}