适合新手或前端学习 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 吧~