Go CDK とは
Go CDK とは The Go Cloud Development Kit の略で、主要クラウドベンダーが提供しているほぼ同一の機能を持ったサービスを統一的な API で扱うためのプロジェクトです(旧称 Go Cloud)。
例えばクラウドストレージサービスにオブジェクトを保存・取得する処理は Go CDK を使うことで次のように書くことができます1。
- Amazon S3
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
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
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()
に与えている URL の scheme のみです。素晴らしいですね!
このように Go CDK を使うことでマルチクラウドなアプリケーションやクラウドポータビリティの高いアプリケーションを容易に実装することができます。
Go CDK についてより詳しく知りたい方は公式の情報をご参照ください。
大変便利な Go CDK ですが2020年10月現在のプロジェクトステータスは「API は alpha だけど production-ready」2 という感じらしいです。導入される際は自己責任でお願いします。
マルチクラウド・ポータビリティ以外にもある Go CDK のメリット
本題です。
「AWS しか使わない!ベンダーロックイン上等!」といった考えの人もいると思います。
本稿ではそういった方でも Go CDK を使うメリットは十分あるということを、「S3 のオブジェクト操作(保存・取得)」を例にご紹介したいと思います。
API が分かりやすい・扱いやすい
Go CDK の API は直観的に理解しやすく扱いやすい設計です。
Go CDK でのクラウドストレージへのオブジェクトの読み書きは blob.Bucket
の
NewReader()
及び NewWriter()
によって得られる blob.Reader
(io.Reader
を実装) と blob.Writer
(io.Writer
を実装) を使います。
オブジェクトの取得(読み込み)を blob.Reader
(io.Reader
)、保存(書き込み)を io.Writer
(io.Writer
) で行えるというのは非常に直観的です。これにより、ローカルファイルを操作するかのような感覚でクラウド上のオブジェクトを扱うことができます。
AWS SDK を使う場合と比べてどうわかりやすくなるかを、具体例を挙げつつ見ていきます。
S3 にオブジェクトを保存する場合
AWS SDK の場合、s3manager.Uploader
の Upload()
を使うことになります。
アップロードするオブジェクトの内容は io.Reader
としてメソッドに渡します。ローカルにあるファイルをアップロードする場合なら os.File
をそのまま渡せて便利なのですが、厄介なのはメモリ上にあるデータを何らかの形式でエンコードしてそのまま保存したい場合です。
例えば JSON エンコードしてそのまま S3 という処理は、AWS SDK では次のようになります。
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()
を使う必要があります。
Go CDK であれば、書き込みは blob.Writer
(io.Writer
) で行うのでそのまま json.NewEncoder()
に渡すだけです。
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
を使うだけです。
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()
を使います。
s3manager.Uploader
と対になる s3manager.Downloader
は出力先が io.WriterAt
を実装している必要があるため、このケースでは使えないことに注意が必要です。
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 の場合はアップロードの時と逆になるように書くだけです。
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
を使うことができます。
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 です。
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()
を使って自前で実装する必要があります。
Go CDK での並列ダウンロード実装例 (長いので折りたたみます)
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
の実装を参考にしています
ローカル実行が容易になる
Go CDK は全てのサービスに対しローカル実装を提供するように開発が進められています。そのため、クラウドサービスの操作を簡単にローカル実装に差し替えることができます。
例えば開発用のローカルサーバなどでは全てのサービスをローカル実装に差し替えておくと AWS や GCP へのアクセスを発生させずに動作させることができるので便利です。
クラウドストレージを扱う gocloud.dev/blob
パッケージの場合、fileblob
というローカルファイルの読み書きを行う実装が提供されています。
以下はエンコードした JSON の出力先をオプションに応じて S3 とローカルとに切り替える例です。
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 を実装する構造体をテストすることを考えてみます。
type JSONUploader interface {
func Upload(ctx context.Context, key string, v interface{}) error
AWS SDK の場合、各種サービスクライアントの interface が提供されているのでそれを使うことでテスタビリティを担保します。
s3manager
なら s3manageriface
というパッケージで interface が提供されています。
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 の場合はそのまま実装するだけでテスタビリティの高い構造体となります。
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
というインメモリの blob
実装を使うと便利です。
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 自体もまだまだ発展途上なので今後さらに機能が充実することを期待したいですね。