本节目标是实现一个简单的路由自动装载服务端,包含完整装载日志,请求日志,自动绑定controller到路由,只需按照抽象类书写controller即可。先上【GitHub地址】
Koa 是下一代的 Node.js 的 Web 框架。由 Express 团队设计。旨在提供一个更小型、更富有表现力、更可靠的 Web 应用和 API 的开发基础。它的特点是优雅、简洁、表达力强、自由度高。跟express相比,它是一个更轻量的node框架,因为它所有功能都通过插件实现,这种插拔式的架构设计模式。
多个中间件会形成一个栈结构(middle stack),以"先进后出"(first-in-last-out)的顺序执行。
最外层的中间件首先执行。
调用next函数,把执行权交给下一个中间件。
...
最内层的中间件最后执行。
执行结束后,把执行权交回上一层的中间件。
...
最外层的中间件收回执行权之后,执行next函数后面的代码
更多关于koa的介绍,推荐阮一峰大神的koa 框架教程,关于koa的洋葱模型,推荐看这片文章Gopal -【Node】深入浅出 Koa 的洋葱模型
如下代码:
import Koa from 'koa'
const app = new Koa()
app.use((ctx, next) => {
ctx.body = 'hello koa!'
})
app.listen(3000, () => {
console.log('服务启动了!')
})
使用 ts-node 跑起来后,访问 http://127.0.0.1:3000 时,就会得到 hello koa! 的输出了。
Koa 应用程序不是 HTTP 服务器的1对1展现。 可以将一个或多个 Koa 应用程序安装在一起以形成具有单个HTTP服务器的更大应用程序。这里的 app.listen(...) 方法只是以下方法的语法糖:
import http from 'http'
http.createServer(app.callback()).listen(3000, () => {
console.log('服务启动了!')
});
这意味着您可以将同一个应用程序同时作为 HTTP 和 HTTPS 或多个地址:
import Koa from 'koa'
import http from 'http'
const app = new Koa()
app.use((ctx, next) => {
ctx.body = 'hello koa!'
})
http.createServer(app.callback()).listen(3000, () => {
console.log('服务启动了!', 3000)
});
http.createServer(app.callback()).listen(3001, () => {
console.log('服务启动了!', 3001)
});
实例一:实现一个计算整个请求处理所花时间的中间件
export function responseTime() {
return (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set('X-Response-Time', `${ms}ms`);
}
}
实例二:实现一个请求日志中间件
export function reponseLogger() {
return (ctx, next) => {
const start = Date.now();
await next()
const ms = Date.now() - start;
// 记录请求和响应日志
ctx.logger.info(`
======>
timestamp: ${new Date()}
request method: ${ctx.method}
request url: ${ctx.path}
request query: ${JSON.stringify(ctx.query)}
request body: ${JSON.stringify(ctx.request.body)}
<======
response body: ${JSON.stringify(ctx.body)},
response time: ${ms}ms
`)
}
}
通过以上事例,可以看出首先请求流通过 x-response-time 和 logger 中间件来请求何时开始,然后继续移交控制给 response 中间件。当一个中间件调用 next() 则该函数暂停并将控制传递给定义的下一个中间件。当在下游没有更多的中间件执行后,堆栈将展开并且每个中间件恢复执行其上游行为。
基于以上的了解,开始封装基于koa的服务运用,首先我们在server下建立application.ts
:
export class Application {
// koa实例
public app
// config
public config
// server
public server
// logger
public logger
// 构造器
constructor(config) {}
// 挂载全局中间件
useMiddlewares() {}
// 挂载路由
mountRouter() {}
// 启动服务
start()
}
application 大致上的结构是这样的,我们只需要基于这个结构去补充实现即可。
到这里,发现首先需要一个日志模块来方便调试,以及打印一些必要的信息输出到控制台,为了后续服务部署时,也能很好的管理日志,这里我们选用成熟的日志框架 log4js
安装log4js
pnpm i log4js
封装一个基础的logger类:
mport log4js, { Configuration, Logger } from 'log4js'
const _logger = Symbol('_logger')
/**
* log4js基础适配器
*/
export class Base {
/**
* logger 标记
*/
private [_logger]: Logger | null = null
/**
* 构造器
* @param config
*/
constructor(config: Configuration) {
const logConfig = this.formatConfig(config)
this.setLogger(logConfig)
}
/**
* 链路日志
* @param message
* @param args
* @returns
*/
trace(message: any, ...args: any[]) {
return this[_logger] && this[_logger].trace(message, args)
}
/**
* 调试日志
* @param message
* @param args
* @returns
*/
debug(message: any, ...args: any[]) {
return this[_logger]!.debug(message, ...args)
}
/**
* 信息日志
* @param message
* @param args
* @returns
*/
info(message: any, ...args: any[]) {
return this[_logger]!.info(message, ...args)
}
/**
* 警告日志
* @param message
* @param args
* @returns
*/
warn(message: any, ...args: any[]) {
return this[_logger]!.warn(message, ...args)
}
/**
* 错误日志
* @param message
* @param args
* @returns
*/
error(message: any, ...args: any[]) {
return this[_logger]!.error(message, ...args)
}
/**
* 致命错误日志
* @param message
* @param args
* @returns
*/
fatal(message: any, ...args: any[]) {
return this[_logger]!.fatal(message, ...args)
}
/**
* log4js 配置加载
*/
configure(config: Configuration) {
return log4js.configure(config)
}
/**
* log4js 获取log4js的实例
*/
setLogger(config: Configuration, category?: string) {
this.configure(config)
this[_logger] = log4js.getLogger(category)
}
/**
* 格式化配置
* @param config
* @returns
*/
formatConfig(config: Configuration) {
return config
}
}
再基于这个基础类实现一个DateFile类型的logger:
import { DateFileAppender } from 'log4js'
import { Base } from "./Base"
import { LoggerConfig, LoggerLevel } from "../types"
/**
* date file适配器
*/
export class DateFileLogger extends Base {
formatConfig(config: LoggerConfig & DateFileAppender) {
let { level, filename, pattern, alwaysIncludePattern, absolute, layout, mode, numBackups } = config
level = (level ? level.toUpperCase() : 'ALL') as LoggerLevel
layout = layout || { type: 'pattern', pattern: '%[[%d] [%z] [%p]%] - %m' }
return Object.assign({
appenders: {
// 控制台输出
console: {type: 'console', layout},
// 输出到文件
dateFile: {type: 'dateFile', filename, pattern, alwaysIncludePattern, absolute, layout, mode, numBackups}
},
categories: {
default: { appenders: ['dateFile', 'console'], level }
}
}, config)
}
}
在server文件夹下创建一个logger.ts,主要作用是让我们的logger实例单例化:
import { ILoggerConfig, loggerConfig } from "@/config"
import { DateFileLogger, Logger } from "@/utils"
export type ApplicationLogger = Logger<DateFileLogger>
/**
* 全局保存实例,避免重复实例化
*/
let globalLogger: ApplicationLogger | null = null
export const createLogger = (config: ILoggerConfig, name: string = 'app') => {
if (globalLogger) return globalLogger
const handler = config.handler
delete config.handler
globalLogger = new Logger<DateFileLogger>(config, handler)
return globalLogger
}
export const logger = createLogger(loggerConfig)
logger配置文件:
/**
* 日志配置
*/
import { APP_ROOT } from "@/constants";
import { DateFileLogger } from "@/utils/logger"
import { DateFileAppender } from 'log4js';
import path from 'path';
export type ILoggerConfig = Partial<DateFileAppender> & {
handler: any
status: 'close' | 'open'
}
/**
* 日志配置
*/
export const loggerConfig: ILoggerConfig = {
handler: DateFileLogger,
filename: path.resolve(APP_ROOT, 'logs/logs.log'),
pattern: '-yyyy-MM-dd',
alwaysIncludePattern: false,
status: 'open'
}
继续完善application.ts,把内容都补充起来
import Koa from 'koa'
import { createServer, Server } from 'http'
import { LoggerNameSpace, NOT_FOUND_APPLICATION_CONFIG } from '@/constants'
import { ApplicationLogger, createLogger } from './logger'
import { useMiddlewares } from './core/middlewares/useMiddlewares'
import { loggerConfig } from '@/config'
import { initRouter } from './router'
import type { AppContext, Config } from '@/types'
/**
* 应用
*/
export class Application {
/**
* koa实例
*/
public app: Koa
/**
* 服务配置
*/
public config: Config.Application
/**
* 服务实例
*/
public server: Server
/**
* 日志实例
*/
public logger: ApplicationLogger
/**
* 构造函数
* @param config
*/
constructor(config: Config.Application) {
if (!config) throw TypeError(NOT_FOUND_APPLICATION_CONFIG)
this.config = config
this.app = new Koa()
this.server = createServer(this.app.callback())
this.logger = createLogger(loggerConfig)
this.useMiddleware()
this.mountRouter()
}
/**
* 挂载中间件
*/
useMiddleware() {
// 做一些对象的挂载方便后续使用
this.app.use(async (ctx: AppContext, next) => {
ctx.$ = ctx.server = this
ctx.logger = this.logger
await next()
})
// 挂载中间件
useMiddlewares(this.app)
}
/**
* 启动服务
*/
start() {
const { host, port } = this.config
try {
this.server.listen(port, host, () => {
this.logger.info(LoggerNameSpace.App, `服务已运行在http://${host}:${port}`, '✔ ')
})
} catch (error) {
this.logger.fatal(LoggerNameSpace.App, `服务http://${host}:${port}启动失败!`, error)
}
}
}
useMiddlewares.ts,主要作用是把中间件统一到一个地方管理,方便排查和统一配置:
import Koa from 'koa';
import koaBody from 'koa-body'
import koaCors from '@koa/cors'
import { requestError } from './requestError'
import { requestLog } from './requestLogger'
/**
* middleware
* @param app
*/
export const useMiddlewares = (app: Koa) => {
app.use(koaBody())
.use(koaCors({
allowHeaders: 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With',
allowMethods: 'PUT, POST, GET, DELETE, OPTIONS',
origin: '*'
}))
.use(requestLog())
.use(requestError())
}
corsAllow 只做了一件事就是把 options的请求直接响应 200 requestError 把错误更友好的响应给页面或者接口 requestLogger 请求日志
这是浏览器的同源策略所造成的,同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。
相信大家对于以上的解决方法都很熟悉,这里不再对每一种方法展开讲解,接下来主要讲一下CORS;
CORS请求默认不发送Cookie和HTTP认证信息,如果要把Cookie发到服务器,一方面需要服务器同意,设置响应头Access-Control-Allow-Credentials: true,另一方面在客户端发出请求的时候也要进行一些设置;
预检请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个域。除了Origin,预检请求的头信息包括两个特殊字段:
服务器收到预检请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨域请求,就可以做出回应。上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://127.0.0.1:3000可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。
如果浏览器否定了“预检”请求,就会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段,这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获h。
服务器回应的其他CORS字段
import Router from 'koa-router'
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
import TestController from '../controller/test.ts'
const router = new Router({
prefix: '/standard-test'
})
router.allowedMethods()
router.get(
'/name',
commonMiddleware,
validateParams('get', testSchema),
TestController.getName
)
router.post(
'/name',
commonMiddleware,
validateParams('post', testSchema),
TestController.updateName
)
export default router
上面写了一个很简单的test.ts的路由模块,可能你会觉得很清晰阿,想要什么功能都能实现,这是肯定的,这可是官方的写法,但是实现和开发成本又是另外一回事,特别是当业务复杂起来后,在真正的业务上,一个模块不可能只有两个简单的接口,根据上面的test.ts可以分析传统写法的短板,重点是我引入的两个中间件上:
1、2点非常明显;第3点对应的是上面的 validateParams 中间件,需要传入请求方法和joi校验规则,因为不同的请求方法,koa在拿参数的方式不同,这里就体现了在使用 validateParams 中间件时,无法感知当前接口是什么请求方法,需要手动传入;第4点对应的是上面的 commonMiddleware,当每个接口都需要使用到时,传统写法只能一个个接口进行添加,无法对整个模块进行使用。
实现一个简单的、不怎么灵活的路由自动注入,首先在controller下新建一个BaseController的抽象类
/**
* 封装一个抽象的核心controller
*/
import { Response } from "@/server";
/**
* 核心抽象类
*/
export abstract class BaseController {
/**
* 请求方法枚举
*/
public METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT'
}
/**
* 指定当前api的请求方式
*/
public abstract method: string | string[]
/**
* 指定当前api必须完成方法
* @param params
*/
public abstract handler(params?: Record<string, any>): Response
}
新建controller/test/list.api.ts:
import { Response } from "@/server"
import { BaseController } from "../BaseController"
interface IParams {
name: string
}
/**
* 测试api,请求地址:router.prefix + /test/list
*/
export default class Test extends BaseController{
public method = this.METHODS.GET
public handler<IParams>(params: IParams) {
return Response.success(params, '成功')
}
}
用这个不怎么灵活的controller作为路由自动注入的例子,可以很好的理解整个流程:
server/router/router.ts:
import { application } from '@/config';
import Router from 'koa-router'
const router = new Router({
prefix: application.routerPrefix || '/'
})
router.allowedMethods()
export default router
开始实现路由自动注入,我们实现注入的逻辑是:比如 controller/test/list.api.ts 那我们注入的路径是:router.prefix + /test/list
server/router/initRouter.ts
记载的逻辑是这样的,首先递归拿到controller文件夹下的所有 .api.ts 的文件,然后导入文件,并实例化它,获得对应的 method
和 handler
, 然后动态注入到koa-router
中,最后把router挂载到koa中。
import { Application } from "../application";
import router from './router'
import { readdirRecursive } from '@/utils/file';
import path from "path";
import { AppContext } from '@/types/application';
import { Next } from "koa";
import { Response } from "../response";
import { CONTROLLER_ROOT, LoggerNameSpace } from "@/constants";
interface IRouterMate {
routePath: string
pathApi: string
}
/**
* 初始化路由挂载
* @param app
* @returns
*/
export function initRouter(app: Application) {
const methodsRouter = (router as any).methods.map((m: string) => m.toLowerCase())
/**
* 收集controller
* @param controllerPath
*/
async function importControllerFiles(controllerPath: string) {
const controllerFiles = readdirRecursive(controllerPath).filter((filePath) => {
return filePath.endsWith('.api.ts')
})
const routerMeta: IRouterMate[] = []
controllerFiles.forEach(fileApi => {
const infoApi = path.parse(fileApi)
const pathApi = path.join(controllerPath, fileApi)
const routePath = '/' + path.posix.join(...infoApi.dir.split(path.sep), path.basename(infoApi.base, '.api.ts'))
routerMeta.push({
routePath,
pathApi
})
})
for (let i = 0; i < routerMeta.length; i++) {
const item = routerMeta[i]
await controllerMounter(item.routePath, item.pathApi)
}
}
/**
* 过滤掉路由不允许的路由方法
* @param methods
*/
function filterRouteMethod(methods: string[]) {
return methods.map(method => method.toLowerCase()).filter(method => methodsRouter.includes(method))
}
/**
* 挂载controller
* @param routePath
* @param controllerPath
*/
async function controllerMounter(routePath: string, controllerPath: string) {
const controller = (await import(controllerPath)).default
const controllerInstance = new controller()
const instanceMethod = controllerInstance.method
// 获取当前api请求的方法
const methods = instanceMethod ? Array.isArray(instanceMethod) ? filterRouteMethod(instanceMethod) : filterRouteMethod([instanceMethod]) : ['get']
for (const method of methods) {
mountControllerWithRouter(method, controllerInstance, routePath)
}
}
/**
* 把controller和路由挂起来
* @param method
* @param controller
* @param routePath
*/
function mountControllerWithRouter(method: string, controller: any, routePath: string) {
// 这里可以加载一些前置的中间件
// 加载路由
;(router as any)[method](routePath, async (ctx: AppContext, next: Next) => {
try {
const params = {
...ctx.query,
...ctx.request.body
}
const responceRes = await controller.handler(params, ctx.$)
if (responceRes !== undefined) {
ctx.body = responceRes
return await next()
}
ctx.status = 404
ctx.body = Response.error('没有你要访问的内容')
} catch (error) {
const err = error instanceof Error ? error : new Error(error as string)
ctx.status = 500
ctx.body = Response.error(err.message, err.stack)
}
await next()
})
// 这里可以放一些后置处理的中间件
// 记录一条日志
app.logger.info(LoggerNameSpace.App, `✔ 加载 ~[HTTP接口]~{${method}}~{${routePath}}`)
}
importControllerFiles(CONTROLLER_ROOT).then(() => {
app.app.use(router.routes())
})
}
这部分的源码在【initRoute.ts】
在 application.ts 中使用:
import Koa from 'koa'
import { createServer, Server } from 'http'
import { LoggerNameSpace, NOT_FOUND_APPLICATION_CONFIG } from '@/constants'
import { ApplicationLogger, createLogger } from './logger'
import { useMiddlewares } from './core/middlewares/useMiddlewares'
import { loggerConfig } from '@/config'
import { initRouter } from './router'
import type { AppContext, Config } from '@/types'
/**
* 应用
*/
export class Application {
//...some code
/**
* 构造函数
* @param config
*/
constructor(config: Config.Application) {
//...some code
this.mountRouter()
}
//...some code
/**
* 挂载路由
*/
mountRouter() {
initRouter(this)
}
}
到这里整个应用应该已经可以正常启动了,接下来我们在 src/app.ts 下直接启动服务:
import { application } from '@/config'
import { Application } from './server'
const app = new Application(application)
app.start()
正常的话可以看到如下的日志:
[2022-08-25T11:28:27.957] [13208] [INFO] - Application 服务已运行在http://127.0.0.1:4001 ✔
[2022-08-25T11:28:28.023] [13208] [INFO] - Application ✔ 加载 ~[HTTP接口]~{get}~{/test/list}
尝试访问: http://127.0.0.1:4001/api/test/list?name=1&b=2 出现如下日志,说明我们的服务已经完整的启动了,并且在根目录创建了logs目录
[2022-08-25T17:05:28.511] [4968] [INFO] -
======>
timestamp: Thu Aug 25 2022 17:05:28 GMT+0800 (中国标准时间)
request method: GET
request url: /api/test/list
request query: {"name":"1","b":"2"}
request body: {}
<======
response body: {"data":{"name":"1","b":"2"},"code":1,"message":"成功"}
本节主要是熟悉并使用了koa、以及koa插件机制,并完成了koa的应用封装,封装并使用了多个中间件,实现简单的路由自动注入。下一篇将为整个应用引入typescript装饰器,详解typescript的装饰器使用方式,并为我们的koa路由自动注入改造成装饰器方式。
本篇【GitHub地址】
欢迎关注小博客,没啥特点只有一些记录,还不完善,正在调整中博客地址
本文首发于我的博客从零开始实现一个koa-starter(二)
本文由 mdnice 多平台发布