项目源代码
创建项目
pnpm init -y
安装依赖
pnpm i ts-node typescript cross-env eslint eslint-config-prettier eslint-plugin-prettier husky lint-staged nodemon pm2 prettier @types/koa @types/koa-router @types/koa2-cors @typescript-eslint/eslint-plugin @typescript-eslint/parser @commitlint/cli @commitlint/config-conventional rimraf tsconfig-paths tsc-alias @types/ip @types/koa-session @types/koa-send @types/koa-static @ykmmky/koa2-swagger-ui @types/swagger-jsdoc -D
tsc-alias: 的作用是使tsc能够识别tsconfig.json中配置的路径别名
tsconfig-paths: 的作用是使ts-node能够识别tsconfig.json中配置的路径别名
pnpm i koa koa-router koa-session koa2-cors log4js path koa-body ip dayjs koa-send koa-static swagger-jsdoc
新建配置文件
.prettierrc
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
.prettierignore
dist/
node_modules
*.log
logs/
dll/
public/
yarn.lock
package-lock.json
.eslintrc
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"plugins": ["@typescript-eslint/eslint-plugin"],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"root": true,
"env": {
"node": true
},
"ignorePatterns": [".eslintrc.js"],
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/no-unused-vars": "off"
}
}
.eslintignore
node_modules
dist
nodemon.json
{
"exec": "npm run dev",
"watch": ["src/*"],
"ext": "js, ts, html, css, json"
}
pm2-start.json
{
"apps": [
{
"name": "koa-ts-server",
"script": "./dist/app.js",
"instances": "2",
"exec_mode": "cluster",
"watch": false,
"watch_delay": 4000,
"ignore_watch" : [
"node_modules",
"src"
],
"max_memory_restart": "1000M",
"min_uptime": "5s",
"max_restarts": 5,
"error_file": "./logs/koa-ts-server_err.log",
"out_file": "/dev/null",
"log_date_format": "YYYY-MM-DD HH:mm Z"
}
]
}
package.json
{
"name": "koa-ts",
"version": "1.0.0",
"author": "pan",
"scripts": {
"dev": "cross-env NODE_ENV=development ts-node -r tsconfig-paths/register src/app.ts",
"watch": "nodemon",
"build-ts": "tsc && tsc-alias",
"build:test": "rimraf dist && npm run lint && npm run build-ts",
"serve:test": "cross-env NODE_ENV=development pm2 startOrReload pm2-start.json --no-daemon",
"build:production": "rimraf dist && npm run lint && npm run build-ts",
"serve:production": "cross-env NODE_ENV=production pm2 startOrReload pm2-start.json --no-daemon",
"format": "prettier --write \"src/**/*.ts\" ",
"lint": "eslint \"src/**/*.ts\" --fix",
"stop": "pm2 status && pm2 stop all"
},
"keywords": [],
"license": "MIT",
"repository": {
...
},
"dependencies": {
...
},
"devDependencies": {
...
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -e $HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"src/**/*.{ts,.tsx}": [
"prettier --trailing-comma es5 --single-quote --write",
"yarn lint",
"git add ."
]
}
}
tsconfig.json
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"lib": [
"es6"
], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
// "strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": {
"*": [
"node_modules/*"
],
"@/*": ["src/*"],
"controller/*": ["src/controller/*"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
"typeRoots": [
"src/@types",
], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": true,
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"ts-node": {
"esm": true
},
"include": [
"./**/*"
],
"exclude": [
"node_modules"
]
}
tsconfig.json中的这段配置是用于告诉ts-node使用esm模式编译
"ts-node": {
"esm": true
}
paths: 用于设置项目中用到的别名
创建相关文件
src/util/DateUtil.ts
import dayjs from 'dayjs'
/**
* 获取当前时间的日期字符串
* @param formatStr 日期格式化模板. 默认:YYYY-MM-DD HH:mm:ss
* @return 格式化的日期字符串
*/
export const buildDateStr = (formatStr: string = 'YYYY-MM-DD HH:mm:ss'):string => dayjs().format(formatStr)
/**
* 获取当前时间的日期字符串
* @return 格式化的日期字符串. 如: 2020-10-01 10:00:00
*/
export const buildFormatDatetimeStr = ():string => buildDateStr()
/**
* 获取当前时间的日期字符串
* @return 格式化的日期字符串. 如: 2020-10-01
*/
export const buildFormatDateStr = () => buildDateStr('YYYY-MM-DD')
src/util/Log4jsUtil.ts
import log4js from 'log4js'
log4js.configure({
appenders: {cheese: {type: "console", filename: "cheese.log"}},
categories: {default: {appenders: ["cheese"], level: "debug"}}
});
/**
* 获取日志记录工具
* @example
* const logger = getLogger('user')
* logger.debug('你好') // 输出: [2010-01-17 11:43:37.987] [DEBUG] user - 你好
* logger.info('你好') // 输出: [2010-01-17 11:43:37.987] [INFO] user - 你好
* logger.warn('你好') // 输出: [2010-01-17 11:43:37.987] [WARN] user - 你好
* logger.error('你好') // 输出: [2010-01-17 11:43:37.987] [ERROR] user - 你好
* logger.fatal('你好') // 输出: [2010-01-17 11:43:37.987] [FATAL] user - 你好
* @param category 日志分类
*/
export function getLogger(category: string) {
return log4js.getLogger(category)
}
src/util/ResponseResultBuilder.ts
import { buildFormatDatetimeStr } from '@/util/DateUtil'
import { Context } from 'koa'
export type RespCode = 200 | 401 | 500 | 400
export type RespData = unknown | string
export interface ResponseResult<RespData> {
/**
* 业务响应状态码
*/
code: RespCode
/**
* 实际的业务数据
*/
ret: RespData
/**
* 异常消息提示
*/
errorMsg: string
/**
* 响应时间. 格式: YYYY-MM-DD HH:mm:ss
*/
createAt: string
}
export function buildSuc<RespData>(ret: RespData): ResponseResult<RespData> {
return {
code: 200,
ret: ret,
errorMsg: '',
createAt: buildFormatDatetimeStr(),
}
}
export function buildExp(
errorMsg = '接口出错了',
code: RespCode = 400
): ResponseResult<string> {
return {
code: code,
ret: '',
errorMsg,
createAt: buildFormatDatetimeStr(),
}
}
/**
* 向response body中输出业务接口执行成功的响应数据
* @param ctx koa的Context
* @param bizData 业务数据
*/
export function responseSucData<RespData>(ctx: Context, bizData?: RespData) {
ctx.body = buildSuc(bizData ? bizData : '')
}
/**
* 向response body中输出业务接口执行异常的响应数据
* @param ctx koa的Context
* @param errorMsg 异常提示信息
*/
export function responseExp(
ctx: Context,
errorMsg = '接口出错了',
code: RespCode = 400
) {
ctx.body = buildExp(errorMsg, code)
}
src/exception/SystemException.ts
import {RespCode} from "@/util/ResponseResultBuilder";
/**
自定义异常
*/
export class SystemException extends Error {
/**
* http响应状态码
* @private
*/
private httpStatusCode: number
/**
* 业务状态码
* @private
*/
private bizCode: RespCode
/**
* 显示给客户端的自定义异常信息
* @private
*/
private errorMsg: string
constructor(expMsg: string, bizCode: RespCode = 400, httpStatusCode: number = 200) {
super(`bizCode: ${bizCode}, httpStatusCode: ${httpStatusCode}, expMsg: ${expMsg}`);
this.httpStatusCode = httpStatusCode
this.bizCode = bizCode
this.errorMsg = expMsg
}
public getHttpStatusCode(): number {
return this.httpStatusCode
}
public getBizCode(): RespCode {
return this.bizCode
}
public getErrorMsg(): string {
return this.errorMsg
}
}
src/middlewares/ErrorHandler.ts
import {Context, Next} from 'koa'
import {getLogger} from "@/util/Log4jsUtil";
import {responseExp} from "@/util/ResponseResultBuilder";
import {SystemException} from "@/exception/SystemException";
const logger = getLogger("error")
// 这个middleware处理在其它middleware中出现的异常
// 并将异常消息回传给客户端:{ code: '错误代码', msg: '错误信息' }
export const errorHandler = (ctx: Context, next: Next) => {
return next().catch((err) => {
logger.error(err.stack)
if (err.status === 401) {
ctx.status = 401
ctx.body = '您暂时无权访问受保护的资源'
} else if (err instanceof SystemException) {
const sysExp = err as SystemException
responseExp(ctx, sysExp.getErrorMsg(), sysExp.getBizCode())
// 保证返回状态是 200, 这样前端不会抛出异常
ctx.status = sysExp.getHttpStatusCode()
} else {
responseExp(ctx, `${ctx.method} ${ctx.url} 接口执行发生非预期异常!`, 500)
// 保证返回状态是 200, 这样前端不会抛出异常
ctx.status = 200
}
return Promise.resolve()
})
}
src/controller/TestController.ts
import {Context} from 'koa'
import KoaRouter from 'koa-router'
import {getLogger} from "@/util/Log4jsUtil";
import {responseSucData} from "@/util/ResponseResultBuilder";
const logger = getLogger('test')
const testControllerRouter = new KoaRouter({prefix: '/v1/test'})
testControllerRouter.post('/user', async (ctx: Context) => {
logger.debug(`post-body: ${JSON.stringify(ctx.request.body)}`)
responseSucData(ctx, '')
})
testControllerRouter.get('/user/detail/:id',async (ctx: Context) => {
const {id} = ctx.params
logger.debug(`get id: ${id}`)
responseSucData(ctx, '')
})
testControllerRouter.get('/user/list',async (ctx: Context) => {
logger.debug(`get list: ${JSON.stringify(ctx.query)}, querystring: ${ctx.querystring}`)
responseSucData(ctx, '')
})
testControllerRouter.get('/user/exp',async (ctx: Context) => {
logger.debug(`get exception`)
throw new Error('非预期异常')
})
testControllerRouter.get('/user/custom-exp',async (ctx: Context) => {
logger.debug(`get custom-exp`)
throw new SystemException('预期自定义异常')
})
testControllerRouter.post('/user/form',async (ctx: Context) => {
logger.debug(`post-form: ${JSON.stringify(ctx.request.body)}`)
responseSucData(ctx, '')
})
testControllerRouter.put('/user/:id', async (ctx: Context) => {
const {id} = ctx.params
logger.debug(`put id: ${id}, body: ${JSON.stringify(ctx.request.body)}`)
responseSucData(ctx, '')
})
testControllerRouter.delete('/user/:id', async (ctx: Context) => {
const {id} = ctx.params
logger.debug(`delete id: ${id}`)
responseSucData(ctx, '')
})
export default testControllerRouter
src/controller/FakerController.ts
import { Context } from 'koa'
import log4js from 'log4js'
import KoaRouter from 'koa-router'
// 日志配置
const logger = log4js.getLogger()
logger.level = 'debug'
const fakerControllerRouter = new KoaRouter({ prefix: '/v1/faker' })
/**
* 该路由可以匹配所有如下形式的路由:(这里路由用的是正则)
* /v1/faker/
* /v1/faker/xx
* /v1/faker/xxx
* /v1/faker/xxx/xxx
* /v1/faker/后可以是任意长度任意形式的uri
*/
fakerControllerRouter.all('/(.*)',async (ctx: Context)=>{
logger.debug('debug日志')
logger.info('info日志')
console.log(ctx.url)
ctx.body = 'faker Ts Index'
})
export default fakerControllerRouter
src/controller/OtherFakerController.ts
import { Context } from 'koa'
import log4js from 'log4js'
import KoaRouter from 'koa-router'
// 日志配置
const logger = log4js.getLogger()
logger.level = 'debug'
const otherFakerControllerRouter = new KoaRouter({ prefix: '/v1/other/faker' })
/**
* 该路由可以匹配所有如下形式的路由:(这里路由用的是正则)
* /v1/other/faker/
* /v1/other/faker/xx
* /v1/other/faker/xxx
* /v1/other/faker/xxx/xxx
* /v1/other/faker/后可以是任意长度任意形式的uri
*/
otherFakerControllerRouter.all('/:projectFlag/(.*)',async (ctx: Context)=>{
const { projectFlag } = ctx.params
console.log(ctx.url)
logger.info(`url:${ctx.url}, projectFlag:${projectFlag}, params:${JSON.stringify(ctx.params)}`)
ctx.body = 'other faker Ts Index'
})
export default otherFakerControllerRouter
src/app.ts
import Koa, {Context} from 'koa'
import KoaBody from 'koa-body'
import Cors from 'koa2-cors'
import Path from 'path'
import testController from 'controller/TestController'
import {getLogger} from "@/util/Log4jsUtil";
import AddressIP from 'ip'
import Session from "koa-session";
import {errorHandler} from "@/middlewares/ErrorHandler";
const logger = getLogger('app')
const app = new Koa()
const CONFIG = {
key: 'sessionId',
maxAge: 86400000,
autoCommit: true,
overwrite: true,
httpOnly: true,
signed: true,
rolling: false,
renew: false
}
app.use(Session(CONFIG, app))
// 文件上传配置
app.use(
KoaBody({
// 支持文件格式
multipart: true,
formidable: {
// 上传目录
uploadDir: Path.join(__dirname, '../public/uploads'),
// 保留文件扩展名
keepExtensions: true,
// 设置上传文件大小最大限制,默认2M
maxFileSize: 200 * 1024 * 1024,
},
})
)
// 跨域配置
app.use(
Cors({
origin: function (ctx: Context) {
return '*'
},
exposeHeaders: ['Authorization'],
maxAge: 5 * 24 * 60 * 60,
credentials: true,
allowMethods: ['GET', 'POST', 'OPTIONS', 'DELETE', 'PUT'],
allowHeaders: [
'Content-Type',
'Authorization',
'Accept',
'X-Requested-With',
],
})
)
// 统一异常处理一定要放在所有路由配置之前
app.use(errorHandler)
// 路由配置
app.use(testController.routes()).use(testController.allowedMethods())
const port = 3000
app.listen(port, () => {
logger.info(`\nkoa服务启动: \n${AddressIP.address()}:${port}\n127.0.0.1:${port}`)
})
验证
pnpm run watch 启动项目, 访问: http://localhost:3000/v1/test 出现如下界面,表示项目启动成功
在webstorm中debug调试
节点形参:$ContentRoot$\node_modules\nodemon\bin\nodemon.js -r tsconfig-paths/register -r ts-node/register
接口测试
增
http://localhost:3000/v1/test/user
{
"name":"张三",
"age":12,
"birthday":"2000-01-01",
"sex": 1
}
通过id查询
http://localhost:3000/v1/test/user/detail/12
http://localhost:3000/v1/test/user/12
{
"name":"张三",
"age":12,
"birthday":"2000-01-01",
"sex": 1
}
通过id更新
http://localhost:3000/v1/test/user/12
通过id删除
http://localhost:3000/v1/test/user/detail/12
非预期异常测试
http://localhost:3000/v1/test/user/exp
预期异常测试
http://localhost:3000/v1/test/user/custom-exp
列表查询
http://localhost:3000/v1/test/user/list?kw=张三&hobby=吃饭&hobby=睡觉
form传参
http://localhost:3000/v1/test/user/form
参考文章
【实战篇】koa2+Ts项目的优雅使用和封装 - 掘金 (juejin.cn)
ts-node不支持alias,编译报错 - 掘金 (juejin.cn)
CommonJS vs native ECMAScript modules | ts-node (typestrong.org)