適合新手或前端學習 node.js 作後臺的起步教程javascript
header
基本配置說明這裏我使用 typescript
去編寫的理由是由於很是好用的類型提示和代碼追蹤,因此在純 javascript
編程的項目中,typescript
是最好維護和閱讀的,這裏建議使用 vs code 這個代碼編輯器。第一次寫後臺應用,因此編程思路可能跟純後臺的編程思惟有所不一樣,不過我習慣標準的 jsdoc
註釋規範去編碼,因此應該是不存在代碼閱讀難度的。html
代碼地址:node-koa前端
先來看下目錄結構java
cd project
並建立 src
目錄mkdir src
複製代碼
package.json
,以後的全部配置和命令都會寫在裏面npm init
複製代碼
koa
和對應的路由 koa-router
npm install koa koa-router
複製代碼
TypeScript
對應的類型檢測提示npm install --save-dev @types/koa @types/koa-router
複製代碼
TypeScript
熱更新編譯npm install --save-dev typescript ts-node nodemon
複製代碼
ts-node
和 nodemon
這兩個須要全局安裝才能執行熱更新的命令npm install -g -force ts-node nodemon
複製代碼
package.json
設置"scripts": {
"start": "tsc && node dist/index.js",
"watch-update": "nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts"
},
複製代碼
npm watch-update
那就執行 nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts
window
環境下的問題仍是 npm
的問題,項目首次建立並執行的時候,全部依賴均可以本地安裝而且 npm watch-update
也能夠完美執行可是再次打開項目的時候就出錯了,目前還沒找到緣由,不過以上方法能夠解決node
koa-body
中間件做爲解析POST傳參和上傳圖片用npm install koa-body
複製代碼
modules/config.ts
項目設置mysql
class ModuleConfig {
/** 端口號 */
public readonly port = 1995;
/** 數據庫配置 */
public readonly db = {
host: 'localhost',
user: 'root',
password: 'root',
/** 數據庫名 */
database: 'test',
/** 連接上限次數 */
connection_limit: 10
}
/** 上傳圖片存放目錄 */
public readonly upload_path = 'public/upload/images/';
/** 上傳圖片大小限制 */
public readonly upload_img_size = 5 * 1024 * 1024;
// formData.append('img', file)
/** 前端上傳圖片時約定的字段 */
public readonly upload_img_name = 'img';
/** 用戶臨時表 */
public readonly user_file = 'public/user.json';
/** token 長度 */
public readonly token_size = 28;
}
/** 項目配置 */
const config = new ModuleConfig();
export default config;
複製代碼
header
基本配置說明index.ts
文件下git
import * as Koa from 'koa';
import * as koaBody from 'koa-body';
import config from './modules/config'; // 項目配置
import router from './api/main'; // 路由模塊,後面有說
import stateInfo from './modules/state'; // 自定義的請求返回格式模塊
import session from './modules/session'; // 自定義的 session 模塊,後面有說
import './api/apiUser'; // 用戶模塊
import './api/apiUpload'; // 上傳文件模塊
import './api/apiTest'; // 基礎測試模塊
import './api/apiTodo'; // 用戶列表模塊
const App = new Koa();
// 先統一設置請求頭信息...
App.use(async (ctx, next) => {
ctx.set({
'Access-Control-Allow-Origin': '*', // 打開跨域
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept Authorization',
});
// 若是前端設置了 XHR.setRequestHeader('Content-Type', 'application/json')
// ctx.set 就必須攜帶 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept Authorization'
// 若是前端設置了 XHR.setRequestHeader('Authorization', 'xxxx') 一樣的就是上面 Authorization 字段
// 而且這裏要轉換一下狀態碼
if (ctx.request.method === 'OPTIONS') {
ctx.response.status = 200;
}
try {
await next();
} catch (err) {
ctx.response.status = err.statusCode || err.status || 500;
ctx.response.body = {
message: err.message
}
}
});
// 使用中間件處理 post 傳參 和上傳圖片
App.use(koaBody({
multipart: true,
formidable: {
maxFileSize: config.upload_img_size
}
}));
// 開始使用路由
App.use(router.routes())
複製代碼
完事以後運行項目 (代碼熱更新)github
nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts
複製代碼
先來定義一個路由而後導出來使用,後面可能會有多個模塊的接口,因此所有都是基於這個去使用,index.ts
也是 api/main.ts
文件下sql
import * as Router from 'koa-router';
/** api路由模塊 */
const router = new Router();
export default router;
複製代碼
api/apiTest.ts
文件下,來寫個不用鏈接數據庫的 GET
和 POST
請求做爲測試用,而且接收參數,寫好以後在前端請求,前端的代碼我就不作說明了,看註釋應該懂,聲明文件都寫好了。寫完以後再到前端頁面請求一下是否正確跑通了。typescript
import router from './main';
import html from '../modules/template';
import stateInfo from '../modules/state'; // 這個是我寫好的狀態數據返回到前端的一個統一格式模塊,具體看代碼,這裏不作過多描述。
// '/*' 監聽所有
router.get('/', (ctx, next) => {
// 指定返回類型
ctx.response.type = 'html';
ctx.body = html;
console.log('根目錄');
// 302 重定向到其餘網站
// ctx.status = 302;
// ctx.redirect('https://www.baidu.com');
})
// get 請求
router.get('/getHome', (ctx, next) => {
/** 接收參數 */
const params: object | string = ctx.query || ctx.querystring;
console.log('get /getHome', params);
ctx.body = stateInfo.getSuccessData({
method: 'get',
port: 1995,
time: Date.now()
});
})
// post 請求
router.post('/sendData', (ctx, next) => {
/** 接收參數 */
const params: object = ctx.request.body || ctx.params;
console.log('post /sendData', params);
const result = {
data: '請求成功'
}
ctx.body = stateInfo.getSuccessData(result, 'post success')
})
複製代碼
modules/api/apiUpload.ts
文件下,這裏我尚未用到七牛或者其餘平臺的接口,只是簡單模擬了一個上傳異步的方法代替,固然,有老哥作過的能夠告訴我~
import router from './main';
import * as fss from 'fs';
import * as path from 'path';
import config from '../modules/config';
import stateInfo from '../modules/state';
// 上傳圖片
router.post('/uploadImg', async (ctx, next) => {
const file = ctx.request.files[config.upload_img_name];
let fileName = ctx.request.body.name || `img_${Date.now()}`;
fileName = `${fileName}.${file.name.split('.')[1]}`;
// 建立可讀流
const render = fs.createReadStream(file.path);
const filePath = path.join(config.upload_path, fileName); // 這裏說明一下,文件是默認保存到配置好的本地目錄下,至於上到七牛那邊話,可能須要其餘操做,目前還沒作到那一步
const fileDir = path.join(config.upload_path);
// 判斷文件所在目錄是否存在(這兩行代碼能夠忽略不寫)
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir);
}
// 建立寫入流
const upStream = fs.createWriteStream(filePath);
render.pipe(upStream);
const result = {
image: '',
file: ''
}
/** 模擬上傳到七牛雲 */
function uploadApi() {
return new Promise(function (resolve, reject) {
const delay = Math.floor(Math.random() * 5) * 100 + 500;
setTimeout(() => {
result.image = `http://${ctx.headers.host}/${config.upload_path}${fileName}`;
result.file = `${config.upload_path}${fileName}`;
resolve();
}, delay);
});
}
await uploadApi();
ctx.body = stateInfo.getSuccessData(result, '上傳成功');
})
複製代碼
modules/api/mysql.ts
文件下,這裏封裝了一個數據庫增刪改查的方法,以後全部的數據庫操做都是經過這個方法去完成。
import * as mysql from 'mysql';
import config from './config';
import { mysqlErrorType } from './interfaces'; // 這個是TS數據類型模塊,這裏也不作說明,用過TS的應該知道
/** 數據庫 */
const pool = mysql.createPool({
host: config.db.host,
user: config.db.user,
password: config.db.password,
database: config.db.database
});
/** * 數據庫增刪改查 * @param command 增刪改查語句 * @param value 對應的值 */
export default function query(command: string, value?: Array<any>): Promise<any> {
/** 錯誤信息 */
let errorInfo: mysqlErrorType = null;
return new Promise((resolve, reject) => {
pool.getConnection((error: any, connection) => {
if (error) {
errorInfo = {
info: error,
message: '數據庫鏈接出錯'
}
reject(errorInfo);
} else {
const callback: mysql.queryCallback = (error: any, results, fields) => {
connection.release();
if (error) {
errorInfo = {
info: error,
message: '數據庫增刪改查出錯'
}
reject(errorInfo);
} else {
resolve({ results, fields });
}
}
if (value) {
pool.query(command, value, callback);
} else {
pool.query(command, callback);
}
}
});
});
}
複製代碼
這裏我用到的本地服務是用(upupw)搭建,超簡單的操做,數據庫表工具是navicat
我是一個徹底不懂 mysql
的前端,因此這裏的操做我是邊問個人 PHP 同事邊作筆記邊操做的,因此就沒什麼好說的了,可能看這篇文章的你會知道這些。
user
表的格式
api/apiUser.ts
文件下
import router from './main';
import query from '../modules/mysql';
import stateInfo from '../modules/state';
import session from '../modules/session'; // 這個模塊是我按照本身的思路去自行寫的,等下再說明
import config from '../modules/config';
import { mysqlErrorType, mysqlQueryType, userInfoType } from '../modules/interfaces';
// 註冊
router.post('/register', async (ctx) => {
/** 接收參數 */
const params: userInfoType = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
/** 帳號是否可用 */
let validAccount = false;
// console.log('註冊傳參', params);
if (!/^[A-Za-z0-9]+$/.test(params.account)) {
return ctx.body = stateInfo.getFailData('註冊失敗!帳號必須爲6-12英文或數字組成');
}
if (!/^[A-Za-z0-9]+$/.test(params.password)) {
return ctx.body = stateInfo.getFailData('註冊失敗!密碼必須爲6-12英文或數字組成');
}
if (!params.name.trim()) {
params.name = '用戶未設置暱稱';
}
// 先查詢是否有重複帳號
await query(`select account from user where account = '${ params.account }'`).then((res: mysqlQueryType) => {
// console.log('註冊查詢', res);
if (res.results.length > 0) {
bodyResult = stateInfo.getFailData('該帳號已被註冊');
} else {
validAccount = true;
}
}).catch((error: mysqlErrorType) => {
// console.log('註冊查詢錯誤', error);
bodyResult = stateInfo.getFailData(error.message);
})
// 再寫入表格
if (validAccount) {
await query('insert into user(account, password, name) values(?,?,?)', [params.account, params.password, params.name]).then((res: mysqlQueryType) => {
// console.log('註冊寫入', res);
bodyResult = stateInfo.getSuccessData(params, '註冊成功');
}).catch((error: mysqlErrorType) => {
// console.log('註冊寫入錯誤', error);
bodyResult = stateInfo.getFailData(error.message);
})
}
ctx.body = bodyResult;
})
// 登陸
router.post('/login', async (ctx) => {
/** 接收參數 */
const params: userInfoType = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
// console.log('登陸', params);
if (params.account.trim() === '') {
return ctx.body = stateInfo.getFailData('登陸失敗!帳號不能爲空');
}
if (params.password.trim() === '') {
return ctx.body = stateInfo.getFailData('登陸失敗!密碼不能爲空');
}
// 先查詢是否有當前帳號
await query(`select * from user where account = '${ params.account }'`).then((res: mysqlQueryType) => {
// console.log('登陸查詢', res.results);
// 再判斷帳號是否可用
if (res.results.length > 0) {
const data: userInfoType = res.results[0];
// 最後判斷密碼是否正確
if (data.password == params.password) {
data.token = session.setRecord(data);
bodyResult = stateInfo.getSuccessData(data ,'登陸成功');
} else {
bodyResult = stateInfo.getFailData('密碼不正確');
}
} else {
bodyResult = stateInfo.getFailData('該帳號不存在,請先註冊');
}
}).catch((error: mysqlErrorType) => {
// console.log('登陸查詢錯誤', error);
bodyResult = stateInfo.getFailData(error.message);
})
ctx.body = bodyResult;
})
// 獲取用戶信息
router.get('/getUserInfo', async (ctx) => {
const token: string = ctx.header.authorization;
/** 接收參數 */
const params = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
console.log('getUserInfo', params, token);
if (token.length != config.token_size) {
return ctx.body = stateInfo.getFailData('token 不正確');
}
let state = session.updateRecord(token);
if (!state.success) {
return ctx.body = stateInfo.getFailData(state.message);
}
await query(`select * from user where account = '${ state.info.account }'`).then((res: mysqlQueryType) => {
// 判斷帳號是否可用
if (res.results.length > 0) {
const data: userInfoType = res.results[0];
bodyResult = stateInfo.getSuccessData(data);
} else {
bodyResult = stateInfo.getFailData('該帳號不存在,可能已經從數據庫中刪除');
}
}).catch((error: mysqlErrorType) => {
bodyResult = stateInfo.getFailData(error.message);
})
ctx.body = bodyResult;
})
// 退出登陸
router.get('/logout', ctx => {
const token: string = ctx.header.authorization;
/** 接收參數 */
const params = ctx.request.body;
console.log('logout', params, token);
if (token.length != config.token_size) {
return ctx.body = stateInfo.getFailData('token 不正確');
}
const state = session.removeRecord(token);
if (state) {
return ctx.body = stateInfo.getSuccessData('退出登陸成功');
} else {
return ctx.body = stateInfo.getFailData('token 不存在');
}
})
複製代碼
實現思路:利用js
內存進行讀寫用戶的token
信息,只在對內存寫的時候(異步寫入防止阻塞)把信息以json
格式寫入到json文件中,而後實例化的時候讀取上次紀錄的token
信息並把過時的剔除掉。
思路實現過程:
public/
目錄下新建一個user.json
的文件做爲一張臨時的 token 紀錄表ModuleSession
的模塊,userRecord
這個私有的屬實是 {} ,而後以 token
做爲key去存儲用戶信息,這個信息裏面帶有一個參數 online
意思是在線的時間,以後取值作判斷的時候會用到ModuleSession
的模塊中,對外暴露的只有3個方法:
setRecord
首次設置紀錄並返回 token 登陸用,而且紀錄在 userRecord
裏面,再寫入到臨時表 user.json
updateRecord
這個是每次請求的時候都會先執行的方法,用來判斷前端傳過來的 token
是否在 userRecord
裏面,如在存在再判斷 userRecord[token].online
及其餘操做,所有經過判斷就說明當前 token
沒問題而且更新 userRecord[token].online
爲當前時間,最後再寫入到臨時表去。這個過程可能比較複雜,仍是看代碼比較好理解點。removeRecord
這個邏輯就簡單了,直接從 userRecord
中刪除當前token,退出登陸用ModuleSession
在實例化時,先從 user.json
臨時表裏面讀取上次寫入的信息,而後剔除過期的token,保證數據同步。modules/session.ts
文件下
import * as fs from 'fs';
import config from './config';
import { userRecordType, userInfoType, sessionResultType } from '../modules/interfaces';
class ModuleSession {
constructor() {
this.init();
}
/** 效期(小時) */
private maxAge = 12;
/** 更新 & 檢測時間間隔(10分鐘) */
private interval = 600000;
/** 用戶 token 紀錄 */
private userRecord: userRecordType = {};
/** * 寫入文件 * @param obj 要寫入的對象 */
private write(obj?: userRecordType) {
const data = obj || this.userRecord;
// 同步寫入(貌似不必)
// fs.writeFileSync(config.user_file, JSON.stringify(data), { encoding: 'utf8' });
// 異步寫入
fs.writeFile(config.user_file, JSON.stringify(data), { encoding: 'utf8' }, (err) => {
if (err) {
console.log('session 寫入失敗', err);
} else {
console.log('session 寫入成功');
}
})
}
/** 從本地臨時表裏面初始化用戶狀態 */
private init() {
const userFrom = fs.readFileSync(config.user_file).toString();
this.userRecord = userFrom ? JSON.parse(userFrom) : {};
this.checkRecord();
// console.log('token臨時表', userFrom, this.userRecord);
}
/** 生成 token */
private getToken(): string {
const getCode = (n: number): string => {
let codes = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789';
let code = '';
for (let i = 0; i < n; i++) {
code += codes.charAt(Math.floor(Math.random() * codes.length));
}
if (this.userRecord[code]) {
return getCode(n);
}
return code;
}
const code = getCode(config.token_size);
return code;
}
/** 定時檢測過時的 token 並清理 */
private checkRecord() {
const check = () => {
const now = Date.now();
let isChange = false;
for (const key in this.userRecord) {
if (this.userRecord.hasOwnProperty(key)) {
const item = this.userRecord[key];
if (now - item.online > this.maxAge * 3600000) {
isChange = true;
delete this.userRecord[key];
}
}
}
if (isChange) {
this.write();
}
}
// 10分鐘檢測一次
setInterval(check, this.interval);
check();
}
/** * 設置紀錄並返回 token * @param data 用戶信息 */
public setRecord(data: userInfoType) {
const token = this.getToken();
data.online = Date.now();
this.userRecord[token] = data;
this.write();
return token;
}
/** * 更新並檢測 token * @param token */
public updateRecord(token: string) {
let result: sessionResultType = {
message: '',
success: false,
info: null
}
if (!this.userRecord.hasOwnProperty(token)) {
result.message = 'token 已過時或不存在';
return result;
}
const userInfo = this.userRecord[token];
const now = Date.now();
if (now - userInfo.online > this.maxAge * 3600000) {
result.message = 'token 已過時';
return result;
}
result.message = 'token 經過驗證';
result.success = true;
result.info = userInfo;
// 更新在線時間並寫入臨時表
// 這裏優化一下,寫入和更新的時間間隔爲10分鐘,避免頻繁寫入
if (now - userInfo.online > this.interval) {
this.userRecord[token].online = now;
this.write();
}
return result;
}
/** * 從紀錄中刪除 token 紀錄(退出登陸時用) * @param token */
public removeRecord(token: string) {
if (this.userRecord.hasOwnProperty(token)) {
delete this.userRecord[token];
this.write();
return true;
} else {
return false;
}
}
}
/** session 模塊 */
const session = new ModuleSession();
export default session;
複製代碼
由於以後的接口都是依賴 token
的,因此這裏我把 token
判斷的代碼抽到 index.ts
中去了
// 先統一設置請求配置 => 跨域,請求頭信息...
App.use(async (ctx, next) => {
... // 以前的代碼不變
if (ctx.request.method === 'OPTIONS') {
ctx.response.status = 200;
} else {
/** 過濾掉不用 token 也能夠請求的接口 */
const rule = /\/register|\/login|\/uploadImg|\/getData|\/postData/;
/** 請求路徑 */
const path = ctx.request.path;
// 這裏進行全局的 token 驗證判斷
if (!rule.test(path) && path != '/') {
const token: string = ctx.header.authorization;
if (token.length != config.token_size) {
return ctx.body = stateInfo.getFailData(config.token_tip);
}
const state = session.updateRecord(token);
if (!state.success) {
return ctx.body = stateInfo.getFailData(state.message);
}
// 設置 token 信息到上下文中給接口模塊裏面調用
ctx['the_state'] = state;
}
}
})
複製代碼
先來看下數據庫表結構
而後新建一個apiTodo.ts
做爲用戶列表的增刪改查接口模塊
import router from './main';
import query from '../modules/mysql';
import stateInfo from '../modules/state';
import { mysqlQueryType, mysqlErrorType, sessionResultType } from '../modules/interfaces';
// 獲取全部列表
router.get('/getList', async (ctx) => {
const state: sessionResultType = ctx['the_state'];
/** 返回結果 */
let bodyResult = null;
// console.log('getList');
// 這裏要開始連表查詢
await query(`select * from user_list where user_id = '${ state.info.id }'`).then((res: mysqlQueryType) => {
// console.log('/getList 查詢', res.results);
bodyResult = stateInfo.getSuccessData({
list: res.results.length > 0 ? res.results : []
});
}).catch((err: mysqlErrorType) => {
bodyResult = stateInfo.getFailData(err.message);
})
ctx.body = bodyResult;
})
// 添加列表
router.post('/addList', async (ctx) => {
const state: sessionResultType = ctx['the_state'];
/** 接收參數 */
const params = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
if (!params.content) {
return ctx.body = stateInfo.getFailData('添加的列表內容不能爲空!');
}
// 寫入列表
await query('insert into user_list(list_text, list_time, user_id) values(?,?,?)', [params.content, new Date().toLocaleDateString(), state.info.id]).then((res: mysqlQueryType) => {
// console.log('寫入列表', res.results.insertId);
bodyResult = stateInfo.getSuccessData({
id: res.results.insertId
}, '添加成功');
}).catch((err: mysqlErrorType) => {
// console.log('註冊寫入錯誤', err);
bodyResult = stateInfo.getFailData(err.message);
})
ctx.body = bodyResult;
})
// 修改列表
router.post('/modifyList', async (ctx) => {
const state: sessionResultType = ctx['the_state'];
/** 接收參數 */
const params = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
if (!params.id) {
return ctx.body = stateInfo.getFailData('列表id不能爲空');
}
if (!params.content) {
return ctx.body = stateInfo.getFailData('列表內容不能爲空');
}
// 修改列表
await query(`update user_list set list_text='${params.content}', list_time='${new Date().toLocaleDateString()}' where list_id='${params.id}'`).then((res: mysqlQueryType) => {
console.log('修改列表', res);
if (res.results.affectedRows > 0) {
bodyResult = stateInfo.getSuccessData({}, '修改爲功');
} else {
bodyResult = stateInfo.getFailData('列表id不存在');
}
}).catch((err: mysqlErrorType) => {
// console.log('註冊寫入錯誤', err);
bodyResult = stateInfo.getFailData(err.message);
})
ctx.body = bodyResult;
})
// 刪除列表
router.post('/deleteList', async (ctx) => {
const state: sessionResultType = ctx['the_state'];
/** 接收參數 */
const params = ctx.request.body;
/** 返回結果 */
let bodyResult = null;
// 從數據庫中刪除
await query(`delete from user_list where list_id=${params.id} and user_id = ${state.info.id}`).then((res: mysqlQueryType) => {
console.log('從數據庫中刪除', res);
if (res.results.affectedRows > 0) {
bodyResult = stateInfo.getSuccessData({}, '刪除成功');
} else {
bodyResult = stateInfo.getFailData('當前列表id不存在或已刪除');
}
}).catch((err: mysqlErrorType) => {
console.log('從數據庫中刪除失敗', err);
bodyResult = stateInfo.getFailData(err.message);
})
ctx.body = bodyResult;
})
複製代碼
待更新...
項目的全部前端調試都是寫好的,由於是後端的分享,因此這裏作代碼說明太費時間了。前端的網絡請求和其餘一下基本操做能夠看開頭個人博客,基本的網頁操做所有都有了,須要的老哥能夠看下,最後能夠的話給個人 GitHub 點個 star 吧~