Bytebase DiffMetadata 重构解析:从 SQLService 匿名调用到 DatabaseService 的 IAM 门控 DDL 生成
2026/9/15 13:16:49 网站建设 项目流程

Bytebase DiffMetadata 重构解析:从 SQLService 匿名调用到 DatabaseService 的 IAM 门控 DDL 生成

【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase

导读:本文基于 Bytebase 仓库中已批准的架构设计文档(2026-07-29-diff-metadata-database-service-design.md),完整剖析DiffMetadata接口从SQLService迁往DatabaseService并重塑请求契约的工程决策:服务端改为按数据库资源名从存储读取源(当前)schema,新增bb.databases.diffMetadata权限并把该能力收紧到 schema 变更编写角色。读完本文,你将掌握该 RPC 的 Proto 契约、IAM 授权边界、后端实现调用链、前端调用改造以及 3.21 版本兼容性影响,并能直接定位到仓库中的对应源码与测试。

背景:一次"从无凭证纯函数到受管资源读取"的接口重塑

Bytebase 原有的SQLService.DiffMetadata是一个无凭证(anonymous)纯函数:调用方把两份完整 schema(源与目标)同时上传给服务端,服务端不触碰任何存储数据,直接对两份元数据求差并生成迁移 DDL,因此被标记为allow_without_credential = true

这个形态存在两个结构性缺陷:

  1. 数据新鲜度与语义错位:源 schema 由调用方自备,可能与数据库中真实同步的 schema 脱节;
  2. 安全级别错配:既然不读取存储内容,接口无需鉴权;但它生成的是变更 DDL,属于变更编写(change authoring)行为,理应由 IAM 管控。

因此设计文档(2026-07-29 批准)决定做一次彻底的迁移与重塑:

  • 迁移DiffMetadataSQLService移到DatabaseService,与已有的DiffSchema并列;
  • 重塑:请求只携带数据库资源名name目标DatabaseMetadata,源 schema 与引擎由服务端从存储读取,旧请求中的engine字段随之消失;
  • 加锁:新 RPC 读取存储中的 schema 内容,安全类别升级为 IAM 门控,引入新权限bb.databases.diffMetadata
  • 删旧:旧SQLService.DiffMetadata在同一版本(3.21)中直接删除,不做废弃别名兼容(clean cut),作为带标签的 breaking change 发布。

一、Proto 契约:请求瘦身、响应不变

1.1 新 RPC 与 REST 绑定

在 proto/v1/v1/database_service.proto 中,DatabaseService紧挨着DiffSchema声明了新的 RPC:

// Generates migration statements from the database's current schema to the // given target metadata. // Permissions required: bb.databases.diffMetadata rpc DiffMetadata(DiffMetadataRequest) returns (DiffMetadataResponse) { option (google.api.http) = { post: "/v1/{name=instances/*/databases/*}:diffMetadata" body: "*" additional_bindings: { post: "/v1/{name=projects/*/instances/*/databases/*}:diffMetadata" body: "*" } }; option (bytebase.v1.permission) = "bb.databases.diffMetadata"; option (bytebase.v1.auth_method) = IAM; option (bytebase.v1.mcp_method_class) = WRITE; }

要点:

  • REST 绑定POST /v1/{name=instances/*/databases/*}:diffMetadata,并保留projects/*/instances/*/databases/*形式作为additional_bindings,与DiffSchema的绑定风格镜像一致;
  • auth_method = IAM:ACL 拦截器可直接把资源名解析到其所属项目完成鉴权,无需任何自定义鉴权代码;
  • mcp_method_class = WRITE:该方法在 MCP 网关中被归入写操作类别,与它的"生成变更 DDL"语义一致。

1.2 请求与响应消息

请求消息定义在 database_service.proto:

message DiffMetadataRequest { // The database whose current schema is the diff source. // Format: instances/{instance}/databases/{database} or projects/{project}/instances/{instance}/databases/{database} string name = 1 [ (google.api.field_behavior) = REQUIRED, (google.api.resource_reference) = {type: "bytebase.com/Database"} ]; // The metadata of the target schema. The source metadata and the engine are // read from the database, so only the target travels in the request. // Must describe the COMPLETE target schema: the diff runs against the full // stored source, so any object omitted from the target (for example by a // truncated metadata fetch) is treated as dropped. DatabaseMetadata target_metadata = 2 [(google.api.field_behavior) = REQUIRED]; } message DiffMetadataResponse { // The generated migration statements. string diff = 1; }

值得注意 proto 注释中明确写出的完整性(completeness)约束:diff 是针对完整的存储源 schema 进行的,目标中任何被省略的对象(例如被截断的元数据获取导致遗漏的表)都会被解读为"删除"。这是本设计埋下的一个关键陷阱,后文"前端改造"一节会展开说明它曾如何在代码评审中被发现。

响应仍只有一个string diff字段,承载生成的迁移语句。

1.3 SQLService 的瘦身

sql_service.proto 中删除了该 RPC 及其两个消息,并移除import "v1/database_service.proto"。由于DatabaseMetadata是 SQL 执行服务唯一用到的 database-service 类型,删除后SQL 执行服务与数据库类型彻底解耦——这是这次重塑带来的一个附带收益。

仓库佐证:生成代码 backend/generated-go/v1/database_service.pb.go 与 Connect 桩 backend/generated-go/v1/v1connect/database_service.connect.go 中均已存在DiffMetadata;而 grpc-doc 生成文档 proto/gen/grpc-doc/v1/README.md 中已无SQLService/DiffMetadata条目,说明旧接口确已移除。

二、IAM 权限与角色边界:变更编写 ≠ schema 读取

2.1 新权限的定义与同步

权限常量定义在 backend/common/permission/permission.go#L32:

DatabasesDiffMetadata Permission = "bb.databases.diffMetadata"

声明式清单同步在 backend/common/permission/permission.yaml#L19。前端侧的 TypeScript 生成类型通过 frontend/scripts/copy_config_files.sh 同步生成。

2.2 预置角色的授权范围:精确到四个"变更编写"角色

在 backend/store/predefined_roles.go 中,permission.DatabasesDiffMetadata恰好出现在四个预置角色中(分别对应 workspaceAdmin、workspaceDBA、projectOwner、projectDeveloper):

角色是否拥有bb.databases.diffMetadata
Workspace admin
Workspace DBA
Project owner
Project developer
Project viewer
Project releaser
SQL editor 两个角色

这一取舍背后是明确的语义决策(2026-07-29):生成迁移 DDL 属于变更编写行为,而非 schema 读取行为。即使 diff 输出本身不会泄露超出bb.databases.getSchema能获取的信息,权限模型依然刻意收紧——per-method 权限让角色设计保持显式,DDL 生成只归属 authoring 角色,而非 read 角色。

自定义角色需要显式添加该权限,这一点写入了发布说明(release notes)。

三、后端实现走查:五步调用链

新实现位于 backend/api/v1/database_service.go,紧邻DiffSchema。核心流程可拆解为五步:

func (s *DatabaseService) DiffMetadata(ctx context.Context, req *connect.Request[v1pb.DiffMetadataRequest]) (*connect.Response[v1pb.DiffMetadataResponse], error) { request := req.Msg if request.TargetMetadata == nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("target_metadata is required")) } projectID, instanceID, databaseID, err := common.GetDatabaseResourceName(request.Name) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } _, instance, err := s.findDatabaseForResource(ctx, projectID, instanceID, databaseID, false) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } if instance == nil { return nil, connect.NewError(connect.CodeNotFound, errors.Errorf("instance %q not found", instanceID)) } engine := instance.Metadata.GetEngine() switch engine { case storepb.Engine_MYSQL, storepb.Engine_POSTGRES, storepb.Engine_TIDB, storepb.Engine_ORACLE, storepb.Engine_MSSQL: default: return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf("unsupported engine: %v", engine)) } sourceDBSchema, err := s.store.GetDBSchema(ctx, &store.FindDBSchemaMessage{ Workspace: common.GetWorkspaceIDFromContext(ctx), InstanceID: instanceID, DatabaseName: databaseID, }) if err != nil { return nil, connect.NewError(connect.CodeInternal, errors.Wrapf(err, "failed to get database schema")) } if sourceDBSchema == nil { return nil, connect.NewError(connect.CodeNotFound, errors.Errorf("schema not found for database %q; sync the database first", request.Name)) } storeTargetMetadata := convertV1DatabaseMetadata(request.TargetMetadata) targetDBSchema := model.NewDatabaseMetadata(storeTargetMetadata, nil, nil, engine, store.IsObjectCaseSensitive(instance)) migrationSQL, err := schema.DiffMigration(engine, sourceDBSchema, targetDBSchema) if err != nil { return nil, connect.NewError(connect.CodeInternal, errors.Wrapf(err, "failed to compute diff between source and target schemas")) } return connect.NewResponse(&v1pb.DiffMetadataResponse{ Diff: migrationSQL, }), nil }

逐一对应设计文档的五步:

  1. 解析资源名common.GetDatabaseResourceNamename解析为 project/instance/database 三段,findDatabaseForResource加载实例(workspace 作用域);
  2. 引擎门控:沿用旧 RPC 接受的引擎集合——MYSQL、POSTGRES、TIDB、ORACLE、MSSQL,其余引擎返回InvalidArgument
  3. 读取源 schemastore.GetDBSchema直接返回model.DatabaseMetadata无需任何转换;若数据库从未同步过 schema,返回NotFound(提示 "sync the database first");
  4. 转换目标convertV1DatabaseMetadata把请求中的 v1 目标元数据转成 store 模型,再经model.NewDatabaseMetadata(..., store.IsObjectCaseSensitive(instance))包装。这里有一个值得注意的行为修正:旧 handler 硬编码大小写敏感性为true,新实现改用实例的实际排序规则行为,与DiffSchema保持一致——这对大小写敏感方言(如 PostgreSQL 双引号标识符)的正确 diff 至关重要;
  5. 生成 diffschema.DiffMigration(engine, source, target)返回迁移语句,原样放进DiffMetadataResponse.diff

同时,SQLService.DiffMetadata及其实现被删除,sql_service.go随之移除plugin/schema依赖。

四、前端调用改造:签名不变,wire 调用切换

4.1 generateDiffDDL 的改造

前端唯一调用点是 schema 编辑器的 generateDiffDDL.ts,被 SchemaEditorSheet.tsx 使用。该工具函数签名保持不变(database + source + target),本地短路与校验逻辑继续留在客户端:

  • isEqual(source, target)相等时直接返回空语句(无网络请求);
  • validateDatabaseMetadata(targetMetadata)校验失败时返回 "Invalid schema" 与校验信息;
  • 只有 wire 调用从旧的 SQLService 形态切换为:
const newRequest = create(DiffMetadataRequestSchema, { name: database.name, targetMetadata: targetMetadata, }); const diffResponse = await databaseServiceClientConnect.diffMetadata(newRequest, { contextValues: createContextValues().set(silentContextKey, true), });

从源码结构看,generateDiffDDL持有的Database对象原本就会携带 store 拉取的原始元数据,因此服务端读取源 schema 在语义上等价且更新鲜(fresher)。

4.2 一个被代码评审拦下的坑:limit: 200 截断

设计文档明确记录了一次由请求重塑暴露的回归风险:SchemaEditorSheet此前用limit: 200拉取基线元数据(窗口式编辑器的性能保护,PR #17514)。旧接口两侧都截断时是安全的;但新接口的服务端源是完整schema,一旦目标侧省略了任何表,diff 就会对每一张被截断的表生成DROP语句。

这个问题在评审中被捕获(PR #21068),修复方案是让 sheet 改为拉取不限量的元数据——这与其他元数据消费者(默认即无限制)保持一致,同时请求的 proto 注释中显式记录了完整性要求。对应的前端 e2e 测试见 schema-editor-diff-insert.spec.ts。

五、兼容性影响矩阵(3.21)

表面影响
gRPC/Connectbytebase.v1.SQLService/DiffMetadata已删除——破坏性变更。调用方必须切换到bytebase.v1.DatabaseService/DiffMetadata并采用新请求形态
RESTPOST /v1/schemaDesign:diffMetadata已删除——破坏性变更。POST /v1/{name=instances/*/databases/*}:diffMetadata(需鉴权)取代
匿名 schema diff 访问按设计消失——新 RPC 读取存储 schema,要求bb.databases.diffMetadata
滚动升级期间缓存的 3.21 前前端 bundleschema 编辑器 DDL 预览会失败,刷新后恢复——随 clean cut 一并接受
自定义角色需自行添加bb.databases.diffMetadata才能使用新 RPC(四个预置角色随版本更新)

版本发布以--label breaking标记,并配有## Breaking Changes章节,覆盖方法移除、REST 路径变更与新权限三项。

关于"clean cut"的取舍

设计文档强调,旧 RPC 是匿名的,因此无法通过认证日志排除未知的外部调用方。之所以仍选择直接删除而非保留废弃别名,是因为:唯一已知调用方是 Bytebase 自己的 schema 编辑器,且请求形态已经改变,别名只会保留一个死契约(dead contract)。匿名或外部调用方将收到 unimplemented/404,必须改用新 RPC。

六、测试与质量保障:e2e 钉死角色边界

6.1 新增 e2e:TestDiffMetadata

backend/tests/diff_metadata_test.go(194 行)用真实 PostgreSQL 容器验证了完整行为矩阵:

  1. no-op 目标产生空 diff:把拉取到的当前元数据原样回传,diff 必须为空——这钉死了 store→v1→store 往返保真度,任何转换器丢字段都会在这里暴露为虚假 DDL;
  2. 新增一张表t_diffid integer NOT NULL):diff 包含CREATE TABLEt_diff,且不包含DROP/ALTER(单表新增不拖带虚假变更);
  3. 缺 targetInvalidArgument
  4. 无项目角色的 workspace 成员PermissionDenied
  5. project viewer仍然PermissionDenied(钉死角色边界:能读 schema 不代表能写 DDL);
  6. project developer→ 成功,且 diff 与 owner 完全一致。

6.2 转换器单元测试:TestDiffMetadataPreservesSRIDInvisible

batch 转换器测试 database_converter_test.go#L85-L129 中的TestDiffMetadataPreservesSRIDInvisible直接练习 handler 实际运行的转换 + diff 管线(convertV1DatabaseMetadataDiffMigration):一个携带SRID 4326的几何列与一个INVISIBLE 列在源/目标间保持不变,仅另一列注释被编辑时,diff 不得产生虚假的SRID/INVISIBLE变更,也不得出现幻影 DDL——这验证了 v1→store 转换不会剥落空间参考系与列可见性信息。

6.3 标准门禁

设计文档列出的标准门禁为:buf、golangci-lint、go build、e2e、pnpm 套件。

七、备选方案权衡:为什么最终选择 clean cut

设计文档记录了三个被评估后否决的替代方案,理解它们有助于把握本设计的边界:

  1. 保留旧的"双元数据"形态的废弃匿名别名:最初确实以Legacy*消息重命名实现过,随后被刻意删除——别名只会承载一个唯一已知调用方是自己前端的死契约,而匿名表面恰恰是本变更要退役的东西,因此选择 clean cut(Danny,2026-07-29);
  2. 并入DiffSchema作为target_metadataoneof 成员:这是诱人的终态(每个数据库一个 diff 表面),但契约变更幅度超出本次需求,且DiffSchema自身仍带着TODO(d): secure it(目前还挂在bb.databases.get上,见 database_service.proto#L188-L190),留待该 TODO 处理时再合并;
  3. 复用bb.databases.getSchema或把新权限授予所有 getSchema 角色:被否决——per-method 权限保持角色设计显式,DDL 生成属于 authoring 角色而非 read 角色。

总结

DiffMetadata的重构是 Bytebase 在"变更治理"主线上的一次典型演进:把本应受管的能力从匿名的纯函数收拢进 IAM 门控的资源型 RPC,同时通过请求重塑(服务端读源、客户端只传目标)简化调用契约、修正大小写敏感性处理,并以 e2e 测试将"项目查看者不可生成变更 DDL"这一角色边界永久钉死。对集成方而言,最重要的迁移动作是:改用DatabaseService.DiffMetadata、补齐bb.databases.diffMetadata权限、并保证传入的目标元数据完整无截断。

【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询