政务低代码平台实战⑤:双输出模式——存DB与导出JSP的完整链路

非科班野生程序员,深耕政务信息化20年。表单设计器设计好了,设计结果存哪?运行时怎么还原?这篇拆解设计器的两条出路——存数据库动态渲染,和导出JSP静态加载。两种模式还能互相转换,导出的JSP可以载回设计器继续编辑。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。


背景

表单设计器解决了"怎么设计"的问题。但设计结果有两条出路:

  1. 开发阶段:频繁调整布局、改字段、改SQL。需要存数据库,随时载回设计器修改,运行时动态渲染。
  2. 上线后:表单不再变化。动态渲染每次都读DB、解析组件树、生成HTML,有性能开销。不如导出成JSP文件直接部署。

两条路都要走通,而且要能互相切换。


两条出路的数据流

设计器(formEdit.jsp)
    │
    ├── 保存到DB(add()按钮)
    │     ├── T_FORM     → 表单定义(HTML、JS脚本、参数)
    │     ├── T_FIELD    → 字段定义(名称、类型、代码表、默认值)
    │     ├── T_TABLE    → 数据源绑定(form_id关联)
    │     ├── T_EL       → 元素树(组件ID、父ID、类型、JSON配置、事件)
    │     └── T_EL_COLUMS → 元素属性(每个组件的详细属性键值对)
    │           │
    │           ↓ type=1/2
    │     onlyFrom.jsp 从DB读取 → 动态渲染
    │
    └── 导出JSP(saveto()按钮)
          ├── 写 xxx.jsp 文件到磁盘
          ├── 写 xxx.js  文件到磁盘
          └── T_FORM.type=3, filepath='xxx.jsp'
                │
                ↓ type=3
          运行时读JSP文件直接输出

保存到数据库

涉及的5张表

T_FORM(表单主表):

字段含义
ID_表单唯一ID
NAME_表单名称
DESCRIPTION_描述
TYPE_类型:1=列表, 2=表单, 3=JSP文件
HTML_表单HTML内容(CLOB)
SCRIPT_表单JS脚本(CLOB)
COLS列数
H高度
W宽度
PARAMETERS_参数
REPORT_打印模板
SQLID关联的SQL编号
FILEPATHJSP文件路径(type=3时)

T_FIELD(字段表): 每个绑定数据的控件一条记录

字段含义
FIELDID字段ID
ID_所属表单ID
NAME_英文名(binding字段名)
DESCRIPTION_中文名
COLTPYE控件类型
DATATYPE数据类型
N_COLS新增时列宽
D_COLS显示时列宽
P_VAL是否流程变量
COMMENS自定义选项JSON
CODENAME二级代码编码
READONLY是否只读

T_EL(元素树): 设计器组件树的每个节点

字段含义
EL_ID元素ID(组件ID)
PARENT_EL_ID父元素ID
TYPE_组件类型(browise.form.TextBox等)
JSON_创建时的JSON配置
OTHER_其他参数
ORDERID排序
EVENT_事件绑定JSON

T_EL_COLUMS(元素属性): 每个组件的详细属性

字段含义
EL_ID元素ID
COL_属性名(dojoType/width/binding/disabled等)
VALUE_属性值

T_TABLE(数据源绑定): 表单关联的数据表

字段含义
ID_表单ID
FORM_ID数据源form的ID

保存过程

uiglobal.js 中的 add() 函数:

function add() {
    // 1. 收集所有组件信息
    var nodes = getformorgrid();  // 获取所有Form和Grid节点
    
    // 2. 遍历组件树,生成HTML
    var shtml = "";
    for (var i = 0; i < nodes.length; i++) {
        if (nodes[i].declaredClass == 'browise.form.Form') {
            shtml += gettable(nodes[i].id, parent_id);  // Form+Table序列化为HTML
        } else if (nodes[i].declaredClass == 'browise.grid.Grid') {
            shtml += getgrid(nodes[i].id, parent_id);   // Grid序列化为HTML
        }
    }
    
    // 3. 发送到后端
    var dc = new browise.ds.DataCenter();
    dc.addStore(dsForm);     // 表单元数据
    dc.addStore(dsField);    // 字段定义
    dc.addStore(dsEl);       // 元素树
    dc.addStore(dsElCol);    // 元素属性
    dc.addStore(dstable);    // 数据源
    
    var URL = webRootDir + "/BusinessAction?Business=formManager&Action=Business";
    browise.Action.requestData({ url: URL, load: function(dc1) {
        alert("保存成功!");
    }}, dc, true);
}

后端 formManagerControl.Business() 接收数据:

// 如果是type=2(修改),先删后插
if ("2".equals(type)) {
    formService.deleteform(id);     // 删表单
    formService.deletefield(id);    // 删字段
    formService.deleteel(id);       // 删元素
    formService.deleteelcloum(id);  // 删元素属性
    formService.deletetable(id);    // 删数据源
}

// 重新插入
formService.insertform(dsForm);
formService.insertfield(dsField);
formService.insertel(dsEl);
formService.insertelcloum(dsElCol);
formService.inserttable(dsTable);

"先删后插"的策略虽然粗暴,但保证了数据一致性——不会出现修改后组件树和属性不同步的问题。


导出JSP文件

uiglobal.js 中的 saveto() 弹出对话框输入文件名和路径,然后调用后端:

// formManagerControl.SaveToJSP()
public DataCenter SaveToJSP(DataCenter center, ...) throws Exception {
    DataStore dsea01 = center.getStore("ea01");
    String filename = dsea01.getRowset().getrow(0).getItemStringValue("name_");
    String path = dsea01.getRowset().getrow(0).getItemStringValue("description_");
    String html = dsea01.getRowset().getrow(0).getItemStringValue("html_");
    String script = dsea01.getRowset().getrow(0).getItemStringValue("script_");
    
    // 拼JSP文件头
    String jspContent = "<!DOCTYPE html PUBLIC ...>\n"
        + "<%@page contentType=\"text/html; charset=UTF-8\"%>\n"
        + "<%\n"
        + "\tString sessionid = \"0000000000000000000\";\n"
        + "%>\n"
        + "<html>\n<head>\n"
        + "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>\n"
        + "<%@ include file=\"/browise/ria3.3/pages/config.jsp\"%>\n";
    
    // 拼JS文件引用
    jspContent += "<script type=\"text/javascript\" src=\"" + filename + ".js\"></script>\n";
    
    // 拼HTML body
    jspContent += "</head>\n<body class=\"unieap\">\n" + html + "\n</body>\n</html>";
    
    // 写JSP文件
    String jspPath = request.getRealPath("/") + "pages/" + path + "/" + filename + ".jsp";
    FileOutputStream fos = new FileOutputStream(jspPath);
    fos.write(jspContent.getBytes("UTF-8"));
    fos.close();
    
    // 写JS文件
    String jsPath = request.getRealPath("/") + "pages/" + path + "/" + filename + ".js";
    fos = new FileOutputStream(jsPath);
    fos.write(script.getBytes("UTF-8"));
    fos.close();
    
    // 更新T_FORM:type=3, filepath
    dsea01.getRowset().getrow(0).setItemValue("type_", "3");
    dsea01.getRowset().getrow(0).setItemValue("filepath", "pages/" + path + "/" + filename + ".jsp");
    formService.updateform(dsea01);
    
    return dc;
}

导出做了三件事:

  1. 把设计器的HTML和JS分别写成 .jsp.js 文件
  2. JSP文件包含标准的 <%@ page %> 声明和 config.jsp 引用
  3. 更新 T_FORMtype=3filepath

三种表单类型的运行时调度

万能表单 onlyFrom.jsp 根据type决定渲染方式:

<%
    String type1 = formds.getRowset().getrow(0).getItemStringValue("type");
    if ("3".equals(type1)) {
        // type=3:读JSP文件直接输出
        String html = FileUtil.readJspFile(
            formds.getRowset().getrow(0).getItemStringValue("filepath"));
        out.println(html);
    } else {
        // type=1/2:从数据库的html_字段读HTML
        String html = String.valueOf(formds.getRowset().getrow(0).getItemValue("html_"));
        out.println(html);
    }
%>
type来源性能适用场景
1T_FORM.HTML_字段列表页面(开发阶段)
2T_FORM.HTML_字段表单页面(开发阶段)
3JSP文件上线后(不再修改)

type=1和type=2的区别不在渲染,而在设计器模式——UI版本设计列表页(含Grid+条件),UI2版本设计表单页(纯表单,oneSql=true)。


载入回设计器

已保存的表单可以载回设计器继续编辑。openload() 弹窗选择表单,后端 detail() 方法查出T_FORM、T_FIELD、T_EL、T_EL_COLUMS数据返回前端,loadformdb() 函数还原组件树:

function loadformdb(rowdata) {
    // rowdata包含T_EL元素列表
    // 按orderid排序,逐个还原:
    // 1. 先创建Form容器(createFormofDb)
    // 2. 再创建Table(createTABLEofDb)
    // 3. 创建TR(createTRofDb)
    // 4. 创建TD(createTDofDb)
    // 5. 在TD里创建控件(根据type_创建对应组件)
    // 6. 恢复属性(从T_EL_COLUMS读回)
    // 7. 恢复事件绑定
}

还原顺序严格按照 orderid——先父后子,保证DOM树层级正确。


完整闭环

设计器设计表单
    ↓
保存到DB(type=1/2)
    ↓
运行测试 → 不满意? → 载回设计器修改
    ↓                    ↓
满意了               修改完再保存
    ↓                    ↓
导出JSP(type=3)    继续测试
    ↓
上线运行(读JSP文件,高性能)
    ↓
政策变了要改表单?→ 载回设计器(type改回1/2)→ 修改 → 再导出

设计器 → DB → 测试 → JSP → 上线 → 载回修改 → 再导出。整个生命周期都走通了。


决策原则

开发阶段求灵活,上线后求性能。

存DB的好处是随时可编辑、可载回、可修改。导JSP的好处是零数据库访问、直接输出HTML。两种模式不是互斥的,是同一个设计结果在不同阶段的不同表现形式。

关键设计是 T_FORM.type 字段——一个字段控制渲染方式,业务代码不用变,只有渲染层根据type走不同的分支。而且type可以随时改回来:导出JSP后,载入设计器时type自动改回1/2,修改完再导出。


你项目里的表单是怎么管理生命周期的?欢迎评论区聊聊。


系列导航:

  • 总纲:[政务低代码平台实战——从元数据引擎到可视化设计器的五个关键决策]
  • 上一篇:[政务低代码平台实战④:可视化表单设计器——拖拽、合并拆分、代码生成]
  • 框架系列:[Java Web自研框架18年架构决策复盘(系列文总纲)]
  • 工作流系列:[政务工作流实战(二)——BPMN画不出来的七个设计决策]

作者:许彰午 | 非科班野生程序员,深耕政务信息化20年

标签: #Java #低代码 #JSP导出 #动态渲染 #表单管理 #政务信息化

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐