美文网首页
nodejs开发starUML插件总结

nodejs开发starUML插件总结

作者: 超跑飞飞 | 来源:发表于2019-02-12 09:12 被阅读0次

    一、需求分析

    starUML介绍

    StarUML是一种创建UML类图,生成类图和其他类型的统一建模语言(UML)图表的工具

    该工具使用nodejs+electron开发。目前官方插件功能支持导出html格式的文件,由首左侧导航菜单和右侧iframe嵌套页面组成,如下图:

    starUML导出的html页面

    目前支持左侧多级菜单、右侧页面展示(包括标题 图片 表格等)
    需要在starUML编辑界面中点击 文件 > 导出 > 导出html doc...功能,在指定目录下生成html文件包。
    目前需要开发的是在starUML工具菜单中集成一个可以将UML图表导出为WORD格式的文件,尽可能还原原先的功能,包括:

    1. 左侧多级快捷导航菜单
    2. 将html页面转换为word格式页面,包括大小标题、描述、表格、图片
      目的是为了更直观的展示内容,方便阅读,另外在WORD中可以进行编辑修改的功能。

    二、可行性调研

    生成word文件需要用到文件流读写,starUML平台使用nodejs混合桌面应用开发,所以选择nodejs来操作生成word,由于自己写word格式文件难度较大,所以需要选择一个第三方工具包处理生成word文件,nodejs生态圈还不是很大,工具较少,最早确定使用officegen工具进行word文件的生成

    三、开发starUML插件

    资料整理

    starUML工具下载以及插件开发文档
    http://staruml.io/download 下载地址
    https://github.com/staruml/staruml-dev-docs/wiki 文档
    基于软件版本2.8.1进行开发 文档中有简单的示例功能 下面简单示例一下步骤

    插件开发模式

    首先根据操作系统打开对应的目录

    • Mac OS X: ~/Library/Application Support/StarUML/extensions/user
    • Windows: C:\Users<user>\AppData\Roaming\StarUML\extensions\user
    • Linux: ~/.config/StarUML/extensions/user
      在此目录下创建你的插件文件夹

    代码编写

    进入到新创建的文件夹中,创建main.js文件
    写入如下内容

    /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, browser: true */
    /*global $, define, app, C2S, md5 */
    // 项目基于require.js的模块机制 包括以上全局模块可以使用
    define(function (require, exports, module) {
        "use strict";
        // 引入一些全局提供的功能模块
        var ExtensionUtils = app.getModule("utils/ExtensionUtils"),
            NodeDomain = app.getModule("utils/NodeDomain"),
            FileUtils = app.getModule("file/FileUtils"),
            FileSystem = app.getModule("filesystem/FileSystem"),
            Async = app.getModule("utils/Async"),
            Repository = app.getModule("core/Repository"),
            ProjectManager = app.getModule("engine/ProjectManager"),
            Commands = app.getModule("command/Commands"),
            CommandManager = app.getModule("command/CommandManager"),
            MenuManager = app.getModule("menu/MenuManager"),
            DiagramManager = app.getModule("diagrams/DiagramManager"),
            Dialogs = app.getModule("dialogs/Dialogs"),
            MetadataJson = app.getModule("metadata-json/MetadataJson");
        // Officegen   = app.getModule("officegen/Officegen");
        // console.log(Officegen);
        // 定义操作菜单路径
        var CMD_FILE_EXPORT_WORD_DOCS = 'file.export.wordDocs';
        // 注册自定义node模块
        var hopeDomain = new NodeDomain("hope", ExtensionUtils.getModulePath(module, "node/HopeDomain"));
    
        /**
         * 写入自定义文件 html、txtd等
         * @param filename 格式 - 文件路径/文件名.后缀名
         * @param txt 文本内容
         * @returns {RegExpExecArray}
         * @private
         */
        function _writeBinaryFile (filename, txt) {
            console.log(filename);
            console.log(txt);
            return hopeDomain.exec("writeFile", filename, txt);
        }
    
        // 写入word文件
        function writeDoc () {
            hopeDomain.exec("writeDoc")
                .done(function (res) {
                    // 返回执行结果
                    console.log('res', res);
                }).fail(function (err) {
                console.error("writeDoc-error", err);
            });
        }
    
        // 注册软件顶部菜单栏
        CommandManager.register("WORD Docs...", CMD_FILE_EXPORT_WORD_DOCS, writeDoc);
    
        // 设置菜单 给 file->Export->下面新增一个`WORD Docs...`菜单
        var menuItem = MenuManager.getMenuItem(Commands.FILE_EXPORT);
        menuItem.addMenuDivider();
        menuItem.addMenuItem(CMD_FILE_EXPORT_WORD_DOCS);
    });
    

    软件启动时候会自动加载自定义插件目录下的main.js文件
    由于上面用到了自定义的nodejs模块 需要在插件目录中再创建一个node目录
    目录下新建HopeDomain.js文件


    插件目录结构
    // HopeDomain.js
    /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4,
    maxerr: 50, node: true */
    /*global */
    (function () {
        "use strict";
        // 引入nodejs fs文件操作模块
        var fs = require("fs");
        // 引入html转word插件
        var HtmlDocx = require('html-docx-js');
    
        // 引入nodejs office文件生成库
        var officegen = require('officegen')
        // html片段
        var html = '<h1>标题1</h1>'
    
        /**
         * 写入文件方法
         * @param filename
         * @param txt
         * @returns {*}
         */
        function writeFile (filename, txt) {
            return fs.writeFileSync(filename, txt, { encoding: 'utf8' });
        }
    
        /**
         * 写入word文件方法
         * @returns {*}
         */
        function writeDoc () {
            // 使用html片段生成word文件流 并写入到文件中保存
            var docx = HtmlDocx.asBlob(html, { orientation: 'landscape', margins: { top: 720 } });
            return writeFile('/Users/feifei/Desktop/html-docs/hope-' + new Date().getTime() + '.docx', docx)
            // officegen生成word文件方法 没有实现成功
            // var docx = officegen('docx');
            // var header = docx.getHeader().createP({ align: ('center') });
            // header.addText('hoperun', { font_size: 8, font_face: 'SimSun' });
            // header.addHorizontalLine();
            // var pObj1 = docx.createP();
            // pObj1.addLineBreak();
            // pObj1.addLineBreak();
            // pObj1.options.align = 'center';
            // pObj1.addText('目录', {
            //     font_size: 20
            // });
            // var out = fs.createWriteStream('/Users/feifei/Desktop/html-docs/hope-' + new Date().getTime() + '.docx'); // 创建文件
            // out.on('error', function (err) {
            // });
            // docx.generate(out, {
            //     'finalize': function (written) {
            //         if (written === 0) {
            //             console.log('恭喜处理成功!');
            //         }
            //     },
            //     'error': function (err) {
            //     }
            // });
        }
    
        // 自定义node模块初始化方法
        function init (domainManager) {
            // 如果没有hope模块则创建自己的hope模块
            if (!domainManager.hasDomain("hope")) {
                domainManager.registerDomain("hope", { major: 0, minor: 1 });
            }
            // 注册nodejs与插件异步通信回调的方法
            domainManager.registerCommand(
                "hope",       // domain name
                "writeFile",    // command name
                writeFile,   // command handler function
                false,          // this command is synchronous in Node
                "Returns the total or free memory on the user's system in bytes",
                [
                    {
                        name: "filename", // parameters
                        type: "string",
                        description: "file name"
                    },
                    {
                        name: "txt", // parameters
                        type: "string",
                        description: "txt data"
                    }
                ],
                [
                    {
                        name: "result", // return values
                        type: "string",
                        description: "result"
                    }
                ]
            );
            domainManager.registerCommand(
                "hope",       // domain name
                "writeDoc",    // command name
                writeDoc,   // command handler function
                false,          // this command is synchronous in Node
                "Returns the total or free memory on the user's system in bytes",
                [],
                []
            );
        }
    
        exports.init = init;
    
    }());
    

    开发插件目前只实现了用html转word文档的插件html-docx-js将html文档转为word文件,无法定制化,word内容根据html格式生成且样式不太美观
    之前选择的officegen插件在这里不能使用 可能因为软件nodejs版本较低不支持
    最终没有达到理想的效果,放弃了这种方式

    最后总结一下在开发插件中遇到的一些问题

    • 工具支持插件开发的dev模式,可以刷新插件,重新加载,但是经常刷新了还是看不到修改代码后的效果,最后发现需要重启软件才有效果,因此走了不少弯路,模式比较麻烦
    • nodejs模块是异步执行回调的 插件中代码可以调试 开发nodejs模块调试比较困难 因此无法看到officegen运行出错的信息
    • 参考一些现有的插件,原生node部分开发难度比较大,还有starUML图表数据解析转化比较复杂

    四、开发nodejs html转word工具

    由于开发starUML插件不是很理想,所以选择了另外一种方式,在工具自带的转换html格式的文档之后再进行操作,将html文件转换为word文件,不需要处理图表数据,从现成的html文件中取数据

    准备工作

    • html文档目录&文件分析
    • 按照文档结构生成word文档目录
    • 图表svg格式图片处理嵌入
    • html页面转换为word格式

    所用到的第三方库简介

    1. cheerio jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方,一般用在nodejs爬虫功能的开发中。
    1. officegen 模块可以为Microsoft Office 2007及更高版本生成Office Open XML文件。此模块不依赖于任何框架,您不需要安装Microsoft Office,因此您可以将它用于任何类型的JavaScript应用程序。输出也是流而不是文件,不依赖于任何输出工具。此模块应适用于支持Node.js 0.10或更高版本的任何环境,包括Linux,OSX和Windows。
      此模块生成Excel(.xlsx),PowerPoint(.pptx)和Word(.docx)文档。 Officegen还支持带有嵌入数据的PowerPoint本机图表对象。
    1. svg2png 一个可以将svg文件转为png格式图片的小工具 使用 pn 模块中提供的文件操作功能
      pn全称node-pn,由于早期版本nodejs不支持promise,pn将nodejs常用api的方法全部转化为promise实现,方便进行异步操作。(目前node开发环境支持es6语法,svg2png插件基于这个库实现的)

    图片处理

    由于部分html页面中包含图片资源,处理模板时候,图片处理流程不好控制,所以单独先将图片文件夹中的svg文件转换为png格式的图片

    /**
     * 处理图片文件夹的方法
     * @param dirname 文件夹名称
     * @param url
     * @returns {Promise<any>}
     */
    function parseImg (dirname) {
        return new Promise((resolve, reject) => {
            //根据文件路径读取文件,返回文件列表
            fs.readdir(dirname, function (err, files) {
                let svgArr = []; // 图片数组
                if (err) {
                    console.log('\n未发现图片目录'.red);
                    resolve();
                } else {
                    //遍历读取到的文件列表
                    files.forEach(function (filename, index) {
                        svgArr.push(filename);
                        let fileUrl = path.join(dirname, filename);
                        if (filename.split('.')[ 1 ] === 'svg') {
                            fsP.readFile(fileUrl)
                                .then(svg2png)
                                .then(buffer => {
                                    fsP.writeFile(fileUrl.replace('svg', 'png'), buffer).then(() => {
                                        resolve(svgArr)
                                    })
                                })
                                .catch(e => reject(e));
                        }
    
                    });
                }
            });
        })
    }
    

    模板处理

    首先创建word文件流,在文件流中添加内容,类似富文本编辑器,先获取所有html内容集合,根据标题创建目录和链接,跟下面每个页面对应上,可以从目录点击跳转对应页面,当页word内容填充完毕创建文件流,由officegen插件生成最终的word文件,其中获取所有文件(fileDisplay),目录生成(parseLink),单个html内容解析(parseHtml)使用单独方法处理,下面单独讲解。

    /**
     * 开始模板处理流程
     * @param path 当前html文件夹路径
     * @returns {Promise<void>}
     */
    async function start (path) {
        // 指定生成文件为word格式
        const docx = officegen('docx');
        // 获取到所有html文件内容的集合
        const htmlMap = await fileDisplay(path + '/contents');
        // 创建页眉
        const header = docx.getHeader().createP({ align: ('center') });
        header.addText('hoperun', { font_size: 8, font_face: 'SimSun' });
        header.addHorizontalLine();
        // 创建目录页面
        let pObj1 = docx.createP();
        pObj1.addLineBreak(); // 换行
        pObj1.addLineBreak();
        pObj1.options.align = 'center';
        pObj1.addText('目录', {
            font_size: 20
        });
        pObj1.addLineBreak();
        pObj1.addLineBreak();
        
        // 创建目录每一行的标题
        htmlMap.map((item, index) => {
            let pObj = docx.createP();
            parseLink(pObj, item, index)
        })
        docx.putPageBreak();
        
        // 处理单个html页面的模板
        htmlMap.map((item, index) => {
            parseHtml(docx, item, index, process.cwd()); // process.cwd() 当前nodejs命令执行的文件夹路径
        })
        const out = fs.createWriteStream(path + '/out.docx'); // 创建文件
        out.on('error', (err) => {
            console.log(err);
        });
        // 生成word文件
        docx.generate(out, {
            // 完成
            'finalize': function (written) {
                if (written === 0) {
                    console.log('');
                }
            },
            'error': function (err) {
                console.log(err);
            }
        });
    }
    
    1、获取所有html文件内容 - fileDisplay 方法实现

    从导航栏html文件中,解析所有的超链接,得到有序的html页面名称,根据页面名称读取对应html文件,将页面内容,文件名,文件类型集合返回。

    //文件遍历方法
    function fileDisplay (filePath) {
        return new Promise((resolve, reject) => {
            // 菜单文件路径
            let menuUrl = path.join(filePath, 'navigation.html');
            // 读取菜单html内容
            let menu = fs.readFileSync(menuUrl, 'utf-8');
            // 解析html
            const $menu = cheerio.load(menu);
            // 拿到所有的超链接
            const files = $menu('#navigation-tree a');
            if(!files.length) {
                console.log('\n处理失败,未发现html文件,请检查导出目录文件是否齐全');
            }
            let contArr = [];
            //根据文件路径读取文件,返回文件列
            files.each(function (index, item) {
                let filename = $menu(this).attr("href");  // 文件名
                let type = $menu(this).siblings("span").attr('class'); // 文件类型
                //获取当前文件的绝对路径
                let filedir = path.join(filePath, filename);
                // 读取文件内容
                let content = fs.readFileSync(filedir, 'utf-8');
                const $ = cheerio.load(content);
                contArr.push({
                    html: $('body').html(),
                    title: $menu(this).text(),
                    type: type ? type.replace('node-icon ', '') : 'tit'
                });
                resolve(contArr)
            });
        })
    }
    
    2、目录生成 - parseLink 方法实现

    由于标题没有层级关系,所以加上字符缩进还有符号美化一下,另外给每个标题加上超链接标识,方便跳转至对应页面

    // 根据类型添加不同符号、层级缩进空格 美化菜单
    const textArr = {
        'tit': ' ★  ',
        '_icon-UMLModel': '  ▸ ',
        '_icon-UMLComponentDiagram': '   ☆  ',
        '_icon-UMLComponent': '    + ',
        '_icon-UMLOperation': '      ● ',
        '_icon-UMLDependency': '    ↑ ',
        '_icon-UMLUseCaseDiagram': '   ☆  ',
        '_icon-UMLUseCase': '    + ',
        '_icon-UMLAttribute': '      ● ',
        '_icon-UMLInteraction': '    + ',
        '_icon-UMLSequenceDiagram': '   ☆  ',
        '_icon-UMLLifeline': '      ● ',
        '_icon-UMLMessage': '      ● ',
        '_icon-UMLAssociation': '    + ',
        '_icon-UMLAssociationEnd': '      ● ',
        '_icon-UMLInclude': '      ● ',
        '_icon-UMLActor': '    + '
    }
    // 格式化标题内容
    function getTitleText (item) {
        return textArr[ item.type ] + item.title
    }
    
    /**
     * 解析菜单
     * @param p 文本容器
     * @param item 单个页面对象
     * @param index 索引
     */
    function parseLink (p, item, index) {
        p.addText(getTitleText(item), {
            color: item.type === 'tit' || item.type === '_icon-UMLModel' ? '#333' : '#050cff', // 文本颜色
            font_face: 'Arial', // 字体
            font_size: item.type === 'tit' ? 15 : 12, // 字号
            hyperlink: 'hoperun' + index // 锚点跳转标识
        });
    }
    
    3、html内容解析 - parseHtml 方法实现

    解析html部分比较复杂,要分析html的dom结构,使用jquery选择器取到对应数据,包括标题,描述,表格,图片,锚点的添加。

    /**
     * 解析html文件内容
     * @param docx doc对象
     * @param item 页面对象
     * @param index 索引
     * @param doc_url html文档目录
     */
    function parseHtml (docx, item, index, doc_url) {
        // 解析页面html内容
        const $ = cheerio.load(item.html);
        // 创建标题
        let pObj = docx.createP();
        // 锚点开始
        pObj.startBookmark('hoperun' + index);
        pObj.options.align = 'center';
        pObj.options.pStyleDef = 'Heading1';
        let title = $('h1').eq(0).text();
        // 处理内容不恰当的标题
        if (title === '(unnamed)' || title === '/') {
            title = $('section').eq(1).find('a').last().text().replace('/:', '');
        }
        pObj.addLineBreak();
        pObj.addText(title, { font_size: 24, font_face: '', bold: true, underline: true });
        pObj.addLineBreak();
        pObj.addLineBreak();
        // 创建描述
        let pObj1 = docx.createP();
        pObj1.options.align = 'left';
        pObj1.addText('Description', { font_size: 24, font_face: '', bold: true, underline: true });
        $('h3').each(function (index, item) {
            if ($(this).text() === 'Description') {
                let pObjd = docx.createListOfDots();
                if ($(this).next().find('li').length > 0) {
                    $(this).next().find('li').each(function () {
                        pObjd.addText($(this).contents().filter(function (index, content) {
                            return content.nodeType === 3;
                        }).text());
                    })
                } else {
                    pObjd.addText('none');
                }
            }
        })
        // 创建图片
        if ($('img').length >= 1) {
            let p = docx.createP();
            p.addText($('img').length === 1 ? 'Diagram' : 'Diagrams', {
                font_size: 24,
                font_face: '',
                bold: true,
                underline: true
            });
            // 替换图片地址
            $('img').each(function (item, index) {
                p.addLineBreak();
                let url = $(this).attr('src').replace('svg', 'png').replace('../', './')
                p.addImage(path.resolve(doc_url, url));
                p.addLineBreak();
            })
        }
        // 创建表格
        if ($('table').length > 0) {
            let table = [];
            let trs = $('table').eq(0).find('tr');
            trs.each(function (i) {
                let tds = [];
                if (i === 0) {
                    $(this).children('th').each(function (j) {
                        tds.push({
                            val: $(this).text(),
                            opts: {
                                cellColWidth: 4261,
                                b: true,
                                sz: '48',
                                shd: {
                                    fill: "7F7F7F",
                                    themeFill: "text1",
                                    "themeFillTint": "80"
                                },
                                fontFamily: "Avenir Book"
                            }
                        })
                    })
                } else {
                    $(this).children('td').each(function (j) {
                        tds.push($(this).text());
                    })
                }
                if (tds) {
                    table.push(tds);
                }
            })
            var tableStyle = {
                tableColWidth: 4261,
                tableSize: 24,
                tableColor: "ada",
                tableAlign: "left",
                tableFontFamily: "Comic Sans MS",
                borders: true
            }
            if (table[ 0 ].length > 1) {
                let pObj = docx.createP();
                pObj.addLineBreak();
                pObj.addText('Properties', { font_size: 24, font_face: '', bold: true, underline: true });
                pObj.addLineBreak();
                docx.createTable(table, tableStyle);
            }
        }
        // 锚点结束
        pObj.endBookmark();
        // 换页
        docx.putPageBreak();
    }
    

    流程整理

    将图片处理跟模板解析集成到一起

    /**
     * 功能入口函数
     * @param path 当前命令执行的目录(html-docs文件夹目录)
     * @returns {Promise<void>}
     */
    async function office (path) {
        // 首先处理图片
        let res = await parseImg(path + '/diagrams');
        // 在图片处理完成之后再处理html模板,影响解析html时候图片资源的嵌入
        setTimeout(() => {
            start(path);
        }, 3000)
    }
    

    五、开发nodejs命令行工具

    开发过程中是将nodejs代码直接写在html-docs文件夹中的,不方便使用,需要将功能跟html文件分离,所以选择开发一个简单的nodejs全局命令行工具,只需在需要转换的目录下执行一个命令就可以处理好。

    准备工作

    需要用到的几个插件介绍

    1. commander node.js命令行界面的完整解决方案,受Ruby Commander启发。
    2. inquirer 一个用户与命令行交互的工具,比如npm init,脚手架工具生成项目,项目没有用到,开发命令行工具必备小工具。
    3. colors colors.js 是一个用于 node.js 终端 console.log 的颜色库 美化命令行。
    4. single-line-log nodejs命令行的小工具,可以在同一行输出不用点的内容,可以做文本动态显示,进度条。

    目录分析

    脚手架工具目录

    bin 目录中放可执行命令文件
    lib 目录中放插件包 比如html转word的工具就放到这里作为一个插件
    index.js文件为入口 只需将lib目录导出

     module.exports=require('./lib')
    

    package.json 文件为项目元数据信息,作为命令行工具需要增加bin字段,如图上hoperun为全局可执行命令的名称,对应的值为bin下面对应的执行文件

    bin/office.js

    #!/usr/bin/env node
    const program = require('commander');
    const office = require('../lib/html-doc/office')
    const inquirer = require('inquirer');
    // 初始化配置选择项
    const initQuestions = [ {
        type: 'list',
        name: 'plattype',
        message: '请选择平台类型?',
        choices: [
            'pass',
            'sass',
            'iaas'
        ]
    },
        {
            type: 'list',
            name: 'vmCounts',
            message: '请选择您包含的虚拟机数量?',
            choices: [ '100', '200', '500', '1000' ]
        }
    ];
    // 登录命令输入项
    const loginQuestions = [ {
        type: 'input',
        name: 'username',
        message: '请输入用户名',
    },
        {
            type: 'password',
            name: 'password',
            message: '请输入用户密码'
        }
    ];
    
    // 定义版本和参数选项
    program
        .version('v' + require('../package.json').version, '-v, --version')
        .description('nodejs 命令行工具')
        .option('-s, --star', 'starUMl生成word文档功能')
        .option('-g, --generate', '生成xxx')
        .option('-l, --login', '登录');
    
    // 必须在.parse()之前,因为node的emit()是即时的
    program.on('--help', function () {
        console.log('  Examples:');
        console.log('');
        console.log('    this is an example');
        console.log('');
    });
    
    program.parse(process.argv);
    // 如果输入的命令是star话执行office方法,将当前命令执行的目录地址传入工具进行处理
    if (program.star) {
        office(process.cwd())
    }
    
    if (program.generate) {
        inquirer.prompt(initQuestions).then(result => {
            console.log("您选择的平台类型信息如下:");
            console.log(JSON.stringify(result));
        })
        console.log('generate something')
    }
    
    if (program.login) {
        inquirer.prompt(loginQuestions).then(result => {
            console.log("您登陆的账户信息如下:");
            console.log(JSON.stringify(result));
        })
    }
    // if (program.args.length === 0) {
    //     program.help()
    // }
    

    六、项目优化

    由于项目没有用户界面,只能在命令行操作,等待处理图片,解析html,生成word时候添加一些log日志,提示信息的比较好,所以选择了一些命令行优化工具,比如同行打印,文本颜色。

    在office转换工具中,增加了一些错误处理,逻辑判断,保证在工具运行前,运行中都能将交互内容进行输出,让工具使用中更加人性化。

    简单几个示例

    目录检测

       let checkDir = fs.existsSync(path + '/contents');
        let checkNav = fs.existsSync(path + '/contents/navigation.html')
        if (!checkDir || !checkNav) {
            console.log((`\n当前目录${path}\n请在starUML生成的html目录下执行该命令`).red);
            return
        }
    

    处理用时

    // 生成word文件
        docx.generate(out, {
            // 完成
            'finalize': function (written) {
                if (written === 0) {
                    console.log('');
                    console.log('');
                    console.log('-----------------------------------------');
                    console.log('恭喜处理成功!');
                    console.log(('用时:' + ((new Date().getTime() - start_time) / 1000).toString() + 's').green);
                    console.log('-----------------------------------------');
                    console.log('');
                    console.log('');
                }
            },
            'error': function (err) {
                console.log(err);
            }
        });
    

    同行打印,颜色提示

    console.log('\n开始生成word文件......'.green);
    
    slog(`正在处理第${index + 1}个文件...`);
    

    相关文章

      网友评论

          本文标题:nodejs开发starUML插件总结

          本文链接:https://www.haomeiwen.com/subject/mujajqtx.html