vuejs、eggjs、mqtt


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部署前端也比較簡單就不說明了,簡單記錄就這么多,有機會再分享。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM