前言
不断进步,此项目结构本人多次尝试,觉得之前的有不足,遂更新~。
文章较长,总耗时:1.5h;如果对您有帮助,恳请动动您的小手,给个👍~
第三次更新,更新了哪些内容,为了什么?
1、更新了目录结构;将主要文件全部放src中;主要考虑:ts打包后存放地址集中到src中;考虑到package.json安装完依赖后有node_modules;上线时如果没有依赖更新不想再频繁安装依赖;将package.json独立出来就可以只打包src目录下的文件;上传时候也可以只上传打包后的src文件夹
2、更新模型字段的ts类型定义;第一考虑是因为之前查询出数据后在取值编译器很多时候没有提示,甚至报红,增加ts类型定义后完美/舒服。
3、修复了controllers下index文件在不同平台上的报错;windows与mac文件路径表述是不用的,windows是\;mac是/;增加了判断以及错误捕捉,方便项目在多个平台正常运行;
目录结构
工具
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支持下