はじめに
Lambda関数を色々触っていると、Javaの限界を感じることが多い(別にJavaをdisるわけではなく、Lambdaとの親和性と言う意味ではイマイチだと主張したい)。
手軽さで言えばPythonは間違いなく最強の一角だと言えるが、importが増えると結局処理が重くなるという話があり、Golangを勧められる機会が増えてきたので、ここらで一丁、覚えてみようと思った。
統合開発環境は何が良いか?
色々と試してみたわけではないが、Eclipseは重いし、Golang拡張はJDKのバージョン縛りがあって面倒だったので、VSCode+Remote Development Extension Pack+EC2にしてみたら非常に快適だった。ローカル環境がWindowsで動かせるモノの制約が面倒だというのもあるので、この構成はオススメ。
Go言語だけ触るなら別に何の環境でも良いのだけど、SAMなりServerless Frameworkなりcurlなりを並行で触ることを考えると、EC2を直接触れるというのは生産性に大きく貢献してくれる。
Remote Development Extention Packの導入については、以下の記事が分かりやすかった。
【Qiita】Visual Studio Code Remote Developmentのメモ
VSCodeの日本語対応については以下。
【Qiita】Visual Studio Codeで日本語化する方法[Windows]
どちらもすごい簡単な上にサクッと導入できるのが良い感じだった。VSCodeのインストールからで1時間くらいで済む。
Go言語ランタイムのインストール
デフォルトのEC2にはGo言語のランタイムが入っていないのでインストールする。
$ sudo yum install golang
でOK。めちゃくちゃ楽ちん。export GOPATH=適当なパス
をしておくのを忘れないように。
もろもろのモジュール等が散らかってしまう。
バージョン1.13以降はGo Modulesが標準搭載されてビルドも楽にできるようになっているぞ!
全体構成
以下のようになる。アプリケーションの仕様は以下の通り。
- id, name を属性に持ったDynamoDBにアクセスするLambda関数を準備する
- DynamoDBはTerraformで準備する
- Lambda関数にはAPI Gateway経由でアクセスする
- Lambda関数、API GatewayのデプロイはSAMを使う(面倒なので、2つのLambdaを1つのAPI Gatewayに統合するのは割愛する)
- DynamoDBへのアクセスは、putUserで書き込みを行い、getUserで参照を行う
- DynamoDBへのアクセスはdynamodbモジュールを介し、putUser, getUser は dynamodb モジュールを呼び出す
.
├── common
│ ├── modules
│ │ └── dynamodb
│ │ ├── dynamodb.go
│ │ └── go.mod
│ └── terraform
│ └── main.tf
├── getUser
│ ├── go.mod
│ ├── main.go
│ ├── main_test.go
│ ├── Makefile
│ └── template.yml
└── putUser
├── go.mod
├── main.go
├── Makefile
└── template.yml
参考にしたのはGoとSAMで学ぶAWS Lambdaだが、2018年の書籍で2年間の間にGo言語のバージョンが上がってモジュール管理のデファクトがdepからGo Modulesになったりしているので、その辺は吸収する。
事前準備
以下のようにTerraformを書いて、上記仕様の通りのDynamoDBを用意する。
本来はちゃんとリソース分割とかをするが、今回はここが本筋ではないのでテキトーなのはご容赦いただきたい。S3バケットはSAMテンプレートの置き場所なので、これも今回の本筋ではない。
※SAMとTerraformの親和性が悪くて色々残念な感じではあるが、致し方なし……
resource "aws_s3_bucket" "cfn_stack_get" {
bucket = "goapigwtest-cfn-stack-get"
acl = "private"
}
resource "aws_s3_bucket" "cfn_stack_put" {
bucket = "goapigwtest-cfn-stack-put"
acl = "private"
}
resource "aws_dynamodb_table" "users" {
name = "users-table"
billing_mode = "PROVISIONED"
read_capacity = 1
write_capacity = 1
hash_key = "id"
attribute {
name = "id"
type = "S"
}
}
Go言語のAPI Gatewayの実装
ご存じの通り、API Gatewayのプロキシ統合におけるリクエストの内容にはクセがあるので、公式のドキュメントを確認しながら作ろう。
※自力で統合リクエストをパースするのは死ねるからやめよう。
typeされたAPIGatewayProxyRequesのメンバにアクセスし、APIGatewayProxyResponseのメンバに情報を詰めていくことになる。
package main
import (
"context"
"log"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-lambda-go/lambdacontext"
"local.packages/dynamodb"
)
var ()
const ()
func init() {
}
func main() {
lambda.Start(handler)
}
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
var (
statusCode int
id string
idIsNotNull bool
name string
nameIsNotNull bool
)
if lc, ok := lambdacontext.FromContext(ctx); ok {
log.Printf("AwsRequestID: %s", lc.AwsRequestID)
}
statusCode = 200
if len(request.QueryStringParameters) == 0 {
log.Println("QueryStringParameters is not specified")
statusCode = 400
} else {
if id, idIsNotNull = request.QueryStringParameters["id"]; !idIsNotNull {
log.Println("[QueryStringParameters]id is not specified")
statusCode = 400
}
if name, nameIsNotNull = request.QueryStringParameters["name"]; !nameIsNotNull {
log.Println("[QueryStringParameters]name is not specified")
statusCode = 400
}
}
if statusCode == 200 {
err := dynamodb.PutUser(id, name)
if err != nil {
statusCode = 500
}
}
response := events.APIGatewayProxyResponse{
StatusCode: statusCode,
IsBase64Encoded: false,
}
return response, nil
}
最初の方に書いた以下の部分は定数とグローバル変数の定義。
書かなくても良いが、明示的に無いことを示すために書いてみた。こういうのも、モダンプログラミングでは無駄なものとして極力書かないようにするものなのだろうか。
var ()
const ()
QueryStringParameters には普通に構造体メンバのようにアクセスできる。
map型なので、キー名から値を取得することも可能だし、map型はlenで要素数を取れる。
C言語っぽくありながら、JavaやPythonのいいとこ取りをしてる感があって好感。
if len(request.QueryStringParameters) == 0 {
log.Println("QueryStringParameters is not specified")
statusCode = 400
} else {
if id, idIsNotNull = request.QueryStringParameters["id"]; !idIsNotNull {
なお、C言語で言うforの初期化ステートメントのようなことを、if文でもできるようになっている。
最初のステートメントでrequest.QueryStringParameters["id"]
の値と中身の有無を取り、その直後に判定するといった感じだ。
応答は、以下のように APIGatewayProxyResponse の値を詰めて返してあげれば良い。
response := events.APIGatewayProxyResponse{
StatusCode: statusCode,
IsBase64Encoded: false,
}
return response, nil
ポイントは、return で2つの値を返していること。
この場合、関数宣言は
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)
といった感じで、2つの型を応答に書けばよい。ちなみに、Go言語では関数宣言は
func 関数名(引数名1 型, 引数名2 型……) アウトプットの型
が基本形で、アウトプットが複数ある場合は (アウトプットの型1, アウトプットの型2……)
となる。
アウトプットのための構造体を引数で渡す必要がなくなるので、モジュール結合度を低くシンプルに保つことができる。素晴らしい。
同じ要領で、getUser側を作る。
package main
import (
"context"
"encoding/json"
"log"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-lambda-go/lambdacontext"
"local.packages/dynamodb"
)
var ()
const ()
func init() {
}
func main() {
lambda.Start(handler)
}
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
var (
statusCode int
id string
idIsNotNull bool
record dynamodb.Item
returnbody string
err error
)
if lc, ok := lambdacontext.FromContext(ctx); ok {
log.Printf("AwsRequestID: %s", lc.AwsRequestID)
}
statusCode = 200
if len(request.QueryStringParameters) == 0 {
log.Println("QueryStringParameters is not specified.")
statusCode = 400
} else {
if id, idIsNotNull = request.QueryStringParameters["id"]; !idIsNotNull {
log.Println("[QueryStringParameters]id is not specified")
statusCode = 400
}
}
if statusCode == 200 {
record, err = dynamodb.GetUser(id)
if err != nil {
if err.Error() == "Not Found" {
statusCode = 404
} else {
statusCode = 500
}
} else {
jsonBytes, _ := json.Marshal(record)
returnbody = string(jsonBytes)
}
}
response := events.APIGatewayProxyResponse{
StatusCode: statusCode,
IsBase64Encoded: false,
Body: returnbody,
Headers: map[string]string{
"Content-Type": "application/json",
},
}
return response, nil
}
putUser側と比べて大きな差はないが、APIGatewayProxyResponse に Body と Headers を入れているので参考にしていただきたい。Bodyには文字列を設定しなければいけないので、直前で加工している部分がポイントか。
} else {
jsonBytes, _ := json.Marshal(record)
returnbody = string(jsonBytes)
}
}
response := events.APIGatewayProxyResponse{
StatusCode: statusCode,
IsBase64Encoded: false,
Body: returnbody,
Headers: map[string]string{
"Content-Type": "application/json",
},
}
DynamoDB接続
接続まわりについては、ある意味定型なので、SDKの利用方法を確認してもらえば良いと思う。
package dynamodb
import (
"errors"
"log"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)
type Item struct {
Id string `dynamodbav:"id"`
Name string `dynamodbav:"name"`
}
func PutUser(id string, name string) error {
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
svc := dynamodb.New(sess)
item := Item{
Id: id,
Name: name,
}
av, err := dynamodbattribute.MarshalMap(item)
if err != nil {
log.Println("Got error marshalling new item:")
log.Println(err.Error())
return err
}
input := &dynamodb.PutItemInput{
Item: av,
TableName: aws.String("users-table"),
}
_, err = svc.PutItem(input)
if err != nil {
log.Println("Got error calling PutItem:")
log.Println(err.Error())
return err
}
return nil
}
func GetUser(id string) (Item, error) {
var (
item Item
)
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
svc := dynamodb.New(sess)
log.Printf("item: %s", id)
result, err := svc.GetItem(&dynamodb.GetItemInput{
TableName: aws.String("users-table"),
Key: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(id),
},
},
})
if err != nil {
log.Println("Got error calling GetItem:")
log.Println(err.Error())
return item, errors.New("Library Error")
}
err = dynamodbattribute.UnmarshalMap(result.Item, &item)
if err != nil {
log.Println("Failed to unmarshal Record", err)
log.Println(err.Error())
return item, errors.New("Library Error")
}
if item.Id == "" {
log.Printf("Could not find id: %s", id)
return item, errors.New("Not Found")
}
return item, nil
}
GetUserの途中で、
err = dynamodbattribute.UnmarshalMap(result.Item, &item)
としている部分については、DynamoDBから取得した型を普通のJSON型に変換している。
ハマりどころとしては、構造体のメンバ名の先頭が大文字でなければいけないのに対して、テーブル定義上ではカラム名の先頭が小文字になってしまっている場合、以下のようにマッピングしてあげないとエラーになってしまう点。
type Item struct {
Id string `dynamodbav:"id"`
Name string `dynamodbav:"name"`
}
パッケージ化
さて、上記のDynamoDBアクセス部品はローカルパッケージ化してアクセスしているようにしている。
体系的に知るには以下をまずは読んだ方が良い。
【Qiita】Go Modules でインターネット上のレポジトリにはないローカルパッケージを import する方法
その上で、今回はgetUser, putUserそれぞれのgo.mod内で
replace local.packages/dynamodb => ../common/modules/dynamodb
と定義し、main.go内で
import (
"local.packages/dynamodb"
)
としてアクセスしている。
ハマりどころとして、言語仕様上、funcで定義する関数名の先頭文字が大文字の場合しかパッケージ外からの参照ができないということ。これを知らずに小文字にしていてずっと「Not Found」になって悩んでいたよ…(言語仕様はちゃんと確認しておきましょう)
テストプログラム
goについても、JUnit+Maven/Gradleでmvn test
するような感じで、go test
を実行するとテストモジュールが起動される。
テストモジュールは以下のような全体像。
package main
import (
"context"
"os"
"testing"
"github.com/pkg/errors"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambdacontext"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)
var (
items = []struct {
Id string `dynamodbav:"id"`
Name string `dynamodbav:"name"`
}{
{"test11111", "Tanaka Ichiro"},
{"test22222", "Sato Jiro"},
}
)
const ()
func setup() error {
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
svc := dynamodb.New(sess)
for _, item := range items {
av, err := dynamodbattribute.MarshalMap(item)
if err != nil {
return errors.Wrap(err, "Got error marshalling new item")
}
_, err = svc.PutItem(&dynamodb.PutItemInput{
Item: av,
TableName: aws.String("users-table"),
})
if err != nil {
return errors.Wrap(err, "Got error calling PutItem")
}
}
return nil
}
func teardown() error {
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
svc := dynamodb.New(sess)
for _, item := range items {
_, err := svc.DeleteItem(&dynamodb.DeleteItemInput{
TableName: aws.String("users-table"),
Key: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(item.Id),
},
},
})
if err != nil {
return errors.Wrap(err, "Got error calling DeleteItem")
}
}
return nil
}
func TestHandler(t *testing.T) {
tests := []struct {
queryStringParameters map[string]string
expected int
}{
{queryStringParameters: map[string]string{"id": "test11111"}, expected: 200},
{queryStringParameters: map[string]string{"id": "test22222"}, expected: 200},
{queryStringParameters: map[string]string{"id": "test33333"}, expected: 404},
}
lc := &lambdacontext.LambdaContext{
AwsRequestID: "test request",
}
ctx := lambdacontext.NewContext(context.Background(), lc)
for _, te := range tests {
res, _ := handler(ctx, events.APIGatewayProxyRequest{
QueryStringParameters: te.queryStringParameters,
})
if res.StatusCode != te.expected {
t.Errorf("StatusCode=%d, Expected %d", res.StatusCode, te.expected)
}
}
}
func TestMain(m *testing.M) {
setup()
ret := m.Run()
teardown()
os.Exit(ret)
}
基本は以下にメイン処理を書くが、実際の中身はハンドラ側で対応する。
func TestMain(m *testing.M) {
setup()
ret := m.Run()
teardown()
os.Exit(ret)
}
ポイントは、ハンドラを挟んでコールしているsetup()
とteardown()
で、要は準備と後始末である。今回は、setup()でDynamoDBにレコードを敷き込み、teardown()で削除している。
TestHandler()では、実装しているgetUserのメイン処理に従い、queryStringParametersの値を変えたりしつつでループして試験している。
これをgo test ./...
でテストモジュールが起動してくる。
デプロイ用のSAMテンプレート
以下のような感じで準備する。
これも、今回の本筋ではないので詳細は省く。ここは「とりあえず動けばいい」で作ったのでManagedPolicyArns
とかテキトーすぎるので、そのまま使わないように。
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: APIGateway test for Golang
Resources:
# ------------------------------------------------------------#
# IAM Role
# ------------------------------------------------------------#
LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: lambdaexecutionrole-get
Description: Lambda Execution Role
Path: /serivice-role/
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole
- arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
# ------------------------------------------------------------#
# Lambda Function
# ------------------------------------------------------------#
GoApigwTest:
Type: AWS::Serverless::Function
Properties:
CodeUri: artifact
Handler: goapigwtest-get
Runtime: go1.x
Role: !GetAtt LambdaExecutionRole.Arn
Timeout: 180
Events:
ApiEvent:
Type: Api
Properties:
Path: testapi
Method: get
LambdaPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref GoApigwTest
Principal: apigateway.amazonaws.com
# ------------------------------------------------------------#
# Cloud Watch Logs
# ------------------------------------------------------------#
GoApigwTestLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/lambda/${GoApigwTest}
RetentionInDays: 1
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: APIGateway test for Golang
Resources:
# ------------------------------------------------------------#
# IAM Role
# ------------------------------------------------------------#
LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: lambdaexecutionrole-put
Description: Lambda Execution Role
Path: /serivice-role/
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole
- arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
# ------------------------------------------------------------#
# Lambda Function
# ------------------------------------------------------------#
GoApigwTest:
Type: AWS::Serverless::Function
Properties:
CodeUri: artifact
Handler: goapigwtest
Runtime: go1.x
Role: !GetAtt LambdaExecutionRole.Arn
Timeout: 180
Events:
ApiEvent:
Type: Api
Properties:
Path: testapi
Method: post
LambdaPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref GoApigwTest
Principal: apigateway.amazonaws.com
# ------------------------------------------------------------#
# Cloud Watch Logs
# ------------------------------------------------------------#
GoApigwTestLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/lambda/${GoApigwTest}
RetentionInDays: 1
今更Makefileかよ!だけど……
サブタイトルの通りの内容ではあるのだけど、使ってみると意外と楽に扱える。
まあ、MavenやらGradleの代わりだと思えば良い。プリミティブに良く出来ているものは、いつになっても使えるものなのだ。
STACK_NAME := GoApigwTest-Get
STACK_BUCKET := goapigwtest-cfn-stack-get
TEMPLATE_FILE := template.yml
SAM_FILE := sam.yml
build:
GOARCH=amd64 GOOS=linux go build -o artifact/goapigwtest-get
.PHONY: build
deploy: build
sam package \
--template-file $(TEMPLATE_FILE) \
--s3-bucket $(STACK_BUCKET) \
--output-template-file $(SAM_FILE)
sam deploy \
--template-file $(SAM_FILE) \
--stack-name $(STACK_NAME) \
--capabilities CAPABILITY_NAMED_IAM
.PHONY: deploy
delete: clean
aws cloudformation delete-stack --stack-name $(STACK_NAME)
aws s3 rm "s3://$(STACK_BUCKET)" --recursive
.PHONY: delete
test:
go test ./...
.PHONY: test
clean:
rm -rf artifact
rm -f sam.yml
.PHONY: clean
これで、make deploy
したら、ばっちりビルドしてAPI Gateway+Lambdaのデプロイまでやってくれる。
動かしてみる
やったー動いたー!
$ curl -i -X POST https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/testapi?id=11111\&name=Taro
HTTP/2 200
content-type: application/json
content-length: 0
date: Sun, 05 Jul 2020 06:00:27 GMT
~ (以下略) ~
curl -i -X GET https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/testapi?id=11111
HTTP/2 200
content-type: application/json
content-length: 28
date: Sun, 05 Jul 2020 06:06:53 GMT
~ (中略) ~
{"Id":"11111","Name":"Taro"}