使用SpringBoot + WebSocket实现单人聊天

前言

最近在做一个聊天功能,具体需求:类似微信,在一个好友列表中,点击某个好友就可以建立与该好友的聊天连接,向该好友发送消息,对方能够实时显示出来,进行真正意义上的聊天。
在做之前,不管在界面布局,还是功能实现方面都下了一点功夫,最终还是一点点实现了,现在就记录一下。

在编码之前得先了解一下WebSocket

  1. 什么是WebSocket?
  • WebSocket,即Web浏览器与Web服务器之间全双工通信标准;是HTML5中的协议,支持持久连续,http协议不支持持久性连接。Http1.0和HTTP1.1都不支持持久性的链接,HTTP1.1中的keep-alive,将多个http请求合并为1个
  • 一旦确立WebSocket通信连接,不论服务器还是客户端,任意一方都可直接向对方发送报文
  1. WebSocket特点?
  • 推送功能:支持由服务器向客户端推送数据的推送功能,这样,服务器可直接发送数据,而不必等待客户端的请求
  • 减少通信量:只要建立起WebSocket,就可以一直保持连接状态

头部字段多了下面2个属性:

Upgrade:webSocket
Connection:Upgrade

1、实现效果

点击左侧好友列表时,会建立websocket连接,把当前发消息的用户发送给websocket服务器

输入消息

2、前端实现

<!-- Chat.vue页面 -->
<template>
  <div id="chat">
    <!-- 聊天消息管理 -->
    <el-container style="height: 620px; border: 1px solid #eee">
      <el-aside width="250px">
        <user-list
          :friendList="this.friendList"
          @set-contact="getcontact"
          ref="friends"
          :activeIndex="activeIndex"
        ></user-list>
      </el-aside>
      <el-container>
        <el-header style="text-align: right; font-size: 12px">
          <span>
            <h2>{{this.username}}</h2>
          </span>
        </el-header>
        <el-main style="height:400px;" class="msg-main">
          <chat-msg ref="msg" :user="this.username" id="msg-box"></chat-msg>
        </el-main>
        <div class="m-text">
          <textarea
            placeholder="按 Ctrl + Enter 发送"
            ref="sendMsg"
            v-model="contentText"
            @keyup.enter="sendText()"
          ></textarea>
          <div class="btn" :class="{['btn-active']:contentText}" @click="sendText()">发送</div>
        </div>
      </el-container>
    </el-container>
  </div>
</template>

<script>
import UserList from "../../components/chat/friendList";
import ChatMsg from "../../components/chat/message";
import InputText from "../../components/chat/text";
export default {
  data() {
    return {
      //好友列表
      friendList: [],
      activeIndex: null,
      //当前聊天好友
      activeFriend: [],
      friends: "",
      ws: null,
      count: 0,
      userId: this.$store.state.userInfo.uid, // 当前用户ID
      username: this.$store.state.userInfo.username, // 当前用户昵称
      avatar: this.$store.state.userInfo.uavatar, // 当前用户头像
      msgList: [], // 聊天记录的数组
      contentText: "" // input输入的值
    };
  },
  components: {
    UserList,
    ChatMsg,
    InputText
  },
  mounted() {
    this.getFridends(this.userId);
  },
  destroyed() {
    // 离开页面时关闭websocket连接
    this.ws.onclose(undefined);
  },
  methods: {
    select(value) {
      this.activeIndex = this.friendList.indexOf(value);
    },
    getcontact(list) {
      console.log(this.$store.state.userInfo);
      this.activeFriend = list;
      //保存当前聊天好友
      this.friends = list.ffriendName;
      this.activeIndex = this.friendList.indexOf(this.friends);
      this.getFridendInfo(this.friends);
      this.initWebSocket(this.username);
      this.getFridendMsg(this.friends);
    },
    // 发送聊天信息
    sendText() {
      let _this = this;
      _this.$refs["sendMsg"].focus();
      if (!_this.contentText) {
        return;
      }
      let params = {
        mfromuser: _this.username,
        mtouser: _this.activeFriend[0].uusername,
        mfromavatar: _this.activeFriend[0].uavatar,
        mtoavatar: _this.avatar,
        mmsg: _this.contentText,
        mtype: 1
      };
      _this.ws.send(JSON.stringify(params)); //调用WebSocket send()发送信息的方法
      _this.contentText = "";
      setTimeout(() => {
        _this.scrollToBottom();
      }, 200);
    },
    // 进入页面创建websocket连接
    initWebSocket(id) {
      let _this = this;
      // 判断页面有没有存在websocket连接
      if (window.WebSocket) {
        var serverHot = window.location.hostname;
        // 填写本地IP地址,此处的 :8021端口号要与后端配置的一致!
        var url = "ws://" + serverHot + ":8021" + "/websocket/" + id; 
        // `ws://127.0.0.1/9101/websocket/10086/老王`
        let ws = new WebSocket(url);
        _this.ws = ws;
        ws.onopen = function(e) {
          console.log("服务器连接成功: " + url);
        };
        ws.onclose = function(e) {
          console.log("服务器连接关闭: " + url);
        };
        ws.onerror = function() {
          console.log("服务器连接出错: " + url);
        };
        ws.onmessage = function(e) {
          //接收服务器返回的数据
          console.log(e);
          let resData = e.data.split("|");
          console.log(resData);
          //更新消息
          if (resData[0] == _this.username) {
            _this.getFridendMsg(_this.friends);
          } else {
            _this.$message.error("发送消息失败,当前用户不存在");
          }
        };
      }
    },
    //获取好友列表
    async getFridends(id) {
      console.log(id);
      const { data: res } = await this.$http.get(
        "api/business/chat/getfriends/" + id
      );
      if (res.success) {
        // console.log(res);
        this.friendList = res.data;
      } else {
        this.$message.error("获取好友列表失败:" + res.data.errorMsg);
      }
    },
    //获取好友消息列表
    async getFridendMsg(name) {
      const { data: res } = await this.$http.get(
        "api/business/chat/getFriendMsg/" + name
      );
      if (res.success) {
        // console.log(res);
        this.msgList = res.data;
        //把好友聊天记录传给消息组件
        this.$refs.msg.getMsgList(this.msgList);
      } else {
        this.$message.error("获取好友消息失败:" + res.data.errorMsg);
      }
    },
    //获取好友信息
    async getFridendInfo(name) {
      const { data: res } = await this.$http.get(
        "api//system/user/getFriendInfo/" + name
      );
      if (res.success) {
        console.log(res);
        //好友信息
        this.activeFriend = res.data;
      } else {
        this.$message.error("获取好友信息失败:" + res.data.errorMsg);
      }
    },
    // 滚动条到底部
    scrollToBottom() {
      this.$nextTick(() => {
        var container = this.$el.querySelector(".msg-main");
        // console.log(container);
        // console.log(container.scrollTop);
        container.scrollTop = container.scrollHeight;
      });
    }
  }
};
</script>
<style scoped lang="less">
#chat {
  overflow: hidden;
}
.el-header {
  background-color: #b3c0d1;
  color: #333;
  line-height: 60px;
}

.el-aside {
  color: #fff;
  background-color: #2e3238;
}
.m-list {
  li {
    padding: 12px 15px;
    border-bottom: 1px solid #292c33;
    cursor: pointer;
    transition: background-color 0.1s;

    &:hover {
      background-color: rgba(255, 255, 255, 0.03);
    }
    &.active {
      background-color: rgba(255, 255, 255, 0.1);
    }
  }
  .avatar,
  .name {
    vertical-align: middle;
  }
  .avatar {
    border-radius: 2px;
  }
  .name {
    display: inline-block;
    margin: 0 0 0 15px;
  }
}
.m-message {
  padding: 10px 15px;
  li {
    margin-bottom: 15px;
  }
  .time {
    margin: 7px 0;
    text-align: center;

    > span {
      display: inline-block;
      padding: 3px 20px;
      font-size: 12px;
      color: #fff;
      border-radius: 2px;
      background-color: #dcdcdc;
    }
  }
  .avatar {
    float: left;
    margin: 0 15px 0 0;
    border-radius: 3px;
  }
  .text {
    display: inline-block;
    position: relative;
    padding: 3px 17px;
    max-width: ~"calc(100% - 40px)";
    min-height: 30px;
    line-height: 2.5;
    font-size: 12px;
    text-align: left;
    word-break: break-all;
    background-color: #fafafa;
    border-radius: 4px;

    &:before {
      content: " ";
      position: absolute;
      top: 9px;
      right: 100%;
      border: 6px solid transparent;
      border-right-color: #fafafa;
    }
  }

  .self {
    text-align: right;

    .avatar {
      float: right;
      margin: 0 0 0 10px;
    }
    .text {
      background-color: #b2e281;

      &:before {
        right: inherit;
        left: 100%;
        border-right-color: transparent;
        border-left-color: #b2e281;
      }
    }
  }
}
.m-text {
  display: flex;
  height: 90px;
  border-top: solid 1px #ddd;
  textarea {
    padding-left: 10px;
    height: 100%;
    width: 100%;
    border: none;
    outline: none;
    font-family: "Micrsofot Yahei";
    resize: none;
  }
  .btn {
    height: 2.3rem;
    min-width: 4rem;
    background: #e0e0e0;
    padding: 0.5rem;
    font-size: 0.88rem;
    color: white;
    text-align: center;
    border-radius: 0.2rem;
    margin-left: 0.5rem;
    transition: 0.5s;
  }
  .btn-active {
    background: #409eff;
  }
}
</style>
<!-- friendList.vue -->
<template>
  <div class="m-list">
    <ul v-for="(friend, i) in friendList">
      <li @click="setContact(i)" :class="{'active':currentIndex===i}">
        <img class="avatar" width="50" height="50" />
        <p class="name">{{friend.ffriendName}}</p>
      </li>
    </ul>
  </div>
</template>
<script>
export default {
  data() {
    return {
      currentIndex: this.activeIndex
    };
  },
  props: ["friendList", "activeIndex"],
  methods: {
    setContact(index) {
      this.currentIndex = index;
      console.log(this.currentIndex);
      //与父组件进行通信。把当前点击好友的用户信息传给父组件
      this.$emit("set-contact", this.friendList[index]);
    }
  }
};
</script>
<style lang="less" scoped>
.m-list {
  li {
    padding: 12px 15px;
    border-bottom: 1px solid #292c33;
    cursor: pointer;
    transition: background-color 0.1s;

    &:hover {
      background-color: rgba(255, 255, 255, 0.03);
    }
    &.active {
      background-color: rgba(255, 255, 255, 0.1);
    }
  }
  .avatar,
  .name {
    vertical-align: middle;
  }
  .avatar {
    border-radius: 2px;
  }
  .name {
    display: inline-block;
    margin: 0 0 0 15px;
  }
}
</style>
<!-- message.vue -->
<template>
  <div class="m-message">
    <ul v-for="item in msgList">
      <li>
        <p class="time">
          <span>{{item.mcreateTime | time}}</span>
        </p>
        <div class="main" :class="{self:item.mfromUser===username}">
          <div class="title">{{item.mfromUser}}</div>
          <img class="avatar" width="40" height="40" />
          <div class="text">{{item.mmsg}}</div>
        </div>
      </li>
    </ul>
  </div>
</template>
<script>
export default {
  data() {
    return {
      msgList: this.userList,
      username: this.user
    };
  },
  props: ["user", "userList"],
  methods: {
    getMsgList(list) {
      console.log(list);
      this.msgList = list;
    },
  },
  filters: {
    // 将日期过滤为 yy-mm-dd hh:mm:ss
    time(date) {
      if (typeof date === "string") {
        date = new Date(date); //把定义的时间赋值进来进行下面的转换
        let year = date.getFullYear();
        let month = date.getMonth() + 1;
        let day = date.getDate();
        let hour = date.getHours();
        let minute = date.getMinutes();
        let second = date.getSeconds();
        return (
          year +
          "-" +
          month +
          "-" +
          day +
          " " +
          hour +
          ":" +
          minute +
          ":" +
          second
        );
      }
    }
  }
};
</script>
<style lang="less" scoped>
.m-message {
  padding: 10px 15px;
  // overflow-y: scroll;
  li {
    margin-bottom: 15px;
  }
  .time {
    margin: 7px 0;
    text-align: center;

    > span {
      display: inline-block;
      padding: 3px 20px;
      font-size: 12px;
      color: #fff;
      border-radius: 2px;
      background-color: #dcdcdc;
    }
  }
  .avatar {
    float: left;
    margin: 0 15px 0 0;
    border-radius: 3px;
  }
  .text {
    display: inline-block;
    position: relative;
    padding: 3px 17px;
    max-width: ~"calc(100% - 40px)";
    min-height: 30px;
    line-height: 2.5;
    font-size: 12px;
    text-align: left;
    word-break: break-all;
    background-color: #fafafa;
    border-radius: 4px;

    &:before {
      content: " ";
      position: absolute;
      top: 9px;
      right: 100%;
      border: 6px solid transparent;
      border-right-color: #fafafa;
    }
  }

  .self {
    text-align: right;

    .avatar {
      float: right;
      margin: 0 0 0 10px;
    }
    .text {
      background-color: #b2e281;

      &:before {
        right: inherit;
        left: 100%;
        border-right-color: transparent;
        border-left-color: #b2e281;
      }
    }
  }
}
</style>

3、后端实现

添加依赖

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
// WebSocketController.java
@Controller
@ServerEndpoint(value = "/websocket/{user}")
@Api(tags = "业务模块-websocket连接接口")
public class WebSocketController {

    // 这里使用静态,让 service 属于类
    private static ChatMsgService chatMsgService;
    // 注入的时候,给类的 service 注入
    @Resource
    public void setChatService(ChatMsgService chatService) {
        WebSocketController.chatMsgService = chatService;
    }

    // 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
    private static int onlineCount = 0;
    // concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。若要实现服务端与单一客户端通信的话,可以使用Map来存放,其中Key可以为用户标识
    private static ConcurrentHashMap<String, WebSocketController> webSocketSet = new ConcurrentHashMap<String, WebSocketController>();
    // 与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session WebSocketSession;
    // 记录当前发消息的用户
    private String user = "";


    /**
     * 连接建立成功调用的方法
     *
     * session 可选的参数。session为与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    @OnOpen
    public void onOpen(@PathParam(value = "user") String param, Session WebSocketsession) {
        user = param;
        // System.out.println(user);
        this.WebSocketSession = WebSocketsession;
        webSocketSet.put(param, this);// 加入map中
        addOnlineCount();     // 在线数加1
        // System.out.println("有新连接加入!当前在线人数为" + getOnlineCount());
    }


    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        if (!user.equals("")) {
            webSocketSet.remove(user); // 从set中删除
            subOnlineCount();     // 在线数减1
            // System.out.println("有一连接关闭!当前在线人数为" + getOnlineCount());
        }
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param chatmsg 客户端发送过来的消息
     * @param session 可选的参数
     */
    @OnMessage
    public void onMessage(String chatmsg, Session session)  throws SystemException{
        JSONObject jsonObject = JSONObject.parseObject(chatmsg);
        //给指定的人发消息
        sendToUser(jsonObject.toJavaObject(ChatMsgVO.class));
        //sendAll(message);
    }

    /**
     * 给指定的人发送消息
     *
     * @param chatMsg 消息对象
     */
    public void sendToUser(ChatMsgVO chatMsg)  throws SystemException{
        String fromUser = chatMsg.getMFromUser();
        String mMsg = chatMsg.getMMsg();
        System.out.println(fromUser);
        mMsg= EmojiFilter.filterEmoji(mMsg);//过滤输入法输入的表情
        chatMsgService.InsertChatMsg(chatMsg);
        try {
            if (webSocketSet.get(fromUser) != null) {
                webSocketSet.get(fromUser).sendMessage(chatMsg.getMFromUser()+"|"+mMsg);
            }else{
                webSocketSet.get(chatMsg.getMFromUser()).sendMessage("0"+"|"+"当前用户不在线");
            }
        } catch (IOException e) {
            throw new SystemException(SystemCodeEnum.PARAMETER_ERROR,e.getMessage());
        }
    }

    /**
     * 给所有人发消息
     *
     * @param message
     */
    private void sendAll(String message) {
        String sendMessage = message.split("[|]")[1];
        //遍历HashMap
        for (String key : webSocketSet.keySet()) {
            try {
                //判断接收用户是否是当前发消息的用户
                if (!user.equals(key)) {
                    webSocketSet.get(key).sendMessage(sendMessage);
                    System.out.println("key = " + key);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 发生错误时调用
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    /**
     * 这个方法与上面几个方法不一样。没有用注解,是根据自己需要添加的方法。
     *
     * @param message
     * @throws IOException
     */
    public void sendMessage(String message) throws IOException {
        this.WebSocketSession.getBasicRemote().sendText(message);
        //this.session.getAsyncRemote().sendText(message);
    }
    public static synchronized int getOnlineCount() {
        return onlineCount;
    }
    public static synchronized void addOnlineCount() {
        WebSocketController.onlineCount++;
    }
    public static synchronized void subOnlineCount() {
        WebSocketController.onlineCount--;
    }
}

//ChatController.java
@RestController
@RequestMapping("business/chat")
public class ChatMsgController {
    @Resource
    private ApiUserService apiUserService;

    @Resource
    private ChatMsgService chatMsgService;
    
    @Resource
    private ChatFriendsService chatFriendsService;

    @ApiOperation(value = "获取好友聊天记录", notes = "根据当前用户查询好友聊天记录")
    @GetMapping("/getFriendMsg/{username}")
    public ResponseBean<List<ChatMsg>> getFriendMsg(@PathVariable String username) throws SystemException {
        UserInfoVO userInfoVO = apiUserService.info();
        List<ChatMsg> chatMsgs = chatMsgService.getFriendMsg(userInfoVO.getUsername(), username);
        return ResponseBean.success(chatMsgs);
    }

    @ApiOperation(value = "获取用户好友", notes = "根据用户id查询用户好友")
    @GetMapping("/getfriends/{id}")
    public ResponseBean<List<ChatFriends>> getFriends(@PathVariable Long id) throws SystemException {
        List<ChatFriends> chatFriendsList = chatFriendsService.getFriends(id);
        return ResponseBean.success(chatFriendsList);
    } 

}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345