自动生成 Cloudflare 的 Terraform 提供者
2024-09-24
Cloudflare Terraform 提供者过去是手动维护的。借助我们现有的 OpenAPI 代码生成管道,我们现在可以自动生成提供者,以更好地覆盖端点和属性,在新产品发布时更快更新,新增了一个新的 API 文档网站。请继续阅读,了解我们是如何做到的。...
继续阅读 »
\n
即使在 4 个拉取请求完成后,也不能保证覆盖所有可用属性,这意味着可能会忘记小而重要的细节而没有暴露给客户,导致在尝试配置资源时受挫。\n\n为了解决这个问题,我们的 Terraform 提供者需要依赖于我们的 SDK 生态系统的其他部分已经从中受益的相同 OpenAPI 模式。
\nTerraform 与我们的 SDK 的不同之处在于,它管理资源的生命周期。随之而来的是与已知值以及管理请求和响应有效负载中的差异相关的一系列新问题。 我们来比较一下创建新 DNS 记录并将其取回的两种不同方法。
使用我们的 Go SDK:
\n// Create the new record\nrecord, _ := client.DNS.Records.New(context.TODO(), dns.RecordNewParams{\n\tZoneID: cloudflare.F("023e105f4ecef8ad9ca31a8372d0c353"),\n\tRecord: dns.RecordParam{\n\t\tName: cloudflare.String("@"),\n\t\tType: cloudflare.String("CNAME"),\n Content: cloudflare.String("example.com"),\n\t},\n})\n\n\n// Wasteful fetch, but shows the point\nclient.DNS.Records.Get(\n\tcontext.Background(),\n\trecord.ID,\n\tdns.RecordGetParams{\n\t\tZoneID: cloudflare.String("023e105f4ecef8ad9ca31a8372d0c353"),\n\t},\n)\n
\n \n使用 Terraform 时:
\nresource "cloudflare_dns_record" "example" {\n zone_id = "023e105f4ecef8ad9ca31a8372d0c353"\n name = "@"\n content = "example.com"\n type = "CNAME"\n}
\n 表面上看,Terraform 方法更简单,您是对的。我们已为您处理了了解如何创建新资源和维护更改的复杂性。但问题在于,为了让 Terraform 提供这种抽象和数据保证,在应用时必须知道所有值。这意味着,即使您没有使用代理
值 value,Terraform 也需要知道这个值需要是什么,以便将其保存在状态文件中,并在日后管理该属性。以下错误是 Terraform 操作人员在应用值未知时,通常会从提供者那里看到的错误。
Error: Provider produced inconsistent result after apply\n\nWhen applying changes to example_thing.foo, provider "provider[\\"registry.terraform.io/example/example\\"]"\nproduced an unexpected new value: .foo: was null, but now cty.StringVal("").
\n 而在使用 SDK 时,如果您不需要某个字段,则可以忽略它,永远不需要担心维护已知值。
为我们的 OpenAPI 模式解决这个问题不是易事。自从引入 Terraform 生成支持以来,我们模式的质量已经提高了一个数量级。现在,我们显式地调用所有存在的默认值,基于请求有效负载的变量响应属性,以及任何服务器端计算的属性。这一切都意味着为与我们 API 交互的任何人提供更好的体验。
\n要构建 Terraform 提供者并向操作人员公开资源或数据源,您需要两个主要的东西:一个提供者服务器和一个提供者。
\n
提供者服务器负责暴露一个 gRPC 服务器,Terraform 核心(通过 CLI)使用该服务器在管理资源或从操作人员提供的配置中读取数据源时使用该服务器进行通信。
\n提供者负责包装资源和数据源,与远程服务通信,并管理状态文件。为此,您可以依赖 terraform-plugin-sdk (通常称为 SDKv2)或 terraform-plugin-framework ,其中包括 Terraform 提供的所有接口和方法,以便正确管理其内部机制。使用哪一个插件取决于提供者的年龄。 SDKv2 存在的时间更长,大多数 Terraform 提供者都使用它,但由于时间长和复杂性,它有许多核心未解决的问题必须保留,以便为依赖它的客户提供向后兼容性。terraform-plugin-framework
是新版本,虽然缺乏 SDKv2 的功能广度,但提供了一种更像 Go 的方法来构建提供者,并解决了 SDKv2 中的许多底层错误。
(有关 SDKv2 和该框架的更深入比较,您可以查看我和来自 Octopus Deploy 的 John Bristowe 之间的对话。)
Cloudflare Terraform 提供者的大部分内容都是使用 SDKv2 构建的,但在 2023 年初,我们采用了多路复用方式,在我们的提供者中同时提供两者。要理解为什么需要这样做,我们必须对 SDKv2 有所了解。 SDKv2 的组织方式并不利于一致且可靠地表示 null 或“未设置”的值。您可以使用实验性的 ResourceData.GetRawConfig 来检查配置中是否已设置值、为 null 或未知,但实际上并不支持将其写回为 null。\n\n我们首次发现这个限制是在边缘规则引擎(规则集)开始引入新服务的时候,这些服务需要支持的 API 响应中包含未设置(或缺失)、 true
或 false
状态的布尔值,每个状态都有自己的原因和目的。虽然这不是 Cloudflare 的常规 API 设计,但它是一种合法的方式,我们应该能够处理。但是,如上所述,SDKv2 提供者不能处理。这是因为,当一个值没有出现在响应中或读入状态时,它会获得一个与 Go 兼容的零值作为默认值。表现为在写入状态为假值后无法取消设置值(反之亦然)。
要可靠地使用这些布尔值的三个状态,我们拥有的唯一解决方案是迁移到 terraform-plugin-framework
,该框架具有写回未设置值的正确实现。
在我们开始在老提供者中使用 terraform-plugin-framework
添加更多功能后,开发人员体验显然得到了改善,因此我们添加了一个限制,以防止未来任何人继续使用 SDKv2 并无意中让自己陷入这个问题。\n\n当我们决定将自动生成 Terraform 提供者时,最理想的做法是将所有资源都基于 terraform-plugin-framework
,并彻底摆脱 SDKv2 中的问题。这确实使迁移复杂化了,因为内部结构改进后,主要组件也发生了变化,例如我们需要熟悉的模式和 CRUD 操作。但是,这是一项值得的投资,因为通过这样做,我们已经为提供者的基础做好了面向未来的准备,并减少了因存在缺陷的遗留内部机制而导致的妥协,以提供优秀的 Terraform 体验。
代码生成管道常见的一个问题是,除非您有现成的工具来实现你的新产品,否则很难判断它是否有效或是否值得使用。当然,您也可以生成测试来演练新产品,但如果管道中存在错误,因为你生成的测试断言会显示这个缺陷是预期的行为。
我们已有的一个重要反馈回路就是现有的验收测试套件。对现有提供者中的所有资源进行回归和功能测试。最棒的是,由于测试套件正在创建和管理真实资源,我们只需查看 HTTP 流量,看看 API 调用是否被远程端点接受,就能很容易判断结果是否是一个有效的实现。移植测试套件只需复制所有现有的测试,并检查任何类型断言的差异(例如列表到单个嵌套列表),然后启动测试运行以确定资源是否正常工作。
虽然集中式模式管道显著提高了效率,模式修复几乎瞬间即可传播到整个生态系统,但它无法帮助我们解决最大的障碍,即揭露隐藏其他缺陷的缺陷。这非常耗时,因为修复 Terraform 中的问题时,有三个地方可能遇到错误:
在进行任何 API 调用之前,Terraform 会实施逻辑模式验证,当遇到验证错误时,它将立即停止。
如果任何 API 调用失败,它会在 CRUD 操作处停止并返回诊断信息,并立即停止。
在 CRUD 操作运行后,Terraform 会进行检查,以确保所有的值都是已知的。
这意味着,如果我们在第一步遇到缺陷,然后予以修复,不能保证或无法知道是否还有两个错误在等着我们。更不用说,如果我们在第 2 步中发现了一个错误并发布了修复,就不会在下一轮测试中的第 1 步发现一个错误。
对此没有灵丹妙药,我们的解决办法是注意模式行为中的有问题模式,并在它进入代码生成管道之前,在 OpenAPI 架构中应用 CI lint 规则。采用这种方法逐步减少第 1 步和第 2 步的错误数量,直到基本上只要处理第三步中的错误类型。
\n在 Terraform 提供者的 CRUD 操作中,相当常见如下样板文件:
\nvar plan ThingModel\ndiags := req.Plan.Get(ctx, &plan)\nresp.Diagnostics.Append(diags...)\nif resp.Diagnostics.HasError() {\n\treturn\n}\n\nout, err := r.client.UpdateThingModel(ctx, client.ThingModelRequest{\n\tAttrA: plan.AttrA.ValueString(),\n\tAttrB: plan.AttrB.ValueString(),\n\tAttrC: plan.AttrC.ValueString(),\n})\nif err != nil {\n\tresp.Diagnostics.AddError(\n\t\t"Error updating project Thing",\n\t\t"Could not update Thing, unexpected error: "+err.Error(),\n\t)\n\treturn\n}\n\nresult := convertResponseToThingModel(out)\ntflog.Info(ctx, "created thing", map[string]interface{}{\n\t"attr_a": result.AttrA.ValueString(),\n\t"attr_b": result.AttrB.ValueString(),\n\t"attr_c": result.AttrC.ValueString(),\n})\n\ndiags = resp.State.Set(ctx, result)\nresp.Diagnostics.Append(diags...)\nif resp.Diagnostics.HasError() {\n\treturn\n}
\n 总体而言:
我们使用 req.Plan.Get()
获取建议的更新(称为计划)
执行使用新值的更新 API 调用
将数据从 Go 类型转换为 Terraform 模型 (convertResponseToThingModel
)
调用 resp.State.Set()
来设置状态
最初,这似乎没有太大问题。然而,第 3 步将 Go 类型转换为 Terraform 模型时,很快就会变得繁琐、容易出错且复杂,因为所有资源都需要这样做才能在类型和相关的 Terraform 模型之间切换。\n\n为了避免生成比必要更复杂的代码,我们的提供者中进行的一项改进是,所有 CRUD 方法使用统一的 apijson.Marshal
、 apijson.Unmarshal
和 apijson.UnmarshalComp
方法,这些方法通过基于结构体标签集中转换和处理逻辑来解决这个问题。
var data *ThingModel\n\nresp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)\nif resp.Diagnostics.HasError() {\n\treturn\n}\n\ndataBytes, err := apijson.Marshal(data)\nif err != nil {\n\tresp.Diagnostics.AddError("failed to serialize http request", err.Error())\n\treturn\n}\nres := new(http.Response)\nenv := ThingResultEnvelope{*data}\n_, err = r.client.Thing.Update(\n\t// ...\n)\nif err != nil {\n\tresp.Diagnostics.AddError("failed to make http request", err.Error())\n\treturn\n}\n\nbytes, _ := io.ReadAll(res.Body)\nerr = apijson.UnmarshalComputed(bytes, &env)\nif err != nil {\n\tresp.Diagnostics.AddError("failed to deserialize http request", err.Error())\n\treturn\n}\ndata = &env.Result\n\nresp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
\n 我们不需要生成数百个类型到模型转换方法的实例,而是给 Terraform 模型添加正确的标签,以一致的方式处理数据的序列化和反序列化。这只是对代码的一个小改动,但从长远来看,却使生成的代码更具可重用性和可读性。这种方法的另一个好处是,它非常适合错误修复,因为一旦您识别出特定类型字段的错误,只要在统一界面中修复该错误,就能解决其他您可能还没有发现的错误。
\n为了提升我们的 OpenAPI 模式使用,我们正在加强 SDK 与新 API 文档站点的集成。它使用了我们过去两年投资并解决了一些常见使用问题的相同管道。
\n如果您使用过我们的 API 文档站点,您就知道我们提供使用 curl
等命令行工具与 API 交互的示例。这是一个很好的起点,但如果您使用的是其中一个 SDK 库,您需要设法将其转换为您想要使用的方法或类型定义。现在我们使用相同的管道来生成 SDK 和文档,我们将通过提供您可能使用的所有库中的示例来解决这个问题,而不限于 curl
。
使用 cURL 获取所有区域的示例。
\n使用 Typescript 库获取所有区域的示例。
\n使用 Python 库获取所有区域的示例。
\n使用 Go 库获取所有区域的示例。.
通过这一改进,我们还可以记住语言选择,因此,如果您选择使用 Typescript 库查看文档并继续浏览,我们将一直向您显示使用 Typescript 的示例,直至更换到其他语言。
最棒的是,当我们向现有端点引入新属性或添加 SDK 语言时,这个文档站点会自动与管道保持同步。使其保持最新不再需要付出巨大的努力。
\n我们一直难以解决的一个问题是 API 端点的庞大数量以及如何表示它们。截至本文,我们有 1,330 个端点,对于每个端点,我们有一个请求有效负载,一个响应有效负载,以及多个关联的类型。在渲染这么多信息时,我们过去使用的解决方案不得不做出一些权衡,以便让部分表示能够正常工作。
API 文档站点的下一个迭代通过几种方式解决了这个问题:
它被实施为一个现代 React 应用,将交互式客户端体验与静态预渲染内容结合起来,从而实现快速初始加载和快捷导航。(是的,即使不启用 JavaScript,它也能正常工作!)。
它会随着您浏览时逐步获取底层数据。
通过解决这个基本问题,我们还解锁了对文档站点和 SDK 生态系统的其他计划改进,以提升用户体验,而不再需要像过去那样做出权衡。
\n在文档网站中,用户最希望重新实现的功能之一就是 API 端点的最低所需权限。文档网站的早期版本中曾经提供这个功能。然而,大多数使用它的人并不知道,这些值是手动维护的,且经常不正确,导致用户提交支持工单并感到沮丧。
在 Cloudflare 的身份和访问管理系统中,“我需要什么才能访问这个端点”并不是一个简单的问题。这样做的原因是,在请求发送到控制平面的正常流程中,我们需要两个不同的系统来回答问题的一部分,然后将这些信息结合起来给出完整的答案。由于我们最初无法作为 OpenAPI 管道的一部分自动执行此操作,因此我们选择将其排除在外,而不是提供一个错误且无法验证的值。
快进到今天,我们很高兴地宣布,端点权限已经回来了!我们构建了一些新的工具,以抽象的方式回答这个问题,让我们可以将其集成到代码生成管道中,并让所有端点自动获取这些信息。与代码生成平台的其他部分非常类似,它专注于让服务团队拥有并维护高质量的模式,可供重复使用并增加价值,无需他们进行任何工作。
\n随着这些公告发布,我们将不再需要等待更新进入 SDK 生态系统。通过这些新的改进,我们能够在团队记录新属性和端点时立即优化它们的能力。您还在犹豫什么?欢迎探索 Terraform 提供者和 API 文档站点。
"],"published_at":[0,"2024-09-24T14:00+01:00"],"updated_at":[0,"2024-12-12T00:18:18.131Z"],"feature_image":[0,"https://cf-assets.www.cloudflare.com/zkvhlag99gkb/1zh37HN3XlMPz8Imlc1pkQ/c93812e87ed5454947b29df8f6aa0671/image1.png"],"tags":[1,[[0,{"id":[0,"1Cv5JjXzKWKEA10JdYbXu1"],"name":[0,"Birthday Week"],"slug":[0,"birthday-week"]}],[0,{"id":[0,"5x72ei67SoD11VQ0uqFtpF"],"name":[0,"API"],"slug":[0,"api"]}],[0,{"id":[0,"3rbBQ4KGWcg4kafhbLilKu"],"name":[0,"SDK"],"slug":[0,"sdk"]}],[0,{"id":[0,"4PjcrP7azfu8cw8rGcpYoM"],"name":[0,"Terraform"],"slug":[0,"terraform"]}],[0,{"id":[0,"5agGcvExXGm8IVuFGxiuBF"],"name":[0,"Open API"],"slug":[0,"open-api"]}],[0,{"id":[0,"3JAY3z7p7An94s6ScuSQPf"],"name":[0,"开发人员平台"],"slug":[0,"developer-platform"]}],[0,{"id":[0,"4HIPcb68qM0e26fIxyfzwQ"],"name":[0,"开发人员"],"slug":[0,"developers"]}],[0,{"id":[0,"6QktrXeEFcl4e2dZUTZVGl"],"name":[0,"产品新闻"],"slug":[0,"product-news"]}]]],"relatedTags":[0],"authors":[1,[[0,{"name":[0,"Jacob Bednarz"],"slug":[0,"jacob-bednarz"],"bio":[0,"System Engineer, Control Plane"],"profile_image":[0,"https://cf-assets.www.cloudflare.com/zkvhlag99gkb/qHSrNJsFuwJYKkEu0l8nw/179454d306e61284f83e97e94326fa0d/jacob-bednarz.jpg"],"location":[0,"Australia"],"website":[0,"https://jacobbednarz.com"],"twitter":[0,"@jacobbednarz"],"facebook":[0,null]}]]],"meta_description":[0,"The Cloudflare Terraform provider used to be manually maintained. With the help of our existing OpenAPI code generation pipeline, we’re now automatically generating the provider for better endpoint and attribute coverage, faster updates when new products are announced and a new API documentation site to top it all off. Read on to see how we pulled it all together."],"primary_author":[0,{}],"localeList":[0,{"name":[0,"Automatically generating Cloudflare’s Terraform provider - LL- koKR, zhCN, zhTW, esES, frFR, deDE"],"enUS":[0,"English for Locale"],"zhCN":[0,"Translated for Locale"],"zhHansCN":[0,"No Page for Locale"],"zhTW":[0,"Translated for Locale"],"frFR":[0,"Translated for Locale"],"deDE":[0,"Translated for Locale"],"itIT":[0,"English for Locale"],"jaJP":[0,"Translated for Locale"],"koKR":[0,"Translated for Locale"],"ptBR":[0,"English for Locale"],"esLA":[0,"English for Locale"],"esES":[0,"Translated for Locale"],"enAU":[0,"No Page for Locale"],"enCA":[0,"No Page for Locale"],"enIN":[0,"No Page for Locale"],"enGB":[0,"No Page for Locale"],"idID":[0,"No Page for Locale"],"ruRU":[0,"No Page for Locale"],"svSE":[0,"No Page for Locale"],"viVN":[0,"No Page for Locale"],"plPL":[0,"No Page for Locale"],"arAR":[0,"No Page for Locale"],"nlNL":[0,"English for Locale"],"thTH":[0,"English for Locale"],"trTR":[0,"English for Locale"],"heIL":[0,"English for Locale"],"lvLV":[0,"No Page for Locale"],"etEE":[0,"No Page for Locale"],"ltLT":[0,"No Page for Locale"]}],"url":[0,"https://blog.cloudflare.com/automatically-generating-cloudflares-terraform-provider"],"metadata":[0,{"title":[0,"Automatically generating Cloudflare’s Terraform provider"],"description":[0,"The Cloudflare Terraform provider used to be manually maintained. With the help of our existing OpenAPI code generation pipeline, we’re now automatically generating the provider for better endpoint and attribute coverage, faster updates when new products are announced and a new API documentation site to top it all off. Read on to see how we pulled it all together."],"imgPreview":[0,"https://cf-assets.www.cloudflare.com/zkvhlag99gkb/2jMU7eEhUq17urGaWaoNmk/b4ba02608e15e967c21348c69ccb5ee0/Automatically_generating_Cloudflare_s_Terraform_provider-OG.png"]}]}],"locale":[0,"zh-cn"],"translations":[0,{"posts.by":[0,"作者"],"footer.gdpr":[0,"GDPR"],"lang_blurb1":[0,"这篇博文也有 {lang1} 版本。"],"lang_blurb2":[0,"这篇博文也有 {lang1} 和{lang2}版本。"],"lang_blurb3":[0,"这篇博文也有 {lang1}、{lang2} 和{lang3}版本。"],"footer.press":[0,"新闻"],"header.title":[0,"Cloudflare 博客"],"search.clear":[0,"清除"],"search.filter":[0,"过滤"],"search.source":[0,"来源"],"footer.careers":[0,"招聘"],"footer.company":[0,"公司"],"footer.support":[0,"支持"],"footer.the_net":[0,"theNet"],"search.filters":[0,"过滤器"],"footer.our_team":[0,"我们的团队"],"footer.webinars":[0,"网络研讨会"],"page.more_posts":[0,"更多帖子"],"posts.time_read":[0,"{time} 分钟阅读时间"],"search.language":[0,"语言"],"footer.community":[0,"社区"],"footer.resources":[0,"资源"],"footer.solutions":[0,"解决方案"],"footer.trademark":[0,"商标"],"header.subscribe":[0,"订阅"],"footer.compliance":[0,"合规性"],"footer.free_plans":[0,"Free 计划"],"footer.impact_ESG":[0,"影响/ESG"],"posts.follow_on_X":[0,"在 X 上关注"],"footer.help_center":[0,"帮助中心"],"footer.network_map":[0,"网络地图"],"header.please_wait":[0,"请稍候"],"page.related_posts":[0,"相关帖子"],"search.result_stat":[0,"针对 {search_keyword} 的第 {search_range} 个搜索结果(共 {search_total} 个结果)"],"footer.case_studies":[0,"案例研究"],"footer.connect_2024":[0,"Connect 2024"],"footer.terms_of_use":[0,"服务条款"],"footer.white_papers":[0,"白皮书"],"footer.cloudflare_tv":[0,"Cloudflare TV"],"footer.community_hub":[0,"社区中心"],"footer.compare_plans":[0,"比较各项计划"],"footer.contact_sales":[0,"联系销售"],"header.contact_sales":[0,"联系销售团队"],"header.email_address":[0,"电子邮件地址"],"page.error.not_found":[0,"未找到页面"],"footer.developer_docs":[0,"开发人员文档"],"footer.privacy_policy":[0,"隐私政策"],"footer.request_a_demo":[0,"请求演示"],"page.continue_reading":[0,"继续阅读"],"footer.analysts_report":[0,"分析报告"],"footer.for_enterprises":[0,"企业级服务"],"footer.getting_started":[0,"开始使用"],"footer.learning_center":[0,"学习中心"],"footer.project_galileo":[0,"Project Galileo"],"pagination.newer_posts":[0,"较新的帖子"],"pagination.older_posts":[0,"较旧的帖子"],"posts.social_buttons.x":[0,"在 X 上讨论"],"search.icon_aria_label":[0,"搜索"],"search.source_location":[0,"来源/位置"],"footer.about_cloudflare":[0,"关于 Cloudflare"],"footer.athenian_project":[0,"Athenian Project"],"footer.become_a_partner":[0,"成为合作伙伴"],"footer.cloudflare_radar":[0,"Cloudflare Radar"],"footer.network_services":[0,"网络服务"],"footer.trust_and_safety":[0,"信任与安全"],"header.get_started_free":[0,"免费开始使用"],"page.search.placeholder":[0,"搜索 Cloudflare"],"footer.cloudflare_status":[0,"Cloudflare 状态"],"footer.cookie_preference":[0,"Cookie 首选项"],"header.valid_email_error":[0,"必须是有效的电子邮件地址。"],"search.result_stat_empty":[0,"显示第 {search_range} 个结果(共 {search_total} 个结果)"],"footer.connectivity_cloud":[0,"全球连通云"],"footer.developer_services":[0,"开发人员服务"],"footer.investor_relations":[0,"投资者关系"],"page.not_found.error_code":[0,"错误代码:404"],"search.autocomplete_title":[0,"请输入查询内容。按回车键发送"],"footer.logos_and_press_kit":[0,"标识与媒体资料包"],"footer.application_services":[0,"应用程序服务"],"footer.get_a_recommendation":[0,"获得推荐"],"posts.social_buttons.reddit":[0,"在 Reddit 上讨论"],"footer.sse_and_sase_services":[0,"SSE 和 SASE 服务"],"page.not_found.outdated_link":[0,"您可能使用了过期的链接,或者输入了错误的地址。"],"footer.report_security_issues":[0,"报告安全问题"],"page.error.error_message_page":[0,"抱歉,我们找不到您要打开的页面。"],"header.subscribe_notifications":[0,"订阅以接收新文章的通知:"],"footer.cloudflare_for_campaigns":[0,"Cloudflare for Campaigns"],"header.subscription_confimation":[0,"订阅已确认。感谢订阅!"],"posts.social_buttons.hackernews":[0,"在 Hacker News 上讨论"],"footer.diversity_equity_inclusion":[0,"多元、公平与包容"],"footer.critical_infrastructure_defense_project":[0,"关键基础设施防护项目"]}]}" ssr="" client="load" opts="{"name":"PostCard","value":true}" await-children="">2024-09-24
Cloudflare Terraform 提供者过去是手动维护的。借助我们现有的 OpenAPI 代码生成管道,我们现在可以自动生成提供者,以更好地覆盖端点和属性,在新产品发布时更快更新,新增了一个新的 API 文档网站。请继续阅读,了解我们是如何做到的。...
继续阅读 »