今天的任務是來重構昨天所撰寫的 Pulumi 程式,首先先來回顧一下昨天產生的 VPC 結構:
可以從圖中發現,所有的資源幾乎都是用雲端預設的名稱,比較不好辨認。
今天我們首要任務就是設定雲端資源的名稱。
在 AWS 中不一定每個資源都有 name,有些是透過 Tags 中的 Name 做為顯示的名稱。有些資源甚至連名稱都沒有,例如 RouteTableAssociation,這個資源就是用來關聯 RouteTable 與 Subnet,不需要特別給 name。
通常來說,我們都希望一起建立的相關資源都有相同的 Tags 標示,讓我們之後可以透過 Tag 來辨識資源。
例如說我們想要為每個資源都加上 project、stack、IaC tool 的 Tag,可以先將這些通用的 Tag 存放到一個變數中,再到每個資源中參考這個變數。
// 在檔案最上方引用 @pulumi/pulumi 套件
import * as pulumi from "@pulumi/pulumi";
// 定義 defaultTags 變數,待會每個資源都要有這些變數
const defaultTags = {
"pulumi:project": pulumi.getProject(),
"pulumi:stack": pulumi.getStack(),
"ManagedBy": "Pulumi",
};
接著我們將原本的 VPC、Subnet、RouteTable、Internet Gateway、NAT Gateway 等資源都加上 Name 與預設的 Tag。
將原本的 myVpc 加上 tags:
const myVpc = new aws.ec2.Vpc("my-vpc", {
cidrBlock: "10.120.0.0/16",
tags: {
"Name": "my-vpc",
...defaultTags
}
});
將所有資源都改完了,執行 pulumi up
,就可以看到以下這些資源都會被加上 Tag 了!
Type Name Status Info
pulumi:pulumi:Stack aws-vpc-ts-dev
~ ├─ aws:ec2:Vpc my-vpc updated (2s) [diff: ~tags]
~ ├─ aws:ec2:Eip my-nat-gateway-eip updated (1s) [diff: ~tags]
~ ├─ aws:ec2:Subnet my-public-subnet-1 updated (0.60s) [diff: ~tags]
~ ├─ aws:ec2:Subnet my-private-subnet-ap-east-1a updated (0.84s) [diff: ~tags]
~ ├─ aws:ec2:DefaultRouteTable my-default-rt updated (1s) [diff: ~tags]
~ ├─ aws:ec2:InternetGateway my-igw updated (1s) [diff: ~tags]
~ ├─ aws:ec2:Subnet my-private-subnet-ap-east-1b updated (1s) [diff: ~tags]
~ ├─ aws:ec2:Subnet my-public-subnet-2 updated (1s) [diff: ~tags]
~ ├─ aws:ec2:NatGateway my-nat-gateway updated (1s) [diff: ~tags]
~ ├─ aws:ec2:RouteTable my-private-subnet-ap-east-1b-rt updated (0.59s) [diff: ~tags]
~ └─ aws:ec2:RouteTable my-private-subnet-ap-east-1a-rt updated (0.80s) [diff: ~tags]
透過 AWS VCP Resource Map 可以看到所有資源都被成功設定 Name tag 了。
也能在 VPC 的 Tags 面板上看到設定上去的 Tags。
接著我們來處理寫死的 Subnet CIDR。我希望可以有個 function,讓我傳入 VPC 的 CIDR,並指定一個更小的子網路遮罩,就能產生所有可用的子網路分割後的 CIDR 列表。其實就是實作 Terraform 的 cidrsubnet function。
由於 Node.JS 的套件中,找不到類似功能的套件可以使用,就請 ChatGPT 幫忙寫一下,以下是 AI 生成的 Code。
在 Node.JS 版本中,ChatGPT 產生的程式碼有用到第三方套件,需要透過 npm install ip
安裝 ip
套件。因為 ip
套件是使用 JavaScript 撰寫的,並且套件內沒有附上型別定義檔案 (declaration files),因此我們需要再安裝 @types/ip
提供 ip
套件的型別支援。
import * as ip from 'ip';
function splitSubnets(baseCidr: string, newPrefixLength: number) {
let subnets = [];
let baseSubnet = ip.cidrSubnet(baseCidr);
let firstAddress = ip.toLong(baseSubnet.firstAddress);
let lastAddress = ip.toLong(baseSubnet.lastAddress);
let mask = ip.fromPrefixLen(newPrefixLength);
let increment = ip.toLong(mask) - ip.toLong('255.255.255.255');
for (let i = firstAddress; i <= lastAddress; i += Math.abs(increment) + 1) {
let subnet = ip.fromLong(i - 1) + '/' + newPrefixLength;
subnets.push(subnet);
}
return subnets;
}
Python 版本的程式碼中,是使用內建的 ipaddress package 協助我們計算子網路,不需要再安裝其他 package。
from ipaddress import ip_network, IPv4Address
def split_subnets(base_cidr, new_prefix_length):
subnets = []
base_subnet = ip_network(base_cidr)
first_address = int(base_subnet.network_address)
last_address = int(base_subnet.broadcast_address)
mask = ip_network(f"0.0.0.0/{new_prefix_length}")
increment = int(mask.netmask) - int(IPv4Address('255.255.255.255'))
i = first_address
while i <= last_address:
subnet = f"{IPv4Address(i)}/{new_prefix_length}"
subnets.append(subnet)
i += abs(increment) + 1
return subnets
有了自動計算子網路分割的函式後,我們就可以來重構 Pulumi 的程式了。
首先我們先將 ChatGPT 產生的程式碼放到另一個 TypeScript 檔案,並在 index.ts 中 import 進來。
新增一個 cidr_subnet_utils.ts
並修改 ChatGPT 的程式,將 splitSubnets 函式 export。並補上一些 TypeScript 的型別定義。
import * as ip from 'ip';
export function splitSubnets(baseCidr: string, newPrefixLength: number): string[] {
let subnets: string[] = [];
let baseSubnet = ip.cidrSubnet(baseCidr);
let firstAddress = ip.toLong(baseSubnet.firstAddress);
let lastAddress = ip.toLong(baseSubnet.lastAddress);
let mask = ip.fromPrefixLen(newPrefixLength);
let increment = ip.toLong(mask) - ip.toLong('255.255.255.255');
for (let i = firstAddress; i <= lastAddress; i += Math.abs(increment) + 1) {
let subnet = ip.fromLong(i - 1) + '/' + newPrefixLength;
subnets.push(subnet);
}
return subnets;
}
接著我們在 index.ts
上 import splitSubnets 函式。
import {splitSubnets} from './cidr_subnet_utils';
在我們的 VPC 中,有兩個 Public Subnet、兩個 Private Subnet。我希望使用 splitSubnets 得到的子網路,平均分給 Public Subnet 與 Private 使用。
// 宣告一個變數儲存 vpcCidr
const vpcCidr = '10.120.0.0/16';
// 使用 splitSubnets 函式,將 10.120.0.0/16 分割成 256 個 /24 的子網路
const subnetting = splitSubnets(vpcCidr, 24);
// 前 128 個子網路為 public 子網路
// 10.120.0.0/24, 10.120.1.0/24 .... 10.120.127.0/24
const publicSubnetCidrs = subnetting.slice(0, subnetting.length / 2);
// 後 128 個子網路為 private 子網路
// 10.120.128.0/24, 10.120.129.0/24, ... 10.120.255.0/24
const privateSubnetCidrs = subnetting.slice(subnetting.length / 2);
接著修改 vpc 的 cidrBlock,參考 vpcCidr 變數:
const myVpc = new aws.ec2.Vpc("my-vpc", {
cidrBlock: vpcCidr,
tags: {
"Name": "my-vpc",
...defaultTags
}
});
然後修改 Public Subnet 的 cidrBlock,參考 publicSubnetCidrs 的值
const publicSubnets: Record<string, aws.ec2.Subnet> = {
'my-public-subnet-1': new aws.ec2.Subnet("my-public-subnet-1", {
vpcId: myVpc.id,
cidrBlock: publicSubnetCidrs[0],
availabilityZone: 'ap-east-1a',
tags: {
"Name": "my-public-subnet-1",
...defaultTags
}
}),
'my-public-subnet-2': new aws.ec2.Subnet("my-public-subnet-2", {
vpcId: myVpc.id,
cidrBlock: publicSubnetCidrs[1],
availabilityZone: 'ap-east-1b',
tags: {
"Name": "my-public-subnet-2",
...defaultTags
}
})
};
最後,修改 Private Subnet 的部分,由於昨天已經將 Private Subnet 的 cidr、az 都放到 privateSubnetArgs Object 中了,因此我們直接修改這個變數的內容就可以了。
const privateSubnetArgs = [
{cidr: privateSubnetCidrs[0], az: 'ap-east-1a'},
{cidr: privateSubnetCidrs[1], az: 'ap-east-1b'}
];
全部修改完了,我們只用 pulumi up
驗證沒有任何的資源需要修改。重構的精神就是不修改原有程式運行的行為下,讓程式的可讀性、可維護性變的更高。
Previewing update (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/xxxxxx/aws-vpc-ts/dev/previews/6994442e-2d5e-4da8-a896-fd6d6ad209c5
Type Name Plan
pulumi:pulumi:Stack aws-vpc-ts-dev
Resources:
17 unchanged
Do you want to perform this update? yes
Updating (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/xxxxxx/aws-vpc-ts/dev/updates/13
Type Name Status
pulumi:pulumi:Stack aws-vpc-ts-dev
Resources:
17 unchanged
Duration: 6s
接著我們要來處理另一個寫死的值:availabilityZone。
在 Terraform 中,我們可以使用 aws_availability_zones data source 來取得 AWS 上的所有可用區列表。那在 Pulumi 中怎麼做呢?
之前提到過 Pulumi 的 aws package 是使用 Pulumi Terraform Adapter。Terraform 能做,Pulumi 就是要怎麼做的問題了。
這裡介紹一個 Pulumi 提供的工具:Pulumi AI
現在 LLM AI 大紅,Pulumi 也整合了 LLM,並製作出 Pulumi AI 讓使用者可以免費使用。我們可以直接問 Pulumi 任何我們想做的 IaC,他就會產生對應的範例給我們。而且更棒的是,所有 Pulumi 支援的程式語言,他能都產生。
這個連結是 Pulumi AI 回答如何得到非本地區域的可用區列表:https://www.pulumi.com/ai/conversations/f9ed3f18-971f-4efd-98f7-f7ebf8b70b2d。
先說結論,產生出來的 Code 使用的 aws.getAvailabilityZones
函式取得可用區列表的方式是正確的。但是,篩選出所有非本地區域的方式就是錯的了......
我們來修正一下他的錯誤,首先我們要知道,要使用的 resource 或是 function 應該去哪找文件。
Pulumi 的文件有三個地方可以找:
關於這個 aws.getAvailabilityZones
這個功能,透過 Pulumi 的文件,會看到他關於 filter 的用法也是連結到 AWS API reference 的文件中。
可以在文件中看到,如果要找出所有非本地區域,應該要設定 opt-in-status: opt-in-not-required。這是因為,所有本地區域都是 opt-in 的,如果要找非本地區域,可以透過 opt-in-not-required 篩選出不需 opt-in (預設都有的) 可用區。
const nonLocalAvailabilityZones = aws.getAvailabilityZones({
filters: [
{
name: "opt-in-status",
values: ["opt-in-not-required"],
},
],
});
好了,這樣我們就可以拿到所有非本地區域的可用區列表了......嗎?
事情並沒有這麼簡單,getAvailabilityZones
回傳的資料型別是 Promise<GetAvailabilityZonesResult>
。這邊的 Promise 就是目前 TypeScript/JavaScript 主流處理非同步的物件。
在其他程式語言的 getAvailabilityZones 幾乎都會回傳那個程式語言非同步的物件。例如 Java 會回傳 Future、.NET 會回傳 Async Task。
不過 Golang 與 Python 好像不用特別處理這個問題,呼叫完 getAvailabilityZones 就能拿到輸出了。
那要如何在 TypeScript 中處理這個問題呢?簡單,熟悉 Promise 與 ES6 的人都知道,直接加個 await 就能拿到 GetAvailabilityZonesResult 的結果了。
const nonLocalAvailabilityZones = await aws.getAvailabilityZones({
filters: [
{
name: "opt-in-status",
values: ["opt-in-not-required"],
},
],
});
答案是:不行!
因為我們的 Pulumi 專案中所有程式碼都是直接寫在 top level。而我們的專案類型中, TypeScript 並不支援 Top-level await expressions
。
這要怎麼辦呢?
其實,Pulumi TypeScript runtime 還提供了另一種方式執行 TypeScript 的程式,也就是預設如果 export 一個 function 的話,就會直接執行這個 function。
所以我們來重構程式,將原本的程式除了 import 以外的內容,全部搬進這個匿名 async function 中。
export = async function () {
// 除了 import 之外的所有 Code 都搬到這個 function 裡面
}
接著就可以在這個 function 內使用 await 取得被 resolve 的 Promise 結果了。
const nonLocalAvailabilityZones = await aws.getAvailabilityZones({
filters: [
{
name: "opt-in-status",
values: ["opt-in-not-required"],
},
],
});
// nonLocalAvailabilityZones 中會包含可用區的名稱、編號等資訊,我們只要拿可用區的名稱即可
const azNames = nonLocalAvailabilityZones.names;
接著修改 Public Subnet 與 Private Subnet 中的內容,以下只列出 availabilityZone 的部分,其他部分照舊。
const publicSubnets: Record<string, aws.ec2.Subnet> = {
'my-public-subnet-1': new aws.ec2.Subnet("my-public-subnet-1", {
......
availabilityZone: azNames[0],
......
}),
'my-public-subnet-2': new aws.ec2.Subnet("my-public-subnet-2", {
......
availabilityZone: azNames[1],
......
})
};
.....
const privateSubnetArgs = [
{cidr: privateSubnetCidrs[0], az: azNames[0]},
{cidr: privateSubnetCidrs[1], az: azNames[1]}
];
重構完,執行 pulumi up
後發現,沒有任何資源變更,就是正確的結果!
今天最後一個練習,是我想將 Subnet、RouteTable、RouteTableAssociation 三個資源封裝成一個 PrivateSubnet 類別。重構完後,我們應該可以這樣操作 Private Subnet:
const privateSubnet1 = new PrivateSubnet(...)
privateSubnet1.addNatGateway(natGatewayId)
首先我們先將整個建立 private subnet 的流程,透過Extract Function 重構技巧 抽成一個函式。
function createPrivateSubnet(arg: {az: string;cidr: string},
myVpc: aws.ec2.Vpc,
defaultTags: Record<string, string>,
natGateway: aws.ec2.NatGateway) {
const subnet = new aws.ec2.Subnet(`my-private-subnet-${arg.az}`, {
vpcId: myVpc.id,
cidrBlock: arg.cidr,
availabilityZone: arg.az,
tags: {
"Name": `my-private-subnet-${arg.az}`,
...defaultTags
}
});
const rt = new aws.ec2.RouteTable(`my-private-subnet-${arg.az}-rt`, {
vpcId: myVpc.id,
routes: [{cidrBlock: '0.0.0.0/0', natGatewayId: natGateway.id}],
tags: {
'Name': `my-private-subnet-${arg.az}-rt`,
...defaultTags
}
});
new aws.ec2.RouteTableAssociation(`my-private-subnet-${arg.az}-rt-association`, {
routeTableId: rt.id,
subnetId: subnet.id,
});
return {
name: `my-private-subnet-${arg.az}`,
subnet: subnet,
};
}
接著原本建立 Private Subnet 的迴圈就可以修改成這樣:
for (const arg of privateSubnetArgs) {
const createdSubnet = createPrivateSubnet(arg, myVpc, defaultTags, natGateway);
privateSubnets[createdSubnet.name] = createdSubnet.subnet;
}
接著我們使用 Introduce Parameter Object 重構方法,將 createPrivateSubnet 的參數變成參數物件,並改成只拿取我們需要的資訊,例如 VpcId 、NatGatewayId,而不是傳入整個物件,以減少耦合。
我們可以使用 TypeScript 的 Interface 直接定義一個 CreatePrivateSubnetArgs Interface:
interface CreatePrivateSubnetArgs {
az: string;
cidr: string;
vpcId: pulumi.Input<string>;
natGatewayId: pulumi.Input<string>;
defaultTags: Record<string, string>;
}
notes: 之後再來解釋為何 vpcId 與 natGatewayId 會是
pulumi.Input<string>
,不是string
接著將 createPrivateSubnet
的參數改為這個參數物件:
function createPrivateSubnet(args: CreatePrivateSubnetArgs) {
const subnet = new aws.ec2.Subnet(`my-private-subnet-${args.az}`, {
vpcId: args.vpcId,
cidrBlock: args.cidr,
availabilityZone: args.az,
tags: {
"Name": `my-private-subnet-${args.az}`,
...args.defaultTags
}
});
const rt = new aws.ec2.RouteTable(`my-private-subnet-${args.az}-rt`, {
vpcId: args.vpcId,
routes: [{cidrBlock: '0.0.0.0/0', natGatewayId: args.natGatewayId}],
tags: {
'Name': `my-private-subnet-${args.az}-rt`,
...args.defaultTags
}
});
new aws.ec2.RouteTableAssociation(`my-private-subnet-${args.az}-rt-association`, {
routeTableId: rt.id,
subnetId: subnet.id,
});
return {
name: `my-private-subnet-${args.az}`,
subnet: subnet,
};
}
呼叫函式的地方也要做相對應的修改:
const createdSubnet = createPrivateSubnet({
az: arg.az,
cidr: arg.cidr,
vpcId: myVpc.id,
natGatewayId: natGateway.id,
defaultTags: defaultTags
});
最後一步,就是將 createPrivateSubnet 轉換為 class 的建構子,並引入 PrivateSubent class 了!
這邊與前面的 function 差異只在於,將 name 與 subnet 儲存在 class 的 field 中,而不是直接 return object。
class PrivateSubnet {
public name: string;
public subnet: aws.ec2.Subnet;
constructor(args: CreatePrivateSubnetArgs) {
const subnet = new aws.ec2.Subnet(`my-private-subnet-${args.az}`, {
vpcId: args.vpcId,
cidrBlock: args.cidr,
availabilityZone: args.az,
tags: {
"Name": `my-private-subnet-${args.az}`,
...args.defaultTags
}
});
const rt = new aws.ec2.RouteTable(`my-private-subnet-${args.az}-rt`, {
vpcId: args.vpcId,
routes: [{cidrBlock: '0.0.0.0/0', natGatewayId: args.natGatewayId}],
tags: {
'Name': `my-private-subnet-${args.az}-rt`,
...args.defaultTags
}
});
new aws.ec2.RouteTableAssociation(`my-private-subnet-${args.az}-rt-association`, {
routeTableId: rt.id,
subnetId: subnet.id,
});
this.name = `my-private-subnet-${args.az}`;
this.subnet = subnet;
}
}
呼叫的地方也只要做最小的修改即可
const createdSubnet = new PrivateSubnet({
az: arg.az,
cidr: arg.cidr,
vpcId: myVpc.id,
natGatewayId: natGateway.id,
defaultTags: defaultTags
});
privateSubnets[createdSubnet.name] = createdSubnet.subnet;
還記得前面我們提到,希望使用 privateSubnet1.addNatGateway(natGatewayId)
的方式將 NAT Gateway 加入 Subnet 的 RouteTable 嗎?
現在我們就來定義 addNatGateway 方法:
addNatGateway(natGatewayId: pulumi.Input<string>) {
new aws.ec2.Route(`my-private-subnet-${this.name}-nat-gateway-route`, {
routeTableId: this.routeTable.id,
destinationCidrBlock: '0.0.0.0/0',
natGatewayId: natGatewayId,
});
}
這時我們會發現,this.routeTable
並不存在,因此我們需要更改建構子,將 RouteTable 儲存到 class 的 field 中:
class PrivateSubnet {
private routeTable: aws.ec2.RouteTable;
...
constructor(args: CreatePrivateSubnetArgs)
......
this.routeTable = rt;
}
}
最後更改建立 Private Subnet 的地方:
const createdSubnet = new PrivateSubnet({...});
createdSubnet.addNatGateway(natGateway.id);
最後執行一下 pulumi up
,會發現有錯誤...
View in Browser (Ctrl+O): https://app.pulumi.com/xxxxxx/aws-vpc-ts/dev/updates/19
Type Name Status Info
pulumi:pulumi:Stack aws-vpc-ts-dev **failed** 1 error
+ ├─ aws:ec2:Route my-private-subnet-ap-east-1b-nat-gateway-route **creating failed** 1 error
+ └─ aws:ec2:Route my-private-subnet-ap-east-1a-nat-gateway-route **creating failed** 1 error
Diagnostics:
pulumi:pulumi:Stack (aws-vpc-ts-dev):
error: update failed
aws:ec2:Route (my-private-subnet-ap-east-1a-nat-gateway-route):
error: 1 error occurred:
* creating Route in Route Table (rtb-0157e7aea75f5cfff) with destination (0.0.0.0/0): RouteAlreadyExists: The route identified by 0.0.0.0/0 already exists.
status code: 400, request id: aa56a7a2-e71b-4854-a555-e8f77de1ccc8
aws:ec2:Route (my-private-subnet-ap-east-1b-nat-gateway-route):
error: 1 error occurred:
* creating Route in Route Table (rtb-05d1d27af906e8612) with destination (0.0.0.0/0): RouteAlreadyExists: The route identified by 0.0.0.0/0 already exists.
status code: 400, request id: 9bd9d5d2-e845-4f41-9e82-8eb3061cf5a7
看了一下文件,發現是因為在 RouteTable 裡面定義 Route,然後又新增 Route 資源可能會發生衝突......
上 AWS 將 private route table 的 default route 都刪除後,重新執行 pulumi up
就可以了。
接著還能將 PrivateSubnet 的 natGatewayId 參數移除、將整個 PrivteSubnet class 移動到另一個檔案,這些就留給讀者自行實作。重構後的結果可以參考 GitHub 的內容。
雖然本系列不是專門講解重構的文章,但重構是軟體工程師日常開發的一環,每天都在做大大小小的重構。因此本篇文章嘗試使用小步驟的重構方式,慢慢帶讀者了解如何從一團程式碼逐漸變成一個類別的感覺。之後熟悉了這些過程後,就可以跨大步一點,直接從一團程式碼 Extract Class 變成類別。