Elpis-core
该项目是一个基于 Koa 二次封装的框架内核,使用一系列的 loader 来加载各个组件并注入到 koa 实例上,loader 部分包含 middlewareLoader、routerSchemaLoader、routerLoader、controllerLoader、serviceLoader、configLoader、extendLoader、globalMiddlewareLoader 八个 loader 组件,项目使用 pnpm 的 workspace 能力将 elpis-core 封装为一个独立的包,目录结构如下:
package
|
| -- elpis-core
|
| -- loader
|
| -- middleware.ts
| -- router-schema.ts
| -- router.ts
| -- controller.ts
| -- service.ts
| -- config.ts
| -- extend.ts
| -- globalMiddleware.ts
| -- index.ts
| -- package.json
|
| -- elpis-types
|
| -- core.d.ts
| -- index.d.ts
| -- package.json
|
| -- elpis-utils
|
| -- env.ts
| -- helper.ts
| -- index.ts
| -- package.json
|
| -- elpis-base
|
| -- constroller.ts
| -- service.ts
| -- index.ts
| -- package.json
核心组件
下面介绍各个核心组件。
middlewareLoader
middlewareLoader 会自动扫描 app/middleware 目录下的所有中间件,并将他们挂载到 koa 实例上下文上去,这些中间件组成洋葱圈模型,请求进入时会由一层一层的中间件去处理,这种方式可以极好的提高代码的拓展性和可维护性。
const middlewareLoader = async (app: Elpis.App): Promise<void> => {
// 读取 app/middleware 目录下的所有文件
const middlewarePath = path.resolve(
app.businessPath || "",
`.${sep}middleware`
);
const files = globSync(path.resolve(middlewarePath, `.${sep}**${sep}*.ts`), {
windowsPathsNoEscape: true,
});
// 遍历所有文件,把内容加载到 app.middlewares 中
const middlewares: Record<string, any> = {};
for (const file of files) {
const middleware = (await import(file)).default(app);
// 获取相对于 middleware 目录的路径
const relativePath = path.relative(middlewarePath, file);
// 分割路径为数组,移除最后一个(文件名)
const pathParts = relativePath.split(sep).slice(0, -1);
// 获取文件名(不含扩展名)并处理为驼峰式
let fileName = path.basename(file, ".ts");
fileName = camelCase(fileName);
// 递归创建嵌套对象
let current = middlewares;
pathParts.forEach((part) => {
current[part] = current[part] || {};
current = current[part];
});
// 在最终层级设置中间件
current[fileName] = middleware;
}
app.middlewares = middlewares;
};
routerSchemaLoader
routerSchemaLoader 会自动扫描 app/routerSchema 目录下的所有文件,并挂载到 koa 实例上下文,routerSchema 是对接口参数的规则校验,通过 json-schema & ajv 对 API 规则进行约束,配合 ApiParamsVerifyMiddleware 中间件使用
const routerSchemaLoader = async (app: Elpis.App): Promise<void> => {
// 读取 app/router-schema 目录下的所有文件
const routerSchemaPath = path.resolve(
app.businessPath || "",
`.${sep}router-schema`
);
const files = globSync(path.resolve(routerSchemaPath, `.${sep}**${sep}*.ts`), {
windowsPathsNoEscape: true,
});
// 注册所有 routerSchema,使得可以 'app.routerSchema' 访问
let routerSchemas: Record<string, any> = {};
for (const file of files) {
const routerSchema = (await import(file)).default;
routerSchemas = { ...routerSchemas, ...routerSchema };
}
app.routerSchema = routerSchemas;
};
ApiParamsVerifyMiddleware
ApiParamsVerifyMiddleware 是 API 参数验证中间件,存放于 api/middleware 目录下,由middlewareLoader 挂载到 koa 上下文中,并由 globalMiddleware 使用。
const ApiParamsVerifyMiddleware = (app: Elpis.App) => {
const ajv = new Ajv();
return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
const { path, params, method } = ctx;
const { body, query, headers } = ctx.request;
// 只对 API 请求进行参数验证
if (!path.includes("/api")) {
return await next();
}
// 日志记录请求参数
app.logger.info(`[${method} ${path}] body: ${JSON.stringify(body)}`);
app.logger.info(`[${method} ${path}] query: ${JSON.stringify(query)}`);
app.logger.info(`[${method} ${path}] params: ${JSON.stringify(params)}`);
app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);
const schema = app.routerSchema[path]?.[method.toLowerCase()];
const appendValidate = {
body: body,
query: query,
params,
};
if (!schema) {
return await next();
}
// 验证 body,query,parmas 参数是否合法
const schemaKeys = Object.keys(schema) as (keyof typeof appendValidate)[];
const validateArr = schemaKeys.map(async (sKey) => {
const validate = await ajv.compile(schema[sKey]);
const flag = await validate(appendValidate[sKey]);
// 验证不通过,返回相关错误信息
if (!flag)
return Promise.reject({
key: sKey,
message: validate.errors,
});
return flag;
});
await Promise.all(validateArr)
.then(() => {
next();
})
.catch((err) => {
let message = "request validata fail";
// 输出错误信息
if ("key" in err) {
const msg = err.message[0];
message = `[${
err.key
}] request validata fail: ${msg?.instancePath?.slice(1)} ${
msg.message
}`;
}
ctx.status = 200;
ctx.body = {
success: false,
message,
code: 442,
};
return;
});
};
};
globalMiddleware
globalMiddleware 的作用是加载全局的 middleware 文件,即加载 app/middleware.ts 文件,该文件中决定了 use 哪些中间件。
const globalMiddleware = async (app: Elpis.App) => {
try {
const middleware = await import(`${app.businessPath}${sep}middleware.ts`);
middleware.default(app);
console.log(`-- [start] load appMiddleware done --`);
} catch (e) {
console.log(e);
console.log('[exception] there is no global middleware file');
}
};
controllerLoader
controllerLoader 会自动扫描 app/controller 目录下的所有文件,并将所有的 controller 实例(约定:controller 都是 class)化后挂载到 koa 上下文。controller控制器是负责调用对应的service处理请求并返回响应的模块,
const controllerLoader = async (app: Elpis.App): Promise<void> => {
// 读取 app/controller 目录下的所有文件
const controllerPath = path.resolve(
app.businessPath || "",
`.${sep}controller`
);
const files = globSync(path.resolve(controllerPath, `.${sep}**${sep}*.ts`), {
windowsPathsNoEscape: true,
});
// 遍历所有文件,把内容加载到 app.controllers 中
const controllers: Record<string, any> = {};
await Promise.all(
files.map(async (file) => {
// controller 都是class, 所有需要new
const ControllerModule = (await import(file)).default;
const controller = new ControllerModule(app);
// 获取相对于 controller 目录的路径
const relativePath = path.relative(controllerPath, file);
// 分割路径为数组,移除最后一个(文件名)
const pathParts = relativePath.split(sep).slice(0, -1);
// 获取文件名
let fileName = path.basename(file, ".ts");
// 文件名驼峰化
fileName = camelCase(fileName);
let current = controllers;
pathParts.forEach((part) => {
current[part] = current[part] || {};
current = current[part];
});
current[fileName] = controller;
})
);
app.controller = controllers;
};
serviceLoader
serviceLoader 会自动扫描 app/service 目录下的所有文件,并将所有的 service 挂载到 koa 上下文。service 负责处理具体的业务逻辑,一个 service 可以被多个 controller 调用,这样保证 controller 控制器逻辑相对简单,只负责调用 service 来处理具体的业务。
const serviceLoader = async (app: Elpis.App): Promise<void> => {
// 读取 app/service 目录下的所有文件
const servicePath = path.resolve(
app.businessPath || "",
`.${sep}service`
);
const files = globSync(path.resolve(servicePath, `.${sep}**${sep}*.ts`), {
windowsPathsNoEscape: true,
});
// 遍历所有文件,把内容加载到 app.controllers 中
const services: Record<string, any> = {};
for (const file of files) {
const serviceModule = (await import(file)).default;
const service = new serviceModule(app);
// 获取相对于 service 目录的路径
const relativePath = path.relative(servicePath, file);
// 分割路径为数组,移除最后一个(文件名)
const pathParts = relativePath.split(sep).slice(0, -1);
// 获取文件名(不含扩展名)并处理为驼峰式
let fileName = path.basename(file, ".ts");
fileName = camelCase(fileName);
// 递归创建嵌套对象
let current = services;
pathParts.forEach((part) => {
current[part] = current[part] || {};
current = current[part];
});
// 在最终层级设置中间件
current[fileName] = service;
}
app.service = services;
}
configLoader
configLoader 会自动扫描根目录下 config 文件下的所有文件,并将所有的 config 挂载到 koa 上下文。 config 为不同环境的配置文件,如: config.default.ts(默认配置文件)、 config.local.ts(本地配置文件)、 config.bate.ts(测试配置文件)、 config.prod.ts(生产配置文件)。Elpis-core 会根据 loadEnv 提供的方法与环境变量,选择对应的 config 。
const configLoader = async (app: Elpis.App): Promise<void> => {
// 找到 config/ 目录
const configPath = path.resolve(app.baseDir || "", `.${sep}config`);
// 获取 default.config
let defaultConfig = {};
try {
const module = await import(
path.resolve(configPath, `.${sep}config.default.ts`)
);
defaultConfig = module.default || module;
} catch {
console.log(`[exception] there is no default.config file `);
}
// 获取 env.config
let envConfig = {};
try {
if (app.loadEnv.isLocal()) {
// 本地环境
const module = await import(
path.resolve(configPath, `.${sep}config.local.ts`)
);
envConfig = module.default || module;
} else if (app.loadEnv.isBeta()) {
// 测试环境
const module = await import(
path.resolve(configPath, `.${sep}config.beta.ts`)
);
envConfig = module.default || module;
} else if (app.loadEnv.isProduction()) {
// 生产环境
const module = await import(
path.resolve(configPath, `.${sep}config.prod.ts`)
);
envConfig = module.default || module;
}
} catch {
console.log(`[exception] there is no env.config file `);
}
// 覆盖并加载 config 配置
app.config = Object.assign({}, defaultConfig, envConfig);
};
loadEnv
loadEnv 用于提供一系列方法判断或获取环境变量。
const loadEnv = (app: Elpis.App): Elpis.EnvUtils => {
return {
// 判断是否是本地环境
isLocal: () => {
return process.env._ENV === "local";
},
// 判断是否是测试环境
isBeta: () => {
return process.env._ENV === "beta";
},
// 判断是否是生产环境
isProduction: () => {
return process.env._ENV === "production";
},
// 获取当前环境
getEnv: () => {
return process.env._ENV ?? "local";
}
}
}
extendLoader
extendLoader 会自动扫描 app/extend 目录下的所有文件,并将所有的 extend 挂载到 koa 上下文。 extend 为扩展文件,用于为 koa 实例扩展额外的功能,如日志记录功能等。
const extendLoader = async (app: Elpis.App): Promise<void> => {
// 读取 app/extend 目录下的所有文件
const extendPath = path.resolve(
app.businessPath || "",
`.${sep}extend`
);
const files = globSync(path.resolve(extendPath, `.${sep}**${sep}*.ts`), {
windowsPathsNoEscape: true,
});
// 遍历所有文件,把内容加载到 app 上
for (const file of files) {
const ExtendModule = (await import(file)).default(app);
// 获取文件名(不含扩展名)并处理为驼峰式
let fileName = path.basename(file, ".ts");
fileName = camelCase(fileName);
// 过滤 app 已经存在的 key
for (const key in app) {
if (key === fileName) {
console.log(`[extend load error] name:${fileName} is already in app`);
continue;
}
}
// 挂载 extend 到 app 上
app[fileName] = ExtendModule;
}
}
ApiSignVerifyMiddleWare
ApiSignVerifyMiddleWare 是一个 API 签名验证中间件,采用对称加密,并支持设置有效时间,用来判断请求的签名是否合法。
const ApiSignVerifyMiddleWare = (app: Elpis.App) => {
return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
const { path, method } = ctx;
// 只对 API 请求进行签名验证
if (!path.includes("/api")) {
return await next();
}
// 进行签名验证
const { headers } = ctx.request;
const { s_sign: sSgin, s_t: st } = headers;
// 签名密钥
const signKey = "ylc5dgw2hasd0jwq";
// 计算签名
const signature = md5(`${signKey}_${st}`);
// 有效时间
const validTime = 1000 * 60 * 10;
app.logger.info(`[${method} ${path}] signature: ${signature}`);
if (
!sSgin ||
!st ||
(sSgin as string).toLowerCase() !== signature ||
Date.now() - Number(st) > validTime
) {
ctx.status = 200;
ctx.body = {
success: false,
message: "签名验证失败",
code: 445,
};
return;
}
await next();
};
};
ErrorHandleMiddleWare
ErrorHandleMiddleWare 是运行时的异常错误处理中间件,用于兜底所有异常,防止服务端异常直接抛出给前端页面,并支持进行重定向。
const ErrorHandleMiddleWare = (app: Elpis.App) => {
return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
try {
await next();
} catch (err: any) {
// 异常处理
const { status, message, detail } = err;
app.logger.info(JSON.stringify(err));
app.logger.error('[-- exception --]', err);
app.logger.error('[-- exception --]', status, message, detail);
if (message && message.indexOf('template not found') > -1) {
// 页面不存在,进行重定向
ctx.status = 302;
ctx.redirect(`${app.options?.homePath}`);
return;
}
// 返回给前端
const resBody = {
success: false,
code: 500,
message: '系统异常',
}
ctx.status = 200;
ctx.body = resBody;
}
}
}
总结
以上是 Elpis-core 应用的核心组件及相关基建的介绍,Elpis是一个前端 BFF 应用框架,目的是为开发者提供一个高效,可扩展,易维护的应用程序框架,目前仅实现内核部分和相关的部分基建,文中实现可能存在错误的理解和待优化的地方,欢迎大家讨论或指出错误。