vuejs、eggjs、mqtt全棧式開發設備管理系統

vuejs、eggjs、mqtt全棧式開發簡單設備管理系統

業餘時間用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、jwt
vue

  • 用戶模塊(用戶管理,用戶增刪改查)
  • 設備模塊(設備管理、設備參數監控、設備參數記錄、設備類別管理、參數管理等)
  • 受權模塊(引入OAuth2.0受權服務,方便將接口以OAuth提供第三方)
  • 消息模塊(用戶申請幫助消息、設備參數告警消息等)

效果圖(對一個後端css永遠是內傷)

登陸頁:node

主頁:mysql

設備頁:webpack

設備參數監控頁:ios

前臺

項目結構

前端使用vue-cli腳手架構建,基本目錄結構以下:nginx

main.js入口

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引入

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

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 }
注意:
    一、經過baseURL配置項能夠配置接口的基礎path
    二、經過request的interceptors,能夠實現任意請求前先判斷本地有無token,有的話寫入header或query等地方,從而實現token發送
    三、經過response的interceptors能夠對響應數據作進一步處理,好比401或403跳轉至登陸頁、報錯時直接reject返回err信息等
    四、基本的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()
}
注意:
    一、前臺應用做爲一個mqtt客戶端,後臺也做爲一個客戶端,全部的實時設備消息先後端都能接收到,前端負責展示層、後端負責持久層
    二、先後端只需監聽/devices/#主題便可,全部的設備消息都發送到/devices/設備id,這樣先後端獲取topic名稱便可判斷當前消息來源於哪一個設備
    三、mqtt連接error時採用client.reconnect()進行重連操做
    四、mqtt還負責用戶登陸、退出之類的消息推送,收到消息直接調用element-ui中的Notification提示便可
    五、設備參數實時消息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部署前端也比較簡單就不說明了,簡單記錄就這麼多,有機會再分享。

相關文章
相關標籤/搜索