簡版在線聊天Websocket

序言

  • What is Webscoket ?
  • websocket 應用場景
  • 簡版羣聊實現
  • 代碼例子
  • 小結

Webscoket

Websokcet 是一種單個TCP鏈接上進行全雙工通訊的協議,經過HTTP/1.1 協議的101狀態碼進行握手。javascript

http://websocket.org

Websocket 應用場景

Websocket 和 http 協議都是web通信協議,二者有何區別?先說Http,它是一種請求響應協議,這種模型決定了,只能客戶端請求,服務端被動回答。若是咱們有服務端主動推送給客戶端的需求怎麼辦?好比一個股票網站,咱們會選擇主動輪詢,也就是」拉模式「。css

你們能夠思考下主動輪詢帶來的問題是什麼?前端

主動輪詢其實會產生大量無效請求,增長了服務器壓力。vue

由此,websocket 協議的補充,爲咱們帶來了新的解決思路。java

簡版羣聊實現

利用Websocket 實現一個簡陋羣聊功能,加深一下Websocket 理解。nginx

  1. 假設李雷和韓梅梅都登陸在線;
  2. 李雷經過瀏覽器發送消息轉nginx 代理到Ws服務器;
  3. Ws服務器加載全部在線會話廣播消息;
  4. 韓梅梅接受到消息。

0_ws

代碼例子

後端(shop-server)git

引入pom.xml 依賴github

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
  </dependency>

配置類web

package com.onlythinking.shop.websocket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * <p> The describe </p>
 *
 * @author Li Xingping
 */
@Slf4j
@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter endpointExporter() {
        return new ServerEndpointExporter();
    }

}

接受請求端點spring

package com.onlythinking.shop.websocket;

import com.alibaba.fastjson.JSON;
import com.google.common.collect.Maps;
import com.onlythinking.shop.websocket.handler.ChatWsHandler;
import com.onlythinking.shop.websocket.handler.KfWsHandler;
import com.onlythinking.shop.websocket.handler.WsHandler;
import com.onlythinking.shop.websocket.store.WsReqPayLoad;
import com.onlythinking.shop.websocket.store.WsRespPayLoad;
import com.onlythinking.shop.websocket.store.WsStore;
import com.onlythinking.shop.websocket.store.WsUser;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;

/**
 * <p> The describe </p>
 *
 * @author Li Xingping
 */
@Slf4j
@Component
@ServerEndpoint("/ws")
public class WebsocketServerEndpoint {

    private static Map<String, WsHandler> wsHandler = Maps.newConcurrentMap();

    static {
        wsHandler.put("robot", new KfWsHandler());
        wsHandler.put("chat", new ChatWsHandler());
    }

    @OnOpen
    public void onOpen(Session session) {
        log.info("New ws connection {} ", session.getId());
        WsStore.put(session.getId(), WsUser.builder().id(session.getId()).session(session).build());
        respMsg(session, WsRespPayLoad.ok().toJson());
    }

    @OnClose
    public void onClose(Session session, CloseReason closeReason) {
        WsStore.remove(session.getId());
        log.warn("ws closed,reason:{}", closeReason);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("accept client messages: {}" + message);
        WsReqPayLoad payLoad = JSON.parseObject(message, WsReqPayLoad.class);
        if (StringUtils.isBlank(payLoad.getType())) {
            respMsg(session, WsRespPayLoad.ofError("Type is null.").toJson());
            return;
        }
        WsUser wsUser = WsStore.get(session.getId());
        if (null == wsUser || StringUtils.isBlank(wsUser.getUsername())) {
            WsStore.put(session.getId(), WsUser.builder()
              .id(session.getId())
              .username(payLoad.getUsername())
              .avatar(payLoad.getAvatar())
              .session(session)
              .build()
            );
        }
        WsHandler handler = wsHandler.get(payLoad.getType());
        if (null != handler) {
            WsRespPayLoad resp = handler.onMessage(session, payLoad);
            if (null != resp) {
                respMsg(session, resp.toJson());
            }
        } else {
            respMsg(session, WsRespPayLoad.ok().toJson());
        }
    }

    @OnError
    public void onError(Session session, Throwable e) {
        WsStore.remove(session.getId());
        log.error("WS Error: ", e);
    }

    private void respMsg(Session session, String content) {
        try {
            session.getBasicRemote().sendText(content);
        } catch (IOException e) {
            log.error("Ws resp msg error {} {}", content, e);
        }
    }
}

聊天業務處理器

package com.onlythinking.shop.websocket.handler;

import com.onlythinking.shop.websocket.store.*;
import lombok.extern.slf4j.Slf4j;

import javax.websocket.Session;
import java.util.Date;
import java.util.List;

/**
 * <p> The describe </p>
 *
 * @author Li Xingping
 */
@Slf4j
public class ChatWsHandler implements WsHandler {

    @Override
    public WsRespPayLoad onMessage(Session session, WsReqPayLoad payLoad) {
        // 廣播消息
        List<WsUser> allSessions = WsStore.getAll();
        for (WsUser s : allSessions) {
            WsRespPayLoad resp = WsRespPayLoad.builder()
              .data(
                WsChatResp.builder()
                  .username(payLoad.getUsername())
                  .avatar(payLoad.getAvatar())
                  .msg(payLoad.getData())
                  .createdTime(new Date())
                  .self(s.getId().equals(session.getId()))
                  .build()
              )
              .build();
            log.info("Broadcast message {} {} ", s.getId(), s.getUsername());
            s.getSession().getAsyncRemote().sendText(resp.toJson());
        }
        return null;
    }
}

前端(shop-web-mgt)

引入依賴

npm install vue-native-websocket --save

添加Store

import Vue from 'vue'

const ws = {
  state: {
    wsData: {
      hasNewMsg: false,
    },
    socket: {
      isConnected: false,
      message: '',
      reconnectError: false,
    }
  },
  mutations: {
    SET_WSDATA(state, data) {
      state.wsData.hasNewMsg = data.hasNewMsg
    },
    RESET_WSDATA(state, data) {
      state.wsData.hasNewMsg = false
    },
    SOCKET_ONOPEN(state, event) {
      Vue.prototype.$socket = event.currentTarget;
      state.socket.isConnected = true
    },
    SOCKET_ONCLOSE(state, event) {
      state.socket.isConnected = false
    },
    SOCKET_ONERROR(state, event) {
      console.error(state, event)
    },
    // default handler called for all methods
    SOCKET_ONMESSAGE(state, message) {
      state.socket.message = message
    },
    // mutations for reconnect methods
    SOCKET_RECONNECT(state, count) {
      console.info(state, count)
    },
    SOCKET_RECONNECT_ERROR(state) {
      state.socket.reconnectError = true;
    },
  },
  actions: {
    AskRobot({rootGetters}, data) {
      return new Promise((resolve, reject) => {
        console.log('Ask robot msg', data);
        const payLoad = {
          type: 'robot',
          username: rootGetters.loginName,
          data: data
        };
        Vue.prototype.$socket.sendObj(payLoad)
        resolve(1)
      })
    },
    SendChatMsg({rootGetters}, data) {
      return new Promise((resolve, reject) => {
        console.log('Send chat msg', data);
        const payLoad = {
          type: 'chat',
          username: rootGetters.loginName,
          data: data
        };
        Vue.prototype.$socket.sendObj(payLoad)
        resolve(1)
      })
    },
    MessageRead({commit, state}, data) {
      commit('RESET_WSDATA', {})
    },
  }
};

export default ws

編寫組件

<template>
  <div>
    <ot-drawer
      title="聊天"
      :visible.sync="chatVisible"
      direction="rtl"
      :before-close="handleClose">
      <div class="chat-body">
        <div id="msgList" style="margin-bottom: 200px" class="chat-msg">
          <div class="chat-msg-item" v-for="item in msgList">
            <div v-if="!item.self">
              <div class="msg-header">
                <img
                  :src="baseUrl+'/api/insecure/avatar?code='+item.avatar+'&size=64'"
                  class="user-avatar"
                >
                <span class="avatar-name">{{item.username}}</span>&nbsp;&nbsp;
                <div style="display: inline-block; float: right">
                  {{item.createdTime | parseTime('{h}:{i}')}}
                </div>
              </div>
              <div class="msg-body" style="float: left;">
                {{item.msg}}
              </div>
            </div>
            <div v-else>
              <div class="msg-header clearfix">
                <img
                  :src="baseUrl+'/api/insecure/avatar?code='+item.avatar+'&size=64'"
                  class="user-avatar"
                  style="float: right"
                >
              </div>
              <div class="msg-body" style="float: right;background-color: #67C23A">
                {{item.msg}}
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="chat-send">
        <el-input
          v-model="text"
          autocomplete="off"
          placeholder="請輸入你想說的內容..."
          @keyup.enter.native="handleSendMsg"
        ></el-input>
        <div class="chat-btns">

          <el-button
            class="action-item"
            @click="handleClearMsg"
          >清空
          </el-button>
          <el-button
            type="success"
            class="action-item"
            @click="handleSendMsg"
            v-scroll-to="{ el: '#msgList', offset: 140 }"
          >發送
          </el-button>
        </div>
      </div>
    </ot-drawer>
  </div>
</template>

<script>

  import {mapGetters} from 'vuex'
  import store from '@/store'
  import {config} from '@/utils/config'
  import OtDrawer from '@/components/OtDrawer'
  import Cookies from 'js-cookie'

  export default {
    name: 'UserChat',
    components: {OtDrawer},
    props: {
      visible: {
        type: Boolean,
        default: false
      }
    },
    data() {
      return {
        baseUrl: config.baseUrl,
        text: '',
        msgList: [],
      }
    },
    computed: {
      ...mapGetters([
        'roles', 'isConnected', 'message', 'reconnectError'
      ]),
      chatVisible: {
        get() {
          return this.visible
        },
        set(val) {
          this.$emit('update:visible', val)
        }
      }
    },
    beforeDestroy() {
      if (this.isConnected) {
        this.$disconnect()
      }
    },
    mounted() {
      console.log('Chat mounted.')
      if (!this.isConnected) {
        this.$connect(config.wsUrl, {
          format: 'json',
          store: store
        })
      }
      // 監聽消息接收
      this.$options.sockets.onmessage = (res) => {
        const data = JSON.parse(res.data);
        console.log('收到消息', data);
        if (data.code === 0) {
          // 鏈接創建成功
          if (!data.data.msg) {
            return;
          }
          this.msgList.push(data.data)
        } else if (data.code === 400) {
          this.$message({
            type: 'warning',
            message: data.data
          })
        }
      };
    },
    methods: {
      handleSendMsg() {
        if (!this.text) {
          this.$message({
            type: 'warning',
            message: '請輸入內容'
          });
          return;
        }
        this.$store.dispatch('SendChatMsg', this.text).then(data => {
          this.text = ''
        })
      },
      handleClearMsg() {
        this.msgList = [];
        Cookies.remove('chatMsg');
        // 刪除
      },
      // 聊天關閉前
      handleClose() {
        // 緩存消息到本地
        Cookies.set('chatMsg', JSON.stringify(this.msgList));
        this.$emit('update:visible', false)
      }
    },
    created() {
      // 加載緩存數據
      const chatMsg = Cookies.get('chatMsg');
      if (chatMsg) {
        this.msgList = JSON.parse(chatMsg);
      }
    }
  }
</script>

<style>
  .el-drawer__body {
    height: 100%;
    box-sizing: border-box;
    overflow-y: auto;
    background-color: rgba(244, 244, 244, 1);
    scroll-snap-type: y proximity;
  }
</style>

<style rel="stylesheet/scss" lang="scss" scoped>

  .user-avatar {
    width: 20px;
    height: 20px;
    border-radius: 4px;
    vertical-align: middle;
  }

  .msg-header {
    font-size: 12px;
    color: rgba(109, 114, 120, 1);
  }

  .avatar-name {
    vertical-align: middle;
  }

  .msg-body {
    text-align: center;
    max-width: 300px;
    min-width: 100px;
    word-wrap: break-word;

    margin: 4px 0;
    padding: 4px;
    line-height: 24px;
    border-radius: 4px;
    background-color: rgba(255, 255, 255, 1);
  }

  .chat-body {
    height: 100%;
    position: relative;
  }

  .chat-msg {
    padding: 10px;

    .chat-msg-item {
      margin-top: 10px;
      height: 65px;
    }
  }

  .chat-send {
    padding: 20px;
    background-color: rgba(255, 255, 255, 1);
    position: absolute;
    left: 50%;
    width: 100%;
    transform: translateX(-50%);
    bottom: 0px;
  }

  .chat-btns {
    text-align: center;
  }

  .action-item {
    margin-top: 10px;
  }
</style>

Nginx 代理配置 nginx.conf (若有須要可添加)

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

upstream websocket {
    server 127.0.0.1:8300;
}

server {
     server_name shop-web-mgt.onlythinking.com;
     listen 443 ssl;
     location / {
         proxy_pass http://websocket;
         proxy_read_timeout 300s;
         proxy_send_timeout 300s;
         
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection $connection_upgrade;
     }
    ssl_certificate /etc/data/shop-web-mgt.onlythinking.com/full.pem;
    ssl_certificate_key /etc/data/shop-web-mgt.onlythinking.com/privkey.pem;
}

實現效果圖

界面比較醜,由於不太擅長,請你們別見笑!!

聊天界面

聊天界面

聊天界面

項目地址

https://github.com/cuteJ/shop... (後端)

https://github.com/cuteJ/shop... (前端)

項目演示地址

http://shop-web-mgt.onlythink...

小結

該篇學習Websocket,寫此Demo加深印象!

相關文章
相關標籤/搜索