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

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

面向用户的配置访问器的高级理念和要求。

问题
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 层次结构。这样,只需添加最少的代码,即可将配置纳入崩溃报告中,而无需强制组件框架将其所有配置值存储在内存中,从而避免复制组件自己的副本。

除了生成检查输出之外,另一种方法是在组件管理器的检查中公开配置。

人体工学

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

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

熟悉

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

语法

请看以下使用 RFC-0146 语法的 config stanza:

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 中构建访问器支持时,我们将确保用户可以配置包含目录布局,以符合他们拥有的任何样式指南。

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 实现。

实现

版本控制

访问器将从组件框架接收配置值,其中包含对其编码的配置架构的校验和(有关背景信息,请参阅 CML 中的配置 RFC,未来的 RFC 将指定交付机制)。访问器必须检查收到的校验和是否与生成访问器时使用的校验和完全一致,如果不一致,则中止组件。这样可以防止误解载荷,并作为最终保护措施来防止错误打包从不同架构编译的组件二进制文件和/或清单。

此设计的一个隐含意义是,当组件的配置架构发生更改时,需要重新编译组件。

一种被拒绝的替代方案是让 runner 与 Component Manager 协作,在启动组件之前验证校验和。

库内部

根据 RFC-0127,结构化配置载荷将编码为持久性 FIDL 消息,并以结构作为主要对象(更多详细信息将在未来的 RFC 中记录)。

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

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

我们已考虑并拒绝了以下替代方案

命名

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

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

生成的结构体将命名为 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++ 驱动程序与由 ELF 运行程序直接运行的 C++ 组件具有不同的访问器库,即使它们使用相同的语言也是如此。

语言支持

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

依赖项

对于生成的库,每个依赖项都代表着对 OOT 集成者的税收,应尽可能避免。

树外支持

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

性能

解析结构化配置载荷可能会增加组件启动时间的一些额外开销,在组件之前直接将配置值编译到其二进制文件中的情况下,影响最为显著。我们预计无论采用哪种方式,都不会对用户造成影响,因为配置解析预计是组件首次启动时的一次性操作。

通过在内部重复使用由 FIDL 工具链生成的解析器(其性能已持续进行基准测试),可以部分缓解这种潜在影响。

我们将监控 TimeToStart 性能指标。

向后兼容性

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

安全注意事项

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

我们希望提供不可靠且不可为 null 的访问器,以阻止在代码中使用默认配置值,从而更轻松地审核运行时执行的实际值,并减少可能出现的错误配置和后续攻击的范围。

隐私注意事项

未来对结构化配置的扩展可能需要进行更改才能支持使用 PII,但我们预计不会有任何配置访问器需要注意的更改。

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

测试

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

对于结构化配置,使用访问器库将通过多语言一致性测试来涵盖,确保配置可以由组件加载、解析、编码、交付和解析,该组件会将结果报告回一致性套件。

文档

在结构化配置的功能文档和 Codelab 中,将提供每种受支持语言的访问器库示例。

缺点、替代方案和未知因素

替代方案:在 Component Manager 的 Inspect 中公开配置

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

替代方案:使用全局变量进行访问

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

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

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

不过,许多语言都不鼓励使用全局变量/静态变量(除非必要),而选择这种风格的 API 会迫使开发者使用它们,即使并非绝对必要。各个组件的开发者仍然可以选择将生成的访问器结果封装在全局变量中。

此外,某些环境(例如当前的驱动程序框架)不鼓励或不允许使用具有隐式初始化的全局变量。生成的配置访问的样式应尽可能让不同环境中的开发者感到熟悉,并且使用函数调用意味着这些不同环境只会因需要额外的参数而有所不同。

替代方案:基于 Runner 的验证

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

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

我们预计无法验证校验和的情况不会很常见,因此提前将错误从校验和转移到其他位置的价值有限。我们还可以选择以产品组装工具可以验证的方式在二进制文件中包含访问器库元数据,这样我们就可以在组件开发生命周期的更早阶段发现这些错误。实现预处理创建验证的复杂性较高,改进体验的预期价值较低,并且有机会通过平台外的工具更早地发现这些错误,这表明在我们获得改变我们对这些权衡取舍的看法的新信息之前,我们不应追求此功能。

替代方案:将校验和和检查支持细化为 FIDL 功能

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

从长远来看,这种方法很有前景,CF 和 FIDL 团队将努力使各自的技术更加紧密地结合起来,这是围绕此 RFC 和其他主题进行讨论的结果。具体而言,我们将讨论以下内容:

  • 为生成的 FIDL 代码设计专用或“组件本地”命名空间,这样我们就可以生成具有额外依赖项的代码,而不会影响常规 IPC 使用情形,同时还可以解决与命名和平台版本控制相关的问题
  • FIDL 中用于“同步版本控制”的选项,可提供相关工具来防止组件清单和实现二进制文件之间出现偏差
  • 为 FIDL 类型生成额外的 Inspect 调试代码

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

替代方案:在 FIDL 绑定和可重用的运行时库中提供支持

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

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

此选项的优势在于,它可以减少我们执行的代码生成“风格”的数量,因为无需任何代码生成器了解每个运行时用于传递配置的方法,这些知识始终可以存在于可重用的库中。它还具有以下优势:可减少将结构化配置与 OOT 构建系统集成所需的工作量,因为现有构建系统已集成 FIDL 工具链。

不过,此选项存在一个重大缺点。FIDL 工具链目前无法将绑定中的功能标记为不稳定或实验性。绑定发出的所有代码都具有相同的稳定性需求,这意味着向可供 SDK 使用的 C++ 后端添加结构化配置属性后,所有 SDK 客户都可以立即使用这些属性。这与我们逐步推出结构化配置的目标相冲突,因为我们希望在继续向用户学习的同时,尽可能保留修改 API 的能力。

此外,目前尚不清楚 FIDL 工具链生成依赖于更高级别 Fuchsia 概念(例如 Inspect)的绑定层是否合理,因为当前生成的绑定本身就用于实现这些更高级别的概念。

替代方案:用于配置的 fidlgen 后端

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

不过,为了避免向用户公开多个生成的命名空间,我们需要在单个命名空间内同时发出配置支持代码和 FIDL 网域对象。生成的库无法与同一库的基本/非配置 FIDL 绑定链接到同一二进制文件中。实际上,我们不希望用户尝试为给定库同时生成“配置感知”和“基本”FIDL 绑定,但这会为 FIDL 代码生成器带来新的限制,要么防范该使用情形,要么围绕该使用情形进行设计。我们认为,在更广泛地协调 CF 和 FIDL 技术之前,此解决方案只是临时性的。我们倾向于承担技术债务,以获得更大的设计和实现自由度,同时不会给 FIDL 团队带来潜在的维护风险。

替代方案:通过其他层公开 FIDL 库

我们可以生成一个“基础”FIDL 绑定,该绑定具有与当前所有绑定相同的内容,然后生成一个额外的“层”配置支持代码,该代码知道如何从组件的运行时检索该 FIDL 类型。这样可以实现我们的实现目标,即在 FIDL IPC 和其他问题之间实现清晰的分离,但会让用户接触到两个生成的命名空间,而不会给他们带来任何额外的好处。

替代方案:生成不含 FIDL 依赖项的库

我们可以考虑完全不依赖 FIDL,并生成自己的解析器。如果我们发现无法将用户与访问器库生成的 FIDL 依赖项隔离开,那么这种方法会更具吸引力。

在先技术和参考资料

这方面的工作在许多方面都与 FIDL 类似,因此读者如果了解如何访问 Fuchsia 的二进制格式,将有助于理解本 RFC。

Fuchsia 有许多手写配置访问器的示例,例如 fshost 的配置类,该类在切换到结构化配置的早期原型之前,用于解析自定义的以换行符分隔的格式。

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