ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Wing 编译器插件设计指南:preSynth / postSynth / validate 钩子与实战

Wing 编译器插件设计指南:preSynth / postSynth / validate 钩子与实战
  • 编程语言
  • 编译器
  • 云原生
  • 语言运行时

【免费下载链接】wing

A programming language for the cloud ☁️ A unified programming model, combining infrastructure and runtime code into one language ⚡

项目地址:https://gitcode.com/gh_mirrors/wi/wing
点击查看免费下载

Wing 编译器插件允许开发者在不修改编译器源码的前提下,定制编译输出(例如基础设施定义)。本文基于仓库中已归档的插件文档 docs/contributing/998-archived/02-plugins.md,完整讲解插件的基本形态、三大编译钩子的 API 与代码示例,并结合 Platform 接口 实现与 synth 流程 源码,以及 tests/valid/platforms 目录下的真实插件示例,帮助读者掌握编写、命名与加载编译器插件的完整方法。

什么是编译器插件

编译器插件用于扩展 Wing 编译器的行为,核心诉求是定制编译输出,尤其是基础设施定义部分。例如,可以编写一个插件,强制为生成基础设施中的所有资源统一打上组织约定的标签(tagging convention),而无须在每个 Wing 应用中重复书写该逻辑。

插件系统的价值在于:它将"业务功能需求"与"非功能需求"(合规、安全、命名规范、标签策略等)解耦。应用开发者只关心应用自身的功能逻辑,而平台/合规团队可以通过插件统一注入组织级约束。

插件设计形态

一个编译器插件就是一个JavaScript 文件,通过exports导出多个函数(即钩子 hook)。编译器会在编译流程的不同阶段调用这些钩子:

  • 每个钩子都会收到一个上下文对象,插件借助该对象介入编译过程并施加定制;
  • 单个插件可以实现任意数量的钩子,但官方建议只实现实际需要的钩子,避免冗余逻辑干扰编译流程;
  • 插件按加载顺序依次执行,多个插件同时存在时,需要注意执行顺序对结果的影响(后执行的插件会看到前一个插件修改后的配置)。

从当前仓库源码看,这一设计已演化为平台(Platform)机制中的钩子集合。在 platform.ts 中,IPlatform接口明确声明了preSynth?、postSynth?、validate?三个可选钩子方法,而 platform-manager.ts 中的collectHooks()会把所有平台实例的三个钩子分别收集到synthHooks的preSynthesize、postSynthesize、validate数组中,随后在合成阶段依次调用。

编译钩子详解

编译器提供三类钩子,分别作用于编译流程的不同时间点:合成前(preSynth)、合成后(postSynth)、校验(validate)。

preSynth 钩子

API 签名:

preSynth(app: Construct): void;

执行时机与能力:该钩子在编译器开始合成(synthesize)之前被调用。对于基于 Terraform 的目标(如tf-aws),钩子参数是构造树(construct tree)的根节点。此时插件可以添加或修改 CDK for Terraform(cdktf)构造,然后再进行树的合成。

示例:向根节点添加一个 S3 存储桶(该插件面向tf-aws目标):

const s3_bucket = require("@cdktf/provider-aws/lib/s3-bucket"); // exports the preSynth function exports.preSynth = function(app) { // app is the root node of the construct tree new s3_bucket.S3Bucket(app, "MyPluginBucket", { bucket: "my-plugin-bucket", }); }

从源码实现看,preSynth 阶段是"以构造对象形态介入"的最佳时机。在 shared-tf/app.ts 的synth()方法中,先调用preSynthesizeAllConstructs(this)触发所有构造的_preSynthesize()回调,再依次执行_synthHooks.preSynthesize数组中的每个钩子(即插件导出的preSynth),最后才执行cdktfApp.synth()真正生成 Terraform 文件。也就是说,preSynth 钩子运行时,构造树仍然"可编辑"。

preSynth 阶段也常用于为整棵构造树施加 cdktf 的Aspect(面向切面的树遍历机制)。仓库中真实的 permission-boundary.js 插件正是如此:它在preSynth中通过cdktf.Aspects.of(app).add(...)注册一个PermissionBoundaryAspect,该 Aspect 遍历树中所有terraformResourceType === "aws_iam_role"的节点并为其设置permissionsBoundary。类似的 replicate-s3.js 插件也用preSynth注册 Aspect,为所有 S3 桶自动开启版本化并创建副本桶。

postSynth 钩子

API 签名:

postSynth(config: any): any;

执行时机与能力:该钩子在工件(artifacts)合成完成后运行。对于tf-aws等 Terraform 目标,钩子拿到的是原始的 Terraform JSON 配置,可以对其中的 JSON 进行任意修改,最终写入编译输出目录。

适用场景:用于添加那些无法在 preSynth 构造树上下文中完成的定制。值得注意的是,postSynth 不是校验阶段——配置在此阶段仍然可被后续插件继续修改,因此不要在此处做"最终校验"。

示例:将 Terraform 配置改为使用 S3 后端(示例中为简洁使用了硬编码值,实际中可以通过环境变量、类构造参数、配置文件等方式注入):

// exports the postSynth function exports.postSynth = function(config) { config.terraform.backend = { s3: { bucket: "my-wing-state-bucket", key: "plugins-rock.tfstate", region: "us-east-1", } } return config; }

返回值语义:postSynth 钩子必须返回修改后的config,该返回值会覆盖原有配置,并作为 validate 阶段及后续插件的输入。仓库中的真实示例 tf-backend.js 完整演示了这一模式:插件根据TF_BACKEND环境变量(s3/azurerm/gcs)选择后端,在postSynth中设置config.terraform.backend并返回 config,同时支持通过STATE_FILE环境变量定制状态文件路径(默认wing-tf.tfstate)。

在 shared-tf/app.ts 的实现中,postSynthesize钩子的返回值会被JSON.stringify(hook(tfConfig), null, 2)后写入main.tf.json——这正是"返回值覆盖输出文件"的落地方式。

validate 钩子

API 签名:

validate(config: any): void;

执行时机与能力:该钩子在postSynth之后立即被调用,接收相同的上下文对象。对于 Terraform 目标,它就是同一份 Terraform JSON 配置。但与 postSynth 不同,validate 阶段不允许修改配置,因此插件可以放心地对配置进行最终校验,而不必担心其他插件在此之后继续改动它。

示例:校验所有存储桶都已开启版本化,否则在编译期抛出错误:

// exports the validate function exports.validate = function(config) { for (const bucketEntry of Object.keys(config.resource.aws_s3_bucket)) { const bucket = config.resource.aws_s3_bucket[bucketEntry]; if (!bucket.versioning.enabled) { throw new Error(`Bucket ${bucketEntry} does not have versioning enabled`); } } }

从源码看,shared-tf/app.ts 中validate钩子在 postSynthesize 写盘之后执行,其返回值不被采用,钩子只负责"检查并抛错"。这与文档所述"不可变阶段"的定位完全一致。

三个钩子的执行顺序小结

结合 shared-tf/app.ts 的synth()实现,整个流程可以归纳为:

  1. preSynthesizeAllConstructs(this):触发所有构造自身的_preSynthesize()回调;
  2. 依次执行所有插件的preSynth(app):此时持有构造树根节点,可增删改 cdktf 构造、注册 Aspect;
  3. cdktfApp.synth():真正合成 Terraform 文件并移动到输出目录;
  4. 依次执行所有插件的postSynth(config):持有原始 Terraform JSON,可修改并返回新配置,返回值写入main.tf.json;
  5. 依次执行所有插件的validate(config):持有最终配置,只校验不可改,失败则抛错中断编译。

插件命名(Plugin Names)

插件还支持一个可选特性:指定名称。默认情况下,编译器在输出诊断信息时会使用插件的绝对路径。例如以下插件:

exports.preSynth = function(app) { // something went oops here throw new Error("oops"); }

会产生如下报错:

preflight error: Plugin "/some/absolute/path/to/plugin/oops-plugin.js" failed, during "preSynth". cause: oops

如果希望诊断信息更友好,只需在插件中导出名称:

exports.name = 'my-oopsie-plugin';

此时报错变为:

preflight error: Plugin "my-oopsie-plugin" failed, during "preSynth". cause: oops

从 compile.ts 的resolvePlatformPaths()实现可以印证:加载时插件路径会被解析为绝对路径(以;拼接后通过WING_PLATFORMS环境变量传递给 preflight 进程),这也解释了为何默认诊断消息会展示绝对路径。导出name后,编译器在错误文案中优先使用该名称。

插件的加载与使用

通过 CLI 加载插件

在 Wing CLI 中,插件通过compile等命令的-t, --platform选项传入(可重复传入多个,构成插件列表)。以 cli.ts 中的compile命令为例:

wing compile -t tf-aws -t ./my-plugin.js app.w

-t既可以是内置目标(sim、tf-aws、tf-azure、tf-gcp),也可以是一个自定义插件文件路径;多个-t按传入顺序依次执行,因此要确保插件顺序符合预期结果。相关设计背景可参阅 RFC 文档 2023-01-21-wing-plugins.md。

说明:本仓库中,原始的--plugins选项已被平台机制(platform)取代,插件文件被实现为导出Platform类的 JS 模块(例如 tf-backend.js 中的exports.Platform = class TFBackend),其target属性声明适用目标(如"tf-*"、"tf-aws"),钩子方法直接定义在类上。这与本文描述的钩子设计一脉相承,是当前仓库中实际生效的写法。

多阶段综合示例:Lambda 非功能需求

结合文档描述的三阶段能力,一个典型的综合插件可以同时实现:在preSynth中用 Aspect 为所有 Lambda 函数打团队标签;在postSynth中强制所有aws_lambda_function使用nodejs18.x运行时;在validate中扫描环境变量、发现PASSWORD/SECRET等可疑变量即抛错拒绝部署:

// my-plugin.js const cdktf = require("cdktf"); class TagLambdaAspect { visit(node) { if (node.terraformResourceType === "aws_lambda_function") { node.tags = { ...node.tags, owner: "wingnuts team" }; } } } exports.preSynth = function (app) { cdktf.Aspects.of(app).add(new TagLambdaAspect()); }; exports.postSynth = function (config) { for (const key of Object.keys(config.resource.aws_lambda_function)) { config.resource.aws_lambda_function[key].runtime = "nodejs18.x"; } return config; }; exports.validate = function (config) { for (const key of Object.keys(config.resource.aws_lambda_function)) { const env = config.resource.aws_lambda_function[key].environment?.variables; if (env?.PASSWORD || env?.SECRET) { throw new Error(`Found unsafe environment variables in: ${key}`); } } };

编译命令:

wing compile -t tf-aws -t ./my-plugin.js app.w

常见使用场景

结合仓库文档与真实插件,编译器插件主要解决以下组织级非功能需求:

  • 统一标签与命名:通过 Aspect 为所有资源注入团队标签、名称前缀/后缀(见 permission-boundary.js 的 Aspect 模式);
  • 合规与安全校验:在validate阶段检查桶版本化、禁止 IAM 策略中的Allow "*"通配符、拦截危险环境变量;
  • Terraform 后端定制:在postSynth阶段切换 S3 / azurerm / gcs 远端状态存储(见 tf-backend.js);
  • 资源批量增强:为所有 S3 桶开启版本化与跨区域复制(见 replicate-s3.js);
  • 强制运行时与网络约束:统一 Lambda 运行时版本、将应用资源强制部署到指定 VPC/子网。

编写插件的最佳实践

  • 按需实现钩子:只实现当前需求涉及的钩子,其余不要导出,避免在编译流程中引入不必要的副作用;
  • 明确阶段边界:需要增删构造/注册 Aspect 用preSynth;需要改写 Terraform JSON 用postSynth(并记得return config);需要最终把关用validate(不要修改配置);
  • 用插件名称提升可诊断性:导出exports.name,让编译错误信息不再暴露冗长的绝对路径;
  • 通过环境变量或配置文件参数化:参考 tf-backend.js 的模式,将桶名、区域等敏感/易变值从环境变量读取,而非硬编码;
  • 注意插件执行顺序:多个插件按-t传入顺序执行,postSynth 阶段配置仍可被后续插件修改,涉及先后依赖时务必调整加载顺序。

进一步阅读

  • 本文依据的原始文档:02-plugins.md(已归档)
  • 插件机制演进背景:2023-01-21-wing-plugins.md(RFC)
  • 平台接口定义:platform.ts
  • 钩子收集逻辑:platform-manager.ts
  • 钩子实际调用流程:shared-tf/app.ts
  • CLI 平台参数解析:cli.ts
  • 真实插件示例:tests/valid/platforms 目录下的tf-backend.js、permission-boundary.js、replicate-s3.js
  • 编译目标背景文档:01-compile-targets.md
  • 编程语言
  • 编译器
  • 云原生
  • 语言运行时

【免费下载链接】wing

A programming language for the cloud ☁️ A unified programming model, combining infrastructure and runtime code into one language ⚡

项目地址:https://gitcode.com/gh_mirrors/wi/wing
点击查看免费下载

相关推荐

上一篇:Apache Pulsar 端到端消息加密实战:CryptoKeyReader、AES 数据密钥与 ECDSA/RSA 密钥对详解
下一篇:Marlin 固件 MMU3 串行通信协议深度解析:消息帧格式、CRC8 校验与换料状态机

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进