RFC-0158:结构化配置访问器

RFC-0158:结构化配置访问器
状态已接受
领域
  • 组件框架
说明

面向用户的配置访问程序的简要原则和要求。

问题
  • 95371
Gerrit 更改
作者
审核人
提交日期(年-月-日)2021-12-20
审核日期(年-月-日)2022-05-04

总结

面向用户的生成访问器库(为结构化配置实现 RFC-0127)的要求、设计理念和简要实现详情。

设计初衷

此 RFC 基于结构化配置 RFC 中面向用户的 API 的非规范性示例构建而成,概述了开发者用于访问其配置的所生成库的设计原则和限制条件。

利益相关方

教员:hjfreyer@google.com

审核者

  • geb@google.com(组件框架)
  • jsankey@google.com(RFC-0127 作者)
  • yifeit@google.com (FIDL)

咨询人员:xbhatnag@google.com、hjfreyer@google.com、jamesr@google.com

社交:此 RFC 是组件框架团队与 FIDL 团队的成员之间展开讨论的产物,旨在解答在对 RFC-0127 的初始阶段进行原型设计时出现的问题。

设计

RFC-0127 草拟了一个面向用户的界面,以便在代码中使用配置值。它提供特定于组件配置的静态类型的生成库,并且具有单个入口点,该入口点保证返回有效配置。

目标

我们针对访问器设定了几个目标:它们应传达不故障性、在替换方面不透明、可调试,并提供 Fuchsia 开发者所熟悉的类型接口,并尽可能重复使用现有工具。

正确

组件作者必须能假设值始终可用,并且始终填充所有字段。配置字段的可为 null 性应与输入声明一致(在写入可为 null 的字段时,配置架构不支持该字段),并且访问器函数应该绝对可靠,即不会返回错误或抛出异常。如果访问器收到无法解析的配置载荷,则应快速失败并终止组件的执行(例如 abort())。

不透明

根据 RFC-0127,组件框架最终将提供用于在运行时替换组件配置值的 API。从作者的角度看,解析后的类型并未描述可能已发生的任何替换项。生成的库不负责处理替换项。

可调试

访问者应提供一个选项,用于将组件的配置输出为检查层次结构。这样一来,您只需添加最少的代码即可将配置包含在崩溃报告中,而不会强制组件框架将所有配置值存储在其内存中,从而复制组件自己的副本。

除了生成 Inspect 输出外,您还可以选择在组件管理器的 Inspect 中提供配置。

人体工学

生成的库应该有一个包含组件的所有配置字段的顶级类型。它应该由单个顶级访问器函数返回。

用户应仅与其可以控制的单个生成的库的命名空间或库名称交互。

熟悉

FIDL 绑定的用户应该对为配置架构生成的类型感到熟悉。

语法

考虑使用 RFC-0146 中的语法此 config 节:

config: {
    check_interval_ns: { type: "int64" },
    data_path: {
        type: "string",
        max_size: 256,
    },
    test_only: { type: "bool" },
},

构建模板

fuchsia.git 中,访问器必须在定义配置键的组件所在的 build 文件中定义。例如,对于 Rust 二进制文件:

import("//build/components.gni")
import("//build/rust/rustc_binary.gni")

fuchsia_component("my_component") {
  manifest = "meta/my_component.cml"
  deps = [ ":my_bin" ]
}

fuchsia_structured_config_rust_lib("my_component_config") {
  library_name = "my_component"

  # NOTE: This target internally depends on a target which is generated by the
  # fuchsia_component() template, so the graph is not cyclic:
  #
  # :my_component -> :my_bin -> :config_lib -> :my_component_manifest_compile
  #
  # where :my_component_manifest_compile does not depend on :my_component
  component = ":my_component"

  # By default all fields are included, this Inspect won't have `data_path`
  inspect_skip_fields = ["data_path"]
}

rustc_binary("my_bin") {
  sources = [ "src/main.rs" ]
  deps = [ ":my_component_config" ]
}

fuchsia_structured_config_values("my_config_values") {
  component = ":my_component"
  values_source = "config/my_component.json5"
}

fuchsia_package("my_package") {
  deps = [
    ":my_component",
    ":my_config_values",
  ]
}

当结构化配置可供 OOT 使用时,我们将为树外客户开发类似的 build 集成。

C++

#include "src/my_project/my_component/config.h"

int main(int argc, char** argv) {
  auto config = my_component::Config::TakeFromStartupHandle();

  auto context = sys::ComponentContext::CreateAndServeOutgoingDirectory();
  auto inspector_ = std::make_unique<sys::ComponentInspector>(context);
  config.RecordInspect(inspector_.GetRoot().CreateChild("config"));

  if config.test_only() {
    // ...
  }

  std::ifstream file(config.data_path());
  // ...

  while (true) {
    // ...
    std::this_thread::sleep_for(
      std::chrono::nanoseconds(config.check_interval_ns()));
  }
}

对于驱动程序,将需要启动参数以避免进程全局依赖项:

#include "src/my_project/my_component/config.h"

zx_status<> Init(fdf::wire::DriverStartArgs& start_args, /*...*/) {
  auto config = my_component::Config::TakeFromStartArgs(start_args);
  // ...
}

fuchsia.git 中的构建模板会将头文件放置在 target_gen_dir 中,并将其添加到库的 include_dirs 中,以便用户可以将生成的头文件添加为其组件的实现头文件

在 SDK 中构建访问器支持时,我们将确保用户可以配置 include 目录布局,使其与其拥有的任何样式指南相匹配。

Rust

#[fuchsia::component]
async fn main() {
    let inspector = fuchsia_inspect::component::inspector();
    let config_node = inspector.root().create_child("config");

    let config = my_component::Config::take_from_args();
    config.record_inspect(&config_node);

    if config.test_only {
        // ...
    }

    let contents = std::fs::read(&config.data_path).unwrap();
    let mut interval = Interval::new(Duration::from_nanos(config.check_interval_ns));
    let _checker = Task::local(async move {
        while let Some(()) = interval.next().await {
            // ... use `contents` ...
        }
    });

    // for completeness, set up an /out directory for our inspect and serve it
    let mut fs = ServiceFs::new_local();
    inspect_runtime::serve(inspector, &mut fs).unwrap();
    fs.take_and_serve_directory_handle().unwrap();
    while let Some(()) = fs.next().await {}
}

请注意,此示例使用异步和 VFS 来演示传送 Inspect 的功能,但执行器和 VFS 实现不仅仅是访问结构化配置值所必需的。

实现

版本控制

访问者将从组件框架接收配置值,以及编码时所针对的配置架构的校验和(如需了解背景信息,请参阅 RFC 中关于配置的 RFC 了解背景信息,未来的 RFC 将会指定传送机制)。访问器必须检查收到的校验和是否与生成访问器的校验和完全匹配;如果不匹配,访问器会中止组件。这样可以防止误解载荷,从而起到最后的防护措施,防止从不同架构编译的组件二进制文件和/或清单被错误打包。

这种设计意味着,当组件的配置架构发生更改时,需要重新编译组件。

遭到拒绝的替代方案是让运行程序与组件管理器合作,以在启动组件之前验证校验和。

库内部设计

根据 RFC-0127,结构化配置载荷将被编码为永久性 FIDL 消息,并使用结构体作为主要对象(有关详情,将在未来 RFC 中记录)。

我们将生成用于解析编码消息的 FIDL 库,还会生成特定于运行时的小型封装容器库,这些库知道如何

  1. 从特定于语言或运行程序的运行时中检索已编码的消息
  2. 根据组件清单中的校验和检查已编码消息的校验和
  3. 调用 FIDL 绑定的解码功能
  4. 将解码后的 FIDL 域对象转换为生成的类型

我们已考虑并拒绝

命名

生成的封装容器库将包含面向用户的预期 API,用于访问配置。默认情况下,封装容器的命名空间与生成它的 GN 规则的目标名称相同,并可使用 library_name 参数进行替换。

生成的 FIDL 库将从 build 模板中接收其名称,默认情况下,是移除了下划线的 GN 目标名称,并会附加到平台名称(树内组件为 fuchsia.)。例如,在上面的语法代码段中,GN 模板调用会创建一个名称为 fuchsia.mycomponent 的 FIDL 库。

生成的结构体将命名为 Config。如果它是普遍请求的功能,我们可能允许用户替换该结构体。

配置字段标识符规则意味着 CML 中的所有配置键都是有效的 FIDL 字段标识符,无需进行任何修改。

生成的 FIDL 库

每个结构化配置架构都会编译为一个 FIDL 结构体,该结构体将转换为结构化配置封装容器中定义的类型。用户不会看到 FIDL 工具链生成的类型。

对于上面的语法示例,应如下所示:

library fuchsia.mycomponent;

type Config = struct {
    check_interval_ns int64;
    data_path string:256;
    test_only bool;
};

生成的封装容器代码

每个封装容器都将包含一个与生成的 FIDL 网域对象的类型对应的类型,但包含一个用于从组件运行时检索配置的附加工厂函数,以及一个用于记录要检查的配置的方法。

生成的 C++ 库将提供一个让统一 C++ FIDL 绑定中的自然类型用户感觉熟悉的接口。例如,上面的语法示例将用 C++ 生成以下代码:

namespace my_component {
class Config {
public:
  static Config TakeFromStartupHandle() noexcept;
  void RecordInspect(inspect::Node* node);

  const uint64_t& check_interval_ns() const { return check_interval_ns_; }
  uint64_t& check_interval_ns() { return check_interval_ns_; }

  const std::string& data_path() const { return data_path_; }
  std::string& data_path() { return data_path_; }

  const bool& test_only() const { return test_only_; }
  bool& test_only() { return test_only_; }

private:
  // ...
};
};

在 Rust 中,我们将生成一个类似于单一绑定变种的类型。上面的语法示例将为 Rust 组件生成以下代码:

pub struct Config {
    pub check_interval_ns: u64,
    pub data_path: String,
    pub test_only: bool,
}

impl Config {
  pub fn take_from_args() -> Self { ... }
  pub fn record_inspect(&self, node: &fuchsia_inspect::Node) { ... }
}

我们将为每种语言或运行时环境生成一个访问器库“变种”,这些库使用不同的方法提供编码的配置(运行程序实现的详细信息将在未来的 RFC 中得到批准)。例如,C++ 驱动程序的访问器库与 C++ 组件直接由 ELF 运行程序运行的访问器库不同,即使它们使用相同语言也是如此。

语言支持

最初,我们将支持 C++ 和 Rust。随着时间的推移,我们将涵盖所有目标端支持的语言

依赖项

所生成库的每个依赖项都代表对 OOT 集成器的税费,应尽可能避免。

树外支持

树外客户最终将需要生成自己的配置访问器,类似于 FIDL 工具链与 petals 的 build 集成的方式。

性能

解析结构化配置载荷可能会给组件的启动时间增加一些额外的开销,在组件之前将配置值直接编译为二进制文件的情况下,这种影响最为显著。我们预计不会出现用户可见的影响,因为配置解析预计是在组件首次启动时执行的一次性操作。

通过在内部重复使用由 FIDL 工具链生成的解析器,可以持续对其性能进行基准化分析,从而在一定程度上减轻这种潜在影响。

我们会监控TimeToStart的效果指标。

向后兼容性

配置访问器库是从组件的已编译清单生成,并将嵌入与清单相同的配置架构校验和。我们不支持组件的配置架构、其值文件及其访问器库之间的版本偏差。将来,整体组件框架可能会支持配置值的演变,但从组件的角度来看,它始终会获得完整且一致的配置。

安全注意事项

组件应根据其声明的架构,确认其收到的配置载荷的格式是正确的,因为组件框架仅在能够提供兼容的配置时才能启动该组件。此分辨率和编码的详细信息将在后续 RFC 中定义。

我们希望提供绝对可靠的、不可为 null 的访问器,避免在代码中使用默认配置值,从而更轻松地审核在运行时执行哪些实际值,并缩小可能的错误配置和后续攻击的表面积。

隐私注意事项

将来对结构化配置进行扩展时,可能需要进行更改以支持使用个人身份信息,但我们预计对于哪些配置访问器需要知悉的变化,我们预计不会做出任何更改。

无需在生成的检查辅助程序中隐去用户数据,因为 Archivist 在读取值时会执行隐去操作。使用结构化配置的用户需要将其配置字段添加到产品的选择器许可名单中,这样他们才能在崩溃报告中显示。

测试

为了支持编写测试的开发者,应该可以在代码中构建配置对象。

针对结构化配置的多语言一致性测试将涵盖访问器库的使用,这可确保配置可由将结果报告给一致性套件的组件加载、解析、编码、传递和解析。

文档

我们会在结构化配置的功能文档和 Codelab 中记录访问器库以及每种受支持语言的示例。

缺点、替代方案和未知情况

替代方案:在组件管理器的 Inspect 中提供配置

我们可以将已解析的配置值添加到组件管理器的 Inspect 输出中,而不是在访问器库中生成检查代码。通过 CPU 统计信息执行此操作的先例性,但跟踪此功能的资源使用情况很难,我们更倾向于使用一种解决方案,将内存使用情况“计费”到具有配置值的组件。

替代方案:用于访问的全局变量

我们可以考虑使用访问器 API,用于向已解析配置的单个全局实例提供访问权限。例如,在 Rust 中:

static CONFIG: Lazy<Config> = Lazy::new(|| /* ...retrieve from runner... */);

从资源经济的角度来看,使用配置值的单个全局实例很有吸引力,因为它使用语言功能来确保一次仅将组件配置的一个副本实例化到内存中。它还从概念上反映了开发者通常对其配置的看法。

不过,除非必要,否则许多语言不建议使用全局变量/静态变量,选择这种类型的 API 会迫使开发者使用,即使并非绝对必要。各个组件的开发者仍然可以选择将生成的访问器结果封装在全局变量中。

此外,某些环境(例如当前驱动程序框架)不鼓励或禁止通过隐式初始化使用全局变量。所生成的配置访问权限的样式对不同环境的开发者应尽可能熟悉,使用函数调用意味着这些不同的环境仅在需要额外参数方面有所不同。

替代方案:基于运行程序的验证

对于基于 ELF 的组件,可以将配置校验和包含在自定义标头中,该标头可由运行程序借助加载器服务进行验证。这样一来,组件框架便可以在执行组件代码之前验证已编译的访问器是否与编码值匹配,从而可以直接向日志报告错误,而无需依赖于组件在尝试读取配置之前已正确配置其输出。也就是说,我们预计,在使用 SDK 中的工具打包组件时,会很少出现校验和不匹配问题,而在加载器和运行程序中设计并实现一个更快失败的校验和验证步骤将耗费更多精力。

用于在创建进程之前验证配置校验和的设计需要处理使用单个二进制文件为具有不同配置接口的多个组件的情况。

我们预计验证校验和失败会很常见,因此提前移动错误的价值将受到限制。我们还可以采用产品组合工具可以验证的方式,在二进制文件中包含访问器库元数据,这样我们就能在组件开发生命周期的更早阶段发现这些错误。实现预处理创建验证的过程非常复杂,改善体验的预期价值较低,并且有机会通过平台外部工具尽早改变这些错误,这表明,在获得新信息来改变我们对这些权衡的态度之前,我们不应继续采用此功能。

替代方案:优化校验和和检查 FIDL 功能的支持

我们可以决定对结构化配置所需的类型哈希和调试功能进行泛化,并将其提供给 FIDL 的所有用户。

从长远来看,这种方法非常有前景,根据有关此 RFC 和其他主题的讨论,CF 和 FIDL 团队将努力使其技术更紧密地协调一致。具体而言,我们将讨论以下内容:

  • 为生成的 FIDL 代码设计专用命名空间或“组件本地”命名空间,这样我们就可以生成具有其他依赖项的代码,而不会影响一般 IPC 用例,还可以解决命名和平台版本控制方面的问题
  • FIDL 中用于“锁步版本控制”的选项,用于防止组件清单之间发生偏差并实现二进制文件
  • 针对 FIDL 类型生成额外的检查调试代码

FIDL 方面支持这些概念所需的工作将需要一些时间,我们选择不阻止其上的结构化配置访问器。我们预计未来的 RFC 会描述 FIDL 的这些功能以及其他组件感知功能,并在必要时将结构化配置用户迁移到新的访问器 API。

替代方案:支持 FIDL 绑定和可重复使用的运行时库

我们可以修改 FIDL 后端以发出其他代码来支持结构化配置用例。这可以通过传递给后端的标志(例如 --enable-config-codegen)或通过传递给后端的自定义属性(例如 @structured_config_checksum()@structured_config_inspect())来完成。

这样一来,我们就可以发出用于校验和验证和检查调试的代码,然后从知道每个运行时向组件传递编码配置的方法的可重用库中调用该功能。

此选项的优势在于可以减少我们执行的 Codegen 的“变种”数量,因为代码生成器不需要知道每个运行时用于提供配置的方法 - 这些信息始终存在于可重复使用的库中。它还的优势在于,可以减少将结构化配置与 OOT 构建系统集成的工作量,因为现有配置已经集成了 FIDL 工具链。

不过,这种方法有一个明显的缺点。FIDL 工具链目前无法将绑定中的功能标记为不稳定或实验性。该绑定发出的所有代码都具有相同的稳定性需求,这意味着所有 SDK 客户都可以立即向提供 SDK 的 C++ 后端添加结构化配置属性。这与我们逐步发布结构化配置的目标相冲突,使我们能够在继续向用户学习的同时,尽可能多地修改 API。

此外,当当前生成的绑定本身用于实现这些更高级别的概念时,FIDL 工具链生成依赖于较高级别的 Fuchsia 概念(如 Inspect)的绑定层还不明确。

替代方案:使用 fidlgen 后端进行配置

我们可以定义单独的后端来生成结构化配置支持代码。这样,我们就可以实现与当前 fidlgen 后端不同的稳定性属性,并在生成的访问器中包含其他依赖项。

不过,为了避免将用户暴露于生成的多个命名空间,我们需要在单个命名空间中同时发出配置支持代码和 FIDL 网域对象。这个生成的库无法关联到与同一库的基本/非配置 FIDL 绑定相同的二进制文件。在实践中,我们并不期望用户尝试为给定库同时生成“配置感知”和“基本”FIDL 绑定,但会对 FIDL 代码生成工具产生新的约束,以防范这种用例或围绕该用例进行设计。我们认为,在我们更广泛地协调 CF 和 FIDL 技术之前,此解决方案只是暂时的。我们希望承担能够提供更大的设计和实现自由度的技术债务,而不会给 FIDL 团队带来潜在的维护风险。

替代方案:公开具有额外层的 FIDL 库

我们可以生成一个与当前所有绑定具有相同内容的“基本”FIDL 绑定,然后生成另一个“配置支持代码层”,这些代码知道如何从组件的运行时检索该 FIDL 类型。这可以实现我们在 FIDL IPC 和其他关注点之间完全分离的实现目标,但会导致用户看到两个生成的命名空间,而不会带来任何额外的好处。

替代方案:生成没有 FIDL 依赖项的库

我们可以考虑没有任何 FIDL 依赖项,并生成我们自己的解析器。如果我们发现无法将用户隔离到访问器库生成的 FIDL 依赖项中,这种方法更具吸引力。

早期技术和参考资料

此处的工作在许多方面与 FIDL 类似,并且阅读此 RFC 的读者了解访问 Fuchsia 的二进制格式将大有裨益。

Fuchsia 提供了一些用于配置的手写访问器示例,例如 fshost 的配置类,它用于在切换到结构化配置的早期原型之前解析自定义的换行符格式。

有许多特定于语言的库为动态“配置接口”(例如 argv 或 JSON 配置文件)提供“类型化解析器”。在 fuchsia.git 中,argh 是用于解析 argv 的热门 Rust 库,而 serde/serde_json 通常以类似方式从 bootfs 和软件包中解析配置文件。