从0开始开发一个表单引擎(上)
起因
在我们能够在IDE中自动生成CRUD代码以后,难免会思考是不是能够向前走一步,把生成的CRUD代码自动部署到应用中。
正好某项目有类似的PoC的需求,要点是通过自定义表单的字段,生成填报,浏览,编辑,删除表单的页面,也即我们通常理解的“表单引擎”。
渲染页面是前端的工作。作为后端,其实就是提供CRUD的API。
我们不妨从后端的角度梳理一下,当我们通过一些模板文件生成Java代码以后,这些代码应该怎么部署到应用中呢?
假设
- 我们的后端服务都是spring boot的web应用。
- 一个表单以一张数据库表来表示。
- 我们已经有一套模板,可以按照数据库表字段生成从entity,mapper,service到controller的代码。
需求定义
- 我们需要一个后端服务,提供下面几个功能。
- 用户自定义表单(数据库表)字段,例如在前端表现上,可能是一个表单编辑器:
- 保存表单以后,后端服务将代码部署到spring boot的web容器中,立即提供针对该表单的CRUD操作,其实就是4个REST API。他们可能是这样的URL:
- /form/表名/create
- /form/表名/query
- /form/表名/edit
- /form/表名/delete
任务拆分
首先总结一下上面的“业务步骤”:
对照上面的流程,我们还差哪些呢?
- 定义一个API,用于提供给用户来指定这个表单(数据库表)有哪一些字段,他们是什么类型,是否必填等。
- 根据#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的使用了。
- 创建表。嗯,jdbcTemplate,mybatis,任何一个可以动态生成sql语句的方法都可以创建数据库表。
- 当然有些同学肯定会想到不应该把业务表放在同一个数据源,这些同学很有技术意识。但是我们只是PoC,管他的呢(雾)。
- 自动生成CRUD代码,这部分已经有现成逻辑了,我们拷过来就好了。
- 部署到spring。这个好像有点难。我想想,大概分成这么几步:
- 编译代码。这意味着我们需要能读入Java代码文件,然后调用javac编译器,编译成.class文件。
- 加载class到spring。比如controller,service,mapper,我猜他们肯定要成为一个spring bean,能被自动装配,才能对外提供服务吧?
大概是上面这样,应该没有漏掉。
OK,Let's get our hands dirty。
定义API
这一节我们主要考虑,我们要表达一个表单,应该包含哪些信息。
- 表名。这张表我们叫它什么名字,以后怎么访问它。
- 字段列表。我们需要定义一个列表,每个元素代表一个字段,包含:
- 字段名,例如“姓名”。
- 字段的标识,例如“name”,这样我们知道数据库表里面如何存放它。
- 字段的类型,很重要。例如是字符,数字,还是一个日期。作为PoC,我们先支持数字,字符串,日期,布尔好了,不用支持太多。
- 一些校验字段,例如是否必填,长度等。作为PoC,这些我们也不用做得太复杂,首先考虑把他们存下来。
所以一个创建表单的API可以是这样:
method:POST
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对象就好了。
我们写一个单测看一下效果。
生成的是一段合法的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
为了简化,Service,Mapper和Entity,我们都使用mybatis-plus的方式来生成。用mybatis-plus,我们定义的mapper,service都不用手写代码了,仅需要继承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>
}
有两点注意:
- 我们主动生成了getter和setter方法。原因是,虽然使用了@Data注解,但是测试中发现,在单元测试中lombok没有起作用。
- id生成的方式是INPUT。原因是我们希望手动生成id。考虑这种情况:如果要支持一对多的结构(例如任务表对应多个子任务表),手动产生id会比较方便。(为什么?)
Mapper
public interface ${entityName}Mapper extends BaseMapper<${entityName}> {
}
注意到Mapper使用mybatis-plus风格很简单。
Service和ServiceImpl
Service接口的定义:
public interface ${entityName}Service extends IService<${entityName}> {
}
ServiceImpl的实现:
@Service
public class ${entityName}ServiceImpl extends ServiceImpl<${entityName}Mapper, ${entityName}> implements ${entityName}Service {
}
注意只要继承了mybatis-plus的service类,无需手动注入mapper了。
Controller
主要是注入service,调用并生成CRUD的API。
@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中。
我们一般能想到两种方式:
- 直接在代码调用命令行(javac xxx.java)来编译
- 实际上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需要引用两种类型的类:
- SpringBoot的相关依赖,例如我们注解了类为RestController。
- 之前生成的Entity,Service和Mapper的类。
不正确设置classpath的话我们的编译是无法成功的。
设置编译的classpath
对于上面提到的第二种类引用,我们只需要保证把所有生成的.class文件(上面的编译成功后,可以获取到字节码的byte[]表示)输出到一个指定的目录,并把该目录作为classpath即可。
对于Spring的相关依赖,这个有一些复杂,需要分情况讨论:
- 如果应用是通过mvn test或者IDE中启动的,Spring的相关依赖已经自动加入到应用classpath中,无需额外指定了。
- 如果应用是通过spring boot fat jar(java -jar 你的spring-boot应用.jar)形式启动的,先给结论:如果不做特殊处理,无论如何怎么折腾,你也加载不到Spring boot相关的依赖。后面的章节会详细讨论原因。
我们这里先就着第一种情况,设置一个class文件生成路径,这里有一个讨巧的做法,使用路径:
System.get
Property("user.dir")/
"target"
/
"classes"
这样无论在单测中,IDE中启动应用,还是在IDE中启动单测,你都不需要区别对待。
所以这里的建议是:默认情况下,使用该路径作为编译代码的.class文件的输出路径,和编译代码的classpath。
编译成功以后,我们会得到两样东西:
- 我们能拿到一个Class<?>实例。
- 编译会产生一个.class文件。
注册到spring中
Entity不用注册,编译完成以后,加载class就行了。Mapper,ServiceImpl和Controller除了编译本身,还需要注册到spring中。
这里其实需要两个步骤:
- 编译后得到的Class<?>实例,需要实例化成一个Java类实例,并且注册到spring上下文中,成为一个spring bean。
- 特殊的类型的,例如Mapper,Controller,实际上要起作用,不仅仅只是存在spring上下文,还需要额外的注册工作。
注册为spring bean
spring中BeanFactoryPostProcessor提供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
- 调用registerBeanDef注册。
- 使用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)
- 重新扫描mapper class所在package。mybatis 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 spring的autoconfigure中拷贝的。应该有更好的方法直接注册clazz,不过看scanner里面的逻辑比较多,所以我直接“重新scan”了。
- 注册为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是相对于模块的路径。
在IDE中启动,我们的测试代码会放在项目根目录下面的target/classes目录下。
启动以后,我们尝试创建一个表单,并做一下添加和列表的操作。
这样,我们没有写一行代码,表单填报的研发工作就完成了。
接下来我们考虑如何部署这个应用(不是在IDE或者单测中启动)。spring boot jar类的加载有一些特别,所以我们将会遇到的第一个难题就是,上面的代码编译是无法找到其他第三方依赖的(spring,lombok,spring boot,fastjson...)。