Golangはじめて物語(APIGateway+Lambdaといっしょ編)


はじめに

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の親和性が悪くて色々残念な感じではあるが、致し方なし……

main.tf
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のメンバに情報を詰めていくことになる。

putUser/main.go
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側を作る。

getUser/main.go
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の利用方法を確認してもらえば良いと思う。

common/modules/dynamodb/dynamodb.go
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を実行するとテストモジュールが起動される。

テストモジュールは以下のような全体像。

getUser/main_test.go
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とかテキトーすぎるので、そのまま使わないように。

getUser/template.yml
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

putUser/template.yml
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の代わりだと思えば良い。プリミティブに良く出来ているものは、いつになっても使えるものなのだ。

getUser/Makefile
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"}