#はじめに
この記事では、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の一部を抜粋したものです。
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の形式が決まっています。
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"`
}
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に詰めて返却する場合は下記のようになります。
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を別途用意しました。
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を実行できます。
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
で取得できるカラム名を元に対象レコードが何番目に格納されているかを判断して取得することにしました。
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テストは次のように書くことができます。
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で書いておりますが、その部分は割愛しております(モックデータ作るの辛いのでごめんなさい...)。
最後に
結構まとまりがなく書いてしまいましたが、少しでも参考になれば幸いです。