vuejs、eggjs、mqtt全棧式開發簡單裝置管理系統
業餘時間用eggjs、vuejs開發了一個裝置管理系統,通過mqtt協定上傳裝置資料至web端實時展現,包含裝置參數分析、發送裝置報警等子產品。收獲還是挺多的,特别是vue的學習,這裡簡單記錄一下:
源碼位址:https://github.com/caiya/vuejs-admin,寫文不易,有幫助的話麻煩給個star,感謝!
技術棧
前端:vue、vuex、vue-router、element-ui、axios、mqttjs
後端:eggjs、mysql、sequlize、restful、oauth2.0、mqtt、jwt
- 使用者子產品(使用者管理,使用者增删改查)
- 裝置子產品(裝置管理、裝置參數監控、裝置參數記錄、裝置類别管理、參數管理等)
- 授權子產品(引入OAuth2.0授權服務,友善将接口以OAuth提供第三方)
- 消息子產品(使用者申請幫助消息、裝置參數告警消息等)
效果圖(對一個後端css永遠是内傷)
登入頁:
首頁:
裝置頁:
裝置參數監控頁:
前台
項目結構
前端使用vue-cli腳手架建構,基本目錄結構如下:
main.js入口
vue項目的入口檔案,這裡主要是引入iconfont、element-ui、echarts、moment、vuex等子產品。
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import { axios } from './http/base'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import './assets/fonts/iconfont.css'
import ECharts from 'vue-echarts/components/ECharts'
// import ECharts modules manually to reduce bundle size
import 'echarts/lib/chart/line'
import 'echarts/lib/component/tooltip'
// register component to use
Vue.component('chart', ECharts)
import store from './store'
import moment from 'moment'
Vue.prototype.$moment = moment
Vue.use(ElementUI)
// 引入mqtt
import './mq'
Vue.config.productionTip = false
// 挂載到prototype上面,確定元件中可以直接使用this.axios
// Vue.prototype.axios = axios
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
components: { App },
template: '<App/>'
})
注意:
1、引入比較大的子產品比如echarts時,盡量手動按需進行子產品導入,節省打封包件大小
2、一般通過将子產品比如moment挂載到Vue的prototype上面,這樣就可以在任意vue元件中使用*this.$moment*進行moment操作了
3、iconfont是阿裡的圖示樣式,下載下傳下來後放入assets中再引入即可
vuex引入
vuex引入的時候采用了子產品話引入,入口檔案代碼為:
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import devArgsMsg from './modules/devArgsMsg'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
user,
devArgsMsg
}
})
其中user、devArgsMsg為兩個獨立子產品,這樣分子產品引入可以避免項目過大結構不清晰的問題。其中user.js子產品代碼:
import * as TYPES from '../mutation.types'
const state = {
userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}'),
token: localStorage.getItem('token') || ''
}
const actions = {
}
const mutations = {
[TYPES.LOGIN]: (state, loginData) => {
state.userInfo = loginData.user
state.token = loginData.token
localStorage.setItem('userInfo', JSON.stringify(loginData.user))
localStorage.setItem('token', loginData.token)
},
[TYPES.LOGOUT]: state => {
state.userInfo = {}
state.token = ''
localStorage.removeItem('userInfo')
localStorage.removeItem('token')
}
}
const getters = {
}
export default {
state,
actions,
mutations,
getters
}
關于mutations.type.js:
// 各種mutation類型
// 使用者子產品
export const LOGOUT = 'LOGOUT'
export const LOGIN = 'LOGIN'
// 裝置子產品
export const SETDEVARGSMSG = 'setDevArgsMsg'
注意:
1、mutations的名稱定義時遵循官方,一般定義為常量
2、state的資料隻有通過mutation才能操作,不能直接在元件中設定state,否則無效
3、mutation中的操作都是同步操作,異步操作或網絡請求或同時多個mutation操作可以放入action中進行
4、使用者資訊、登入token一般放入h5的localStorage,這樣重新整理頁面保證關鍵資料不丢失
5、vuex中的getters相當于state的計算屬性,監聽state資料變動時可以使用getters
vue-router路由子產品
路由子產品基本使用:
import Vue from 'vue'
import Router from 'vue-router'
import store from '../store'
Vue.use(Router)
const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Login',
component: resolve => require(['@/views/auth/Login'], resolve)
},
{
path: '', // 預設位址為登入頁
name: '',
component: resolve => require(['@/views/auth/Login'], resolve)
},
{
path: '/main',
name: '',
component: resolve => require(['@/views/Main'], resolve),
meta: {
requireAuth: true, // 添加該字段,表示進入這個路由是需要登入的
nav: '歡迎頁'
},
children: [{
path: 'user',
component: resolve => require(['@/views/user/List'], resolve),
name: 'UserList',
meta: {
requireAuth: true,
nav: '使用者管理',
activeItem: '1-1'
},
}, {
path: 'user/setting/:userId?',
name: 'UserSetting',
component: resolve => require(['@/views/user/Setting'], resolve),
meta: {
requireAuth: true,
nav: '資料設定',
activeItem: '1-2'
},
}, {
path: 'device',
component: resolve => require(['@/views/device/List'], resolve),
name: 'Device',
meta: {
requireAuth: true,
nav: '裝置清單',
activeItem: '3-1'
},
},{
path: 'device/edit/:devId?',
component: resolve => require(['@/views/device/Edit'], resolve),
name: 'DeviceEdit',
meta: {
requireAuth: true,
nav: '裝置編輯',
activeItem: '3-1'
},
},{
path: 'device/type',
component: resolve => require(['@/views/devType/List'], resolve),
name: 'DevTypeList',
meta: {
requireAuth: true,
nav: '裝置類别',
activeItem: '3-2'
},
}, {
path: 'device/arg',
component: resolve => require(['@/views/devArg/List'], resolve),
name: 'DevArgList',
meta: {
requireAuth: true,
nav: '裝置參數',
activeItem: '3-3'
},
},{
path: 'device/monitor',
component: resolve => require(['@/views/device/Monitor'], resolve),
name: 'DevMonitor',
meta: {
requireAuth: true,
nav: '裝置監控',
activeItem: '3-4'
},
}, {
path: '', // 背景首頁預設頁
component: resolve => require(['@/views/common/Welcome'], resolve),
name: 'Welcome',
meta: {
requireAuth: true,
nav: '歡迎頁'
},
}]
}
]
})
其中,每個路由的meta中繼資料中加入requireAuth字段,以便識别該路由是否需要授權,再在router.beforeEach的鈎子函數中作相應判斷:
router.beforeEach((to, from, next) => {
if (to.path === '/' && store.state.user.token) {
return next('/main')
}
if (to.meta.requireAuth) { // 如果需要攔截
if (store.state.user.token) {
next()
} else {
next({
path: '/',
query: {
redirect: to.fullPath
}
})
}
} else {
next()
}
})
export default router
其中store.state.user.token為使用者登入成功後寫入vuex中的token資料,這裡用來判斷是否已登入,已登入過的再次通路首頁(登入頁)則直接跳轉至背景首頁,否則重定向至登入頁。
axios發送http請求
axios是vue官方推薦的xmlhttprequest類庫,使用起來比較友善:
/*
* @Author: cnblogs.com/vipzhou
* @Date: 2018-02-22 21:29:32
* @Last Modified by: mikey.zhaopeng
* @Last Modified time: 2018-02-22 21:48:40
*/
import axios from 'axios'
import router from '../router'
import store from '../store'
// axios 配置
axios.defaults.timeout = 10000
axios.defaults.baseURL = '/api/v1'
// 請求攔截器
axios.interceptors.request.use(config => {
if (store.state.user.token) { // TODO 判斷token是否存在
config.headers.Authorization = `Bearer ${store.state.user.token}`
}
return config
}, err => {
return Promise.reject(err)
})
axios.interceptors.response.use(response => {
return response
}, err => {
if (err.response) {
switch (err.response.status) {
case 401:
store.commit('LOGOUT')
router.replace({ path: '/', query: { redirect: router.currentRoute.fullPath } })
break
case 403:
store.commit('LOGOUT')
router.replace({ path: '/', query: { redirect: router.currentRoute.fullPath } })
break
}
}
return Promise.reject(new Error(err.response.data.error || err.message))
})
/**
* @param {string} url
* @param {object} params={}
*/
const fetch = (url, params = {}) => {
return new Promise((resolve, reject) => {
axios.get(url, {
params
}).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
/**
* @param {string} url
* @param {object} data={}
*/
const post = (url, data = {}) => {
return new Promise((resolve, reject) => {
axios.post(url, data).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
/**
* @param {string} url
* @param {object} data={}
*/
const put = (url, data = {}) => {
return new Promise((resolve, reject) => {
axios.put(url, data).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
/**
* @param {string} url
* @param {object} params={}
*/
const del = (url) => {
return new Promise((resolve, reject) => {
axios.delete(url, {}).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
export { axios, fetch, post, put, del }
封裝完基本http請求之後,其餘子產品在改基礎上封裝即可,比如使用者user.js的http:
/*
* @Author: cnblogs.com/vipzhou
* @Date: 2018-02-22 21:30:19
* @Last Modified by: vipzhou
* @Last Modified time: 2018-02-24 00:12:00
*/
import * as http from './base'
/**
* 登陸
* @param {object} data
*/
const login = (data) => {
return http.post('/users/login', data)
}
/**
* 擷取使用者清單
* @param {object} params
*/
const getUserList = params => {
return http.fetch('/users', params)
}
/**
* 删除使用者
* @param {object} params
*/
const deleteUserById = id => {
return http.del(`/users/${id}`)
}
/**
* 擷取使用者詳情
* @param {id} id
*/
const getUserDetail = id => {
return http.fetch(`/users/${id}`, {})
}
/**
* 儲存使用者資訊
* @param {object} user
*/
const updateUserInfo = user => {
if (!user.id) {
return Promise.reject(new Error(`arg id can't be null`))
}
return http.put(`/users/${user.id}`, user)
}
/**
* 添加使用者
* @param {user對象} user
*/
const addUser = user => {
return http.post('/users', Object.assign({
password: '123456'
}, user))
}
/**
* 退出登陸
* @param {email} email
*/
const logout = email => {
return http.post('/users/logout', {
email
})
}
export { login, getUserList, deleteUserById, getUserDetail, updateUserInfo, addUser, logout }
注意:
1、通過baseURL配置項可以配置接口的基礎path
2、通過request的interceptors,可以實作任意請求前先判斷本地有無token,有的話寫入header或query等地方,進而實作token發送
3、通過response的interceptors可以對響應資料做進一步處理,比如401或403跳轉至登入頁、報錯時直接reject傳回err資訊等
4、基本的rest請求方式代碼封裝基本一緻,隻是method不同而已
關于mqtt子產品
mqtt是一種傳輸協定,轉為IOT物聯網子產品而生,特點是長連接配接、輕量級等,nodejs使用mqtt子產品作為用戶端,每個mqtt都有一個server端(mqtt broker),這裡使用公共broker:ws://mq.tongxinmao.com:18832/web。
mqtt采用簡單的釋出訂閱模式,消息釋出者(一般是裝置端)釋出裝置相關消息至某個topic(topic支援表達式寫法),消費者(一般是各個應用程式)接收消息并持久化處理等。
import mqtt from "mqtt"
import Vue from "vue"
import store from '../store'
import { Notification } from 'element-ui'
let client = null
// 開啟訂閱(登入成功後調用)
export const startSub = () => {
client = mqtt.connect("ws://mq.tongxinmao.com:18832/web")
client.on("connect", () => {
client.subscribe("msgNotice") // 訂閱消息類通知主題
client.subscribe("/devices/#") // 訂閱所有裝置相關主題
console.log("連結mqtt成功,并已訂閱相關主題")
}).on('error', err => {
console.log("連結mqtt報錯", err)
client.end()
client.reconnect()
}).on("message", (topic, message) => {
console.log('topic', topic);
// message is Buffer
if (topic + '' === 'msgNotice') { // 消息類通知主題
Notification({
title: '通知',
type: "success",
message: JSON.parse(message.toString()).msg
})
} else { // 裝置相關主題,這裡将各個子產品消息寫入各個子產品的vuex state中,然後各個子產品再getter取值
const devId = topic.substring(9);
const arg = {
devId,
msg: message.toString()
}
console.log('收到裝置上傳消息:', arg);
store.commit('setDevArgsMsg', arg);
}
})
Vue.prototype.$mqtt = client // 友善在vue元件中可以直接使用this.$mqtt -> client
}
// 關閉訂閱(登出時調用)
export const closeSub = () => {
client && client.end()
}
注意:
1、前台應用作為一個mqtt用戶端,背景也作為一個用戶端,所有的實時裝置消息前後端都能接收到,前端負責展現層、後端負責持久層
2、前後端隻需監聽/devices/#主題即可,所有的裝置消息都發送到/devices/裝置id,這樣前後端擷取topic名稱即可判斷目前消息來源于哪個裝置
3、mqtt連結error時采用client.reconnect()進行重連操作
4、mqtt還負責使用者登入、退出之類的消息推送,收到消息直接調用element-ui中的Notification提示即可
5、裝置參數實時消息mqtt接收到後存入vuex的state中,各個元件再使用getters監聽取值再實時圖表展示
關于mqtt實時推送
裝置端發送的實時參數消息發送至主題/devices/裝置id,消息格式為:參數名1:參數實時值1|參數名2:參數實時值2|參數名3:參數實時值3...
浏覽器端mqtt收到的實時消息通過store.commit('setDevArgsMsg', arg);放入vuex中,其中arg格式為:
{
devId, // 目前裝置id
msg: message.toString() // 報警消息
}
vuex中的寫法為:
const mutations = {
[TYPES.SETDEVARGSMSG]: (state, {msg = '', devId = ''}) => {
const time = moment().format('YYYY/MM/DD HH:mm:ss')
const argValues = msg.split('|')
argValues.forEach(item => {
state.msgs.push({
name: time,
value: [time, item.split(':')[1], item.split(':')[0], devId],
})
})
}
}
const getters = {
doneMsg: state => {
return state.msgs
}
}
拿到實時消息周遊取出存入state中,這裡聲明doneMsg這個getters,友善在監控頁面直接監聽,監控頁面寫法:
前端遇到的問題
首頁左側菜單欄頁面重新整理時高亮丢失
解決辦法是:在每個router的meta中定義activeItem字段,表示目前路由對應高亮的左側菜單:
面包屑導航動态改變
解決辦法是:監聽$route路由對象,重新設定導航内容:
後端
後端接口使用restful風格,提供OAuth2授權,基于eggjs、mysql開發:
Eggjs中使用koa2中間件
其實隻需要在config.default.js中設定中間件:
// add your config here
config.middleware = ['errorHandler', 'auth'];
然後再在app/middleware目錄下建立一個同名檔案,比如:err_handler.js,然後寫入中間件内容即可。
使用koa2中間件,直接引入:
module.exports = require('koa-jwt')
使用自定義中間件,寫法如下:
module.exports = () => {
return (ctx, next) => {
return next().catch (err => {
console.log('err: ', 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 = { error };
if (status === 422) {
ctx.body.error_description = err.errors;
}
ctx.status = status;
})
}
};
關于路由
項目路由不算複雜,rest風格路由定義也比較簡單:
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
// OAuth controller
app.get('/oauth2', controller.oauth.authorize);
app.all('/oauth2/token', app.oAuth2Server.token(), 'oauth.token'); // 擷取token
app.all('/oauth2/authorize', app.oAuth2Server.authorize()); // 擷取授權碼
app.all('/oauth2/authenticate', app.oAuth2Server.authenticate(), 'oauth.authenticate'); // 驗證請求
// rest接口
router.post('/api/v1/users/login', controller.v1.users.login);
router.post('/api/v1/users/logout', controller.v1.users.logout);
router.post('/api/v1/tools/upload', controller.v1.tools.upload);
router.resources('users', '/api/v1/users', controller.v1.users);
...其它接口省略
};
Jwt驗證
前後端接口統一采用jwt驗證,使用者登入成功時調用jwt sign服務生成token傳回:
const ctx = this.ctx
ctx.validate(users_rules.loginRule)
const {email, password} = ctx.request.body
const user = await ctx.model.User.getUserByArgs({email}, '')
if (!user) {
ctx.throw(404, 'email not found')
}
if (!(ctx.service.user.compareSync(password, user.hashedPassword))) {
ctx.throw(404, 'password wrong')
}
delete user.dataValues.hashedPassword
// 發送登入通知
msgNoticePub({msg: `使用者${user.email}在${moment().format('YYYYMMDD hh:mm:ss')}登入系統,點選檢視使用者資訊`, type: 'login'})
ctx.body = {
user,
token: await ctx.service.auth.sign(user) // 生成jwt token
}
這裡的auth.sign的service寫法如下:
const Service = require('egg').Service;
const jwt = require('jsonwebtoken')
class AuthService extends Service {
sign(user) {
let userToken = {
id: user.id
}
const token = jwt.sign(userToken, this.app.config.auth.secret, {expiresIn: '7d'})
return token
}
}
module.exports = AuthService;
Postal.js釋出訂閱
使用postal.js釋出訂閱,確定代碼子產品清晰,postal的釋出訂閱模式簡單如下:
postal.publish({ // 動態讓客戶端訂閲
channel: "msg",
topic: "item.notice",
data: {...data} // 發送的消息 {msg: "xxx裝置掉線了...."}
})
// 動态給前端推送消息
postal.subscribe({
channel: "msg",
topic: "item.notice",
callback: function (data, envelope) {
client.publish('msgNotice', JSON.stringify(data)) // 向前端釋出消息
console.log('向前端推送消息成功:', JSON.stringify(data))
}
})
Model模型定義
eggjs下定義資料庫資料模型比較簡單,在app/model目錄下建立任意檔案,如下是定義一個role模型:
'use strict'
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
const Role = app.model.define('role', {
role: {type: STRING, allowNull: false, unique: true}, // 角色名英文
roleName: {type: STRING, allowNull: false, unique: true}, // 角色名稱(中文)
pid: TEXT, // 權限id集合
permission: TEXT // 權限url集合
}, {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
freezeTableName: true
});
return Role;
};
關于部署
eggjs還是比較nice的一個架構,部署時可以擺脫pm2,egg-cluster也比較穩定,适合直接線上部署,直接上線後:
npm start // 啟動應用
npm stop // 停止應用
nginx部署前端也比較簡單就不說明了,簡單記錄就這麼多,有機會再分享。
作者:
西安-晁州出處:https://vipzhou.cn
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接。如有問題,可以郵件:[email protected]
QQ:1419901425 聯系我
如果喜歡我的文章,請關注我的公衆号 Coding雜貨鋪