Koa2 + Ts 项目结构搭建 保姆级教程(附带源码)-20240110最新版

4,815 阅读5分钟

前言

不断进步,此项目结构本人多次尝试,觉得之前的有不足,遂更新~。

文章较长,总耗时:1.5h;如果对您有帮助,恳请动动您的小手,给个👍~

第三次更新,更新了哪些内容,为了什么?

1、更新了目录结构;将主要文件全部放src中;主要考虑:ts打包后存放地址集中到src中;考虑到package.json安装完依赖后有node_modules;上线时如果没有依赖更新不想再频繁安装依赖;将package.json独立出来就可以只打包src目录下的文件;上传时候也可以只上传打包后的src文件夹

2、更新模型字段的ts类型定义;第一考虑是因为之前查询出数据后在取值编译器很多时候没有提示,甚至报红,增加ts类型定义后完美/舒服。
   
3、修复了controllers下index文件在不同平台上的报错;windows与mac文件路径表述是不用的,windows是\;mac是/;增加了判断以及错误捕捉,方便项目在多个平台正常运行;

目录结构

项目目录.png

工具

node框架:koa + ts
热更新:nodemon + ts-node
代码格式化:prettier
代码检测:eslint
数据库:mysql
orm:sequelize
日志:log4js
token生成:jsonwebtoken
进程守护:pm2

1、创建包管理文件

yarn init

2、安装依赖

安装生产环境依赖
yarn add koa koa-body koa-router log4js pm2 request qs jsonwebtoken sequelize mysql2

安装开发环境依赖
yarn add @types/koa @types/koa-router @types/log4js @types/request @types/qs @types/jsonwebtoken prettier eslint nodemon typescript ts-node @types/sequelize @types/mysql @types/node -D

3、创建ts管理文件:tsconfig.json

{
  "compilerOptions": {
    // 目标语言版本
    "target": "esnext",
    // 指定生成代码的模板标准
    "module": "commonjs",
    // 指定编译目录(要编译哪个目录)
    "rootDir": "./src",
    // 严格模式
    "strict": true,
    //  tsc编译后存放目录
    "outDir": "./dist/src",
    // 没有默认导出时, 编译器会创建一个默认导出
    "allowSyntheticDefaultImports": true,
    // 允许export= 导出, 由import from导入
    "esModuleInterop": true,
    // 禁止对同一个文件的不一致的引用
    "forceConsistentCasingInFileNames": true
  },
  "ts-node": {
    "compilerOptions": {
      "module": "CommonJS"
    }
  }
}

4、创建ESLINT配置文件:.eslintrc.cjs

/* eslint-env node */
module.exports = {
    root: true,
    extends: ["eslint:recommended"],
    parserOptions: {
        ecmaVersion: "latest"
    }
};

5、创建Prettier配置文件:.prettierrc.json

{
    "$schema": "https://json.schemastore.org/prettierrc",
    "semi": false,
    "tabWidth": 2,
    "singleQuote": true,
    "printWidth": 100,
    "trailingComma": "none"
}

6、创建项目主文件夹:src

7、src下创建入口文件:app.ts

import Koa from "koa";
import http from "http";
import koaBody from "koa-body";
import { getIpAddress } from "./utils/util";
import { loggerMiddleware } from "./log/log";
import { FIXED_KEY } from "./config/constant";
import { privateRouter, publicRouter, openRouter } from "./router";
import { errorHandler, responseHandler } from "./middleware/response";

const app = new Koa();
// log middleware
app.use(loggerMiddleware);

// Error Handler
app.use(errorHandler);

// Global middleware
app.use(koaBody({ multipart: true }));

// Routes
app.use(publicRouter.routes()).use(publicRouter.allowedMethods()); // 公共路由
app.use(privateRouter.routes()).use(privateRouter.allowedMethods()); // 权限路由
app.use(openRouter.routes()).use(openRouter.allowedMethods()); // 公开路由

// Response
app.use(responseHandler);

const port = FIXED_KEY.port;

const server = http.createServer(app.callback());

server.listen(port);

server.on("error", (err: Error) => {
  console.log(err);
});

server.on("listening", () => {
  const ip = getIpAddress();
  const address = `http://${ip}:${port}`;
  const localAddress = `http://localhost:${port}`;
  console.log(`app started at address \n\n${localAddress}\n\n${address}`);
});

接下来按照app.ts引入的文件顺序来创建基本的目录结构

8、src下创建工具库文件夹:utils

9、utils文件夹下新增常用函数封装文件:util.ts

import { Context } from 'vm'

import { JWT } from '../config/constant'

import jwt from 'jsonwebtoken'

/*获取当前ip地址*/

export const getIpAddress = () => {

  const interfaces = require('os').networkInterfaces()

  for (const devName in interfaces) {

    const temp = interfaces[devName]

    for (let i = 0; i < temp.length; i++) {

      const alias = temp[i]

      if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {

        return alias.address

      }

    }

  }

}

// 获取客户端ip地址

export const getClientIpAddress = (ctx: Context) => {

  const headers = ctx.headers

  if (headers['x-forwarded-for']) {

    const ipList = headers['x-forwarded-for'].split(',')

    return ipList[0]

  }

  return '0.0.0.0'

}

// 通过token解析userId

export const decodeToken = (token: string) => {

  let jwtInfo = jwt.verify(token, JWT.secret) as any

  try {

    return jwtInfo.userId

  } catch (err) {

    return 'token不合法'

  }

}

// 根据userId生成token

export const generatorToken = (userId: number) => {

  return jwt.sign({ userId }, JWT.secret, { expiresIn: JWT.expires })

}

10、utils文件夹下新增mysql连接池封装文件:pool.ts

import { DATABASE, ENV } from "../config/constant";
import { logger } from "../log/log";
import { Sequelize } from "sequelize";

const { dbName, user, password, host, port } =
  process.env.NODE_ENV === ENV.production
    ? DATABASE.production
    : DATABASE.development;

const sequelize = new Sequelize(dbName, user, password, {
  dialect: "mysql",
  host: host,
  port: port,
  timezone: "+08:00",
  logging: false,
  dialectOptions: {
    dateStrings: true,
    typeCast: true,
  },
  // query: {
  //     raw: true
  // },
  define: {
    timestamps: true,
    paranoid: true,
    createdAt: "created_at",
    updatedAt: "updated_at",
    deletedAt: "deleted_at",
    // 把驼峰命名转换为下划线
    underscored: true,
  },
});

// 同步模型
sequelize
  .sync({ force: false, alter: true })
  .then((res) => {})
  .catch((err) => {
    logger.error("模型同步失败");
  });

sequelize
  .authenticate()
  .then(() => {})
  .catch((err: Error) => {
    logger.error(err.message);
  });

export default sequelize;

11、src下新增日志文件夹:log

12、log文件夹新增日志配置文件:log.ts

import Koa from 'koa'

import log4js from 'log4js'

import { getClientIpAddress } from '../utils/util'

log4js.configure({

  pm2: true,

  appenders: {

    everything: {

      type: 'dateFile',

      filename: __dirname + '/all-the-logs.log',

      maxLogSize: '10M',

      backups: 20

    }

  },

  categories: {

    default: { appenders: ['everything'], level: 'debug' }

  }

})

export const logger = log4js.getLogger()

export const loggerMiddleware = async (ctx: Koa.Context, next: Koa.Next) => {

  // 请求开始时间

  const start = new Date()

  await next()

  // 结束时间

  const ms = Number(new Date()) - Number(start)

  // 打印出请求相关参数

  const remoteAddress = getClientIpAddress(ctx)

  let logText = `${ctx.method} ${ctx.status} ${ctx.url} 请求参数: ${JSON.stringify(

    ctx.request.body

  )} 响应参数: ${JSON.stringify(ctx.body)} - ${remoteAddress} - ${ms}ms`

  logger.info(logText)

}

13、src下新增常用配置参数文件夹:config

14、config文件夹下新增常用接口状态code:code.ts

export const CODE = {
  // 普通错误code 均为 -1;前端直接捕获-1的错误 抛出
  success: { code: 0, message: "success", key: "success" },
  missingParameters: {
    code: -1,
    message: "缺少参数",
    key: "missingParameters",
  },
  tokenFailed: { code: 1, message: "token校验失败", key: "tokenFailed" },
  adminUserIsExist: {
    code: 3,
    message: "账号名已存在",
    key: "adminUserIsExist",
  },
  illegalRequest: { code: 4, message: "非法请求", key: "illegalRequest" },
};

15、config文件夹下新增全局通用的配置参数文件:constant.ts

// 环境变量配置
import { anyKeyObject } from "../type/global";

export const ENV = {
  development: "development",
  production: "production",
};

// mysql配置
export const DATABASE = {
  // 本地环境
  development: {
    dbName: "xxx",
    user: "root",
    password: "xxx",
    host: "xxx",
    port: 3306,
  },

  // 阿里云
  production: {
    dbName: "xxx",
    user: "root",
    password: "xxx",
    host: "xxx",
    port: 3306,
  },
};

// jsonwebtoken-jwt配置
export const JWT = {
  secret: "xxx", //密钥
  expires: 60 * 60 * 24 * 30, // 30天
};

// sms短信配置
export const SMS = {
  accessKeyId: "xxx",
  accessKeySecret: "xxx",
  signName: "xxx",
  templateCode: "xxx",
};

// 平台Map
export const PLATFORM = {
  wxMini: "微信小程序",
  wxH5: "微信H5",
  webH5: "webH5",
  dyMini: "抖音小程序",
  ksMini: "快手小程序",
  qqMini: "QQ小程序",
};

// 支付配置
export const PAY = {
  wx: {
    miniAppid: "xxx",
    h5Appid: "xxx",
    mchid: "xxx",
    v3Key: "xxx", //https://pay.weixin.qq.com/index.php/core/cert/api_cert#/api-password-v3
  },
};

// 支付方式配置
export const PAY_TYPE = [{ label: "微信小程序支付", value: 1 }];

// xxx
export const WX_MINI = {
  appid: "xxx",
  secret: "xxx",
};

// 全局参数
export const FIXED_KEY = {
  port: 3232,
};

16、config文件夹下新增pm2配置文件:pm2.config.ts

const ENV = {
  development: "development",
  production: "production",
};

// eslint-disable-next-line no-undef
module.exports = {
  apps: [
    {
      name: "production", //需与package.json里--only 后缀名相同
      script: "./src/app.js",// 运营入口
      args: "one two",
      instances: 2,//cpu有几核开几个就行;我服务器是2核4g所以开了2个
      cron_restart: "0 03 * * *",//每天凌晨3点重启;
      autorestart: true,
      watch: false,
      min_uptime: "200s",
      max_restarts: 10,
      ignore_watch: [
        // 不用监听的文件
        "node_modules",
        ".idea",
        "log",
      ],
      max_memory_restart: "300M",//内存占用超过300M后重启
      restart_delay: "3000",
      env: {
        NODE_ENV: ENV.production, //process.env.NODE_ENV值
      },
    },
    {
      name: "test", //需与package.json里--only 后缀名相同
      script: "./src/app.js",
      args: "one two",
      instances: 1,
      cron_restart: "0 03 * * *",//每天凌晨3点重启;
      autorestart: true,
      watch: true,
      ignore_watch: [
        // 不用监听的文件
        "node_modules",
        ".idea",
        "log",
      ],
      max_memory_restart: "300M",
      env: {
        NODE_ENV: ENV.development, //process.env.NODE_ENV值
      },
    },
  ],
};

17、src下新增路由文件夹:router

18、router文件夹下新增统一导出路由文件:index.ts

import privateRouter from "./private";
import publicRouter from "./public";
import openRouter from "./open";

export { privateRouter, publicRouter, openRouter };

19、router文件夹下新增私有路由文件(此项目特指需要header携带登录token路由):private.ts

import koaRouter from "koa-router";
import controllers from "../controllers";
import { jwtMiddlewareDeal, platformMiddlewareDeal } from "../middleware/jwt";

const router = new koaRouter();

router.use(platformMiddlewareDeal);
router.use(jwtMiddlewareDeal);

const platform = "/game";

const service = {
  global: "",
  user: "/user",
  product: "/product",
  banner: "/banner",
  order: "/order",
};

// user服务
// 忘记密码
router.post(
  `${platform}${service.user}/password/change`,
  controllers.app_user.userPasswordChangeApi,
);

export default router;

20、router文件夹下新增公有路由文件(与私有相反):public.ts

import controllers from "../controllers";
import koaRouter from "koa-router";
import { platformMiddlewareDeal } from "../middleware/jwt";

const router = new koaRouter();

router.use(platformMiddlewareDeal);

const platform = "/game";

const service = {
  global: "",
  user: "/user",
  product: "/product",
  banner: "/banner",
  order: "/order",
};

// global服务
// 测试接口
router.get(`${platform}${service.global}/test`

export default router;

21、router文件夹下新增公开路由文件:open.ts(主要用于第三方回调;去除platform请求头的校验)

import controllers from "../controllers";
import koaRouter from "koa-router";
import {scrapyScanGameApi} from "../controllers/app/scrapy";

const router = new koaRouter();

const platform = "/game";

const service = {
  global: "",
  user: "/user",
  product: "/product",
  banner: "/banner",
  order: "/order",
};

// global服务
// 微信支付结果通知
router.post(
  `${platform}${service.global}/notify/wxMiniPay`,
  controllers.app_global.wxMiniPayNoticeApi,
);

export default router;

22、src下新增中间件文件夹:middleware

23、middleware新增请求头校验token中间件:jwt.ts

import { Context, Next } from "koa";
import { CODE } from "../config/code";
import { decodeToken } from "../utils/util";
import { getRequestType } from "../type/global";
import { PLATFORM } from "../config/constant";
import { getUserInfoByIdService } from "../services/user";

export const jwtMiddlewareDeal = async (ctx: Context, next: Next) => {
  const token = ctx.request.headers.token;
  if (typeof token === "string") {
    try {
      const userId = decodeToken(token);
      const userInfo = await getUserInfoByIdService({ id: Number(userId) });
      if (!userInfo) {
        throw CODE.tokenFailed;
      } else {
        ctx.userId = Number(userId);
        ctx.userInfo = userInfo;
      }
    } catch (error) {
      throw CODE.tokenFailed;
    }
  } else {
    throw CODE.tokenFailed;
  }
  return next();
};

// 校验header中platform是否合法
export const platformMiddlewareDeal = async (ctx: Context, next: Next) => {
  const { platform } = ctx.request.headers as getRequestType;
  // @ts-ignore
  if (!PLATFORM[platform]) {
    throw CODE.missingParameters;
  }
  ctx.platform = platform;
  return next();
};

24、middleware新增接口返回统一出口中间件:response.ts

import Koa from "koa";
import { logger } from "../log/log";
import { CODE } from "../config/code";
import { Context } from "koa";

// 这个middleware用于将ctx.result中的内容最终回传给客户端
export const responseHandler = (ctx: Context) => {
  if (ctx.body !== undefined) {
    ctx.type = "json";
    // 当需要返回前端空数据;也就是啥内容也没有的时候;接口层面直接用ctx.body=null
    if (ctx.body === null) {
      ctx.body = null;
    } else {
      ctx.body = {
        code: CODE.success.code,
        data: ctx.body,
        message: CODE.success.message,
      };
    }
  }
};

// 这个middleware处理在其它middleware中出现的异常,我们在next()后面进行异常捕获,出现异常直接进入这个中间件进行处理
export const errorHandler = (ctx: Koa.Context, next: Koa.Next) => {
  return next().catch((err: { code: any; message: any }) => {
    if (typeof err === "object") {
      ctx.body = {
        code: err.code,
        data: null,
        message: err.message,
      };
    } else {
      ctx.body = {
        code: -1,
        data: null,
        message: err,
      };
    }

    logger.error(err);

    // 保证返回状态是 200
    ctx.status = 200;

    return Promise.resolve();
  });
};

25、src下新增orm(sequelize)映射文件夹:models

26、models文件夹下新增表映射文件:group.ts(示例)

import sequelize from "../utils/pool";
import { Model, DataTypes } from "sequelize";

class Group extends Model {
  declare id: number;
  declare name: string;
  declare description: string;
  declare qrcode: string;
  declare avatar: string;
  declare office?: number;
}

Group.init(
  {
    id: {
      type: DataTypes.INTEGER.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
      comment: "主键ID",
    },
    name: {
      type: DataTypes.STRING,
      allowNull: false,
      comment: "群聊名称",
    },
    description: {
      type: DataTypes.STRING,
      allowNull: true,
      comment: "群聊描述",
    },
    qrcode: {
      type: DataTypes.STRING,
      allowNull: false,
      comment: "群聊二维码",
    },
    avatar: {
      type: DataTypes.STRING,
      allowNull: false,
      comment: "群聊头像",
    },
    office: {
      type: DataTypes.INTEGER,
      allowNull: true,
      defaultValue: 1,
      comment: "是否为官方群 1是 2否",
    },
  },
  {
    sequelize,
    modelName: "group",
    freezeTableName: true,
  },
);

export default Group;

27、src下新增数据库处理封装文件夹:services

28、services文件夹下文件夹同时新增user表处理文件:group.ts;

import Group from "../models/group";
import {Op} from "sequelize";

// 获取群聊列表
export const getGroupListService = ({offset, limit, keyword}: { offset: number, limit: number, keyword: string }) => {
    return Group.findAndCountAll({
        order: [['id', 'desc']],
        offset,
        limit,
        where: {
            name: {
                [Op.like]: `%${keyword}%`
            }
        }
    })
}

// 获取群聊详情
export const getGroupInfoService = ({id}: { id: number }) => {
    return Group.findOne({
        where: {
            id
        }
    })
}

29、src下新增逻辑处理文件夹:controllers

30、controllers文件夹下新增文件目录处理成对象映射的文件:index.ts

import fs from "fs";
import path from "path";
import { ENV } from "../config/constant";

const controllers = {} as { [key: string]: any };

// 处理ts编译后js文件识别问题
const fileType = process.env.NODE_ENV === ENV.production ? "js" : "ts";

function readFileList(dir: any) {
  const files = fs.readdirSync(dir);
  files.forEach((item) => {
    if (item === `index.${fileType}`) return;

    const fullPath = path.join(dir, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory()) {
      readFileList(path.join(dir, item));
    } else {
      const platform = process.platform;
      if (platform.toLowerCase() === "win32") {
        // windows
        const temp = fullPath.split(__dirname + "\")[1];
        const obj_temp = temp.replaceAll("\", "_").split(`.${fileType}`)[0];
        controllers[obj_temp] = require(`./${temp}`);
      } else {
        // 除windows外
        const temp = fullPath.split(__dirname + "/")[1];
        const obj_temp = temp.replaceAll("/", "_").split(`.${fileType}`)[0];
        controllers[obj_temp] = require(`./${temp}`);
      }
    }
  });
}

readFileList(__dirname);

export default controllers;

31、controllers文件夹新增app文件夹;app文件夹作为应用主要逻辑存放文件夹;同时新增接口文件:group.ts(示例)

import {Context, Next} from "koa";
import {getRequestType} from "../../type/global";
import {getGroupInfoService, getGroupListService} from "../../services/group";
import {CODE} from "../../config/code";

// 获取群聊列表
export const groupListApi = async (ctx: Context, next: Next) => {
    let {offset = 0, limit = 10, keyword} = (ctx.request.query || {}) as getRequestType

    offset = Number(offset)
    limit = Number(limit)

    // 获取群聊列表
    const groupInfo = await getGroupListService({offset, limit, keyword})

    ctx.body = {count: groupInfo.count, list: groupInfo.rows}

    return next()
}

// 获取群聊详情
export const groupInfoApi = async (ctx: Context, next: Next) => {
    const {id} = (ctx.request.query || {}) as getRequestType
    if (!id) {
        throw CODE.missingParameters
    }

    // 获取群聊详情
    const groupInfo = await getGroupInfoService({id: Number(id)})
    if (!groupInfo) {
        throw "群聊不存在"
    }

    ctx.body = groupInfo

    return next()
}

32、最后补上包管理文件package.json运行命令

{
  "name": "server",
  "version": "1.0.0",
  "main": "src/app.ts",
  "author": "wujunjie",
  "license": "MIT",
  "scripts": {
    "dev": "nodemon src/app.ts",
    "test": "pm2 start src/config/pm2.config.js --only test",
    "prod": "pm2 start src/config/pm2.config.js --only production",
    "stop": "pm2 stop src/config/pm2.config.js",
    "delete": "pm2 delete src/config/pm2.config.js",
    "list": "pm2 list",
    "tsc": "tsc && npm run copy-package && npm run copy-assets",
    "copy-package": "cp package.json dist/",
    "copy-assets": "cp src/assets/apiclient_key.pem src/assets/apiclient_cert.pem dist/src/assets/"
  },
  "dependencies": {
    "@alicloud/dysmsapi20170525": "2.0.24",
    "ali-oss": "^6.19.0",
    "axios": "^1.6.0",
    "cheerio": "^1.0.0-rc.12",
    "crypto": "^1.0.1",
    "dayjs": "^1.11.10",
    "decimal.js": "^10.4.3",
    "jsonwebtoken": "^9.0.1",
    "koa": "^2.14.2",
    "koa-body": "^6.0.1",
    "koa-router": "^12.0.0",
    "log4js": "^6.9.1",
    "mime-types": "^2.1.35",
    "mysql2": "^3.5.2",
    "node-schedule": "^2.1.1",
    "pm2": "^5.3.0",
    "qs": "^6.11.2",
    "sequelize": "^6.32.1",
    "wechatpay-node-v3": "^2.2.0-beta"
  },
  "devDependencies": {
    "@types/ali-oss": "^6.16.11",
    "@types/axios": "^0.14.0",
    "@types/jsonwebtoken": "^9.0.2",
    "@types/koa": "^2.13.6",
    "@types/koa-router": "^7.4.4",
    "@types/log4js": "^2.3.5",
    "@types/mime-types": "^2.1.3",
    "@types/mysql": "^2.15.21",
    "@types/node": "^20.4.2",
    "@types/node-schedule": "^2.1.5",
    "@types/qs": "^6.9.7",
    "@types/sequelize": "^4.28.15",
    "eslint": "^8.45.0",
    "nodemon": "^3.0.1",
    "prettier": "^3.0.0",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.6"
  }
}

33、跑一下试试看

本地运行:yarn dev

生产环境运行,运用pm2守护进程,同时使用config下的pm2配置文件进行启动,线上的环境参数为production(process.env.NODE_ENV): yarn prod

其他的命令自己脑补一下吧  可以查询一下pm2文档就可以轻松了解

最后附上项目代码地址:点击查看,点个Star支持下