node.js+vue.js搭建程式設計類課程教學輔助系統
畢業才剛剛兩個多月而已,現在想想大學生活是那麼的遙不可及,感覺已經過了好久好久,社會了兩個月才明白學校的好啊。。。額,扯遠了,自從畢業開始就想找個時間寫下畢設的記錄總結,結果找了好久好久到今天才開始動筆。
我的畢業設計題目是:教學輔助系統的設計與實現,,是不是很俗。。。至於為啥是這個題目呢,完全是被導師坑了。。。。。
1、需求分析
拿到這個題目想著這個可能被做了無數次了,就像著哪裡能夠做出點創新,,最後強行創新出了一個個性化組題(根據學生水平出題)和徽章激勵(達到某個要求給予一個徽章)。最後就產生了如下需求,系統有學生端和管理端:
學生端:
- 個人資料設定
- 徽章激勵機制
- 檢視課程資訊,下載課程資料
- 知識點檢測及針對性訓練
- 線上作業,考試
- 線上答疑,向老師或者學生提問
管理端:
- 課程管理,使用者管理(需要管理員許可權)
- 課程資訊管理
- 課程公告管理
- 題庫管理,支援單選,多選,填空,程式設計題,支援題目編組
- 釋出作業,包括個性組題和手動組題
- 釋出考試,包括隨機出題和手動出題
- 自動判題,支援程式設計題判重
- 線上答疑,給學生解答
- 統計分析,包含測試統計和課程統計
洋洋灑灑需求列了一大堆,後面才發現是給自己挖坑,,答辯老師一看這類的題目就不感興趣了,不論你做的咋樣(況且我的演講能力真的很一般),最後累死累活寫了一大堆功能也沒太高的分,,不過倒是讓我的系統設計能力和程式碼能力有了不少的提高。
2、架構選擇
大三的時候瞭解到Node.js這個比較“奇葩"的非同步語言,再加上在公司實習了三個月也是用的node開發,對node已經比較熟悉了,於是就用它做了後臺,前端用最近比較火的vue.js做單頁應用。當時還想著負載均衡啥的,就沒有用傳統的session,cookie機制,轉而用jwt做的基於token的身份認證,同時後臺介面也是類Restful風格的(因為純正的Rest介面太難設計了)。
總的來說後臺用了以下技術和框架:
總的來說後臺用了以下技術和框架:
- 語言:Node.js
- web框架:kOA
- 前後臺傳輸協議:jwt
- 快取:redis
- 資料庫:mysql
- 程式碼判重:SIM
前臺技術如下:
- 框架:Vue.js
- UI框架:Element-UI
- 圖表元件:G2
3、系統基礎框架搭建
本系統是前後端分離的,下面分別介紹前後端的實現基礎。
1、後臺
一個web後臺最重要的無非那麼幾個部分:路由;許可權驗證;資料持久化。
a、路由
KOA作為一個web框架其實它本身並沒有提供路由功能,需要配合使用koa-router來實現路由,koa-router以類似下面這樣的風格來進行路由:
KOA作為一個web框架其實它本身並沒有提供路由功能,需要配合使用koa-router來實現路由,koa-router以類似下面這樣的風格來進行路由:
const app = require("koa");
const router = require("koa-router");
router.get("/hello",koa=>{
koa.response="hello";
});
app.use(router.routes())
顯然這樣在專案中是很不方便的,如果每個路由都要手動進行掛載,很難將每個檔案中的路由都掛載到一個router中。因此在參考網上的實現後,我寫了一個方法在啟動時自動掃描某個資料夾下所有的路由檔案並掛載到router中,程式碼如下:
const fs = require('fs');
const path = require('path');
const koaBody = require('koa-body');
const config = require('../config/config.js');
function addMapping(router, filePath) {
let mapping = require(filePath);
for (let url in mapping) {
if (url.startsWith('GET ')) {
let temp = url.substring(4);
router.get(temp, mapping[url]);
console.log(`----GET:${temp}`);
} else if (url.startsWith('POST ')) {
let temp = url.substring(5);
router.post(temp, mapping[url]);
console.log(`----POST:${temp}`);
} else if (url.startsWith('PUT ')) {
let temp = url.substring(4);
router.put(temp, mapping[url]);
console.log(`----PUT:${temp}`)
} else if (url.startsWith('DELETE ')) {
let temp = url.substring(7);
router.delete(temp, mapping[url]);
console.log(`----DELETE: ${temp}`);
} else {
console.log(`xxxxx無效路徑:${url}`);
}
}
}
function addControllers(router, filePath) {
let files = fs.readdirSync(filePath);
files.forEach(element => {
let temp = path.join(filePath, element);
let state = fs.statSync(temp);
if (state.isDirectory()) {
addControllers(router, temp);
} else {
if (!temp.endsWith('Helper.js')) {
console.log('\n--開始處理: ' + element + "路由");
addMapping(router, temp);
}
}
});
}
function engine(router, folder) {
addControllers(router, folder);
return router.routes();
}
module.exports = engine;
然後在index.js中use此方法:
const RouterMW = require("./middleware/controllerEngine.js");
app.use(RouterMW(router,path.join(config.rootPath, 'api')));
然後路由檔案以下面的形式編寫:
const knowledgePointDao = require('../dao/knowledgePointDao.js');
/**
* 返回某門課的全部知識點,按章節分類
*/
exports["GET /course/:c_id/knowledge_point"] = async (ctx, next) => {
let res = await knowledgePointDao.getPontsOrderBySection(ctx.params.c_id);
ctx.onSuccess(res);
}
//返回某位學生知識點答題情況
exports["GET /user/:u_id/course/:c_id/knowledge_point/condition"]=async(ctx,next)=>{
let {u_id,c_id}=ctx.params;
let res = await knowledgePointDao.getStudentCondition(u_id,c_id);
ctx.onSuccess(res);
}
b、許可權驗證
許可權管理是一個系統最重要的部分之一,目前主流的方式為基於角色的許可權管理, 一個使用者對應多個角色,每個角色對應多個許可權(本系統中每個使用者對應一個身份,每個身份對應多個角色)。我們的系統如何實現的呢?先從登入開始說起,本系統拋棄了傳統的cookie,session模式,使用json web token(JWT)來做身份認證,使用者登入後返回一個token給客戶端,程式碼如下所示:
//生成隨機鹽值
let str = StringHelper.getRandomString(0, 10);
//使用該鹽值生成token
let token = jwt.sign({
u_id: userInfo.u_id,
isRememberMe
}, str, {
expiresIn: isRememberMe ? config.longTokenExpiration:config.shortTokenExpiration
});
//token-鹽值存入redis,如想讓該token過期,redis中清楚該token鍵值對即可
await RedisHelper.setString(token, str, 30 * 24 * 60 * 60);
res.code = 1;
res.info = '登入成功';
res.data = {
u_type: userInfo.u_type,
u_id: userInfo.u_id,
token
};
以後每次客戶端請求都要在header中設定該token,然後每次服務端收到請求都先驗證是否擁有許可權,驗證程式碼使用router.use(auth)
,掛載到koa-router中,這樣每次在進入具體的路由前都要先執行auth方法進行許可權驗證,主要驗證程式碼邏輯如下:
/**
* 1 驗證成功
* 2 登入資訊無效 401
* 3 已登入,無操作許可權 403
* 4 token已過期
*/
let verify = async (ctx) => {
let token = ctx.headers.authorization;
if (typeof (token) != 'string') {
return 2;
}
let yan = await redisHelper.getString(token);
if (yan == null) {
return 2;
}
let data;
try {
data = jwt.verify(token, yan);
} catch (e) {
return 2;
}
if (data.exp * 1000 < Date.now()) {
return 4;
}
//判斷是否需要重新整理token,如需要重新整理將新token寫入響應頭
if (!data.isRememberMe && (data.exp * 1000 - Date.now()) < 30 * 60 * 1000) {
//token有效期不足半小時,重新簽發新token給客戶端
let newYan = StringHelper.getRandomString(0, 10);
let newToken = jwt.sign({
u_id: data.u_id,
isRememberMe:false
}, newYan, {
expiresIn: config.shortTokenExpiration
});
// await redisHelper.deleteKey(token);
await redisHelper.setString(newToken, newYan,config.shortTokenExpiration);
ctx.response.set('new-token', newToken);
ctx.response.set('Access-Control-Expose-Headers','new-token');
}
//獲取使用者資訊
let userInfoKey = data.u_id + '_userInfo';
let userInfo = await redisHelper.getString(userInfoKey);
if (userInfo == null || Object.keys(userInfo).length != 3) {
userInfo = await mysqlHelper.first(`select u_id,u_type,j_id from user where u_id=?`, data.u_id);
await redisHelper.setString(userInfoKey, JSON.stringify(userInfo), 24 * 60 * 60);
}else{
userInfo = JSON.parse(userInfo);
}
ctx.userInfo = userInfo;
//更新使用者上次訪問時間
mysqlHelper.execute(`update user set last_login_time=? where u_id=?`,Date.now(),userInfo.u_id);
//管理員擁有全部許可權
if (userInfo.u_type == 0) {
return 1;
}
//獲取該使用者型別許可權
let authKey = userInfo.j_id + '_authority';
let urls = await redisHelper.getObject(authKey);
// let urls = null;
if (urls == null) {
urls = await mysqlHelper.row(`
select b.r_id,b.url,b.method from jurisdiction_resource a inner join resource b on a.r_id = b.r_id where a.j_id=?
`, userInfo.j_id);
let temp = {};
urls.forEach(item => {
temp[item.url + item.method] = true;
})
await redisHelper.setObject(authKey, temp);
urls = temp;
}
//判斷是否擁有許可權
if (urls.hasOwnProperty(ctx._matchedRoute.replace(config.url_prefix, '') + ctx.method)) {
return 1;
} else {
return 3;
}
}
根據使用者id獲取使用者身份id,根據使用者身份id從redis中獲取擁有的許可權,如為null,從mysql資料庫中拉取,並存入redis中,然後判斷是否擁有要訪問的url許可權。
c、資料持久化
本系統中使用mysql儲存資料,redis做快取,由於當時操作庫不支援promise,故對它兩做了個promise封裝,方便程式碼中呼叫,參見:MysqlHelper,RedisHelper.js。
2、前端
前端使用vue-cli構建vue專案,主要用到了vue-router,element-ui,axios這三個元件。
a、路由組織
單頁應用需要前端自己組織路由。本系統將路由分成了三個部分:公共,管理端,學生端。index.js如下:
export default new Router({
mode: 'history',
base: '/app/',
routes: [{
path: '',
name: 'indexPage',
component: IndexPage
},
{
path: '/about',
name: 'about',
component: About
},
Admin,
Client,
Public,
{
path: '*',
name: "NotFound",
component: NotFound
}
]
})
其中的Admin,Client,Public分別為各部分的路由,以子路由的形式一級級組織。如下所示:
export default {
path: "/client",
component: Client,
beforeEnter: (to, from, next) => {
if (getClientUserInfo() == null) {
next({
path: '/public/client_login',
replace: true,
})
} else {
next();
}
},
children: [{
//學生端主頁
path: '',
name: "ClientMain",
component: ClientHome
}, {
//學生個人資料頁面
path: 'person/student_info',
name: "StudentInfo",
component: StudentInfo
}, {
//公告頁面
path: 'course/:c_id/announcement',
name: 'Main',
component: Announcement
}, {
//課程基本資訊
path: 'course/:c_id/base',
component: ClientMain,
children: [{
path: 'course_intro',
name: "ClientCourseIntro",
component: CourseIntro
}, {
path: 'exam_type',
name: "ClientExamType",
component: ExamType
}
......
其中的beforEnter為鉤子函式,每次進入路由時執行該函式,用於判斷使用者是否登入。這裡涉及到了一個前端鑑權的概念,由於前後端分離了,前端也必須做鑑權以免使用者進入到了無許可權的頁面,這裡我只是簡單的做了登入判斷,更詳細的url鑑權也可實現,只需在對應的鉤子函式中進行鑑權操作,更多關於鉤子函式資訊點選這裡。
b、請求封裝
前端還有一個比較重要的部分是ajax請求的處理,請求處理還保護錯誤處理,有些錯誤只需要統一處理,而有些又需要獨立的處理,這樣一來就需要根據業務需求進行一下請求封裝了,對結果進行處理後再返回給呼叫者。我的實現思路是發起請求,收到響應後先對錯誤進行一個同意彈窗提示,然後再將錯誤繼續向後傳遞,呼叫者可選擇性的捕獲錯誤進行鍼對性處理,主要程式碼如下:
request = (url, method, params, form, isFormData, type) => {
let token;
if (type == 'admin')
token = getToken();
else
token = getClientToken();
let headers = {
'Authorization': token
};
if (isFormData) {
headers['Content-Type'] = "multipart/form-data";
}
return new Promise((resolve, reject) => {
axios({
url,
method,
params,
data: form,
headers,
// timeout:2000
}).then(res => {
resolve(res.data);
//檢查是否有更新token
// console.log(res);
if (res.headers['new-token'] != undefined) {
console.log('set new token');
if (vm.$route.path.startsWith('/admin')){
localStorage.setItem("token",res.headers['new-token']);
window.token = undefined;
}else if(vm.$route.path.startsWith('/client')){
localStorage.setItem("clientToken",res.headers['new-token']);
window.clientToken = undefined;
}
}
}).catch(err => {
reject(err);
if (err.code == 'ECONNABORTED') {
alertNotify("錯誤", "請求超時", "error");
return;
}
if (err.message == 'Network Error') {
alertNotify("錯誤", "無法連線伺服器", 'error');
return;
}
if (err.response != undefined) {
switch (err.response.status) {
case 401:
if (window.isGoToLogin) {
return;
}
//使用該變量表示是否已經彈窗提示了,避免大量未登入彈窗堆積。
window.isGoToLogin = true;
vm.$alert(err.response.data, "警告", {
type: "warning",
showClose: false
}).then(res => {
window.isGoToLogin = false;
if (vm.$route.path.startsWith('/admin/')) {
clearInfo();
vm.$router.replace("/public/admin_login");
} else {
clearClientInfo();
vm.$router.replace("/public/client_login");
}
});
break;
case 403:
alertNotify("Error:403", '拒絕執行:' + err.response.data, "error");
break;
case 404:
alertNotify("Error:404", "找不到資源:" + url.substr(0, url.indexOf('?')), 'error');
break;
case 400:
alertNotify("Error:400", "請求引數錯誤:" + err.response.data, 'error');
break;
case 500:
alertNotify("Error:500", "伺服器內部錯誤:" + err.response.data, 'error');
default:
console.log('存在錯誤未處理:' + err);
}
} else {
console.log(err);
}
})
})
}