
记一次外部数据源接入引发的思考
这次问题发生在一次工单系统的版本升级里。
系统本身是公司内部流程工单系统。普通用户在里面填写、提交工单;模板设计者可以在前台配置表单字段、字段联动和流程等。对普通用户来说,一个下拉框只是一个下拉框。对模板设计者和系统管理员来说,它背后可能连着一份外部数据。
旧版本允许模板里的下拉框直接引用外部 JDBC 数据源。新版本准备把这条路收掉,改成由 API 提供数据。这个改动不是为了换一种写法。JDBC 直连数据库会牵涉账号、网络连通性、权限范围、审计日志和数据暴露边界。
根据公司对于系统安全的合规审计,这些要求不能靠模板设计者配置时“注意一下”来解决。
API 的好处是入口统一。权限可以收敛在服务端,调用日志也能集中记录。模板只需要知道“我要拿一组候选项”,不需要知道背后连的是哪台数据库、哪个账号、哪张表。
麻烦在于旧配置已经存在。升级前必须查出哪些历史模板还在引用外部数据源,整理清单,再通知到对应的模板设计者。漏掉一个模板,旧模块下线后就可能变成某个线上表单里突然失效的下拉框。
这项任务后来完成了。回头看,它给我的提醒很直接:迁移类需求最怕的不是新方案写不出来,而是旧配置里的依赖没人知道在哪里。
最开始只查到了第一层
模板数据存在 SQL Server 表里,表单字段配置保存在 models.form_item_json 这一类 JSON 字段中。最直观的结构大概是这样:
[
{
"id": "field_1",
"name": "SelectInput",
"title": "供应商",
"props": {
"dataSource": {
"dsId": "xxx",
"nameField": "supplier_name",
"tableName": "md_supplier"
}
}
}
]如果所有数据源都在这一层,查询不难。把数组展开,取 props,再取 props.dataSource.dsId。
我最开始写的 SQL 大概就是这个方向:
DECLARE @sourceId NVARCHAR(64) = N'xxx';
SELECT
wm.id,
wm.name,
item.id AS field_id,
item.title AS field_title,
dataSource.dsId
FROM dbo.wflow_models AS wm
CROSS APPLY OPENJSON(
CASE
WHEN ISJSON(wm.form_item_json) = 1 THEN wm.form_item_json
ELSE N'[]'
END
)
WITH (
id NVARCHAR(100) '$.id',
title NVARCHAR(200) '$.title',
props NVARCHAR(MAX) '$.props' AS JSON
) AS item
CROSS APPLY OPENJSON(item.props, '$.dataSource')
WITH (
dsId NVARCHAR(64) '$.dsId'
) AS dataSource
WHERE wm.is_deleted = 0
AND dataSource.dsId = @sourceId;这条 SQL 有结果,也确实能找到一部分模板。但它漏了另一类配置:表格组件里的列。
表格组件本身是一个字段,表格里的每一列也是字段配置。数据源可能不在顶层字段的 props.dataSource,而是在更深的结构里:
form_item_json
└─ 字段数组
└─ 表格字段 props
└─ columns 数组
└─ 列字段 props
└─ dataSource.dsId这类问题最危险的地方在于,SQL 不报错,也能返回结果。看起来像是排查成功,实际清单是不完整的。后来拿一份漏掉的模板 JSON 对照,才发现不是 SQL Server 查不出来,而是我只查了第一层。
OPENJSON 不会替你递归整棵树
当时我有一个误区:把最外层 JSON 用 OPENJSON 展开以后,以为它能帮我找到任何深度的 dsId。
它不会。
OPENJSON 只处理你交给它的那一层。传给它数组,它返回数组元素。传给它对象,它返回当前对象的键和值。更深的对象和数组会继续作为 JSON 片段保留。你要继续往下找,就必须再展开一次,并且知道下一层可能是什么结构。
针对表格列,查询要继续往下走:
DECLARE @sourceId NVARCHAR(64) = N'xxx';
SELECT
wm.id,
wm.name,
item.id AS table_field_id,
item.title AS table_field_title,
col.id AS column_field_id,
col.title AS column_title,
columnDs.dsId
FROM dbo.wflow_models AS wm
CROSS APPLY OPENJSON(
CASE
WHEN ISJSON(wm.form_item_json) = 1 THEN wm.form_item_json
ELSE N'[]'
END
)
WITH (
id NVARCHAR(100) '$.id',
title NVARCHAR(200) '$.title',
props NVARCHAR(MAX) '$.props' AS JSON
) AS item
CROSS APPLY OPENJSON(item.props, '$.columns')
WITH (
id NVARCHAR(100) '$.id',
title NVARCHAR(200) '$.title',
props NVARCHAR(MAX) '$.props' AS JSON
) AS col
CROSS APPLY OPENJSON(col.props, '$.dataSource')
WITH (
dsId NVARCHAR(64) '$.dsId'
) AS columnDs
WHERE wm.is_deleted = 0
AND columnDs.dsId = @sourceId;这段查询比第一版啰嗦,但它有一个好处:能告诉你命中的是哪个模板、哪个表格字段、哪一列。后面通知模板设计者改造时,这比只给一个模板 ID 有用得多。
这里还有一个小细节:如果历史数据里可能有脏 JSON,不要只在末尾的 WHERE 里补一个 ISJSON。OPENJSON 在 FROM 阶段就会处理入参,脏 JSON 可能让排查脚本直接失败。上面的写法用 CASE 把非法 JSON 当成空数组处理,排查脚本会跳过这类记录。
不过,ISJSON 只能说明它是不是合法 JSON。它不能保证结构符合当前版本的模板设计器约定。
这不是单纯的 SQL 问题
这次排查一开始看起来像 SQL 问题:怎么从 JSON 字段里查出 dataSource.dsId。
后来我发现,它其实是配置模型问题。要回答的不是“怎么写一条更聪明的 SQL”,而是“历史模板里,数据源引用可能出现在哪些位置”。
已知路径可以用 OPENJSON 逐层展开。比如普通下拉框查 props.dataSource.dsId,表格列查 props.columns[*].props.dataSource.dsId。如果还有弹窗、子表单、动态区域,也要把这些组件的配置路径补进来。
但这次需求还有一个现实边界:它是迁移前的影响分析,不是要给线上请求链路提供 JSON 搜索服务。我们要找的是系统生成的外部数据源 ID。这个 ID 很长,格式固定,不是用户自由输入的描述文本。
在这个边界下,可以加一层文本扫描作为兜底:
DECLARE @sourceId NVARCHAR(64) = N'xxx';
SELECT
wm.id,
wm.name
FROM dbo.wflow_models AS wm
WHERE wm.is_deleted = 0
AND ISJSON(wm.form_item_json) = 1
AND wm.form_item_json LIKE N'%"' + @sourceId + N'"%';这个写法不是结构化解析。它只能回答一个小问题:这段配置里有没有出现过这个完整资源 ID。它适合做迁移排查时的候选集扫描,然后再回到 JSON 结构里确认命中位置。
这里的边界必须说清楚。
如果查询目标是短名称、用户输入、表名片段,或者可能出现在说明文字里的内容,LIKE 很容易误命中。如果 JSON 由不同序列化器生成,字符串里可能有空格、转义或大小写差异,简单文本匹配也可能漏。它这次能用,是因为目标值是系统生成的完整 ID,而且排查发生在专项迁移期间,不在高频业务链路上。
如果要更稳,可以把文本扫描作为“高召回候选集”,再用结构化解析或人工抽样做确认。不要拿文本扫描结果直接作为最终业务结论。
交付物不是一段 SQL
这类任务交出去的东西不应该是一段查询语句,而是一份可以行动的影响清单。
我更愿意把结果整理成下面这种结构:
模板 ID
模板名称
命中的数据源 ID
命中位置
组件类型
字段标题
字段 ID
模板设计者或维护人
建议改造方式只告诉别人“这个模板命中了旧数据源”还不够。模板设计者打开一个字段很多的表单以后,可能不知道是哪一个下拉框、哪一个表格列出了问题。排查结果越具体,迁移沟通成本越低。
所以我的处理顺序会变成两步。
第一步尽量不漏。已知组件路径逐层解析,再用完整 ID 文本扫描做兜底。
第二步确认位置。回到模板 JSON 里定位字段或表格列,确认它确实依赖旧数据源,而不是一段无关文本里刚好出现了相同 ID。
这样做会多花一点时间,但迁移问题的成本不在查询多跑了几分钟,而在漏掉一个依赖以后谁来解释和补救。
第二次再查引用,就应该建引用表
这次可以靠 JSON 扫描完成,因为它是一次明确范围内的迁移。
但如果团队以后反复问这些问题:某个数据源被哪些模板引用,下线一个 API 会影响哪些表单,某类字段配置分布在哪里,那依赖关系就不应该继续只藏在 JSON 里。
更合适的方式是把模板和资源的引用单独保存:
CREATE TABLE dbo.wflow_model_resource_ref (
id BIGINT IDENTITY PRIMARY KEY,
model_id BIGINT NOT NULL,
resource_type NVARCHAR(50) NOT NULL,
resource_id NVARCHAR(100) NOT NULL,
component_type NVARCHAR(100) NULL,
field_id NVARCHAR(100) NULL,
field_title NVARCHAR(200) NULL,
json_path NVARCHAR(500) NULL,
updated_at DATETIME2 NOT NULL DEFAULT SYSDATETIME()
);
CREATE INDEX ix_model_resource_ref_resource
ON dbo.wflow_model_resource_ref(resource_type, resource_id);模板保存或发布时,系统解析 JSON,并同步这张引用表。以后做影响分析就不必扫整段配置文本,而是普通的索引查询:
SELECT
model_id,
component_type,
field_id,
field_title,
json_path
FROM dbo.wflow_model_resource_ref
WHERE resource_type = N'EXTERNAL_DATASOURCE'
AND resource_id = @sourceId;这样做会增加保存时的实现成本,也要处理引用同步和模板保存的一致性。比如模板保存成功但引用表同步失败时,要不要阻断发布,或者记录待修复任务。这些都要设计清楚。
但成本放在这里是值得的。表单 JSON 可以继续演进,引用关系则变成系统能查询、审计和约束的数据。
