ヘヴィメタル・エンジニアリング

AWS特化型エンジニアのほのぼのヘヴィメタルブログ

ヘヴィメタル・エンジニアリング

クラウド特化型ヘヴィメタルエンジニアのほのぼのブログ

【GO】 外部ライブラリを用いたコードをテスタブルに書き換える ~GCPクライアントライブラリ編~

はじめに

よくGOに触れるものです。

以前、GCPのクライアントライブラリを使ったコードを書いていたときに、それをテスタブルに書くにはどうすれば良いのか試行錯誤したので、その内容について書いていこうと思います。

特にGCPに偏った話ではなく、外部ライブラリを用いているコードに対しても応用が効くので、誰かの助けになればと思います。

最終的に出来上がったコードはGitHubで公開しています。

github.com

初期コード

今回はBigQueryのクライアントライブラリを例にとって説明します。

ローカルからBigQueryに接続する方法は省略していますが、権限をもったサービスアカウントのキーをローカル環境変数GOOGLE_APPLICATION_CREDENTIALSに読ませています。

まずは適当なGOプロジェクトを作成して、BigQueryのクライアントライブラリをinstallします。

$ mkdir go-sample

$ cd go-sample
$ go mod init go-sample
$ go get cloud.google.com/go/bigquery
$ touch main.go
$ mkdir bigquery
$ mkdir pkg

$ cd bigquery
$ touch export.go

$ cd ../pkg
$ touch client.go

main.go、bigquery/ecport.go、pkg/client.goは以下のようになります

main.go

package main

import (
    "context"
    "fmt"
    "go-sample/bigquery"
    "go-sample/pkg"
)

func main () {
    ctx := context.TODO()
    projectID := "xxx"

    client, err := pkg.Client(ctx, projectID)
    if err != nil {
        fmt.Println(err)
    }

    if err := bigquery.Export(ctx, client); err != nil {
        fmt.Println(err)
    }
}

bigquery/export.go

package bigquery

import (
    "cloud.google.com/go/bigquery"
    "context"
    "fmt"
)

type Item struct {
    Name string
    Gender string
}

func Export(ctx context.Context, client *bigquery.Client) error {
    dataset := "test"
    table := "test-table"

    item := []Item{
        {
            Name: "kendric",
            Gender: "male",
        },
    }

    if err := client.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil {
        return fmt.Errorf("Failed to insert record to Bigquery.")
    }

    fmt.Println("Finished to insert record to bigquery.")

    return nil
}

pkg/client.go

package pkg

import (
    "cloud.google.com/go/bigquery"
    "context"
    "fmt"
)

func Client(ctx context.Context, projectID string) (*bigquery.Client, error){
    client, err := bigquery.NewClient(ctx, projectID)
    if err != nil {
        return nil, fmt.Errorf("Failed to get client.")
    }
    return client, nil
}

Export()の中ではClientを取得して、BigQueryデータセット、BigQueryテーブルを指定してClientから呼び出されるメソッドに代入して、itemをinsertしています。

とっても簡易的なコードですね。

さてテスタブルに書き換えていきましょう。

Test

Test内容

まずこのExport()のテストとしてどんな内容のものを書けばいいか考えてみましょう。

といっても、とても簡素なのであまりテストしがいが無いですが、汎用的に応用できる考え方で進めていきます。

BigQueryライブラリから呼び出されるメソッドは外部定義されており、そもそも開発元でテストコードがあることでしょう。

なので、責任領域を分けて、Export()内の他の処理のテストに注力することにします。

その際、BigQueryライブラリから呼び出されるメソッドはすべてmock化すればよく、そうすると他の処理のテストに集中できます。

mock化するメソッドをwrapする

mock化したいということはわかりました。

まず思いつくのは、Export()は第2引数でclientを受け取っていて、そのメソッドを呼びしています。

であれば、clientのmockを定義してTestの際にExportに渡せば良いことでしょう。

ですが、clientから呼ばれるメソッドは Dataset() → Table() → Inserter() → Put()とチェーンされているためそのmockを定義することは容易ではありません。

そんなときはもっと単純に可読性高く変更しましょう。

まずはチェーンされているメソッドの箇所をwrapします。

bigquery/export.go

func Export(ctx context.Context, client *bigquery.Client) error {
    dataset := "test"
    table := "test-table"

    item := []Item{
        {
            Name: "kendric",
            Gender: "male",
        },
    }

    if err := putRecord(ctx, dataset, table, item, client); err != nil {
        return err
    }

    fmt.Println("Finished to insert record to bigquery.")

    return nil
}

func putRecord(ctx context.Context, dataset string, table string, item []Item, client *bigquery.Client) error {
    if err := client.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil {
        return fmt.Errorf("Failed to insert record to Bigquery.")
    }
    return nil
}

こうすると、putRecordをmock化すればいいと視覚的にわかりやすくなります。

ただ、Export()の引数でclientを受け取ってそれをputRecordで用いているため、まだ汎用的なmockを作ることができません。

であれば思いつくのが、clientは事前に構造体に入れておいて、レシーバ引数で受け取れるようにすればよいのではないでしょうか?

それならばTestの際にclientに適当な仮の値を入れることもできます。

実際に一気に書き換えてみましょう。

テスタブルに修正

書き換えたコードが以下です。

main.go

package main

import (
    "context"
    "fmt"
    "go-sample/bigquery"
    "go-sample/pkg"
)

func main () {
    ctx := context.TODO()
    projectID := "xxx"

    client, err := pkg.Client(ctx, projectID)
    if err != nil {
        fmt.Println(err)
    }

    bqClientImpl := &bigquery.BigqueryClientImpl{client}
    bq := bigquery.Exporter{bqClient}

    if err := bq.Export(ctx); err != nil {
        fmt.Println(err)
    }
}

bigquery/export.go

package bigquery

import (
    "cloud.google.com/go/bigquery"
    "context"
    "fmt"
)

type Item struct {
    Name string
    Gender string
}

type (
    BigqueryClient interface {
        putRecord(ctx context.Context, dataset string, table string, item []Item) error
    }

    Exporter struct {
        Bq BigqueryClient
    }

    BigqueryClientImpl struct {
        BqClient *bigquery.Client
    }

    MockBigqueryClientImpl struct {
        BqClient *bigquery.Client
    }
)

func (b *Exporter) Export(ctx context.Context) error {
    dataset := "test"
    table := "test-table"

    item := []Item{
        {
            Name: "kendric",
            Gender: "male",
        },
    }

    if err := b.Bq.putRecord(ctx, dataset, table, item); err != nil {
        return err
    }

    fmt.Println("Finished to insert record to bigquery.")

    return nil
}

func (b *BigqueryClientImpl) putRecord(ctx context.Context, dataset string, table string, item []Item) error {
    if err := b.BqClient.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil {
        return fmt.Errorf("Failed to insert record to Bigquery.")
    }
    return nil
}

重要なのはExport()の呼び出し元で、正規かmockかを選択できるようにしたことです。

Exporter構造体はmockも受け付けることができるので、Testの際は柔軟に切り替えることができます。

またinterfaceも定義して、putRecordを置くことで、実際のputRecordの入ったstructをExporterが受け取ることができます。

Testコード

実際のテストコードもmain.goで構造体を初期化したのと同じやり方で書けます。

bigquery/export_test.go

package bigquery

import (
    "context"
    "testing"
)

func Test_Export(t *testing.T) {
    mockBqClientImpl := &MockBigqueryClientImpl{nil}
    bq := Exporter{mockBqClientImpl}

    t.Run("Test to put record into bigquery", func(t *testing.T) {
        ctx := context.TODO()
        if err := bq.Export(ctx); err != nil {
            t.Fatal("expect error")
        }
    })
}

bigquery/export_mock.go

package bigquery

import (
    "context"
)

func (m *MockBigqueryClientImpl) putRecord(ctx context.Context, dataset string, table string, item []Item) error {
    return nil
}

代わりにmockを代入するだけです。

clientにはnilを代入しています。

mock化したputRecord()はお好きな用に書き換えましょう。

これでExport()内のBigQueryクライアントライブラリメソッドをすべてmock化できました。

例のコードはBigQueryのメソッド以外に処理を用意していないため、mock化後は意味のないコードになりましたが、BigQueryテーブルに書き込む際にデータ整形処理などが入っている場合は、その処理のテストに注力することができます。

まとめ

今回はGCPのクライアントライブラリを例にとりましたが、外部ライブラリ全般に応用できるので、mock化する際は参考にしてみてください。

BigQueryテーブル、データセットの作成を効率化 ~ Makefile編 ~

はじめに

よくGCPに触れるものです。

データウェアハウスとして頻繁に用いられる BigQuery ですが、いちいちデータセットやテーブルを作成するのは面倒なので、コマンドベースでお手軽に作成できるようにしましょう。

今回は Makefile を用います。

準備

まず以下のコマンドをローカルで使えるように準備してください。

gcloud
bq
make

bq のオペーレーションが行われるGCPプロジェクトはgcloudコマンドの向き先に依存するので、gcloudコマンドで configuration を設定しましょう。

$ gcloud config set project <project name>

これでbqコマンドを用いたとき之処理が、gcloudコマンドで設定した向き先で行われます。

Makefileの作成

まず以下のようなディレクトリ構成を作ります。

working dir
├── Makefile
├── .env

Makefile内の環境変数渡しにはdotenvを用います。

次にMakefile内にBigQueryデータセットとテーブルを作成する処理を追加します。

Makefile

include .env

.PHONY: create-bigquery-dataset create-bigquery-table

create-bigquery-dataset:
    @read -p "Specify the dataset name  : " dataset; \
    bq mk --dataset \
        --location=$(BIGQUERY_DATASET_LOCATION) \
        $$dataset

create-bigquery-table:
    @read -p "Specify the dataset name : " dataset; \
    read -p "Specify the table name: " table; \
    bq mk --table \
        --time_partitioning_type=DAY \
        --time_partitioning_field=createdTime \
        --schema=id:STRING,name:STRING,createdTime:TIMESTAMP \
                 $$dataset.$$table

テーブルのschemaは至って単純なもので、例としてTIMESTAMP型の column を用意して、パーティションを切ってみました。

時系列データはパーティションを切ることで、BigQueryのクエリパフォーマンスが向上します。

Makefile内でのポイントとしては

・includeで.envを読み込む
・コマンドの頭に@をつけることで、コマンドラインにコマンドを表示しない
・readコマンドで読んだ変数はMakefile内で$$を付けて使用

です。

そして、データセット作成の際に指定している環境変数は.envに書き込みます。

.env

BIGQUERY_DATASET_LOCATION=us

動作確認

Makefile を置いているディレクトリでmakeコマンドを実行しましょう。

$ make create-bigquery-dataset
create bigquery dataset...
Specify the dataset name  : foo_dataset
Dataset 'xxx:foo_dataset' successfully created.


$ make create-bigquery-table
create bigquery table...
Specify the dataset name  : foo_dataset
Specify the table name  : bar_table
Table 'xxx:foo_dataset.bar_table' successfully created.

実際に作成されたかGCPコンソールからも確認しましょう。 f:id:xkenshirou:20211014201741p:plain

TIPS

パーティションをカラムに設定した場合は、パーティションの有効期限も指定することをおすすめします。

データウェアハウスに貯めるデータとして、一定期間過去のデータは不要な場合は、ライフサイクルを設定することが大切です。
これにより余分なコストを抑えることができます。

ついでに Makefileパーティションの有効期限を設定するコマンドも用意しましょう。

Makefile

.PHONY: set-bigquery-table-expiration-date

set-bigquery-table-expiration-date:
    @read -p "Specify the dataset name : " dataset; \
    read -p "Specify the table name : " table; \
    bq update --time_partitioning_expiration $(BIGQUERY_TABLE_PARTITIONING_EXPIRATION_TIME) $(GCP_PROJECT_NAME):$$dataset.$$table

.envにGCP_PROJECT_NAMEも設定します。

今回は有効期限を30日にします。

.env

BIGQUERY_TABLE_PARTITIONING_EXPIRATION_TIME=2592000

コマンドを実行します。

$ make set-bigquery-table-expiration-date
Specify the dataset name  : foo_dataset
Specify the table name  : bar_table
Table 'xxx:foo_dataset.bar_table' successfully updated.

GCPコンソールで確認します。

f:id:xkenshirou:20211014202413p:plain

設定できました。

まとめ

このようにコマンド化することで、環境構築が簡単になります。

あくまで今回はBigQueryの環境構築をしましたが、様々な環境構築をMakefileで効率化することで開発スピードは上がります。

できるところはどんどん作業効率化していきましょう。

アカウント間S3レプリケーションのやり方

はじめに

よくAWSに触れるものです。

複数AWS環境を持っている方で、S3バケット内のオブジェクトデータを移したいと思うことはよくあるのではないでしょうか。

そんなときに、スタンダードなやり方としては$ aws s3 sync$ aws s3 cpを使うかと思うのですが、それだといちいちコマンドを打つのがめんどくさいですし、そもそも膨大なオブジェクトデータが存在しているとかなりの時間がかかります。

そんなときはS3のレプリケーション機能を使いましょう。

S3レプリケーション機能はレプリケート元バケットに新しくオブジェクトが追加されると、レプリケート先S3バケットにもオブジェクトが再現されます。

今回はそんな便利なS3のレプリケーション機能について実践します。

導入

まずドキュメントでS3レプリケーションについてのドキュメントを読んだときに、

あれ?レプリケートって言葉はどっち方向の言葉?レプリケート元ってどっち?

てなることがあるかなと思います。

なので、以下のような図を用意したので、そちらを参考にしてください。

f:id:xkenshirou:20210212191505p:plain

手順

それでは早速、実践していきましょう。

1. S3バケットのバージョニングを有効にする

レプリケート元、先バケットのバージョニングを有効にしましょう。

2. レプリケート元バケットレプリケーションルールを作成

バケットの管理からレプリケーションルールの作成を選択します。

f:id:xkenshirou:20210212191921p:plain

ここでは

  • 適当なルール名を入力
  • ルールスコープはすべてのオブジェクトを選択
  • 送信先でレプリケート先アカウントを指定 ※ この際、オブジェクト所有者を送信先バケット所有者に変更を選択してください
  • IAMロールを付与

の用に設定を行います。

IAMロールはここで自動作成しても良いです。

以下のポリシーを持ったロールを作成しましょう。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:ListBucket",
                "s3:GetReplicationConfiguration",
                "s3:GetObjectVersionForReplication",
                "s3:GetObjectVersionAcl",
                "s3:GetObjectVersionTagging",
                "s3:GetObjectRetention",
                "s3:GetObjectLegalHold"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:s3:::<レプリケート先バケット名>",
                "arn:aws:s3:::<レプリケート先バケット名>/*"
            ]
        },
        {
            "Action": [
                "s3:ReplicateObject",
                "s3:ReplicateDelete",
                "s3:ReplicateTags",
                "s3:ObjectOwnerOverrideToBucketOwner"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:s3:::<レプリケート先バケット名>/*",
                "arn:aws:s3:::<レプリケート先バケット名>/*"
            ]
        }
    ]
}

3. レプリケート先バケットバケットポリシーを追加する

以下のようなバケットポリシーを追加します。

{
    "Version": "2008-10-17",
    "Id": "",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<account-id>:role/<レプリケーションルールに付与したロール名>"
            },
            "Action": [
                "s3:ReplicateObject",
                "s3:ReplicateDelete"
            ],
            "Resource": "arn:aws:s3:::<このポリシーを追加しているバケット名>/*"
        },
        {
            "Sid": "2",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<account-id>:role/service-role/<レプリケーションルールに付与したロール名>"
            },
            "Action": [
                "s3:GetBucketVersioning",
                "s3:PutBucketVersioning"
            ],
            "Resource": "arn:aws:s3:::<このポリシーを追加しているバケット名>"
        }
        {
            "Sid": "3",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<account-id>:root"
            },
            "Action": "s3:ObjectOwnerOverrideToBucketOwner",
            "Resource": "arn:aws:s3:::<このポリシーを追加しているバケット名>/*"
        }
    ]
}

この際、Sid: 3のポリシーが無いと、オブジェクトの所有権をレプリケート先バケットにすることができないので注意しましょう。

これでレプリケーションの設定は完了なので、実際にレプリケート元バケットにオブジェクトを保存して、レプリケート先バケットに再現されるか試してみましょう。

おわりに

AWSのストアやストレージ系のレプリケーション機能については以前もこちらで紹介しています。

xkenshirou.hatenablog.com

レプリケーション機能は他にもECRにも存在しているので、今度そちらにも触れようと思います。

参考

レプリケーション設定方法
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/user-guide/enable-replication.html

アカウント間レプリケーションの設定方法
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/replication-walkthrough-2.html

レプリケーションする際のオブジェクト所有権を移す
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/replication-change-owner.html