使用 Node.js 寫一個程式碼生成器

若邪發表於2019-05-10

背景

第一次接觸程式碼生成器用的是動軟程式碼生成器,資料庫設計好之後,一鍵生成後端 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 是存放模板以及資料配置的目錄。結構如下:

使用 Node.js 寫一個程式碼生成器

只生成 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 資料轉成我們需要的格式即可。

生成前端專案程式碼的時候,會遇到這種情況:

某個目錄結構是這樣的:

使用 Node.js 寫一個程式碼生成器

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

相關文章