• 使用 Node.js 写一个代码生成器


    背景

    第一次接触代码生成器用的是动软代码生成器,数据库设计好之后,一键生成后端 curd代码。之后也用过 CodeSmith , T4。目前市面上也有很多优秀的代码生成器,而且大部分都提供可视化界面操作。

    自己写一个的原因是因为要集成到自己写的一个小工具中,而且使用 Node.js 这种动态脚本语言进行编写更加灵活。

    原理

    代码生成器的原理就是:数据 + 模板 => 文件

    数据一般为数据库的表字段结构。

    模板的语法与使用的模板引擎有关。

    使用模板引擎将数据模板进行编译,编译后的内容输出到文件中就得到了一份代码文件。

    功能

    因为这个代码生成器是要集成到一个小工具 lazy-mock 内,这个工具的主要功能是启动一个 mock server 服务,包含curd功能,并且支持数据的持久化,文件变化的时候自动重启服务以最新的代码提供 api mock 服务。

    代码生成器的功能就是根据配置的数据和模板,编译后将内容输出到指定的目录文件中。因为添加了新的文件,mock server 服务会自动重启。

    还要支持模板的定制与开发,以及使用 CLI 安装模板。

    可以开发前端项目的模板,直接将编译后的内容输出到前端项目的相关目录下,webpack 的热更新功能也会起作用。

    模板引擎

    模板引擎使用的是 nunjucks

    lazy-mock 使用的构建工具是 gulp,使用 gulp-nodemon 实现 mock-server 服务的自动重启。所以这里使用 gulp-nunjucks-render 配合 gulp 的构建流程。

    代码生成

    编写一个 gulp task :

    const rename = require('gulp-rename')
    const nunjucksRender = require('gulp-nunjucks-render')
    const codeGenerate = require('./templates/generate')
    const ServerFullPath = require('./package.json').ServerFullPath; //mock -server项目的绝对路径
    const FrontendFullPath = require('./package.json').FrontendFullPath; //前端项目的绝对路径
    const nunjucksRenderConfig = {
      path: 'templates/server',
      envOptions: {
        tags: {
          blockStart: '<%',
          blockEnd: '%>',
          variableStart: '<$',
          variableEnd: '$>',
          commentStart: '<#',
          commentEnd: '#>'
        },
      },
      ext: '.js',
      //以上是 nunjucks 的配置
      ServerFullPath,
      FrontendFullPath
    }
    gulp.task('code', function () {
      require('events').EventEmitter.defaultMaxListeners = 0
      return codeGenerate(gulp, nunjucksRender, rename, nunjucksRenderConfig)
    });
    

    代码具体结构细节可以打开 lazy-mock 进行参照

    为了支持模板的开发,以及更灵活的配置,我将代码生成的逻辑全都放在模板目录中。

    templates 是存放模板以及数据配置的目录。结构如下:

    只生成 lazy-mock 代码的模板中 :

    generate.js的内容如下:

    const path = require('path')
    const CodeGenerateConfig = require('./config').default;
    const Model = CodeGenerateConfig.model;
    
    module.exports = function generate(gulp, nunjucksRender, rename, nunjucksRenderConfig) {
        nunjucksRenderConfig.data = {
            model: CodeGenerateConfig.model,
            config: CodeGenerateConfig.config
        }
        const ServerProjectRootPath = nunjucksRenderConfig.ServerFullPath;
        //server
        const serverTemplatePath = 'templates/server/'
        gulp.src(`${serverTemplatePath}controller.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + '.js'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ControllerRelativePath));
    
        gulp.src(`${serverTemplatePath}service.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + 'Service.js'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ServiceRelativePath));
    
        gulp.src(`${serverTemplatePath}model.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + 'Model.js'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ModelRelativePath));
    
        gulp.src(`${serverTemplatePath}db.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + '_db.json'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.DBRelativePath));
    
        return gulp.src(`${serverTemplatePath}route.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + 'Route.js'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.RouteRelativePath));
    }
    

    类似:

    gulp.src(`${serverTemplatePath}controller.njk`)
            .pipe(nunjucksRender(nunjucksRenderConfig))
            .pipe(rename(Model.name + '.js'))
            .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ControllerRelativePath));
    

    表示使用 controller.njk 作为模板,nunjucksRenderConfig作为数据(模板内可以获取到 nunjucksRenderConfig 属性 data 上的数据)。编译后进行文件重命名,并保存到指定目录下。

    model.js 的内容如下:

    var shortid = require('shortid')
    var Mock = require('mockjs')
    var Random = Mock.Random
    
    //必须包含字段id
    export default {
        name: "book",
        Name: "Book",
        properties: [
            {
                key: "id",
                title: "id"
            },
            {
                key: "name",
                title: "书名"
            },
            {
                key: "author",
                title: "作者"
            },
            {
                key: "press",
                title: "出版社"
            }
        ],
        buildMockData: function () {//不需要生成设为false
            let data = []
            for (let i = 0; i < 100; i++) {
                data.push({
                    id: shortid.generate(),
                    name: Random.cword(5, 7),
                    author: Random.cname(),
                    press: Random.cword(5, 7)
                })
            }
            return data
        }
    }
    

    模板中使用最多的就是这个数据,也是生成新代码需要配置的地方,比如这里配置的是 book ,生成的就是关于 book 的curd 的 mock 服务。要生成别的,修改后执行生成命令即可。

    buildMockData 函数的作用是生成 mock 服务需要的随机数据,在 db.njk 模板中会使用:

    {
      "<$ model.name $>":<% if model.buildMockData %><$ model.buildMockData()|dump|safe $><% else %>[]<% endif %>
    }
    

    这也是 nunjucks 如何在模板中执行函数

    config.js 的内容如下:

    export default {
        //server
        RouteRelativePath: '/src/routes/',
        ControllerRelativePath: '/src/controllers/',
        ServiceRelativePath: '/src/services/',
        ModelRelativePath: '/src/models/',
        DBRelativePath: '/src/db/'
    }
    

    配置相应的模板编译后保存的位置。

    config/index.js 的内容如下:

    import model from './model';
    import config from './config';
    export default {
        model,
        config
    }
    

    针对 lazy-mock 的代码生成的功能就已经完成了,要实现模板的定制直接修改模板文件即可,比如要修改 mock server 服务 api 的接口定义,直接修改 route.njk 文件:

    import KoaRouter from 'koa-router'
    import controllers from '../controllers/index.js'
    import PermissionCheck from '../middleware/PermissionCheck'
    
    const router = new KoaRouter()
    router
        .get('/<$ model.name $>/paged', controllers.<$model.name $>.get<$ model.Name $>PagedList)
        .get('/<$ model.name $>/:id', controllers.<$ model.name $>.get<$ model.Name $>)
        .del('/<$ model.name $>/del', controllers.<$ model.name $>.del<$ model.Name $>)
        .del('/<$ model.name $>/batchdel', controllers.<$ model.name $>.del<$ model.Name $>s)
        .post('/<$ model.name $>/save', controllers.<$ model.name $>.save<$ model.Name $>)
    
    module.exports = router
    

    模板开发与安装

    不同的项目,代码结构是不一样的,每次直接修改模板文件会很麻烦。

    需要提供这样的功能:针对不同的项目开发一套独立的模板,支持模板的安装。

    代码生成的相关逻辑都在模板目录的文件中,模板开发没有什么规则限制,只要保证目录名为 templatesgenerate.js中导出generate函数即可。

    模板的安装原理就是将模板目录中的文件全部覆盖掉即可。不过具体的安装分为本地安装与在线安装。

    之前已经说了,这个代码生成器是集成在 lazy-mock 中的,我的做法是在初始化一个新 lazy-mock 项目的时候,指定使用相应的模板进行初始化,也就是安装相应的模板。

    使用 Node.js 写了一个 CLI 工具 lazy-mock-cli,已发到 npm ,其功能包含下载指定的远程模板来初始化新的 lazy-mock 项目。代码参考( copy )了 vue-cli2。代码不难,说下某些关键点。

    安装 CLI 工具:

    npm install lazy-mock -g
    

    使用模板初始化项目:

    lazy-mock init d2-admin-pm my-project
    

    d2-admin-pm 是我为一个前端项目已经写好的一个模板。

    init 命令调用的是 lazy-mock-init.js 中的逻辑:

    #!/usr/bin/env node
    const download = require('download-git-repo')
    const program = require('commander')
    const ora = require('ora')
    const exists = require('fs').existsSync
    const rm = require('rimraf').sync
    const path = require('path')
    const chalk = require('chalk')
    const inquirer = require('inquirer')
    const home = require('user-home')
    const fse = require('fs-extra')
    const tildify = require('tildify')
    const cliSpinners = require('cli-spinners');
    const logger = require('../lib/logger')
    const localPath = require('../lib/local-path')
    
    const isLocalPath = localPath.isLocalPath
    const getTemplatePath = localPath.getTemplatePath
    
    program.usage('<template-name> [project-name]')
        .option('-c, --clone', 'use git clone')
        .option('--offline', 'use cached template')
    
    program.on('--help', () => {
        console.log('  Examples:')
        console.log()
        console.log(chalk.gray('    # create a new project with an official template'))
        console.log('    $ lazy-mock init d2-admin-pm my-project')
        console.log()
        console.log(chalk.gray('    # create a new project straight from a github template'))
        console.log('    $ vue init username/repo my-project')
        console.log()
    })
    
    function help() {
        program.parse(process.argv)
        if (program.args.length < 1) return program.help()
    }
    help()
    //模板
    let template = program.args[0]
    //判断是否使用官方模板
    const hasSlash = template.indexOf('/') > -1
    //项目名称
    const rawName = program.args[1]
    //在当前文件下创建
    const inPlace = !rawName || rawName === '.'
    //项目名称
    const name = inPlace ? path.relative('../', process.cwd()) : rawName
    //创建项目完整目标位置
    const to = path.resolve(rawName || '.')
    const clone = program.clone || false
    
    //缓存位置
    const serverTmp = path.join(home, '.lazy-mock', 'sever')
    const tmp = path.join(home, '.lazy-mock', 'templates', template.replace(/[/:]/g, '-'))
    if (program.offline) {
        console.log(`> Use cached template at ${chalk.yellow(tildify(tmp))}`)
        template = tmp
    }
    
    //判断是否当前目录下初始化或者覆盖已有目录
    if (inPlace || exists(to)) {
        inquirer.prompt([{
            type: 'confirm',
            message: inPlace
                ? 'Generate project in current directory?'
                : 'Target directory exists. Continue?',
            name: 'ok'
        }]).then(answers => {
            if (answers.ok) {
                run()
            }
        }).catch(logger.fatal)
    } else {
        run()
    }
    
    function run() {
        //使用本地缓存
        if (isLocalPath(template)) {
            const templatePath = getTemplatePath(template)
            if (exists(templatePath)) {
                generate(name, templatePath, to, err => {
                    if (err) logger.fatal(err)
                    console.log()
                    logger.success('Generated "%s"', name)
                })
            } else {
                logger.fatal('Local template "%s" not found.', template)
            }
        } else {
            if (!hasSlash) {
                //使用官方模板
                const officialTemplate = 'lazy-mock-templates/' + template
                downloadAndGenerate(officialTemplate)
            } else {
                downloadAndGenerate(template)
            }
        }
    }
    
    function downloadAndGenerate(template) {
        downloadServer(() => {
            downloadTemplate(template)
        })
    }
    
    function downloadServer(done) {
        const spinner = ora('downloading server')
        spinner.spinner = cliSpinners.bouncingBall
        spinner.start()
        if (exists(serverTmp)) rm(serverTmp)
        download('wjkang/lazy-mock', serverTmp, { clone }, err => {
            spinner.stop()
            if (err) logger.fatal('Failed to download server ' + template + ': ' + err.message.trim())
            done()
        })
    }
    
    function downloadTemplate(template) {
        const spinner = ora('downloading template')
        spinner.spinner = cliSpinners.bouncingBall
        spinner.start()
        if (exists(tmp)) rm(tmp)
        download(template, tmp, { clone }, err => {
            spinner.stop()
            if (err) logger.fatal('Failed to download template ' + template + ': ' + err.message.trim())
            generate(name, tmp, to, err => {
                if (err) logger.fatal(err)
                console.log()
                logger.success('Generated "%s"', name)
            })
        })
    }
    
    function generate(name, src, dest, done) {
        try {
            fse.removeSync(path.join(serverTmp, 'templates'))
            const packageObj = fse.readJsonSync(path.join(serverTmp, 'package.json'))
            packageObj.name = name
            packageObj.author = ""
            packageObj.description = ""
            packageObj.ServerFullPath = path.join(dest)
            packageObj.FrontendFullPath = path.join(dest, "front-page")
            fse.writeJsonSync(path.join(serverTmp, 'package.json'), packageObj, { spaces: 2 })
            fse.copySync(serverTmp, dest)
            fse.copySync(path.join(src, 'templates'), path.join(dest, 'templates'))
        } catch (err) {
            done(err)
            return
        }
        done()
    }
    

    判断了是使用本地缓存的模板还是拉取最新的模板,拉取线上模板时是从官方仓库拉取还是从别的仓库拉取。

    一些小问题

    目前代码生成的相关数据并不是来源于数据库,而是在 model.js 中简单配置的,原因是我认为一个 mock server 不需要数据库,lazy-mock 确实如此。

    但是如果写一个正儿八经的代码生成器,那肯定是需要根据已经设计好的数据库表来生成代码的。那么就需要连接数据库,读取数据表的字段信息,比如字段名称,字段类型,字段描述等。而不同关系型数据库,读取表字段信息的 sql 是不一样的,所以还要写一堆balabala的判断。可以使用现成的工具 sequelize-auto , 把它读取的 model 数据转成我们需要的格式即可。

    生成前端项目代码的时候,会遇到这种情况:

    某个目录结构是这样的:

    index.js 的内容:

    import layoutHeaderAside from '@/layout/header-aside'
    export default {
        "layoutHeaderAside": layoutHeaderAside,
        "menu": () => import(/* webpackChunkName: "menu" */'@/pages/sys/menu'),
        "route": () => import(/* webpackChunkName: "route" */'@/pages/sys/route'),
        "role": () => import(/* webpackChunkName: "role" */'@/pages/sys/role'),
        "user": () => import(/* webpackChunkName: "user" */'@/pages/sys/user'),
        "interface": () => import(/* webpackChunkName: "interface" */'@/pages/sys/interface')
    }
    

    如果添加一个 book 就需要在这里加上"book": () => import(/* webpackChunkName: "book" */'@/pages/sys/book')

    这一行内容也是可以通过配置模板来生成的,比如模板内容为:

    "<$ model.name $>": () => import(/* webpackChunkName: "<$ model.name $>" */'@/pages<$ model.module $><$ model.name $>')
    

    但是生成的内容怎么加到index.js中呢?

    第一种方法:复制粘贴

    第二种方法:

    这部分的模板为 routerMapComponent.njk

    export default {
        "<$ model.name $>": () => import(/* webpackChunkName: "<$ model.name $>" */'@/pages<$ model.module $><$ model.name $>')
    }
    

    编译后文件保存到 routerMapComponents 目录下,比如 book.js

    修改 index.js :

    const files = require.context('./', true, /.js$/);
    import layoutHeaderAside from '@/layout/header-aside'
    
    let componentMaps = {
        "layoutHeaderAside": layoutHeaderAside,
        "menu": () => import(/* webpackChunkName: "menu" */'@/pages/sys/menu'),
        "route": () => import(/* webpackChunkName: "route" */'@/pages/sys/route'),
        "role": () => import(/* webpackChunkName: "role" */'@/pages/sys/role'),
        "user": () => import(/* webpackChunkName: "user" */'@/pages/sys/user'),
        "interface": () => import(/* webpackChunkName: "interface" */'@/pages/sys/interface'),
    }
    files.keys().forEach((key) => {
        if (key === './index.js') return
        Object.assign(componentMaps, files(key).default)
    })
    export default componentMaps
    

    使用了 require.context

    我目前也是使用了这种方法

    第三种方法:

    开发模板的时候,做特殊处理,读取原有 index.js 的内容,按行进行分割,在数组的最后一个元素之前插入新生成的内容,注意逗号的处理,将新数组内容重新写入 index.js 中,注意换行。

    打个广告

    如果你想要快速的创建一个 mock-server,同时还支持数据的持久化,又不需要安装数据库,还支持代码生成器的模板开发,欢迎试试 lazy-mock

  • 相关阅读:
    9
    8
    7
    6
    5
    第四周
    作业14-数据库
    作业13-网络
    作业12-流与文件
    作业11-多线程
  • 原文地址:https://www.cnblogs.com/jaycewu/p/10842426.html
Copyright © 2020-2023  润新知