即時通訊應用, 包含服務端、管理端和客戶端
現已部署上線,歡迎體驗客戶端和管理端
請不要隨意更改默認角色和權限,請有點愛心,別整一些很不文明的名字
使用egg 框架,IM 服務的服務端
移動互聯網發展至今,以微信為首的即時通訊服務已經融入了我們生活中的各個角落,在公司的一些業務中也扮演著重要的角色,對於即時通訊我們公司原來是使用的環信的服務,但是有很多定制化的需求無法實現,所以後來決定內部開發一個滿足定制化需求的即時通訊微服務。
使用socket.io框架是因為當時後端缺人,加上看了一些例子後覺得使用起來真的很方便,而且全平台支持,所以這個微服務就在前端團隊進行落地實踐,目前效果還不錯。
社區目前這方面的內容比較少或者太簡陋(只有一個公共的聊天室這種)。另外就是在業務開發過程中被PM 搞得很難受,所以想脫離一些特有的業務上的東西,實現一個功能簡單五臟俱全的不摻雜公司業務的IM 應用,包含服務端,管理端和客戶端。客戶端的模仿對像是微信,因為我很熟悉,不用在產品上面思考太多,另外就是試用的人很熟悉,不需要太多的溝通成本。
要開發一套完整的即時通訊服務,需要以下部分:
為企業級框架和應用而生
選用阿里的egg.js 框架做支撐,看中的原因是他們內部大規模的落地和安全方面做得比較好,沒有選擇nest 的原因是集成socket.io比較麻煩,ORM 選用sequelize,數據庫是mysql ,之前一起使用過,上手難度小
開箱即用的中台前端/設計解決方案
選擇Ant Design Pro 作為模板開發管理端,選用的原因是我對Vue 全家桶比較熟悉,想藉著這個機會熟悉下整套React 生態的開發流程,感受下目前國內兩大開發框架的本質區別和殊途同歸,Ant Design Pro 已經發布了好幾年了,也的確給中小型企業帶來效率的提升,也正好適合我這的需求。
Vue.js 開發的標準工具
使用@vue/cli 搭建IM 服務的客戶端,一個移動端的H5 項目,UI 框架使用的有贊vant,集成了我的開源組件vue-page-stack和黃老師的better-scroll,實現IM 的基礎功能
作為一個前端工程師,大多數的日常工作是不需要思考實體關係的。但是,就我的實際體驗來看,懂得實體關係可以幫助我們更好的理解業務模型。而對產品和業務理解的提升對我們的幫助是非常大的,可以在需求評審的時候發現很多不符合邏輯的地方(怎麼又要吐槽產品經理了),這時候能提出來就會主動避免我們在後續的過程中進行反復開發,同時可以和產品側的同學形成比較良好的互動(而不是互懟)。下面簡單羅列下比較重要的實體關係:
通過上圖可以看到user 是整個關係圖中的核心,下面介紹下各個實體之間的關係:
下面詳細介紹下會話、角色與權限:
完成一個即時通訊應用,需要考慮的第一個事情就是會話,就是我們微信裡面的對話窗口。思考會話和消息、用戶、群組之間的關係花費了不少的精力,最終形成以下的基本關係:
也就是說,用戶和會話沒有直接的關係,只能通過用戶對應的單聊和群聊去獲取會話,這樣做可以有以下的好處:
為了設計一個靈活、通用、方便的權限管理系統,本系統採用RBAC(基於角色的訪問控制)控制,來設計一個通用的『用戶角色權限』平台,方便後期擴展。
RBAC(基於角色的訪問控制)是指用戶通過角色與權限進行關聯。即一個用戶擁有若干角色,每一個角色擁有若干權限(當然了,別把衝突的角色和權限配在一起)。這樣,就構造成“用戶—角色—權限”的授權模型。在這種模型中,用戶與角色之間、角色與權限之間,一般是多對多的關係。
本系統默認有管理員、一般用戶、禁言用戶和封禁用戶這幾種角色,給不同的角色分配不同的權限,所以需要針對管理和發言等接口路由做一下統一的鑑權(通過中間件的方式)處理,具體方式和方法在後端項目中會詳細說明。本系統暫時採用預先定義了角色和權限的方式,後續想要擴展的話可以編輯角色和權限。
沒見過微信的管理端,但是可以想像一下,管理員可以配置用戶的角色和權限,可以編輯群組的狀態:
註冊登錄後,可以正常的添加好友和加入群組,可以修改個人基礎信息和處理申請
無法登錄
舉個例子:現在有一個新版的個人中心需要上線測試,首先新建一個角色『測試個人中心』,再給這個角色分配對應的權限;然後給普通用戶做個分組,選出一些人配置上這個角色,這樣就可以進行測試了。
下面說下即時通訊服務的核心通訊原理,和一般的http 服務一樣,有一個服務端和客戶端進行通訊,只不過詳細的協議和處理方式不一樣。
由於歷史原因,現在主流的http 協議是無狀態協議(HTTP2 暫時應用不廣泛),一般情況是由客戶端主動發起請求,然後服務端去響應。那麼為了實現服務端向客戶端推送信息,就需要前端主動向後端去輪詢,這種方式低效且容易出錯,在之前我們的管理端首頁確實是這麼做的(5s 一次)。
為了實現這種服務端主動推送信息的需求, HTML5 開始提供一種在單個TCP 連接上進行全雙工通訊的協議,也就是WebSocket。 WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。 WebSocket 協議在2008 年誕生,2011 年成為國際標準,目前絕大部分瀏覽器都已經支持了。
WebSocket 的用法相當簡單:
var ws = new WebSocket("wss://echo.websocket.org");
ws.onopen = function(evt) {
console.log("Connection open ...");
ws.send("Hello WebSockets!");
};
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};
ws.onclose = function(evt) {
console.log("Connection closed.");
};
有了WebSocket 協議讓服務端主動推送信息有了先進的武器,那麼有沒有什麼方式可以兼容新舊瀏覽器呢?其實很多人想到了這點,答案就是socket.io
socket.io socket.io進一步封裝了WebSocket的接口,而且可以在舊版本瀏覽器中自主切換到使用輪詢的方式進行通訊(我們使用者是不會感知的),形成了一套統一的接口,大大減輕了開發的負擔。主要具有以下優點:
這是socket.io 主頁
最快,最可靠的即時通訊引擎(FEATURING THE FASTEST AND MOST RELIABLE REAL-TIME ENGINE)
使用起來真的很簡單:
var io = require('socket.io')(80);
var cfg = require('./config.json');
var tw = require('node-tweet-stream')(cfg);
tw.track('socket.io');
tw.track('javascript');
tw.on('tweet', function(tweet){
io.emit('tweet', tweet);
});
著重講下Server 端項目中我認為幾個重要的點,大部分內容需要去egg 官網查看。
使用腳手架npm init egg --type=simple初始化server 項目,安裝mysql(我的是8.0 版本),配置上sequelize 所需的數據庫鏈接密碼等,就可以啟動了
// 目录结构说明
├── package.json // 项目信息
├── app.js // 启动文件,其中有一些钩子函数
├── app
| ├── router.js // 路由
│ ├── controller
│ ├── service
│ ├── middleware // 中间件
│ ├── model // 实体模型
│ └── io // socket.io 相关
│ ├── controller
│ └── middleware // io独有的中间件
├── config // 配置文件
| ├── plugin.js // 插件配置文件
| └── config.default.js // 默认的配置文件
├── logs // server运行期间产生的log文件
└── public // 静态文件和上传文件目录
Router 主要用來描述請求URL 和具體承擔執行動作的Controller 的對應關係,即app/router
app/middleware/auth.jsapp/middleware/admin.js 因為本系統預設有管理員和一般通信用戶的不同角色,所以需要針對管理和通信的接口路由做一下統一的鑑權處理。
比如管理端的路由/v1/admin/... ,想在這個系列路由全都添加管理員的鑑權,這時候可以用中間件的方式進行鑑權,下面是在admin router 中使用中間件的具體例子
// middware
module.exports = () => {
return async function admin(ctx, next) {
let { session } = ctx;
// 判断admin权限
if (session.user && session.user.rights.some(right => right.keyName === 'admin')) {
await next();
} else {
ctx.redirect('/login');
}
};
};
// router
const admin = app.middleware.admin();
router.get('/api/v1/admin/rights', admin, controller.v1.admin.rightsIndex);
使用的sequelize+mysql 組合,egg 也有sequelize 的相關插件,sequelize 即是一款Node 環境使用的ORM,支持Postgres, MySQL, MariaDB, SQLite 和Microsoft SQL Server,使用起來還是挺方便的。需要先定義模型和模型直接的關係,有了關係之後便可以使用一些預設的方法了。
模型的基礎信息比較容易處理,需要注意的就是實體之間的關係設計,即associate,下面是user 的關係描述
// User.js
module.exports = app => {
const { STRING } = app.Sequelize;
const User = app.model.define('user', {
provider: {
type: STRING
},
username: {
type: STRING,
unique: 'username'
},
password: {
type: STRING
}
});
User.associate = function() {
// One-To-One associations
app.model.User.hasOne(app.model.UserInfo);
// One-To-Many associations
app.model.User.hasMany(app.model.Apply);
// Many-To-Many associations
app.model.User.belongsToMany(app.model.Group, { through: 'user_group' });
app.model.User.belongsToMany(app.model.Role, { through: 'user_role' });
};
return User;
};
例如user 和userInfo 的關係就是一對一的關係,定義好了之後,我們在新建user 的時候就可以使用user.setUserInfo(userInfo)了,想獲取此user 的基礎信息的時候也可以通過user.getUserInfo()
User 和Apply(申請)的關係就是一對多,即一個用戶可以對應多個自己的申請,目前只有好友申請和入群申請:
添加申請的時候可以user.addApply(apply) ,獲取的時候可以這樣獲取:
const result = await ctx.model.Apply.findAndCountAll({
where: {
userId: ctx.session.user.id,
hasHandled: false
}
});
user 和group 的關係就是多對多,即一個用戶可以對應多個群組,一個群組也可以對應多個用戶,這樣sequelize 會建立一個中間表user_group 來實現這種關係。
一般我這麼使用:
group.addUser(user); // 建立群组和用户的关系
user.getGroups(); // 获取用户的群组信息
egg 提供了egg-socket.io 插件,需要在安裝egg-socket.io 後在config/plugin.js 開啟插件,io 有自己的中間件和controller
io 的路由和一般的http 請求的不太一樣,注意這裡的路由不能添加中間件處理(我沒成功),所以禁言處理我是在controller 裡面處理的
// 加入群
io.of('/').route('/v1/im/join', app.io.controller.im.join);
// 发送消息
io.of('/').route('/v1/im/new-message', app.io.controller.im.newMessage);
// 查询消息
io.of('/').route('/v1/im/get-messages', app.io.controller.im.getMessages);
注意:我把群組和好友關係都看做是一個room(也就是一個會話),這樣就是直接向這個romm 裡面發消息,裡面的人都可以收到
有兩個默認的中間件,一個是連接和斷開時候調用的connection Middleware,這裡用來校驗登錄狀態和處理業務邏輯了;另外一個是每次發消息時候調用的packet Middleware,這裡用來打印log
由於預設了禁言權限,在controller 裡面進行處理
// 对用户发言的权限进行判断
if (!ctx.session.user.rights.some(right => right.keyName === 'speak')) {
return;
}
聊天分為單聊和群聊,聊天信息暫時有一般的文字、圖片、視頻和定位消息,可以根據業務擴展為訂單或者商品等
message 的結構設計參考了幾家第三方服務的設計,也結合本項目自身的情況做了調整,可以隨意擴展,做如下說明:
const Message = app.model.define('message', {
/**
* 消息类型:
* 0:单聊
* 1:群聊
*/
type: {
type: STRING
},
// 消息体
body: {
type: JSON
},
fromId: { type: INTEGER },
toId: { type: INTEGER }
});
body 裡面存放的是消息體,使用json 用來存放不同的消息格式:
// 文本消息
{
"type": "txt",
"msg":"哈哈哈" //消息内容
}
// 图片消息
{
"type": "img",
"url": "http://nimtest.nos.netease.com/cbc500e8-e19c-4b0f-834b-c32d4dc1075e",
"ext":"jpg",
"w":360, //宽
"h":480, //高
"size": 388245
}
// 视频消息
{
"type": 'video',
"url": "http://nimtest.nos.netease.com/cbc500e8-e19c-4b0f-834b-c32d4dc1075e",
"ext":"mp4",
"w":360, //宽
"h":480, //高
"size": 388245
}
// 地理位置消息
{
"type": "loc",
"title":"中国 浙江省 杭州市 网商路 599号", //地理位置title
"lng":120.1908686708565, // 经度
"lat":30.18704515647036 // 纬度
}
當前只有一個,就是更新baidu 的token,這裡還算簡單,參考官方文檔即可
智能對話定制與服務平台UNIT
這個還是挺有意思的,可以在https://ai.baidu.com/新建機器人和添加對應的技能,我這裡是閒聊,還有智能問答等可以選擇
如果不想啟動可以在app.js 和app/schedule/baidu.js 中刪除ctx.service.baidu.getToken();
首先需要在配置文件裡面進行配置,我這裡限制了文件大小,餅跨站了ios 的視頻文件格式:
config.multipart = {
mode: 'file',
fileSize: '3mb',
fileExtensions: ['.mov']
};
使用了一個統一的接口來處理文件上傳,核心問題是文件的寫入,files 是前端傳來的文件列表
for (const file of ctx.request.files) {
// 生成文件路径,注意upload文件路径需要存在
const filePath = `./public/upload/${
Date.now() + Math.floor(Math.random() * 100000).toString() + '.' + file.filename.split('.').pop()
}`;
const reader = fs.createReadStream(file.filepath); // 创建可读流
const upStream = fs.createWriteStream(filePath); // 创建可写流
reader.pipe(upStream); // 可读流通过管道写入可写流
data.push({
url: filePath.slice(1)
});
}
我這裡是存儲到了server 目錄的/public/upload/ ,這個目錄需要做一下靜態文件的配置:
config.static = {
prefix: '/public/',
dir: path.join(appInfo.baseDir, 'public')
};
這個章節的egg 官方文檔,要你的命,例子啥也沒有,一定要去看源碼,太坑人了,我研究了很久才弄明白是怎麼回事。
因為我想更自由的控制賬戶密碼登錄,所以賬號密碼登錄並沒有使用passport,使用的就是普通的接口認證配合session。
下面詳細說下使用第三方平台(我選用的是GitHub)登錄的過程:
開啟插件:
// config/plugin.js
module.exports.passport = {
enable: true,
package: 'egg-passport',
};
module.exports.passportGithub = {
enable: true,
package: 'egg-passport-github',
};
// config.default.js
config.passportGithub = {
key: 'your_clientID',
secret: 'your_clientSecret',
callbackURL: 'http://localhost:3000/api/v1/passport/github/callback' // 注意这里非常的关键,这里需要和你在github上面设置的Authorization callback URL一致
};
this.app.passport.verify(verify);
const github = app.passport.authenticate('github', { successRedirect: '/' }); // successRedirect就是最后校验完毕后前端会跳转的路由,我这里直接跳转到主页了
router.get('/v1/passport/github', github);
router.get('/v1/passport/github/callback', github);
http://localhost:3000/v1/passport/github/callback?code=12313123123 /v1/passport/github /callback?code=12313123123 ,我們的githubPassport 插件會去獲取用戶在github 上的信息,獲取到詳細信息後,我們需要在app/passport/verify.js去驗證用戶信息,並且和我們自身平台的用戶信息做關聯,也要給session 賦值 // verify.js
module.exports = async (ctx, githubUser) => {
const { service } = ctx;
const { provider, name, photo, displayName } = githubUser;
ctx.logger.info('githubUser', { provider, name, photo, displayName });
let user = await ctx.model.User.findOne({
where: {
username: name
}
});
if (!user) {
user = await ctx.model.User.create({
provider,
username: name
});
const userInfo = await ctx.model.UserInfo.create({
nickname: displayName,
photo
});
const role = await ctx.model.Role.findOne({
where: {
keyName: 'user'
}
});
user.setUserInfo(userInfo);
user.addRole(role);
await user.save();
}
const { rights, roles } = await service.user.getUserAttribute(user.id);
// 权限判断
if (!rights.some(item => item.keyName === 'login')) {
ctx.body = {
statusCode: '1',
errorMessage: '不具备登录权限'
};
return;
}
ctx.session.user = {
id: user.id,
roles,
rights
};
return githubUser;
};
注意看上面的代碼,如果是首次授權將會創建這個用戶,如果是第二次授權,那麼用戶已經被創建了
系統部署或者運行的時候,需要預設一些數據和表,代碼在app.js和app/service/startup.js
邏輯就是項目啟動完畢後,利用model 同步表結構到數據庫中,然後開始新建一些基礎數據:
做完以上這些就算是完成了初始數據了,可以進行正常的運轉
我是在騰訊雲買的服務器centos,在阿里雲買的域名,裝了node(12.18.2) 、 nginx 和mysql8.0,直接在centos 上面啟動,前端使用nginx 進行反向代理。由於服務器資源有限,沒有使用一些自動化工具Jenkins 和Docker,這就導致了我在更新的時候得有一些手動操作。