本項目服務端基於node.js技術,使用了koa框架,全部數據存儲在mongodb中。客戶端使用react框架,使用redux和immutable.js管理狀態,APP端基於react-native和expo開發。本文須要對JavaScript較爲熟悉,講解核心功能點的設計思路。html
服務端架構前端
服務端負責兩件事:node
1.提供基於WebSocket的接口
2.提供index.html響應react
服務端使用了koa-socket這個包,它集成了socket.io並實現了socket中間件機制,服務端基於該中間件機制,本身實現了一套接口路由ios
每一個接口都是一個async函數,函數名即接口名,同時也是socket事件名web
async login(ctx) { return 'login success' }
而後寫了個route
中間件,用來完成路由匹配,當判斷路由匹配時,以ctx
對象做爲參數執行路由方法,並將方法返回值做爲接口返回值mongodb
function noop() {} /** * 路由處理 * @param {IO} io koa socket io實例 * @param {Object} routes 路由 */ module.exports = function (io, _io, routes) { Object.keys(routes).forEach((route) => { io.on(route, noop); // 註冊事件 }); return async (ctx) => { // 判斷路由是否存在 if (routes[ctx.event]) { const { event, data, socket } = ctx; // 執行路由並獲取返回數據 ctx.res = await routes[ctx.event]({ event, // 事件名 data, // 請求數據 socket, // 用戶socket實例 io, // koa-socket實例 _io, // socket.io實例 }); } }; };
還有一個重要catchError
中間件是,它負責捕獲全局異常,業務流程中大量使用assert
判斷業務邏輯,不知足條件時會中斷流程並返回錯誤消息,catchError將捕獲業務邏輯異常,並取出錯誤消息返回給客戶端數據庫
const assert = require('assert'); /** * 全局異常捕獲 */ module.exports = function () { return async (ctx, next) => { try { await next(); } catch (err) { if (err instanceof assert.AssertionError) { ctx.res = err.message; return; } ctx.res = `Server Error: ${err.message}`; console.error('Unhandled Error\n', err); } }; };
這些就是服務端的核心邏輯,基於該架構下定義接口組成業務邏輯express
另外,服務端還負責提供index.html響應,即客戶端首頁。客戶端的其餘資源是放在CDN上的,這樣能夠緩解服務端帶寬壓力,可是index.html不能使用強緩存,由於會使得客戶端更新不可控,所以index.html放在服務端redux
客戶端架構
客戶端使用socket.io-client鏈接服務端,鏈接成功後請求接口嘗試登陸,若是localStorage沒有令牌或者接口返回令牌過時,將會以遊客身份登陸,登陸成功會返回用戶信息以及羣組,好友列表,接着去請求各羣組,好友的歷史消息
客戶端須要監聽connect / disconnect / message三個消息
1.connect:socket鏈接成功
2.disconnect socket鏈接斷開
3.message 接收到新消息
客戶端使用redux管理數據,須要被組件共享的數據放在redux中,只有自身使用的數據仍是放在組件的state中,客戶端存儲的redux數據結構以下:
用戶用戶信息
_id用戶id
用戶名用戶名
linkmans聯繫人列表,包括羣組,好友以及臨時會話
isAdmin是不是管理員
焦點當前聚焦的聯繫人id,既對話中的目標
鏈接鏈接狀態
ui客戶端UI相關和功能開關
客戶端的數據流,主要有兩條線路
1.用戶操做=>請求接口=>返回數據=>更新redux =>視圖從新渲染
2.監聽新消息=>處理數據=>更新redux =>視圖從新渲染
用戶系統
用戶架構定義:
const UserSchema = new Schema({ createTime: { type: Date, default: Date.now }, lastLoginTime: { type: Date, default: Date.now }, username: { type: String, trim: true, unique: true, match: /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]){1,8}$/, index: true, }, salt: String, password: String, avatar: { type: String, }, });
createTime
:建立時間lastLoginTime
:最後一次登陸時間,用來清理殭屍號用username
:用戶暱稱,同時也是帳號salt
:加密鹽password
:用戶密碼avatar
:用戶頭像URL地址
用戶註冊
註冊接口須要username
/ password
兩個參數,首先作判空處理
const { username, password } = ctx.data; assert(username, '用戶名不能爲空'); assert(password, '密碼不能爲空');
而後判斷用戶名是否已存在,同時獲取默認羣組,新註冊用戶要加入到默認羣組
const user = await User.findOne({ username }); assert(!user, '該用戶名已存在'); const defaultGroup = await Group.findOne({ isDefault: true }); assert(defaultGroup, '默認羣組不存在');
存密碼明文確定是不行的,生成隨機鹽,並使用鹽加密密碼
const salt = await bcrypt.genSalt$(saltRounds); const hash = await bcrypt.hash$(password, salt);
給用戶一個隨機默認頭像,保存用戶信息到數據庫
let newUser = null; try { newUser = await User.create({ username, salt, password: hash, avatar: getRandomAvatar(), }); } catch (err) { if (err.name === 'ValidationError') { return '用戶名包含不支持的字符或者長度超過限制'; } throw err; }
將用戶添加到默認羣組,而後生成用戶令牌
令是用來免費碼登陸的憑證,存儲在客戶端localStorage,令牌裏帶帶用戶id,過時時間,客戶端信息三個數據,用戶id和過時時間容易理解,客戶端信息是爲了防止令牌盜用,以前也試過驗證客戶端ip一致性,可是ip可能會有常常改變的狀況,搞得用戶每次自動登陸都被斷定爲盜用了......
defaultGroup.members.push(newUser); await defaultGroup.save(); const token = generateToken(newUser._id, environment);
將用戶id與當前 socket 鏈接關聯, 服務端是以 ctx.socket.user 是否爲 undefined 來判斷登陸態的 更新 Socket 表中當前 socket 鏈接信息, 後面獲取在線用戶會取 Socket 表數據
ctx.socket.user = newUser._id; await Socket.update({ id: ctx.socket.id }, { user: newUser._id, os, // 客戶端系統 browser, // 客戶端瀏覽器 environment, // 客戶端環境信息 });
最後將數據返回客戶端
return { _id: newUser._id, avatar: newUser.avatar, username: newUser.username, groups: [{ _id: defaultGroup._id, name: defaultGroup.name, avatar: defaultGroup.avatar, creator: defaultGroup.creator, createTime: defaultGroup.createTime, messages: [], }], friends: [], token, }
用戶登陸
fiora是不限制多登錄的,每一個用戶均可以在無限個終端登陸
登陸有三種狀況:
遊客登陸
令牌登陸
用戶名/密碼登陸
遊客登陸僅能查看默認羣組消息,而且不能發消息,主要是爲了下降第一次來的用戶的體驗成本
令牌登陸是最經常使用的,客戶端首先從localStorage取令牌,令牌存在就會使用令牌登陸
首先對令牌解碼取出負載數據,判斷令牌是否過時以及客戶端信息是否匹配
let payload = null; try { payload = jwt.decode(token, config.jwtSecret); } catch (err) { return '非法token'; } assert(Date.now() < payload.expires, 'token已過時'); assert.equal(environment, payload.environment, '非法登陸');
從數據庫查找用戶信息,更新最後登陸時間,查找用戶所在的羣組,並將socket添加到該羣組,而後查找用戶的好友
const user = await User.findOne({ _id: payload.user }, { _id: 1, avatar: 1, username: 1 }); assert(user, '用戶不存在'); user.lastLoginTime = Date.now(); await user.save(); const groups = await Group.find({ members: user }, { _id: 1, name: 1, avatar: 1, creator: 1, createTime: 1 }); groups.forEach((group) => { ctx.socket.socket.join(group._id); return group; }); const friends = await Friend .find({ from: user._id }) .populate('to', { avatar: 1, username: 1 });
更新socket信息,與註冊相同
ctx.socket.user = user._id; await Socket.update({ id: ctx.socket.id }, { user: user._id, os, browser, environment, });
最後返回數據
用戶名/密碼與令牌登陸僅一開始的邏輯不一樣,沒有解碼令牌驗證數據這步
先驗證用戶名是否存在,而後驗證密碼是否匹配
const user = await User.findOne({ username }); assert(user, '該用戶不存在'); const isPasswordCorrect = bcrypt.compareSync(password, user.password); assert(isPasswordCorrect, '密碼錯誤');
接下來邏輯就與令牌登陸一致了
消息系統
發送消息
sendMessage接口有三個參數:
to:發送的對象,羣組或者用戶
type:消息類型
content:消息內容
由於羣聊和私聊共用這一個接口,因此首先須要判斷是羣聊仍是私聊,獲取羣組id或者用戶戶ID,羣聊/私聊經過參數
區分羣聊時到是相應的羣組id,而後獲取羣組信息
私聊時到是發送者和接收者二人id拼接的結果,去掉髮送者id就獲得了接收者id,而後獲取接收者信息
let groupId = ''; let userId = ''; if (isValid(to)) { const group = await Group.findOne({ _id: to }); assert(group, '羣組不存在'); } else { userId = to.replace(ctx.socket.user, ''); assert(isValid(userId), '無效的用戶ID'); const user = await User.findOne({ _id: userId }); assert(user, '用戶不存在'); }
部分消息類型須要作些處理,文本消息判斷長度並作xss處理,邀請消息判斷邀請的羣組是否存在,而後將邀請人,羣組id,羣組名等信息存儲到消息體中
let messageContent = content; if (type === 'text') { assert(messageContent.length <= 2048, '消息長度過長'); messageContent = xss(content); } else if (type === 'invite') { const group = await Group.findOne({ name: content }); assert(group, '目標羣組不存在'); const user = await User.findOne({ _id: ctx.socket.user }); messageContent = JSON.stringify({ inviter: user.username, groupId: group._id, groupName: group.name, }); }
將新消息存入數據庫
let message; try { message = await Message.create({ from: ctx.socket.user, to, type, content: messageContent, }); } catch (err) { throw err; }
接下來構造一個不包含敏感信息的消息數據, 數據中包含發送者的id、用戶名、頭像, 其中用戶名和頭像是比較冗餘的數據, 之後考慮會優化成只傳一個id, 客戶端維護用戶信息, 經過id匹配出用戶名和頭像, 能節約不少流量 若是是羣聊消息, 直接把消息推送到對應羣組便可 私聊消息更復雜一些, 由於 fiora 是容許多登陸的, 首先須要推送給接收者的全部在線 socket, 而後還要推送給自身的其他在線 socket
const user = await User.findOne({ _id: ctx.socket.user }, { username: 1, avatar: 1 }); const messageData = { _id: message._id, createTime: message.createTime, from: user.toObject(), to, type, content: messageContent, }; if (groupId) { ctx.socket.socket.to(groupId).emit('message', messageData); } else { const sockets = await Socket.find({ user: userId }); sockets.forEach((socket) => { ctx._io.to(socket.id).emit('message', messageData); }); const selfSockets = await Socket.find({ user: ctx.socket.user }); selfSockets.forEach((socket) => { if (socket.id !== ctx.socket.id) { ctx._io.to(socket.id).emit('message', messageData); } }); }
最後把消息數據返回給客戶端,表示消息發送成功。客戶端爲了優化用戶體驗,發送消息時會當即在頁面上顯示新信息,同時請求接口發送消息。若是消息發送失敗,就刪掉該條消息
獲取歷史消息
getLinkmanHistoryMessages接口有兩個參數:
linkmanId
:聯繫人id,羣組或者倆用戶id拼接existCount
:已有的消息個數
詳細邏輯比較簡單,按建立時間倒序查找已有個數+每次獲取個數數量的消息,而後去掉已有個數的消息再反轉一下,就是按時間排序的新消息
const messages = await Message .find( { to: linkmanId }, { type: 1, content: 1, from: 1, createTime: 1 }, { sort: { createTime: -1 }, limit: EachFetchMessagesCount + existCount }, ) .populate('from', { username: 1, avatar: 1 }); const result = messages.slice(existCount).reverse();
返回給客戶端
接收推送消息
客戶端訂閱消息事件接收新消息 socket.on('message')
接收到新消息時,先判斷狀態中是否存在該聯繫人,若是存在則將消息存到對應的聯繫人下,若是不存在則是一條臨時會話的消息,構造一個臨時聯繫人並獲取歷史消息,而後將臨時聯繫人添加到州中。若是是來自本身其它終端的消息,則不須要建立聯繫人
const state = store.getState(); const isSelfMessage = message.from._id === state.getIn(['user', '_id']); const linkman = state.getIn(['user', 'linkmans']).find(l => l.get('_id') === message.to); let title = ''; if (linkman) { action.addLinkmanMessage(message.to, message); if (linkman.get('type') === 'group') { title = `${message.from.username} 在 ${linkman.get('name')} 對你們說:`; } else { title = `${message.from.username} 對你說:`; } } else { // 聯繫人不存在而且是本身發的消息, 不建立新聯繫人 if (isSelfMessage) { return; } const newLinkman = { _id: getFriendId( state.getIn(['user', '_id']), message.from._id, ), type: 'temporary', createTime: Date.now(), avatar: message.from.avatar, name: message.from.username, messages: [], unread: 1, }; action.addLinkman(newLinkman); title = `${message.from.username} 對你說:`; fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id }).then(([err, res]) => { if (!err) { action.addLinkmanMessages(newLinkman._id, res); } }); }
若是當前聊天頁是在後臺的,而且打開了消息通知開關,則會彈出桌面提醒
if (windowStatus === 'blur' && state.getIn(['ui', 'notificationSwitch'])) { notification( title, message.from.avatar, message.type === 'text' ? message.content : `[${message.type}]`, Math.random(), ); }
若是打開了聲音開關,則響一聲新消息提示音
if (state.getIn(['ui', 'soundSwitch'])) { const soundType = state.getIn(['ui', 'sound']); sound(soundType); }
若是打開了語言播報開關而且是文本消息,將消息內的url和#過濾掉,排除長度大於200的消息,而後推送到消息朗讀隊列中
if (message.type === 'text' && state.getIn(['ui', 'voiceSwitch'])) { const text = message.content .replace(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, '') .replace(/#/g, ''); // The maximum number of words is 200 if (text.length > 200) { return; } const from = linkman && linkman.get('type') === 'group' ? `${message.from.username}在${linkman.get('name')}說` : `${message.from.username}對你說`; if (text) { voice.push(from !== prevFrom ? from + text : text, message.from.username); } prevFrom = from; }
更多中間件
限制未登陸請求
大多數接口是隻容許已登陸用戶訪問的,若是接口須要登陸且socket鏈接沒有用戶信息,則返回「未登陸」錯誤
/** * 攔截未登陸請求 */ module.exports = function () { const noUseLoginEvent = { register: true, login: true, loginByToken: true, guest: true, getDefalutGroupHistoryMessages: true, getDefaultGroupOnlineMembers: true, }; return async (ctx, next) => { if (!noUseLoginEvent[ctx.event] && !ctx.socket.user) { ctx.res = '請登陸後再試'; return; } await next(); }; };
限制調用頻率
爲了防止刷接口的狀況,減輕服務器壓力,限制同一插座鏈接每分鐘內最多請求30次接口
const MaxCallPerMinutes = 30; /** * Limiting the frequency of interface calls */ module.exports = function () { let callTimes = {}; setInterval(() => callTimes = {}, 60000); // Emptying every 60 seconds return async (ctx, next) => { const socketId = ctx.socket.id; const count = callTimes[socketId] || 0; if (count >= MaxCallPerMinutes) { return ctx.res = '接口調用頻繁'; } callTimes[socketId] = count + 1; await next(); }; };
小黑屋
管理員帳號能夠將用戶添加到小黑屋,被添加到小黑屋的用戶沒法請求任何接口,10分鐘後自動解禁
/ ** /** * Refusing to seal user requests */ module.exports = function () { return async (ctx, next) => { const sealList = global.mdb.get('sealList'); if (ctx.socket.user && sealList.has(ctx.socket.user.toString())) { return ctx.res = '你已經被關進小黑屋中, 請反思後再試'; } await next(); }; };
其它
表情
表情是一張雪碧圖,點擊表情會向輸入框插入格式爲#(xx)
的文本,例如#(滑稽)
。在渲染消息時,經過正則匹配將這些文本替換爲<img>
,並計算出該表情在雪碧圖中的位置,而後渲染到頁面上
不設置src會顯示一個邊框,須要將src設置爲一張透明圖
function convertExpression(txt) { return txt.replace( /#\(([\u4e00-\u9fa5a-z]+)\)/g, (r, e) => { const index = expressions.default.indexOf(e); if (index !== -1) { return `class="expression-baidu" src="${transparentImage}" style="background-position: left ${-30 * index}px;" onerror="this.style.display='none'" alt="${r}">`; } return r; }, ); }
表情包搜索
的爬https://www.doutula.com上的搜...
const res = await axios.get(`https://www.doutula.com/search?keyword=${encodeURIComponent(keywords)}`); assert(res.status === 200, '搜索表情包失敗, 請重試'); const images = res.data.match(/data-original="[^ "]+"/g) || []; return images.map(i => i.substring(15, i.length - 1));
桌面消息通知
效果如上圖,不一樣系統/瀏覽器在樣式上會有區別
常常有人問到這個是怎麼實現的,實際上是HTML5增長的功能Notification
粘貼發圖
監聽paste事件,獲取粘貼內容,若是包含Files類型內容,則讀取內容並生成Image對象。注意:經過該方式拿到的圖片,會比原圖片體積大不少,所以最好壓縮一下再使用
@autobind handlePaste(e) { const { items, types } = (e.clipboardData || e.originalEvent.clipboardData); // 若是包含文件內容 if (types.indexOf('Files') > -1) { for (let index = 0; index < items.length; index++) { const item = items[index]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) { const that = this; const reader = new FileReader(); reader.onloadend = function () { const image = new Image(); image.onload = () => { // 獲取到 image 圖片對象 }; image.src = this.result; }; reader.readAsDataURL(file); } } } e.preventDefault(); } }
後話
想把前端學好,js真的真的很重要!!!個人web前端學習q.u.n【731771211】,學習資源免費分享,五年資深前端攻城獅在線課堂講解實戰技術。歡迎新手,進階
點擊:加入