一、前语

绝大部分项目中, 用户、人物、权限等概念和功用都是必要的, 它体系的地基.

若依的权限办理体系给了我不少启发, 我方案用自己熟悉的 js/ts 去建立一个后台办理体系, 其权限模块思路借鉴于若依的权限办理部分, 并写下博客, 记载心得.

由于我方案项目创立做起, 流程较长, 我方案将整个项目分为 2-3 节, 本文作为第一节, 首要介绍了根底服务的建立, 包含 koa2 + ts 后端环境的建立和常用中间件的运用和封装

文中若有过错或许可优化之处, 望请不吝赐教

二、环境建立

1. 创立包办理文件

# 生成package.json
npm init -y

新建 src 文件夹, 在 src 根目录新建 app.ts 文件, app.ts 便是整个项目的入口

2. 设备依靠

# 依靠
npm i koa koa-bodyparser koa-router koa-session koa-static koa2-cors log4js mongodb mysql svg-captcha validator ajv ioredis jsonwebtoken
# 依靠注解
npm i --save-dev @types/koa @types/koa-bodyparser @types/koa-router @types/koa-session @types/koa-static @types/koa2-cors @types/log4js @types/mongodb @types/mysql @types/validator @types/ajv @types/ioredis @types/jsonwebtoken

3. 初始化 ts 装备

# 生成tsconfig.json
tsc --init

修正 tsconfig.json

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2015", // 目标语言版别
    "module": "commonjs", // 指定生成代码的模板标准
    "rootDir": "./", // 指定输出目录, 默许是dist文件夹
    "strict": true, // 严厉模式
    "allowSyntheticDefaultImports": true, // 没有默许导出时, 编译器会创立一个默许导出
    "esModuleInterop": true, // 答应export= 导出, 由import from导入
    "forceConsistentCasingInFileNames": true // 强制区别大小写
  },
  "include": [
    // 需求编译的的文件和目录
    "src/**/*"
  ],
  "files": ["src/app.ts"]
}

4. 运转项目

在 app.ts 里实例化一个 koa 服务器

// src/app.ts
// 引进koa
import Koa from 'koa'
import http from 'http'
// 创立koa实例
const app = new Koa()
// 创立服务器
const server: http.Server = new http.Server(app.callback())
// 中间件
app.use(async (ctx) => {
  ctx.body = 'Hello World'
})
// 监听端口
app.listen(9000, () => {
  console.log('run success')
  console.log('app started at port 9000...')
})

运转

# 编译ts文件
tsc
# 运转编译的文件
node ./dist/src/app.js

假如成功的话, 指令行会打印

run success
app started at port 9000...

咱们能够在浏览器拜访 http://localhost:9000/
此刻页面上应该会显现 Hello World

每次都需求履行编译和运转, 太费事, 咱们能够在修正 package.json, 增加 dev 指令

// package.json
{
  ...
  "scripts": {
    "dev": "tsc && node ./dist/src/app.js"
  },
  ...
}

之后每次运转 npm run dev 即可
可是这样依然繁琐, 咱们期望能够主动监听文件的保存并主动刷新, 这个能够用 nodemon 和 ts-node 来完成
前者能够监听文件的改变, 主动重启服务, 后者能够直接运转 ts 文件

设备 nodemon

npm i nodemon ts-node typescript --save-dev

修正 package.json 的 dev 指令

// package.json
{
  ...
  "scripts": {
    "dev": "nodemon ./src/app.ts"
  },
  ...
}

5. 代码风格操控和主动格局化

为了操控代码风格和质量, 咱们能够引进 eslint 和 prettier

npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier prettier --save-dev

在根目录创立 .eslintrc.js

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    node: true,
    es2021: true,
  },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 12,
    sourceType: 'module',
    tsconfigRootDir: __dirname,
    project: ['./tsconfig.json'],
  },
  plugins: ['@typescript-eslint'],
  rules: {
    '@typescript-eslint/no-unsafe-assignment': 'off',
    '@typescript-eslint/no-non-null-assertion': 'off',
    'no-useless-escape': 'off',
    '@typescript-eslint/no-unsafe-member-access': 'off',
    '@typescript-eslint/unbound-method': 'off',
    '@typescript-eslint/await-thnable': 'off',
    '@typescript-eslint/restrict-template-expressions': 'off',
    '@typescript-eslint/no-misused-promises': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
    '@typescript-eslint/no-unsafe-call': 'off',
    '@typescript-eslint/no-unsafe-argument': 'off',
    'no-async-promise-executor': 'off',
    '@typescript-eslint/no-floating-promises': 'off',
    '@typescript-eslint/require-await': 'off',
    '@typescript-eslint/no-var-requires': 'off',
    '@typescript-eslint/ban-types': 'off',
    'no-prototype-builtins': 'off',
    'space-before-function-paren': 0,
  },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'prettier',
  ],
}

在根目录创立.prettierrc 和.editorconfig

// .prettierrc
{
  "printWidth": 120,
  "semi": false,
  "singleQuote": true,
  "prettier.spaceBeforeFunctionParen": true
}
# .editorconfig
root = true
[*.{js,ts,json}]
charset=utf-8
end_of_line=lf
insert_final_newline=false
indent_style=space
trim_trailing_whitespace = true
indent_size=2
insert_final_newline = true

修正 vscode 装备文件 setting.json, 在结尾增加下列行

// setting.json
{
  ...
  "eslint.format.enable": true,
  "editor.codeActionsOnSave": {
    // 启用ESLint规则格局化以上设为none的代码
      "source.fixAll.eslint": true
  },
  "editor.tabSize": 2,
  // 保存时格局化代码
  "editor.formatOnSave": true,
  "eslint.trace.server": "off",
  // 张贴时格局化代码
  "editor.formatOnPaste": false,
  "[typescript]": {
      "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
}

设备 vscode 插件 Prettier – Code formatter

修正 src/app.ts, 在结尾增加下列代码

// src/app.ts
let a = {
  num: 1,
}

此刻 eslint 会提示有 2 个警告 1 个过错, 代码有 3 处不符合咱们设置的标准

  • ‘a’ is never reassigned. Use ‘const’ instead.
  • num 这行应该运用 2 个空格作为缩进, 而不是 4 个空格
  • 完毕行应该没有分号作为完毕

当咱们 ctrl+s 保存文件, 会发现修正器将代码修正为

// src/app.ts
const a = {
  num: 1,
}

不标准的当地被修复了, 这表明咱们的装备成功了

6. 项目编译

在正式环境里布置项目, 咱们不再需求对文件修正进行监听, 而是直接编译文件. 因而咱们增加一个 build 指令

// package.json
{
  ...
  "scripts": {
    "dev": "nodemon ./src/app.ts",
    "build": "tsc && node ./dist/src/app.js"
  },
  ...
}

假如项目是布置 window 环境下, 直接 ctrl+c 就能够中止项目, 可是假如在 linux 环境下, 重启项目就比较费事, 能够考虑运用 pm2 来办理 node 服务

# 全局设备pm2
npm install pm2 -g
# 项目根目录
# 运转项目
npm run build
# 检查项目运转状况
pm2 list
# 重启项目
pm2 restart ./dist/src/app.js

7. 环境变量

为了区别正式环境和开发环境, 咱们能够运用 cross-env 增加环境变量

# 设备cross-env
npm i cross-env --save-dev

修正 dev 和 build 指令

// package.json
{
  ...
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon ./src/app.ts",
    "build": "tsc && cross-env NODE_ENV=production node dist/src/app.js"
  },
  ...
}

修正 src/app.ts

// src/app.ts
import Koa from 'koa'
import http from 'http'
// 创立koa实例
const app = new Koa()
// 创立服务器
const server: http.Server = new http.Server(app.callback())
// 中间件
app.use(async (ctx) => {
  ctx.body = 'Hello World'
})
// 监听端口
app.listen(9000, () => {
  console.log('run success')
  console.log('app started at port 9000...')
  console.log(process.env.NODE_ENV)
})

咱们就能看到操控台打印出来的 development。

8. 断点调试

在开发过程种, 当咱们遇到过错的时候, 能够运用修正器的断点调试功用, 在不修正代码的状况下就能做到暂停和逐步调试, 能极大地提升开发体会和效率

  1. 打开 vscode, 按下 ctrl+shift+d 或许直接点击左边的”运转和调试:运转”按钮, 左边会呈现”运转和调试:运转”窗口;
  2. 点击”运转和调试”, 点击”创立 launch.json 文件”, 挑选环境-“node.js”。vscode 会主动在根目录出产.vscode 文件夹, 和.vscode/launch.json 文件
  3. 在左边”运转和调试”窗口, 点击”Launch Program”(挑选运转类型), 挑选 node.js, 挑选发动装备为”Run Script: Dev”
  4. 按下 F5 或许点击左边的调试按钮

此刻项目会以调试模式发动, 点击需求打断点的代码的行数左边, 就会生成一个红点, 阐明断点设置成功

至此,根底环境建立基本完成

三、中间件开发

1. 中间件的含义和作用

中间件是一种封装办法, 用于处理 http 恳求的功用
假如将一个 http 恳求比喻为一条运水的管道, 那么中间件便是管道上的外表、阀门等处理设备
中间件首要有 3 个中心概念: 恳求 request、呼应 response 和 next 函数
request 和 response 是恳求的上下文信息 Context, next 函数用于操控状况.
假如一个恳求, 没有设置 response, 那么这个恳求就会回来 404
假如一个恳求, 进入某个中间件后, 只有当履行 next 函数后, 恳求才会继续履行下一个中间件. 假如没有履行 next 函数, 而是设置 response 的状况码和回来值, 那么恳求就会完毕, 不再继续履行下面的中间件, 而是直接把 response 回来给前端.

2. koa 如何运用中间件

koa 运用 use 办法载入中间件

// 中间件middleware
const middleware1 = (ctx, next) => {
  console.log('1')
  next()
}
// 中间件middleware2
const middleware2 = (ctx, next) => {
  console.log('2')
  ctx.body = 'hello world'
}
app.use(middleware1)
app.use(middleware2)

值得注意的是, 中间件先被 use 的, 就先履行

下面咱们介绍一些常用的中间件, 并开发一些中间件

为了模块清晰, 咱们在 src 目录下创立 core 文件夹, 用户寄存中心静态类
在 src/core 目录下, 新建 Init.ts, 用于初始化中间件

3. 恳求报文处理中间件

http 恳求里, 报文主体(即 body 参数部分)是以二进制的数据在网络中进行传输, 而且为了优化速度, 还常常会对内容进行紧缩编码, 比方 gzip
koa-bodyparser 中间件, 会将 post 恳求的恳求报文进行处理, 将恳求主体以 json 格局, 挂载在 ctx.request.body 上

// src/core/Init.ts
import Koa from 'koa'
import http from 'http'
import koaBodyParser from 'koa-bodyparser'
class Init {
  public static app: Koa<Koa.DefaultState, Koa.DefaultContext>
  public static server: http.Server
  public static initCore(app: Koa<Koa.DefaultState, Koa.DefaultContext>, server: http.Server) {
    Init.app = app
    Init.server = server
    Init.loadBodyParser()
  }
  // 解析body参数
  public static loadBodyParser() {
    Init.app.use(koaBodyParser())
  }
}
export default Init.initCore
// src/app.ts
import Koa from 'koa'
import http from 'http'
import initCore from './core/Init'
// 创立koa实例
const app = new Koa()
// 创立服务器
const server: http.Server = new http.Server(app.callback())
// 履行初始化
initCore(app, server)
// 监听端口
app.listen(9000, () => {
  console.log('run success')
  console.log('app started at port 9000...')
  console.log(process.env.NODE_ENV)
})

4. 路由中间件

服务器端路由, 即依据不同途径的 http 恳求做出对应的相应和处理

// src/core/Init.ts
class Init {
  ...
  static async initLoadRouters() {
    Init.app.use((ctx) => {
      console.log(ctx.path)
      switch (ctx.path) {
        case '/login':
          // 只答应post恳求
          if (ctx.method === 'GET') {
            ctx.status = 404
            break
          }
          ctx.body = '登录成功'
          break
        case '/getUser':
          // 只答应get恳求
          if (ctx.method === 'POST') {
            ctx.status = 404
            break
          }
          ctx.body = 'admin'
          break
        default: {
          ctx.status = 404
        }
      }
    })
  }
  ...
}

可是这样做, 一切的处理都在一起, 在实践开发中并不合适。咱们期望把不同的恳求处理函数放在不同的目录和文件里, 而且能方便地设置恳求途径和恳求办法
koa-router 是 koa 的一个路由中间件, 它能够将恳求的 URL 和办法(如:GET 、 POST 、 PUT 、 DELETE 等) 匹配到对应的呼应程序或页面

在 src 下新建目录 api, 在 api 下新建目录 v1, 表明接口的版别, 当时版别的接口都放在 v1 目录下

在 v1 下新建文件 text.ts, 在这个文件里, 咱们创立一个路由实例

// src/api/v1/test.ts
import Router from 'koa-router'
const router = new Router({
  prefix: '/api/v1',
})
router.get('/test', async (ctx) => {
  ctx.body = 'test'
})
export default router

然后在 init 里加载这个路由实例

// src/core/Init.ts
import test from '../api/v1/test'
class Init {
  ...
  static async initLoadRouters() {
    Init.app.use(test.routes())
  }
  ...
}

目前咱们约定, src/api 目录下, 只寄存 http 恳求路由文件, 因而手动引动过分繁琐, 咱们期望能够主动递归遍历 src/api 目录, 主动加载一切的路由处理

在 src 目录下新建 common 目录, 用于寄存公共文件, 在 common 下新建 utils 目录, 用于寄存东西函数, 再在 utils 下新建 utils.ts

咱们先在 utils 里写一个遍历目录下一切文件默许导出的办法

// src/common/utils/utils.ts
import fs from 'fs'
import path from 'path'
/**
 * 获取某个目录下一切文件的默许导出
 * @param filePath 需求遍历的文件途径
 */
export async function getAllFilesExport(filePath: string, callback: Function) {
  // 依据文件途径读取文件, 回来一个文件列表
  const files: string[] = fs.readdirSync(filePath)
  // 遍历读取到的文件列表
  files.forEach((fileName) => {
    // path.join得到当时文件的绝对途径
    const absFilePath: string = path.join(filePath, fileName)
    const stats: fs.Stats = fs.statSync(absFilePath)
    const isFile = stats.isFile() // 是否为文件
    const isDir = stats.isDirectory() // 是否为文件夹
    if (isFile) {
      const file = require(absFilePath)
      callback(file.default)
    }
    if (isDir) {
      getAllFilesExport(absFilePath, callback) // 递归, 假如是文件夹, 就继续遍历该文件夹里面的文件;
    }
  })
}

然后在 init 里调用

// src/core/Init.ts
class Init {
  ...
  static async initLoadRouters() {
    const dirPath = path.join(`${process.cwd()}/src/api/`)
    getAllFilesExport(dirPath, (file: Router) => {
      Init.app.use(file.routes())
    })
  }
  ...
}

此刻 src/api 目录下的路由会被主动调用, 可是还有一个问题, 便是在 build 后, 目录会变化, 变成/dist/src/api/, 咱们需求依据环境变量, 操控加载目录

其它需求运用绝对途径的当地, 都会有这个问题, 所以咱们能够创立一个公共变量, 利于复用

在 src 下创立目录 config, 在 config 下创立 Config.ts, 用于寄存装备和公共变量

// src/config/Config.ts
const isDev = process.env.NODE_ENV === 'development'
export default class Config {
  // 服务器端口
  public static readonly HTTP_PORT = 9000
  // 接口前缀
  public static readonly API_PREFIX = '/api/'
  // 根目录
  public static readonly BASE = isDev ? 'src' : 'dist/src'
}

修正 app.ts

// src/app.ts
import Koa from 'koa'
import http from 'http'
import initCore from './core/Init'
import Config from './config/Config'
// 创立koa实例
const app = new Koa()
// 创立服务器
const server: http.Server = new http.Server(app.callback())
// 履行初始化
initCore(app, server)
// 监听端口
app.listen(Config.HTTP_PORT, () => {
  console.log('run success')
  console.log(`app started at port ${Config.HTTP_PORT}...`)
  console.log(process.env.NODE_ENV)
})

修正 src/api/v1/test.ts

// src/api/v1/test.ts
import Router from 'koa-router'
import Config from '../../config/Config'
const router = new Router({
  prefix: `${Config.API_PREFIX}v1`, // 途径前缀
})
// 指定一个url和恳求办法匹配处理
router
  .get('/test', (ctx) => {
    ctx.body = 'test'
  })
  .post('/login', (ctx) => {
    ctx.body = '登录'
  })
export default router

修正 src/core/Init.ts

// src/core/Init.ts
import Koa from 'koa'
import http from 'http'
import koaBodyParser from 'koa-bodyparser'
import path from 'path'
import { getAllFilesExport } from '../common/utils/utils'
import Router from 'koa-router'
import Config from '../config/Config'
class Init {
  public static app: Koa<Koa.DefaultState, Koa.DefaultContext>
  public static server: http.Server
  public static initCore(app: Koa<Koa.DefaultState, Koa.DefaultContext>, server: http.Server) {
    Init.app = app
    Init.server = server
    Init.loadBodyParser()
    Init.initLoadRouters()
  }
  // 解析body参数
  public static loadBodyParser() {
    Init.app.use(koaBodyParser())
  }
  // http路由加载
  static async initLoadRouters() {
    const dirPath = path.join(`${process.cwd()}/${Config.BASE}/api/`)
    getAllFilesExport(dirPath, (file: Router) => {
      Init.app.use(file.routes())
    })
  }
}
export default Init.initCore

5. 过错监听和日志处理

koa 能够经过 ctx.throw()办法或许创立一个 Error 实例并运用 throw 关键字直接抛出过错, 过错会中止程序的履行.
假如过错会被 try…catch 捕获, 一旦被程序就会履行 catch 里的句子, 然后继续往下履行.

// 中间件one
const one = (ctx, next) => {
  console.log('>> one')
  next()
  console.log('<< one')
}
// 中间件two
const two = (ctx, next) => {
  console.log('>> two')
  next()
  console.log('<< two')
}
app.use(one)
app.use(two)

最终的打印结果是

>> one
>> two
<< one
<< two

ctx 的当时的上下文, next 有点类型回调函数的意思, 会在履行完 next 后再履行下一步
因而, 在第一个中间件里, 用 try…catch 将 next 包裹, 就能监听往后一切中间件的过错, 知道给 ctx.body 赋值, 整个呼应完毕
假如咱们自定义一些”过错”, 当捕获到不同”过错”时, 做出呼应的处理, 那这个中间件不仅仅能够捕获反常, 还能给接口呼应一个一致的出口.
在 src/core 目录下, 新建 HttpException.ts, 用于寄存不同的 http 过错类型

// src/core/HttpException.ts
// http反常
export class HttpException extends Error {
  public message: string
  public errorCode: number
  public code: number
  public data: any
  public isBuffer = false
  public responseType: string | undefined
  constructor(data = {}, msg = '服务器反常, 请联络办理员', errorCode = 10000, code = 400) {
    super()
    this.message = msg
    this.errorCode = errorCode
    this.code = code
    this.data = data
  }
}
// http参数反常
export class ParameterException extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 422
    this.message = msg || '参数过错'
    this.errorCode = errorCode || 10000
  }
}
// http恳求成功
export class Success extends HttpException {
  public data
  public responseType
  public session
  constructor(data?: unknown, msg = 'ok', code = 200, errorCode = 0, responseType?: string, session?: string) {
    super()
    this.code = code //200查询成功, 201操作成功
    this.message = msg
    this.errorCode = errorCode || 0
    this.data = data
    this.responseType = responseType
    this.session = session
  }
}
// 回来文件流
export class Buffer extends Success {
  public data
  public responseType
  public session
  public isBuffer
  constructor(data?: any, responseType?: string, session?: string) {
    super()
    this.code = 200 //200查询成功, 201操作成功
    this.message = 'ok'
    this.errorCode = 0
    this.data = data
    this.responseType = responseType
    this.session = session
    this.isBuffer = true
  }
}
// 404
export class NotFount extends HttpException {
  constructor(msg: string, errorCode: number) {
    super()
    this.code = 404
    this.message = msg || '资源未找到'
    this.errorCode = errorCode || 10001
  }
}
// 授权失败
export class AuthFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 401
    this.message = msg || '授权失败'
    this.errorCode = errorCode || 10002
  }
}
// Forbbiden
export class Forbbiden extends HttpException {
  constructor(msg: string, errorCode?: number) {
    super()
    this.code = 403
    this.message = msg || '制止拜访'
    this.errorCode = errorCode || 100006
  }
}
// 查询失败
export class QueryFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 500
    this.message = msg || '未查到匹配的数据'
    this.errorCode = errorCode || 100006
  }
}
// 查询失败
export class dataBaseFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 500
    this.message = msg || '数据库犯错'
    this.errorCode = errorCode || 100005
  }
}

当恳求过程中, 呈现一些咱们预料之中的状况, 比方

  • 恳求成功应该回来数据
  • 恳求参数过错或许校验失败
  • 恳求成功需求回来文件
  • 登录失效

咱们能够抛出过错, 并阻拦做出处理, 回来对应的状况码和数据。

假如是意料之外的过错,则按反常处理, 并打印日志

在 src 目录下新建目录 middlewares, 用户寄存自定义中间件, 然后在 middlewares 目录下新建 catchError.ts, 开发过错阻拦中间件

// src/middlewares/catchError.ts
import koa from 'koa'
import { Success, HttpException } from '../core/HttpException'
export async function catchError(ctx: koa.Context, next: Function) {
  const { method, path } = ctx
  try {
    await next()
  } catch (error: any) {
    // 当时过错是否是咱们自定义的Http过错
    const isHttpException = error instanceof HttpException
    // 假如不是, 则抛出过错
    if (!isHttpException) {
      ctx.body = {
        msg: '未知过错',
        errorCode: 9999,
        requestUrl: `${method} ${path}`,
      }
      ctx.status = 500
    }
    // 假如是已知过错
    else {
      if (error.responseType) {
        ctx.response.type = error.responseType
      }
      // 假如是文件流, 则直接回来文件
      if (error.isBuffer) {
        ctx.body = error.data
      } else {
        ctx.body = {
          msg: error.message,
          errorCode: error.errorCode,
          data: error.data,
        }
      }
      ctx.status = error.code
    }
  }
}

然后再 Init 里运用中间件

// src/core/Init.ts
class Init {
  ...
  Init.initCatchError()
  ...
  ...
  // 过错监听和日志处理
  public static initCatchError() {
    Init.app.use(catchError)
  }
  ...
}

咱们修正下 src/api/v1/test.ts 接口, 依据不同的恳求抛出不同的处理

// src/api/v1/test.ts
import { Success, ParameterException, AuthFailed } from '../../core/HttpException'
router.get('/test', (ctx) => {
  const { id } = ctx.request.body
  const token = ctx.header['authorization'] || ctx.cookies.get('authorization')
  // 假如没有带着登录信息
  if (!token) {
    throw new AuthFailed('未登录')
  }
  // 假如短少参数或许参数类型过错
  if (typeof id !== 'number') {
    throw new ParameterException('短少参数id')
  }
  // 恳求成功
  throw new Success('text')
})

打开浏览器拜访http://localhost:9000/api/v1/test,能够看到接口回来

{“msg”:”未登录”,”errorCode”:10002,”data”:{}}

这样当一切的过错, 以及 http 恳求呼应,都会会集在 catchError 中间件中处理,依据咱们设置的 HttpException 回来对应的数据.

除去操控太和回来客户端的过错信息之外,咱们还需求一个日志体系,将过错记载在一个固定目录下,便于日后检查

在 src/common 下创立文件夹 lib, 然后新建 logs.ts, 用于寄存日志装备.咱们的日志体系首要依靠于 log4js.

在 src 下新建 server 目录, 用户寄存单例和公共事务
在 src/server 下, 新建 logs 目录, 用户寄存日志相关的事务
src/server/logs 下, 新建 logsConfing.ts, 用户寄存 log4js 的装备

// src/server/logs/logsConfing.ts
export default {
  appenders: {
    console: {
      type: 'console',
    },
    date: {
      type: 'dateFile',
      filename: 'logs/date',
      category: 'normal',
      alwaysIncludePattern: true,
      pattern: '-yyyy-MM-dd-hh.log',
    },
  },
  categories: {
    default: {
      appenders: ['console', 'date'],
      level: 'info',
    },
  },
}

log4js 装备项很多, 这儿只介绍了 appenders 和 categories 装备

  • appenders 能够在 appenders 增加特点设置日志类型.咱们这儿增加了 console 和 date,并经过 type 设置记载类型.type 为 console 时,表明记载在操控台;type 为 dateFile, date 表明记载在文件里,并依据时刻将日志分片

  • categories 经过 categories.defult.appenders,能够设置启用的 appenders.经过 categories.defult.level,能够对日志进行过滤

在 src/server/logs 目录下, 新建 logger.ts, 用于获取 logger.
由于咱们的装备, 是在 logs 目录下新建日期日志, 而 logs 文件并不需求记载在 git 版别里, 咱们也没有手动创立 logs 目录. 当代码运转时, 因为 logs 目录不存在, 就会报错. 所以在服务发动时,需求先判别 logs 目录是否存在.

// src/server/logs/logger.ts
import log4js from 'log4js'
import fs from 'fs'
import { isDirectory } from '../../common/utils/utils'
import logsConfing from './logsConfing'
//检查某个目录是否存在
if (!isDirectory('logs')) {
  fs.mkdirSync('logs')
}
log4js.configure(logsConfing)
const logger = log4js.getLogger('cheese')
export default logger
// src/common/utils/utils.ts
/**
 * 判别某个文件夹是否存在
 * @param path
 * @returns {boolean}
 */
export function isDirectory(path: string): boolean {
  try {
    const stat = fs.statSync(path)
    return stat.isDirectory()
  } catch (error) {
    return false
  }
}

日志的内容, 除去过错信息外, 咱们还期望知道过错是调用哪个接口产生的, 因而咱们还需求拿到上下文信息 Context, 即恳求信息和呼应信息. Context 运用的频率很高, 咱们能够将这些常用的类型整合起来
在 src/common 目录下, 新建 typings 目录, 用于寄存公共的描述文件
在 src/common/typings 下, 新建 model.d.ts

// src/common/typings/model.d.ts
import Koa from 'koa'
export namespace Models {
  type Ctx = Koa.Context
}

然后在 src/server/logs 目录下, 创立 index.ts, 用于封装具体事务

// src/server/logs/index.ts
import logger from './logger'
import { Models } from '../../common/typings/model'
/**
 * 记载信息日志
 * @param ctx
 */
export function infoLog(ctx: Models.Ctx) {
  const { method, response, originalUrl } = ctx
  logger.info(method, response.status, originalUrl)
}
/**
 * 记载过错日志
 * @param ctx
 * @param error
 */
export function errorLog(ctx: Models.Ctx, error: any) {
  const { method, response, originalUrl } = ctx
  logger.error(method, response.status, originalUrl, error)
}

然后在 catchError 中间件里,咱们引进 logger

// src/middlewares/catchError
import logger from '../common/lib/logs'
export default async function catchError(ctx: koa.Context, next: Function) {
  const { method, path } = ctx
  try {
    await next()
  } catch (error: any) {
    // 当时过错是否是咱们自定义的Http过错
    const isHttpException = error instanceof HttpException
    // 假如不是, 则抛出过错
    if (!isHttpException) {
      logger.error(method, ctx.response.status, ctx.originalUrl, error)
      ...
    }
    // 假如是已知过错
    else {
      ...
      if (error instanceof Success || error instanceof Buffer) {
        logger.info(method, ctx.response.status, ctx.originalUrl)
      } else {
        logger.error(method, ctx.response.status, ctx.originalUrl, error)
      }
    }
  }
}

当服务重启后, 咱们能够看到, 项目主动在根目录生成了 logs 文件夹和 date.-2022-05-05-13.log(称号便是当时时刻), 在当咱们调用接口时,就能在 logs 里看到日志文件 date.-2022-05-05-13.log 内容更新了

[2022-05-05T13:41:54.102] [ERROR] cheese - GET 401 /api/v1/test AuthFailed [Error]: 未登录
    at F:\code\git-demo\node\demo\src\api\v1\test.ts:14:13
    at dispatch (F:\code\git-demo\node\demo\node_modules\koa-compose\index.js:42:32)
    at F:\code\git-demo\node\demo\node_modules\koa-router\lib\router.js:372:16
    at dispatch (F:\code\git-demo\node\demo\node_modules\koa-compose\index.js:42:32)
    at F:\code\git-demo\node\demo\node_modules\koa-compose\index.js:34:12
    at dispatch (F:\code\git-demo\node\demo\node_modules\koa-router\lib\router.js:377:31)
    at dispatch (F:\code\git-demo\node\demo\node_modules\koa-compose\index.js:42:32)
    at F:\code\git-demo\node\demo\src\middlewares\catchError.ts:6:11
    at Generator.next (<anonymous>)
    at F:\code\git-demo\node\demo\src\middlewares\catchError.ts:8:71 {
  isBuffer: false,
  errorCode: 10002,
  code: 401,
  data: undefined
}

四、引进数据库

1. Mysql

在 src/server 下, 新建 mysql 目录, 在 mysql 下新建 mysqlConfing.ts, 用于寄存装备文件

// src/config/Config.ts
export default class Config {
  ...
  // mysql装备
  public static readonly MYSQL = {
    DB_NAME: 'ts',
    HOST: '127.0.0.1',
    PORT: 3306,
    USER_NAME: 'admin',
    PASSWORD: 'admin',
    CONNECTION_LIMIT: 60 * 60 * 1000,
    CONNECT_TIMEOUT: 1000 * 60 * 60 * 1000,
    ACQUIRE_TIMEOUT: 60 * 60 * 1000,
    TIMEOUT: 1000 * 60 * 60 * 1000,
  }
}
// src/server/mysql/mysqlConfing.ts
import Config from '../../config/Config'
export default {
  host: Config.MYSQL.HOST,
  port: Config.MYSQL.PORT,
  user: Config.MYSQL.USER_NAME,
  password: Config.MYSQL.PASSWORD,
  database: Config.MYSQL.DB_NAME,
  multipleStatements: true, // 运转履行多条句子
  connectionLimit: Config.MYSQL.CONNECTION_LIMIT,
  connectTimeout: Config.MYSQL.CONNECT_TIMEOUT,
  acquireTimeout: Config.MYSQL.ACQUIRE_TIMEOUT,
  timeout: Config.MYSQL.TIMEOUT,
}

在 src/server/mysql 下新建 pool.ts, 创立 mysql 衔接池实例

// src/server/mysql/pool.ts
import mysql from 'mysql'
import mysqlConfing from './mysqlConfing'
const pool = mysql.createPool(mysqlConfing)
export default pool

在 src/server/mysql 下新建 index.ts, 用于封装常用办法和公共事务

// src/server/mysql/index.ts
import { Models } from '../../common/typings/model'
import pool from './pool'
import { DataBaseFailed } from '../../core/HttpException'
import { lineToHumpObject } from '../../common/utils/utils'
import mysql from 'mysql'
/*
 * 数据库增删改查
 * @param command 增删改查句子
 * @param value 对应的值
 */
export async function command(command: string, value?: Array<any>): Promise<Models.Result> {
  try {
    return new Promise<Models.Result>((resolve, reject: (error: Models.MysqlError) => void) => {
      pool.getConnection((error: mysql.MysqlError, connection: mysql.PoolConnection) => {
        // 假如衔接犯错, 抛出过错
        if (error) {
          const result: Models.MysqlError = {
            error,
            msg: '数据库衔接犯错' + ':' + error.message,
          }
          reject(result)
        }
        const callback: mysql.queryCallback = (err, results?: any, fields?: mysql.FieldInfo[]) => {
          connection.release()
          if (err) {
            const result: Models.MysqlError = {
              error: err,
              msg: err.sqlMessage || '数据库增删改查犯错',
            }
            reject(result)
          } else {
            const result: Models.Result = {
              msg: 'ok',
              state: 1,
              // 将数据库里的字段, 由下划线更改为小驼峰
              results: results instanceof Array ? results.map(lineToHumpObject) : results,
              fields: fields || [],
            }
            resolve(result)
          }
        }
        // 履行mysql句子
        if (value) {
          pool.query(command, value, callback)
        } else {
          pool.query(command, callback)
        }
      })
    }).catch((error) => {
      throw new DataBaseFailed(error.msg)
    })
  } catch {
    throw new DataBaseFailed()
  }
}

其它相关文件修正:

// src/config/Config.ts
export default class Config {
  ...
  // 默许时刻格局
  public static readonly DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'
}

设备 moment, 用于对时刻的处理

npm i moment
npm i @types/moment --save-dev

在 src/utils 下新增 date.ts, 用于寄存时刻相关的东西函数

// src/common/utils/date.ts
import moment from 'moment'
import Config from '../../config/Config'
/**
 * 格局化时刻
 * @param date
 * @param pattern
 * @returns
 */
export function format(date: Date, pattern = Config.DEFAULT_DATE_FORMAT) {
  return moment(date).format(pattern)
}

mysql 存储的字段, 一般是以下划线的格局存储, 而 js 代码中, 一般是以驼峰式命名变量名, 所以需求对数据做一层转换

// src/common/utils/utils.ts
...
export function isValidKey(key: string | number | symbol, object: object): key is keyof typeof object {
  return key in object
}
/**
 * 下划线转驼峰
 * @param str
 * @returns
 */
export function lineToHump(str: string): string {
  if (str.startsWith('_')) {
    return str
  }
  return str.replace(/\_(\w)/g, (all, letter: string) => letter.toUpperCase())
}
/**
 * 驼峰转下划线
 * @param str
 * @returns
 */
export function humpToLine(str = ''): string {
  if (typeof str !== 'string') {
    return str
  }
  return str.replace(/([A-Z])/g, '_$1').toLowerCase()
}
/**
 * 将目标的一切特点由下划线转换成驼峰
 * @param obj
 * @returns
 */
export function lineToHumpObject(obj: Object) {
  let key: string
  const element: {
    [key: string]: any
  } = {}
  for (key in obj) {
    if (obj.hasOwnProperty(key)) {
      if (isValidKey(key, obj)) {
        const value = obj[key]
        if (typeof key === 'string' && (key as string).indexOf('_at') > -1) {
          element[lineToHump(key)] = format(value)
        } else {
          element[lineToHump(key)] = value
        }
      }
    }
  }
  return {
    ...element,
  }
}
/**
 * 将目标的一切特点由驼峰转换为下划线
 * @param obj
 * @returns
 */
export function humpToLineObject(obj: Object) {
  let key: string
  const element: {
    [key: string]: any
  } = {}
  for (key in obj) {
    if (obj.hasOwnProperty(key)) {
      if (isValidKey(key, obj)) {
        const value = obj[key]
        element[humpToLine(key)] = value || null
      }
    }
  }
  return {
    ...element,
  }
}

2. Redis

在 src/server 下, 新建 redis 目录, 在 redis 下新建 redisConfing.ts, 用于寄存装备文件

// src/config/Config.ts
export default class Config {
  ...
  // redis
  public static readonly REDIS = {
    PORT: 6379,
    HOST: '127.0.0.1',
    PASSWORD: 'admin',
    DB: 0,
  }
}
// src/server/redis/redisConfing.ts
import Config from '../../config/Config'
export default {
  port: Config.REDIS.PORT, // Redis port
  host: Config.REDIS.HOST, // Redis host
  password: Config.REDIS.PASSWORD,
  db: Config.REDIS.DB,
}

在 src/server/redis 下新建 redis.ts, 创立 redis 实例

// src/server/redis/redis.ts
import redisConfing from './redisConfing'
import IoreDis from 'IoreDis'
export default new IoreDis(redisConfing)

在 src/server/redis 下新建 index.ts, 用于封装常用办法和公共事务

// src/server/redis/index.ts
import Config from '../../config/Config'
import { DataBaseFailed } from '../../core/HttpException'
import redis from './redis'
/**
 * redis报错回调
 * @param err
 */
export function redisCatch(err: Error) {
  throw new DataBaseFailed(err.message)
}
/**
 * 挑选数据库
 * @param DbName
 * @returns
 */
export async function selectDb(DbName: number) {
  return new Promise((resolve) => {
    redis
      .select(DbName)
      .then(() => {
        resolve(true)
      })
      .catch(redisCatch)
  })
}

五、总结

本文首要介绍了 koa2+ts 后端项目的环境建立和常用中间件, 下一篇会在本文根底上, 介绍注册和登录体系的设计和开发

本文的完好代码地址 github koa-ts-learn step1