实现企业级全栈应用服务框架-Elpis(一)

前言

作为一名前端练习生,回顾以往工作日常,基本都是CRUD的需求,到现在最多也就是个熟练工,比如像服务端、项目部署、架构设计等很多方面都是短板,不得不承认,现在的市场不仅要求前端只会前端框架,相反,对非前端的知识体系都要涉及到。也是为了更好的拓展知识广度,所以我们的主角诞生喽,一个基于Vue3 + Node.js服务端渲染的框架-Elpis。也是机缘巧合,某平台刷到了"哲玄前端",买下了《大前端全栈实践系列》,所以,谨以此记录一下本人的学习过程。

elpis整体架构设计

elpis-core整体设计

elpis-core是基于koa.js实现的类似于egg.js的轻量化服务端的框架。通过koa.js实例,将各种loader文件进行解析并挂载到Koa实例中,本篇着重来说BFF层。

BFF

BFF(Backend For Frontend)从字面意思来看就是为前端而生的后端(这里说的有点过分哈哈),它是一种架构设计,主要用于解决前后端数据协作及微服务框架的数据聚合等问题,结构大体如下图所示:

洋葱模型

Koa中间件机制基于一个称为"洋葱模型"的设计模式。这种模式形象地将中间件的执行过程比喻为洋葱的层次。每个中间件都是洋葱的一层,每一层都有对应的处理逻辑,把controller需要处理的非业务逻辑细化到各个loader中去单独处理,controller只关心对应的业务逻辑,减少了杂项干扰。elpis-core内核引擎也是基于这个思维模式去建立起来的,整体流程如下图:

目录结构

js 复制代码
  Elpis
    | app/ 
    |  | controller/ //控制器
    |  |   extend/ // 扩展
    |  |   middleware/ // 中间件
    |  |   public/ 
    |  |    | output/ // 入口文件
    |  |    | static/ // 静态资源
    |  |   router/ // 路由
    |  |   router-schema/ // 路由schema配置
    |  |   service/ // 服务层
    | config/ // 环境配置
    | elpis-core/ // 内核引擎
    |  | laoder/ // 存放各个模块的loader
    |  | env.js // 环境配置
    |  | index.js // 启动入口
    | logs/ // 日志文件
    | index.js // 框架页面配置
    | .eslintignore
    | .eslintrc
    | .gitignore
    | package-lock.json
    | package.json

具体实现如下:

js 复制代码
const Koa = require("koa");
const path = require('path')
const { sep } = path
const env = require('./env')
const configLoader = require('./loader/config')
const serviceLoader = require('./loader/service')
const middlewareLoader = require('./loader/middleware')
const routerSchemaLoader = require('./loader/router-schema')
const controllerLoader = require('./loader/controller')
const extendLoader = require('./loader/extend')
const routerLoader = require('./loader/router')
module.exports = {
  start(options = {}) {
    const app = new Koa();
    app.options = options // 应用配置 
    app.baseDir = process.cwd() // 基础路径
    app.businessPath = path.resolve(app.baseDir, `.${sep}app`) // 业务文件路径
    app.env = env()
    configLoader(app)
    serviceLoader(app)
    middlewareLoader(app)
    routerSchemaLoader(app)
    controllerLoader(app)
    extendLoader(app)
    try {
      require(`${app.businessPath}${sep}middleware.js`)(app)
    } catch (err) {
      console.error(`[error]:register global middleware failed`)
    }
    routerLoader(app)
    try {
      const port = process.env.PORT ?? 8080;
      const host = process.env.IP ?? "0.0.0.0";
      app.listen(port, host);
    } catch (err) {
      console.error("Failed to start server:", err);
    }
  },
};

通过上述代码逻辑,会把各个loader通过从对应文件夹下读取对应文件内容并加载进来,然后统一挂载到Koa的实例对象上去,然后通过app去获取。

configLoader

configLoader主要是配置不同的环境变量,将本地、测试、生产多个环境下的配置挂载到Koa的实例上。

js 复制代码
/**
 *  config loader
 * @param {object} app koa实例
 * 配置区分: 本地、测试、生产不同环境下的配置文件env.config
 * 通过env.config 覆盖default.config加载到app.config中
 * 目录对应的config配置
 * 默认: config/config.default.js
 * 本地: config/config.local.js
 * 测试: config/config.beta.js
 * 生产: config/config.prod.js
 */
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
    // 找到config目录
    const configPath = path.resolve(app.baseDir, `.${sep}config`)
    // 获取默认配置目录
    let defaultConfig = {};
    try {
        defaultConfig = require(path.resolve(configPath, `.${sep}config.default.js`))
    } catch (error) {
        console.log('[error]:there is no config.default file')
    }
    // 获取env.config
    let envConfig = {}
    try {
        if (app.env.isLocal()) {
            envConfig = require(path.resolve(configPath, `.${sep}config.local.js`))
        } else if (app.env.isBeta()) {
            envConfig = require(path.resolve(configPath, `.${sep}config.beta.js`))
        } else if (app.env.isProd()) {
            envConfig = require(path.resolve(configPath, `.${sep}config.prod.js`))
        }
    } catch (error) {
        console.log('[error]:there is no envConfig', error)
    }
    // 覆盖并加载config配置
    app.config = Object.assign({}, defaultConfig, envConfig)

}

serviceLoader

serviceLoader会通过app/service下以及其子目录下的所有文件内容,并将service挂载到Koa上,通过app.service.${目录}.${文件}访问。

js 复制代码
/**
 * controller loader
 * @param {object} app koa实例
 * 加载所有service,可通过app.service.${目录}.${文件}访问
 * eg:
 * app/service
 *   ├── custom-module
 *        ├── custom-service.js
 */
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
    // 读取app/service/**/**.js的所有js文件
    const servicePath = path.resolve(app.businessPath, `.${sep}service`)
    const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`))
    const service = {}
    // 遍历文件目录
    fileList.forEach(file => {
        // 获取文件名称
        let name = path.resolve(file)
        // 截取文件路径 eg:app/service/custom-module/custom-service.js ==> custom-module/custom-service
        name = name.substring(name.lastIndexOf(`service${sep}`) + `service${sep}`.length, name.lastIndexOf('.'))
        // 替换文件路径中的-和_为驼峰 eg:custom-module/custom-service.js ==> customModule/customService
        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
        // 挂载service到app对象中
        let tempService = service
        const names = name.split(sep)
        for (let i = 0, len = names.length; i < len; ++i) {
            if (i === len - 1) {
                const ServiceModule = require(path.resolve(file))(app)
                tempService[names[i]] = new ServiceModule()
            } else {
                if (!tempService[names[i]]) {
                    tempService[names[i]] = {}
                }
                tempService = tempService[names[i]]
            }
        }
    })
    app.service = service
}

middlewareLoader

middlewareloader会读取app/middleware下子目录以及下面所有中间件文件内容,并挂载到Koa上。中间件做了一系列的处理,这里设计了三种常见的场景:异常处理api合法性校验api参数校验

js 复制代码
/**
 * middleware loader
 * @param {object} app koa实例
 * 加载所有middleware,可通过app.middleware.${目录}.${文件}访问
 * eg:
 * app/middleware
 *   ├── custom-module
 *        ├── custom-middleware.js
 */
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
  // 读取app/middleware/**/**.js的所有js文件
  const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`)
  const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`))
  const middlewares = {}
  // 遍历文件目录
  fileList.forEach(file => {
    // 获取文件名称
    let name = path.resolve(file)
    // 截取文件路径 eg:app/middleware/custom-module/custom-middleware.js ==> custom-module/custom-middleware
    name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'))
    // 替换文件路径中的-和_为驼峰 eg:custom-module/custom-middleware.js ==> customModule/customMiddleware
    name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
    // 挂载middleware到app对象中
    let tempMiddleware = middlewares
    const names = name.split(sep)
    for (let i = 0, len = names.length; i < len; ++i) {
      if (i === len - 1) {
        tempMiddleware[names[i]] = require(path.resolve(file))(app)
      } else {
        if (!tempMiddleware[names[i]]) {
          tempMiddleware[names[i]] = {}
        }
        tempMiddleware = tempMiddleware[names[i]]
      }
    }
  })
  app.middlewares = middlewares
}

routerSchemaLoader

routerSchemeLoader会读取app/router-schema下的文件内容,app/router-schema下的文件描述信息遵循json-schema的配置规范,并且使用了ajv.js对api请求信息进行了校验。

js 复制代码
/**
 * router-schema loader
 * @param {object} app koa实例
 * 通过'json-schema & ajv'对API 规则进行约束,配合api-params-verify 中间件 
 * app/router-schema/**.js 
 * 
 * 输出:
 * app.routerSchema = {
 *  "${api}":${jsonSchema},
 *  ...
 * }
 * */
const glob = require('glob')
const path = require('path')
const { sep } = path
module.exports = (app) => {
  // 读取app/router-schema/**.js的所有js文件
  const routerSchemaPath = path.resolve(app.businessPath, `.${sep}router-schema`)
  const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`))
  // 注册所有routerSchema, 可以通过'app.routerSchema'访问
  let routerSchema = {}
  fileList.forEach(file => {
    routerSchema = {
      ...routerSchema,
      ...require(path.resolve(file))
    }
  })
  app.routerSchema = routerSchema
}

schema配置举例demo:

js 复制代码
module.exports = {
  '/api/project/list': {
    'get': {
      query: {
        type: 'object',
        properties: {
          proj_key: {
            type: 'string',

          },
        },
        required: ['proj_key']
      },
    }
  },
}

如上配置所示,当请求这个接口时,如果query参数缺少proj_key,这个请求就会返回报错信息。

controllerLoader

controllerLoader会读取app/controller下以及其子目录下的所有文件内容,并将controller挂载到Koa上,通过app.controller.${目录}.${文件}访问。

js 复制代码
/**
 * controller loader
 * @param {object} app koa实例
 * 加载所有controller,可通过app.controller.${目录}.${文件}访问
 * eg:
 * app/controller
 *   ├── custom-module
 *        ├── custom-controller.js
 */
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
    // 读取app/controller/**/**.js的所有js文件
    const controllerPath = path.resolve(app.businessPath, `.${sep}controller`)
    const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`))
    const controller = {}
    // 遍历文件目录
    fileList.forEach(file => {
        // 获取文件名称
        let name = path.resolve(file)
        // 截取文件路径 eg:app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
        name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf('.'))
        // 替换文件路径中的-和_为驼峰 eg:custom-module/custom-controller.js ==> customModule/customController
        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
        // 挂载controller到app对象中
        let tempController = controller
        const names = name.split(sep)
        for (let i = 0, len = names.length; i < len; ++i) {
            if (i === len - 1) {
                const ControllerModule = require(path.resolve(file))(app)
                tempController[names[i]] = new ControllerModule()
            } else {
                if (!tempController[names[i]]) {
                    tempController[names[i]] = {}
                }
                tempController = tempController[names[i]]
            }
        }
    })
    app.controller = controller
}

extendLoader

extendLoader读取app/extend下的所有文件内容,并挂载到Koa上。主要用于拓展应用的额外能力,例如日志,当线上环境遇见报错,想要排查具体报错内容,就需要查看对应的日志内容,结合日志内容来定位报错原因并解决问题。

js 复制代码
/**
 * extend loader
 * @param {object} app koa实例
 * 加载所有extend,可通过app.extend.${文件}访问
 * eg:
 * app/extend
 *    ├── custom-extend.js
 */
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
    // 读取app/extend/**.js的所有js文件
    const extendPath = path.resolve(app.businessPath, `.${sep}extend`)
    const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`))
    // 遍历文件目录
    fileList.forEach(file => {
        // 获取文件名称
        let name = path.resolve(file)
        // 截取文件路径 eg:app/extend/custom-extend.js ==> custom-extend
        name = name.substring(name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length, name.lastIndexOf('.'))
        // 替换文件路径中的-和_为驼峰 eg:custom-module/custom-extend.js ==> customModule.customExtend
        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
        // 过滤app存在的key
        for (const key in app) {
            if (key === name) {
                console.log(`extend key is existed, ${name}`);
                return
            }
        }
        app[name] = require(path.resolve(file))(app)
    })
}

全局中间件

注册全局中间件就是为了渲染模板引擎,并且引入之前提到在app/middleware文件夹下设定的三种场景:

js 复制代码
const path = require('path')
module.exports = (app) => {
    // 配置静态根目录
    const koaStatic = require('koa-static')
    app.use(koaStatic(path.resolve(process.cwd(), './app/public')))
    // 模版渲染引擎
    const koaNunjucks = require('koa-nunjucks-2')
    app.use(koaNunjucks({
        ext: 'tpl',
        path: path.resolve(process.cwd(), './app/public'),
        nunjucksConfig: {
            noCache: true,
            trimBlocks: true,
        }
    }))
    // 引入解析请求体的中间件
    const koaBodyParser = require('koa-bodyparser')
    app.use(koaBodyParser({
        formLimit: '10mb',
        enableTypes: ['json', 'form', 'text'],
    }))
    // 引入错误处理中间件
    app.use(app.middlewares.errorHandler)
    // 引入api签名校验中间件
    app.use(app.middlewares.apiSignVerify)
    // 引入api参数校验中间件
    app.use(app.middlewares.apiParamsVerify)
}

routerLoader

routerLoader读取app/router下的所有文件内容,并挂载到Koa上。

js 复制代码
/**
 * router loader
 * @param {object} app koa实例
 * 解析所有app/router目录下的js文件,加载到KoaRouter下
 */
const glob = require('glob')
const path = require('path')
const { sep } = path
const KoaRouter = require('koa-router')
module.exports = (app) => {
    // 找到路由文件路径
    const routerPath = path.resolve(app.businessPath, `.${sep}router`)
    const router = new KoaRouter()
    // 注册所有路由
    const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`))
    fileList.forEach((file) => {
        require(path.resolve(file))(app, router)
    })
    // 路由兜底
    router.get('*', async (ctx, next) => {
        ctx.status = 302 // 临时重定向
        ctx.redirect(`${app?.options?.homePage ?? '/'}`)
    })
    // 路由注册到app
    app.use(router.routes())
    app.use(router.allowedMethods())
}

总结

至此,elpis-core内核引擎这一基础模块可以画上句号了,通过上述实践,我对Node.js有一个初步的认知,也是迈出了服务端渲染的第一步,继续加油~~

相关推荐
一只小风华~5 小时前
学习笔记:Vue Router 中的链接匹配机制与样式控制
前端·javascript·vue.js·笔记·学习·ecmascript
星空下的曙光6 小时前
Node.js events模块所有 API 详解 + 常用 API + 使用场景
node.js
无责任此方_修行中7 小时前
我的两次 Vibe Coding 经历,一次天堂,一次地狱
后端·node.js·vibecoding
FuckPatience7 小时前
前端Vue 后端ASP.NET Core WebApi 本地调试交互过程
前端·vue.js·asp.net
北城以北88887 小时前
Vue-- Axios 交互(二)
javascript·vue.js·交互
我总是词不达意8 小时前
vue3 + el-upload组件集成阿里云视频点播从本地上传至点播存储
前端·vue.js·阿里云·elementui
北城以北88888 小时前
Vue-- Axios 交互(一)
前端·javascript·vue.js
前端开发爱好者8 小时前
Vite➕ 收费了!
前端·javascript·vue.js
羊羊小栈8 小时前
基于「多模态大模型 + BGE向量检索增强RAG」的新能源汽车故障诊断智能问答系统(vue+flask+AI算法)
vue.js·人工智能·算法·flask·汽车·毕业设计·大作业