聊天室應用開發實踐(二):實現基於 Web 的聊天室

聲網Agora發表於2019-04-10

上一篇內容中,作者 monkeyHi 已經分享了聲網Agora 信令 SDk 的基本使用,可實現的場景,並分析了伺服器端 Demo 的介面原理。這一篇,作者將帶大家進行簡單的實踐,實現一個基於 Web 的簡單的聊天室雛形。

本文首發於RTC 開發者社群可以點選這裡與作者交流。

基於Agora已經發布的 Web 版 Demo,只要簡單配置就已經具備最基本的實時聊天室功能。

獲取web 版 demo

我們基於官方的 Demo 來修改出一個聊天室。下載好 Web Demo 後解壓,目錄如下:

|---Agora_Signaling_Web
    |---libs  // sdk 在這裡
    |---samples // demo在這裡
        |---Agora-Signaling-Tutorial-Web // 聊天室demo
複製程式碼

用Visual Studio Code 開啟 samples 目錄下的 Agora-Signaling-Tutorial-Web

|——Agora-Signaling-Tutorial-Web
    |—src
    |  ├─assets
    |  │  ├─images
    |  │  └─stylesheets
    |  ├─pages
    |  │  ├─index
    |  │  └─meeting
    |  └─utils
    |—static
        |--agora.config.js  // 這裡配置AppId
        |--AgoraSig.js  // 複製它到 assets 目錄
複製程式碼

安裝依賴包和執行demo

先簡單說一下web端的sdk,這個sdk包裝程度很高,甚至不需要開發者懂websockt和webrtc。只要呼叫對應的功能介面即可。

接下來,我們一起跑起demo,這個demo是一個webpack專案,專業的web開發工程師一定不會覺著陌生。

  • 首先,複製 AgoraSig.js到asstes目錄。
  • 其次,npm install
  • 接下來,配置 appid 開啟 src\static\agora.config.js ,修改AGORA_APP_ID 為我們自己的AppId
  • 最後,npm start

這時,瀏覽器應該已經彈出一個頁面(如下圖)。

聊天室應用開發實踐(二):實現基於 Web 的聊天室

隨便輸入一個使用者名稱,我們就可以進入聊天室了,當然大家可以新增自己的使用者鑑權業務。

聊天室應用開發實踐(二):實現基於 Web 的聊天室

我們開啟兩個標籤頁,分別用accontA 和accontB的身份加入同一個p2p頻道,嘗試互發訊息。

筆者發現,我們這個聊天室並不需要另外執行過server端。而前面介紹的server端,從介面上看,其功能更傾向於做系統廣播,系統訊息通知。因此,如果你想實現具備雲訊息備份的功能的聊天軟體,必須要在端上實現儲存和上傳備份。

毫不誇張的說,這個聊天室,只要一個 page 服務就可以跑起來了。

Web 版 Demo程式碼講解

拿到一個web端專案,我們首先要看的就是package.json

我們可以從中看到專案依賴哪些Package以及專案的啟動指令碼。

  "scripts": {
    "test": "jest ./test", // 測試
    "lint": "eslint .", // eslint格式化當前目錄
    "format": "eslint . --fix", // eslint fix當前目錄下的程式碼格式
    "dev": "cross-env NODE_ENV=development webpack-dev-server --open", // 這個會啟動 webpack-dev-server 並用瀏覽器開啟頁面
    "start": "npm run dev", // 功能同上一條
    "build": "cross-env NODE_ENV=production webpack --progress --hide-modules" // 編譯
  },
複製程式碼

配置檔案 static/agora.config.js

只需要在以下兩行程式碼中進行配置。

const AGORA_APP_ID = '6cb4f3s4c67404d4ba0ec0b'  // appid
const AGORA_CERTIFICATE_ID = '' // 如果開啟了token模式,這裡要配置certificate_id
複製程式碼

sdk檔案 static/AgoraSig.js

這裡和lib目錄中的sdk檔案是一樣的,可以用新的sdk來替換。

新下載下來的demo ,還應該複製該檔案到 src/assets目錄下。

utils 目錄

utils目錄中封裝了一些工具類。

signalingClient.js

這個檔案中,主要對信令SDK做了進一步封裝,將一些Action 轉換為promise, 同時用 Event 替代了Callback。

/**
 * Wrapper for Agora Signaling SDK
 * Transfer some action to Promise and use Event instead of Callback
 */
import EventEmitter from 'events';

// 信令客戶端類
export default class SignalingClient {
  constructor(appId, appcertificate) {
    this._appId = appId;
    this._appcert = appcertificate;
    // Init signal using signal sdk
    this.signal = Signal(appId) // eslint-disable-line 
    // init event emitter for channel/session/call
    this.channelEmitter = new EventEmitter();
    this.sessionEmitter = new EventEmitter();
  }

  /**
   * @description login agora signaling server and init 'session'
   * 登入 信令服務,並初始化會話
   * @description use sessionEmitter to resolve session's callback
   * @param {String} account
   * @param {*} token default to be omitted
   * @returns {Promise}
   */
  login(account, token = '_no_need_token') {
    this.account = account;
    return new Promise((resolve, reject) => {
      this.session = this.signal.login(account, token);
      // Proxy callback on session to sessionEmitter
      [
        'onLoginSuccess',
        'onError',
        'onLoginFailed',
        'onLogout',
        'onMessageInstantReceive',
        'onInviteReceived'
      ].map(event => {
        return (this.session[event] = (...args) => {
          this.sessionEmitter.emit(event, ...args);
        });
      });
      // Promise.then
      this.sessionEmitter.once('onLoginSuccess', uid => {
        this._uid = uid;
        resolve(uid);
      });
      // Promise.catch
      this.sessionEmitter.once('onLoginFailed', (...args) => {
        reject(...args);
      });
    });
  }

  /**
   * @description logout agora signaling server
   * 退出信令服務
   * @returns {Promise}
   */
  logout() {
    return new Promise((resolve, reject) => {
      this.session.logout();
      this.sessionEmitter.once('onLogout', (...args) => {
        resolve(...args);
      });
    });
  }

  /**
   * @description join channel
   * 加入某個頻道
   * @description use channelEmitter to resolve channel's callback
   * @param {String} channel
   * @returns {Promise}
   */
  join(channel) {
    this._channel = channel;
    return new Promise((resolve, reject) => {
      if (!this.session) {
        throw {
          Message: '"session" must be initialized before joining channel'
        };
      }
      this.channel = this.session.channelJoin(channel);
      // Proxy callback on channel to channelEmitter
      // 將回撥 都代理到 對應channelEmitter
      [
        'onChannelJoined',
        'onChannelJoinFailed',
        'onChannelLeaved',
        'onChannelUserJoined',
        'onChannelUserLeaved',
        'onChannelUserList',
        'onChannelAttrUpdated',
        'onMessageChannelReceive'
      ].map(event => {
        return (this.channel[event] = (...args) => {
          this.channelEmitter.emit(event, ...args);
        });
      });
      // Promise.then
      this.channelEmitter.once('onChannelJoined', (...args) => {
        resolve(...args);
      });
      // Promise.catch
      this.channelEmitter.once('onChannelJoinFailed', (...args) => {
        this.channelEmitter.removeAllListeners()
        reject(...args);
      });
    });
  }

  /**
   * @description leave channel
   * 離開當前頻道
   * @returns {Promise}
   */
  leave() {
    return new Promise((resolve, reject) => {
      if (this.channel) {
        this.channel.channelLeave();
        this.channelEmitter.once('onChannelLeaved', (...args) => {
          this.channelEmitter.removeAllListeners()
          resolve(...args);
        });
      } else {
        resolve();
      }
    });
  }

  /**
   * @description send p2p message
   * 傳送點對點訊息
   * @description if you want to send an object, use JSON.stringify
   * @param {String} peerAccount
   * @param {String} text
   */
  sendMessage(peerAccount, text) {
    this.session && this.session.messageInstantSend(peerAccount, text);
  }

  /**
   * @description broadcast message in the channel 
   * 傳送頻道訊息
   * @description if you want to send an object, use JSON.stringify
   * 可以通過JSON.Stringify的方式傳送object
   * @param {String} text
   */
  broadcastMessage(text) {
    this.channel && this.channel.messageChannelSend(text);
  }
}

複製程式碼

pages

這裡面分別是兩個頁面的實現,預設的index頁面和聊天頁面。大家可以在對webpack有一定了解的情況下,修改這兩個頁面。

pages/index.js

大家注意這裡, 點選Join-meeting,我們獲取id為account-name的DOM值,然後將account的值放到的url中。

$('#join-meeting').click(function(e) {
  // Join btn clicked
  e.preventDefault();
  var account = $('#account-name').val() || '';
  if (checkAccount(account)) {
    // Account has to be a non empty numeric value
    window.location.href = `meeting.html?account=${account}`;
  } else {
    $('#account-name')
      .removeClass('is-invalid')
      .addClass('is-invalid');
  }
});
複製程式碼

後續聊天頁面的account值通過url中的account引數來傳值。

pages/meeting.js

metting.js中主要定義了client類和聊天相關的類方法。

首先,我們來看看檔案尾部:

// 檢測獲取appid 並檢測是否為空
const appid = AGORA_APP_ID || '',
  appcert = AGORA_CERTIFICATE_ID || '';
if (!appid) {
  alert('App ID missing!');
}
//從url獲取account值 ,Browser 模組事先在util/index.js中定義好的。
let localAccount = Browser.getParameterByName('account');
let signal = new SignalingClient(appid, appcert);
// Let channelName = Math.random() * 10000 + "";
// by default call btn is disabled
// 信令登陸
signal.login(localAccount).then(() => {
  // Once logged in, enable the call btn
  let client = new Client(signal, localAccount);
  $('#localAccount').html(localAccount);
});
複製程式碼

接下來,我們應該關注client類,這裡筆者只節選部分程式碼。

相信很多朋友都發現了,demo中聊天頭像都一樣,傻傻分不清。

其實,生成渲染訊息的方法裡市可以自定義頭像的,預設被寫為固定圖片,大家其實可以根據accont來拼裝頭像連結的,當然你得自己做個使用者頭像介面。

buildMsg(msg, me, ts) {
    let html = '';
    let timeStr = this.compareByLastMoment(ts);
    if (timeStr) {
      html += `<div>${timeStr}</div>`;
    }
    let className = me ? 'message right clearfix' : 'message clearfix';
    html += '<li class="' + className + '">';
    // 注意看這裡 
    html += '<img src="https://user-gold-cdn.xitu.io/2019/4/10/16a066f2aedfd60f?w=128&h=128&f=jpeg&s=5676">';
    html +=
      '<div class="bubble">' +
      Utils.safe_tags_replace(msg) +
      '<div class="corner"></div>';
    html += '<span>' + this.parseTwitterDate(ts) + '</span></div></li>';

    return html;
  }

複製程式碼

這裡大家要注意跨域的問題。需要自己對介面url做代理來解決跨域安全問題。開發狀態下,直接配置devServer的proxy即可。

又有的朋友說啦,我想保留訊息記錄,其實在端上儲存訊息記錄還是比較容易的。

注意看onReceiveMessage()

onReceiveMessage(account, msg, type) {
    let client = this;
    var conversations = this.chats.filter(function(item) {
      return item.account === account;
    });

    if (conversations.length === 0) {
      // No conversation yet, create one
      conversations = [{ id: new Date().getTime(), account: account, type: type }];
      client.chats.splice(0, 0, conversations[0]);
      client.updateLocalStorage();
      client.updateChatList();
    }
    // 可以看到下面對訊息做了簡單處理,然後丟到msgs中
    for (let i = 0; i < conversations.length; i++) {
      let conversation = conversations[i];

      let msgs = this.messages[conversation.id] || [];
      let msg_item = { ts: new Date(), text: msg, account: account };
      msgs.push(msg_item);
      this.updateMessageMap(conversation, msgs);
      let chatMsgContainer = $('.chat-messages');
      if (String(conversation.id) === String(this.current_conversation.id)) {
        this.showMessage(this.current_conversation.id)
        chatMsgContainer.scrollTop(chatMsgContainer[0].scrollHeight);
      }
    }
  }

複製程式碼

我們看一下引用它的位置。

聊天室應用開發實踐(二):實現基於 Web 的聊天室

顯然,無論p2p訊息還是Chanel訊息,都會呼叫這個onReceiveMessage()方法。因此,大家可以通過修改onReceiveMessage實現自己的聊天記錄功能。具體是通過介面儲存到我們自己的伺服器,還是藉助localStorage,都可以比較好的實現web端的聊天記錄功能。

諸如 window.localStorage.setItem('msglog',msgs)

既然可以暫時儲存在localStorage,那麼,想匯出聊天資料為json,csv也不會麻煩到哪裡去。

可能會遇到的問題

  1. npm install 報錯

    解決方法: 更換倉庫地址; 使用yarn install; 使用vpn

  2. 可以傳送訊息,但是收不到訊息

    檢查asset目錄和static目錄,是否存在AgoraSig.js ,如果不存在,從sdk的lib目錄中複製並重新命名為AgoraSig.js

總結

總體來說,基於Agora信令實現聊天室非常簡單,基於demo,自己擴充套件一些使用者管理業務就可以實現。大家可以集中精力優化互動體驗,美化UI,專注於端上業務。但是,如果想要更多的可控權,希望在server端實現聊天記錄之類的功能。基於信令當前版本做這類功能,需要自己來開發。信令的優勢在於,方便實現一些訊息通知的場景。而且對接非常容易,只要簡單封裝即可直接嵌入端上。另外,對於彈幕的實現,大家可以嘗試在端實傳送訊息是同時推送訊息到儲存訊息的介面。

相關文章