业余时间用eggjs、vuejs开发了一个设备管理系统,经过mqtt协议上传设备数据至web端实时展示,包含设备参数分析、发送设备报警等模块。收获仍是挺多的,特别是vue的学习,这里简单记录一下:css
源码地址:https://github.com/caiya/vuejs-admin,写文不易,有帮助的话麻烦给个star,感谢!前端
前端:vue、vuex、vue-router、element-ui、axios、mqttjs
后端:eggjs、mysql、sequlize、restful、oauth2.0、mqtt、jwtvue
- 用户模块(用户管理,用户增删改查)
- 设备模块(设备管理、设备参数监控、设备参数记录、设备类别管理、参数管理等)
- 受权模块(引入OAuth2.0受权服务,方便将接口以OAuth提供第三方)
- 消息模块(用户申请帮助消息、设备参数告警消息等)
登陆页:node
主页:mysql
设备页:webpack
设备参数监控页:ios
前端使用vue-cli脚手架构建,基本目录结构以下:nginx
vue项目的入口文件,这里主要是引入iconfont、element-ui、echarts、moment、vuex等模块。git
// 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/>' })
注意: 一、引入比较大的模块好比echarts时,尽可能手动按需进行模块导入,节省打包文件大小 二、通常经过将模块好比moment挂载到Vue的prototype上面,这样就能够在任意vue组件中使用*this.$moment*进行moment操做了 三、iconfont是阿里的图标样式,下载下来后放入assets中再引入便可
vuex引入的时候采用了模块话引入,入口文件代码为:github
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'
注意: 一、mutations的名称定义时遵循官方,通常定义为常量 二、state的数据只有经过mutation才能操做,不能直接在组件中设置state,不然无效 三、mutation中的操做都是同步操做,异步操做或网络请求或同时多个mutation操做能够放入action中进行 四、用户信息、登陆token通常放入h5的localStorage,这样刷新页面保证关键数据不丢失 五、vuex中的getters至关于state的计算属性,监听state数据变更时可使用getters
路由模块基本使用:
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是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 }
注意: 一、经过baseURL配置项能够配置接口的基础path 二、经过request的interceptors,能够实现任意请求前先判断本地有无token,有的话写入header或query等地方,从而实现token发送 三、经过response的interceptors能够对响应数据作进一步处理,好比401或403跳转至登陆页、报错时直接reject返回err信息等 四、基本的rest请求方式代码封装基本一致,只是method不一样而已
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() }
注意: 一、前台应用做为一个mqtt客户端,后台也做为一个客户端,全部的实时设备消息先后端都能接收到,前端负责展示层、后端负责持久层 二、先后端只需监听/devices/#主题便可,全部的设备消息都发送到/devices/设备id,这样先后端获取topic名称便可判断当前消息来源于哪一个设备 三、mqtt连接error时采用client.reconnect()进行重连操做 四、mqtt还负责用户登陆、退出之类的消息推送,收到消息直接调用element-ui中的Notification提示便可 五、设备参数实时消息mqtt接收到后存入vuex的state中,各个组件再使用getters监听取值再实时图表展现
设备端发送的实时参数消息发送至主题/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开发:
其实只须要在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 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的发布订阅模式简单以下:
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)) } })
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部署前端也比较简单就不说明了,简单记录就这么多,有机会再分享。