- 后端
- 任务调度
- 工作流自动化
- 云原生
- MLOps
- 微服务
【免费下载链接】flyte
Dynamic, resilient AI orchestration. Coordinate data, models, and compute as you build AI workflows.
本文基于 Flyte 2 的 RFC 文档(docs/rfcs/20260804_settings_service.md)展开,全面剖析其设计动机、作用域继承模型、四组 RPC 契约、Postgres 存储与稀疏序列化、纯函数解析引擎,以及分阶段落地计划。通过对照仓库中的 IDL 定义(flyteidl2/settings/settings_definition.proto、flyteidl2/settings/settings_service.proto)、完整调用链演练(flyteidl2/settings/settings_customer_flow.md)与实际实现(runs/service/settings_service.go 等),读者可以完整掌握该服务的作用域语义、API 行为、存储格式与实现路径,并能直接参照仓库代码深入验证。
1 背景与动机:为什么 Flyte 2 需要 Settings Service
Flyte 2 已经拥有一套完整、强类型的 settings API 定义在 IDL 中(flyteidl2/settings/settings_service.proto与flyteidl2/settings/settings_definition.proto),并已为 Go、TypeScript、Python、Rust 四种语言生成了客户端代码——但迟迟没有服务端实现。本 RFC 的核心目标,就是把这份已经设计、评审、合并并完成代码生成的 API 真正落地为可运行的SettingsService。
1.1 现状的两条配置来源与真实缺口
在 Settings Service 出现之前,决定一个任务(task)如何运行的旋钮只来自两个地方:
- 静态服务端配置(例如
runs.*配置段、executor 插件配置):全局生效、修改需要重新部署,且无法按项目或域区分。 - 用户代码:每个仓库、每个团队都要在
@task(resources=..., env_vars=...)中重复声明,平台团队无法集中强制或提供默认值。
由此产生四类真实痛点:
- 没有按环境的默认值:无法表达"
production域的所有任务以LOG_LEVEL=info、最多 64 个并发 action 运行,而development域以LOG_LEVEL=debug且不限并发"。 - 没有平台护栏:管理员无法强制"该项目的任何任务最多申请 16 CPU / 64Gi 内存"——资源上限完全由用户代码说了算。
- 配置漂移与重复:团队把相同的 labels、annotations、service account 和 env vars 复制进每个任务;一旦某个值变化(新的成本归属标签、轮换的服务账号),要在 N 个仓库中修改,而不是改一处。
- 改配置要重新部署:修改默认值意味着编辑服务端 YAML 并滚动发布。Settings 应当是数据,而不是代码。
1.2 与 Flyte 1 matchable attributes 的继承关系
Flyte 1 曾用matchable attributes(project_domain_attributes等)部分解决过这个问题,验证了需求,但也暴露出已知可用性缺陷:结构自由松散、优先级不清晰、CRUD 接口令人困惑。Flyte 2 的 settings IDL 正是作为其替代品设计的——类型化 schema、显式继承状态、带作用域标注的读取——并已合并。实现该服务是缺失的最后一步。
2 核心模型:作用域链、三态继承与设置 schema
2.1 作用域链:instance → domain → project
Settings 沿instance → domain → project的作用域链解析,最具体的层级在标量冲突时胜出,map 则做加性合并(子级在 key 冲突时覆盖父级)。每个叶子都携带一个SettingState:
| 状态 | 值 | 含义 |
|---|---|---|
INHERIT | 0(默认) | 委托给父作用域 |
UNSET | 1 | 显式置空,阻断继承链(但不提供替代值) |
VALUE | 2 | 该作用域携带一个真实类型化值 |
这一**三态(INHERIT/UNSET/VALUE)**设计是普通 key-value 系统无法表达的:普通系统只能"有值/无值",无法表达"显式地不要继承"。
2.2 完整设置 schema(已固化在 IDL 中)
| 设置 | 类型 | 控制内容 |
|---|---|---|
run.default_queue | string | 运行的默认队列 |
run.max_action_concurrency | int64 | 每次运行最多并发执行的 action 数(0 = 不限) |
run.run_base_dir | string | 代码包/卸载的运行元数据的基础目录 |
security.service_account | string | 任务 Pod 的 Kubernetes service account |
storage.raw_data_path | string | 原始数据基础路径,如s3://bucket/prefix |
task_resource.min.{cpu,gpu,memory,storage} | quantity | 注入任务的资源请求下限 |
task_resource.max.{cpu,gpu,memory,storage} | quantity | 资源上限;已有值会被封顶(cap) |
task_resource.mirror_limits_request | bool | 把请求值复制到缺失的 limits 中 |
labels、annotations | string map | 应用到任务 Pod,跨作用域加性合并 |
environment_variables | string map | 注入任务 Pod,跨作用域加性合并 |
pod_template_name | string | (RFC 提议新增)作为任务 Pod 基础的PodTemplate资源名 |
在 settings_definition.proto 中,schema 被组织为分组消息:RunSettings、SecuritySettings、StorageSettings、TaskResourceSettings(含TaskResourceDefaults的 min/max 四维资源)、顶层StringMapSetting的labels/annotations/environment_variables、AppSettings,以及顶层StringSetting pod_template_name(field 9)。每个叶子包装器都含state、对应的类型化值字段与scope_level(仅在GetSettings响应中填充,不落库)。此外每个字段都带desc扩展(extension 1364),客户端可经 proto 反射在运行时读取人类可读描述而无需一次服务端往返——settings_definition.proto的头部注释给出了 Go/TypeScript/Python 三端的反射示例。
2.3 典型场景举例
例 1 —— 按域设置环境默认值:
| 作用域 | environment_variables |
|---|---|
| instance | {LOG_LEVEL: info} |
domaindevelopment | {LOG_LEVEL: debug} |
projectrecsys(位于development) | {TEAM: ml} |
recsys/development中的一次运行解析为{LOG_LEVEL: debug, TEAM: ml}——map 父级优先合并、子级在冲突时胜出。GetSettings会对每个值标注其来源scope_level,UI 可以显示"继承自 domain"。
例 2 —— 资源护栏:平台团队在实例级设置task_resource.max.cpu = "16"、task_resource.max.memory = "64Gi"。运行提交时,解析后的设置应用到每个任务:超过上限的请求被封顶;可选地通过mirror_limits_request将请求镜像到缺失的 limits。GPU 上限只用于封顶,绝不会向未申请 GPU 的任务注入 GPU。
例 3 —— 路由到正确的队列:run.default_queue = "gpu-pool"(设置在 domainproduction)、run.max_action_concurrency = 64(实例级)。在production中创建的、未显式指定队列的运行落到gpu-pool;除非更具体的作用域覆盖,否则每次运行都被封顶为 64 个并发执行 action。
例 4 —— 显式置空继承值:实例级默认security.service_account = "default-runner",某个不受信任的项目必须不继承它。把该项目级值设为UNSET即可在不提供替代值的情况下阻断继承。
例 5 —— 按项目/域选择 PodTemplate:Flyte 1 支持隐式的按项目-域PodTemplate(任务 Pod 运行在各项目-域命名空间,命名空间感知的PodTemplateStore自动拾取各命名空间中的PodTemplate)。Flyte 2 将所有任务 Pod 运行在单一命名空间,该维度消失——目前只剩按任务(TaskMetadata.pod_template_name)与集群级(default-pod-template-name插件配置)两种选择。pod_template_name设置无需新机制即可恢复按作用域的选择能力:管理员在实例/域/项目级设置它,创建运行解析后把名称盖印到未自行指定模板的任务上,其余交给现有PodTemplateStore查找即可。这恰好覆盖了类型化设置覆盖不到的一切(tolerations、node selectors、sidecars、init containers……),因为它是引用完整的 Kubernetes API 而不是在 settings schema 里重复造轮子。
3 API 契约:四个 RPC 与作用域键
RFC 提议的唯一 schema 变更是在Settings上新增顶层StringSetting pod_template_name字段(与其他 Pod 级设置labels、annotations、environment_variables并列),外加make buf重新生成代码。其余一切已定义并完成代码生成。
3.1 服务接口
service SettingsService { rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse); // 合并后的有效值 rpc GetSettingsForEdit(GetSettingsForEditRequest) returns (GetSettingsForEditResponse); // 不合并,每个作用域层级一条记录 rpc CreateSettings(CreateSettingsRequest) returns (CreateSettingsResponse); rpc UpdateSettings(UpdateSettingsRequest) returns (UpdateSettingsResponse); // 通过 version 做乐观锁 }在 settings_service.proto 中,GetSettings与GetSettingsForEdit均标记idempotency_level = NO_SIDE_EFFECTS。SettingsKey{org, domain, project}通过填充哪些字段决定作用域:{}= 实例级,{domain}= 域级,{domain, project}= 项目级(GetSettingsRequest的注释明确:org 单独 = org 作用域、org+domain = domain 作用域、org+domain+project = project 作用域)。
关于org字段:OSS 部署没有组织概念;客户端留空org,服务端将其规范化为现有占位符DefaultOrganization = "flyte"(同 secret 与 app 服务使用的约定,参考 flyteplugins/go/tasks/pluginmachinery/secret/embedded_secret_manager.go)。实例级设置就存储在该占位符键下。客户流文档进一步明确:存储键忽略org,OSS 无组织概念,记录统一存为v1::{domain}:{project},无论客户端发送什么 org;请求键中的org仍会在响应中原样回显。
3.2SettingsRecord与版本语义
SettingsRecord承载"单个作用域层级的存储设置 + 键 + 版本":
GetSettingsForEdit响应中每个层级带版本,用于后续UpdateSettings的乐观锁;- 某个层级无记录时
version为 0(JSON 中省略),客户端据此应使用CreateSettings而非UpdateSettings; - 合并视图(
GetSettings)不携带 version:合并结果不对应任何单一存储行,需要版本做更新的客户端应使用GetSettingsForEdit。
4 存储设计:每作用域一行 + 稀疏 JSONB + 乐观锁
4.1 表结构与键编码
一个 Postgres 行对应一个作用域层级,遵循runs/repository/中既有的 sqlx + 手写 SQL 模式。仓库中的实际迁移文件 runs/migrations/sql/20260812100000_add_settings_table.sql 与 RFC 草案完全一致:
CREATE TABLE IF NOT EXISTS settings ( id BIGSERIAL PRIMARY KEY, key TEXT NOT NULL UNIQUE, data JSONB NOT NULL DEFAULT '{}', version BIGINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP );键的编码在 runs/repository/models/settings.go 的EncodeSettingsKey(domain, project string)中实现:格式为v1::{domain}:{project},空段保留,因此实例级键形如v1:::。RFC 草案中的注释"v1:{org}:{domain}:{project}"对应 OSS 下 org 恒为空的实际约定。
4.2 稀疏序列化:INHERIT 永不落库
data是Settingsproto 经 protojson 序列化的结果,且是稀疏的:状态为INHERIT的叶子在写入前被剪除(PruneInherited),读取时再水合(Hydrate)回显式的INHERIT。这带来一个可验证的效果:无论客户端是省略某设置还是发送空对象{},二者等价,都不会写入数据库。在 settings_service.go 的CreateSettings/UpdateSettings中,pruneSettings后执行protojson.Marshal再入库;settings_resolve.go 的decodeStored则用protojson.UnmarshalOptions{DiscardUnknown: true}反序列化,丢弃未知字段——由更新版本服务端写入的行,在旧版本服务端上仍能正常加载,为滚动升级提供了前向兼容。
4.3 乐观锁与一次查询解析
version实现乐观锁:UPDATE ... SET version = version + 1 WHERE key = $1 AND version = $2;影响 0 行即调用方竞争失败,必须重读(与现有 trigger 仓库同一模式)。仓库实现 runs/repository/impl/settings.go 中,缺失记录返回ErrSettingsNotFound、版本过期返回ErrSettingsVersionConflict,服务层分别映射为 Connect 的CodeNotFound与CodeFailedPrecondition。
解析一个键只需一次查询:构造至多 3 个祖先键,用WHERE key = ANY($1)一次取回;缺失的行就是全INHERIT。这正是 settings_resolve.go 中fetchLevels的职责:按 org → domain → project 的"键阶梯"构造层级键列表,一次GetSettingsByKeys取回行并按下标对齐(nil 表示该层级无记录)。
5 解析引擎:纯函数合并规则
解析引擎是纯函数,无需数据库即可独立测试。实现位于 runs/service/settings_merge.go:
- 标量(
StringSetting、Int64Setting、BoolSetting、QuantitySetting、AcceleratorSetting):按 instance → domain → project 迭代,最后一个状态非INHERIT的层级胜出(因此子级UNSET会阻断父级设置的值);胜者被标注其scope_level。通用mergeScalar[T]以泛型实现,setLevel回调负责盖印作用域。 - 字符串 map:父级优先加性合并、子级在 key 冲突时覆盖;某层级为
UNSET状态则清空此前累积的所有条目。scope_level记录贡献条目最具体的层级。 - 任务资源:按字段对 min/max 数量边界做标量合并。
mergeTaskResourceDefaults把四个维度(cpu/gpu/memory/storage)按层级重新分组,各自委托给mergeQuantitySettings,任一维度解析成功即返回结果;mergeTaskResourceSettings再按层级重组 min、max、mirror_limits_request与default_accelerator。加速器整体(设备、分区、类别)只来自单一层级,绝不混搭。 - 各分组合并器(
mergeRunSettings、mergeSecuritySettings、mergeStorageSettings、mergeAppSettings)均"无自有规则、只重组再委托";任一分组无任何层级设置时返回 nil,合并输出不携带空分组。
6 服务放置、校验与运行时的实际落地
6.1 Connect 处理器与注册
服务直接实现settingsconnect.SettingsServiceHandler(Connect-RPC,挂在共享的http.ServeMux上,镜像runs/service/project_service.go的ProjectService),并在runs/setup.go中注册。仓库 runs/setup.go 已包含完整接线:
settingsRepo := impl.NewSettingsRepo(sc.DB) settingsSvc := service.NewSettingsService(settingsRepo) settingsPath, settingsHandler := settingsconnect.NewSettingsServiceHandler(settingsSvc, connect.WithInterceptors(otelInterceptor)) logger.Infof(ctx, "Mounted SettingsService at %s", settingsPath)放在runs/下可以共享数据库、迁移、SetupContext与嵌入式 Postgres 测试脚手架。
6.2 手写校验(生成的 Validate() 不生效)
validation 在服务中手写实现——protoc-gen-validate 生成的Validate()不执行 settings protos 中使用的buf.validate注解,因此 settings_service.go 内的validateSettingsKey/validateSettings等自行检查:
- 键形状:org 为空默认
"flyte";项目必须伴随 domain(project != "" && domain == ""拒绝)。 - 数量:经
resource.ParseQuantity校验(validateQuantity逐维校验 cpu/gpu/memory/storage,错误消息带点路径如task_resource.max.memory)。 - 并发上限:
max_action_concurrency∈ {0} ∪ [2, MaxUint32]——上限为 1 会让任何含多于一个 action 的运行死锁(服务代码注释说明:0 表示不限,1 被拒绝;上限 MaxUint32 是因为解析后的值要应用到RunSpec.max_action_concurrency,它是 uint32)。 - 默认加速器:VALUE 状态的
default_accelerator设备名必须是core.AcceleratorModel的accelerator_name枚举值之一(canonicalAcceleratorNames从枚举 descriptor 一次性读取;与任务的gpu_accelerator不同——后者要兼容 SDK 写过的各种拼写,设置是新事物,只接受规范值)。
6.3 让设置生效:settings applier
在 runs/service/settings_apply.go 中,applyRunSettings展示了"显式用户值永远胜出"的原则——它只填补调用方留空的字段,且INHERIT/UNSET的设置不贡献任何值:
spec.Queue为空且run.default_queue为VALUE时填入默认队列;spec.MaxActionConcurrency == 0且设置解析出值时填入(proto 中 0 即"未设置",校验器已将值限制在 0 或 [2, MaxUint32],收窄到 uint32 不会溢出);- 任务未显式指定模板名时,把
pod_template_name盖印到spec.DefaultSettings.PodTemplateName(在 action 创建时投影到未命名自有模板的任务上)。
7 分阶段实施计划:可并行贡献的 ~15 个小 PR
RFC 将实现拆分为四个阶段,每个任务都是自带仓库内可复制模式的独立 PR;标注[independent]的任务不依赖同阶段其他任务。
Phase 1 —— 持久层(适合新手)
| # | 任务 | 可复制的模式 | 交付物 |
|---|---|---|---|
| 1.1 | [independent]迁移:settings表 | runs/migrations/sql/*.sql | 一个 SQL 文件(仓库中已落地,见 20260812100000_add_settings_table.sql) |
| 1.2 | [independent]模型 + 键编码器(v1::{domain}:{project},空 org 规范化为flyte) | runs/repository/models/project.go | models/settings.go+ 单元测试(已落地,见 models/settings.go) |
| 1.3 | Repo 接口(Create/Get/GetByKeys/Update)+ 哨兵错误(ErrSettingsNotFound、ErrSettingsVersionConflict) | runs/repository/interfaces/project.go | interfaces/settings.go(mockery 自动拾取) |
| 1.4 | sqlx 实现(含乐观锁Update) | runs/repository/impl/project.go;锁参考impl/trigger.go | impl/settings.go+ 嵌入式 Postgres 测试 |
| 1.5 | [independent]Proto:为Settings新增pod_template_nameStringSetting+ 重新生成(make buf) | settings_definition.proto现有字段 | 一个 proto 字段 + 生成的代码 |
Phase 2 —— 解析引擎 + 服务(核心)
| # | 任务 | 依赖 | 交付物 |
|---|---|---|---|
| 2.1 | [independent]Transformers:Settingsproto ⇄ 稀疏 protojson(PruneInherited/Hydrate) | — | repository/transformers/settings.go+ 表驱动测试 |
| 2.2 | [independent]合并引擎:标量合并、map 合并、任务资源合并、作用域标注 | — | 纯函数 + 覆盖每个SettingState组合的表驱动测试 |
| 2.3 | [independent]校验器:键形状、数量、并发边界 | — | 纯函数 + 测试 |
| 2.4 | Connect 处理器:CreateSettings、UpdateSettings | 1.x, 2.1, 2.3 | runs/service/settings_service.go(写半边) |
| 2.5 | Connect 处理器:GetSettings、GetSettingsForEdit(单次GetByKeys取数 + 合并) | 1.x, 2.1, 2.2 | 读半边 + 服务测试 |
| 2.6 | 在runs/setup.go注册 + 就绪 | 2.4, 2.5 | 3 行接线 +runs/test/api冒烟测试 |
Phase 2 退出标准:settings_customer_flow.md中的每个请求/响应示例都能在运行中的服务器上逐字节复现——该文档同时充当验收测试规范。仓库中已有 runs/test/api/settings_flow_test.go 针对运行中的服务器执行该文档的每个请求与响应,即文档由 CI 验证而非手工维护。
Phase 3 —— 让设置真正生效
目前还没有任何代码读取 settings;今天的等价旋钮来自静态配置。本阶段把解析接入运行创建路径:
| # | 任务 | 应用范围 |
|---|---|---|
| 3.1 | Settings applier 脚手架:每次运行创建解析一次,应用到 run/task spec | run.default_queue、run.max_action_concurrency |
| 3.2 | [independent]Pod 级设置 | environment_variables、labels、annotations、security.service_account |
| 3.2b | [independent]Pod 模板选择:把解析出的名称盖印到未显式指定pod_template_name的任务;其余交给PodTemplateStore | pod_template_name |
| 3.3 | [independent]任务资源边界 | task_resource.min/max、mirror_limits_request |
| 3.4 | [independent]存储设置 | storage.raw_data_path、run.run_base_dir |
| 3.5 | 优先级规则 + 文档:用户显式提供的 spec 值永远胜出 | 以上全部 |
Phase 4 —— 验证与文档(全部独立)
SDK 已支持编辑 settings,因此不需要新的客户端工作——只等服务端落地后的端到端验证:
| # | 任务 |
|---|---|
| 4.1 | 对现有 flyte-sdk settings 命令对新服务器做端到端验证;修复发现的缺口 |
| 4.2 | 面向用户的文档:作用域、继承、UNSET语义的概念页 |
8 完整调用链演练:从 GetSettingsForEdit 到 UpdateSettings
flyteidl2/settings/settings_customer_flow.md 提供了一个完整的客户流演练,这里摘录其关键 API 规则与流程要点:
API 规则要点:
- 省略某设置与发送
{}都表示 INHERIT,且都不存储; - 未带
state: SETTING_STATE_VALUE发送的值会被当作 INHERIT 忽略; GetSettings自上而下解析继承并返回一条合并记录,每个解析出的设置标注来源scopeLevel;SCOPE_LEVEL_ORG是枚举零值,JSON 中省略,因此缺失的scopeLevel表示值解析自 org;GetSettingsForEdit返回requestedKey+levels数组(每作用域层级一条,从最宽到最具体),每条为{key, settings, version};无存储记录的层级 settings 为空、version 为 0(JSON 省略),缺失的version字段即"尚无记录,请对该层级使用CreateSettings";- map 设置在
GetSettings上加性合并:父条目在前、子条目盖在其上(key 冲突子胜),UNSET层级清除其上方累积的一切;合并 map 的scopeLevel是贡献条目最具体的层级。
流程五步走:
- GetSettings(org 级):org 无父级可继承,每个设置要么有值要么不出现在响应中;三个值都解析自 org,故无
scopeLevel、无version(合并记录永不携带)。 - 更新项目设置:先
GetSettingsForEdit拿各层级存储态(domain 无记录 → 空 settings、无 version,即用CreateSettings的信号;project 条目带version: 1供更新使用),再UpdateSettings(version 1 → 2;defaultQueue空对象被剪除,不出现于存储行与回显)。 - GetSettings(项目级,无 domain 记录):org → project 两级解析;
defaultQueue解析自 org(SCOPE_LEVEL_ORG零值省略),taskResource.min.cpu来自项目覆盖(标注SCOPE_LEVEL_PROJECT),environmentVariables加性合并 org+project(LOG_LEVEL项目胜出)。 - CreateSettings(domain 级):
production域尚无记录,创建defaultQueue=fast-queue;空的 CPU 与 env-var 对象被剪除,仅defaultQueue落库,version 1。 - GetSettings(项目级,domain 已建):解析链变为 org → domain → project;
defaultQueue现解析为 domain 的fast-queue并首次出现非零scopeLevel: SCOPE_LEVEL_DOMAIN;CPU 与环境变量不变。
关键洞察:某一作用域的变更在下一次GetSettings调用时对全部子作用域立即可见——新增 domain 设置时无需触碰任何项目级记录。
9 结论与延伸阅读
API 已在四种语言中完成设计、评审、合并与代码生成;仓库中已存在的存储与服务模式恰好满足其需求。剩余的是边界清晰、可切成约 15 个小 PR 的实现工作,其中大部分相互独立——既是新贡献者良好的上手入口,又交付了长期被请求的能力:集中、分层、类型化的运行时配置。
仓库中已落地的实现与测试包括:迁移 runs/migrations/sql/20260812100000_add_settings_table.sql、模型 runs/repository/models/settings.go、仓库接口 runs/repository/interfaces/settings.go、实现 runs/repository/impl/settings.go、解析引擎 runs/service/settings_merge.go 与取数 runs/service/settings_resolve.go、服务 runs/service/settings_service.go、应用器 runs/service/settings_apply.go、端到端流测试 runs/test/api/settings_flow_test.go 及服务测试 runs/service/settings_service_test.go。对作用域语义、UNSET行为与四 RPC 的逐字节复现验证,可继续阅读 settings_customer_flow.md。
- 后端
- 任务调度
- 工作流自动化
- 云原生
- MLOps
- 微服务
【免费下载链接】flyte
Dynamic, resilient AI orchestration. Coordinate data, models, and compute as you build AI workflows.
相关推荐
DDDSample架构揭秘:三层架构与领域模型的最佳实践
DDDSample架构揭秘:三层架构与领域模型的最佳实践 领域驱动设计(DDD)是现代软件开发中的重要方法论,而DDDSample项目正是这一理念的完美实践。本
后端DeepSeek Harness Web 配置平面解析:settings/credentials/llm 三域 RPC、分层脱敏与 Models 配置页实战
DeepSeek Harness Web 配置平面解析:settings/credentials/llm 三域 RPC、分层脱敏与 Models 配置页实战 本
人工智能AI AgentAgent 框架DeepSeekFirst Contributions 实战指南:Git 身份配置的三层作用域——global、仓库级与命令行配置详解
First Contributions 实战指南:Git 身份配置的三层作用域——global、仓库级与命令行配置详解 在 first contribution
文档教程开源治理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考