从0开始开发一个表单引擎(上)

简介: 介绍如何通过一张数据库表中字段的相关信息,生成一系列CRUD的api。

0开始开发一个表单引擎(上)

起因

在我们能够在IDE中自动生成CRUD代码以后,难免会思考是不是能够向前走一步,把生成的CRUD代码自动部署到应用中。

正好某项目有类似的PoC的需求,要点是通过自定义表单的字段,生成填报,浏览,编辑,删除表单的页面,也即我们通常理解的表单引擎

渲染页面是前端的工作。作为后端,其实就是提供CRUDAPI

我们不妨从后端的角度梳理一下,当我们通过一些模板文件生成Java代码以后,这些代码应该怎么部署到应用中呢?

假设

  • 我们的后端服务都是spring      bootweb应用。
  • 一个表单以一张数据库表来表示。
  • 我们已经有一套模板,可以按照数据库表字段生成从entitymapperservicecontroller的代码。

需求定义

  • 我们需要一个后端服务,提供下面几个功能。
  • 用户自定义表单(数据库表)字段,例如在前端表现上,可能是一个表单编辑器:

image.png

  • 保存表单以后,后端服务将代码部署到spring bootweb容器中,立即提供针对该表单的CRUD操作,其实就是4REST API。他们可能是这样的URL
  • /form/表名/create
  • /form/表名/query
  • /form/表名/edit
  • /form/表名/delete

 

任务拆分

首先总结一下上面的业务步骤

image.png

对照上面的流程,我们还差哪些呢?

  1. 定义一个API,用于提供给用户来指定这个表单(数据库表)有哪一些字段,他们是什么类型,是否必填等。
  2. 根据#1的信息,生成一个建表语句,例如,用户提供了一张入学登记表,包含姓名,年龄,性别,班级字段,那我们应该生成这样的建表语句。
--建表语句本身
CREATE TABLE FR_ENROLL_REG (
id INT AUTO_INCREMENT PRIMARY KEY,
FD_NAME VARCHAR(255) NOT NULL COMMENT "姓名"
FD_AGE INT DEFAULT 10 COMMENT "年龄",
FD_GENDER VARCHAR(10) DEFAULT 'male' COMMENT "性别",
FD_CLASS VARCHAR(10) NOT NULL  COMMENT "班级",
-- 其他公共字段,gmt_created, deleted等
)  ENGINE=INNODB COMMENT '入学登记';

似乎还好,我们在自动生成JAVA代码里面,已经很熟悉freemarker的使用了。

  1. 创建表。嗯,jdbcTemplatemybatis,任何一个可以动态生成sql语句的方法都可以创建数据库表。
  1. 当然有些同学肯定会想到不应该把业务表放在同一个数据源,这些同学很有技术意识。但是我们只是PoC,管他的呢(雾)。
  1. 自动生成CRUD代码,这部分已经有现成逻辑了,我们拷过来就好了。
  2. 部署到spring。这个好像有点难。我想想,大概分成这么几步:
  1. 编译代码。这意味着我们需要能读入Java代码文件,然后调用javac编译器,编译成.class文件。
  2. 加载classspring。比如controllerservicemapper,我猜他们肯定要成为一个spring bean,能被自动装配,才能对外提供服务吧?

 

image.png

大概是上面这样,应该没有漏掉。

OKLet's get our hands dirty

 

定义API

这一节我们主要考虑,我们要表达一个表单,应该包含哪些信息。

  • 表名。这张表我们叫它什么名字,以后怎么访问它。
  • 字段列表。我们需要定义一个列表,每个元素代表一个字段,包含:
  • 字段名,例如姓名
  • 字段的标识,例如“name”,这样我们知道数据库表里面如何存放它。
  • 字段的类型,很重要。例如是字符,数字,还是一个日期。作为PoC,我们先支持数字,字符串,日期,布尔好了,不用支持太多。
  • 一些校验字段,例如是否必填,长度等。作为PoC,这些我们也不用做得太复杂,首先考虑把他们存下来。

所以一个创建表单的API可以是这样:

methodPOST

url/formCreate

请求体:

{
"tableName": "account",
"entityName": "Account",
"tableDescription": "账户",
"columnList": [
{
"fieldType": "Long",
"fieldName": "id",
"fieldComment": "主键",
"isPrimaryKey": true,
"required": true,
"length": 11
},
{
"fieldType": "String",
"fieldName": "name",
"fieldComment": "姓名",
"isPrimaryKey": false,
"required": true,
"length": 128
},
{
"fieldType": "Date",
"fieldName": "birthday",
"fieldComment": "出生日期",
"isPrimaryKey": false,
"required": false
}
]
}

这样前端通过某种表单编辑器,提供上面的信息后(前端可能还会要求我们保存一些布局和控件类型的信息),我们就能够创建一张数据库表了。

  • 题外话

其实保存表单的元数据,并不一定要创建数据库表,另一种常用的方式,也可以通过键值对的方式来保存字段。比如:使用一张表存放上面的字段信息,保存填表数据的时候,使用<“表单实例id”字段名字段值”>三元组保存到一个窄表中。

这样的方式我们就不需要创建数据库表了,修改元数据本身的时候,无需修改数据库schema,相对要简单。

这两种方式各有什么优缺点呢?大家可以思考一下。

 

建表语句渲染

Java有很多开源模板引擎。我们自己经常用的就是freemarker。写好一个模板文件,Java代码中放入一个kv参数对就可以渲染出来了。

撰写模板文件

CREATE TABLE `${formMain.tableName}` (
<#list fieldList as formField>
`${formField.dbFieldName}` ${formField.dbType}(${formField.dbLength}) ${formField.fieldMustInput?then('NOT NULL', 'NULL')} <#if formField.dbDefaultVal??>DEFAULT '${formField.dbDefaultVal}' </#if><#if formField.dbFieldTxt??>COMMENT '${formField.dbFieldTxt}'</#if>,
</#list>
`gmt_create` datetime(0) DEFAULT NULL COMMENT '创建时间',
`gmt_modified` datetime(0) DEFAULT NULL COMMENT '修改时间',
`creator` varchar(255) DEFAULT NULL COMMENT '创建人',
`modifier` varchar(255) DEFAULT NULL COMMENT '修改人',
`deleted` tinyint(1)  DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (${primaryKeyStr})
) ENGINE = InnoDB CHARACTER SET = utf8;

生成建表语句

这部分比较简单,使用freemarker获取上面的template文件,传入formMain对象就好了。

我们写一个单测看一下效果。

image.png

生成的是一段合法的DDL

创建表

我们使用mybatis,直接传入这个建表语句。

@Mapper
public interface TableManagementMapper {
 
         
void createNewTable(@Param("sql") String sql);
}

 

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.aliyun.gts.bpaas.form.engine.domain.mapper.TableManagementMapper">

   <update id="createNewTable" parameterType="java.lang.String" >

       ${sql}

   </update>

</mapper>

  • 思考

之江警告:这里会不会有什么安全隐患?

 

自动生成CRUD代码

对于一个典型的Java web应用,我们有下面几个文件要生成:

  • Controller
  • Service(和ServiceImpl
  • Mapper(如果是使用的mybatis的话)
  • Entity

为了简化,ServiceMapperEntity,我们都使用mybatis-plus的方式来生成。用mybatis-plus,我们定义的mapperservice都不用手写代码了,仅需要继承mybatis-plus提供的父类就行。

变量的说明

  • entityName:驼峰命名实体名,例如“Order”“Student”
  • tableName:表名,一般是实体名的下划线表示,例如“order”“student_enroll”

Entity

@TableName("${tableName}")
@Data
public class ${entityName} implements Serializable {
 
         
private static final long serialVersionUID = 1L;
 
         
<#list columnList as po>
<#if po.isPrimaryKey>
@TableId(type = IdType.INPUT)
<#else>
<#if po.fieldType =='Date'>
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
</#if>
</#if>
@ApiModelProperty(value = "${po.fieldComment}")
private ${po.fieldType} ${po.fieldName};
 
         
public ${po.fieldType} get${po.fieldName?cap_first}() {
return this.${po.fieldName};
}
public void set${po.fieldName?cap_first}(${po.fieldType} ${po.fieldName}) {
this.${po.fieldName} = ${po.fieldName};
}
 
         
</#list>
}

有两点注意:

  1. 我们主动生成了gettersetter方法。原因是,虽然使用了@Data注解,但是测试中发现,在单元测试中lombok没有起作用。
  2. id生成的方式是INPUT。原因是我们希望手动生成id。考虑这种情况:如果要支持一对多的结构(例如任务表对应多个子任务表),手动产生id会比较方便。(为什么?)

Mapper

public interface ${entityName}Mapper extends BaseMapper<${entityName}> {
 
         
}

注意到Mapper使用mybatis-plus风格很简单。

ServiceServiceImpl

Service接口的定义:

public interface ${entityName}Service extends IService<${entityName}> {
}

ServiceImpl的实现:

@Service
public class ${entityName}ServiceImpl extends ServiceImpl<${entityName}Mapper, ${entityName}> implements ${entityName}Service {
 
         
}

注意只要继承了mybatis-plusservice类,无需手动注入mapper了。

Controller

主要是注入service,调用并生成CRUDAPI

@Api(tags="${tableDescription}")
@RestController
@RequestMapping("/form/${entityName?uncap_first}")
@Slf4j
public class ${entityName}Controller {
 
         
@Autowired
private ${entityName}Service ${entityName?uncap_first}Service;
 
         
/**
* 分页列表查询
*
* @param ${entityName}
* @param pageNo
* @param pageSize
* @param req
* @return
*/
@ApiOperation(value="${tableDescription}-分页列表查询", notes="${tableDescription}-分页列表查询")
@GetMapping(value = "/list")
public ResultResponse<?> queryPageList(${entityName} ${entityName?uncap_first},
@RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
@RequestParam(name="pageSize", defaultValue="10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<${entityName}> queryWrapper = QueryGenerator.initQueryWrapper(${entityName?uncap_first}, req.getParameterMap());
Page<${entityName}> page = new Page<${entityName}>(pageNo, pageSize);
IPage<${entityName}> pageList = ${entityName?uncap_first}Service.page(page, queryWrapper);
return ResultResponse.succResult(pageList);
}
 
         
/**
*   添加
*
* @param ${entityName?uncap_first}
* @return
*/
@ApiOperation(value="${tableDescription}-添加", notes="${tableDescription}-添加")
@PostMapping(value = "/add")
public ResultResponse<?> add(@RequestBody ${entityName} ${entityName?uncap_first}) {
${entityName?uncap_first}Service.save(${entityName?uncap_first});
return ResultResponse.succResult("添加成功!");
}
 
         
/**
*  编辑
*
* @param ${entityName?uncap_first}
* @return
*/
@ApiOperation(value="${tableDescription}-编辑", notes="${tableDescription}-编辑")
@PutMapping(value = "/edit")
public ResultResponse<?> edit(@RequestBody ${entityName} ${entityName?uncap_first}) {
${entityName?uncap_first}Service.updateById(${entityName?uncap_first});
return ResultResponse.succResult("编辑成功!");
}
 
         
/**
*   通过id删除
*
* @param id
* @return
*/
@ApiOperation(value="${tableDescription}-通过id删除", notes="${tableDescription}-通过id删除")
@DeleteMapping(value = "/delete")
public ResultResponse<?> delete(@RequestParam(name="id",required=true) String id) {
${entityName?uncap_first}Service.removeById(id);
return ResultResponse.succResult("删除成功!");
}
}

编译代码

生成了一个Java类的代码以后,我们需要编辑成.class文件,并能够加载到当前的classLoader中。

我们一般能想到两种方式:

  1. 直接在代码调用命令行(javac      xxx.java)来编译
  2. 实际上JDK已经提供了一个名为JavaCompiler的类用于代码编译。

我们选择第二种方式,下面一段代码就能编译任意一段String表示的Java class

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<JavaFileObject>();
 
         
StringWriter writer = new StringWriter();
PrintWriter out = new PrintWriter(writer);
out.println("public class HelloWorld {");
out.println("  public static void main(String args[]) {");
out.println("    System.out.println(\"This is in another java file\");");
out.println("  }");
out.println("}");
out.close();
JavaFileObject file = new JavaSourceFromString("HelloWorld", writer.toString());
 
         
Iterable<? extends JavaFileObject> compilationUnits = Arrays.asList(file);
List<String> options = Arrays.asList("-g", "-nowarn", "-cp", CompilerUtils.getClasspath());
CompilationTask task = compiler.getTask(null, null, diagnostics, options, null, compilationUnits);
 
         
boolean success = task.call();

其中注意:

options作为一个list,包含了和javac一样的命令行参数,“-cp”表示classpath,这个非常重要。我们编译的代码,例如Controller.java需要引用两种类型的类:

  1. SpringBoot的相关依赖,例如我们注解了类为RestController
  2. 之前生成的EntityServiceMapper的类。

不正确设置classpath的话我们的编译是无法成功的。

设置编译的classpath

对于上面提到的第二种类引用,我们只需要保证把所有生成的.class文件(上面的编译成功后,可以获取到字节码的byte[]表示)输出到一个指定的目录,并把该目录作为classpath即可。

对于Spring的相关依赖,这个有一些复杂,需要分情况讨论:

  1. 如果应用是通过mvn      test或者IDE中启动的,Spring的相关依赖已经自动加入到应用classpath中,无需额外指定了。
  2. 如果应用是通过spring      boot fat jarjava -jar 你的spring-boot应用.jar)形式启动的,先给结论:如果不做特殊处理,无论如何怎么折腾,你也加载不到Spring boot相关的依赖。后面的章节会详细讨论原因。

我们这里先就着第一种情况,设置一个class文件生成路径,这里有一个讨巧的做法,使用路径:

System.getProperty("user.dir")/"target"/"classes"

这样无论在单测中,IDE中启动应用,还是在IDE中启动单测,你都不需要区别对待。

所以这里的建议是:默认情况下,使用该路径作为编译代码的.class文件的输出路径,和编译代码的classpath

编译成功以后,我们会得到两样东西:

  1. 我们能拿到一个Class<?>实例。
  2. 编译会产生一个.class文件。

注册到spring

Entity不用注册,编译完成以后,加载class就行了。MapperServiceImplController除了编译本身,还需要注册到spring中。

这里其实需要两个步骤:

  1. 编译后得到的Class<?>实例,需要实例化成一个Java类实例,并且注册到spring上下文中,成为一个spring      bean
  2. 特殊的类型的,例如MapperController,实际上要起作用,不仅仅只是存在spring上下文,还需要额外的注册工作。

注册为spring bean

springBeanFactoryPostProcessor提供beanFactory,用于修改bean的定义。可以使用beanFactory来注册bean,对于singleton类型的bean,可用如下方式注册。(注意:Mapper其实不是Singleton类型的spring bean

public class SpringBeanRegister implements BeanFactoryPostProcessor {
 
         
private ConfigurableListableBeanFactory beanFactory;
 
         
private ApplicationContext applicationContext;
 
         
protected Object registerBeanDef(Class<?> clazz) {
GenericBeanDefinition beanOtherDef = new GenericBeanDefinition();
beanOtherDef.setBeanClass(clazz);
DefaultListableBeanFactory defaultListableBeanFactory = (DefaultListableBeanFactory)beanFactory;
defaultListableBeanFactory.registerBeanDefinition(clazz.getSimpleName(), beanOtherDef);
return applicationContext.getBean(clazz.getSimpleName());
}
 
         
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
this.beanFactory = beanFactory;
}
 
         
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}

我们下面分别介绍注册不同类型的bean的具体做法。

注册ServiceImpl

仅需要调用上面的registerBeanDef直接注册即可。

注册Controller

  1. 调用registerBeanDef注册。
  2. 使用requestMappingHandlerMapping注册Controller对应的URL      mapping

publicvoidregisterController(Class<?> clazz) {

// https://www.cnblogs.com/colin-xun/p/10573504.html
RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping)applicationContext
.getBean(
"requestMappingHandlerMapping");
try {
registerBeanDef(clazz);
Method method = requestMappingHandlerMapping.getClass().getSuperclass().getSuperclass().getDeclaredMethod(
"detectHandlerMethods", Object.class);
method.setAccessible(true);
method.invoke(requestMappingHandlerMapping, clazz.getSimpleName());
} catch (Exception ex) {
throw new CodeGenException("failed to register controller of " + clazz.getName(), ex);
}
}

注册Mybatis Mapper

编译以后得到的Mapper class,不能直接注册为spring bean。(所以不要使用上面的registerBeanDef

  1. 重新扫描mapper      class所在packagemybatis spring会对mapper使用proxy进行重新封装。
  • 小知识

proxy在获取mapper对象进行数据库操作的时候是per SQL session的,所以mybatis mapper并不是singleton bean

privatevoidregisterMapperInSpring(Class<?> clazz) {

AutowireCapableBeanFactory factory =
applicationContext.getAutowireCapableBeanFactory();
 
         
ClassPathMapperScanner scanner = new ClassPathMapperScanner((BeanDefinitionRegistry)factory);
 
         
// this check is needed in Spring 3.1
Optional.ofNullable(resourceLoader).ifPresent(scanner::setResourceLoader);
 
         
scanner.registerFilters();
scanner.doScan(AUTO_CODE_PACKAGE + ".mapper");
 
         
}

实际上上面的代码是从mybatis springautoconfigure中拷贝的。应该有更好的方法直接注册clazz,不过看scanner里面的逻辑比较多,所以我直接重新scan”了。

  1. 注册为mapper

SQLSessionFactory添加mapper

privatevoidaddMapperInDao(Class<?> mapperClass) {

sqlSessionFactory = applicationContext.getBean(SqlSessionFactory.class);
try {
sqlSessionFactory.getConfiguration().addMapper(mapperClass);
} catch (Exception ex) {
throw new CodeGenException("faild to register mapper " + mapperClass.getName(), ex);
}
}

关于Mybatis spring是如何加载mapper的,本文先不涉及。

测试一下

上面的代码在单测和在IDE中直接启动都没有问题。

单测中,我们自动生成的代码会放在target/classes。因为此时user.dir是相对于模块的路径。

image.png

IDE中启动,我们的测试代码会放在项目根目录下面的target/classes目录下。

启动以后,我们尝试创建一个表单,并做一下添加和列表的操作。

image.png

这样,我们没有写一行代码,表单填报的研发工作就完成了。

接下来我们考虑如何部署这个应用(不是在IDE或者单测中启动)。spring boot jar类的加载有一些特别,所以我们将会遇到的第一个难题就是,上面的代码编译是无法找到其他第三方依赖的(springlombokspring bootfastjson...)。

 

目录
相关文章
|
3月前
|
缓存 前端开发 数据可视化
前端基础(七)_表单的基本组成与使用
本文详细介绍了HTML表单的基本组成和使用,包括`<form>`标签、`<input>`表单域、`<select>`下拉列表、`<textarea>`多行文本域等元素。文章解释了表单的提交方式(GET和POST)、表单域的各种类型(文本、密码、单选按钮、复选框等)、提交按钮和重置按钮的作用,以及如何通过`<label>`标签提高表单的可访问性。此外,还讨论了表单元素的属性,如`readonly`、`disabled`、`maxlength`等。
34 1
|
4月前
|
安全 前端开发 PHP
构建与验证表单:传统PHP与Laravel框架的比较分析——探索Web开发中表单处理的优化策略和最佳实践
【8月更文挑战第31天】在 Web 开发中,表单构建与数据验证至关重要。传统 PHP 方法需手动处理 HTML 表单和数据验证,而 Laravel 框架则提供了一种更现代、高效的解决方案。本文通过对比传统 PHP 和 Laravel 的方法,探讨表单构建与验证的最佳实践。Laravel 通过简洁的语法糖、内置的数据过滤和验证机制,显著提升了代码的安全性和可维护性,适用于大型项目或需要快速开发的场景。然而,在追求灵活性的小型项目中,直接使用 PHP 仍是不错的选择。了解两者的优劣,有助于开发者根据项目需求做出最佳决策。
40 0
|
4月前
|
JSON 前端开发 API
Django 后端架构开发:通用表单视图、组件对接、验证机制和组件开发
Django 后端架构开发:通用表单视图、组件对接、验证机制和组件开发
70 2
|
前端开发 API 容器
关于我对表单设计的一点思考—自动化生成表单
关于我对表单设计的一点思考—自动化生成表单
178 0
|
IDE Java fastjson
从0开始开发一个表单引擎(下)
上一篇文档里,我们介绍了如何开发一个表单引擎:通过生成数据库建表语句,Java代码,并部署到spring上下文,实现表单实例CRUD API的创建。 同时我们也演示了如何在IDE中启动应用,成功创建表单信息并使用表单CRUD API来创建表单实例。 本节我们主要讨论解决表单引擎发布部署的实际问题。
370 1
从0开始开发一个表单引擎(下)
|
前端开发
3.26前端作业-表格表单的综合应用
3.26前端作业-表格表单的综合应用
93 0
3.26前端作业-表格表单的综合应用
|
存储 自然语言处理 数据可视化
云巧动态表单的国际化方案解密
介绍云巧动态表单以及解决的问题和价值,解密云巧动态表单的国际化能力和整体方案
407 0
|
JavaScript 前端开发 编译器
第三十九章 构建数据库应用程序 - 将数据绑定到表单
第三十九章 构建数据库应用程序 - 将数据绑定到表单
115 0
|
前端开发
模板驱动表单学习
模板驱动表单学习
113 0
模板驱动表单学习
|
JSON 移动开发 数据格式
强大的移动端表单开发方案 @alitajs/dform
强大的移动端表单开发方案 @alitajs/dform
374 0
强大的移动端表单开发方案 @alitajs/dform