创建一个以ts和es6为语法且能使用路径别名的koa2项目

950 阅读4分钟

项目源代码

gitee.com/free_pan/ko…

创建项目

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 出现如下界面,表示项目启动成功

image.png

在webstorm中debug调试

image.png

节点形参:$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
}

image.png

通过id查询

http://localhost:3000/v1/test/user/detail/12

image.png

http://localhost:3000/v1/test/user/12

{
  "name":"张三",
  "age":12,
  "birthday":"2000-01-01",
  "sex": 1
}

通过id更新

image.png

http://localhost:3000/v1/test/user/12

通过id删除

http://localhost:3000/v1/test/user/detail/12

image.png

非预期异常测试

http://localhost:3000/v1/test/user/exp

image.png

预期异常测试

http://localhost:3000/v1/test/user/custom-exp

image.png

列表查询

http://localhost:3000/v1/test/user/list?kw=张三&hobby=吃饭&hobby=睡觉

image.png

form传参

http://localhost:3000/v1/test/user/form

image.png

参考文章

【实战篇】koa2+Ts项目的优雅使用和封装 - 掘金 (juejin.cn)

ts-node不支持alias,编译报错 - 掘金 (juejin.cn)

CommonJS vs native ECMAScript modules | ts-node (typestrong.org)

koa-router如何匹配任意路径

使用Koa2 怎么实现一个文件上传下载功能

记一次线上Sequelize连接池“ResourceRequest timed out”排查历程