MySQL 5.7 で非ネイティブなパーティションを判別
MySQL 5.7 へのアップグレード時に、パーティションを更新しないと該当テーブル検索時に以下のような警告が出力されるようになります。
非推奨パーティションの警告
The partition engine, used by table 'テーブル名', is deprecated and will be removed in a future release. Please use native partitioning instead.
これ自体は以下を実行して InnoDB native なパーティションへ更新すれば解決する話ですが。
native partitioning への更新方法
ALTER TABLE テーブル名 ENGINE = INNODB;
パーティションの native と 非native を見分けるにはどうすればよいのか気になったので information_schema
のテーブルを調べてみました。
PARTITIONS
テーブル等には特に違いが見られなかったのですが、INNODB_SYS_TABLES
テーブルの FILE_FORMAT
と ROW_FORMAT
の値で違いを確認できました。
INNODB_SYS_TABLES の内容
パーティション | FILE_FORMAT | ROW_FORMAT |
---|---|---|
非native の場合 | Antelope | Compact |
native の場合 | Barracuda | Dynamic |
なお、INNODB_SYS_TABLES テーブルでパーティションに関するレコードは、NAME の値が スキーマ名/テーブル名#P#パーティション名[#SP#サブパーティション名]
のようになっていました。
ALTER TABLE ・・・
でパーティションを更新すると、Antelope と Compact から Barracuda と Dynamic へ変化したので、とりあえずはこれで判別できそうです。
Elasticsearch で nested の集計
Elasticsearch において 2層の nested 型フィールドで集計してみました。
サンプルのソースコードは http://github.com/fits/try_samples/tree/master/blog/20211127/
はじめに
下記のような 2層のカテゴリ(categories
と children
は nested 型)を持つドキュメントに対して、カテゴリ単位で集計します。
ドキュメント例
{ "name": "item-1", "categories": [ { "code": "B", "name": "categoryB", "children": [ { "code": "U", "name": "subcategoryBU" } ] } ] }
まずは、下記処理でマッピング定義とドキュメントを登録しました。
Elasticsearch のデフォルトでは、日付では無い文字列を text
型のフィールドと keyword
型の keyword サブフィールドへマッピングするようになっていますが、ここでは文字列を keyword
型へマッピングするように dynamic_templates
を設定しました。
init.ts
import { send } from './es_util.ts' const index = 'sample' const baseUrl = 'http://localhost:9200' const indexUrl = `${baseUrl}/${index}` const N = 20 const cts1 = ['A', 'B', 'C'] const cts2 = ['S', 'T', 'U'] // インデックスの有無を確認 const { status } = await fetch(indexUrl, { method: 'HEAD' }) if (status == 200) { // インデックス削除 await fetch(indexUrl, { method: 'DELETE' }) } // マッピング定義 const { body } = await send(indexUrl, 'PUT', { mappings: { dynamic_templates: [ { // 文字列を keyword 型へ動的マッピングする設定 string_keyword: { match_mapping_type: 'string', mapping: { type: 'keyword' } } } ], properties: { categories: { type: 'nested', properties: { children: { type: 'nested' } } } } } }) console.log(body) const selectCategory = (cts: Array<string>) => cts[Math.round(Math.random() * (cts.length - 1))] // ドキュメント登録 for (let i = 0; i < N; i++) { const ct1 = selectCategory(cts1) const ct2 = selectCategory(cts2) const { body: r } = await send(`${indexUrl}/_doc`, 'POST', { name: `item-${i + 1}`, categories: [ { code: ct1, name: `category${ct1}`, children: [ { code: ct2, name: `subcategory${ct1}${ct2}` } ] } ] }) console.log(`result: ${r.result}, _id: ${r._id}`) }
es_util.ts
export const send = async (url: string, method: string, body: any) => { const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) const resBody = await res.json() return { ok: res.ok, body: resBody } }
Deno で実行します。
実行例
> deno run --allow-net init.ts { acknowledged: true, shards_acknowledged: true, index: "sample" } result: created, _id: 632STX0BXMPSr8jZ-Q7n ・・・
(a) terms による集計
categories.code
とそれに属する children.code
の値でグルーピングして、該当するドキュメント数をカウントしてみます。
特定のフィールドの値でグルーピングするには terms
を使用します。
ちなみに、terms.field へ text 型のフィールドを指定する事はできないようです。(※1)
また、nested 型のフィールドに対して集計する際は nested.path
を設定する必要がありました。(※2)
更に、categories と children の親子関係を保持したままドキュメント数をカウントするには aggs を入れ子にする必要があります。(※3)
(※1)今回は dynamic_templates を設定しているため、 code や name フィールドは keyword 型になっていますが、 Elasticsearch デフォルトの動的マッピングを使用した際は、 'categories.code.keyword' のように keyword サブフィールドの方を terms.field へ設定する事になります (※2)nested.path を設定せずに、、 terms.field を categories.code としても集計結果は 0 になった (※3)入れ子にしなかった場合、 親子関係を無視して categories.code と children.code の値で それぞれ独立したカウントになる(ソースの sample0.ts 参照)
デフォルトでは、ドキュメント数の多い順にソートされるようなので、order
を指定して terms.field で指定したフィールドの値順になるようにしています。
aggs 直下の categories
、count_items
、children
の箇所では任意の名前を使えます。
sample1.ts(前半)
・・・ const { body } = await send(`${indexUrl}/_search`, 'POST', { size: 0, aggs: { categories: { nested: { path: 'categories' }, aggs: { count_items: { terms: { field: 'categories.code', order: { _term: 'asc' } }, aggs: { children: { nested: { path: 'categories.children' }, aggs: { count_items: { terms: { field: 'categories.children.code', order: { _term: 'asc' } } } } } } } } } } }) console.log(JSON.stringify(body, null, 2)) console.log('----------') ・・・
この部分の実行結果は以下の通りです。
(a) 実行結果(前半)
> deno run --allow-net sample1.ts { ・・・ "hits": { "total": { "value": 20, "relation": "eq" }, "max_score": null, "hits": [] }, "aggregations": { "categories": { "doc_count": 20, "count_items": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "A", "doc_count": 4, "children": { "doc_count": 4, "count_items": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "T", "doc_count": 3 }, { "key": "U", "doc_count": 1 } ] } } }, ・・・ ] } } } }
ここから必要な内容だけを残すように加工してみます。
sample1.ts(後半)
・・・ console.log('----------') const fieldName = (rs: any) => Object.keys(rs).find(k => !['key', 'doc_count'].includes(k)) type Bucket = { key: string, doc_count: number } // 集計結果の加工 const toDoc = (rs: any) => { const k1 = fieldName(rs) if (!k1) { return {} } const k2 = fieldName(rs[k1])! const bs = rs[k1][k2].buckets.map((b: Bucket) => Object.assign( { code: b.key, [k2]: b.doc_count }, toDoc(b) ) ) return { [k1]: bs } } const res = toDoc(body.aggregations) console.log(JSON.stringify(res, null, 2))
実行結果は以下の通りです。
(a) 実行結果(後半)
> deno run --allow-net sample1.ts ・・・ ---------- { "categories": [ { "code": "A", "count_items": 4, "children": [ { "code": "T", "count_items": 3 }, { "code": "U", "count_items": 1 } ] }, { "code": "B", "count_items": 10, "children": [ { "code": "S", "count_items": 2 }, { "code": "T", "count_items": 4 }, { "code": "U", "count_items": 4 } ] }, { "code": "C", "count_items": 6, "children": [ { "code": "S", "count_items": 2 }, { "code": "T", "count_items": 3 }, { "code": "U", "count_items": 1 } ] } ] }
(b) multi_terms による集計
terms を使った集計だと code の値しか取れなかったので、ここでは multi_terms
を使って categories.name や children.name の値も同時に取得できるようにしてみます。
multi_terms.terms
で複数のフィールドを指定すれば、フィールドの値を連結した値(key_as_string
の値)でグルーピングするようです。
集計結果の key が配列になるので、そこから code と name の値を取り出す事が可能です。
sample2.ts
・・・ const { body } = await send(`${indexUrl}/_search`, 'POST', { size: 0, aggs: { categories: { nested: { path: 'categories' }, aggs: { count_items: { multi_terms: { terms: [ { field: 'categories.code' }, { field: 'categories.name' } ], order: { _term: 'asc' } }, aggs: { children: { nested: { path: 'categories.children' }, aggs: { count_items: { multi_terms: { terms: [ { field: 'categories.children.code' }, { field: 'categories.children.name' } ], order: { _term: 'asc' } } } } } } } } } } }) console.log(JSON.stringify(body, null, 2)) console.log('----------') const fieldName = (rs: any) => Object.keys(rs).find(k => !['key', 'doc_count', 'key_as_string'].includes(k)) type Bucket = { key: [string, string], doc_count: number } const toDoc = (rs: any) => { const k1 = fieldName(rs) if (!k1) { return {} } const k2 = fieldName(rs[k1])! const bs = rs[k1][k2].buckets.map((b: Bucket) => Object.assign( { code: b.key[0], name: b.key[1], [k2]: b.doc_count }, toDoc(b) ) ) return { [k1]: bs } } const res = toDoc(body.aggregations) console.log(JSON.stringify(res, null, 2))
実行結果は以下の通りです。
(b) 実行結果
> deno run --allow-net sample2.ts { ・・・ "aggregations": { "categories": { "doc_count": 20, "count_items": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": [ "A", "categoryA" ], "key_as_string": "A|categoryA", "doc_count": 4, "children": { "doc_count": 4, "count_items": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": [ "T", "subcategoryAT" ], "key_as_string": "T|subcategoryAT", "doc_count": 3 }, { "key": [ "U", "subcategoryAU" ], "key_as_string": "U|subcategoryAU", "doc_count": 1 } ] } } }, ・・・ ] } } } } ---------- { "categories": [ { "code": "A", "name": "categoryA", "count_items": 4, "children": [ { "code": "T", "name": "subcategoryAT", "count_items": 3 }, { "code": "U", "name": "subcategoryAU", "count_items": 1 } ] }, { "code": "B", "name": "categoryB", "count_items": 10, "children": [ { "code": "S", "name": "subcategoryBS", "count_items": 2 }, { "code": "T", "name": "subcategoryBT", "count_items": 4 }, { "code": "U", "name": "subcategoryBU", "count_items": 4 } ] }, { "code": "C", "name": "categoryC", "count_items": 6, "children": [ { "code": "S", "name": "subcategoryCS", "count_items": 2 }, { "code": "T", "name": "subcategoryCT", "count_items": 3 }, { "code": "U", "name": "subcategoryCU", "count_items": 1 } ] } ] }
Elasticsearch で検索条件に合致した nested の要素だけを抽出
Elasticsearch の nested を用いた検索において、inner_hits
を使って検索条件に合致した nested の要素だけを抽出するようにしてみました。
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20211011/
はじめに
Elasticsearch では nested という型を用いる事で、入れ子構造を実現できるようになっています。
ここでは、商品毎に複数のエディションがあって、エディション毎にセールの履歴を保持しているような、2層の nested を持つサンプルデータを用意してみました。
Elasticsearch のマッピング設定は以下。
マッピング設定
$ curl -s http://localhost:9200/items/_mappings | jq { "items": { "mappings": { "properties": { "editions": { "type": "nested", "properties": { "edition": { "type": "keyword" }, "price": { "type": "scaled_float", "scaling_factor": 100 }, "sales": { "type": "nested", "properties": { "date_from": { "type": "date" }, "date_to": { "type": "date" }, "sale_price": { "type": "scaled_float", "scaling_factor": 100 } } } } }, "id": { "type": "keyword" }, "name": { "type": "text" } } } } }
ドキュメントの内容は以下の通りです。
全ドキュメント内容
$ curl -s http://localhost:9200/items/_search | jq { ・・・ "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_score": 1, "_source": { "id": "id-001", "name": "item-A", "editions": [ { "edition": "Standard", "price": 1000, "sales": [ { "sale_price": 900, "date_from": "2021-01-01T00:00:00Z", "date_to": "2021-01-03T00:00:00Z" }, { "sale_price": 800, "date_from": "2021-07-01T00:00:00Z", "date_to": "2021-07-05T00:00:00Z" } ] }, { "edition": "Extra", "price": 2000, "sales": [ { "sale_price": 1800, "date_from": "2021-01-01T00:00:00Z", "date_to": "2021-01-03T00:00:00Z" }, { "sale_price": 1700, "date_from": "2021-07-01T00:00:00Z", "date_to": "2021-07-05T00:00:00Z" }, { "sale_price": 1500, "date_from": "2021-09-01T00:00:00Z", "date_to": "2021-09-02T00:00:00Z" } ] } ] } }, { "_index": "items", "_type": "_doc", "_id": "id-002", "_score": 1, "_source": { "id": "id-002", "name": "item-B", "editions": [ { "edition": "Standard", "price": 1500, "sales": [ { "sale_price": 1400, "date_from": "2021-09-01T00:00:00Z", "date_to": "2021-09-05T00:00:00Z" } ] }, { "edition": "Extra", "price": 5000, "sales": [] } ] } }, { "_index": "items", "_type": "_doc", "_id": "id-003", "_score": 1, "_source": { "id": "id-003", "name": "item-C", "editions": [ { "edition": "Standard", "price": 7000, "sales": [ { "sale_price": 6800, "date_from": "2021-01-01T00:00:00Z", "date_to": "2021-01-03T00:00:00Z" }, { "sale_price": 6700, "date_from": "2021-02-01T00:00:00Z", "date_to": "2021-02-05T00:00:00Z" }, { "sale_price": 6600, "date_from": "2021-04-01T00:00:00Z", "date_to": "2021-04-02T00:00:00Z" }, { "sale_price": 6500, "date_from": "2021-07-01T00:00:00Z", "date_to": "2021-07-15T00:00:00Z" } ] } ] } }, { "_index": "items", "_type": "_doc", "_id": "id-004", "_score": 1, "_source": { "id": "id-004", "name": "item-D", "editions": [ { "edition": "Standard", "price": 4000, "sales": [] }, { "edition": "Extra", "price": 6000, "sales": [] }, { "edition": "Premium", "price": 9000, "sales": [] } ] } } ] } }
検索
Deno 用の TypeScript で検索処理を実装してみます。
Deno 用の公式 Elasticsearch クライアントライブラリは今のところ無さそうなので、fetch
を使って REST API を呼び出すような関数を用意してみました。
es_util.ts
// Elasticsearch の REST API 呼び出し export const send = async (url: string, method: string, body: any) => { const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) const resBody = await res.json() return { ok: res.ok, body: resBody } }
ここでは、下記のような条件で検索する事にします。
- 2021/7/1 以降のセール価格が 1500 以下だったもの
1. inner_hits を用いない検索
まずは、inner_hits
を用いず普通に検索します。
nested のフィールドを検索条件として指定するために、Nested query({ nested: path: ・・・, query: ・・・ }
)を使用します。
editions
内の sales
を検索条件として指定するために、Nested query を入れ子にします。
search1.ts
import { send } from './es_util.ts' const index = 'items' const baseUrl = 'http://localhost:9200' const indexUrl = `${baseUrl}/${index}` const { body } = await send(`${indexUrl}/_search`, 'POST', { query: { nested: { path: 'editions', query: { nested: { path: 'editions.sales', query: { bool: { must: [ { range: { 'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' } } }, { range: { 'editions.sales.sale_price': { lte: 1500 } } } ] } } } } } } }) console.log(JSON.stringify(body, null, 2))
実行結果は次の通り、_source
は元のドキュメント内容そのままなので、どのエディションの、どのセール履歴が検索条件に合致したかは分かりません。
実行結果
> deno run --allow-net search1.ts { ・・・ "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_score": 2, "_source": { "id": "id-001", "name": "item-A", "editions": [ { "edition": "Standard", "price": 1000, "sales": [ { "sale_price": 900, "date_from": "2021-01-01T00:00:00Z", "date_to": "2021-01-03T00:00:00Z" }, { "sale_price": 800, "date_from": "2021-07-01T00:00:00Z", "date_to": "2021-07-05T00:00:00Z" } ] }, { "edition": "Extra", "price": 2000, "sales": [ { "sale_price": 1800, "date_from": "2021-01-01T00:00:00Z", "date_to": "2021-01-03T00:00:00Z" }, { "sale_price": 1700, "date_from": "2021-07-01T00:00:00Z", "date_to": "2021-07-05T00:00:00Z" }, { "sale_price": 1500, "date_from": "2021-09-01T00:00:00Z", "date_to": "2021-09-02T00:00:00Z" } ] } ] } }, { "_index": "items", "_type": "_doc", "_id": "id-002", "_score": 2, "_source": { "id": "id-002", "name": "item-B", "editions": [ { "edition": "Standard", "price": 1500, "sales": [ { "sale_price": 1400, "date_from": "2021-09-01T00:00:00Z", "date_to": "2021-09-05T00:00:00Z" } ] }, { "edition": "Extra", "price": 5000, "sales": [] } ] } } ] } }
2. inner_hits を用いた検索
それでは、本題の inner_hits
を使った検索を行います。
inner_hits は Nested query で指定できます。
inner_hits を指定した場合は _source
とは別の inner_hits
という項目に該当結果が設定されます。
ここでは、inner_hits で取得する内容は Source filtering を使って、元の _source
から取り除くように設定しています。(Source filtering は inner_hits 内でも設定可)
また、_source
と inner_hits
の結果を結合して、検索条件に合致した要素だけで構成されるドキュメントを構築する処理(toDoc
)も用意してみました。
search2.ts
・・・ const { body } = await send(`${indexUrl}/_search`, 'POST', { _source: { // _source から editions の内容を取り除く excludes: ['editions'] }, query: { nested: { path: 'editions', query: { nested: { path: 'editions.sales', query: { bool: { must: [ { range: { 'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' } } }, { range: { 'editions.sales.sale_price': { lte: 1500 } } } ] } }, // editions.sales に対する inner_hits の設定 inner_hits: {} } }, // editions に対する inner_hits の設定 inner_hits: { _source: { // inner_hits の _source から sales の部分を取り除く excludes: ['editions.sales'] } } } } }) console.log(JSON.stringify(body, null, 2)) console.log('-----') // _source の内容に inner_hits の内容を再帰的に結合する処理 const toDoc = (res: any) => res.hits.hits.map((r: any) => { const doc = { ...r._source } for (const [k, v] of Object.entries(r.inner_hits ?? {})) { const key = k.split('.').slice(-1)[0] doc[key] = toDoc(v) } return doc }) const res = toDoc(body) console.log(JSON.stringify(res, null, 2))
実行結果は次のようになりました。
実行結果
> deno run --allow-net search2.ts { ・・・ "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_score": 2, "_source": { "name": "item-A", "id": "id-001" }, "inner_hits": { "editions": { "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_nested": { "field": "editions", "offset": 0 }, "_score": 2, "_source": { "price": 1000, "edition": "Standard" }, "inner_hits": { "editions.sales": { "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_nested": { "field": "editions", "offset": 0, "_nested": { "field": "sales", "offset": 1 } }, "_score": 2, "_source": { "date_to": "2021-07-05T00:00:00Z", "sale_price": 800, "date_from": "2021-07-01T00:00:00Z" } } ] } } } }, { "_index": "items", "_type": "_doc", "_id": "id-001", "_nested": { "field": "editions", "offset": 1 }, "_score": 2, "_source": { "price": 2000, "edition": "Extra" }, "inner_hits": { "editions.sales": { "hits": { ・・・ "hits": [ { "_index": "items", "_type": "_doc", "_id": "id-001", "_nested": { "field": "editions", "offset": 1, "_nested": { "field": "sales", "offset": 2 } }, "_score": 2, "_source": { "date_to": "2021-09-02T00:00:00Z", "sale_price": 1500, "date_from": "2021-09-01T00:00:00Z" } } ] } } } } ] } } } }, ・・・ ] } } ----- [ { "name": "item-A", "id": "id-001", "editions": [ { "price": 1000, "edition": "Standard", "sales": [ { "date_to": "2021-07-05T00:00:00Z", "sale_price": 800, "date_from": "2021-07-01T00:00:00Z" } ] }, { "price": 2000, "edition": "Extra", "sales": [ { "date_to": "2021-09-02T00:00:00Z", "sale_price": 1500, "date_from": "2021-09-01T00:00:00Z" } ] } ] }, { "name": "item-B", "id": "id-002", "editions": [ { "price": 1500, "edition": "Standard", "sales": [ { "date_to": "2021-09-05T00:00:00Z", "sale_price": 1400, "date_from": "2021-09-01T00:00:00Z" } ] } ] } ]
CDK の Vpc.fromLookup では StringParameter.valueFromLookup を使う
CDK で既存 VPC とのピアリング設定等を行う場合、Vpc.fromLookup
を用いて VPC を参照する事になると思います。
VPC ID のハードコーディングを避けるには、SSM(Systems Manager)のパラメータストアから値を取得する方法が考えられます。
CDK で SSM パラメータストアの値を参照するには、以下のような方法が用意されていますが、Vpc.fromLookup
の vpcId へ指定できたのは (b) で (a) は駄目でした。
- (a) StringParameter.valueForStringParameter ※
- (b) StringParameter.valueFromLookup
※ valueForTypedStringParameter や new StringParameter(・・・).stringValue でも同じ
(a) の戻り値を Vpc.fromLookup
の vpcId へ設定すると、All arguments to Vpc.fromLookup() must be concrete (no Tokens)
というエラーが発生しました。
これは、(a) が返すのは実際の値ではなく、実際の値を参照するためのトークンだという事が原因のようです。
一方、(b) は cdk synth 時にパラメータストアから値を取得して cdk.context.json
ファイルにキャッシュするようになっており、パラメータストアから取得した実際の値が返ってきます。
ただし、cdk.context.json にキャッシュした後は、パラメータストアから再取得しないようで、パラメータストア側の値を更新しても反映してくれませんでした。※
※ cdk context --clear で cdk.context.json をクリアすると反映されましたが
スタック定義例
import { App, Stack } from '@aws-cdk/core' import { StringParameter } from '@aws-cdk/aws-ssm' import * as ec2 from '@aws-cdk/aws-ec2' const vpcIdParamName = '/sample/vpcid' const app = new App() const stack = new Stack(app, 'VPCPeeringSample', { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION } }) const vpc = new ec2.Vpc(stack, 'Vpc', { cidr: '192.168.0.0/16', subnetConfiguration: [ { name: 'sample', subnetType: ec2.SubnetType.PUBLIC } ] }) const peerVpc = ec2.Vpc.fromLookup(stack, 'PeerVpc', { vpcId: StringParameter.valueFromLookup(stack, vpcIdParamName) // 以下のようにするとエラーになる // vpcId: StringParameter.valueForStringParameter(stack, vpcIdParamName) }) // VPC ピアリング const vpcPeering = new ec2.CfnVPCPeeringConnection(stack, 'VpcPeering', { vpcId: vpc.vpcId, peerVpcId: peerVpc.vpcId }) vpc.publicSubnets.map((s, i) => { new ec2.CfnRoute(stack, `src-peering-${i}`, { routeTableId: s.routeTable.routeTableId, destinationCidrBlock: peerVpc.vpcCidrBlock, vpcPeeringConnectionId: vpcPeering.ref }) }) ・・・
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210926/
Jest と Vue Test Utils による Vue コンポーネントのテスト
Vue CLI で作成した TypeScript 用の Vue プロジェクトに対して、Jest と Vue Test Utils(@vue/test-utils) を追加導入し、Vue コンポーネントのテスト(TypeScript で実装)を実施するようにしてみました。
今回は、Vue CLI の vue create
時に、Manually select features
を選択して、Choose Vue version
と TypeScript
にだけチェックを付け、Vue.js 2.x と 3.x のプロジェクトをそれぞれ作成して実施しています。
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210621/
(a) Vue.js 2.x の場合
まずは、Vue.js 2.x のプロジェクトで実施してみます。
- Vue.js 2.6.14
a-1. テストモジュールの導入
Jest と Vue Test Utils に必要なモジュールをインストールして設定します。
jest のインストールと設定
jest
と ts-jest
(テストコードを TypeScript で実装するため)、vue-jest
(テストコードで Vue コンポーネントを扱うため)をインストールします。
> npm i -D jest ts-jest vue-jest
ts-jest と vue-jest を適用するように Jest を設定し、jsdom を適用してテストを実施するよう testEnvironment
に jsdom
を設定しておきます。
jest.config.js 設定例
module.exports = { testEnvironment: 'jsdom', preset: 'ts-jest', transform: { '.*\\.(vue)$': 'vue-jest' } }
Vue Test Utils のインストール
@vue/test-utils
をインストールします。
> npm i -D @vue/test-utils
@types/jest のインストールと設定
ついでに、VS Code で jest の関数を使ってもエラー表示にならないようにするための措置を行っておきます。
まずは、jest の型定義をインストールします。
> npm i -D @types/jest
tsconfig.json の types
に jest
を追加します。
tsconfig.json 設定例
{ ・・・ "types": [ ・・・ "jest" ], ・・・ }
a-2. コンポーネントの実装
テスト対象のコンポーネントを実装します。
src/components/Counter.vue
<template> <div> <p> <button @click="countUp">count up</button> </p> <p> counter: {{ count }} </p> </div> </template> <script lang="ts"> import Vue from 'vue' export default Vue.extend({ data() { return { count: 0 } }, methods: { countUp() { this.count++ } } }) </script>
a-3. テストの実装と実施
テストコードを実装し、テストしてみます。
TypeScript で実装する場合、型の都合上 counter.vm.count
とする事はできなかったので、とりあえずは counter.vm.$data.count
としています。
(counter.vm as any).count
とする事も可能ですが、正直どちらも微妙な気がします。
tests/Counter.test.vue
import { mount } from '@vue/test-utils' import Counter from '../src/components/Counter.vue' test('count up', async () => { const counter = mount(Counter) expect(counter.vm.$data.count).toBe(0) // 以下でも可 // expect((counter.vm as any).count).toBe(0) await counter.get('button').trigger('click') expect(counter.vm.$data.count).toBe(1) })
jest コマンドでテストを実施します。
テスト実行
> npx jest PASS tests/Counter.test.ts (5.907 s) ・・・ Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 7.434 s Ran all test suites.
(b) Vue.js 3.x の場合
次は、Vue.js 3.x のプロジェクトで実施してみます。
- Vue.js 3.1.1
b-1. テストモジュールの導入
基本的には 2.x と同じですが、インストールするモジュールのバージョンが多少異なります。
jest のインストールと設定
現時点で、Vue.s 3.x に対応した vue-jest
をインストールするにはバージョンに next
を指定する必要がありました。
また、(現時点で)インストールされる vue-jest 5.0.0-alpha.10 は、jest のバージョン 27
には対応しておらず、jest と ts-jest はバージョン 26
をインストールする必要がありました。
> npm i -D jest@26 ts-jest@26 vue-jest@next
Jest の設定は 2.x と同じですが、こちらは 2.x とは違って testEnvironment
を設定しなくても特に支障は無さそうでした。
jest.config.js 設定例
module.exports = { testEnvironment: 'jsdom', preset: 'ts-jest', transform: { '.*\\.(vue)$': 'vue-jest' } }
Vue Test Utils のインストール
@vue/test-utils のバージョンも next
とする必要がありました。
> npm i -D @vue/test-utils@next
また、@types/jest は 2.x と同様に、必要に応じてインストールして設定しておきます。
b-2. コンポーネントの実装
テスト対象コンポーネントを実装します。
src/components/Counter.vue
<template> <div> <p> <button @click="countUp">count up</button> </p> <p> counter: {{ count }} </p> </div> </template> <script lang="ts"> import { defineComponent } from 'vue' export default defineComponent({ data() { return { count: 0 } }, methods: { countUp() { this.count++ } } }) </script>
上記を以下のように Composition API で実装しても、同じテストコードでテストできました。
src/components/Counter.vue(Composition API 版)
<template> <div> <p> <button @click="countUp">count up</button> </p> <p> counter: {{ count }} </p> </div> </template> <script lang="ts"> import { ref } from 'vue' export default { setup() { const count = ref(0) const countUp = () => count.value++ return { count, countUp } } } </script>
b-3. テストの実装と実施
こちらは 2.x とは違って、counter.vm.count
とする事ができました。
tests/Counter.test.vue
import { mount } from '@vue/test-utils' import Counter from '../src/components/Counter.vue' test('count up', async () => { const counter = mount(Counter) expect(counter.vm.count).toBe(0) await counter.get('button').trigger('click') expect(counter.vm.count).toBe(1) })
jest コマンドでテストを実施します。
テスト実行
> npx jest PASS tests/Counter.test.ts ・・・ Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 3.78 s, estimated 4 s Ran all test suites.
CDK と LocalStack でローカルに Lambda と DynamoDB の実行環境を構築
AWS CDK (Cloud Development Kit) を使って、ローカル環境の LocalStack に Lambda 関数と DynamoDB のテーブルを構築してみました。
下記のようなツールを使用し、CDK によるスタックと Lambda 関数ハンドラーは TypeScript で実装しました。
- AWS CDK v1.105.0
- LocalStack
- Node.js v14.17.0
- AWS CLI 2.2.5
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210524/
1. はじめに
今回は cdk init
を使わずに CDK のコードを自前で構築する事にします。
まず、TypeScript で実装するために下記モジュールを
- typescript
- ts-node
- @types/node
CDK を使って Lambda と DynamoDB を定義するために下記モジュールを
TypeScript のビルド用に下記モジュールを
- esbuild
Lambda から DynamoDB へ接続するために下記モジュールを
- @aws-sdk/client-dynamodb (AWS SDK for JavaScript v3)
そして、aws-cdk を使って LocalStack に対してデプロイ等を実施するために下記モジュールを
- aws-cdk-local
これらをインストールして package.json は以下のようになりました。
package.json
{ "name": "cdk_localstack_sample", "version": "1.0.0", "description": "", "devDependencies": { "@types/node": "^14.17.0", "aws-cdk": "^1.105.0", "aws-cdk-local": "^1.65.4", "esbuild": "^0.12.1", "ts-node": "^9.1.1", "typescript": "^4.2.4" }, "dependencies": { "@aws-cdk/aws-dynamodb": "^1.105.0", "@aws-cdk/aws-lambda": "^1.105.0", "@aws-cdk/aws-lambda-nodejs": "^1.105.0", "@aws-sdk/client-dynamodb": "^3.16.0" } }
CDK 用の設定ファイル cdk.json
はシンプルに以下のようにしました。
cdk.json
{ "app": "npx ts-node --prefer-ts-exts app.ts" }
2. 実装
DynamoDB のテーブルと Lambda 関数を構築するだけの簡単なスタックを定義しました。
app.ts (スタック定義)
import { App, Construct, Stack, StackProps, CfnOutput } from '@aws-cdk/core' import * as dynamodb from '@aws-cdk/aws-dynamodb' import * as lambda from '@aws-cdk/aws-lambda' import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs' class SampleStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props) const table = new dynamodb.Table(this, 'Items', { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING} }) const func = new NodejsFunction(this, 'SampleFunc', { runtime: lambda.Runtime.NODEJS_14_X, entry: './src/handler.ts', environment: { 'TABLE_NAME': table.tableName } }) // テーブルへの書き込み権限を Lambda に付与 table.grantWriteData(func) // Lambda 関数名の出力 new CfnOutput(this, 'functionName', { value: func.functionName }) } } const app = new App() new SampleStack(app, 'SampleStack')
続いて Lambda 関数ハンドラーです。
LocalStack で実行している場合に、接続先の DynamoDB を LocalStack のものに切り替える必要があります。
また、LocalStack では Node.js ランタイムの Lambda 関数ハンドラーを docker で実行するようになっており、その際に LocalStack のホスト名は LOCALSTACK_HOSTNAME
環境変数で、ポート番号は EDGE_PORT
環境変数でそれぞれ渡されるようになっていました。
そこで、今回はこれらの環境変数の値を利用して DynamoDB の接続先を変えるようにしてみました。
src/handler.ts(Lambda 関数ハンドラー)
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb' const tableName = process.env.TABLE_NAME const config = {} if (process.env.LOCALSTACK_HOSTNAME) { // LocalStack の DynamoDB へ接続するための設定 config['endpoint'] = `http://${process.env.LOCALSTACK_HOSTNAME}:${process.env.EDGE_PORT}` } const client = new DynamoDBClient(config) export interface Input { id: string } export const handler = async (event: Input) => { const res = await client.send(new PutItemCommand({ TableName: tableName, Item: { id: { S: event.id } } })) console.log(`dynamodb put-item: ${JSON.stringify(res)}`) return { statusCode: 201, body: { id: event.id } } }
3. デプロイと動作確認
それでは、LocalStack を実行してデプロイを実施してみます。
LocalStack 実行
docker コマンドを使って LocalStack を実行します。
Node.js ランタイムの Lambda 関数を実行するために LAMBDA_EXECUTOR
環境変数の値を docker
にして、/var/run/docker.sock
をマッピングしています。
docker で LocalStack 実行
$ docker run --rm -it -p 4566:4566 -e LAMBDA_EXECUTOR=docker -v /var/run/docker.sock:/var/run/docker.sock localstack/localstack
デプロイ
aws-cdk-local モジュールには、cdklocal
コマンドという LocalStack に対してデプロイ等を実施する cdk コマンドのラッパーが用意されているので、このコマンドを使います。
cdk コマンドと同様に初回時は bootstrap
を実行します。
ブートストラップ処理
$ npx cdklocal bootstrap ⏳ Bootstrapping environment aws://000000000000/ap-northeast-1... CDKToolkit: creating CloudFormation changeset... 7:38:43 AM | UPDATE_IN_PROGRESS | AWS::CloudFormation::Stack | UsePublicAccessBlockConfiguration ✅ Environment aws://000000000000/ap-northeast-1 bootstrapped.
続いてデプロイを実施します。
デプロイ処理
$ npx cdklocal deploy --require-approval never Bundling asset SampleStack/SampleFunc/Code/Stage... ・・・ SampleStack: deploying... ・・・ SampleStack: creating CloudFormation changeset... ・・・ ✅ SampleStack Outputs: SampleStack.functionName = SampleStack-lambda-c75c6ee1 Stack ARN: arn:aws:cloudformation:us-east-1:000000000000:stack/SampleStack/c83384b9
これで DynamoDB のテーブルと Lambda 関数が作られ、Lambda の関数名は SampleStack-lambda-c75c6ee1
となりました。
動作確認
ここからは AWS CLI v2 を使って動作確認してみます。
LocalStack へ接続するには --endpoint-url
に http://localhost:4566
を設定して aws コマンドを使います。
それでは、lambda invoke
を使って SampleStack-lambda-c75c6ee1
を実行してみます。
Lambda の実行
$ aws --endpoint-url=http://localhost:4566 lambda invoke --function-name SampleStack-lambda-c75c6ee1 --payload '{"id":"id1"}' --cli-binary-format raw-in-base64-out output.json { "StatusCode": 200, "LogResult": "", "ExecutedVersion": "$LATEST" }
output.json の内容は以下のようになり、正常に実行できているようです。
output.json
{"body":{"id":"id1"},"statusCode":201}
次に、この Lambda 関数が出力した CloudWatch のログを確認してみます。 こちらも特に問題は無さそうです。
CloudWatch のログ確認
$ aws --endpoint-url=http://localhost:4566 logs tail "/aws/lambda/SampleStack-lambda-c75c6ee1" 2021-05-23T22:42:30.401000+00:00 2021/05/23/[LATEST]24101d6b START RequestId: ced21627-9622-15e9-45cd-e1c1c93c39a9 Version: $LATEST 2021-05-23T22:42:30.419000+00:00 2021/05/23/[LATEST]24101d6b 2021-05-23T22:42:30.437000+00:00 2021/05/23/[LATEST]24101d6b 2021-05-23T22:42:28.141Z ced21627-9622-15e9-45cd-e1c1c93c39a9 INFO dynamodb put-item: {"$metadata":{"httpStatusCode":200,"requestId":"0f5854a3-1110-42e7-bd95-3ff8bd8bb003","attempts":1,"totalRetryDelay":0},"ConsumedCapacity":{"CapacityUnits":1,"TableName":"SampleStack-Items5C12978B-b5cec818"}} 2021-05-23T22:42:30.473000+00:00 2021/05/23/[LATEST]24101d6b END RequestId: ced21627-9622-15e9-45cd-e1c1c93c39a9 ・・・
最後に、SampleStack-Items5C12978B-b5cec818
テーブルの内容も出力してみました。
DynamoDB テーブルの検索結果
$ aws --endpoint-url=http://localhost:4566 dynamodb scan --table-name SampleStack-Items5C12978B-b5cec818 { "Items": [ { "id": { "S": "id1" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null }
CDK で作った CloudFormation テンプレートをプログラム内からデプロイする
AWS CDK (Cloud Development Kit) では通常 cdk deploy
コマンドを使ってデプロイ処理を実施します。
これを cdk
コマンドを使わずにプログラム内から実施できないか、以下の 2通りで試してみました。
なお、実際のところ (a) の場合でも、内部的には CloudFormation の API が呼び出されています。
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210418/
はじめに
今回は DynamoDB のテーブルを作るだけの単純なスタック(以下)を使用します。
a_sample/stack.ts
import { Construct, Stack, StackProps, CfnOutput } from '@aws-cdk/core' import * as dynamodb from '@aws-cdk/aws-dynamodb' export class SampleStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props) const table = new dynamodb.Table(this, 'Items', { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING } }) new CfnOutput(this, 'TableName', { value: table.tableName }) } }
このスタックの CloudFormation テンプレートは、以下のような処理で文字列として取得できます。
a_sample/sample.ts
import { App } from '@aws-cdk/core' import { SampleStack } from './stack' const app = new App() new SampleStack(app, 'SampleStack') // CloudFormation テンプレートの文字列化 const tpl = JSON.stringify(app.synth().stacks[0].template) console.log(tpl)
実行結果例
> cd a_sample > npx ts-node sample.ts {"Resources":{"Items5C12978B":{"Type":"AWS::DynamoDB::Table","Properties":{"KeySchema":[{"AttributeName":"id","KeyType":"HASH"}],"AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}],"ProvisionedThroughput":{"ReadCapacityUnits":5,"WriteCapacityUnits":5}},"UpdateReplacePolicy":"Retain","DeletionPolicy":"Retain"}},"Outputs":{"TableName":{"Value":{"Ref":"Items5C12978B"}}}}
(a) AWS CDK の API を利用
cdk deploy
コマンド処理内で使われている CloudFormationDeployments
を直接使うのが簡単そうだったので、今回はこれを使います。
デフォルトでは cdk deploy
時と同じように進捗状況が出力されますが、これは quiet
を true
にする事で抑制できました。
a_sample/deploy_a.ts
import { App } from '@aws-cdk/core' import { SdkProvider } from 'aws-cdk/lib/api/aws-auth' import { CloudFormationDeployments } from 'aws-cdk/lib/api/cloudformation-deployments' import { SampleStack } from './stack' const app = new App() new SampleStack(app, 'Sample1Stack') const run = async () => { const deployer = new CloudFormationDeployments({ sdkProvider: await SdkProvider.withAwsCliCompatibleDefaults() }) // デプロイ処理 const r = await deployer.deployStack({ stack: app.synth().stacks[0], quiet: true }) console.log(r) } run().catch(err => console.error(err))
パッケージインストール例
> cd a_sample > npm install -D ts-node typescript @types/node ・・・ > npm install @aws-cdk/aws-dynamodb aws-cdk ・・・
実行結果は以下の通りです。 CloudFormation のスタックが登録され、DynamoDB のテーブルが問題なく作成されました。
実行結果
> npx ts-node deploy_a.ts Sample1Stack: creating CloudFormation changeset... { noOp: false, outputs: { TableName: 'Sample1Stack-Items・・・' }, ・・・ stackArtifact: CloudFormationStackArtifact { assembly: CloudAssembly { ・・・ }, id: 'Sample1Stack', manifest: { type: 'aws:cloudformation:stack', environment: 'aws://unknown-account/unknown-region', properties: [Object], metadata: [Object] }, messages: [], _dependencyIDs: [], environment: { account: 'unknown-account', region: 'unknown-region', name: 'aws://unknown-account/unknown-region' }, templateFile: 'Sample1Stack.template.json', parameters: {}, tags: {}, assumeRoleArn: undefined, cloudFormationExecutionRoleArn: undefined, stackTemplateAssetObjectUrl: undefined, requiresBootstrapStackVersion: undefined, bootstrapStackVersionSsmParameter: undefined, terminationProtection: undefined, stackName: 'Sample1Stack', assets: [], displayName: 'Sample1Stack', name: 'Sample1Stack', originalName: 'Sample1Stack', _deps: [], _template: { Resources: [Object], Outputs: [Object] } } }
(b) AWS SDK の CloudFormation API を利用
今回は、AWS SDK for JavaScript v3 を使います。
実質的に AWS CLI の cloudformation deploy
コマンドと同じ事をするだけですが、AWS SDK にはこれに相当する API が用意されていないようなので、下記ソースコードを参考に自作してみました。
- https://github.com/aws/aws-cli/blob/develop/awscli/customizations/cloudformation/deploy.py
- https://github.com/aws/aws-cli/blob/develop/awscli/customizations/cloudformation/deployer.py
なお、CDK の下記ソースコードでも同じような処理になっていました。
- https://github.com/aws/aws-cdk/blob/master/packages/aws-cdk/lib/api/deploy-stack.ts
- https://github.com/aws/aws-cdk/blob/master/packages/aws-cdk/lib/api/util/cloudformation.ts
簡単にまとめると、以下のような処理を実装する事になります。
- (1) DescribeStacks で Stack の有無を確認して、ChangeSetType(
CREATE
かUPDATE
)を決定 - (2) CreateChangeSet で変更セットを作成
- (3) 処理が完了するまで DescribeChangeSet をポーリング
- (4) ExecuteChangeSet で変更セットを実行
- (5) 処理が完了するまで DescribeStacks をポーリング
(1) の DescribeStacks では該当するスタックが存在しない場合にもエラーとなってしまうため、他のエラーと区別するためにエラーメッセージが指定の文字列に合致するかどうかで判定しています。(AWS CLI や CDK と同様)
(3) では変更セットのステータスが CREATE_PENDING
か CREATE_IN_PROGRESS
以外になるまでポーリングし、(5) ではスタックのステータスが xxx_IN_PROGRESS
以外になるまでポーリングすれば良さそうです。
また、(3) におけるポーリングの間隔は AWS CLI と CDK 共に 5秒毎となっていましたが、(5) の方は AWS CLI が 30秒で CDK は 5秒となっているようでした。
b_sample/deployer.ts(CloudFormation の API を使ったデプロイ処理の実装)
import { CloudFormationStackArtifact } from '@aws-cdk/cx-api' import { CloudFormationClient, CreateChangeSetCommand, ExecuteChangeSetCommand, DescribeChangeSetCommand, DescribeStacksCommand, Change, Stack } from '@aws-sdk/client-cloudformation' const MAX_RETRY = 100 const WAIT_TIME_CREATE = 5000 const WAIT_TIME_EXECUTE = 10000 const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const cf = new CloudFormationClient({}) type ChangeSetType = 'CREATE' | 'UPDATE' export class CfDeployer { static async deploy(stackArtifact: CloudFormationStackArtifact): Promise<Stack | undefined> { const stackName = stackArtifact.stackName // (1) const changeSetType = await CfDeployer.selectChangeSetType(stackName) // (2) const r1 = await cf.send(new CreateChangeSetCommand({ ChangeSetName: `ChangeSet-${Date.now()}`, StackName: stackName, TemplateBody: JSON.stringify(stackArtifact.template), ChangeSetType: changeSetType })) console.log(r1) const changeSetId = r1.Id if (!changeSetId) { throw new Error(`failed create ChangeSet: StackName=${stackName}`) } // (3) const cs = await CfDeployer.waitForCreate(changeSetId, stackName) if (cs.length < 1) { console.log('*** NO CHANGE') return CfDeployer.getStack(stackName) } // (4) const r2 = await cf.send(new ExecuteChangeSetCommand({ ChangeSetName: changeSetId, StackName: stackName })) console.log(r2) // (5) return CfDeployer.waitForExecute(stackName) } static async getStackStatus(stackName: string): Promise<string> { const stack = await CfDeployer.getStack(stackName) return stack?.StackStatus ?? 'NONE' } static async getStack(stackName: string): Promise<Stack | undefined> { try { const r = await cf.send(new DescribeStacksCommand({ StackName: stackName })) return r.Stacks?.[0] } catch(e) { // スタックが存在しなかった場合のエラー以外はそのまま throw if (e.Code !== 'ValidationError' || e.message !== `Stack with id ${stackName} does not exist`) { throw e } } return undefined } // (3) private static async waitForCreate(changeSetId: string, stackName: string): Promise<Change[]> { for (let i = 0; i < MAX_RETRY; i++) { await sleep(WAIT_TIME_CREATE) const r = await cf.send(new DescribeChangeSetCommand({ ChangeSetName: changeSetId, StackName: stackName })) if (r.Status === 'CREATE_PENDING' || r.Status === 'CREATE_IN_PROGRESS') { continue } if (r.Status == 'CREATE_COMPLETE') { return r.Changes ?? [] } if (r.Status == 'FAILED') { console.log(`*** failed: ${JSON.stringify(r)}`) return [] } throw new Error(`failed create ChangeSet: ChangeSetId=${changeSetId}, StackName=${stackName}`) } throw new Error(`create ChangeSet timeout: ChangeSetId=${changeSetId}, StackName=${stackName}`) } // (5) private static async waitForExecute(stackName: string): Promise<Stack> { for (let i = 0; i < MAX_RETRY; i++) { await sleep(WAIT_TIME_EXECUTE) const stack = await CfDeployer.getStack(stackName) if (!stack) { throw new Error('not found') } if (!stack.StackStatus?.endsWith('_IN_PROGRESS')) { return stack } } throw new Error(`execute ChangeSet timeout: StackName=${stackName}`) } private static async selectChangeSetType(stackName: string): Promise<ChangeSetType> { const status = await CfDeployer.getStackStatus(stackName) return (status == 'NONE' || status == 'REVIEW_IN_PROGRESS') ? 'CREATE' : 'UPDATE' } }
上記を使ってスタックをデプロイする処理は以下のようになります。
b_sample/deploy_b.ts
import { App } from '@aws-cdk/core' import { CfDeployer } from './deployer' import { SampleStack } from './stack' const app = new App() new SampleStack(app, 'Sample2Stack') CfDeployer.deploy(app.synth().stacks[0]) .then(r => console.log(r)) .catch(err => console.error(err))
パッケージインストール例
> cd b_sample > npm install -D ts-node typescript @types/node ・・・ > npm install @aws-cdk/aws-dynamodb @aws-sdk/client-cloudformation ・・・
実行結果は以下の通りです。 こちらも CloudFormation のスタックが登録され、DynamoDB のテーブルが問題なく作成されました。
実行結果
> npx ts-node deploy_b.ts { '$metadata': { httpStatusCode: 200, requestId: '・・・', extendedRequestId: undefined, cfId: undefined, attempts: 1, totalRetryDelay: 0 }, Id: 'arn:aws:cloudformation:・・・:changeSet/ChangeSet-・・・', StackId: 'arn:aws:cloudformation:・・・:stack/Sample2Stack/・・・' } { '$metadata': { httpStatusCode: 200, requestId: '・・・', extendedRequestId: undefined, cfId: undefined, attempts: 1, totalRetryDelay: 0 } } { StackId: 'arn:aws:cloudformation:・・・', StackName: 'Sample2Stack', ChangeSetId: 'arn:aws:cloudformation:・・・', Description: undefined, Parameters: undefined, ・・・ StackStatus: 'CREATE_COMPLETE', StackStatusReason: undefined, DisableRollback: false, NotificationARNs: [], TimeoutInMinutes: undefined, Capabilities: undefined, Outputs: [ { OutputKey: 'TableName', OutputValue: 'Sample2Stack-Items・・・', Description: undefined, ExportName: undefined } ], RoleARN: undefined, Tags: [], EnableTerminationProtection: false, ParentId: undefined, RootId: undefined, DriftInformation: { StackDriftStatus: 'NOT_CHECKED', LastCheckTimestamp: undefined } }