即時通訊應用服務,整套包含服務端、管理端和客戶端,歡迎Star支援和檢視原始碼。
我們們書接上文,繼續完成完整的即時通訊服務,這篇著重講下Server端專案中我認為幾個重要的點,大部分內容需要去我的倉庫原始碼和 egg 官網檢視。
server 端詳細說明
使用腳手架npm init egg --type=simple
初始化 server 專案,安裝 mysql(我的是 8.0 版本),配置上 sequelize 所需的資料庫連結密碼等,就可以啟動了
著重講下 Server 端專案中我認為幾個重要的點,大部分內容需要去 egg 官網檢視。
// 目錄結構說明
├── 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
- 路由使用了版本號 v1,方便以後升級,一般的增刪改查直接使用 restful 的方式比較簡單
- 除了登入和註冊的介面,在其餘所有 http 介面新增了對 session 的檢查,校驗登入狀態,位置在
app/middleware/auth.js
- 在所有管理端的介面處新增了對 admin 許可權的檢查,位置在
app/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,使用起來還是挺方便的。需要先定義模型和模型直接的關係,有了關係之後便可以使用一些預設的方法了。
model 實體模型
模型的基礎資訊比較容易處理,需要注意的就是實體之間的關係設計,即 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(); // 獲取使用者的群組資訊
需要注意的點
- sequelize 的所有操作都是基於 Promise 的,所有大多時候都使用 await 進行等待
- 修改了某個模型的例項的某個屬性後,需要進行 save
- 當我們需要把模型的資料進行組合後返回給前端的時候,需要通過 get({plain: true})這種方式,轉化成資料,然後再拼接,例如獲取會話列表的時候
socketio
egg 提供了 egg-socket.io 外掛,需要在安裝 egg-socket.io 後在 config/plugin.js 開啟外掛,io 有自己的中介軟體和 controller
socketio 的路由
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 裡面發訊息,裡面的人都可以收到
socketio 的中介軟體
有兩個預設的中介軟體,一個是連線和斷開時候呼叫的 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/
新建機器人和新增對應的技能,我這裡是閒聊,還有智慧問答等可以選擇
- 新建機器人,管理機器人的技能,至少一個
- 前往百度雲"應用列表"中建立、檢視 API Key / Secret Key
- 在 config.default.js 中配置 baidu 相關引數,相關介面說明在這裡
如果不想啟動可以在 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')
};
passport
這個章節的 egg 官方文件,要你的命,例子啥也沒有,一定要去看原始碼,太坑人了,我研究了很久才弄明白是怎麼回事。
因為我想更自由的控制賬戶密碼登入,所以賬號密碼登入並沒有使用 passport,使用的就是普通的介面認證配合 session。
下面詳細說下使用第三方平臺(我選用的是 GitHub)登入的過程:
- 在GitHub OAuth Apps新建你的應用,獲取 key 和 secret
- 在專案安裝 egg-passport 和 egg-passport-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一致
};
- 在 app.js 中開啟 passport
this.app.passport.verify(verify);
- 需要設定兩個 passport 的 get 請求路由,第一個是我們在 login 頁面點選的請求,第二個是我們在上一步設定的 callbackURL,這裡主要是第三方平臺會給我們一個可用的 code,然後根據 OAuth2 授權規則去獲取使用者的詳細資訊
const github = app.passport.authenticate('github', { successRedirect: '/' }); // successRedirect就是最後校驗完畢後前端會跳轉的路由,我這裡直接跳轉到主頁了
router.get('/v1/passport/github', github);
router.get('/v1/passport/github/callback', github);
- 這時候在前端點選
/v1/passport/github
會發起 github 對這個應用的授權,成功後 github 會 302 到http://localhost:3000/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,這就導致了我在更新的時候得有一些手動操作。
未完待續,下一篇講解前端實現的技術難點