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

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

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

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

摘要

面向用户生成的访问器库的相关要求、设计理念和高级实现细节,这些库实现了适用于结构化配置的 RFC-0127

设计初衷

本 RFC 基于结构化配置 RFC 中面向用户的 API 的非规范示例,概述了开发者将用于访问其配置的生成的库的设计原则和约束条件。

利益相关方

主持人:hjfreyer@google.com

Reviewers:

  • 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 输出的替代方法是在 Component Manager 的 Inspect 中公开配置。

Ergonomic

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

用户应仅与单个生成的库的命名空间或库名称进行交互,这些命名空间或库名称由用户控制。

熟悉

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

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 将指定传送机制)。访问器必须检查收到的校验和是否与生成访问器时使用的校验和完全匹配,如果不匹配,则中止该组件。这有助于防止误解载荷,可作为最终防护措施,防止以不同的架构编译的组件二进制文件和/或清单被错误打包。

这种设计的一个含义是,当组件的配置架构发生变化时,需要重新编译组件。

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

库内部

根据 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++ 驱动程序的访问器库与直接由 ELF 运行程序运行的 C++ 组件不同,即使它们使用的是相同的语言。

语言支持

最初,我们将支持 C++ 和 Rust。我们会逐步涵盖所有目标端支持的语言

依赖项

生成的库的每个依赖项都对 OOT 集成商造成了负担,因此应尽可能避免。

树外支持

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

性能

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

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

我们将监控 TimeToStart 性能指标。

向后兼容性

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

安全注意事项

组件应信任其收到的配置载荷是否符合其声明的架构,因为组件框架必须仅在能够提供兼容的配置时启动组件。此分辨率和编码的详细信息将在后续的 RFC 中定义。

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

隐私注意事项

未来对结构化配置的扩展可能需要进行更改,以支持处理个人身份信息 (PII),但我们预计不需要对需要了解哪些配置访问器进行任何更改。

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

测试

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

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

文档

我们将在结构化配置的功能文档和 Codelab 中为每个受支持的语言提供示例,以便记录访问器库。

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

替代方案:在组件管理器的“检查”中公开配置

我们可以将解析后的配置值添加到组件管理器的 Inspect 输出中,而不是在访问器库中生成 Inspect 代码。我们之前曾针对 CPU 统计信息执行过此操作,但跟踪此功能的资源使用情况很困难,因此我们更倾向于使用一种解决方案,将内存用量“结算”给具有配置值的组件。

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

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

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

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

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

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

备选方案:基于运行程序的验证

对于基于 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 工具链,因此可以减少将结构化配置与 OOT 构建系统集成的努力。

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

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

替代方案:用于配置的 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 和软件包中的配置文件。