koa2

Posted by page on May 9, 2023

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;
  }
}

文件上传

分页

简单实现

  1. router接收参数 pageIndex pageSize 调用“分页查询列表”服务;

  2. 查询服务调用公共的mysql查询方法,公共方法支持条件过滤同时支持分页limit,offset参数;

  3. 拼接分页数据返回,包括total, pageIndex, pageSize, data

分页模块

独立paginator中间件,在需要分页处理的接口使用该中间件 主要功能:计算出分页查询参数注入到 ctx.paginationpagenable 生成分页信息

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;

抛出错误

ParameterErrorvalidator 中间件

DatabaseConstraintError:数据库 connection

AuthErrorauth 中间件

RouteNotFoundErrorctx.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.jsonscripts 配置

"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 反向代理

正式部署