單槍匹馬擼個聊天室, 支援Web/Android/iOS三端

碎碎醬發表於2019-03-04

原文地址: github.com/yinxin630/b…

前排提醒, 閱讀本文需要對JavaScript較為熟悉, 本文將講解核心功能點的設計思路

單槍匹馬擼個聊天室, 支援Web/Android/iOS三端
原始碼地址: github.com/yinxin630/f…
線上地址: fiora.suisuijiang.com/

前言

該專案起始於2015年底, 也是我剛開始學習 JavaScript 的時候, 當時僅僅是想做個練手專案. 後面隨著在前端領域的深入學習, 也一直在更新技術棧, 目前已經是重構後的第五個版本

單槍匹馬擼個聊天室, 支援Web/Android/iOS三端

得益於 node.jsreact-native 的出現, 使得 jser 的觸手伸到了服務端和APP端. 本專案服務端基於 node.js 技術, 使用了 koa 框架, 所有資料儲存在 mongodb 中. 客戶端使用 react 框架, 使用 redux 和 immutable.js 管理狀態, 自己設計了一套簡約範的UI風格, APP端基於 react-native 和 expo 開發. 專案部署在我的乞丐版阿里雲ECS上, 學生機配置單核1G記憶體

服務端架構

服務端負責兩件事:

  1. 提供基於WebSocket 的介面
  2. 提供 index.html 響應

服務端使用了 koa-socket 這個包, 它整合了 socket.io 並實現了 socket 中介軟體機制, 服務端基於該中介軟體機制, 自己實現了一套介面路由

每個介面都是一個 async 函式, 函式名即介面名, 同時也是 socket 事件名

async login(ctx) {
    return 'login success'
}
複製程式碼

然後寫了個 route 中介軟體, 用來完成路由匹配, 當判斷路由匹配時, 以 ctx 物件作為引數執行路由方法, 並將方法返回值作為介面返回值

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);
        }
    };
};
複製程式碼

這些就是服務端的核心邏輯, 基於該架構下定義介面組成業務邏輯

另外, 服務端還負責提供 index.html 響應, 即客戶端首頁. 客戶端的其它資源是放在 CDN 上的, 這樣可以緩解服務端頻寬壓力, 但是 index.html 不能使用強快取, 因為會使得客戶端更新不可控, 因此 index.html 放在服務端

客戶端架構

客戶端使用 socket.io-client 連線服務端, 連線成功後請求介面嘗試登入, 如果 localStorage 沒有 token 或者介面返回 token 過期, 將會以遊客身份登入, 登入成功會返回使用者資訊以及群組、好友列表, 接著去請求各群組、好友的歷史訊息

客戶端需要監聽 connect / disconnect / message 三個訊息

  1. connect: socket 連線成功
  2. disconnect socket 連線斷開
  3. message 接收到新訊息

客戶端使用 redux 管理資料, 需要被元件共享的資料放在 redux 中, 只有自身使用的資料還是放在元件的 state 中, 客戶端儲存的 redux 資料結構如下:

客戶端store結構

  • user 使用者資訊
    • _id 使用者id
    • username 使用者名稱
    • linkmans 聯絡人列表, 包括群組、好友以及臨時會話
    • isAdmin 是否是管理員
  • focus 當前聚焦的聯絡人id, 既對話中的目標
  • connect 連線狀態
  • ui 客戶端 UI 相關和功能開關

客戶端的資料流, 主要有兩條線路

  1. 使用者操作 => 請求介面 => 返回資料 => 更新redux => 檢視重新渲染
  2. 監聽新訊息 => 處理資料 => 更新redux => 檢視重新渲染

使用者系統

User Schema 定義:

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;
}
複製程式碼

將使用者新增到預設群組, 然後生成使用者 token token 是用來免密碼登入的憑證, 儲存在客戶端 localStorage, token裡攜帶使用者id、過期時間、客戶端資訊三個資料,使用者id和過期時間容易理解, 客戶端資訊是為了防token盜用, 之前也試過驗證客戶端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 是不限制多登陸的, 每個使用者都可以在無限個終端登入

登入有三種情況:

  • 遊客登入
  • token登入
  • 使用者名稱/密碼登入

遊客登入僅能檢視預設群組訊息, 並且不能發訊息, 主要是為了降低第一次來的使用者的體驗成本

token登入是最常用的, 客戶端首先從 localStorage 取 token, token 存在就會使用 token 登入
首先對 token 解碼取出負載資料, 判斷 token 是否過期以及客戶端資訊是否匹配

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,
});
複製程式碼

最後返回資料

使用者名稱/密碼與 token 登入僅一開始的邏輯不同, 沒有解碼 token 驗證資料這步 先驗證使用者名稱是否存在, 然後驗證密碼是否匹配

const user = await User.findOne({ username });
assert(user, '該使用者不存在');

const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, '密碼錯誤');
複製程式碼

接下來邏輯就與 token 登入一致了

訊息系統

傳送訊息

sendMessage 介面有三個引數:

  • to: 傳送的物件, 群組或者使用者
  • type: 訊息型別
  • content: 訊息內容

因為群聊和私聊共用這一個介面, 所以首先需要判斷是群聊還是私聊, 獲取群組id或者使用者id, 群聊/私聊通過 to 引數區分
群聊時 to 是相應的群組id, 然後獲取群組資訊 私聊時 to 是傳送者和接收者二人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, '使用者不存在');
}
複製程式碼

部分訊息型別需要做些處理, text訊息判斷長度並做xss處理, invite訊息判斷邀請的群組是否存在, 然後將邀請人、群組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();
複製程式碼

返回給客戶端

接收推送訊息

客戶端訂閱 message 事件接收新訊息 socket.on('message')

接收到新訊息時, 先判斷 state 中是否存在該聯絡人, 如果存在則將訊息存到對應的聯絡人下, 如果不存在則是一條臨時會話的訊息, 構造一個臨時聯絡人並獲取歷史訊息, 然後將臨時聯絡人新增到 state 中. 如果是來自自己其它終端的訊息, 則不需要建立聯絡人

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();
    };
};
複製程式碼

限制呼叫頻率

為了防止刷介面的情況, 減輕伺服器壓力, 限制同一 socket 連線每分鐘內最多請求 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>, 並計算出該表情在雪碧圖中的位置, 然後渲染到頁面上 單槍匹馬擼個聊天室, 支援Web/Android/iOS三端 不設定 src 會顯示一個邊框, 需要將 src 設定為一張透明圖

function convertExpression(txt) {
    return txt.replace(
        /#\(([\u4e00-\u9fa5a-z]+)\)/g,
        (r, e) => {
            const index = expressions.default.indexOf(e);
            if (index !== -1) {
                return `<img class="expression-baidu" src="${transparentImage}" style="background-position: left ${-30 * index}px;" onerror="this.style.display='none'" alt="${r}">`;
            }
            return r;
        },
    );
}
複製程式碼

表情包搜尋

爬的 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));
複製程式碼

桌面訊息通知

單槍匹馬擼個聊天室, 支援Web/Android/iOS三端

效果如上圖, 不同系統/瀏覽器在樣式上會有區別 經常有人問到這個是怎麼實現的, 其實是 HTML5 增加的功能 Notification, 更多資訊檢視 developer.mozilla.org/en-US/docs/…

貼上發圖

監聽 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();
    }
}
複製程式碼

語言播報

這是用的百度的語言合成服務, 感謝百度. 詳情請檢視 ai.baidu.com/tech/speech…

後話

如果你對 Fiora 還有什麼疑問, 可以隨時來 fiora.suisuijiang.com/ 交流, 本人每天都會線上

相關文章