前面写过前一篇设计与实现。当时主要解决的是一类重复需求:营销业务需要维护一些配置,研发读取表结构、配置表单,再把入口交给产品和运营使用。

这两年继续使用和调整,基本的增删改查没有变,更多工作放在了配置怎么生效、数据怎么维护、不同团队怎么使用,以及已有能力怎么复用上。

比如新增一个数据源,配置已经保存,当前实例也能连接,另一个实例却还没有对应的连接池。再比如同一张表增加一份管理配置,读到的字段结构可能还是之前缓存的版本。配置驱动减少了重复开发,也把这些问题集中到了通用层。

下面从动态数据源说起。

动态数据源到了多实例环境

前一篇讲了通过 Spring 动态数据源选择业务库。部署多个实例以后,需要区分三样东西:

内容保存位置实例之间的关系
数据源配置管理系统数据库各实例读取同一份配置
连接池各实例内存各自创建、各自管理连接
路由映射各实例内存将数据源标记对应到本机连接池

数据库里保存的是建立连接所需的配置,连接池和路由映射还需要各实例自己加载。

假设有 A、B 两个实例,新增数据源的请求落在 A 上。A 创建连接池并更新路由以后,自己已经可以使用;但下一次访问单表的请求如果落到 B,B 不会因为配置库里多了一条记录,就自动注册这个数据源。

ThreadLocal 只负责当前请求选择哪个数据源。要让请求在不同实例上都能执行,还得把新增的数据源注册到每个实例。

每个实例都要收到通知

后来接入了 RabbitMQ,新增数据源成功后广播通知,各个实例收到消息,再完成本机的注册。

新增数据源请求
    ↓
当前实例创建、注册连接池并验证连接
    ↓
保存数据源配置
    ↓
发送新增通知 → Fanout Exchange
                  ├── 实例 A 的队列 → 注册本机数据源
                  ├── 实例 B 的队列 → 注册本机数据源
                  └── 实例 C 的队列 → 注册本机数据源

这里选择 Fanout Exchange,每个实例声明自己的匿名队列并绑定到交换机。同一条通知要分别进入每个实例的队列,所有正在运行、已经建立绑定的实例才都有机会处理。

如果多个实例消费的是同一个队列,消息会分给其中的消费者处理。对于普通任务分发,这样做没有问题;但这里要求每个实例都更新本地状态,只处理一次并不够。

收到通知以后,各个实例调用本地的数据源注册逻辑,创建连接池、加入路由映射并验证连接。消费通知时不再重复保存配置,持久化由最初的新增操作负责。

启动加载仍然保留

消息广播负责运行中的新增,启动时的全量加载仍然保留。新实例从数据库读取已有配置,建立自己的连接池,无需重放过去的新增消息。

这两个过程解决的时机不同:启动加载取得已有状态,广播通知后续变化。实例离线时,匿名队列不会替它保存完整的变更历史;再次启动时,仍然依赖数据库中的配置。

广播采用异步处理,因此新增接口返回成功时,各实例可能还没有全部完成注册。配置保存和消息发送也没有放在同一个原子操作中,发送或消费失败时,需要继续检查实际注册结果。这版还没有实现失败后的自动对账补偿。

本地注册也不只是往 Map 里放一个对象

本地使用并发 Map 保存数据源,创建连接池后按数据源标记加入映射,再更新 Spring 的目标数据源并重新初始化。路由层重新解析目标后,后续获取连接才能找到新增的数据源。

这里用了按 key 不重复加入的处理,但连接池是在加入映射之前创建的。收到重复通知时,即使同名映射没有覆盖,新创建的连接池也有自己的生命周期需要处理。

同样,并发 Map 保护的是单次映射操作,整个注册还包括创建连接池、刷新路由和验证连接。它们不是一次原子操作,不能只看容器是否线程安全。

目前这套机制处理的是新增。修改连接信息时,还涉及旧连接池上的请求和连接如何结束,不能直接套用新增逻辑。

表结构缓存与表单配置分开维护

第一版已经把字段标题、控件、出现位置等设置保存下来,但有些操作仍然要读取原始表结构,比如配置字段时检查数据库增加了哪些列。

后来把原始表结构放进 Redis。读取时先查缓存,未命中再通过 JDBC 获取,并写回缓存。这样重复打开配置页面时,不必每次都访问业务数据库的元数据接口。

缓存的对象是数据库结构,它的标识使用“数据源 + 表名”。只用表名不够,不同数据库可以存在同名表。

表单设置则是另一回事。一张实际表可以建立多份单表配置,每份配置的字段标题、组件和页面位置都可能不同。这些设置仍然跟着各自的表单标识保存,不能拿一份按表名缓存的结构代替。

数据源 + 实际表
    └── 原始字段结构缓存

表单配置 A → 自己的标题、字段位置和组件
表单配置 B → 自己的标题、字段位置和组件
    └── 两份配置可以指向上面同一张表

使用缓存以后,新增字段不一定马上出现在读取结果里。特别是同一张表再建一个入口时,旧入口之前已经读过结构,新入口可能拿到的还是那份缓存。

这里做过一个调整:新建表单、从原始表结构生成字段配置之前,先清除这张表对应的结构缓存,再重新读取。这样新入口使用的是重新取得的字段信息,而不是等缓存自然过期。

清除缓存后,重新取得的是字段结构,其他入口保存过的标题、组件和页面位置继续保留。已有字段改名、删除或改变类型时,再检查受影响的表单配置;这部分没有自动跟随所有表结构变更。

从能填字段,到方便维护数据

页面可以根据字段类型生成输入框,但真正交给运营使用时,只知道一个字段是整数还是字符串还不够。城市、品牌、车系在数据库里经常保存ID,使用者更熟悉的是名称。让人先查ID,再回来填写,还是把一部分工作留给了人工。

这类基础数据在不同表中反复出现,因此页面逐步接入了城市、品牌、厂商、车系等选择组件。研发配置字段时指定组件,页面渲染时使用对应的选择方式。车系组件支持按输入内容搜索,选项同时展示名称和ID,选中以后将值交回表单。

选择一次,回填相关字段

组件不只负责把输入框换成下拉框,还要处理选中以后写到哪里。

这里把保存ID的字段和需要同步名称的字段一起配置。选择变化时,组件返回ID和显示文本,通用页面根据配置分别回填。业务表只保存ID时使用ID字段;同时保存名称时,再配置名称的同步字段。

例如一张表同时有城市ID和城市名称,运营选择一次城市,两个值就可以一起填入,不用分别输入。换一张表,字段名称可能不同,调整对应的字段配置即可,选择组件仍然共用。

这部分补充的是数据库类型表达不了的含义。同样是整数,一个字段可以使用城市组件,另一个使用车系组件;仅靠 JDBC 读取到的类型,无法判断应该怎么选。

批量导入也要处理名称和ID

页面可以点选,批量数据通常在 Excel 里。导入如果只接收数据库ID,使用者仍然要先做一遍名称匹配。

因此导入增加了列转换规则,说明这一列属于哪种基础数据、匹配后的ID写到哪个字段,以及是否同时保存名称。读取后先做转换,再进入字段校验和保存流程。

读取导入列及转换规则
    ↓
按需要加载城市、车系等基础数据
    ↓
名称匹配ID,按配置补充名称字段
    ↓
校验字段类型、日期等格式
    ↓
按配置校重,再执行保存

基础数据在处理这一批数据之前准备。导入列涉及城市时加载省市数据,涉及车系时加载车系数据,建立名称到ID的 Map,后面的行复用这些结果。这样不用为每一行重复查询同一份基础数据,也不用在一次只导入普通字段的操作中加载全部字典。

省市数据还合并了补充的城市信息,名称已经存在时保留原来的映射。车系、品牌、厂商分别使用对应的数据来源。转换时根据列规则选择映射,再把找到的ID写入目标字段。

找不到对应项时,返回具体行列,让使用者修改文件,而不是把没有匹配上的名称直接当成ID保存。这里采用名称精确匹配,没有做模糊猜测;简称、别名需要与基础数据约定一致。同名条目的歧义也不是一个名称到ID的 Map 就能自动解决的。

枚举字段走类似的处理:根据字段关联的枚举定义,将显示名称转换成保存值。这样 Excel 中填写业务人员熟悉的名称,进入数据操作层时仍然使用约定的编码。

导出沿用表单的字段定义

导出时带上查询条件,并允许选择需要的列。表头使用配置中的显示标题,再根据标题对应的字段取出查询结果,生成 Excel。

导出复用了列表查询,配置了枚举的字段会沿用列表里的名称转换。城市、车系则要看实际保存了什么:表里保存了名称字段,可以选择该字段导出;只有ID的字段,当前导出流程不会统一调用基础数据接口补出名称。

因此页面选择时的名称回填、导入时的名称匹配,会影响后面数据展示和导出的使用方式。研发配置表单时,需要把这些环节一起考虑,而不只是让编辑页面能够提交成功。

这些处理仍然围绕同一份字段配置展开,但页面编辑、批量导入和导出各自承担不同的转换。没有为每张业务表复制一套城市、车系处理代码,也没有把三种操作硬做成完全相同的流程。

权限要跟着表单和操作走

研发配置好表单,再交给运营使用,这个分工一直没有变。其他团队也或多或少有类似的配置维护需求,觉得这套系统用起来方便,就逐渐用到了各自的业务中。到现在,大概有三个事业部、十个左右的团队在使用,单表数量接近 1000 个。

各团队维护的数据和操作人员不同,入口也需要分开管理。这里把业务线、用户和表单关联起来,普通使用者看到自己有操作权限的单表,管理员负责相应范围内的配置和授权。

进入表单以后,再区分具体操作。有的人只需要查询,有的人需要维护数据,导入、导出和删除也分别授权。

这里仍然以表单标识为对象。因为一张物理表可以有多个管理入口,使用者拿到的是某个入口的操作权限,不能仅凭物理表相同就把权限合并。

实现上,每种操作使用一个独立的二进制位,多个操作组合成权限值,检查时做按位与。判断所需的信息是用户、表单和操作类型,管理员则结合所属范围处理。

页面根据操作权限显示按钮,服务端在新增、修改、删除和导出等入口再次判断。比如没有删除权限,页面不显示删除按钮,直接请求删除接口也会被拒绝。导入、导出同样是数据操作,需要独立处理权限。

一表多配置与固定参数

同一张表按分类或业务线拆成多个入口时,除了字段展示不同,还会需要预先带入条件。有些参数显示在查询区域,有些随页面操作传递,不需要使用者每次填写。

最近又把这部分参数放进表单配置。生成页面时,配置中的值覆盖对应的 URL 初始参数,用来固定这份入口的默认条件或透传值。

比如同一张配置表的两个入口,可以分别带入不同的分类。研发配置一次,运营之后从对应入口进入,不必每次重新选择。

这个实现是在生成页面时准备参数,方便使用者从对应的业务入口操作。它还不是服务端统一的行级权限校验,直接数据请求的范围约束需要另外处理。

从配置完成到交付使用

权限确定谁能使用已有入口,新建入口本身也需要管理。一份表单连接哪个数据源、操作什么表、用于什么场景,这些信息需要在交付使用前说明清楚。

后面接入的审批就放在配置创建这一环节。研发完成配置,填写使用场景后提交审核,审核状态和记录与这份表单关联。这样,配置的用途和处理结果留在系统里,后续查看时可以对应起来。

启用审核时,新建配置先处于未提交状态,提交后进入审核中,再根据结果变为通过或驳回。列表页面在开放使用前检查审核状态,未通过时不进入正常的数据管理页面。

研发创建表单配置
    ↓
补充使用场景,提交审核
    ↓
审核中
    ├── 通过 → 可以按已分配权限使用
    └── 驳回 → 调整配置和说明

处理通过或驳回时,先检查配置是否处于审核中,避免在不合适的状态下继续处理结果;审核动作另行记录日志。

审核管理的是入口的建立和交付,权限管理的是交付后谁可以做什么。运营使用已经通过审核的表单时,数据维护仍按操作权限执行,不逐次提交审批。

从维护数据到业务提醒和播报

管理页面方便了数据维护,业务中还有一些需要主动提醒、定期查看结果的需求。比如库存不足时发个提醒,做一些简单的数据播报,或者查看落地完成率。这些需求后来也用到了数据报警,当时已经配置了几十条。

名字叫数据报警,实际用途不止技术侧的异常检查,也包括业务提醒和日常播报。有的关心数据是否低于约定值,有的需要把查询结果整理成消息,供业务人员了解当前情况。

这些场景的查询内容各不相同,但执行过程有不少共性:定时查数据,判断结果,再把需要关注的信息发出去。

数据报警沿用了已经接入的数据源,但没有把查询限定在表单页面的几个筛选框里。研发配置 SQL,说明查什么;再配置结果类型、比较条件、消息模板和发送方式,说明查出来以后怎么办。

SQL 表达业务条件,通用程序负责执行和处理结果。换一个检查规则时,主要调整配置,不需要再复制一份定时任务。

报警配置本身也用单表维护

这里有一个实际的复用:报警配置列表、详情、保存和删除,底层调用的是单表的通用数据操作。

页面上增加了报警专用的输入和校验,保存时将配置转换成字段 Map,只保留配置表中已有的字段,再交给通用层执行新增或修改。列表查询也复用通用查询,之后补充数据源名称等展示信息。

之前也把集中在一起的代码按元信息、数据操作、校验和日志拆过职责,仍然放在同一个应用里。报警模块保存配置时,可以直接调用数据服务,复用原有的保存流程。

这里保留报警专用的页面和规则处理,复用底层数据操作。新增功能需要自己的使用方式时,不必强行塞进通用表单模板。

调度、查询、判断和通知分开

执行入口由已有的任务调度系统触发,任务参数指定需要执行的报警配置。任务逐个读取配置,取得目标数据源,再执行对应的 SQL。

任务调度
    ↓ 传入报警配置标识
读取报警配置
    ↓
选择数据源,执行 SQL
    ↓
按结果类型和比较条件判断
    ↓ 满足条件
生成通知内容
    ↓
交给短信、钉钉或邮件发送服务

调度决定什么时候执行,配置决定查什么和怎么判断,通知服务负责发送。发送渠道的处理没有塞进数据库查询代码里。

查询时复用前面的动态数据源:先按配置选择已注册的连接池,再按数据库类型进入对应的数据访问实现。同一个任务入口可以执行不同数据源上的规则。

结果分成三种处理方式

不同规则需要的结果不一样,执行时分成了三种方式:

结果类型怎么判断满足条件以后
数量将 SQL 返回的数量与参考值比较发送配置好的通知内容
明细列表将结果行数与参考值比较遍历结果,逐条生成通知
整份列表将结果行数与参考值比较走邮件附件方式提供查询结果

比较条件支持等于、不等于、大于、大于等于、小于和小于等于。业务筛选条件写在 SQL 中,通用程序处理返回的数量或结果行数。

拿库存不足提醒来说,查询可以先筛出库存不足的记录,再由通用程序判断是否有结果。需要知道具体是哪项库存不足时,就返回明细,把名称、剩余数量等放进通知。

数据播报、落地完成率这类需求,则由查询准备好需要展示的统计结果,通知部分按模板组织内容。业务计算留在 SQL 中,通用程序处理结果和发送,不需要为每一种指标单独增加一段 Java 判断逻辑。

明细通知使用字段占位符。例如模板里写 {remaining},查询结果中提供同名列,程序就用该行的值替换它。SQL 的列名或别名与模板形成约定,模板不需要知道业务表内部所有字段。

整份列表使用邮件方式,将查询及数据源信息交给下游生成附件。判断报警和生成附件分别读取数据,两次读取之间如果有数据变化,附件内容可能与触发时的结果不同。

重复执行时,怎么避免同一条记录反复通知

定时查询可能连续多次查到同一条记录。明细模式下,结果可以带一个唯一标识,程序按“报警配置 + 唯一标识”检查发送记录,已经记录过的就跳过。

把配置标识放进去,是因为同一条业务记录可能同时符合不同规则。只按业务记录标识判断,会把另一条规则的通知也一起挡住。

唯一标识的含义也要和规则对应。如果它只表示业务记录本身,那么这条记录被通知过以后,再次满足同一规则也会被跳过;若业务要求按不同事件分别通知,标识就需要能区分这些事件。

这版采用的是先查记录、调用发送方法,再保存发送记录。它解决普通重复扫描下的重复通知,但还不是严格的“只发送一次”:两个执行过程可能同时查到未发送;发送调用之后、记录落库之前失败,下次也可能再次发送。

发送记录只用于这里的防重,不代表渠道已经送达。数量模式没有逐条唯一标识判断,条件持续满足时,后续调度仍可能再次触发通知。

SQL 配置和表单输入的边界不同

普通表单提交字段和值,由通用程序组织 SQL;报警允许研发填写完整查询,灵活性更高,对配置人的要求也不同。

保存配置时加入了写操作关键词检查,拦截明显的误填。这只是文本检查,数据库操作权限仍由连接账号决定。完整查询和发送规则继续由研发配置,运营通过对应表单维护业务数据。

留下操作记录

配置、数据和权限分开以后,日志也需要对应到具体操作。单表配置调整、数据维护、导出和审核分别记录表单、操作人、操作类型及相应内容。

同一张实际表有多个入口时,表名只能说明操作了哪里,表单标识才能对应到使用的是哪份配置。因此日志同时关联这两者,方便按入口回查。

这些日志用于追查使用过程,报警发送记录则用于结果防重。当前没有把它们做成包含全部修改前后值的数据版本记录,也不提供据此回滚业务数据的能力。

再看最初的设计

保存元数据、一张表允许多份配置,这两项设计一直保留了下来。前者让标题、控件和字段设置可以持续维护,后者让同一份业务数据可以按不同用途组织入口。

后来的权限、审核和日志都围绕表单标识展开。它逐渐承载了一个管理入口的完整信息:操作哪里、怎样展示、谁能使用、是否通过审核,以及发生过哪些操作。

数据报警的复用方式又有所不同。它使用已有的数据维护和动态数据源能力,但保留自己的查询规则、调度和通知流程。这部分也让我更明确了通用层的范围:能够共用的数据操作放在一起,具体功能怎样使用它,仍然由各自的模块处理。

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