elpis:基于nodeJS实现一个全栈项目(总结)

7 阅读6分钟

该项目是一个基于 Koa + Vue3 来构建的全栈管理系统框架,使用的设计,支持多项目、多模型的灵活配置。

技术栈

后端核心:

  • Koa 2.0 基于洋葱模型的轻量级 Web 框架
  • Koa-Router 路由管理
  • Log4js 日志管理

前端核心:

  • Vue3.0 + Pinia + Vue-Router
  • Element - Plus 组件库

一、内核引擎

洋葱模型中间件这是 Koa 最核心的设计亮点,请求从外层中间件「进入」,经过所有内层中间件后,再从内层「返回」到外层

先来看下服务端引擎项目结构:

Elpis/

├── elpis-core/ # 框架核心引擎

│ ├── index.js # 引擎启动入口

│ ├── env.js # 环境管理器

│ └── loader/ # 加载器集合(核心)

│ ├── middleware.js # 中间件加载器

│ ├── controller.js # 控制器加载器

│ ├── service.js # 服务加载器

│ ├── router.js # 路由加载器

│ ├── router-schema.js # 路由Schema加载器

│ ├── config.js # 配置加载器

│ └── extend.js # 扩展加载器

├── app/ # 应用层(业务代码)

│ ├── controller/ # 控制器层

│ ├── service/ # 服务层

│ ├── middleware/ # 中间件层

│ ├── router/ # 路由定义

│ ├── router-schema/ # API参数Schema

│ └── extend/ # 扩展功能

├── config/ # 配置文件

│ ├── config.default.js # 默认配置

│ ├── config.local.js # 本地环境配置

│ ├── config.beta.js # 测试环境配置

│ └── config.prod.js # 生产环境配置

├── model/ # 模型配置

│ ├── buiness/ # 电商模型

│ └── course/ # 课程模型

│── index.js # 项目启动入口

## 加载器的执行:

  1. 加载 middleware
  2. 加载 routerSchema
  3. 加载 controller
  4. 加载 service
  5. 加载 config
  6. 加载 extend
  7. 加载中间件
  8. router注册在最后
  9. 启动服务

const Koa = require('koa');
const path = require('path')
const { sep } = path

const env = require('./env')

const middlewareLoader = require('./loader/middleware')
const configLoader = require('./loader/config')
const controllerLoader = require('./loader/controller')
const extendLoader = require('./loader/extend')
const routerSchemaLoader = require('./loader/router-schema')
const routerLoader = require('./loader/router')
const serviceLoader = require('./loader/service')

module.exports = {

    /**
     *启动项目  
     * @param options 项目配置
     * 
     */
    start(options = {}) {
        // koa实例
        const app = new Koa();

        // 应用配置
        app.options = options;
        // console.log(app.options)

        app.baseDir = process.cwd();
        // console.log(app.baseDir)

        app.businessPath = path.resolve(app.baseDir, `.${sep}app`);
        // console.log(app.businessPath)

        // 初始化环境配置
        app.env = env()
        console.log(`-- [start] env: ${app.env.get()} --`)

        // 加载 middleware
        middlewareLoader(app)
        console.log(`-- [start] load middleware done --`)

        // 加载 routerSchema
        routerSchemaLoader(app)
        console.log(`-- [start] load routerSchema done --`)
       
        // 加载 controller
        controllerLoader(app)
        console.log(`-- [start] load controller done --`)

        // 加载 service
        serviceLoader(app)
        console.log(`-- [start] load service done --`)

        // 加载 config
        configLoader(app)
        console.log(`-- [start] load config done --`)

        // 加载 extend
        extendLoader(app)
        console.log(`-- [start] load extend done --`)

        try {
            require(`${app.businessPath}${sep}middleware.js`)(app)
        } catch (error) {
            console.log('加载自定义middleware错误')
        }


        // 注册路由
        routerLoader(app)
        console.log(`-- [start] load router done --`)

        // 启动服务
        try {
            const port = process.env.PORT || 8080;
            const host = process.env.IP || '0.0.0.0';
            app.listen(port, host)
            console.log(`Server runnin on port: ${ port }`);
        } catch (e) {
            console.error(e)
        }

        return app
    }
}

middleware: 中间件加载器的核心作用是:自动扫描 app/middleware 目录下的所有中间件文件,将它们加载到内存中,并统一挂载到 app.middlewares 对象上,方便后续使用。这样做的优势在于:

  • 自动配置 (只要把中间件文件放在 app/middleware 目录下,框架会自动加载,无需配置。)
  • 可扩展(新增中间件时,只需要在 app/middleware 目录下创建文件即可)
  • 自动化(开发者只需要关注中间件的业务逻辑,不需要关心如何加载和注册。)
  • 统一规范(所有中间件都遵循相同的命名和加载规则,代码风格统一)

routerSchema: 自动扫描 app/router-schema 目录下的所有 API 参数规则文件,将它们合并成一个统一的 Schema 对象,挂载到 app.routerSchema 上,为 API 参数校验提供规则定义。同时路由区分界面路由和api路由。对api路由可进行相应的参数校验等操作。

controller: 职责是: 接收http请求 > 提取请求参数 > 调用server处理业务逻辑 > 返回响应

service: 职责是:处理业务逻辑 > 数据库操作 > 复杂数据处理、计算 config: 根据当前运行环境(local、beta、production),加载对应的配置文件,并将默认配置和环境配置合并后挂载到 app.config 对象上。在实际开发可配置开发环境、测试环境或生产环境

extend: 扫描 app/extend 目录下的扩展文件,将它们加载并执行,用于扩展 app 对象的功能,例如添加日志等。

Router: 扫描 app/router 目录下的所有路由文件,将它们注册到 Koa-Router 中,建立 URL 路径与 Controller 方法的映射关系。

HTTP 请求处理流程 - 代码示例:

发起http请求

async function getModelList() {
    loading.value = true;
    const res = await $curl({
        method: 'get',
        url: '/api/project/model_list',
        errorMessage: '获取模型列表失败'
    })
    loading.value = false;

    if(!res || !res.data || !res.success) return;
    modelList.value = res.data;
}

中间件 - 参数校验

const md5 = require('md5');
// 签名合法性
module.exports = (app) => {
    return async (ctx, next) => {
        // 只对api请求做校验
        if(ctx.path.indexOf('/api') < 0) {
            return await next()
        }
        const { path, method } = ctx;
        const { headers } = ctx.request;
        const { s_sign: sSign, s_t: st } = headers
        const signKey = '56fds6f5d6s26g5h4yt84k';
        const signature = md5(`${signKey}_${st}`);
        app.logger.info(`[${method} ${path}] signature: ${signature}`);

        if(!sSign || !st || signature !== sSign.toLowerCase() || Date.now() - st > 600000) {
            ctx.status = 200;
            ctx.body = {
                success: false,
                message: 'signature not correct',
                code: 445
            }
            return
        }
        await next()
    }
}

路由匹配

module.exports = (app, router) => {
    const { project: projectController } = app.controller;
    router.get('/api/project/model_list', projectController.getModelList.bind(projectController));
}

Controller 处理

module.exports = (app) => {
    const BaseController = require('./base')(app);
    return class ProjectController extends BaseController {
        async getModelList(ctx) {
          const { project: projectService } = app.service;
          const modelList = await projectService.modelList();
          this.success(ctx, modelList);
        }
    }
}

Service 处理

module.exports = (app) => {
    const BaseService = require('./base')(app);
    const modelList = require('../../model/index.js')(app)
    return class projectService extends BaseService {
        async modelList() {
          return modelList
        }
    }
}

随后 Service 返回数据给 Controller,Controller 调用 success(),设置响应体,中间件返回,最终http响应返回给客户端。

二、webpack5工程化构建

Webpack 是一个现代 JavaScript 应用程序的静态模块打包工具。 简单来说:

  • 把多个文件打包成少数几个文件
  • 把浏览器不认识的代码(Vue、ES6、Less)转换成浏览器认识的代码(HTML、JS、CSS)

webpack五大核心配置:

  1. Entry(入口): 告诉 Webpack 从哪个文件开始打包
// 动态构造 pageEntries
const pageEntries = {};

// 获取 app/pages 目录下面所有入口文件(entry.xx.js)
const entryList = path.relative(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryList).forEach(file => {
    const entryName = path.basename(file, '.js');
    // 构造 entry
    pageEntries[entryName] = path.resolve(process.cwd(), file);
});

module.exports = {
    entry: pageEntries
}
  1. Output(输出):告诉 Webpack 打包后的文件放在哪里
output: {
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    path: path.join(process.cwd(), './app/public/dist/prod/'),
    publicPath: '/dist/prod/',
    crossOriginLoading: 'anonymous'
}
  1. Loader(加载器):让 Webpack 能够处理非 JavaScript 文件
module: {
    rules: [
        {
            test: /\.vue$/,
            use: {
                loader: 'vue-loader'
            }
        },
        {
            test: /\.js$/,
            include: [
                path.resolve(process.cwd(), './app/pages')
            ],
            use: {
                loader: 'babel-loader'
            }
        },
        {
            test:/\.(png|jpe?g|gif)(\?.+)?$/,
            use: {
                loader: 'url-loader',
                options: {
                    limit: 300,
                    esModule: false
                }
            }
        },
        {
            test: /\.css$/,
            use: ['style-loader', 'css-loader']
        },
        {
            test: /\.less$/,
            use: ['style-loader', 'css-loader', 'less-loader']
        },
        {
            test:/\.(eot|svg|ttf|woff|woff2)(\?\S*)?$/,
            use: 'file-loader'
        }
    ]
}
  1. Plugin(插件):执行更广泛的任务(打包优化、资源管理)
plugins: [
        // 每次 build 都会清空 dist 目录
        new CleanWebpackPlugin(['public/dist'], {
            root: path.resolve(process.cwd(), './app/'), // 指定清除目录
            verbose: true, // 默认 true 输出信息
            dry: false, // 执行实际的清理操作
            exclude: [] // 不需要处理的文件
        }),

        // 抽取 css 的公共部分, 有效利用缓存,(非公共部分适应 inline)
        new MiniCssExtractPlugin({
            filename: 'css/[name]_[contenthash:8].bundle.css', // 抽离的 css 文件名
            chunkFilename: 'css/[name]_[contenthash:8].chunk.css', // 抽离的 css 文件名
            ignoreOrder: true // 忽略 css 顺序
        }),

        // 优化并压缩 css 资源
        new CSSMinimizerPlugin(),

        // 多线程打包 js,加快打包速度
        new HappyPack({
            ...happyThreadPoolConfig,
            id: 'js', // 线程 id
            // threadPool: happyThreadPoolConfig.threadPool, // 线程池
            loaders: [`babel-loader?${JSON.stringify({
                presets: ['@babel/preset-env'],
                plugins: [
                    '@babel/plugin-transform-runtime',
                ]
            })}`]
        }),

        // 多线程打包 css, 加快打包速度
        new HappyPack({
            ...happyThreadPoolConfig,
            id: 'css',
            loaders: [{
                loader: 'css-loader',
                options: {
                    importLoaders: 1
                }
            }]
        }),
        
        // 浏览器在请求资源时 不发送用户的身份凭证
        new HtmlWebpackInjectAttributesPlugin({
            crossorigin: 'anonymous'
        })

    ],
  1. Mode(模式):告诉 Webpack 使用哪种模式的内置优化
module.exports = {
    mode: 'development'  // 或 'production'
}

其他重要配置:

Resolve(解析): 如下

// 配置模块解析的具体行为(定义 webpeck 在打包时,如何找到并解析具体模块的路径)
    resolve: {
        extensions: ['.js', '.vue', '.less', '.css'],
        alias: {
            $pages: path.resolve(process.cwd(), './app/pages'),
            $common: path.resolve(process.cwd(), './app/pages/common'),
            $widgets: path.resolve(process.cwd(), './app/pages/widgets'),
            $store: path.resolve(process.cwd(), './app/pages/store'),
        }
    },

DevServer(开发服务器):开发服务器配置

// devServer 配置
const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '/__webpack_hmr', // 默认值
    TIMEOUT: 20000,
}

Optimization(优化):配置打包输出优化(配置代码分割,模块合并,缓存,TreeShaking,压缩等优化策略)

    optimization: {
        /**
         * 把 js 文件打包成3种类型
         * 1. vendor:第三方 lib 库,基本不会改动,除非依赖版本升级
         * 2.common:业务组件代码的公共部分抽取出来,改动较少
         * 3.entry.{page}: 不用页面 entry 里的业务组件代码的差异部分,会轻松改动
         * 目的:把改动和引用频率不一样的 js 区分出来,以达到更好的利用浏览器缓存的效果
         * 
         * 
        */
         splitChunks: {
            chunks: 'all', // 对同步和异步代码都进行分割
            maxAsyncRequests: 10,
            maxInitialRequests: 10,
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/, // 匹配第三方库
                    name: 'vendor', // 输出文件名
                    priority: 20, // 优先级
                    enforce: true, // 强制执行
                    reuseExistingChunk: true // 重复使用已经打包的模块
                },
                common: {
                    test: /[\\/]common|widgets[\\/]/,
                    name: 'common', // 输出文件名
                    minSize: 2000, // 最小文件体积
                    minChunks: 2, // 最小引用次数
                    priority: 10, // 优先级
                    reuseExistingChunk: true // 重复使用已经打包的模块
                }
            }
         },
        //  将 webpack 运行时生成的代码打包到 runtime.js 中
        runtimeChunk: true, // 运行时代码
    }

三、基于vue3完成领域模型建设

这一部分是作为开发前端开发人员的我认为很重要的内容,因为大部分的工作都是重复性且毫无技术提升的增删改查,比如:公司要开发一个后台系统,通常情况下都会基于现有的项目复制一份进行去开发,两个项目有百分之70的功能和页面都是近乎相同,却还是要进行重复的、类似搬砖式、的开发。而这一块领域建设就是通过一份配置去完成各个领域中相同的那百分之70,同时又支持单独的项目配置去重载基础的配置,而且还提供可扩展性。

// 领域模型
// 这份简单的配置就描述了电商领域‘商品管理’和‘订单管理’页面
module.exports = {
  model: 'dashboard',
  name: '电商系统',
  menu: [{
    key: 'product',
    name: '商品管理',
    menuType: 'module',
    moduleType: 'schema',
    schemaConfig: {
      api: '/api/proj/product',
      schema: {
        type: 'object',
        properties: {
          product_name: {
            type: 'string',
            label: '商品名称',
            tableOption: {
              width: 200
            },
            searchOption: {
              comType: 'dynamicSelect',
              api: '/api/proj/product_enum/list'
            }
          },,
          inventory: {
            type: 'number',
            label: '库存',
            tableOption: {
              width: 200
            },
            searchOption: {
              comType: 'input',
            }
          }
        }
      },
      tableConfig: {
        headerButtons: [{
         label: '新增商品',
         eventKey: 'showComponent',
         type: 'primary',
         plain: true
        }],
        rowButtons:[{
          label: '删除',
          eventKey: 'remove',
          eventOption: {
            params: {
              product_id: 'schema::product_id'
            }
          },
          type: 'danger'
        }]
      }
    }
  }, {
    key: 'order',
    name: '订单管理',
    menuType: 'module',
    moduleType: 'custom',
    customConfig: {
      path: '/todo',
    }
  }]
}
// 这份配置是一份集体到某个电商项目的配置
module.exports = {
    name: 'xx电商系统',
    desc: 'xx电商系统',
    homePage: '/schema?proj_key=pdd&key=product',
    menu: [{
        key: 'product',
        name: '商品管理(拼多多)'
    },{
        key: 'data',
        name: '数据分析',
        menuType: 'module',
        moduleType: 'sider',
        siderConfig: {
            menu:[{
                key: 'analysis',
                name: '电商罗盘',
                menuType: 'module',
                moduleType: 'custom',
                customConfig: {
                    path: '/todo',
                }
            }]
        }
    },{
        key: 'search',
        name: '信息查询',
        menuType: 'module',
        moduleType: 'iframe',
        iframeConfig: {
            path: 'http://www.baidu.com/',
        }
    }]
}

这份配置会和上方的领域模型进行合并,通过key当这份配置和上方配置有重复项时,会覆盖掉上方的配置。确保了可扩展性。同时通过moduleType的不同进行不同菜单内容的扩展。

schemaConfig:是非常重要的一个配置项,里边包含了页面上数据在表格和表单的展示,后续还可进行扩展。

tableConfig:配置页面中操作按钮

searchOption:说明‘product_name’,‘inventory’字段,在筛选条件就需要展示product_name和inventory的筛选条件。

tableOption:表示在表格中的配置:如:width = 200px

comType:通过该字段引用,自定义好的各种组件

总之使用schema自定义百分之70的相同的后台页面功能,通过comType引入自定义来提升扩展性。同时配置中还接入api配置,实现一份配置就完成一个项目的开发,大大降低了重复性的工作。