LoginSignup
6

More than 3 years have passed since last update.

posted at

updated at

Organization

Data APIを利用する際のaws-sdk goの実装とunit test時のテクニックについて

はじめに

この記事では、DataAPIを利用してaws-sdk goを使った開発をする際のポイントをいくつかまとめます。
自分はAPI Gateway + AWS Lambda + Aurora Serverless の構成でAPIを作成しましたので、基本的にその構成を想定した記事になります。

data-apiとは

今年の6月ごろから東京リージョンでもAurora Serverless MySQL5.6がData APIのサポートを開始しました。
Data APIを利用することで、Lambda+Aurora Serverlessの構成で完全サーバレスなアプリケーションを構築できるようになりました。

公式の記事によるとData APIを利用することの利点として以下が挙げられます。
・VPCでLambda関数を起動するためのオーバーヘッドなしでデータベースにセキュアにアクセスできる
・Secrets Managerに格納されているデータベース認証情報が活用されるため、API呼び出しでの認証情報が不要
・AWS SDKを用いることで、プログラムインターフェイスによってSQLステートメントが実行可能

環境

$ go version
go version go1.13rc1 darwin/amd64

aws-sam-cliの導入

まずはじめに、aws-sam-cliを使ってテンプレートを用意していきます。
基本的には下記のgithubを見れば導入できると思います。
https://github.com/awslabs/serverless-application-model
公式記事を参考にmacでは場合は下記でインストールできます。

$ brew --version
Homebrew 2.1.10-52-g4822241
Homebrew/homebrew-core (git revision 86eb3; last commit 2019-08-24)
Homebrew/homebrew-cask (git revision 35b0; last commit 2019-08-24)
$ brew tap aws/tap
$ brew install aws-sam-cli
$ sam --version
SAM CLI, version 0.37.0

sam templateの導入

$ sam init --runtime go1.x --name sample-api
Which template source would you like to use?
    1 - AWS Quick Start Templates
    2 - Custom Template Location
Choice: 1

Allow SAM CLI to download AWS-provided quick start templates from Github [Y/n]: Y

-----------------------
Generating application:
-----------------------
Name: sample-api
Runtime: go1.x
Dependency Manager: mod
Application Template: hello-world
Output Directory: .

Next steps can be found in the README file at ./sample-api/README.md

この時点でLambdaのハンドラ関数が用意されたmain.goが作成されます。

$ tree
.
└── sample-api
    ├── Makefile
    ├── README.md
    ├── hello-world
    │   ├── main.go
    │   └── main_test.go
    └── template.yaml

2 directories, 5 files

※GOPATHなどは適宜設定してください

以下はmain.goの一部を抜粋したものです。

main.go
package main

import (
    "errors"
    "fmt"
    "io/ioutil"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

    return events.APIGatewayProxyResponse{
        Body:       fmt.Sprintf("Hello, %v", string(ip)),
        StatusCode: 200,
    }, nil
}

func main() {
    lambda.Start(handler)
}

main関数は、handler関数を引数にlambdaを実行しており、実装はhandler関数に行います。
デプロイ時にはコンパイルするため、ビジネスロジックをhandler関数から別packageに分けるなどしても特に問題ありません。
まずはAPI Gatewayと連携する上でrequest/responseの型を使いこなす必要があります。

request/responseの形式について

API Gateway + AWS LambdaをGoで開発する際の最初のポイントになりますが、requestとresponseの形式が決まっています。

request
APIGatewayProxyRequest struct {
    Resource                        string                        `json:"resource"` // The resource path defined in API Gateway
    Path                            string                        `json:"path"`     // The url path for the caller
    HTTPMethod                      string                        `json:"httpMethod"`
    Headers                         map[string]string             `json:"headers"`
    MultiValueHeaders               map[string][]string           `json:"multiValueHeaders"`
    QueryStringParameters           map[string]string             `json:"queryStringParameters"`
    MultiValueQueryStringParameters map[string][]string           `json:"multiValueQueryStringParameters"`
    PathParameters                  map[string]string             `json:"pathParameters"`
    StageVariables                  map[string]string             `json:"stageVariables"`
    RequestContext                  APIGatewayProxyRequestContext `json:"requestContext"`
    Body                            string                        `json:"body"`
    IsBase64Encoded                 bool                          `json:"isBase64Encoded,omitempty"`
}
response
APIGatewayProxyResponse struct {
    StatusCode        int                 `json:"statusCode"`
    Headers           map[string]string   `json:"headers"`
    MultiValueHeaders map[string][]string `json:"multiValueHeaders"`
    Body              string              `json:"body"`
    IsBase64Encoded   bool                `json:"isBase64Encoded,omitempty"`
}

例えばid=xxxのようなGETパラメータを取得しBodyに詰めて返却する場合は下記のようになります。

example
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    id := request.QueryStringParameters["id"]

    return events.APIGatewayProxyResponse{
        Body:       id,
        StatusCode: 200,
    }, nil
}

Data APIを用いたSQL実行方法について

このmain.goのhandler関数だけで完結することもできますが、DB接続をモック化するためにもdb_connector.goを別途用意しました。

db_connector.go
package db

import (
    "os"

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

func ConnectDb() (*rdsdataservice.RDSDataService, *rdsdataservice.ExecuteStatementInput) {
    region := os.Getenv("REGION")
    dbname := os.Getenv("DB_NAME")
    secretStoreArn := os.Getenv("SECRET_STORE_ARN")
    dbClusterOrInstanceArn := os.Getenv("DB_CLUSTER_ARN")

    sess := session.Must(session.NewSession())
    svc := rdsdataservice.New(sess, aws.NewConfig().WithRegion(region))

    in := &rdsdataservice.ExecuteStatementInput{
        Database:              aws.String(dbname),
        ResourceArn:           aws.String(dbClusterOrInstanceArn),
        SecretArn:             aws.String(secretStoreArn),
        IncludeResultMetadata: aws.Bool(true),
    }
    return svc, in
}

DB接続に必要なのはRDSDataServiceとExecuteStatementInputの2つです。
・RDSDataService:DB Clientになっていて、ExecuteStatementでSQLを実行する
・ExecuteStatementInput:DB名やシークレット情報などData APIの実行オプションを指定する

RDSDataServiceのインスタンスを作成する際に必要なのは、sessionとaws credensialsの情報です。
aws.NewConfig().WithRegion("リージョン名")とすることで、Regionも指定できます。
aws credensialsの情報は、下記のようにpackageを実行する際にprofileを指定すれば勝手に読み込んでくれます。

sam package \
  --template-file=./template/template.yaml  \
  --s3-bucket "$S3_DEPLOY_BUCKET" \
  --output-template-file ./packaged.yaml  \
  --profile=xxx

ExecuteStatementInputにIncludeResultMetadataを指定すると、レスポンスデータにテーブルカラムのメタ情報を含めて返してくれるようになります。

    in := &rdsdataservice.ExecuteStatementInput{
        Database:              aws.String(dbname),
        ResourceArn:           aws.String(dbClusterOrInstanceArn),
        SecretArn:             aws.String(secretStoreArn),
        IncludeResultMetadata: aws.Bool(true),    // このオプションを追加
    }

小ネタですが、sessionを張る関数はNewとMustが用意されているようで、sessionが張れなかった場合にpanicを起こして終了してくれるMustを選択しました。

    sess := session.Must(session.NewSession())

これらを使って下記のようにSQLを実行できます。

example
type Sample struct {
    Svc rdsdataserviceiface.RDSDataServiceAPI
    In  *rdsdataservice.ExecuteStatementInput
}

func (s *Sample) GetSample(id string) (SampleResponse, error) {
    q := "SELECT * FROM sample_table where id = %s;"
    sql := fmt.Sprintf(q, id)
    output, err := s.Svc.ExecuteStatement(s.In.SetSql(sql))

    // (以下略)
}

自分が一番苦労したポイントですが、このoutputの形式が以下のようになっています。

HTTP/1.1 200
Content-type: application/json

{
   "columnMetadata": [ 
      { 
         "arrayBaseColumnType": number,
         "isAutoIncrement": boolean,
         "isCaseSensitive": boolean,
         "isCurrency": boolean,
         "isSigned": boolean,
         "label": "string",
         "name": "string",
         "nullable": number,
         "precision": number,
         "scale": number,
         "schemaName": "string",
         "tableName": "string",
         "type": number,
         "typeName": "string"
      }
   ],
   "generatedFields": [ 
      { 
         "arrayValue": { 
            "arrayValues": [ 
               "ArrayValue"
            ],
            "blobValues": [ blob ],
            "booleanValues": [ boolean ],
            "doubleValues": [ number ],
            "longValues": [ number ],
            "stringValues": [ "string" ]
         },
         "blobValue": blob,
         "booleanValue": boolean,
         "doubleValue": number,
         "isNull": boolean,
         "longValue": number,
         "stringValue": "string",
         "structValue": { 
            "string" : "Field"
         }
      }
   ],
   "numberOfRecordsUpdated": number,
   "records": [ 
      [ 
         { 
            "arrayValue": { 
               "arrayValues": [ 
                  "ArrayValue"
               ],
               "blobValues": [ blob ],
               "booleanValues": [ boolean ],
               "doubleValues": [ number ],
               "longValues": [ number ],
               "stringValues": [ "string" ]
            },
            "blobValue": blob,
            "booleanValue": boolean,
            "doubleValue": number,
            "isNull": boolean,
            "longValue": number,
            "stringValue": "string",
            "structValue": { 
               "string" : "Field"
            }
         }
      ]
   ]
}

例えば次のようなSQLを投げると

SELECT id, name FROM sample_table where id = 1;

レスポンスは以下のようになります。

{
    "sample-data": {
        "ColumnMetadata": [
            {
                "ArrayBaseColumnType": 0,
                "IsAutoIncrement": false,
                "IsCaseSensitive": false,
                "IsCurrency": false,
                "IsSigned": false,
                "Label": "id",
                "Name": "id",
                "Nullable": 0,
                "Precision": 20,
                "Scale": 0,
                "SchemaName": "",
                "TableName": "sample_table",
                "Type": -5,
                "TypeName": "BIGINT UNSIGNED"
            },
            {
                "ArrayBaseColumnType": 0,
                "IsAutoIncrement": false,
                "IsCaseSensitive": false,
                "IsCurrency": false,
                "IsSigned": false,
                "Label": "name",
                "Name": "name",
                "Nullable": 0,
                "Precision": 250,
                "Scale": 0,
                "SchemaName": "",
                "TableName": "sample_table",
                "Type": 12,
                "TypeName": "VARCHAR"
            }
        ],
        "GeneratedFields": null,
        "NumberOfRecordsUpdated": 0,
        "Records": [
            [
                {
                    "BlobValue": null,
                    "BooleanValue": null,
                    "DoubleValue": null,
                    "IsNull": null,
                    "LongValue": 1,
                    "StringValue": null
                },
                {
                    "BlobValue": null,
                    "BooleanValue": null,
                    "DoubleValue": null,
                    "IsNull": null,
                    "LongValue": null,
                    "StringValue": "HOME'S君"
                }
            ]
        ]
    }
}

ここから正しくデータを取得するのは結構大変でした。
もしかしたらもっと良いやり方があるのかもしれませんが、自分はColumnMetadataで取得できるカラム名を元に対象レコードが何番目に格納されているかを判断して取得することにしました。

example
type Sample struct {
    Svc rdsdataserviceiface.RDSDataServiceAPI
    In  *rdsdataservice.ExecuteStatementInput
}

func (s *Sample) GetSample(id string) (SampleResponse, error) {
    q := "SELECT id, name FROM sample_table where id = %s;"
    sql := fmt.Sprintf(q, id)
    output, err := s.Svc.ExecuteStatement(s.In.SetSql(sql))
    if err != nil {
        return SampleResponse{}, err
    }
    ret := map[string]Sample{}
    var si, sn int
    for i, v := range output.ColumnMetadata {
        switch aws.StringValue(v.Label) {
        case "id":
            si = i
        case "name":
            sn = i
        }
    }
    for _, v := range output.Records {
        id := strconv.FormatInt(aws.Int64Value(v[si].LongValue), 10)
        name := aws.StringValue(v[sn].StringValue)
            ret[id] = Sample{
                Id:       StringToInt(id),
                Name:     name,
            }
        }
    }
    return SampleResponse{
        Sample: ret,
    }, nil
}

型の変換も結構頑張らないといけなかったので、どなたかこの辺の知見がある方にアドバイス頂きたいです。

テスト時のモック化について

Data APIを使ってDB接続をする処理をdb_connector.goに分けましたが、aws-sdk goでは簡単にモック化できる仕組みが用意されています。

前述のrdsdataserviceであれば下記のinterfaceを指定して、mockの作成を行うことができます。
https://github.com/aws/aws-sdk-go/blob/master/service/rdsdataservice/rdsdataserviceiface/interface.go
自分はmakefileに下記のコマンドを用意しました。
叩くのは一度だけなので、この記事のように、関連ファイルにスクリプトをコメントしてgo generateで良いのかもしれません。

gen-mock:
    ${GOPATH}/bin/mockgen -source ${GOPATH}/pkg/mod/github.com/aws/aws-sdk-go\@v1.25.19/service/rdsdataservice/rdsdataserviceiface/interface.go -destination src/mock/auroraserverless/rdsdataservice.go -package mock -self_package ./

destinationオプションを指定することで任意の場所にモックファイルを生成できます。
また、packageオプションでモックファイルのパッケージ名を指定できます。

gomockを使うことでunitテストは次のように書くことができます。

example_test
package auroraserverless_test

import (
    "fmt"
    mock "sample-api/src/mock/auroraserverless"
    "reflect"
    "testing"

    "github.com/golang/mock/gomock"
)

func TestGetSample(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    dbMock := mock.NewMockRDSDataServiceAPI(ctrl)
    for _, val := range NormalCases {
        dbMock.EXPECT().ExecuteStatement(mock.In.SetSql(val.in.sql)).Return(val.in.mockData.Output, val.in.mockData.Err)
        s := &Sample{
            Svc: dbMock,
            In:  mock.In,
        }
        got, err := s.GetSample(val.in.Id)
        t.Run("Sampleの取得時にerrorがnilであること", func(t *testing.T) {
            if err != nil {
                t.Errorf("error: %v", err)
            }
        })
        t.Run(val.testCase, func(t *testing.T) {
            for i, v := range val.want.result.Sample {
                t.Run("IDが正しいこと", func(t *testing.T) {
                    if !reflect.DeepEqual(v.Id, got.Sample[i].Id) {
                        t.Errorf("GetSample(%v): Sample[%v].Id does not match \n want:%v \n got:%v", val.in.Id, i, v.Id, got.Sample[i].Id)
                    }
                })
                t.Run("名前が正しいこと", func(t *testing.T) {
                    if !reflect.DeepEqual(v.Name, got.Sample[i].Name) {
                        t.Errorf("GetSample(%v): Sample[%v].Name does not match \n want:%v \n got:%v", val.in.Id, i, v.Name, got.Sample[i].Name)
                    }
                })
            }
        })
    }
}

自分はSQL実行をこのように書いたので、
output, err := s.Svc.ExecuteStatement(s.In.SetSql(sql))
モック化はこうなりました。
dbMock.EXPECT().ExecuteStatement(mock.In.SetSql(val.in.sql)).Return(val.in.mockData.Output, val.in.mockData.Err)

また、実際にはテストケースはTableDrivenTestsで書いておりますが、その部分は割愛しております(モックデータ作るの辛いのでごめんなさい...)。

最後に

結構まとまりがなく書いてしまいましたが、少しでも参考になれば幸いです。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
6