原文地址: github.com/yinxin630/b…html
前排提醒, 閱讀本文須要對JavaScript較爲熟悉, 本文將講解核心功能點的設計思路前端
該項目起始於2015年末, 也是我剛開始學習 JavaScript 的時候, 當時僅僅是想作個練手項目. 後面隨着在前端領域的深刻學習, 也一直在更新技術棧, 目前已是重構後的第五個版本node
得益於 node.js
和 react-native
的出現, 使得 jser 的觸手伸到了服務端和APP端. 本項目服務端基於 node.js 技術, 使用了 koa 框架, 全部數據存儲在 mongodb 中. 客戶端使用 react 框架, 使用 redux 和 immutable.js 管理狀態, 本身設計了一套簡約範的UI風格, APP端基於 react-native 和 expo 開發. 項目部署在個人乞丐版阿里雲ECS上, 學生機配置單核1G內存react
服務端負責兩件事:ios
服務端使用了 koa-socket 這個包, 它集成了 socket.io 並實現了 socket 中間件機制, 服務端基於該中間件機制, 本身實現了一套接口路由git
每一個接口都是一個 async 函數, 函數名即接口名, 同時也是 socket 事件名github
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 放在服務端
客戶端使用 socket.io-client 鏈接服務端, 鏈接成功後請求接口嘗試登陸, 若是 localStorage 沒有 token 或者接口返回 token 過時, 將會以遊客身份登陸, 登陸成功會返回用戶信息以及羣組、好友列表, 接着去請求各羣組、好友的歷史消息
客戶端須要監聽 connect / disconnect / message 三個消息
connect
: socket 鏈接成功disconnect
socket 鏈接斷開message
接收到新消息客戶端使用 redux 管理數據, 須要被組件共享的數據放在 redux 中, 只有自身使用的數據仍是放在組件的 state 中, 客戶端存儲的 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登陸是最經常使用的, 客戶端首先從 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>
, 並計算出該表情在雪碧圖中的位置, 而後渲染到頁面上 不設置 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));
複製代碼
效果如上圖, 不一樣系統/瀏覽器在樣式上會有區別 常常有人問到這個是怎麼實現的, 實際上是 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…
最初的版本
改了下背景和樣式
基於react重寫, 定下了 fiora
名稱
風格開始偏向二次元, 加了些新功能
一個沒有上線過的實驗版本
目前線上跑的版本
若是你對 Fiora 還有什麼疑問, 能夠隨時來 fiora.suisuijiang.com/ 交流, 本人天天都會在線