571
社区成员
发帖
与我相关
我的任务
分享
之前有以聊天室作为例子来学习Java,当时用的是Socket和多线程。这次,使用SpringBoot+WebScoket技术来实现消息的传输。
在学习的过程中,参考的部分例子会直接将subscript URL视为发送URL,直接发送到subscript或先发送到MessageMapping然后再发送到SendTo,二者的区别如下图所示。

/app是服务器端接收客户端发送的消息,即/ws/v1/connect。首先获取登录信息,记录登录用户的ID,并将消息发送到三个不同的通道里。其路线为:request ->SimpAnnotationMethod -> broker -> SimpleBroker ->response。
如果直接发送到subscript通道,其路线为:request -> SimpleBroker -> response,直接将消息发布到通道,通道再将消息传递给subscript。这样做虽然简单,但是没有后端控制逻辑,将来增加额外功能时需要对前端进行更改。
运行:
./gradlew bootRun
使用http://localhost:8080/chatroom打开聊天室页面。

页面被分成三个部分。顶部是登录模块,有一个可输入的帐户ID和连接按钮。左下角显示联系人列表。当有人登录时,他们的ID包括自己的ID都将显示在这里。右下方为聊天界面,输入聊天内容,并按发送按钮即能发送消息。

在聊天屏幕中,有人登录,系统会有消息提示(背景为蓝色,文字居中)。自己这边发送的信息背景为绿色,文字右对齐,并且标记为“我”。对方发送的信息背景为白色,文字左对齐,并且标记对方的姓名。
因为SpringBoot已经很好地集成了WebSocket,所以设置非常简单,只需三个主要的设置,以后写controller或客户端javascript时会用到它们。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/ws/v1");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket-cr").withSockJS();
}
}
控制器提供了加载chatroom.html、websocket连接、发送消息、发布到subscript等功能。
@Controller
@Slf4j
public class ChatRoomWebSocketController {
@Autowired
CrMessageService crMessageService;
@Autowired
private SimpMessageSendingOperations messagingTemplate;
@MessageMapping("/connect")
//接收点,表示接收客户端发送的消息。
//前面设置过application destination prefixes: /ws/v1,即客户端实际上将消息发送到/ws/v1/connect的URL上。
@SendTo("/topic/crcontact")
//将返回对象发送到SendTo指定的通道,即有subscript /topic/crcontact通道的客户端会接收一个crContact(包含当前在线的所有联系人)。
public CrContact connectWs(CrMessage crMessage) throws CrAlertException {
//connectWs的目的是接收用户登录的消息,通过CrMessageService记录登录用户的ID,并将消息发布到控制器三个不同的通道中。
String mrid = crMessage.getCrid();
String uid = crMessage.getSender();
crMessageService.connectChatRoom(mrid, uid);
//提示用户加入
//创建一个信息提示,发布到/topic/alert通道
CrAlert alert = crMessageService.createCrAlert("info", String.format("User[%s] join talk!", uid));
messagingTemplate.convertAndSend("/topic/alert", alert);
//为用户登录创建一个CrMessage,发布到/topic/CrMessage通道。
crMessage.setSender("sysinfo");
crMessage.setMessage(uid + " join talk!");
crMessage = crMessageService.sendCrMessage(crMessage);
messagingTemplate.convertAndSend("/topic/crmessage", crMessage);
return crMessageService.getAllContactByCrid(mrid);
}
@MessageMapping("/disconnect")
@SendTo("/topic/crcontact")
public CrContact disconnectWs(CrMessage crMessage) throws CrAlertException {
String mrid = crMessage.getCrid();
String uid = crMessage.getSender();
crMessageService.disconnectChatRoom(mrid, uid);
//提示用户退出
CrAlert alert = crMessageService.createCrAlert("warning", String.format("User[%s] exit talk!", uid));
messagingTemplate.convertAndSend("/topic/alert", alert);
crMessage.setSender("sysdanger");
crMessage.setMessage(uid + " exit talk!");
crMessage = crMessageService.sendCrMessage(crMessage);
messagingTemplate.convertAndSend("/topic/crmessage", crMessage);
return crMessageService.getAllContactByCrid(mrid);
}
@MessageMapping("/sendCrMessage")
@SendTo("/topic/crmessage")
public CrMessage[] sendMrMessage(CrMessage crMessage) throws CrAlertException {
return new CrMessage[] {crMessageService.sendCrMessage(crMessage)};
}
@MessageExceptionHandler
//处理系统异常的一种方法。
//在接收和发布消息的方法上都有一个Throws CrAlertException。当抛出CrAlertException时,表示消息无法完成发送,并转向处理alert的通道。
@SendTo("/topic/alert")
public CrAlert handleException(Throwable exception) {
return ((CrAlertException)exception).getCrAlert();
}
@GetMapping(value = "/chatroom")
public String viewDefaultMeetingRoomPage(Model model) {
model.addAttribute("crid", crMessageService.getDefaultCrid());
return "chatroom";
}
@GetMapping(value = "/chatroom/{crid}")
public String viewMeetingRoomPage(Model model, @PathVariable("crid") String crid) {
model.addAttribute("crid", crid);
return "chatroom";
}
}
下面是服务器端代码。
@Service
@Slf4j
public class CrMessageService {
private Set<String> cridSet = new HashSet<String>();
private Map<String, Set<String>> crIdSet = new HashMap();
private String defaultCrid = "default";
private DateFormat df = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
@PostConstruct
public void initialize() {
cridSet.add(defaultCrid);
crIdSet.put(defaultCrid, new LinkedHashSet<>());
}
public String getDefaultCrid() {
return defaultCrid;
}
public CrMessage connectChatRoom(String mrid, String userid) {
if (StringUtils.isEmpty(mrid)) {
mrid = defaultCrid;
}
CrMessage crMessage = new CrMessage();
if (!cridSet.contains(mrid)) {
crMessage.setStatus("fail");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("The Meeting Room[{}] not exist!", mrid));
} else if(crIdSet.get(mrid).contains(userid)) {
crMessage.setStatus("fail");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("The user id[{}] existed in Meeting Room[{}]!", userid, mrid));
} else {
Set<String> ids = crIdSet.get(mrid);
ids.add(userid);
crMessage.setStatus("success");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("Welcome to Meeting Room[{}]!", mrid));
log.info(String.format("connect ids size:%d", ids.size()));
}
return crMessage;
}
public CrMessage disconnectChatRoom(String crid, String userid) {
if (StringUtils.isEmpty(crid)) {
crid = defaultCrid;
}
CrMessage crMessage = new CrMessage();
if (!cridSet.contains(crid)) {
crMessage.setStatus("fail");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("The Meeting Room[{}] not exist!", crid));
} else if(!crIdSet.get(crid).contains(userid)) {
crMessage.setStatus("fail");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("The user id[{}] not existed in Meeting Room[{}]!", userid, crid));
} else {
Set<String> ids = crIdSet.get(crid);
ids.remove(userid);
crMessage.setStatus("success");
crMessage.setSender("System");
crMessage.setSendTime(df.format(new Date()));
crMessage.setMessage(String.format("Bye!"));
log.info(String.format("disconnect ids size:%d", ids.size()));
}
return crMessage;
}
public CrContact getAllContactByCrid(String crid) {
CrContact crContact = new CrContact();
crContact.setStatus("success");
crContact.setCrid(crid);
Set<String> ids = crIdSet.get(crid);
crContact.setUids(ids.toArray(new String[ids.size()]));
return crContact;
}
public CrMessage sendCrMessage(CrMessage crMessage) throws CrAlertException {
String uid = crMessage.getSender();
//当用户ID错误,会抛出一个CrAlertException,导向handleException进行处理。
if (uid.equals("error")) {
// 模拟异常并向客户端发送警报
CrAlert mrAlert = createCrAlert("danger", "System error, please try again!");
throw new CrAlertException(mrAlert);
}
String crid = getDefaultCrid();
if (!StringUtils.isEmpty(crMessage.getCrid())) {
crid = crMessage.getCrid();
}
crMessage.setCrid(crid);
crMessage.setSendTime(df.format(new Date()));
crMessage.setStatus("Success");
log.info("service get message:{}", crMessage.getMessage());
return crMessage;
}
public CrAlert createCrAlert(String type, String msg) {
CrAlert alert = new CrAlert();
alert.setType(type);
alert.setMessage(msg);
alert.setAlertSender("sys");
alert.setAlertTime(df.format(new Date()));
return alert;
}
}
下面的代码展示了客户端javascript如何连接到webSocket、发送和接收消息。客户端javascript主要在chatroom.js。
function getCrid() {
return $("#crid").text();
}
function getUid() {
return $("#uid").val();
}
var isConnected = false;
var stompClient = null;
/**
* 连接或断开websocket
*/
function connectWebSocket() {
isConnected = !isConnected;
if (isConnected) {
//首先,建立到/websocket-cr的连接。这里的连接URL与之前设置的endpoint要对应。
//然后,使用StompClient对象建立连接,并subscribe crcontact、crmessage、alert三个通道。这三个通道都有处理消息显示的功能。
//最后,connectWs。
$("#btnConnect").text("Disconnect");
var socket = new SockJS('/websocket-cr');
stompClient = Stomp.over(socket);
stompClient.connect({"uid": getUid()}, function(frame) {
// console.log('Connected: ' + frame);
// subscribe三个通道
stompClient.subscribe('/topic/crcontact', function(respnose) {
displayContacts(JSON.parse(respnose.body));
});
stompClient.subscribe('/topic/crmessage', function(respnose) {
displayAppendCrMessage(JSON.parse(respnose.body));
});
stompClient.subscribe('/topic/alert', function(respnose) {
displayAlert(JSON.parse(respnose.body));
});
// 向服务器发送连接uid
connectWs();
});
} else {
$("#btnConnect").text("Connect");
if (stompClient != null) {
// 发送断开uid到服务器
disconnectWs();
// ws disconnect
stompClient.disconnect();
}
}
console.log("connectWebSocket:" + isConnected);
}
function displayContacts(crContact) {
// console.log("displayContacts ...");
var contactlist = $("#contactlist");
contactlist.children().remove();
if (crContact) {
crContact.uids.forEach(function(uid) {
var msgItem = $("<li/>").addClass("list-group-item")
.text(uid)
.appendTo(contactlist);
});
}
}
function appendCrMessage(messagelist, msg) {
var msgItem = $("<li/>").addClass("list-group-item")
.appendTo(messagelist);
if (msg.sender == getUid()) {
msgItem.addClass("list-group-item-success text-right");
msgItem.text("me:" + msg.message + " at " + msg.sendTime)
} else if (msg.sender == "sysinfo") {
msgItem.addClass("list-group-item-info text-center");
msgItem.text("sys:" + msg.message + " at " + msg.sendTime)
} else if (msg.sender == "sysdanger") {
msgItem.addClass("list-group-item-danger text-center");
msgItem.text("sys:" + msg.message + " at " + msg.sendTime)
} else {
msgItem.text(msg.sender + ":" + msg.message + " at " + msg.sendTime);
}
// console.log((msg.sender == getUid()) + "-" + (msg.sender == "sysinfo") + "-" + (msg.sender == "sysdanger"));
}
function displayAppendCrMessage(crMessages) {
var messagelist = $("#messagelist");
if (crMessages) {
if (Array.isArray(crMessages)) {
crMessages.forEach(function(msg) {
appendCrMessage(messagelist, msg);
});
} else {
appendCrMessage(messagelist, crMessages);
}
}
// 滚动到最后一个
$('ul#messagelist li:last').get(0).scrollIntoView();
}
function displayAlert(crAlert) {
if (crAlert) {
var alertdiv = $("#_alertdiv").clone();
$("body").prepend(alertdiv);
alertdiv.attr("id", "alertdiv");
alertdiv.find(".alert-msg").empty().text(crAlert.message);
alertdiv.removeClass("alert-info alert-warning d-none").addClass("alert-" + crAlert.type).addClass("d-block");
}
}
function closeAlert(btn) {
var alertdiv = $(btn).parent("div");
alertdiv.removeClass("d-block").addClass("d-none");
}
function createCrMessage(message) {
var crid = getCrid();
var uid = getUid();
var crMessage = {
"status" : "",
"crid" : crid,
"sender" : uid,
"sendTime" : "",
"message" : message
}
return crMessage;
}
function connectWs() {
//使用StompClient对象将消息传递到/ws/v1/connect。
//在控制器的@messagmapping中只写了connect,但由于将application destination prefixes设置为/ws/v1,所以完整的URL应该是,prefix+消息映射名称。
console.log("connectWs...");
var crMessage = createCrMessage();
stompClient.send("/ws/v1/connect", {}, JSON.stringify(crMessage));
}
function disconnectWs() {
console.log("disconnectWs...");
var crMessage = createCrMessage();
stompClient.send("/ws/v1/disconnect", {}, JSON.stringify(crMessage));
displayContacts();
}
function sendCrMessage() {
// console.log("sendCrMessage...");
var uid = getUid();
var messageBox = $("#messageBox");
var message = messageBox.val();
messageBox.empty();
console.log(uid + ":" + message);
var crMessage = createCrMessage(message);
//如果直接发布到subscript的通道中,将收到一个JSON对象
//如果把发送和接收分开,后端也可以处理消息,然后通过@sendto
//通过这种方式,可以将多个消息一次性发布到通道上
stompClient.send("/ws/v1/sendCrMessage", {}, JSON.stringify(crMessage));
// stompClient.send("/topic/crmessage", {}, JSON.stringify(crMessage));
}
function initButtons() {
$("#btnConnect").on("click", connectWebSocket);
$("#btnSend").on("click", sendCrMessage);
console.log("initButtons...");
}
$( document ).ready(initButtons);
通过聊天室,练习了一种具有crcontact、crmessage、alert三个通道的方法。这个项目目前还很初级,打算后面采用Kafka或后端数据,保存历史聊天记录以便用户可以登录并查看过去的消息。
作者:NP154