1つのクラウドベンダーに一途なあなたも Go CDK の採用を検討すべき理由

## Go CDK とは

Go CDK とは The Go Cloud Development Kit の略で、主要クラウドベンダーが提供しているほぼ同一の機能を持ったサービスを統一的な API で扱うためのプロジェクトです(旧称 Go Cloud)。

例えばクラウドストレージサービスにオブジェクトを保存・取得する処理は Go CDK を使うことで次のように書くことができます[^1]。

[^1]: 本稿のサンプルコードで使用している `gocloud.dev` は [v0.20.0](https://pkg.go.dev/gocloud.dev@v0.20.0) です

- Amazon S3

```go
package main

import (
	"context"
	"fmt"
	"log"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	// 保存
	if err := bucket.WriteAll(ctx, "sample.txt", []byte("Hello, world!"), nil); err != nil {
		log.Fatal(err)
	}

	// 取得
	data, err := bucket.ReadAll(ctx, "sample.txt")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(data))
}
```

- Google Cloud Storage

```go
package main

import (
	"context"
	"fmt"
	"log"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/gcsblob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "gs://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	// 保存
	if err := bucket.WriteAll(ctx, "sample.txt", []byte("Hello, world!"), nil); err != nil {
		log.Fatal(err)
	}

	// 取得
	data, err := bucket.ReadAll(ctx, "sample.txt")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(data))
}
```

- Azure Blob Storage

```go
package main

import (
	"context"
	"fmt"
	"log"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/azureblob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "azblob://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	// 保存
	if err := bucket.WriteAll(ctx, "sample.txt", []byte("Hello, world!"), nil); err != nil {
		log.Fatal(err)
	}

	// 取得
	data, err := bucket.ReadAll(ctx, "sample.txt")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(data))
}
```

異なるクラウドベンダーを使用する場合のコードの違いはドライバーの import 部分と [`blob.OpenBucket()`](https://pkg.go.dev/gocloud.dev/blob@v0.20.0#OpenBucket) に与えている URL の scheme のみです。素晴らしいですね！

このように Go CDK を使うことでマルチクラウドなアプリケーションやクラウドポータビリティの高いアプリケーションを容易に実装することができます。

Go CDK についてより詳しく知りたい方は公式の情報をご参照ください。

- [GitHub](https://github.com/google/go-cloud)
- [公式ドキュメント](https://gocloud.dev/)
- [go.dev](https://pkg.go.dev/gocloud.dev)

大変便利な Go CDK ですが2020年10月現在のプロジェクトステータスは「API は alpha だけど production-ready」[^2] という感じらしいです。導入される際は自己責任でお願いします。

[^2]: https://github.com/google/go-cloud#project-status

## マルチクラウド・ポータビリティ以外にもある Go CDK のメリット

本題です。

「AWS しか使わない！ベンダーロックイン上等！」といった考えの人もいると思います。
本稿ではそういった方でも Go CDK を使うメリットは十分あるということを、「S3 のオブジェクト操作(保存・取得)」を例にご紹介したいと思います。

### API が分かりやすい・扱いやすい

Go CDK の API は直観的に理解しやすく扱いやすい設計です。

Go CDK でのクラウドストレージへのオブジェクトの読み書きは [`blob.Bucket`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Bucket) の 
[`NewReader()`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Bucket.NewReader) 及び [`NewWriter()`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Bucket.NewWriter) によって得られる [`blob.Reader`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Reader) ([`io.Reader`](https://pkg.go.dev/io#Reader) を実装) と [`blob.Writer`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Writer) ([`io.Writer`](https://pkg.go.dev/io#Writer) を実装) を使います。
オブジェクトの取得(読み込み)を `blob.Reader` (`io.Reader`)、保存(書き込み)を `io.Writer` (`io.Writer`) で行えるというのは非常に直観的です。これにより、ローカルファイルを操作するかのような感覚でクラウド上のオブジェクトを扱うことができます。

AWS SDK を使う場合と比べてどうわかりやすくなるかを、具体例を挙げつつ見ていきます。

#### S3 にオブジェクトを保存する場合

AWS SDK の場合、[`s3manager.Uploader`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3/s3manager#Uploader) の [`Upload()`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3/s3manager#Uploader.Upload) を使うことになります。
アップロードするオブジェクトの内容は `io.Reader` としてメソッドに渡します。ローカルにあるファイルをアップロードする場合なら [`os.File`](https://pkg.go.dev/os#File) をそのまま渡せて便利なのですが、厄介なのはメモリ上にあるデータを何らかの形式でエンコードしてそのまま保存したい場合です。

例えば JSON エンコードしてそのまま S3 という処理は、AWS SDK では次のようになります。

```go
package main

import (
	"encoding/json"
	"io"
	"log"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3/s3manager"
)

func main() {
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		log.Fatal(err)
	}

	uploader := s3manager.NewUploader(sess)

	data := struct {
		Key1 string
		Key2 string
	}{
		Key1: "value1",
		Key2: "value2",
	}

	pr, pw := io.Pipe()

	go func() {
		err := json.NewEncoder(pw).Encode(data)
		pw.CloseWithError(err)
	}()

	in := &s3manager.UploadInput{
		Bucket: aws.String("bucket"),
		Key:    aws.String("sample.json"),
		Body:   pr,
	}
	if _, err := uploader.Upload(in); err != nil {
		log.Fatal(err)
	}
}
```

JSON をエンコードするための `io.Writer` と `s3manager.UploadInput` に渡す `io.Reader` とを繋ぐために [`io.Pipe()`](https://pkg.go.dev/io#Pipe) を使う必要があります。

Go CDK であれば、書き込みは `blob.Writer` (`io.Writer`) で行うのでそのまま [`json.NewEncoder()`](https://pkg.go.dev/encoding/json#NewEncoder) に渡すだけです。

```go
package main

import (
	"context"
	"encoding/json"
	"log"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	data := struct {
		Key1 string
		Key2 string
	}{
		Key1: "value1",
		Key2: "value2",
	}

	w, err := bucket.NewWriter(ctx, "sample.json", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer w.Close()

	if err := json.NewEncoder(w).Encode(data); err != nil {
		log.Fatal(err)
	}
}
```

もちろんローカルファイルをアップロードする場合もシンプルに書けます。
ファイルからファイルへとコピーするかのごとく [`io.Copy`](https://pkg.go.dev/io#Copy) を使うだけです。

```go
package main

import (
	"context"
	"io"
	"log"
	"os"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	file, err := os.Open("sample.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	w, err := bucket.NewWriter(ctx, "sample.txt", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer w.Close()

	if _, err := io.Copy(w, file); err != nil {
		log.Fatal(err)
	}
}
```

ちなみに、`s3blob` の Writer は `s3manager.Uploader` を wrap する形で実装されているため `s3manager.Uploader` の持つ並列アップロード機能の恩恵を受けることができます。

#### S3 からオブジェクトを取得する場合

S3 から JSON を取得してデコードする場合を考えてみましょう。

AWS SDK の場合、[`s3.GetObject()`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3#S3.GetObject) を使います。
`s3manager.Uploader` と対になる [`s3manager.Downloader`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3/s3manager#Downloader) は出力先が [`io.WriterAt`](https://pkg.go.dev/io#WriterAt) を実装している必要があるため、このケースでは使えないことに注意が必要です。

```go
package main

import (
	"encoding/json"
	"fmt"
	"log"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
)

func main() {
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		log.Fatal(err)
	}

	svc := s3.New(sess)

	data := struct {
		Key1 string
		Key2 string
	}{}

	in := &s3.GetObjectInput{
		Bucket: aws.String("bucket"),
		Key:    aws.String("sample.json"),
	}
	out, err := svc.GetObject(in)
	if err != nil {
		log.Fatal(err)
	}
	defer out.Body.Close()

	if err := json.NewDecoder(out.Body).Decode(&data); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%+v\n", data)
}
```

Go CDK の場合はアップロードの時と逆になるように書くだけです。

```go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	r, err := bucket.NewReader(ctx, "sample.json", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer r.Close()

	data := struct {
		Key1 string
		Key2 string
	}{}

	if err := json.NewDecoder(r).Decode(&data); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%+v\n", data)
}
```

取得したオブジェクトをローカルファイルに書き込む場合は [`s3manager.Downloader`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3/s3manager#Downloader) を使うことができます。

```go
package main

import (
	"log"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/aws/aws-sdk-go/service/s3/s3manager"
)

func main() {
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		log.Fatal(err)
	}

	downloader := s3manager.NewDownloader(sess)

	file, err := os.Create("sample.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	in := &s3.GetObjectInput{
		Bucket: aws.String("bucket"),
		Key:    aws.String("sample.txt"),
	}
	if _, err := downloader.Download(file, in); err != nil {
		log.Fatal(err)
	}
}
```

Go CDK ではこの場合もアップロードの時と逆になるように書けば OK です。

```go
package main

import (
	"context"
	"io"
	"log"
	"os"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	file, err := os.Create("sample.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	r, err := bucket.NewReader(ctx, "sample.txt", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer r.Close()

	if _, err := io.Copy(file, r); err != nil {
		log.Fatal(err)
	}
}
```

ただしこの方法はシンプルですが欠点もあります。
`s3manager.Downloder` の場合は出力先に `io.WriterAt` を要求する代わりに並列ダウンロード機能を備えておりパフォーマンスに優れていますが、Go CDK の場合そのままでは並列ダウンロードを行うことができません。
Go CDK で並列ダウンロードを行たい場合は [`NewRangeReader()`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob#Bucket.NewRangeReader) を使って自前で実装する必要があります。

<details><summary>Go CDK での並列ダウンロード実装例 (長いので折りたたみます)</summary>
<div>

```go
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"os"
	"sync"

	"gocloud.dev/blob"
	_ "gocloud.dev/blob/s3blob"
)

const (
	downloadPartSize    = 1024 * 1024 * 5
	downloadConcurrency = 5
)

func main() {
	ctx := context.Background()

	bucket, err := blob.OpenBucket(ctx, "s3://bucket")
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	file, err := os.Create("sample.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	d := &downloader{
		ctx:         ctx,
		bucket:      bucket,
		key:         "sample.txt",
		partSize:    downloadPartSize,
		concurrency: downloadConcurrency,
		w:           file,
	}

	if err := d.download(); err != nil {
		log.Fatal(err)
	}
}

type downloader struct {
	ctx context.Context

	bucket *blob.Bucket
	key    string
	opts   *blob.ReaderOptions

	partSize    int64
	concurrency int

	w io.WriterAt

	wg     sync.WaitGroup
	sizeMu sync.RWMutex
	errMu  sync.RWMutex

	pos        int64
	totalBytes int64
	err        error

	partBodyMaxRetries int
}

func (d *downloader) download() error {
	d.getChunk()
	if err := d.getErr(); err != nil {
		return err
	}

	total := d.getTotalBytes()

	ch := make(chan chunk, d.concurrency)

	for i := 0; i < d.concurrency; i++ {
		d.wg.Add(1)
		go d.downloadPart(ch)
	}

	for d.getErr() == nil {
		if d.pos >= total {
			break
		}

		ch <- chunk{w: d.w, start: d.pos, size: d.partSize}
		d.pos += d.partSize
	}

	close(ch)
	d.wg.Wait()

	return d.getErr()
}

func (d *downloader) downloadPart(ch chan chunk) {
	defer d.wg.Done()
	for {
		c, ok := <-ch
		if !ok {
			break
		}
		if d.getErr() != nil {
			continue
		}

		if err := d.downloadChunk(c); err != nil {
			d.setErr(err)
		}
	}
}

func (d *downloader) getChunk() {
	if d.getErr() != nil {
		return
	}

	c := chunk{w: d.w, start: d.pos, size: d.partSize}
	d.pos += d.partSize

	if err := d.downloadChunk(c); err != nil {
		d.setErr(err)
	}
}

func (d *downloader) downloadChunk(c chunk) error {
	var err error
	for retry := 0; retry <= d.partBodyMaxRetries; retry++ {
		err := d.tryDownloadChunk(c)
		if err == nil {
			break
		}

		bodyErr := &errReadingBody{}
		if !errors.As(err, &bodyErr) {
			return err
		}

		c.cur = 0
	}
	return err
}

func (d *downloader) tryDownloadChunk(c chunk) error {
	r, err := d.bucket.NewRangeReader(d.ctx, d.key, c.start, c.size, d.opts)
	if err != nil {
		return err
	}
	defer r.Close()

	if _, err := io.Copy(&c, r); err != nil {
		return err
	}

	d.setTotalBytes(r.Size())

	return nil
}

func (d *downloader) getErr() error {
	d.errMu.RLock()
	defer d.errMu.RUnlock()

	return d.err
}

func (d *downloader) setErr(err error) {
	d.errMu.Lock()
	defer d.errMu.Unlock()

	d.err = err
}

func (d *downloader) getTotalBytes() int64 {
	d.sizeMu.RLock()
	defer d.sizeMu.RUnlock()

	return d.totalBytes
}

func (d *downloader) setTotalBytes(size int64) {
	d.sizeMu.Lock()
	defer d.sizeMu.Unlock()

	d.totalBytes = size
}

type chunk struct {
	w     io.WriterAt
	start int64
	size  int64
	cur   int64
}

func (c *chunk) Write(p []byte) (int, error) {
	if c.cur >= c.size {
		return 0, io.EOF
	}

	n, err := c.w.WriteAt(p, c.start+c.cur)
	c.cur += int64(n)

	return n, err
}

type errReadingBody struct {
	err error
}

func (e *errReadingBody) Error() string {
	return fmt.Sprintf("failed to read part body: %v", e.err)
}

func (e *errReadingBody) Unwrap() error {
	return e.err
}
```

※ `s3manager.Downloader` の実装を参考にしています 

</div>
</details>

### ローカル実行が容易になる

Go CDK は全てのサービスに対しローカル実装を提供するように開発が進められています。そのため、クラウドサービスの操作を簡単にローカル実装に差し替えることができます。
例えば開発用のローカルサーバなどでは全てのサービスをローカル実装に差し替えておくと AWS や GCP へのアクセスを発生させずに動作させることができるので便利です。

クラウドストレージを扱う [`gocloud.dev/blob`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob) パッケージの場合、[`fileblob`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob/fileblob) というローカルファイルの読み書きを行う実装が提供されています。

以下はエンコードした JSON の出力先をオプションに応じて S3 とローカルとに切り替える例です。

```go
package main

import (
	"context"
	"encoding/json"
	"flag"
	"log"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"gocloud.dev/blob"
	"gocloud.dev/blob/fileblob"
	"gocloud.dev/blob/s3blob"
)

func main() {
	var local bool
	flag.BoolVar(&local, "local", false, "output to a local file")
	flag.Parse()

	ctx := context.Background()

	bucket, err := openBucket(ctx, local)
	if err != nil {
		log.Fatal(err)
	}
	defer bucket.Close()

	data := struct {
		Key1 string
		Key2 string
	}{
		Key1: "value1",
		Key2: "value2",
	}

	w, err := bucket.NewWriter(ctx, "sample.json", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer w.Close()

	if err := json.NewEncoder(w).Encode(data); err != nil {
		log.Fatal(err)
	}
}

func openBucket(ctx context.Context, local bool) (*blob.Bucket, error) {
	if local {
		return openLocalBucket(ctx)
	}

	return openS3Bucket(ctx)
}

func openLocalBucket(ctx context.Context) (*blob.Bucket, error) {
	return fileblob.OpenBucket("output", nil)
}

func openS3Bucket(ctx context.Context) (*blob.Bucket, error) {
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		return nil, err
	}

	return s3blob.OpenBucket(ctx, sess, "bucket", nil)
}
```

そのまま実行すると S3 に `sample.json` が保存されますが、`-local` オプションを付けて実行するとローカルの `output/sample.json` に保存されます。
この際、オブジェクトのプロパティが `output/sample.json.attrs` として保存されます。これにより保存したオブジェクトのプロパティも問題なく取得できる仕組みになっています。

### テスタビリティが圧倒的に向上する

AWS のような外部サービスの API を呼び出すようなコードではどのようにしてテストしやすい実装にするかということで常に頭を悩ませることになりますが、Go CDK ではその心配はありません。
通常は外部サービスを interface として抽象化して mock を実装し、テストでは mock に差し替える・・・ということになるかと思いますが、Go CDK ではすでに各サービスが適切に抽象化され、そのローカル実装が提供されているのでそのまま使うだけで OK です。

例えば以下のような エンコードした JSON をクラウドストレージにアップロードするための interface を実装する構造体をテストすることを考えてみます。

```go
type JSONUploader interface {
	func Upload(ctx context.Context, key string, v interface{}) error
```

AWS SDK の場合、各種サービスクライアントの interface が提供されているのでそれを使うことでテスタビリティを担保します。
`s3manager` なら [`s3manageriface`](https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3/s3manager/s3manageriface) というパッケージで interface が提供されています。


```go
type jsonUploader struct {
	bucketName string
	uploader   s3manageriface.UploaderAPI
}

func (u *jsonUploader) Upload(ctx context.Context, key string, v interface{}) error {
	pr, pw := io.Pipe()

	go func() {
		err := json.NewEncoder(pw).Encode(v)
		pw.CloseWithError(err)
	}()

	in := &s3manager.UploadInput{
		Bucket: aws.String(u.bucketName),
		Key:    aws.String(key),
		Body:   pr,
	}
	if _, err := u.uploader.UploadWithContext(ctx, in); err != nil {
		return err
	}

	return nil
}
```

このような実装にしておけばテストでは `jsonUploader.uploader` に適当な mock を入れておけば実際に S3 にアクセスせずにテストが可能です。ただしこの mock 実装は公式には提供されていないので、自分で実装するか適当な外部パッケージを見つける必要があります。

Go CDK の場合はそのまま実装するだけでテスタビリティの高い構造体となります。

```go
type jsonUploader struct {
	bucket *blob.Bucket
}

func (u *jsonUploader) Upload(ctx context.Context, key string, v interface{}) error {
	w, err := u.bucket.NewWriter(ctx, key, nil)
	if err != nil {
		return err
	}
	defer w.Close()

	if err := json.NewEncoder(w).Encode(v); err != nil {
		return err
	}

	return nil
}
```

テストでは [`memblob`](https://pkg.go.dev/gocloud.dev@v0.20.0/blob/memblob) というインメモリの `blob` 実装を使うと便利です。

```go
func TestUpload(t *testing.T) {
	bucket := memblob.OpenBucket(nil)
	uploader := &jsonUploader{bucket: bucket}

	ctx := context.Background()

	key := "test.json"

	type data struct {
		Key1 string
		Key2 string
	}

	in := &data{
		Key1: "value1",
		Key2: "value2",
	}

	if err := uploader.Upload(ctx, key, in); err != nil {
		t.Fatal(err)
	}

	r, err := bucket.NewReader(ctx, key, nil)
	if err != nil {
		t.Fatal(err)
	}

	out := &data{}
	if err := json.NewDecoder(r).Decode(out); err != nil {
		t.Fatal(err)
	}

	if !reflect.DeepEqual(in, out) {
		t.Error("unmatch")
	}
}
```

## まとめ

Go CDK 導入によるマルチクラウド対応やクラウドポータビリティ以外のメリットについてご紹介しました。
複数のクラウドベンダーを統一的に扱うという性質上、特定のクラウドベンダー固有の機能は使えないなど弱点は勿論あるので、各クラウドベンダーの SDK とは要件に合わせて使い分けることになるとは思います。

Go CDK 自体もまだまだ発展途上なので今後さらに機能が充実することを期待したいですね。