营销系统里经常会有一些一次性的需求,或者配置类的需求,用来配合 C 端页面和 B 端业务。有的只需要维护几项配置,有的需要一个列表和编辑页面,最终操作的就是一张表。

数据由运营维护,管理功能则需要研发来做。功能虽然不复杂,但从 Mapper、Service、Controller 到列表、查询和编辑页面,一套下来还是有不少重复工作。换一张表,很多代码又要写一遍。

最早我做的是根据模板生成代码,后来改成 JSON 配置,再到现在读取数据库元数据、配置表单,通过动态数据源操作不同的表。这里把前后的做法和这一版的实现整理一下。

从生成代码开始

这类功能的结构比较固定,最直接的办法就是把重复代码做成模板。

输入表和字段信息,生成对应的实体、Mapper、Service、Controller,以及列表和编辑页面。查询、保存、删除的基本代码生成后,再根据具体需求调整。

这一版提高了一些效率,不用每次从头写。不过,新增一个管理功能仍然需要研发生成代码、检查修改,再发布到对应工程。生成出来的代码也是一份份独立的代码,公共逻辑有调整,已经生成的功能并不会自动跟着变。

继续往下想,很多区别其实只是字段名称、显示标题、查询条件和页面控件不同。这些信息能不能单独配置,程序只保留一套?

用 JSON 描述页面

接下来把表和字段的元信息放进 JSON。通用页面读取配置,决定列表展示什么、查询区域放什么条件、编辑时使用什么控件。

配置里有表名和字段列表,字段上记录标题、类型、必填、出现位置、枚举或组件等信息。页面拿到配置后就可以按这些信息渲染,不用再为每个表写一份列表和编辑页。

查询和保存也通过公共入口处理,结合配置和请求参数,调用对应的接口。在这套实现里,页面已经共用,后端还可以接原先按表生成的接口:根据配置找到对应的数据操作接口,再完成查询、保存和删除。

但 JSON 需要手工维护。表里加一个字段,配置里还要补一遍;字段标题、位置和组件发生变化,也需要修改 JSON。少写一个字段、写错一个名字,都需要再去检查配置。

程序重复的问题减少了,配置本身的维护又成了一项工作。

读取数据库结构,再配置表单

数据库本来就有表和字段的信息:字段名、类型、长度、是否可空、默认值、主键和注释。这部分没有必要再手工录入一遍。

这一版先读取数据库元数据,再把它转换成表单配置。研发选择数据源和表,系统读出字段,研发调整标题、出现位置和控件,保存后把管理入口交给产品、运营使用。

整个过程可以分成两部分:

研发配置
  选择数据源和表
        ↓
  读取数据库元数据
        ↓
  调整字段标题、必填、出现位置、组件
        ↓
  保存表单和字段配置

运营使用
  打开表单入口
        ↓
  读取已保存的配置,生成列表和编辑页面
        ↓
  提交查询条件或编辑数据
        ↓
  校验参数,选择数据源,执行增删改查

研发仍然负责接入和配置,运营负责使用。接入一张符合现有规则的表,不用再为它单独写一套页面和增删改查代码。如果需要一种新的控件或特殊业务处理,仍然需要开发。

整体结构

系统里保存两类数据:一类是管理工具自己的配置,一类是运营维护的业务数据。配置记录页面怎么用、数据存在哪里,业务数据仍然留在原来的库和表中。

研发配置表单
    │ 读取数据库结构,调整并保存
    ↓
管理系统配置库
    ├── 数据源配置:连接哪个库
    ├── 表单配置:管理哪张表、这个入口做什么
    └── 字段配置:标题、出现位置、必填、组件
              │
       ┌──────┴──────┐
       ↓             ↓
   通用页面模板    通用数据操作
   列表/查询/编辑  校验/组织SQL
       │             │
       └─提交请求───→ │
                     ↓
               Spring动态数据源
                 ├── 业务库A
                 └── 业务库B

运营打开一个入口时,程序先读取它的表单和字段配置,生成列表、查询区域和编辑页面。提交数据后,后端再根据同一份配置确定表和字段,通过动态数据源找到业务库。

这样,页面展示和数据操作都有了共同的依据。比如研发把某个字段配置成查询条件,页面就据此提供输入位置,后端收到条件后按字段名处理;它们不用分别维护一份字段清单。

为什么还要保存元数据

这里讨论过一个问题:既然能从数据库读取元数据,为什么还要保存一份?使用时直接读取不就可以了?

数据库结构能说明字段是什么,却不能完整说明这个字段在管理页面上怎么用。

比如一个数字字段,在页面里可能是普通输入框,也可能是城市选择框或状态下拉框。字段注释可以作为默认标题,但运营习惯的名称不一定和数据库注释完全相同。同一个字段在一个页面上需要编辑,在另一个页面上可能只需要展示。

所以这里分成两层信息:

信息来源和用途
字段名、数据库类型、长度、可空性等从数据库读取,作为配置的基础
显示标题、是否必填、出现位置、组件等由研发调整并保存,决定表单怎么使用

如果只在打开页面时读取数据库结构,能得到字段名和类型,却得不到这些已经调整过的页面设置。还得在别处补一份配置,最后还是要把它们保存下来。

我选择把读取到的字段信息和调整后的设置一起保存,后续按表单维护。数据库结构提供初始信息,保存下来的配置决定这个入口怎么用。同一字段的标题、组件和出现位置,都有明确的保存位置。

效率也是当时考虑的一点,希望保存整理好的元数据后,减少重复读取和转换表结构。不过,这一版还没有完全做到:列表数据查询时,仍会读取数据库结构,与保存的字段配置比较并补齐新字段。保存配置首先解决了页面设置的维护问题,运行时对数据库元数据的访问还可以继续减少。

一个实际表,可以有多个单表配置

另一个讨论是:请求参数直接传表名是不是就够了,一张表对应一个单表。

我这里把表单单独做了一层。一张实际表可以建立多份配置,每份有自己的表单标识、用途和字段设置。运营打开的入口对应其中一份配置,程序再通过配置找到实际表。

这样做主要是为了业务使用方便。数据虽然放在同一张表里,不同分类、不同业务线关心的字段和查询方式可能不同。如果只允许一张表保存一份配置,几个使用场景就得共用一套标题、列表和查询条件。

举个简单的配置例子。一张营销配置表里有名称、分类、业务线、展示地址和状态,可以建成两个入口:

配置列表展示查询条件
活动配置维护名称、分类、展示地址、状态分类、状态
业务线配置查询名称、业务线、状态业务线、状态

两份配置指向同一张表,但各自决定使用哪些字段、怎么显示。运营从相应入口操作,看到的就是这份配置对应的页面。

这里需要区分三个东西:实际表存放数据,表单配置组织管理入口,页面按配置渲染。增加一份配置,就是增加一个使用入口,数据仍在原来的表里。

如果请求只带表名,后端只能知道操作哪张表,无法知道本次使用哪套标题、字段和查询设置。因此这里传表单标识,先找到配置,再取出数据源和表名。

一表多配置的关系就保存在配置数据中,通用程序按标识读取即可,不需要针对每个入口增加一套处理分支。字段设置跟随各自的表单保存,一份配置修改标题,也不会改掉另一份配置的标题。

按分类、业务线组织入口,并不自动限制数据范围;具体能查到、修改哪些记录,还取决于查询条件和权限处理,不能只靠入口不同来区分。

配置数据怎么组织

配置主要分为数据源、表单、字段三部分,关系如下:

数据源配置
    ├── 表单配置 A ──→ 实际表 T
    │     └── A 的字段配置
    │
    └── 表单配置 B ──→ 实际表 T
          └── B 的字段配置

上图中的 A、B 是两份配置,关联同一个数据源和实际表。配置保存时,各有一条表单记录,下面分别关联各自的字段记录。

数据源配置描述连接哪个数据库;表单配置描述这个入口管理哪张表;字段配置描述这个入口如何使用表里的字段。

表单标识把这几部分关联起来。读取某份表单时,既能取到它的数据源和实际表,也能取到属于它的字段配置。物理表名因此不需要成为表单配置的唯一标识。

字段配置主要考虑三个方面:

配置内容解决的问题
名称和类型对应哪个字段,页面显示什么标题,按什么类型处理
出现位置和填写要求是否出现在列表、查询区域或编辑页,是否需要填写
页面组件使用输入框还是选择框,是否需要同时回填相关信息

出现位置可以多选。例如名称在列表和编辑页都出现,分类既能编辑也能查询,创建时间只展示。它们都是同一份字段配置的不同使用位置。

保存时,表单和关联字段作为一组配置处理,放在同一个事务中,避免表单已经更新、字段却没有保存完整。这样调整一份表单时,它关联的配置能够保持一致。

这些配置保存在管理系统自己的库中,业务数据仍在原来的库和表里。修改表单标题和控件,不需要改变业务表结构;运营维护业务数据,也不会改变表单配置。

数据库元数据怎么读取

这部分使用 JDBC 提供的元数据接口。建立连接后,先找到指定的表,再读取字段、主键和注释等结构信息,不需要为每一张表单独写一套读取逻辑。

读取到的信息还需要转换成表单的初始配置。有字段注释就先用注释做标题,没有就使用字段名;根据字段类型确定基本的数据类型,根据是否可空给出初始的填写要求。

这里生成的是初始值,研发仍然可以调整。比如数据库里允许为空的字段,在某个运营场景中可能需要填写;一个整数也可能表示分类,需要使用选择框,而不是普通输入框。

具体读取使用 JDBC 的 DatabaseMetaData:getTables() 查表,getColumns() 读取字段,getPrimaryKeys() 获取主键信息。数据库类型再映射成程序能够识别的数据类型,供后面的表单和校验使用。字段注释先从 JDBC 返回的信息中读取,SQL Server、Oracle 的注释信息还做了针对数据库类型的补充查询。

表结构变化后,已有配置怎么处理?这里按字段名比较数据库字段与表单字段,先补充没有配置过的新字段,保留已有字段的页面设置。

假设一份表单已经配置了名称和状态,数据库后来增加了开始时间:

字段数据库中是否存在已有表单是否配置补充后的处理
名称是是保留原来的标题和页面位置
状态是是保留选择组件等配置
开始时间是否按数据库信息生成初始配置,标记为新增字段

这个过程合并的是字段信息,不是把整份配置重新生成后覆盖。否则研发调整过的内容,每次刷新结构都要再配置一次。

新增字段的补充也不能覆盖所有结构变化。已有字段改名、改变类型或删除后,原配置是否仍然适用,还需要检查。这版没有把它做成一套自动跟随所有数据库变更的机制。

Spring 动态数据源怎么接进来

表单能够关联不同的数据源,执行数据操作时就需要选择对应的连接池。这部分使用 Spring 的 AbstractRoutingDataSource,MyBatis 连接到路由数据源,再由它选择实际的数据源。

表单标识
    ↓ 读取配置
确定目标表和数据源
    ↓ 设置本次操作的数据源标记
通用数据访问层
    ↓
Spring 路由数据源
    ├── 连接池 A → 数据库 A
    └── 连接池 B → 数据库 B

这里有两个不同的问题:一是管理有哪些数据源,二是决定本次请求使用哪个数据源。

数据源加载时,根据配置创建连接池,并建立“数据源标记到连接池”的映射。后续操作复用这些连接池,而不是每次请求都临时创建一个数据源。增加数据源后,再更新路由使用的映射。

在 Spring 中,这个映射交给 setTargetDataSources(),key 是用于路由的标记,value 是对应的 DataSource。更新目标映射后,再通过初始化过程重新解析,路由才能按新的映射查找连接池。连接池的注册和请求中的数据源选择是两步:前者准备可用目标,后者从中选一个。

执行数据操作时,先根据表单配置找到目标数据源,将它的标记放到当前线程的上下文中。路由数据源获取连接时读取这个标记,选择对应的连接池。

这里使用 ThreadLocal 保存标记,使不同请求线程能够各自选择数据源。它只保存当前线程的选择,业务数据和连接池本身并不放在里面。操作结束后需要清理标记,因为服务器的工作线程会被后续请求复用。

路由类通过 determineCurrentLookupKey() 返回当前标记。随后,Spring 在已经注册的目标中找到对应数据源,再向它获取连接。用两个同时处理的请求来看:

线程 A:表单甲 → 数据源标记 A → 连接池 A
线程 B:表单乙 → 数据源标记 B → 连接池 B

这两个线程可以调用同一个通用 Mapper,但取到的连接来自各自选择的连接池。路由标记不是某个全局变量的“当前数据库”,也不会把整个应用统一切换到一个库。

选择数据源的时机也很重要:它发生在获取业务数据库连接之前。路由解决的是“这次取哪个连接”,不会把已经取得的连接自动换到另一个数据库。如果操作中涉及事务,也要考虑连接已经绑定的情况。例如事务已经从数据源 A 取得连接,之后仅修改线程里的标记,并不能让这个事务后续的操作自动转到数据源 B。

前面提到的表单配置事务,负责让表单和字段配置一起保存;它与运营提交后执行的业务数据操作是不同的过程。动态数据源本身也不提供跨库事务能力。

MyBatis 的会话工厂使用路由数据源,通用的数据操作就可以复用同一套 Mapper。业务层先选择数据源,数据访问层再按配置中的表名执行操作,不需要为每个数据库复制一套业务代码。

配置数据和业务数据的访问顺序也因此明确了:先从管理系统读取表单配置,确定目标,再进入业务库操作。表单配置负责选表,动态数据源负责选库,两者共同确定一次请求的目标。

动态数据源并不负责转换 SQL 方言。如果需要接入另一种数据库,分页和部分数据操作仍然要做适配,不能只更换连接配置。

通用页面怎么生成

页面使用 FreeMarker 和 Vue、Element UI。后端先按字段配置的出现位置,将字段拆成列表字段、查询字段、编辑字段,再传给对应模板。

FreeMarker 根据字段配置生成页面结构,Vue 负责数据绑定和提交。不是每次新增表都生成一个新的页面文件,而是使用相同的模板渲染不同配置。

模板根据枚举和组件配置选择控件:有枚举配置时显示枚举下拉框,城市、品牌、图片等字段使用对应组件,其余使用普通输入框。

比如图片字段可以使用上传组件,上传后回填地址;选择城市时,可以同时回填城市编号和名称。它们都不适合只根据数据库的字符串、整数类型来判断,所以保留了单独的组件配置。

编辑已有数据时,后端根据记录 ID 查询数据,再按字段名将值填回配置对象。页面读取这些值作为初始值。新建则使用同一套表单结构。

必填字段也从配置中提取,提交前在页面检查,方便使用者及时发现漏填的内容。页面提示和服务端的数据检查分别承担各自的作用。

一次保存请求怎么走

运营点击保存后,页面提交表单标识、记录标识和本次填写的字段值。服务端使用 Map 接收字段和值,查询结果也以 Map 返回。目标表和字段由元数据描述,通用数据访问层据此执行操作,业务记录不经过逐表实体类映射。

提交表单数据
    ↓
读取这份表单的配置
    ↓
检查字段和数据格式
    ↓
确定目标数据源和表
    ↓
执行新增或修改
    ↓
返回操作结果

表单标识用于找到管理配置,记录标识用于确定编辑的那条数据。它们分别对应表单和业务记录,不能混在一起。

服务端先检查提交的字段名是否存在于这份表单的字段配置中,再检查日期、整数、数字等格式。格式不对时,返回字段的显示名称,让使用者知道是哪一项需要调整。日期判断的过程在判断字符串是否为指定日期格式中单独记录。

校验通过后,再找到实际表和数据源,执行新增或修改。页面提交的是业务字段值,数据库连接和目标表由服务端根据配置确定。

查询也是类似的过程,只是输入变成查询条件,结果变成列表和分页信息。返回的数据按字段名与页面配置对应,日期、枚举等内容再转换成适合展示的形式。

通用数据访问的范围

通用数据访问需要区分两类内容:表名、字段名决定 SQL 的结构,运营填写的值则是查询和保存的数据。

目标表从表单配置中取得。查询或保存请求中的字段名,先检查是否已经配置,通过后再参与 SQL 组装;对应的字段值使用参数绑定。

这里用 MyBatis 的动态 SQL 处理表名和列名,普通字段值通过 #{...} 传入。字段名参与组装,字段值作为参数,两者的处理方式不同。前面的字段检查,就是为了让请求使用的字段与这份表单的配置对应起来。

例如查询“分类为某个值、状态为启用”的记录,处理过程如下:

表单配置 → 确定目标表
提交的分类、状态字段 → 核对是否在配置中
字段名 → 组成查询条件
分类值、状态值 → 作为绑定参数

当前查询主要使用等值条件,多项条件用 AND 连接。分页参数单独处理,不作为业务字段参与条件匹配。记录数查询和当前页查询使用相同的筛选条件,再将列表和分页信息返回页面。

分页使用 SQL Server 的 ROW_NUMBER(),按约定的 Id 字段倒序排列,取当前页对应的行号区间。这版使用固定的主键约定,读取到主键信息后,并没有据此自动生成适配任意主键的分页语句。页码和每页数量由通用层接收,分页 SQL 集中在数据访问层。

查询结果使用字段名到字段值的映射,页面按字段配置取值。新增和修改同样按提交的字段组织数据;已有记录的标识单独参与定位,不作为普通的可编辑字段处理。这样既能复用数据结构,也能区分“修改哪一条”和“修改哪些内容”。

这版主要处理单表查询、分页、新增、修改和删除,复用团队已有的表结构和数据操作约定。页面上可以配置的内容,也以这些操作能够表达的范围为基础。

比如维护名称、地址、分类、状态,可以按字段查询和保存;但涉及跨表统计、联动更新或者调用外部服务时,就需要专门的业务逻辑。这版先把简单的数据维护做通,没有继续用字段配置表达这些复杂操作。

接入一张表时需要做什么

再回到前面那张营销配置表,接入过程就比较清楚了。

研发先确认表结构适合通用操作,选择对应数据源,输入表名。系统读取字段,把字段注释作为默认标题,并填入类型、可空性等基础信息。

接着调整页面配置:名称放在列表和编辑页,分类放在列表、编辑和查询区域,展示地址放在编辑页,状态使用枚举选择。保存后得到一个独立的表单标识,可以打开这套配置对应的列表和编辑页面。

交给运营之前,研发用实际操作检查查询、新增和修改的结果。运营之后通过这个入口维护配置,数据仍然落在原来的业务表里,供相应的 C 端或 B 端场景使用。

同一张实际表需要按分类、业务线组织成不同的管理入口时,就为它分别建立表单配置。每份配置有独立的标识和字段设置,业务数据仍然保存在同一张表中。这里增加的是表单配置,不是另一套数据操作程序。

小结

前面几版一直在减少这类需求里的重复工作:代码生成减少手写代码,JSON 配置让页面共用,现在把表结构读取、表单配置和通用数据操作接到一起。研发把入口配置好,再交给产品、运营使用。

这里我保留了两项设计:把元数据保存下来,让字段标题、填写要求和查询设置可以持续维护;让一张表对应多份表单配置,按业务需要组织入口。后续调整页面时,可以改对应的配置,不必让数据库表结构和管理页面始终一一对应。

最后修改:2019 年 05 月 06 日
如果觉得我的文章对你有用,请随意赞赏