配置加载与表达式求值:从 HCL 到 cty.Value
前置知识
- ›第 1 篇:架构概览与代码库导航
- ›第 2 篇:图引擎与 DAG 遍历(理解求值时机)
- ›具备基本的 HCL 语法知识
配置加载与表达式求值:从 HCL 到 cty.Value
在本系列前几篇文章中,我们将配置视为一个已知的前提条件——它在图构建和遍历之前就已存在。但从磁盘上的 .tf 文件,到最终驱动 provider 调用的 cty.Value,中间其实经历了一套精密的处理流水线:通过虚拟文件系统抽象进行 HCL 解析、组装模块树、用丰富的 Go 类型模型表示所有配置结构,以及最关键的一点——在图遍历期间惰性求值表达式,而非在解析阶段就完成求值。
正是这种惰性求值机制,让 Terraform 的依赖系统得以运转:资源的配置在解析时会立即生成 AST,但配置中各表达式的值要等到图遍历到该资源节点时才会被解析——而此时,所有上游依赖都已经完成了求值。
Parser:HCL 与虚拟文件系统
配置加载从 internal/configs/parser.go#L20-L30 中的 Parser 开始:
type Parser struct {
fs afero.Afero
p *hclparse.Parser
allowExperiments bool
}
这里使用 afero.Afero 虚拟文件系统抽象是经过深思熟虑的设计决策。它让测试时可以使用内存文件系统,可以从归档文件(plan 文件中内嵌了配置)加载配置,也为将来支持远程配置源留有余地——而这一切都无需改动任何解析逻辑。
flowchart TD
FS["Filesystem (afero)"] -->|"ReadFile"| Parser["configs.Parser"]
Parser -->|".tf files"| HCLNative["hclparse.ParseHCL"]
Parser -->|".tf.json files"| HCLJSON["hclparse.ParseJSON"]
HCLNative --> Body["hcl.Body"]
HCLJSON --> Body
Body --> Decode["Decode into Go structs"]
Decode --> File["configs.File"]
File -->|"multiple files"| Module["configs.Module"]
实际的 HCL 解析工作由 hclparse.Parser 完成。第 59–80 行的 LoadHCLFile 方法根据文件扩展名判断格式——.json 文件使用 HCL 的 JSON 语法,其他文件使用原生 HCL 语法。这种双格式支持正是 Terraform 既能用原生 HCL 也能用 JSON 进行配置的原因——解析器会将两者统一规范化为同一种 hcl.Body 表示。
allowExperiments 标志控制实验性语言特性的访问权限。在构建 alpha 版本时,该标志会设为 true,从而启用诸如模块级 for_each(在正式稳定之前)等特性。该标志在解析时和使用时都会被检查,形成双重防护。
提示: Parser 会通过
hclparse.Parser内部缓存所有已加载的文件。这份缓存后续会用于在诊断信息中生成源代码片段。当你看到错误信息中包含源代码摘录时,正是这个缓存在发挥作用。
Module 模型:单个目录的内容表示
解析完单个文件后,它们会被合并成一个 Module,定义位于 internal/configs/module.go#L19-L63:
type Module struct {
SourceDir string
CoreVersionConstraints []VersionConstraint
ActiveExperiments experiments.Set
Backend *Backend
StateStore *StateStore
CloudConfig *CloudConfig
ProviderConfigs map[string]*Provider
ProviderRequirements *RequiredProviders
Variables map[string]*Variable
Locals map[string]*Local
Outputs map[string]*Output
ModuleCalls map[string]*ModuleCall
ManagedResources map[string]*Resource
DataResources map[string]*Resource
EphemeralResources map[string]*Resource
ListResources map[string]*Resource
Actions map[string]*Action
Moved []*Moved
Removed []*Removed
Import []*Import
Checks map[string]*Check
Tests map[string]*TestFile
}
classDiagram
class Module {
+SourceDir string
+Backend *Backend
+ProviderConfigs map[string]*Provider
+Variables map[string]*Variable
+Locals map[string]*Local
+Outputs map[string]*Output
+ModuleCalls map[string]*ModuleCall
+ManagedResources map[string]*Resource
+DataResources map[string]*Resource
+EphemeralResources map[string]*Resource
+ListResources map[string]*Resource
+Actions map[string]*Action
+Moved []*Moved
+Removed []*Removed
+Import []*Import
}
class Resource {
+Mode ResourceMode
+Name string
+Type string
+Config hcl.Body
+Count hcl.Expression
+ForEach hcl.Expression
+ProviderConfigRef *ProviderConfigRef
+DependsOn []hcl.Traversal
}
class Variable {
+Name string
+Type cty.Type
+Default cty.Value
+Validation []*CheckRule
+Ephemeral bool
}
Module --> Resource
Module --> Variable
Module 代表一个目录下的全部内容。同一目录中的所有 .tf 文件会被合并进同一个 Module——这就是为什么你可以在同一目录下把资源拆分到多个文件中,它们之间仍然彼此可见。
注意 Resource.Config 的类型是 hcl.Body,而不是已解码的 Go 结构体。这正是惰性求值的关键所在:资源的属性值在这一阶段仍以未求值的 HCL 表达式形式存在,要等到图遍历阶段——也就是我们已经知道变量和其他资源的值时——才会真正被解析。
将资源拆分为 ManagedResources、DataResources、EphemeralResources 和 ListResources,对应了 Terraform 目前支持的四种资源模式。每种模式有不同的生命周期语义,但共用同一个 Resource 配置类型。
Config 树:模块层级的组装
各个模块通过 internal/configs/config.go#L32-L97 中的 Config 类型组装成一棵树:
type Config struct {
Root *Config
Parent *Config
Path addrs.Module
Children map[string]*Config
Module *Module
// source info, version constraints...
}
classDiagram
class Config {
+Root *Config
+Parent *Config
+Path addrs.Module
+Children map[string]*Config
+Module *Module
+Version *version.Version
}
Config --> Config : Root, Parent
Config --> Config : Children[name]
Config --> Module : Module
这棵树由 config_build.go 负责组装:它处理根模块中的 module 调用,并递归加载子模块(来源可以是本地路径、registry 或其他来源)。Root 字段始终指向根 Config,Parent 指向调用方模块,Path 字段(类型为 addrs.Module)表示静态模块路径,例如 module.network.module.vpc。
这棵树是静态的——它反映的是配置中声明的模块层级。在图遍历阶段,由于 count 和 for_each 的存在,模块可能会扩展为多个实例,从而产生动态的 addrs.ModuleInstance 路径。这一扩展过程由第 2 篇中提到的 ModuleExpansionTransformer 负责处理。
地址系统
addrs 包为整个代码库提供了命名的基础框架。Terraform 中所有可寻址的对象——provider、模块、资源、变量、输出——都有对应的类型:
| 类型 | 示例 | 用途 |
|---|---|---|
addrs.Provider |
registry.terraform.io/hashicorp/aws |
Provider 标识与完全限定名 |
addrs.Module |
module.network.module.vpc |
静态模块路径 |
addrs.ModuleInstance |
module.network[0].module.vpc |
带实例键的动态模块实例 |
addrs.Resource |
aws_instance.web |
模块内的资源 |
addrs.ResourceInstance |
aws_instance.web[0] |
带 count/for_each 键的资源实例 |
addrs.AbsResourceInstance |
module.network.aws_instance.web[0] |
完全限定的资源实例 |
addrs.Reference |
指向任意可寻址地址的引用 | 供 ReferenceTransformer 使用 |
internal/addrs/provider.go#L15 中的 addrs.Provider 类型实际上是外部包 terraform-registry-address 中 tfaddr.Provider 的别名:
type Provider = tfaddr.Provider
这些地址类型可作为 map 的键(在 state 和图节点注册表中),可作为 -target 标志的目标选择条件,也是引用解析的基础。它们都实现了 String() 方法以便人类阅读,并支持相等性比较,因此适合用作 map 的键。
提示: 阅读 Terraform 源代码时,要特别留意一个函数接收的是
addrs.Resource(静态的,无实例键)还是addrs.AbsResourceInstance(带模块路径和实例键的完全限定地址)。传入错误的地址层级是常见的 bug 来源。
惰性表达式求值:lang.Scope 与 cty
这是 Terraform 配置系统中最重要的概念:表达式不在解析时求值。存储在资源配置、变量默认值和输出值中的 hcl.Expression 是在图遍历期间通过 lang.Scope 类型惰性求值的。
求值流水线的工作方式如下:
sequenceDiagram
participant Parser as configs.Parser
participant Module as configs.Module
participant Graph as PlanGraphBuilder
participant Walk as Graph Walk
participant Node as ResourceNode
participant Scope as lang.Scope
participant Provider as Provider
Parser->>Module: Parse .tf files
Note over Module: Stores hcl.Expression<br/>(unevaluated AST)
Module->>Graph: Config input
Graph->>Walk: Built graph
Walk->>Node: Execute(evalCtx)
Node->>Scope: EvalBlock(config, schema)
Scope->>Scope: Resolve references<br/>(var.x, local.y, aws_vpc.main.id)
Scope-->>Node: cty.Value (resolved)
Node->>Provider: PlanResourceChange(resolvedConfig)
当 NodePlannableResourceInstance 这样的图节点执行时,它需要对资源配置进行求值,生成发送给 provider 的"拟议新状态"。这一过程通过 EvalContext 完成,后者提供了一个配置好的 lang.Scope,其中包含:
- 输入变量值 —— 来自
PlanOpts.SetVariables或默认值 - local 值 —— 由各自的表达式计算得出
- 子模块的 output 值 —— 在子模块完成后可用
- 资源属性 —— 在对应资源节点完成后可用
- 内置函数 ——
length()、lookup()、format()等
internal/terraform/eval_variable.go 中的变量求值逻辑和 internal/terraform/evaluate_data.go 中的数据求值逻辑,展示了这一机制的具体实现。核心在于:图的依赖边保证了求值顺序——如果资源 B 引用了资源 A,ReferenceTransformer 就会确保 A 的节点在 B 的节点开始之前完成。当 B 对其表达式求值时,A 的属性已经在 scope 中准备就绪。
cty(读作"see-ty")类型系统是一个外部库,提供了 Terraform 的动态值表示。与 Go 的静态类型系统不同,cty.Value 可以表示任意 Terraform 类型——字符串、数字、布尔值、列表、map、对象和集合——以及特殊值,如 cty.UnknownVal(用于在 apply 之前无法确定的计算属性)和 cty.NullVal(用于缺失的值)。
未知值尤为值得关注。在 plan 阶段,许多资源属性是未知的,因为它们依赖于 apply 之后才会存在的值(例如 EC2 实例 ID)。Terraform 会在表达式求值过程中传播这些未知值——如果你在另一个资源的配置中引用了 aws_instance.web.id,而该 ID 是未知的,那么下游表达式的求值结果也会是未知的。这就是 Terraform 能够为依赖尚未创建的基础设施的资源生成 plan 的原因。
串联全局视图
纵观本系列七篇文章,我们从外层逐步深入 Terraform 的架构核心:
- 第 1 篇梳理了代码库全貌:启动流程、命令注册、包结构与端到端请求链路。
- 第 2 篇深入解析了图引擎:通用 DAG 库、并行遍历器与 transformer 流水线。
- 第 3 篇跟踪了一个资源从 plan 到 apply 的完整生命周期:Context 编排、节点执行与 hook 系统。
- 第 4 篇探索了 provider 插件系统:gRPC 通信、协议版本与三层抽象模型。
- 第 5 篇考察了 state 与 backend:三层 state 模型、backend 接口层级与迁移流程。
- 第 6 篇介绍了 CLI 层:命令结构、面向人类/JSON 输出的 view 机制与诊断系统。
- 本篇以配置加载与惰性表达式求值收尾,完整了整幅架构图。
贯穿这一切的核心线索是图。配置被解析成模块树,模块树为图的构建提供输入。图的顶点是资源节点,在遍历时惰性求值配置表达式并发起 provider 调用。结果流入 state 和变更记录。CLI 层通过 hook 观察执行过程,通过 view 渲染输出。Backend 持久化 state。诊断系统则将丰富的错误信息贯穿各个层次。
对于一个已有十年历史的项目,Terraform 的架构具有令人称道的一致性。providers.Interface、GraphTransformer、EvalContext、statemgr.Full、tfdiags.Diagnostics——这些抽象构成了一套定义清晰的契约,既支撑起了围绕 Terraform 的庞大生态,又让核心引擎的复杂度保持在可控范围内。深入理解这些契约,是高效参与该代码库开发的关键所在。