iT邦幫忙

2023 iThome 鐵人賽

DAY 6
0
Cloud Native

30 天學習 Pulumi:用各種程式語言控制雲端資源系列 第 6

[Day 06] 善用程式語言優勢撰寫 Pulumi IaC

  • 分享至 

  • xImage
  •  

今天的任務是來重構昨天所撰寫的 Pulumi 程式,首先先來回顧一下昨天產生的 VPC 結構:

可以從圖中發現,所有的資源幾乎都是用雲端預設的名稱,比較不好辨認。
今天我們首要任務就是設定雲端資源的名稱。

在 AWS 中不一定每個資源都有 name,有些是透過 Tags 中的 Name 做為顯示的名稱。有些資源甚至連名稱都沒有,例如 RouteTableAssociation,這個資源就是用來關聯 RouteTable 與 Subnet,不需要特別給 name。

通常來說,我們都希望一起建立的相關資源都有相同的 Tags 標示,讓我們之後可以透過 Tag 來辨識資源。

為每個 Resource 都加上 Name

例如說我們想要為每個資源都加上 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 了。
https://ithelp.ithome.com.tw/upload/images/20230921/20162822A2G3JvRNkv.png

也能在 VPC 的 Tags 面板上看到設定上去的 Tags。
https://ithelp.ithome.com.tw/upload/images/20230921/20162822lmyYJpz6ji.png

自動計算 Subnet CIDR

接著我們來處理寫死的 Subnet CIDR。我希望可以有個 function,讓我傳入 VPC 的 CIDR,並指定一個更小的子網路遮罩,就能產生所有可用的子網路分割後的 CIDR 列表。其實就是實作 Terraform 的 cidrsubnet function

由於 Node.JS 的套件中,找不到類似功能的套件可以使用,就請 ChatGPT 幫忙寫一下,以下是 AI 生成的 Code。

Node.JS 版本:

在 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 版本

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

接著我們要來處理另一個寫死的值: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 的文件有三個地方可以找:

  1. Pulumi 的套件通常會提供完整的文件:https://www.pulumi.com/registry/packages/aws/api-docs/getavailabilityzones/
  2. 透過 Pulumi Terraform Adapter 的話,可以參考原始 Provider 的文件:https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zones
  3. 如果可以,可以找到 AWS 的官方文件,有些資源的參數要到這比較清楚
    https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeAvailabilityZones.html

關於這個 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 後發現,沒有任何資源變更,就是正確的結果!

今天最後一個技巧,將 Private Subnet 重構成獨立的類別

今天最後一個練習,是我想將 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 變成類別。


上一篇
[Day 05] 使用 Pulumi 建立一個標準的 AWS VPC
下一篇
[Day 07] Pulumi 中的 Input 與 Output 概念 (1)
系列文
30 天學習 Pulumi:用各種程式語言控制雲端資源30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言