即時(shí)通訊應(yīng)用服務(wù)奏属,整套包含服務(wù)端伶丐、管理端和客戶(hù)端悼做,歡迎Star支持和查看源碼。
現(xiàn)已部署上線(xiàn)哗魂,歡迎體驗(yàn)客戶(hù)端和管理端
咱們書(shū)接上文肛走,繼續(xù)完成完整的即時(shí)通訊服務(wù),這篇著重講下Server端項(xiàng)目中我認(rèn)為幾個(gè)重要的點(diǎn)录别,大部分內(nèi)容需要去我的倉(cāng)庫(kù)源碼和 egg 官網(wǎng)查看朽色。
server 端詳細(xì)說(shuō)明
使用腳手架npm init egg --type=simple
初始化 server 項(xiàng)目,安裝 mysql(我的是 8.0 版本)组题,配置上 sequelize 所需的數(shù)據(jù)庫(kù)鏈接密碼等葫男,就可以啟動(dòng)了
著重講下 Server 端項(xiàng)目中我認(rèn)為幾個(gè)重要的點(diǎn),大部分內(nèi)容需要去 egg 官網(wǎng)查看崔列。
// 目錄結(jié)構(gòu)說(shuō)明
├── package.json // 項(xiàng)目信息
├── app.js // 啟動(dòng)文件梢褐,其中有一些鉤子函數(shù)
├── app
| ├── router.js // 路由
│ ├── controller
│ ├── service
│ ├── middleware // 中間件
│ ├── model // 實(shí)體模型
│ └── io // socket.io 相關(guān)
│ ├── controller
│ └── middleware // io獨(dú)有的中間件
├── config // 配置文件
| ├── plugin.js // 插件配置文件
| └── config.default.js // 默認(rèn)的配置文件
├── logs // server運(yùn)行期間產(chǎn)生的log文件
└── public // 靜態(tài)文件和上傳文件目錄
路由
Router 主要用來(lái)描述請(qǐng)求 URL 和具體承擔(dān)執(zhí)行動(dòng)作的 Controller 的對(duì)應(yīng)關(guān)系,即 app/router
- 路由使用了版本號(hào) v1,方便以后升級(jí)盈咳,一般的增刪改查直接使用 restful 的方式比較簡(jiǎn)單
- 除了登錄和注冊(cè)的接口耿眉,在其余所有 http 接口添加了對(duì) session 的檢查,校驗(yàn)登錄狀態(tài)鱼响,位置在
app/middleware/auth.js
- 在所有管理端的接口處添加了對(duì) admin 權(quán)限的檢查鸣剪,位置在
app/middleware/admin.js
統(tǒng)一鑒權(quán)
因?yàn)楸鞠到y(tǒng)預(yù)設(shè)有管理員和一般通信用戶(hù)的不同角色,所以需要針對(duì)管理和通信的接口路由做一下統(tǒng)一的鑒權(quán)處理热押。
比如管理端的路由/v1/admin/...
西傀,想在這個(gè)系列路由全都添加管理員的鑒權(quán)斤寇,這時(shí)候可以用中間件的方式進(jìn)行鑒權(quán)桶癣,下面是在 admin router 中使用中間件的具體例子
// middware
module.exports = () => {
return async function admin(ctx, next) {
let { session } = ctx;
// 判斷admin權(quán)限
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);
數(shù)據(jù)庫(kù)相關(guān)
使用的 sequelize+mysql 組合,egg 也有 sequelize 的相關(guān)插件娘锁,sequelize 即是一款 Node 環(huán)境使用的 ORM牙寞,支持 Postgres, MySQL, MariaDB, SQLite 和 Microsoft SQL Server,使用起來(lái)還是挺方便的莫秆。需要先定義模型和模型直接的關(guān)系间雀,有了關(guān)系之后便可以使用一些預(yù)設(shè)的方法了。
model 實(shí)體模型
模型的基礎(chǔ)信息比較容易處理镊屎,需要注意的就是實(shí)體之間的關(guān)系設(shè)計(jì)惹挟,即 associate,下面是 user 的關(guān)系描述
// 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;
};
一對(duì)一
例如 user 和 userInfo 的關(guān)系就是一對(duì)一的關(guān)系缝驳,定義好了之后连锯,我們?cè)谛陆?user 的時(shí)候就可以使用 user.setUserInfo(userInfo)
了,想獲取此 user 的基礎(chǔ)信息的時(shí)候也可以通過(guò)user.getUserInfo()
一對(duì)多
User 和 Apply(申請(qǐng))的關(guān)系就是一對(duì)多用狱,即一個(gè)用戶(hù)可以對(duì)應(yīng)多個(gè)自己的申請(qǐng)运怖,目前只有好友申請(qǐng)和入群申請(qǐng):
添加申請(qǐng)的時(shí)候可以user.addApply(apply)
,獲取的時(shí)候可以這樣獲认囊痢:
const result = await ctx.model.Apply.findAndCountAll({
where: {
userId: ctx.session.user.id,
hasHandled: false
}
});
多對(duì)多
user 和 group 的關(guān)系就是多對(duì)多摇展,即一個(gè)用戶(hù)可以對(duì)應(yīng)多個(gè)群組,一個(gè)群組也可以對(duì)應(yīng)多個(gè)用戶(hù)溺忧,這樣 sequelize 會(huì)建立一個(gè)中間表 user_group 來(lái)實(shí)現(xiàn)這種關(guān)系咏连。
一般我這么使用:
group.addUser(user); // 建立群組和用戶(hù)的關(guān)系
user.getGroups(); // 獲取用戶(hù)的群組信息
需要注意的點(diǎn)
- sequelize 的所有操作都是基于 Promise 的,所有大多時(shí)候都使用 await 進(jìn)行等待
- 修改了某個(gè)模型的實(shí)例的某個(gè)屬性后鲁森,需要進(jìn)行 save
- 當(dāng)我們需要把模型的數(shù)據(jù)進(jìn)行組合后返回給前端的時(shí)候捻勉,需要通過(guò) get({plain: true})這種方式,轉(zhuǎn)化成數(shù)據(jù)刀森,然后再拼接踱启,例如獲取會(huì)話(huà)列表的時(shí)候
socketio
egg 提供了 egg-socket.io 插件,需要在安裝 egg-socket.io 后在 config/plugin.js 開(kāi)啟插件,io 有自己的中間件和 controller
socketio 的路由
io 的路由和一般的 http 請(qǐng)求的不太一樣埠偿,注意這里的路由不能添加中間件處理(我沒(méi)成功)透罢,所以禁言處理我是在 controller 里面處理的
// 加入群
io.of('/').route('/v1/im/join', app.io.controller.im.join);
// 發(fā)送消息
io.of('/').route('/v1/im/new-message', app.io.controller.im.newMessage);
// 查詢(xún)消息
io.of('/').route('/v1/im/get-messages', app.io.controller.im.getMessages);
注意:我把群組和好友關(guān)系都看做是一個(gè) room(也就是一個(gè)會(huì)話(huà)),這樣就是直接向這個(gè) romm 里面發(fā)消息冠蒋,里面的人都可以收到
socketio 的中間件
有兩個(gè)默認(rèn)的中間件羽圃,一個(gè)是連接和斷開(kāi)時(shí)候調(diào)用的 connection Middleware,這里用來(lái)校驗(yàn)登錄狀態(tài)和處理業(yè)務(wù)邏輯了抖剿;另外一個(gè)是每次發(fā)消息時(shí)候調(diào)用的 packet Middleware朽寞,這里用來(lái)打印 log
由于預(yù)設(shè)了禁言權(quán)限,在 controller 里面進(jìn)行處理
// 對(duì)用戶(hù)發(fā)言的權(quán)限進(jìn)行判斷
if (!ctx.session.user.rights.some(right => right.keyName === 'speak')) {
return;
}
聊天
聊天分為單聊和群聊斩郎,聊天信息暫時(shí)有一般的文字脑融、圖片、視頻和定位消息缩宜,可以根據(jù)業(yè)務(wù)擴(kuò)展為訂單或者商品等
消息
message 的結(jié)構(gòu)設(shè)計(jì)參考了幾家第三方服務(wù)的設(shè)計(jì)肘迎,也結(jié)合本項(xiàng)目自身的情況做了調(diào)整,可以隨意擴(kuò)展锻煌,做如下說(shuō)明:
const Message = app.model.define('message', {
/**
* 消息類(lèi)型:
* 0:單聊
* 1:群聊
*/
type: {
type: STRING
},
// 消息體
body: {
type: JSON
},
fromId: { type: INTEGER },
toId: { type: INTEGER }
});
body 里面存放的是消息體妓布,使用 json 用來(lái)存放不同的消息格式:
// 文本消息
{
"type": "txt",
"msg":"哈哈哈" //消息內(nèi)容
}
// 圖片消息
{
"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":"中國(guó) 浙江省 杭州市 網(wǎng)商路 599號(hào)", //地理位置title
"lng":120.1908686708565, // 經(jīng)度
"lat":30.18704515647036 // 緯度
}
定時(shí)任務(wù)
當(dāng)前只有一個(gè),就是更新 baidu 的 token宋梧,這里還算簡(jiǎn)單匣沼,參考官方文檔即可
機(jī)器人聊天
智能對(duì)話(huà)定制與服務(wù)平臺(tái) UNIT
這個(gè)還是挺有意思的,可以在 https://ai.baidu.com/
新建機(jī)器人和添加對(duì)應(yīng)的技能捂龄,我這里是閑聊释涛,還有智能問(wèn)答等可以選擇
- 新建機(jī)器人,管理機(jī)器人的技能跺讯,至少一個(gè)
- 前往百度云"應(yīng)用列表"中創(chuàng)建枢贿、查看 API Key / Secret Key
- 在 config.default.js 中配置 baidu 相關(guān)參數(shù),相關(guān)接口說(shuō)明在這里
如果不想啟動(dòng)可以在 app.js 和 app/schedule/baidu.js 中刪除 ctx.service.baidu.getToken();
上傳文件
首先需要在配置文件里面進(jìn)行配置刀脏,我這里限制了文件大小局荚,餅跨站了 ios 的視頻文件格式:
config.multipart = {
mode: 'file',
fileSize: '3mb',
fileExtensions: ['.mov']
};
使用了一個(gè)統(tǒng)一的接口來(lái)處理文件上傳,核心問(wèn)題是文件的寫(xiě)入愈污,files 是前端傳來(lái)的文件列表
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); // 創(chuàng)建可讀流
const upStream = fs.createWriteStream(filePath); // 創(chuàng)建可寫(xiě)流
reader.pipe(upStream); // 可讀流通過(guò)管道寫(xiě)入可寫(xiě)流
data.push({
url: filePath.slice(1)
});
}
我這里是存儲(chǔ)到了 server 目錄的/public/upload/
,這個(gè)目錄需要做一下靜態(tài)文件的配置:
config.static = {
prefix: '/public/',
dir: path.join(appInfo.baseDir, 'public')
};
passport
這個(gè)章節(jié)的 egg 官方文檔暂雹,要你的命首装,例子啥也沒(méi)有,一定要去看源碼杭跪,太坑人了仙逻,我研究了很久才弄明白是怎么回事驰吓。
因?yàn)槲蚁敫杂傻目刂瀑~戶(hù)密碼登錄,所以賬號(hào)密碼登錄并沒(méi)有使用 passport系奉,使用的就是普通的接口認(rèn)證配合 session檬贰。
下面詳細(xì)說(shuō)下使用第三方平臺(tái)(我選用的是 GitHub)登錄的過(guò)程:
- 在GitHub OAuth Apps新建你的應(yīng)用,獲取 key 和 secret
- 在項(xiàng)目安裝 egg-passport 和 egg-passport-github
開(kāi)啟插件:
// 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' // 注意這里非常的關(guān)鍵缺亮,這里需要和你在github上面設(shè)置的Authorization callback URL一致
};
- 在 app.js 中開(kāi)啟 passport
this.app.passport.verify(verify);
- 需要設(shè)置兩個(gè) passport 的 get 請(qǐng)求路由翁涤,第一個(gè)是我們?cè)?login 頁(yè)面點(diǎn)擊的請(qǐng)求,第二個(gè)是我們?cè)谏弦徊皆O(shè)置的 callbackURL萌踱,這里主要是第三方平臺(tái)會(huì)給我們一個(gè)可用的 code葵礼,然后根據(jù) OAuth2 授權(quán)規(guī)則去獲取用戶(hù)的詳細(xì)信息
const github = app.passport.authenticate('github', { successRedirect: '/' }); // successRedirect就是最后校驗(yàn)完畢后前端會(huì)跳轉(zhuǎn)的路由,我這里直接跳轉(zhuǎn)到主頁(yè)了
router.get('/v1/passport/github', github);
router.get('/v1/passport/github/callback', github);
- 這時(shí)候在前端點(diǎn)擊
/v1/passport/github
會(huì)發(fā)起 github 對(duì)這個(gè)應(yīng)用的授權(quán)并鸵,成功后 github 會(huì) 302 到http://localhost:3000/v1/passport/github/callback?code=12313123123
鸳粉,我們的 githubPassport 插件會(huì)去獲取用戶(hù)在 github 上的信息,獲取到詳細(xì)信息后能真,我們需要在app/passport/verify.js
去驗(yàn)證用戶(hù)信息赁严,并且和我們自身平臺(tái)的用戶(hù)信息做關(guān)聯(lián)扰柠,也要給 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);
// 權(quán)限判斷
if (!rights.some(item => item.keyName === 'login')) {
ctx.body = {
statusCode: '1',
errorMessage: '不具備登錄權(quán)限'
};
return;
}
ctx.session.user = {
id: user.id,
roles,
rights
};
return githubUser;
};
注意看上面的代碼粉铐,如果是首次授權(quán)將會(huì)創(chuàng)建這個(gè)用戶(hù),如果是第二次授權(quán)卤档,那么用戶(hù)已經(jīng)被創(chuàng)建了
初始化
系統(tǒng)部署或者運(yùn)行的時(shí)候蝙泼,需要預(yù)設(shè)一些數(shù)據(jù)和表,代碼在app.js
和 app/service/startup.js
邏輯就是項(xiàng)目啟動(dòng)完畢后劝枣,利用 model 同步表結(jié)構(gòu)到數(shù)據(jù)庫(kù)中汤踏,然后開(kāi)始新建一些基礎(chǔ)數(shù)據(jù):
- 新建角色和權(quán)限,并給角色分配權(quán)限
- 新建不同用戶(hù)舔腾,分配角色
- 給一些用戶(hù)建立好友關(guān)系
- 添加申請(qǐng)
- 創(chuàng)建群組溪胶,并添加一些人
做完以上這些就算是完成了初始數(shù)據(jù)了,可以進(jìn)行正常的運(yùn)轉(zhuǎn)
部署
我是在騰訊云買(mǎi)的服務(wù)器 centos稳诚,在阿里云買(mǎi)的域名哗脖,裝了 node(12.18.2) 、 nginx 和 mysql8.0扳还,直接在 centos 上面啟動(dòng)才避,前端使用 nginx 進(jìn)行反向代理。由于服務(wù)器資源有限氨距,沒(méi)有使用一些自動(dòng)化工具 Jenkins 和 Docker桑逝,這就導(dǎo)致了我在更新的時(shí)候得有一些手動(dòng)操作。