在上一篇文章中, 咱們用egg實現了基本的聊天室功能, 本章咱們在其基礎上進行一個小升級, 添加用戶登陸、註冊的功能, 添加查詢聊天室成員的功能。在本章中, 經過token來進行用戶識別, 經過mysql來保存用戶信息, 將會用到egg的對應插件。css
效果圖 html
簡化了項目, 一共只有三個頁面, 可是我會按照完整的項目來調整規範, 項目結構|-- src
|-- assets // 須要打包的靜態資源
|-- components // 公共組建
|-- service // service層, 處理api和數據
|-- stores // vuex管理
|-- style
|-- animate.scss // 過渡動畫
|-- button.scss // 基礎組建樣式, 全局覆蓋vant的樣式
...
|-- index.scss // 收集所有的樣式, 贊成導入
|-- mixin.scss // 公共的mixin
|-- var.scss // 樣式聲明
|-- util
|-- ajax
|-- io
|-- views
... // 具體的路由
app.vue // vue入口
main.ts // 入口文件
router.ts //vue-router
store.ts // vuex 全局方法,入口
vue.config.js // vue配置
複製代碼
這裏要提一下, 開發的時候egg在7001端口, vue在8080端口 明顯會跨域, 因此要設置一下代理 另外一個, 我想將vue打包出來後直接放到egg的public/web下, 這樣就不須要手動搬運了, 因此添加了 baseUrl、outputDir兩個配置, 同時在egg裏面有一個默認的html模板, 直接導入打包後的vue文件, 個人整個項目構成前端
頂級目錄 |-- vue-view 前端部分 |-- egg node部分vue
module.exports = {
lintOnSave: false,
devServer: {
proxy: {
'/api': {
target: 'http://127.0.0.1:7001/',
changeOrigin: true
}
}
},
baseUrl: 'public/web/',
outputDir: '../websocket/app/public/web', // 指向後端的public目錄,
filenameHashing: false
}
複製代碼
// util/ajax
import axios from 'axios'
// 經過導入store, 更改全局的變量來實如今vue外更改vue組件的狀態
import store from '../store'
const CONFIG = {
// 此處應該根據vue環境切換baseUrl, 經過process.env.VUE_APP_SECRET 來獲取當前環境
// baseURL: 'http://127.0.0.1:7001',
timeout: 5000
}
const axiosInstance = axios.create(CONFIG)
// ts聲明
interface options {
url: string,
method: 'get' | 'post' | 'put' | 'delete',
showLoading?: boolean,
head?: object,
data?: any,
showErr?: boolean
}
export default function (options: options) {
// 默認展現loading
options.showLoading = options.hasOwnProperty('showLoading') ? options.showLoading : true;
// 默認展現err
options.showErr = options.hasOwnProperty('showErr') ? options.showErr : true;
options.showLoading && store.commit('changeLoading', true);
// 從localStorage獲取token
const token: string | null = window.localStorage.getItem('token');
const showErr = options.showErr
let arg :any = {}
arg.url = options.url
arg.method = options.method
arg.method === 'get'
? arg.params = options.data
: arg.data = options.data
arg.headers = {
// 'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
}
if (token) {
arg.headers = Object.assign(arg.headers, {token: token})
}
if (options.head) {
arg.headers = Object.assign(arg.headers, options.head)
}
return axiosInstance(arg)
.then(res => {
// 更改組建狀態
options.showLoading && store.commit('changeLoading', false)
return res.data.detail
})
.catch(err => {
options.showLoading && store.commit('changeLoading', false)
const message = err.response.data.message
// 更改組建狀態
showErr && store.dispatch('changeToast', {message: message, type: 'err'})
return Promise.reject(err)
})
}
複製代碼
封裝的IO, 很簡略了。。node
// util/io
import io from 'socket.io-client';
const uri = '/'
const socket = function (token: string):any {
const _io = io(uri, {
query: {
token
}
});
_io.on('connect', function(){
const id = _io.id;
_io.on(id, (msg: any) => {
console.log('#receive,', msg);
});
});
_io.on('disconnect', function(){
console.log('斷開連級');
});
_io.on('connect_error', function (e: any) {
console.log(e, 'reconnect_error');
_io.close()
})
return _io
}
export default socket
複製代碼
store管理, 主要是兩個全局組建的狀態mysql
// store.ts
import Vue from 'vue'
import Vuex from 'vuex'
import Home from './stores/Home'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
// vue 模塊化
Home
},
state: {
// 全局狀態,
loading: false,
toast: {
message: '',
status: false,
type: 'toast'
}
},
mutations: {
changeLoading(state, bool: boolean) {
state.loading = bool
},
changeToast(state, obj: {message: string, status: boolean, type: 'success' | 'err' | 'toast'}) {
state.toast = obj
}
},
actions: {
經過調用這個方法, 在全局管理loading和toast
changeToast({commit}, obj: {message: string, type: 'success' | 'err' | 'toast'}) {
commit('changeToast', {message: obj.message, status: true, type: obj.type})
setTimeout(() => {
commit('changeToast', {message: '', status: false, type: 'toast'})
}, 2000)
}
}
})
複製代碼
在router-view外面 咱們經過vue的transition組件, 實現路由過渡效果,linux
// App.vue
<template>
<div id="app">
<Loading v-if="loading"/>
<Toast v-if="toast.status" :message="toast.message" :type="toast.type"/>
<transition appear name="fade">
<router-view></router-view>
</transition>
</div>
</template>
<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import Loading from './components/Loading.vue'
import Toast from './components/Toast.vue'
@Component({
name: 'App',
components: { Loading, Toast }
})
export default class Index extends Vue {
get loading() {
return this.$store.state.loading
}
get toast() {
return this.$store.state.toast
}
mounted() {
}
}
</script>
複製代碼
對應的動畫效果, 在main中全局引入ios
// 這裏也可使用第三方的動畫庫, 實現更多炫酷效果
@keyframes animatIn {
0% {
transform: translate(-100%, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes animatOut {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(100%, 0);
}
}
.fade-enter {
transform: translate(-100%, 0);
position: absolute!important;
z-index: 999;
top: 0;
left: 0;
}
.fade-enter-active {
animation: animatIn 0.2s;
position: absolute!important;
z-index: 999;
top: 0;
left: 0;
width: 100%;
}
.fade-leave {
transform: translate(0, 0);
}
.fade-leave-active {
animation: animatOut 0.2s;
}
複製代碼
service一共只有三個接口 登陸 註冊, 確認用戶名git
// service/account.ts
import ajax from '../utils/ajax'
interface registerRule {
userName: string,
nick: string,
passWord: string,
// gender: string
}
export function register(data: registerRule) {
return ajax({
url: './api/register',
method: 'post',
data: data
})
}
export function login(data: { userName: string, passWord: string}) {
return ajax({
url: './api/login',
method: 'post',
data: data
}).then(res => {
window.localStorage.setItem('token', res)
}).catch(err => {
return Promise.reject(err)
})
}
export function checkUserName(data: string): Promise<boolean> {
return ajax({
url: './api/checkUserName',
method: 'post',
data: {userName: data}
})
}
複製代碼
登陸和註冊的邏輯就懶得貼了github
<template>
<div class="container">
<Header :onlineUser="onlineUser"/> // 頭部顯示頭銜的組件, 背景是由ajax請求過來的
<MsgItem v-for="(msg, index) in msgList"
:msg="msg.data.message"
:user="msg.user"
:key="index" />
<Send @sendMsg="sendMsg"/>
</div>
</template>
<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import MsgItem from '../../components/MsgItem.vue'
import Header from '../../components/Header.vue'
import Send from '../../components/Send.vue'
import io from '../../utils/io'
let socket: any = undefined
@Component({
name: 'room',
components: {
MsgItem,
Header,
Send
},
})
export default class Index extends Vue {
msgList: Array<object> = []
onlineUser: Array<object> = []
// 點擊發送, 觸發sendMsg事件, 在node監聽
sendMsg(msg: string): void {
socket.emit('sendMsg', msg)
}
mounted() {
// 在鏈接websocket時, 須要將token傳過去, 用於用戶識別
const token = localStorage.getItem('token')
if (!token) {
this.$toast('請先登陸')
setTimeout(() => {
this.$router.push('./login')
}, 1000)
return
}
socket = io(token)
socket.on('online', (data: Array<object>) => {
// 由後端發起的online事件, 更新全部的在線用戶
console.log(data);
this.onlineUser = data
})
socket.on('broadcast', (data: object) => {
// 消息廣播
console.log(data);
this.msgList.push(data)
})
}
}
</script>
複製代碼
由於涉及到了數據庫, 因此咱們須要引入新的包,
按照官方文檔,一桶安裝和配置。記得更新plugin和config.default
// config.default 新添加的配置
config.sequelize = {
dialect: 'mysql',
hostname: 'localhost',
port: 3306,
database: 'chatroom',
password: 'password'
};
config.jwt = {
secret: "123456"
};
複製代碼
Sequelize的使用, 包括生成model, 初始化表,在egg文檔都有, 不在贅述
中間件
// app/middleware/error_handler
// 這就就是修改了一下官方的error_handler
export default function () {
return async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
// 全部的異常都在 app 上觸發一個 error 事件,框架會記錄一條錯誤日誌
ctx.app.emit('error', err, ctx);
const status = err.status || 500;
// 生產環境時 500 錯誤的詳細錯誤內容不返回給客戶端,由於可能包含敏感信息
const error = status === 500 && ctx.app.config.env === 'prod'
? 'Internal Server Error'
: err.message;
// 從 error 對象上讀出各個屬性,設置到響應中
ctx.body = { message: error };
if (status === 422) {
ctx.body.detail = err.errors;
}
ctx.status = status;
}
};
}
複製代碼
// app/middleware/auth
// 權限中間件, 若是未登陸, 直接返回err, 若是已經登陸,將user解密後放在ctx, 便於後面的方法使用
export default async function auth(ctx, next) {
const token = ctx.get('token')
if (!token) {
ctx.body = {
msg: '未登陸'
}
return
}
// 解密token 獲取用戶信息。 實如今下面
const user = ctx.service.account.decrypt(token)
ctx.user = user
await next()
};
複製代碼
// app/extend/helper
// 用戶Socket發送數據格式化
export default {
parseMsg(action, message: string, metadata = {}, user = {}) {
const meta = Object.assign({}, {
timestamp: Date.now(),
}, metadata);
return {
meta,
data: {
action,
message
},
user
};
},
}
複製代碼
// app/extend/context
// 這裏的方法能夠在其餘地方經過ctx調用
module.exports = {
success(status, message, detail) {
this.status = status
this.body = {
message: message,
detail: detail
}
},
err(status, message, detail) {
this.status = status
this.body = {
message: message,
detail: detail
}
},
}
複製代碼
// app/model/Account
const randomColor = require('randomcolor');
interface userInfo {
userName: string,
nick: string,
passWord: string,
gender: string,
avatarUrl: string,
}
const AccountModel = (app) => {
const { INTEGER, BIGINT, CHAR} = app.Sequelize;
const Account = app.model.define('accounts', {
id: { type: BIGINT, primaryKey: true, autoIncrement: true },
userName: CHAR(255),
nick: CHAR(255),
gender: CHAR(1),
avatarUrl: CHAR(255),
passWord: CHAR(255),
whenCreated: INTEGER,
whoCreated: CHAR(255),
deleted: INTEGER,
defaultColor: CHAR
}, {
timestamps: false,
tablseName: 'accounts',
});
Account.createAccount = async (userInfo): Promise<userInfo> => {
return await app.model.Account.create({
userName: userInfo.userName,
nick: userInfo.nick,
passWord: userInfo.passWord,
gender: userInfo.gender | 0,
avatarUrl: userInfo.avatarUrl,
whenCreated: new Date().getTime(),
whoCreated: 'admin',
deleted: 0,
defaultColor: randomColor() // 生成隨機背景顏色, 代替頭像
})
}
return Account;
}
export default AccountModel
複製代碼
使用migrations初始刷數據庫
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, DATE, STRING } = Sequelize;
await queryInterface.createTable('online_users', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
socketId: STRING,
userId: INTEGER,
room: STRING,
online_at: INTEGER,
});
},
down: (queryInterface, Sequelize) => {
}
};
複製代碼
開始寫controller了
app/router
router.get('/', controller.home.index);
router.post('/api/register', controller.account.register);
router.post('/api/checkUserName', controller.account.checkUserName)
router.post('/api/login', controller.account.login);
router.post('/api/test', auth, controller.account.test);
io.of('/').route('sendMsg', io.controller.nsp.sendMsg);
複製代碼
一個一個來
// router
router.get('/', controller.home.index);
// controller/home
import { Controller } from 'egg';
export default class HomeController extends Controller {
public async index() {
const { ctx } = this;
await ctx.render('index.html');
}
}
// 訪問首頁的時候, 將html模板丟出去, 對應的模板位置在view/inndex.html
複製代碼
// app/views/inde.html
// 注意文件路徑
// vue打包後會放在app/public/web文件夾下
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset=utf-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<script src=/public/web/flexible.js></script>
<title>vue-view</title>
<link href=/public/web/css/app.css rel=preload as=style>
<link href=/public/web/css/chunk-vendors.css rel=preload as=style>
<link href=/public/web/js/app.js rel=preload as=script>
<link href=/public/web/js/chunk-vendors.js rel=preload as=script>
<link href=/public/web/css/chunk-vendors.css rel=stylesheet>
<link href=/public/web/css/app.css rel=stylesheet>
</head>
<body>
<noscript><strong>We're sorry but vue-view doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong></noscript>
<div id=app></div>
<script src=/public/web/js/chunk-vendors.js></script>
<script src=/public/web/js/app.js></script>
</body>
</html>
複製代碼
// router
router.post('/api/register', controller.account.register);
router.post('/api/checkUserName', controller.account.checkUserName)
// 註冊的時候回用到兩個接口, 確認用戶名是否存在
複製代碼
檢查用戶名是否存在
// controller/account/checkUserName
const registerRules = {
userName: {
type: 'string',
max: 11,
min: 1
},
nick: 'string',
passWord: {
type: 'string',
max: 11,
min: 6
},
gender: {
type: 'string',
required: false
},
avatarUrl: {
type: 'string',
required: false
}
}
public async checkUserName() {
const body = this.ctx.request.body
this.ctx.validate(checkUserNameRules, body)
const oldName = await this.service.account.checkUserName(body.userName)
this.ctx.success(200, 'success', !oldName)
}
複製代碼
// service
public async checkUserName(userName: string) {
const user = await this.app.model.Account.findOne({
where: {
userName: userName
}
});
if (user) {
return user.dataValues
} else {
return null
}
}
複製代碼
建立帳戶
// router
router.post('/api/checkUserName', controller.account.register)
複製代碼
// controller/account
public async register() {
const body = this.ctx.request.body
this.ctx.validate(registerRules, body)
const oldName = await this.service.account.checkUserName(body.userName)
if (oldName) {
this.ctx.err(422, '用戶名已存在', null)
return
}
// 若是用戶名已存在, 丟422, 不然調用service/createUser
const token = await this.service.account.createUser(body)
this.ctx.body = token
}
複製代碼
// app/service/account
/**
* 加密密碼
*/
public encryptPassWord(passWord: string): string {
// 使用bcrypt加鹽
const salt = bcrypt.genSaltSync(10)
// 返回加密後的密碼
return bcrypt.hashSync(passWord, salt)
}
/**
* 建立帳戶
*/
public async createUser(userInfo: userInfo): Promise<string> {
// 調用上面的encryptPassWord方法加密密碼
const passWord = this.service.account.encryptPassWord(userInfo.passWord)
const tmp = Object.assign({}, userInfo, {passWord: passWord})
// 調用model.Account.createAccount 在數據庫生成新的用戶
const user = await this.ctx.model.Account.createAccount(tmp);
// 調用下面的getToken方法, 將用戶信息加密成token而且返回
const token = await this.service.account.getToken(user.dataValues)
return token
}
/**
* 生成token
*/
public async getToken(userInfo: userInfo): Promise<string> {
// this.app.config.jwt.secret是配置文件裏的,如今是寫死的, 因此生成的token都是同樣的
// 預期的是根據當前時間戳生成secret, 而後將token和secret存在另外一張表裏,
// 這樣就能夠實現刷新token,‘擠下線’之類的操做
const token = this.app.jwt.sign({ user: userInfo }, this.app.config.jwt.secret);
return token
}
複製代碼
// router
router.post('/api/checkUserName', controller.account.login
複製代碼
// controller.account.login
public async login() {
const body = this.ctx.request.body
// 檢查入參
this.ctx.validate(loginRules, body)
// 檢查用戶是否存在
const user = await this.service.account.checkUserName(body.userName)
if (!user) {
this.ctx.err(422, '用戶不存在', null)
return
}
// 覈對密碼
const isMatch = await this.service.account.checkPassWord(user.passWord, body.passWord)
if (isMatch) {
// 生成token
const token = await this.service.account.getToken(user)
this.ctx.success(200, 'success', token)
} else {
this.ctx.err(422, '密碼錯誤', null)
}
}
複製代碼
// service.account.checkPassWord
public async checkPassWord(passWord: string, hashPassWord: string) {
return new Promise((resolve, reject) => {
bcrypt.compare(hashPassWord, passWord, (err, isMatch) => {
if (!err) {
resolve(isMatch)
} else {
reject(err)
}
})
})
}
複製代碼
// service.account.decrypt
// 中間件用到的, 解碼token獲取用戶
public decrypt(token: string): user {
const { user } = this.app.jwt.verify(token, this.app.config.jwt.secret);
return {
id: user.id,
userName: user.userName,
nick: user.nick,
defaultColor: user.defaultColor,
avatarUrl: user.avatarUrl
}
}
複製代碼
前端請求這個藉口以後, 就會存在本地,每次發送ajax的時候,在header裏帶上token,實現登陸的功能。 node在中間件中, 取出token經過中間件獲取用戶, 而後掛載在ctx上,實現完整的登陸邏輯
經過middleware/connection中間件, 在每次鏈接的時候,獲取鏈接者用戶信息,更新在線列表 connection中間件只會在每次鏈接的時候出發
// app/io/middleware/connection
import { Context } from 'egg';
import { uniqBy } from 'lodash';
export default function connectionMiddleware() {
return async (ctx: Context, next: any) => {
const { app, socket } = ctx;
const nsp = app.io.of('/');
const id = socket.id;
// query是客戶度鏈接的時候傳進來的
const query = socket.handshake.query;
const token = query.token
// 解碼用戶
const user = ctx.service.account.decrypt(token)
// 若是沒有登陸, 直接踢出去
if (!user) {
socket.emit(id, {msg: token});
socket.disconnect()
return
}
// 將用戶掛載到鏈接上
socket.user = user
// 查詢房間內的全部客戶端
nsp.clients((err, clients) => {
if (err) throw err;
// 獲取 client 信息
const clientsDetail: Array<object> = [];
clients.forEach(client => {
const _client = app.io.sockets.sockets[client];
const _user = _client.user;
// 這個_user就是房間內用戶的信息
clientsDetail.push(_user)
});
// uniqBy是lodash的方法, 數組去重
// 同一個用戶能夠同時又多個鏈接的
const userList = uniqBy(clientsDetail, 'id')
// 更新在線用戶列表
nsp.emit('online', userList)
});
await next();
};
}
複製代碼
發送信息, 客戶端觸發sendMsg事件
// router
io.of('/').route('sendMsg', io.controller.nsp.sendMsg);
複製代碼
// app/io/controller/nsp
public async sendMsg() {
const { ctx, app } = this;
const socket = ctx.socket;
const nsp = app.io.of('/');
const user = socket.user
const message = ctx.args[0] || {};
const client = socket.id;
// 先前封裝的helper
const msg = ctx.helper.parseMsg('broadcast', message, { client }, user);
nsp.emit('broadcast', msg);
}
複製代碼
這樣基本業務就完成了, 中間還遇到幾個坑, Socket和普通的http走的不一樣的邏輯, 因此Socket沒法使用全局的中間件。 在打包vue的時候, 注意文件路徑。 ts的egg項目要先運行ci, 將ts編譯以後再start ts對egg插件的支持並非很好,常常會遇到沒有定義插件而沒法編譯的狀況。 我偷懶直接改了node_module裏的類型文件。 linux上面安裝sql、遠程數據庫踩到的坑就不提了。。代碼2小時, 環境要折騰幾天。。 最後, 上項目代碼 github.com/xanggang/we… 拉下來估計是跑不起來的...
溜了溜了