基于SpringBoot + WebSocket的在线聊天室

咕咕のdaddy 2022-01-19 18:43:02

设计方案


之前有以聊天室作为例子来学习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时会用到它们。

  •  endpoint : /websocket-cr 连接到Websocket客户端的URL
  •  application destination prefixes: /ws/v1 向websocket客户端发送消息的前置文本
  •  simple broker: /topic  subscript频道的前置文本
@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

下面的代码展示了客户端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
 

...全文
644 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

571

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧