1. 自定义表单系统核心设计思路
第一次接触自定义表单需求时,我盯着产品经理的原型图发了半小时呆——要做一个连字段类型都能动态配置的表单系统?这跟传统CRUD开发完全是两个维度的问题。经过三个版本的迭代,我总结出动态表单系统的三大设计原则:
纵向存储横向展示是核心矛盾点。传统表单的数据库表结构是固定的,比如用户表有username、password等固定列。而动态表单需要像Excel那样,允许用户自定义列名和数据类型。这里就涉及到数据结构设计的根本性转变——必须采用属性-值对(EAV)模型,将横向的字段转为纵向存储。
举个具体场景:某企业需要收集不同部门的调研数据。人力资源部需要"员工满意度"评分(数字类型),而财务部需要"预算金额"(货币类型)。系统要允许各部门管理员自由添加这些字段,并在展示时自动还原成表格形式。
字段绑定机制是保证数据完整性的关键。在纵向存储模式下,如何确定"张三"填写的"年龄"值是对应哪个字段定义?我们通过在字段表和数据表都添加sorted字段来解决。字段表中sorted=1表示"员工姓名",那么数据表中sorted=1的content值就是具体的姓名数据。这种设计类似数据库的外键关联,但更灵活。
动态查询分页是最复杂的实现难点。当数据变成纵向存储后,传统的SELECT * FROM table WHERE条件+分页的查询方式完全失效。我们的解决方案是分两步查询:先通过条件筛选出符合要求的unique_mark(每条记录的全局唯一标识),再根据这些标识获取完整数据。这相当于在应用层实现了数据库的索引功能。
2. 数据库设计实战
2.1 字段定义表结构
字段表(t_inspector_db_fields)是整个系统的元数据中心,它的每个字段都有特殊含义:
CREATE TABLE "t_inspector_db_fields" (
"id" INT8 NOT NULL,
"fields_name" VARCHAR(32) NULL, -- 前端显示的字段名称
"data_type" INT4 NULL, -- 1:文本 2:数字 3:日期
"fields_entry_type" INT4 NULL, -- 输入方式:0单行文本 1多行文本 2下拉框
"datasource_type" INT4 NULL, -- 数据来源:1字典 2API接口
"max_length" INT4 NULL, -- 文本最大长度(-1表示不限制)
"show_table" TINYINT NULL DEFAULT 0, -- 是否在列表展示
"sorted" INT2 NULL, -- 排序+绑定标识
"table_id" INT8 NULL -- 所属表单ID
);
几个关键字段的实战经验:
data_type只是前端校验用,实际存储都转为字符串。这样设计是为了避免频繁修改表结构datasource_type=2时配合data_source字段存储API地址,实现动态下拉选项sorted字段要确保在同一个table_id下唯一,我们使用Redis计数器来保证
2.2 数据存储表设计
数据表(t_inspector_fields_data)采用完全纵向存储:
CREATE TABLE "t_inspector_fields_data" (
"id" VARCHAR(32) NOT NULL,
"fields_id" VARCHAR(32) NOT NULL, -- 关联字段ID
"unique_mark" VARCHAR(32) NOT NULL, -- 单条记录的全局标识
"sorted" INT4 NOT NULL, -- 与字段表的sorted对应
"content" TEXT NULL, -- 实际存储的值
"year" INT4 NULL -- 业务维度字段
);
遇到过的一个坑:最初content字段用的VARCHAR(255),结果用户上传Base64图片时报错。改为TEXT类型后要注意:
- MySQL的TEXT字段不能有默认值
- 模糊查询效率低,需要配合
unique_mark优化 - 使用
SUBSTRING(content,1,100)在列表页预览
2.3 查询性能优化方案
纵向存储最致命的问题是查询效率。我们通过以下优化使百万级数据查询保持在200ms内:
- 冗余业务字段:像year、month这类高频查询条件,虽然破坏了范式,但大幅提升查询速度
- 组合索引:对(table_id, sorted, unique_mark)建立联合索引
- 分步查询:先查小结果集的unique_mark,再用IN查询获取数据
- 缓存机制:用Redis缓存字段定义,减少数据库元数据查询
3. 前后端协作实践
3.1 动态表单渲染协议
前端采用VueJS实现动态渲染,后端返回的数据结构如下:
{
"tableHead": [
{"key":"nickName", "fieldsName":"昵称", "dataType":1},
{"key":"age", "fieldsName":"年龄", "dataType":2}
],
"tableVal": [
{"unique_mark":"a1b2", "nickName":"张三", "age":"25"},
{"unique_mark":"c3d4", "nickName":"李四", "age":"30"}
]
}
开发中遇到的典型问题:
- 字段顺序错乱:前端要根据sorted字段排序展示
- 空值处理:纵向存储不存在的字段不会返回null,需要前端补全
- 类型转换:后端返回的都是字符串,前端要根据dataType转数字/日期
3.2 MyBatis动态SQL技巧
组合查询是最大的挑战,我们利用MyBatis的这些特性:
<select id="search" resultType="map">
SELECT * FROM t_inspector_fields_data
WHERE deleted = 0
<if test="params != null">
<foreach collection="params" item="item" open=" AND (" separator=" OR " close=")">
(sorted = #{item.sorted} AND content LIKE CONCAT('%',#{item.content},'%'))
</foreach>
</if>
ORDER BY id DESC
LIMIT #{page.offset}, #{page.size}
</select>
特别注意:
- 用
${}直接拼接字段名时要防止SQL注入 - UNION ALL查询的性能比OR条件更好
- 分页先在内存中处理unique_mark,再二次查询
3.3 纵向转横向的Java实现
数据库查出的纵向数据需要转为前端需要的横向结构:
public List<Map<String, Object>> convertToHorizontal(List<FieldData> verticalData) {
Map<String, Map<String, Object>> resultMap = new LinkedHashMap<>();
verticalData.forEach(data -> {
Map<String, Object> row = resultMap.computeIfAbsent(
data.getUniqueMark(),
k -> new HashMap<>()
);
row.put(data.getFieldKey(), data.getContent());
});
return new ArrayList<>(resultMap.values());
}
这个转换过程要注意:
- 使用LinkedHashMap保持字段顺序
- 大数据量时要分批次处理避免OOM
- 可以加入字段类型转换逻辑
4. 典型业务场景解决方案
4.1 动态校验规则实现
根据不同字段类型应用不同校验:
// 前端校验规则
const validateRules = {
text: (value, field) => value.length <= field.max_length,
number: (value, field) => !isNaN(value),
date: (value) => !isNaN(Date.parse(value))
};
// 在提交时调用
const validateField = (field, value) => {
const rule = validateRules[field.dataType];
return rule ? rule(value, field) : true;
};
4.2 字段级权限控制
通过扩展字段表实现:
ALTER TABLE t_inspector_db_fields
ADD COLUMN editable_roles VARCHAR(255) COMMENT '可编辑角色ID,逗号分隔';
后端在保存时校验:
public void checkFieldPermission(Field field, User user) {
if (StringUtils.isNotEmpty(field.getEditableRoles())) {
List<String> roles = Arrays.asList(field.getEditableRoles().split(","));
if (!CollectionUtils.containsAny(user.getRoles(), roles)) {
throw new PermissionDeniedException();
}
}
}
4.3 数据版本管理方案
为支持表单修改后历史数据仍可读,我们设计了两套方案:
方案A:快照存储
CREATE TABLE t_form_data_history (
id BIGINT PRIMARY KEY,
unique_mark VARCHAR(32),
form_data JSON,
created_at DATETIME
);
方案B:增量迁移
- 修改字段定义时触发数据迁移任务
- 将旧格式数据转为新格式
- 通过消息队列实现异步处理
实际项目中我们选择了方案B,因为:
- 存储空间更优
- 查询时无需合并处理
- 可以后台静默执行
经过三个版本的迭代,这套自定义表单系统已经支持日均10万+的数据提交量。最大的收获是认识到:灵活性和性能往往需要权衡,好的架构设计就是在两者之间找到最佳平衡点。
1677



被折叠的 条评论
为什么被折叠?



