在 Azure 上开始使用 Terraform:函数、表达式和循环
目录
-
先决条件
-
步骤 1 — 功能
-
步骤 2 — 复杂表达式
-
步骤 3 — 循环
-
结论
随着我们开始使用 Terraform 构建基础设施,用于构建互连模块的逻辑变得更加复杂。这就是为什么必须了解 HCL(Hashicorp 配置语言)的选项和功能。这不仅可以更轻松地在我们的代码中创建必要的逻辑流,而且我们还可以优化我们的配置。以不同的方式编写 Terraform 配置可能会提高部署的性能。
了解如何创建循环和高级表达式可以让我们的代码看起来更清晰,并让我们的队友在阅读 Terraform 配置时更准确地了解基础设施。请记住,我们的 Terraform 代码不只是为了部署我们的基础设施并完成工作。 HCL 的目标是通过提供易于阅读并同时与命令行良好配合的代码来弥合人机友好界面之间的差距。
在本指南中,我们将创建一个模块并学习如何在 Terraform 配置中使用函数、表达式和循环。
先决条件
如果您想遵循本指南中的概念,则需要设置以下内容:
-
Azure 订阅。
-
Azure 云外壳。请务必查看“Azure 上的 Terraform 入门:部署资源”中的先决条件,以获取有关如何设置的指南。
步骤 1 — 功能
HCL 包含内置函数,可以在表达式中调用这些函数以逻辑地为我们的参数创建值。使用 Terraform,我们可以使用交互式控制台来测试我们的 Terraform 表达式,而不必每次都运行我们的 Terraform 配置。只需在安装 Terraform 的位置输入以下语法。这也适用于 Azure Cloud Shell:
terraform console
您将被引导至 Terraform 控制台,该控制台将显示>。您可以在自己的 Terraform 控制台中测试本指南中的其中一些功能示例。有很多内置函数,但我们将介绍最常用的:
Lower 对于需要始终小写的值(如存储帐户名称)很有用:
> lower("TFStoragesta")
tfstoragesta
替换对于操作某些值的命名方案或数字格式很有用。格式如下:
replace(string, substring, replacement)
一个简单的用例是替换文本中的字符串:
> replace("Luke likes CloudSkills", "likes", "loves")
Luke loves CloudSkills
更实际的用例是将replace函数与正则表达式查询一起使用,以将资源 ID 字符串上的任何订阅 ID 替换为特定订阅:
> replace("/subscriptions/f8c37571-4325-4a97-977b-7a216e64bae3/resourceGroups/rg-remotestatetfc", "/[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/", "c1c37531-2355-4a57-947b-4e236e64bae3")
/subscriptions/c1c37531-2355-4a57-947b-4e236e64bae3/resourceGroups/rg-remotestatetfc
Min 和 max 用于查找一组数字中的最大值和最小值,这对于确定最大或最小资源很有用:
> max(32,64,99)
99
> min (32,128,1024)
32
Split 可用于从单个值中获取两个字符串值。例如,可以将Standard_LRS存储类型字符串拆分为azurerm_storage_account资源,该资源采用以下两个参数表示account_teir和account_replication_type:
resource "azurerm_storage_account" "example" {
name = "storageaccountname"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
account_tier = "Standard"
account_replication_type = "LRS"
}
我们可以使用Standard_LRS的变量输入并使用 split 来获取每个值,从而减少所需的变量数量:
> split("_","Standard_LRS")
[
"Standard",
"LRS",
]
Element 可用于访问列表的每个索引,方法是使用元素函数包装我们的 split 函数并指定索引值:
> element(split("_","Standard_LRS"), 0)
Standard
如果我们为Standard_LRS字符串创建了一个输入变量,我们就可以像下面这样编写我们的资源块:
resource "azurerm_storage_account" "example" {
name = "storageaccountname"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
account_tier = element(split("_","var.storage_type"), 0)
account_replication_type = element(split("_","var.storage_type"), 1)
}
Coalesce 将采用一系列参数并返回第一个不为 null 或空字符串""的参数:
> coalesce("40", "50", "20")
40
> coalesce( "", "50", "20")
50
这对于创建一组可选变量并选择包含值的变量很有用:
**注:**本文有几种配置会被截断为
...,以减少本文代码量。
resource "azurerm_virtual_machine" "main" {
name = "${var.prefix}-vm"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
network_interface_ids = [azurerm_network_interface.main.id]
vm_size = coalesce( var.small, var.medium, var.large)
...
Length 返回字符串、列表或映射的数字长度:
> length(["32GB", "64GB"])
2
这对于获取项目的编号非常有用。对于上面的示例,我们可以确定我们有两个磁盘大小,我们将需要创建 2 个磁盘。然后,我们可以将此值输入一个循环,我们将在本指南后面进行演示。
Setproduct 将显示给定集合的所有可能组合:
> setproduct(["development", "staging", "production"], ["EastUS", "WestUS"])
[
[
"development",
"EastUS",
],
[
"development",
"WestUS",
],
[
"staging",
"EastUS",
],
[
"staging",
"WestUS",
],
[
"production",
"EastUS",
],
[
"production",
"WestUS",
],
]
File 将读取文件的内容并将其作为字符串返回:
> file("${path.module}/test.ps1")
#This is an empty PS1
这在使用template_file数据源类型时非常有用,它允许您将变量插入到文件中,然后将它们传递到另一个资源中以供使用。下面我们有一个test.ps1文件,它将设置服务器的 DNS 地址。我们在 PowerShell 脚本中插入了两个变量,分别表示为${DNS_Server1}和${DNS_Server2}:
# test.ps1
Set-DnsClientServerAddress -InterfaceIndex 12 -ServerAddresses ("${DNS_Server1}","{DNS_Server2}")
接下来,我们可以使用template_file资源指定test.ps1脚本,并将脚本内的${DNS_Server1}和${DNS_Server2}变量替换为vars块中声明的各自值。然后,我们可以通过在虚拟机资源块的custom_data属性中使用data.template_file.init.rendered来引用新转换的test.ps1文件,该文件包含 DNS 服务器的正确值:
data "template_file" "init" {
template = "${file("./test.ps1")}"
vars = {
DNS_Server1 = "10.0.0.1"
DNS_Server2 = "10.0.0.2"
}
resource "azurerm_virtual_machine" "main" {
...
os_profile {
computer_name = "myserver"
admin_username = "adminuser"
admin_password = "badpassword123"
custom_data = data.template_file.init.rendered
}
...
}
函数是在我们的配置中操作数据的好方法,但是,有时需要在我们的代码中包含更多逻辑。这就是表达式发挥作用的地方。
第 2 步 - 复杂表达式
表达式用于计算 HCL 配置中的值。复杂的表达式允许我们为部署的资源提供额外的逻辑。以下是我们可以在代码中使用的一些流行表达式的示例:
运营商
运算符要么组合两个值的结果并产生第三个值,要么取一个值并对其进行转换。配置中使用的典型运算符如下:
-
Equal
a==b如果a等于b则结果为真。 -
Not Equal
a != b如果a不等于b,则结果为真。 -
Either Or
a || b如果a或b为真,则结果为真。 -
Both True
a && b如果a和b都为真,则结果为真。 -
Not True
!a如果 a 为假,则结果为真。
条件
条件根据两个布尔(真或假)表达式的结果确定值:
condition ? true : false
这通常用于在 Terraform 配置中设置默认值。例如,如果我们有一个虚拟机模块并希望提供将 VM 部署到与其资源组不同的位置的选项,我们可以为vm_location创建一个变量并为""设置默认值,如下所示:
variable "vm_location" {
type = string
description = "Azure location of terraform server environment"
default = ""
}
在我们的虚拟机资源块中,我们可以为location参数使用条件表达式。如果var.vm_location不是空字符串,则条件表达式提供了使用该值的逻辑。如果var.vm_location仍然是一个空字符串,因为模块的用户从未指定过,那么条件表达式提供了默认为我们的资源组位置的逻辑:
resource "azurerm_virtual_machine" "main" {
name = "MyVMName"
location = var.vm_location != "" ? var.vm_location : azurerm_resource_group.main.location
...
用于表达式
For 表达式允许我们取一个值并改变它。我们可以使用它来遍历值列表并修改它们。例如,我们可以从azurerm_virtual_network数据源中获取子网列表,并使用for表达式遍历每个子网并将输出格式更改为小写:
data "azurerm_virtual_network" "example" {
name = "LukeLab-NC-Prod-Vnet"
resource_group_name = "NetworkingTest-RG"
}
output "subnets" {
value = [for s in data.azurerm_virtual_network.example.subnets : lower(s)]
}
运行此配置时,输出如下:
Outputs:
subnets = [
"lukeapp-nc-prod-subnet",
"lukeapp5-nc-prod-subnet",
"lukeapp4-nc-prod-subnet",
"lukeapp3-nc-prod-subnet",
"lukeapp2-nc-prod-subnet",
]
注意 for 表达式包含在[]中。这将为我们提供一个元组输出。我们还可以通过将 for 表达式包装在{}中来将输出类型更改为对象。此外,我们还必须包含两个结果表达式,使用=>符号将它们分开:
data "azurerm_virtual_network" "example" {
name = "LukeLab-NC-Prod-Vnet"
resource_group_name = "NetworkingTest-RG"
}
output "subnets" {
value = {for s in data.azurerm_virtual_network.example.subnets : s => lower(s)}
}
输出然后更改为没有两个结果的对象类型:
Outputs:
subnets = {
"LukeApp-NC-Prod-Subnet" = "lukeapp-nc-prod-subnet"
"LukeApp2-NC-Prod-Subnet" = "lukeapp2-nc-prod-subnet"
"LukeApp3-NC-Prod-Subnet" = "lukeapp3-nc-prod-subnet"
"LukeApp4-NC-Prod-Subnet" = "lukeapp4-nc-prod-subnet"
"LukeApp5-NC-Prod-Subnet" = "lukeapp5-nc-prod-subnet"
}
我们还可以使用 for 表达式执行更高级的逻辑。我们可以通过包含一个 if 语句和一些逻辑来过滤掉我们的结果。在此示例中,我们仅列出非空字符串的子网:
[for s in data.azurerm_virtual_network.example.subnets : lower(s) if s != ""
对于这方面的更高级示例,我们可以使用regexall过滤我们的结果以仅包含满足我们的正则表达式要求的子网。在此示例中,我们仅选择名称中带有 LukeApp 2-4 的子网。另外,请注意,我们使用length函数包装了regexall函数,然后检查以确保它大于 0。这是验证与正则表达式要求匹配的字符串的推荐方法。如果我们跳过使用length,当正则表达式查询与其他子网不匹配时,if 表达式将出错:
data "azurerm_virtual_network" "example" {
name = "LukeLab-NC-Prod-Vnet"
resource_group_name = "NetworkingTest-RG"
}
output "subnets" {
value = [for s in data.azurerm_virtual_network.example.subnets : s if length(regexall("LukeApp[2-4]-NC-Prod-Subnet", s)) > 0]
}
当我们运行它时,我们只得到与我们的正则表达式匹配的子网:
Outputs:
subnets = [
"LukeApp4-NC-Prod-Subnet",
"LukeApp3-NC-Prod-Subnet",
"LukeApp2-NC-Prod-Subnet",
]
Splat 表达式
Splat 表达式是执行某些与for表达式相同的任务的更简洁的方式。在我们之前的for表达式示例中,我们列出了虚拟网络的子网,如下所示:
[for s in data.azurerm_virtual_network.example.subnets : s]
相反,我们可以像这样使用 splat 表达式。 [*] 符号指示我们遍历列表中的所有元素:
data.azurerm_virtual_network.example[*].subnets
我们还可以引用属性的索引来选择列表中的第一个子网:
data.azurerm_virtual_network.example[*].subnets[0]
复杂表达式是向 Terraform 配置添加逻辑并允许我们创建多功能模块的好方法。例如,如果我们希望部署在开发环境中的所有 VM 都使用速度较慢、成本较低的磁盘类型,我们可以使用条件表达式对逻辑进行编码。使用变量var.environment来说明我们要部署到的环境,然后我们可以引用该变量以有条件地决定使用Standard_LRS或Premium_LRS磁盘类型:
resource "azurerm_virtual_machine" "main" {
...
storage_os_disk {
name = "myosdisk1"
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = var.environment == "Development" ? "Standard_LRS" : "Premium_LRS"
}
...
函数和复杂的表达式可以完成很多繁重的工作,但是我们的工具带中还有一个工具可以帮助简化我们的代码。在下一节中,我们将介绍循环。
步骤 3 — 循环
Terraform 中的循环可以在很多方面帮助我们。我们可以通过循环快速迭代一组组件。我们还可以使用循环有效地扩展我们的资源。以下是我们可以在 Terraform 中循环资源的一些方法:
计数
计数是 Terraform 配置中非常流行的技术。它允许我们创建资源块的多个实例。几乎所有的资源块都可以使用count属性。下面是一个azurerm_resource_group资源块的示例,它的count属性设置为3。这告诉 Terraform 创建此资源三次。另请注意,我们每次都在修改资源组的名称;否则,我们的代码会出错,因为我们不能有重复名称的资源组。我们正在使用count.index变量,只有在使用count参数时才能访问该变量。count.index指定我们资源的索引值,它是分配给该资源的“列表中的位置”的数字。索引编号从 0 开始。因此,如果我们正在部署一个count为3的资源,我们将拥有 0、1 和 2 三个索引:
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
count = 3
name = "rg-MyResourceGroup-${count.index}"
location = "West US 2"
}
当我们针对这个配置运行terraform plan时,我们可以看到我们将部署三个资源组:
Terraform will perform the following actions:
# azurerm_resource_group.main[0] will be created
+ resource "azurerm_resource_group" "main" {
+ id = (known after apply)
+ location = "westus2"
+ name = "rg-MyResourceGroup-0"
}
# azurerm_resource_group.main[1] will be created
+ resource "azurerm_resource_group" "main" {
+ id = (known after apply)
+ location = "westus2"
+ name = "rg-MyResourceGroup-1"
}
# azurerm_resource_group.main[2] will be created
+ resource "azurerm_resource_group" "main" {
+ id = (known after apply)
+ location = "westus2"
+ name = "rg-MyResourceGroup-2"
}
Plan: 3 to add, 0 to change, 0 to destroy.
Count 也用于条件逻辑。如果我们将资源的count值设置为0,Terraform 将部署该资源的 0 个副本。这对于创建仅在某些情况下部署资源的逻辑很方便。在下面的示例中,我们有一个布尔变量bootdiag_storage。我们还有一个用于 azure 存储的资源块,用于我们的启动诊断存储。注意count参数现在有一个条件表达式来评估bootdiag_storage变量是否为真。如果该变量设置为 true,则将count参数设置为1,表示我们将部署此资源。如果该变量设置为 false,则将count设置为0,表示不会部署azure_storage_account资源:
provider "azurerm" {
features {}
}
variable "bootdiag_storage" {
type = bool
description = "Enter the name of the boot diagnostic storage account if one is desired"
default = false
}
resource "azurerm_resource_group" "rg" {
name = "rg-MyResourceGroup"
location = "West US 2"
}
resource "azurerm_storage_account" "bootdiag" {
count = var.bootdiag_storage ? 1 : 0
name = "myterraformvmsadiag"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "GRS"
}
对于_each
For_each 循环允许我们遍历列表或映射。这使我们能够拥有更简洁的代码。例如,我们可以简单地创建一个名为inbound_rules的映射变量,而不是为我们要为 NSG 创建的每个规则创建一个azurerm_network_security_rule资源。然后我们可以使用for_each循环遍历映射中的每个项目并为每个项目创建一个规则.另外,请注意,我们在配置中使用each.key和each.value变量来指定映射中的键和值对:
provider "azurerm" {
features {}
}
variable "inbound_rules" {
type = map
description = "A map of allowed inbound ports and their priority value"
default = {
101 = 3389
102 = 22
103 = 443
}
}
resource "azurerm_resource_group" "rg" {
name = "rg-mytesourcegroup"
location = "West US 2"
}
resource "azurerm_network_security_group" "nsg" {
name = "nsg-mysecuritygroup"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
resource "azurerm_network_security_rule" "nsg_rule" {
for_each = var.inbound_rules
name = "port_${each.value}"
priority = each.key
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = each.value
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
当我们运行terraform plan时,我们可以看到我们所有的 NSG 规则资源块都将使用映射中的值创建:
Terraform will perform the following actions:
# azurerm_network_security_group.nsg will be created
+ resource "azurerm_network_security_group" "nsg" {
+ id = (known after apply)
+ location = "westus2"
+ name = "nsg-mysecuritygroup"
+ resource_group_name = "rg-mytesourcegroup"
+ security_rule = (known after apply)
}
# azurerm_network_security_rule.nsg_rule["101"] will be created
+ resource "azurerm_network_security_rule" "nsg_rule" {
+ access = "Allow"
+ destination_address_prefix = "*"
+ destination_port_range = "3389"
+ direction = "Inbound"
+ id = (known after apply)
+ name = "port_3389"
+ network_security_group_name = "nsg-mysecuritygroup"
+ priority = 101
+ protocol = "Tcp"
+ resource_group_name = "rg-mytesourcegroup"
+ source_address_prefix = "*"
+ source_port_range = "*"
}
# azurerm_network_security_rule.nsg_rule["102"] will be created
+ resource "azurerm_network_security_rule" "nsg_rule" {
+ access = "Allow"
+ destination_address_prefix = "*"
+ destination_port_range = "22"
+ direction = "Inbound"
+ id = (known after apply)
+ name = "port_22"
+ network_security_group_name = "nsg-mysecuritygroup"
+ priority = 102
+ protocol = "Tcp"
+ resource_group_name = "rg-mytesourcegroup"
+ source_address_prefix = "*"
+ source_port_range = "*"
}
# azurerm_network_security_rule.nsg_rule["103"] will be created
+ resource "azurerm_network_security_rule" "nsg_rule" {
+ access = "Allow"
+ destination_address_prefix = "*"
+ destination_port_range = "443"
+ direction = "Inbound"
+ id = (known after apply)
+ name = "port_443"
+ network_security_group_name = "nsg-mysecuritygroup"
+ priority = 103
+ protocol = "Tcp"
+ resource_group_name = "rg-mytesourcegroup"
+ source_address_prefix = "*"
+ source_port_range = "*"
}
# azurerm_resource_group.rg will be created
+ resource "azurerm_resource_group" "rg" {
+ id = (known after apply)
+ location = "westus2"
+ name = "rg-mytesourcegroup"
}
Plan: 5 to add, 0 to change, 0 to destroy.
动态块
包含可重复配置块的资源可以使用动态块。例如,azurerm_virtual_machine资源包括一个storage_data_disk块,用于定义我们的 VM 数据磁盘的配置。该块可在azurerm_virtual_machine内多次使用,指定多个数据盘。我们可以使用动态块来简化代码,而不是一遍又一遍地重复相同的块结构。这是通过在块前面加上dynamic并使用for_each迭代我们的storage_data_disk块来完成的。
在此示例中,我们有一个变量disk_size_gb,它可以接受一串以逗号分隔的数字来指示要添加的磁盘的大小。因此,如果我们想要三个大小分别为 32GB、64GB 和 128GB 的磁盘,我们将在terraform.tfvars文件中将此变量设置为“32,64,128”。
在dynamic storage_data_disk块下的for_each参数中,我们使用split函数来分隔我们的磁盘大小值并遍历每个值。我们使用storage_data_disk.value来引用每个磁盘大小的disk_size_gb中每个数字的值。此外,我们使用storage_data_disk.key来引用storage_data_disk块的索引号,并在name和lun参数中使用该值来提供唯一值:
variable "disk_size_gb" {
description = "Size of disks in GBs. Choose multiple disks by comma separating each size"
type = string
default = "64"
}
# Create virtual machine
resource "azurerm_virtual_machine" "vm" {
name = var.servername
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
network_interface_ids = [azurerm_network_interface.nic.id]
vm_size = "Standard_D2s_v3"
...
dynamic storage_data_disk {
for_each = split(",", var.disk_size_gb)
content {
name = "${var.servername}_datadisk_${storage_data_disk.key}"
create_option = "Empty"
lun = storage_data_disk.key
disk_size_gb = storage_data_disk.value
managed_disk_type = "StandardSSD_LRS"
}
}
...
当我们针对此配置运行terraform plan时,我们会得到以下输出,指定三个磁盘的storage_data_disk个块:
# azurerm_virtual_machine.vm will be created
+ resource "azurerm_virtual_machine" "vm" {
+ availability_set_id = (known after apply)
+ delete_data_disks_on_termination = false
+ delete_os_disk_on_termination = false
+ id = (known after apply)
+ license_type = (known after apply)
+ location = "westus2"
+ name = "vmterraform"
+ network_interface_ids = (known after apply)
+ resource_group_name = "rg-terraexample"
+ tags = (known after apply)
+ vm_size = "Standard_D2s_v3"
+ identity {
+ identity_ids = (known after apply)
+ principal_id = (known after apply)
+ type = (known after apply)
}
+ os_profile {
+ admin_password = (sensitive value)
+ admin_username = "joeblow"
+ computer_name = "vmterraform"
+ custom_data = "c991e3a089d3ad26ec85af93f698c375db0933f2"
}
+ os_profile_windows_config {
+ enable_automatic_upgrades = false
+ provision_vm_agent = true
+ additional_unattend_config {
+ component = "Microsoft-Windows-Shell-Setup"
+ content = (sensitive value)
+ pass = "oobeSystem"
+ setting_name = "FirstLogonCommands"
}
+ additional_unattend_config {
+ component = "Microsoft-Windows-Shell-Setup"
+ content = (sensitive value)
+ pass = "oobeSystem"
+ setting_name = "AutoLogon"
}
}
+ storage_data_disk {
+ caching = (known after apply)
+ create_option = "empty"
+ disk_size_gb = 32
+ lun = 0
+ managed_disk_id = (known after apply)
+ managed_disk_type = "StandardSSD_LRS"
+ name = "vmterraform_datadisk_0"
+ write_accelerator_enabled = false
}
+ storage_data_disk {
+ caching = (known after apply)
+ create_option = "empty"
+ disk_size_gb = 64
+ lun = 1
+ managed_disk_id = (known after apply)
+ managed_disk_type = "StandardSSD_LRS"
+ name = "vmterraform_datadisk_1"
+ write_accelerator_enabled = false
}
+ storage_data_disk {
+ caching = (known after apply)
+ create_option = "empty"
+ disk_size_gb = 128
+ lun = 2
+ managed_disk_id = (known after apply)
+ managed_disk_type = "StandardSSD_LRS"
+ name = "vmterraform_datadisk_2"
+ write_accelerator_enabled = false
}
+ storage_image_reference {
+ offer = "WindowsServer"
+ publisher = "MicrosoftWindowsServer"
+ sku = "2019-Datacenter"
+ version = "latest"
}
+ storage_os_disk {
+ caching = "ReadWrite"
+ create_option = "FromImage"
+ disk_size_gb = (known after apply)
+ managed_disk_id = (known after apply)
+ managed_disk_type = "Premium_LRS"
+ name = "stvmvmterraformos"
+ os_type = (known after apply)
+ write_accelerator_enabled = false
}
}
循环非常适合减少重复代码和创建可扩展资源。但是,它们也会为其他阅读 Terraform 配置的人增加额外的复杂性,因此在 Terraform 代码中使用循环时要明智地选择。
结论
在本文中,我们了解了函数、复杂表达式和循环。我们回顾了示例,并在我们的 Terraform 代码中讨论了它们的好处和用例。 HCL 语言既可读又机器友好,足以执行本指南中描述的复杂逻辑。但是,编写基础架构代码需要平衡,不仅可以完成工作,还可以为其他团队成员提供文档。在有意义的地方使用它们,但不要到代码难以理解的地步。请记住,简单性是创建持久自动化解决方案的关键组成部分。
更多推荐

所有评论(0)