本文来源于公司的一道考核题,简单整理了一下,以供参考。

TL; DR

本文探讨如何实现 动态表单,这里的动态表单是指一类 Notion-like 的表单,即表单的字段可以动态增加,而不是像传统的表单一样,需要在设计时就确定好所有的字段。

本文以支持三种类型属性(单行文本、单选框和复选框)的表单需求为例,给出了三种实现方式:

  1. 文档数据库实现:MongoDB;
  2. 关系型数据库实现:MariaDB;
  3. 图数据库实现:Neo4j。

实例需求

假设我们需要实现一个表单搭建平台,这个表单有三种类型的属性:

  1. 单行文本;
  2. 单选框;
  3. 复选框。

这三种类型的属性可以动态增加,即用户可以在表单设计时,动态增加这三种类型的属性。

为了简化问题,本文不考虑表单验证、安全性、权限等具体的工程化实现细节,只考虑如何实现动态表单的元数据存储。

文档数据库实现

文档数据库如 MongoDB 由于其灵活的数据模型,非常适合实现动态表单。在 MongoDB 中,我们可以使用两个集合(collection)来存储表单的元数据和用户提交的数据。

由于 MongoDB 的灵活性,我们可以根据需要轻松地添加、删除或修改表单字段,而无需更改数据库的结构。

数据模型和 demo 数据

只需要使用两个集合即可:

  • Form 集合:存储表单的元数据,包括表单的名称和其包含的字段(属性)。
  • FormSubmission 集合:存储用户提交的表单数据。

Form 集合

Form 集合中的每个文档代表一个表单,包括表单的名称和其包含的字段。每个字段由其名称和类型组成。

{
    "_id": ObjectId("60d5ec9af682fbd12a892fe1"),
    "name": "Contact Form",
    "fields": [
        { "name": "Name", "type": "TEXT" },
        { "name": "Email", "type": "TEXT" },
        { "name": "Subject", "type": "TEXT" },
        { "name": "Message", "type": "TEXT" }
    ]
},
{
    "_id": ObjectId("60d5ec9af682fbd12a892fe2"),
    "name": "Feedback Form",
    "fields": [
        { "name": "Rating", "type": "RADIO", "options": ["1", "2", "3", "4", "5"] },
        { "name": "Feedback", "type": "TEXT" }
    ]
}

FormSubmission 集合

FormSubmission 集合中的每个文档代表一个表单提交,包括提交的表单 ID 和提交的数据。

{
    "_id": ObjectId("60d5ec9af682fbd12a892fe3"),
    "formId": ObjectId("60d5ec9af682fbd12a892fe1"),
    "data": {
        "Name": "John Doe",
        "Email": "john.doe@example.com",
        "Subject": "Inquiry about product",
        "Message": "I am interested in your product, please provide more details."
    }
},
{
    "_id": ObjectId("60d5ec9af682fbd12a892fe4"),
    "formId": ObjectId("60d5ec9af682fbd12a892fe2"),
    "data": {
        "Rating": "5",
        "Feedback": "Great product, keep up the good work!"
    }
}

查询表单元数据

db.Form.find({ _id: ObjectId("60d5ec9af682fbd12a892fe2") })

结果:

{
  _id: ObjectId("60d5ec9af682fbd12a892fe2"),
  name: 'Feedback Form',
  fields: [
    {
      name: 'Rating',
      type: 'RADIO',
      options: [
        '1',
        '2',
        '3',
        '4',
        '5'
      ]
    },
    {
      name: 'Feedback',
      type: 'TEXT'
    }
  ]
}

查询用户提交数据

db.FormSubmission.find({ _id: ObjectId("60d5ec9af682fbd12a892fe4") })

结果:

{
  _id: ObjectId("60d5ec9af682fbd12a892fe4"),
  formId: ObjectId("60d5ec9af682fbd12a892fe2"),
  data: {
    Rating: '5',
    Feedback: 'Great product, keep up the good work!'
  }
}

关系数据库的实现

关系数据库可以使用 EAV模型(Entity–attribute–value model - Wikipedia) 来实现该需求。简单来说,EAV模型是一种将实体、属性和值分开存储的关系数据库模型,以便在不改变数据库结构的情况下轻松扩展属性。

以下实现以 MariaDB 为例。

DDL 和 demo 数据

包括以下四个表:

  1. Form(实体)表:存储表单的基本信息。
  2. FormAttribute(属性)表:存储表单的属性信息。
  3. AttributeValue(值)表:存储属性值,如单选框的选项。
  4. FormSubmission(提交)表:存储用户提交的表单数据。

DDL & 数据:

-- 创建 Form 表
CREATE TABLE Form (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);

-- 创建 FormAttribute 表
CREATE TABLE FormAttribute (
    id INT AUTO_INCREMENT PRIMARY KEY,
    form_id INT,
    name VARCHAR(255) NOT NULL,
    type ENUM('TEXT', 'RADIO', 'COMBO') NOT NULL,
    FOREIGN KEY (form_id) REFERENCES Form (id)
);

-- 创建 AttributeValue 表
CREATE TABLE AttributeValue (
    id INT AUTO_INCREMENT PRIMARY KEY,
    form_attribute_id INT,
    value VARCHAR(255) NOT NULL,
    FOREIGN KEY (form_attribute_id) REFERENCES FormAttribute (id)
);

-- 创建 FormSubmission 表
CREATE TABLE FormSubmission (
    id INT AUTO_INCREMENT PRIMARY KEY,
    form_id INT,
    form_attribute_id INT,
    value VARCHAR(255),
    FOREIGN KEY (form_id) REFERENCES Form (id),
    FOREIGN KEY (form_attribute_id) REFERENCES FormAttribute (id)
);

-- 插入示例数据
INSERT INTO Form (name) VALUES ('Contact Form'), ('Feedback Form');

-- 插入表单属性
INSERT INTO FormAttribute (form_id, name, type) VALUES
    (1, 'Name', 'TEXT'),
    (1, 'Email', 'TEXT'),
    (1, 'Subject', 'TEXT'),
    (1, 'Message', 'TEXT'),
    (2, 'Rating', 'RADIO'),
    (2, 'Feedback', 'TEXT'),
    (2, 'Gift', 'COMBO');

-- 插入属性值
INSERT INTO AttributeValue (form_attribute_id, value) VALUES
    (5, '1'),
    (5, '2'),
    (5, '3'),
    (5, '4'),
    (5, '5'),
    (7, 'coffee'),
    (7, 'tea'),
    (7, 'juice');

-- 插入用户提交的表单数据
INSERT INTO FormSubmission (form_id, form_attribute_id, value, task_id) VALUES
    (1, 1, 'John Doe', 1),
    (1, 2, 'john.doe@example.com', 1),
    (1, 3, 'Inquiry about product', 1),
    (1, 4, 'I am interested in your product, please provide more details.', 1),
    (2, 5, '5',2),
    (2, 6, 'Great product, keep up the good work!',2),
    (2, 7, 'coffee',2),
    (2, 7, 'juice',2);

查询表单元数据

-- 查询表单元数据
SELECT
    f.name AS form_name,
    fa.name AS attribute_name,
    fa.type AS attribute_type,
    av.value AS attribute_value
FROM
    Form f
JOIN
    FormAttribute fa ON f.id = fa.form_id
JOIN
    AttributeValue av ON fa.id = av.form_attribute_id
WHERE
    f.id = 2;

结果:

查询用户提交数据

-- 查询用户提交数据
SELECT
    f.name AS form_name,
    fa.name AS attribute_name,
    fa.type AS attribute_type,
    fs.value AS attribute_value
FROM
    Form f
JOIN
    FormAttribute fa ON f.id = fa.form_id
JOIN
    FormSubmission fs ON fa.id = fs.form_attribute_id
WHERE
    fs.task_id = 2;

结果:

图数据库的实现

图数据库的方案类似于关系数据库方案,以图三元组存储动态的 实体-属性-值 关系。图数据库直接操作图数据,天生适合此类动态数据的需求。

以下实现使用 Neo4JCypher

节点实体划分和 demo 数据

实体

  1. Form 实体:表示表单。
  2. FormAttribute 实体:表示表单的属性信息。
  3. AttributeValue 实体:表示属性值,如单选的选项。
  4. FormSubmission 实体:表示用户提交的表单数据。

关系

  • HAS_ATTRIBUTEForm -> FormAttribute
  • HAS_OPTION:从 FormAttribute -> AttributeValue
  • SUBMITTED:从 Form -> FormSubmission

demo 数据

// 创建表单节点
CREATE (contactForm:Form {name: 'Contact Form'})
CREATE (feedbackForm:Form {name: 'Feedback Form'})

// 创建表单属性节点
CREATE (nameAttr:FormAttribute {name: 'Name', type: 'TEXT'})
CREATE (emailAttr:FormAttribute {name: 'Email', type: 'TEXT'})
CREATE (subjectAttr:FormAttribute {name: 'Subject', type: 'TEXT'})
CREATE (messageAttr:FormAttribute {name: 'Message', type: 'TEXT'})
CREATE (ratingAttr:FormAttribute {name: 'Rating', type: 'RADIO'})
CREATE (feedbackAttr:FormAttribute {name: 'Feedback', type: 'TEXT'})

// 创建属性值节点
CREATE (rating1:AttributeValue {value: '1'})
CREATE (rating2:AttributeValue {value: '2'})
CREATE (rating3:AttributeValue {value: '3'})
CREATE (rating4:AttributeValue {value: '4'})
CREATE (rating5:AttributeValue {value: '5'})

// 创建表单与属性关系
CREATE (contactForm)-[:HAS_ATTRIBUTE]->(nameAttr)
CREATE (contactForm)-[:HAS_ATTRIBUTE]->(emailAttr)
CREATE (contactForm)-[:HAS_ATTRIBUTE]->(subjectAttr)
CREATE (contactForm)-[:HAS_ATTRIBUTE]->(messageAttr)
CREATE (feedbackForm)-[:HAS_ATTRIBUTE]->(ratingAttr)
CREATE (feedbackForm)-[:HAS_ATTRIBUTE]->(feedbackAttr)

// 创建属性与属性值关系
CREATE (ratingAttr)-[:HAS_OPTION]->(rating1)
CREATE (ratingAttr)-[:HAS_OPTION]->(rating2)
CREATE (ratingAttr)-[:HAS_OPTION]->(rating3)
CREATE (ratingAttr)-[:HAS_OPTION]->(rating4)
CREATE (ratingAttr)-[:HAS_OPTION]->(rating5)

// 创建用户提交的表单数据节点
CREATE (submission1:FormSubmission {name: 'John Doe', email: 'john.doe@example.com', subject: 'Inquiry about product', message: 'I am interested in your product, please provide more details.'})
CREATE (submission2:FormSubmission {rating: '5', feedback: 'Great product, keep up the good work!'})

// 创建表单与提交关系
CREATE (contactForm)-[:SUBMITTED]->(submission1)
CREATE (feedbackForm)-[:SUBMITTED]->(submission2)

图可视化

查询表单元数据

MATCH (f:Form)-[:HAS_ATTRIBUTE]->(fa:FormAttribute)
WHERE f.id = 2
OPTIONAL MATCH (fa)-[:HAS_OPTION]->(av:AttributeValue)
RETURN f.name AS form_name, fa.name AS attribute_name, fa.type AS attribute_type, av.value AS attribute_value

查询用户提交数据

MATCH (f:Form)-[submitted:SUBMITTED]->(fs:FormSubmission)
WHERE fs.id = 2
MATCH (fa:FormAttribute {id: submitted.form_attribute_id})
RETURN f.name AS form_name, fa.name AS attribute_name, fa.type AS attribute_type, submitted.value AS attribute_value

查询优化

这些实现的优化方案类似,主要包括:

  1. 索引;
  2. 缓存;
  3. 复制;
  4. 分片;
  5. 优化查询语句;
  6. 列存储 … …

查询优化不是本文的重点,这里不再展开。这里给出 Neo4j 建立索引的示例:

CREATE INDEX form_id_index IF NOT EXISTS FOR (f:Form) ON (f.id);
CREATE INDEX form_attribute_id_index IF NOT EXISTS FOR (fa:FormAttribute) ON (fa.id);
CREATE INDEX attribute_value_id_index IF NOT EXISTS FOR (av:AttributeValue) ON (av.id);
CREATE INDEX form_submission_id_index IF NOT EXISTS FOR (fs:FormSubmission) ON (fs.id);

通过 PROFILE 可以查看查询语句的执行计划和性能。经过比较,添加索引后 estimatedRowsdbHits 等指标有明显的改善。