zoukankan      html  css  js  c++  java
  • vuejs、eggjs、mqtt全栈式开发设备管理系统

    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部署前端也比较简单就不说明了,简单记录就这么多,有机会再分享。

  • 相关阅读:
    CentOS7 PXE安装批量安装操作系统
    004_MySQL 主从配置
    CentOS 桥接网卡配置
    玩转 Jupyter Notebook (CentOS)
    搭建专属于自己的Leanote云笔记本
    wetty 安装(web+tty)
    wget命令详解
    linux 下find---xargs以及find--- -exec结合使用
    Linux 交换分区swap
    Linux 时区的修改
  • 原文地址:https://www.cnblogs.com/vipzhou/p/8481920.html
Copyright © 2011-2022 走看看