Koa
项目搭建
初始化项目:npm init
安装依赖:npm i koa -S
npm i nodemon -D
入口文件:app.js
启动命令:nodemon app.js
Http服务
// app.js
const Koa = require("koa");
const app = new Koa();
app.listen(3000, () => console.log("server is running"));
Router路由
安装
npm install koa-router -S
使用
// app.js
const Koa = require("koa");
const router = require("./router/index.js");
app.use(router.routes());
app.use(router.allowedMethods());
allowedMethods()
发起请求的路径存在,请求方式不匹配时,返回提示 405 Method Not Allowed
,而非 404 Not Found
添加对含有允许方法的Allow头的OPTIONS预检请求类型作出正确响应
创建
// router/index.js
const Router = require("koa-router");
const authMiddleware = require("../middlewares/auth.js");
const loginRouter = require("./login-router.js");
const whiteList = ["/admin/login"];
const adminRouter = new Router({ prefix: "/admin" })
.use(async (ctx, next) => {
if (whiteList.includes(ctx.request.path)) {
await next();
return;
}
await authMiddleware()(ctx, next);
})
.use(loginRouter.routes());
module.exports = adminRouter;
// login-router.js
const Router = require("koa-router");
const router = new Router();
router.post("/login", async (ctx, next) => {
......
ctx.body = { code: 200, token };
await next();
});
module.exports = router;
koa-body
支持解析多种数据格式
- multipart/form-data
- application/x-www-form-urlencoded
- application/json
- application/json-patch+json
- application/vnd.api+json
- application/csp-report
- text/xml
使用
const Koa = require("koa");
const { koaBody } = require("koa-body");
const app = new Koa();
app.use(koaBody());
koa-static
提供静态资源服务的中间件封装
const path = require("path");
const Koa = require("koa");
const static = require("koa-static");
const app = new Koa();
const webRoot = path.join(__dirname, "./web");
app.use(static(webRoot, { ...options }));
mysql2
安装:npm i mysql2 -S
连接
const mysql = require('mysql2');
// 创建数据库连接
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'root',
database: 'test'
});
module.export = connection;
查询
conn.query(
'SELECT * FROM `t_user` WHERE `user_name` = "admin"',
function(err, results, fields){
// results 结果 fields 额外的元数据
conn.end(); // 手动关闭
}
)
预处理查询
将查询和数据分离,对数据验证和转义,有效防止sql注入
connection.execute(
'SELECT * FROM `table` WHERE `name` = ? AND `age` > ?',
['Tom', 18],
function(err, results, fields) {
// 如再次执行相同的语句,将从缓存中选取
}
);
连接池
复用旧连接来减少重复建立 mysql
链接花费的时间
操作完成时让它们保持打开而不是关闭,无需手动关闭
const mysql = require("mysql2");
const pool = mysql.createPool({
host: "localhost",
user: "root",
password: "root",
database: "test",
connectionLimit: 5, // 最多接收连接数,默认10
maxIdle: 20, // 允许最大空闲连接数,默认10
});
pool.query(...);
promise支持
const mysql = require('mysql2/promise');
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'root',
database: 'test'
});
module.exports = connection;
await connection.query(...);
const mysql = require("mysql2");
const pool = mysql.createPool({
host: "localhost",
user: "root",
password: "root",
database: "test",
connectionLimit: 5, // 最多接收连接数,默认10
maxIdle: 20, // 允许最大空闲连接数,默认10
});
module.exports = pool.promise();
await promisePool.query(...);
注:mysql2的所有api与mysql相同,mysql文档地址 mysql2中文文档
参数校验
joi github schame描述与data校验
router.post(
"/user/add",
validator({
name: Joi.string().required(),
password: Joi.string().required().messages("密码参数格式错误"),
mobile: Joi.string().length(11).empty(""), // 支持“”等空的值
type: Joi.number().valid(2, 3).required(),
tags: Joi.array().items(Joi.string()).min(1).required(),
ext: Joi.string().pattern(/^\.[a-z]+$/i, "ext"), // "ext"即pattern名称,用于错误提示
}),
async (ctx, next) => {
......
ctx.body = { code: 200, message: "success" };
await next();
}
);
validator中间件
// 校验参数中间件
const Joi = require("joi");
const { PARAM_ERROR } = require("../constant/error-type.js");
module.exports = function (schema) {
return async (ctx, next) => {
try {
const object = ctx.method === "POST" ? ctx.request.body : ctx.query;
await Joi.object(schema).validateAsync(object || {}); // 防止空时报错
} catch (error) {
// 未定义错误信息时,Joi抛出内置错误提示
throw { code: PARAM_ERROR.code, message: error.message };
}
await next();
};
};
登录
jwt登录
const bcrypt = require("bcryptjs");
router.post(
"/login",
validator({
name: Joi.string().required(),
password: Joi.string().required(),
}),
async (ctx, next) => {
const { name, password } = ctx.request.body;
// 查询账号信息
const user = await userService.findUser({ userName: name });
// 验证password
if (!user || !bcrypt.compareSync(password, user.password))
throw USER_LOGIN_ERROR; // 用户不存在或密码错误
// token,包含除密码外所有用户信息组成的json
const { password: p, ...userCrypt } = user;
const token = jsonwebtoken.sign(
{ user: userCrypt },
process.env.JWT_SECRET, // token加密密钥
{ expiresIn: "2h" }
);
ctx.body = { code: 200, token };
await next();
}
);
相关笔记:[登录鉴权 | KID-1912’s Blog](https://kid-1912.github.io/2022/02/13/%E7%99%BB%E5%BD%95%E9%89%B4%E6%9D%83/) |
鉴权
// token鉴权中间件
const jwt = require("jsonwebtoken");
const {
USER_AUTH_ERROR,
USER_TOKEN_EXPIRED,
} = require("../constant/error-type.js");
const userService = require("../services/UserService.js");
module.exports = function () {
return async (ctx, next) => {
const token = ctx.headers.authorization;
// 校验token
if (!token) throw USER_AUTH_ERROR;
try {
const { user } = jwt.verify(token, process.env.JWT_SECRET);
const userInfo = await userService.findUser({ id: user.id });
if (!userInfo) throw new Error("不存在的用户");
ctx.state.user = user;
} catch (error) {
console.log("error in auth :", error);
throw USER_TOKEN_EXPIRED; // 校验失败,无效的token
}
await next();
};
};
添加至路由
const whiteList = ["/admin/login"];
const adminRouter = new Router({ prefix: "/admin" })
.use(async (ctx, next) => {
if (whiteList.includes(ctx.request.path)) {
await next();
return;
}
await authMiddleware()(ctx, next);
}).use(...).use(...);
文件下载
大文件下载
详见 KID-1912博客 | 文件流笔记 |
从第三方服务下载
// 第三方服务配置
const axios = require("axios");
const instance = axios.create({
baseURL: process.env.MIDSTAGE_DOMAIN,
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
timeout: 10 * 1000,
});
async function fileDownload(path) {
try {
// 构建请求
const response = await instance.request({
url: "/file/download",
method: "get",
params: { ... },
timeout: 60 * 60 * 1000,
headers: { Cookie },
responseType: "stream",
});
// 已下载的数据 计算下载进度
let downloadedBytes = 0;
const totalBytes = response.headers["content-length"];
response.data.on("data", (chunk) => {
downloadedBytes += chunk.length;
const progress = (downloadedBytes / totalBytes) * 100;
console.log(`下载进度:${progress.toFixed(2)}%`);
});
response.data.on("error", (error) => {
response.data.destroy();
console.log("下载文件中断", error);
});
return response.data;
} catch (error) {
console.log("下载文件失败", error);
return null;
}
}
文件上传
分页
简单实现
-
router接收参数
pageIndex
pageSize
调用“分页查询列表”服务; -
查询服务调用公共的mysql查询方法,公共方法支持条件过滤同时支持分页limit,offset参数;
-
拼接分页数据返回,包括total, pageIndex, pageSize, data
分页模块
独立paginator中间件,在需要分页处理的接口使用该中间件
主要功能:计算出分页查询参数注入到 ctx.pagination
,pagenable
生成分页信息
app.get(
"/user/list/page",
paginator({
defaultLimit: 20, // 控制默认单页条数20条,默认值为10
maximumLimit: 100, // 控制最大支持每页100条,默认值为50
limitField: "pageLength", // 控制每页条数的参数名,默认值为"pageNumber"
pageField: "pageNum", // 控制页码的参数名,默认值为"pageIndex"
}),
async (ctx, next) => {
const { limit, offset, pagenable } = ctx.pagination;
const [list, total] = userModel.findUser({limit, offset});
ctx.body = {data: { list, ...pagenable(total) }}; // "current" "pageCount" "limit" "offset" "total"
}
)
相关插件
koa-pagination-v2
权限设计
定义角色: 支持将模块的权限进行组合划分给自定义的角色
身份验证: 访问资源前首先解出用户身份的角色信息
中间件验证: 中间件拦截请求,进行权限验证
数据权限控制: 为数据记录添加权限相关字段
数据库良好设计
错误处理
错误类型
应用级错误(ApplicationErrors):
- 服务器错误(ServerError):表示应用程序内部发生的错误,如未捕获的异常、数据库未连接等。
- 参数错误(ParameterError):表示请求中的参数错误或缺失。
- 认证和授权错误(AuthError):表示用户认证和授权相关的错误,如无效的令牌、权限不足等。
- 资源未找到(ResourceNotFoundError):表示请求的资源不存在。
- 业务逻辑错误(BusinessLogicError):表示与业务逻辑相关的错误,如重复的数据、无效的操作等。
数据库错误(DatabaseError):
- 连接错误(DatabaseConstraintError):表示无法连接到数据库。
- 查询错误(QueryError):表示执行 SQL 查询时发生的错误。
- 数据库约束错误(DatabaseConstraintError):表示违反数据库定义的约束条件,如唯一性约束、外键约束等。
第三方服务错误(Third-partyServiceErrors):
- API 错误:表示与调用第三方 API 相关的错误,如请求超时、无效的响应等。+
客户端错误(ClientErrors):
- 无效的请求(InvalidRequestError):表示客户端发送的请求不符合规范或格式有误。
- 路由未找到(RouteNotFoundError):表示客户端请求的路由未找到。
Error类
errors 目录下
application-error
database-error
client-error
业务错误
常规错误类声明
class BusinessLogicError {
constructor(type) {
this.name = type;
this.code = errorMap[type].code;
this.message = errorMap[type].message;
}
}
module.exports = BusinessLogicError;
业务错误类声明
class ApplicationError extends Error {
constructor(message) {
super(message);
this.name = 'ApplicationError';
}
}
module.exports = ApplicationError;
抛出错误
ParameterError
:validator
中间件
DatabaseConstraintError
:数据库 connection
AuthError
:auth
中间件
RouteNotFoundError
:ctx.status === 404
……
捕获错误并记录日志
errorMiddle
:错误中间件捕捉请求错误
module.exports = function () {
return async (ctx, next) => {
try {
await next();
if(ctx.status === 404) throw new RouteNotFoundError();
if(ctx.status === 405) throw new InvalidRequestError();
} catch (error) {
console.log("errorMiddle", error);
// 错误日志
// ......记录错误详细信息
// 错误码响应
const code = error.code || error.status;
const message = code && error.message ? error.message : "未知错误";
ctx.body = { code: code || 500, message };
}
};
};
process.on("uncaughtException/unhandledRejection", 错误日志)
同步代码的未处理异常 / 异步代码的未处理异常;
app.on('error', 错误日志)
app应用程序异常;
日志
在某些操作节点,记录多状态日志
const path = require("path");
const winston = require("winston");
const format = winston.format;
const DailyRotateFile = require("winston-daily-rotate-file");
const logsPath = path.join(process.cwd(), "logs");
const logger = winston.createLogger({
level: "info",
levels: format.levels,
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // 添加时间戳
winston.format.json() // 将日志格式化为JSON
),
transports: [
new DailyRotateFile({
filename: path.join(logsPath, "logs-%DATE%.log"),
datePattern: "YYYYMMDD",
zippedArchive: true,
maxFiles: "30d", // 最大保留时长为30天
}),
],
});
if (process.env.NODE_ENV === "development") {
logger.clear();
logger.add(new winston.transports.Console());
}
module.exports = logger;
使用
logger.error({
message: `log message`,
label: "log label",
...more detail
});
logger.info({
message: `log message`,
label: "log label",
...more detail
});
定时任务
const cron = require("node-cron");
cron.schedule(`0 */${interval} * * * *`, handler)
环境变量
cross-env
支持跨平台设置运行环境,package.json
的 scripts
配置
"scripts": {
"dev": "NODE_ENV=development nodemon src/app.js",
"server": "NODE_ENV=production pm2 start src/app.js --name koa2-web-template"
},
dotenv
支持读取 .env
配置作为环境变量
const path = require("path");
// 注入环境变量配置
require("dotenv").config({ path: `./.env.${process.env.NODE_ENV}` });
const Koa = require("koa");
...
跨域
测试环境下,允许跨域访问
const cors = require("koa2-cors");
!isProduction && app.use(cors());
部署
远程登录服务器
搭建Nodejs生产环境
Nginx 反向代理
正式部署