Help us understand the problem. What is going on with this article?

AWSを使ってサーバーレスにRestAPIを作ってみた。

DeNA20新卒アドベントカレンダー23日目です。

サーバーレス

サーバーレスアーキテクチャーがここ最近だいぶ浸透してきたと思います。
サーバーレスと言っても実際にはサーバーはクラウド上にあり、その上でプログラムやコンテナを走らせるのですが、
面倒な(そしてある意味楽しい)サーバーの保守から解放されて、よりアプリケーションそのものに集中できるようになったのは開発者として嬉しいポイントです。
また、他のマネージドサービスとの連携も容易で低いコストでウェブアプリケーションが作れるようになりました。
今回はAWSを使ってサーバーレスに掲示板Rest APIを作ってみました。ちなみにRestではなくGraphQLを使うのも考えましたが、学習が間に合わずに断念…
ちなみに元々掲示板のサーバーサイドだけを作って、各種APIを公開して各々のユーザーがクライアントを作ると言うコンセプトの下にAPIを作っていました。やっていくうちに色々と知見がたまったので公開すると言うのがこの記事の主旨になります。

作成するAPIの構成は以下のようになります。
Untitled Diagram.png
本当はS3を使って画像のアップロードもできるようにしたかったけど間に合いませんでした。

AWS Lambda

AWS Lambdaはクラウド上でプログラムを走らせるためのサービスです。
リクエストごとに料金がかかる仕組みですが、無料分が非常に大きく並大抵のリクエスト程度ならタダで使えます。
ちなみに少し前に流行った最弱AIオセロはAWS Lambdaで動いていました。(C++のプログラムをスタティックビルドして盤面情報をコマンドライン引数としてPythonのsubprocessで動かすと言う力技)

今回はバックエンドのロジックを全てLambdaを使用して実装しました。
AWS Lambdaでは現在Node.js, Python, Ruby, Java, Go, C#が使えるようですが、今回は自身の練習を兼ねてGoで実装しました。

Dynamo DB

データベースにはDynamoDBを使用しています。普段MySQLばかりでNoSQLのデータベースを使ってみたいと言う理由で使いましたが、スケールが容易だったり料金が安いなどのメリットが多い一方、なかなかに癖が強く慣れるのに時間がかかりました。DynamoDBではパーティションキー、もしくはパーティションキーとレンジキーのセットがプライマリキー(=ユニーク)となるのですが、複数のデータを持ってくるような場合はただ一つのパーティションキーとレンジキーによる範囲指定が必要になります。この時、レンジキーによる範囲指定を行うと出力されるデータは自動的に降順もしくは昇順でソートされます。つまり複数のデータを取得する際は同じパーティションキーを持つデータに対して、レンジキーで範囲を指定するが、パーティションキーとレンジキーはユニークになっている必要があることになります。これらの制約のせいでテーブル設計がなかなか難しく、作り直しを何回か行う羽目になりました。

今回の掲示板APIによるデータベースの操作をまとめると以下のようになります。

  • スレッド一覧の取得(スレッドの一覧は更新された時間順に100件までとする。)
  • スレッドの投稿
  • あるスレッドのレスポンスの取得(レスポンスの一覧は投稿順とする。)
  • レスポンスの投稿(スレッドの最終更新時間を更新する。)

従ってスレッドは全て同じパーティションキー、更新時間をレンジキーとしたいのですが、パーティションキーとレンジキーのセットのユニーク性を考えると、パーティションキーが同じであればレンジキーによってのみユニーク性が担保される必要性が出てきます。しかし更新時間がユニークなのはあまり綺麗な実装とは言えない気がするので別のテーブルを考えます。ちなみにスキャンをすることで全データの摘出ができ、それらについてフィルターをかける方法もありますが、テーブルの要素が多くなることはあっても減ることはないのでできる限り避けたいところです。データ転送量で課金なことを考えるとコスト的にもなしです。

DynamoDBでは基本的にパーティションキーとレンジキーの組み合わせでデータを取得しますが、ローカルセカンダリインデックス(LSI)やグローバルセカンダリインデックス(GSI)を追加することで、同じテーブルについて異なるキーで検索をかけることができます。LSIでは同じパーティションキーについてレンジキー相当のキーの追加、GSIではパーティションキー相当のキーとレンジキー相当のキーの追加が可能ですが、内部実装的にはテーブルを複製しているらしいのでDynamoDBのコストとの相談が必要です。ここでキー相当と表現したのは、LSIについては追加したレンジキー相当のキーと既存のパーティションキーとの組み合わせがユニークでなくても良く、GSIについては追加したキー二つがユニークでなくても良いためです。ちなみにテーブルへのLSIの追加はテーブル作成時にしかできないので注意してください。

今回の要件を考えると更新時間をLSIに追加すれば更新時間によるスレッド一覧の取得が可能になります。するとレンジキーは実質スレッドをユニークにするためだけに存在するので、邪道な気もしますがこれをRDBにおけるプライマリキーと見なすことにしました。ただし、DynamoDBではRDSにおけるAuto Incrementのようなオプションは存在しない(できないことはないが別途インデックスを管理するテーブルが必要になる。)のでUUIDを使用し、このUUIDをプライマリキーとしてスレッドに対するレスポンステーブルを作成しました。
スクリーンショット 2019-12-18 22.22.09.png
threadsテーブル
partをパーティションキー、idをレンジキー、updated_atをLSIとしています。
パーティションキーであるpartが全てのスレッドについて0で、毎回のリクエストでパーティションキーに0を入れるのは設計としてどうなんだと言う気もしますが
実際にDynamoDBのドキュメントにディスカッションフォーラムにおけるテーブル設計の例があります。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/SampleData.CreateTables.html
このページのthreadテーブルを見てみるとプライマリキーがフォーラム名となっています。プライマリキーをフォーラム名とかカテゴリ名として掲示板を分けていくとすれば、今回の例ではフォーラムが一つしかない場合と考えられるので、そこまで変な実装でもないのかなと言う気がします。規模が大きくなればパーティションキーを1, 2...と増やしていきパーティションキーごとに掲示板を分けていけば、負荷的にも問題ないと思われます。

スクリーンショット 2019-12-18 22.22.22.png
responsesテーブル
スクリーンショット 2019-12-23 4.35.03.png

responsesカラムにはDynamoDB用に変換されたjsonオブジェクトが入っています。
クエリでデータを取り出す際に取り出すカラムを指定できるので、今回の場合はスレッドのデータにそのままレスポンスのデータをくっつけるのもアリだと思いますが、上記のAWSのサンプルに則って分けました。

Goの実装

LambdaでGoを使いdynamodbにアクセスするにはSDKが必要です。
必要なものだけをインストールしてもいいのですが、面倒なので下記コマンドでまとめてAWS用のGoSDKをインストールしました。
go get github.com/aws/aws-sdk-go/...

LambdaでGoを使うにあたって必要なパッケージはaws-lambda-go/eventsとaws-lambda-go/lambdaになります。
前者がLambdaへの各種データの入出力を扱うパッケージ、後者がlambdaを実行するためのパッケージになっているようです。
GoでLambdaを動かす解説サイトがあまりないので割とドキュメントと睨めっこが多くなるかもしれません。

https://godoc.org/github.com/aws/aws-lambda-go/lambda
https://godoc.org/github.com/aws/aws-lambda-go/events

Goのmain関数でハンドラー関数をlambdaで実行すると言う命令を出します。
下のような定型文を書けばあとはLambdaがよしなにしてくれるので、ハンドラー関数の方で受け取ったデータを処理して返すと言うのが一連の流れです。

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

データの受け取り

データはAPI Gatewayから渡されますが、この時データがどう渡されるかによって処理の書き方が異なります。

getでデータを渡す場合、API Gatewayを通す時にURL文字列を処理する必要があります。
API Gatewayからは以下のような構造体のデータがハンドラ関数に送られます。

type 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"`
}

この時何かしらのデータが、パスパラメータもしくはクエリ文字列として渡されていた場合
前者であればPathParameters
後者であればQueryStringParameters
にそれぞれmapで入っています。
クエリ文字列であれば単純ですが、パスパラメータの場合はAPI Gatewayの方でマッピングを行う必要です。
今回はどちらでも受け取れるようにしました。

postでデータを渡す場合、jsonで渡していればそのままハンドラー関数の引数にjsonが入ってくるので、適当な構造体を用意しておけばそのままデータが使えます。
例えば以下のような構造体で表せるjsonデータを渡す場合

type Request struct {
    ThreadID string `json:"id"`
    Name     string `json:"name"`
    Content  string `json:"content"`
}

ハンドラー関数では以下のように中身が参照できます。

func handler(request Request) {
    name := request.Name
    threadID := request.ThreadID
    content := request.Content
}

jsonでデータを受け取る際は、API Gatewayがそのままデータを渡してくれるので簡潔に受け取ることができます。
events.APIGatewayProxyRequest型で受け取ってもBodyにデータが入っていると思われますが、string型で入ってくるので別途構造体に変換する必要があります。
ちなみにlambda上でプログラムの実行テストができますが、eventsを使ったパラメータの受け取りはAPI Gatewayを介していないためエラーが発生します。
jsonによるデータの受け渡しテストを行なってからAPI Gateway用に書き換える手間が発生するので、getでもjsonでデータを渡すようにする方が楽かもしれません。

データの返却

API Gatewayを通してデータを送るにはevents.APIGatewayProxyResponse型のデータを返す必要があります。

type 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"`
}

この中でStatusCodeとHeadersはAPI Gatewayを通してデータを返却するためには必須です。
StatusCodeはhttpパッケージのものを利用すれば大丈夫です。
HeadersはCORSを行うために'Access-Control-Allow-Originを適切に設定する必要があります。

以下が実際に実装したコードです。
githubはこちら
GoでdynamoDBを操作していますが、これについて詳しく解説すると記事が丸々一本書けそうなので割愛します。
一応はAWSのドキュメントを読めばできますが、ハマるポイントも多く結構苦戦しました。質問があればコメント欄に書いてもらえればできる限り調査します。

スレッド一覧の取得

get_threads.go
package main

import (
    "encoding/json"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "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 Thread struct {
    ID        string `json:"id"`
    Title     string `json:"title"`
    CreatedAt int64  `json:"created_at"`
    UpdatedAt int64  `json:"updated_at"`
}

type Threads struct {
    Threads []Thread
}

// レス用の構造体
type Response struct {
    Threads []Thread `json:"body"`
}

// 何かしらエラーが発生した場合internal server errorを返す。
func internalServerError() (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

// ハンドラー関数、スレッド一覧は何も受け取らない。データベースから取得したスレッド一覧のみ返却する。
func Handler() (interface{}, error) {

    // awsとのsession作成、失敗すればinternal server error
    sess, err := session.NewSession()
    if err != nil {
        return internalServerError()
    }

    // セッションを使ってdynamoDBを利用する
    svc := dynamodb.New(sess)

    // dynamoDB用のqueryの作成
    getQuery := &dynamodb.QueryInput{
        // テーブル名(そのまま)
        TableName: aws.String("threads"),
        // インデックスを使用する場合はインデックス名
        IndexName: aws.String("part-updated_at-index"),
        // カラムに別名をつける
        ExpressionAttributeNames: map[string]*string{
            "#part": aws.String("part"),
        },
        // カラムの値に別名をつける
        ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
            ":part": {
                N: aws.String("0"),
            },
        },
        // 先ほどつけた別名を使って、プライマリキーを指定する
        KeyConditionExpression: aws.String("#part = :part"),
        // カラムのどの値を取得するかの指定
        ProjectionExpression:   aws.String("id, title, created_at, updated_at"),
        // レンジキーによるソート、昇順か降順か
        ScanIndexForward:       aws.Bool(false),
        // 取得するデータ数の上限
        Limit:                  aws.Int64(100),
    }

    // 上で定義したクエリを使って、データを取り出す。失敗すればinternal server error
    result, err := svc.Query(getQuery)
    if err != nil {
        return internalServerError()
    }

    // データが取り出せた場合、スレッドの一覧(=リスト)なのでそれを入れるためのThreadスライスを作成する
    threads := make([]Thread, 0)
    // 各々のスレッドは先ほどProjectionExpressionで指定したようにid, title, created_at, updated_atを持つオブジェクト
    // そしてそれがリストになっている(=ListOfMaps)が、この取り出したデータはDynamoDB用のフォーマットになっている。
    // DynamoDBのデータフォーマットについてはhttps://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Programming.LowLevelAPI.html
    // UnmarshalListOfMapsでスレッド構造体のスライスに入れられるようにデータを変換する。
    if err := dynamodbattribute.UnmarshalListOfMaps(result.Items, &threads); err != nil {
        return internalServerError()
    }

    // json型に変換、失敗すればinternal server error
    jsonBody, err := json.Marshal(threads)
    if err != nil {
        return internalServerError()
    }

    // 取得したデータをjsonで返却する。
    // originが異なるためAccess-Control-Allow-Originを付与
    // 今回はコンセプトのために*にしているが、実際の場面では適切に設定してください。
    return events.APIGatewayProxyResponse{
        Body:       string(jsonBody),
        StatusCode: http.StatusOK,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

// lambdaを実行
func main() {
    lambda.Start(Handler)
}

スレッドの作成

create_threads.go
package main

import (
    "context"
    "net/http"
    "time"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "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"
    "github.com/google/uuid"
    "golang.org/x/sync/errgroup"
)

type Request struct {
    Title   string `json:"title"`
    Name    string `json:"name"`
    Content string `json:"content"`
}

type response struct {
    Name      string `json:"name"`
    CreatedAt int64  `json:"created_at"`
    Content   string `json:"content"`
}

type responses struct {
    ThreadID  string     `json:"thread_id"`
    Responses []response `json:"responses"`
}

type thread struct {
    Part      int    `json:"part"`
    ID        string `json:"id"`
    Title     string `json:"title"`
    CreatedAt int64  `json:"created_at"`
    UpdatedAt int64  `json:"updated_at"`
    Name      string `json:"name"`
}

func internalServerError() (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

// データをデータベースに格納するための関数、タイムアウト処理用のcontextを受け取る。
func insertData(ctx context.Context, svc *dynamodb.DynamoDB, data interface{}, target string) error {

    // 格納するデータをdynamoDB用に変換
    // 中身はただのmapなので手でも作れないことはない
    av, err := dynamodbattribute.MarshalMap(data)
    if err != nil {
        return internalServerError()
    }

    // データを格納するためのパラメータ
    putParams := &dynamodb.PutItemInput{
        TableName: aws.String(target),
        Item:      av,
    }

    // Timeoutになるか、データの格納が成功するまでループ
    for {
        select {
        case <-ctx.Done():
            // タイムアウトした場合はcloudwatchにエラーログを残す
            return fmt.Errorf("inserting data into the table, %v failed", target)
        default:
            _, err = svc.PutItem(putParams)
            if err != nil {
                continue
            }
            return nil
        }
    }

    return nil
}

// ハンドラー関数、データをjsonで受け取るため、Request型のrequestを引数にもつ。
func handler(request Request) (events.APIGatewayProxyResponse, error) {
    name := request.Name
    title := request.Title
    content := request.Content

    sess, err := session.NewSession()
    if err != nil {
        return internalServerError()
    }

    svc := dynamodb.New(sess)
    // スレッドのIDをuuidv4で作成する
    threadID := uuid.New().String()

    // threadsテーブルに格納するデータの構造体
    t := thread{
        Part:      0,
        ID:        threadID,
        Title:     title,
        Name:      name,
        CreatedAt: time.Now().Unix(),
        UpdatedAt: time.Now().Unix(),
    }

    // responsesテーブルに格納するデータの構造体
    r := responses{
        ThreadID: threadID,
        Responses: []response{
            response{
                Name:      name,
                CreatedAt: time.Now().Unix(),
                Content:   content,
            },
        },
    }

    // 上記二テーブルへのデータの格納はgoroutineで並列に実行
    // goroutineでエラーを扱うためにerrgroupを使用
    // タイムアウトしたらinternal server error
    eg, ctx := errgroup.WithContext(context.Background())
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    eg.Go(func() error {
        return insertData(ctx, svc, t, "threads")
    })
    eg.Go(func() error {
        return insertData(ctx, svc, r, "responses")
    })

    if err := eg.Wait(); err != nil {
        return internalServerError()
    }

    // データの格納が完了
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusOK,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

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

レス一覧の取得

get_responses.go
package main

import (
    "encoding/json"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "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"

    "reflect"
)

type Request struct {
    ThreadID string `json:"id"`
}

type Response struct {
    Responses interface{} `json:"body"`
}

func internalServerError() (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

// 取得するレスのIDをクエリパラメータもしくパスパラメータで受け取るため、events.APIGatewayProxyRequest型の引数を持つ。
func Handler(request events.APIGatewayProxyRequest) (interface{}, error) {

    // パスパラメータかクエリでスレのidを取得する。なければStatusBadRequest
    var id string
    if tmp, ok := request.PathParameters["id"]; ok == true {
        id = tmp
    } else if tmp, ok := request.QueryStringParameters["id"]; ok == true {
        id = tmp
    } else {
        return events.APIGatewayProxyResponse{
            StatusCode: http.StatusBadRequest,
            Headers: map[string]string{
                "Access-Control-Allow-Origin": "*",
            },
        }, nil
    }

    sess, err := session.NewSession()
    if err != nil {
        return internalServerError()
    }

    svc := dynamodb.New(sess)

    // 今回は一つのデータを取得するのでGetItemInput型
    getItemInput := &dynamodb.GetItemInput{
        TableName: aws.String("responses"),
        // 単純なオブジェクトなので手でDynamoDBのフォーマットにする
        // もちろんMarshal関数を使って作ってもいい
        Key: map[string]*dynamodb.AttributeValue{
            "thread_id": {
                S: aws.String(id),
            },
        },
    }

    result, err := svc.GetItem(getItemInput)
    if err != nil {
        return internalServerError()
    }

    // 返ってくるデータに合う構造体を作るのが面倒なのでinterface{}に入れてしまう
    // 今回は単一のオブジェクトなのでUnmarshalMap関数を使用する
    var responses interface{}
    if err := dynamodbattribute.UnmarshalMap(result.Item, &responses); err != nil {
        return internalServerError()
    }

    // 中身がjsonで、キーが何なのかもわかっているので、reflectパッケージを使って中身を取り出す
    rv := reflect.ValueOf(responses)
    res := rv.MapIndex(reflect.ValueOf("responses")).Interface()
    // 取り出した中身をjsonに変換する。失敗したらinternal server error
    jsonBody, err := json.Marshal(res)
    if err != nil {
        return internalServerError()
    }

    return events.APIGatewayProxyResponse{
        Body:       string(jsonBody),
        StatusCode: http.StatusOK,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

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

レスの追加

put_response.go
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "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"
    "golang.org/x/sync/errgroup"
)

type Request struct {
    ThreadID string `json:"id"`
    Name     string `json:"name"`
    Content  string `json:"content"`
}

type Response struct {
    Name      string `json:"name"`
    CreatedAt int64  `json:"created_at"`
    Content   string `json:"content"`
}

type UpdatedAt struct {
    UpdatedAt int64 `json:"updated_at"`
}

func internalServerError() (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

func updateData(ctx context.Context, svc *dynamodb.DynamoDB, data *dynamodb.UpdateItemInput) error {

    // 同じくタイムアウトまでデータの格納を試みる
    for {
        select {
        case <-ctx.Done():
            return fmt.Errorf("Update data in the table, %v failed", aws.StringValue(data.TableName))
        default:
            _, err := svc.UpdateItem(data)
            if err != nil {
                continue
            }
            return nil
        }
    }
}

func handler(req Request) (events.APIGatewayProxyResponse, error) {

    now := time.Now().Unix()

    sess, err := session.NewSession()
    if err != nil {
        return internalServerError()
    }

    svc := dynamodb.New(sess)

    r := []Response{Response{
        Name:      req.Name,
        CreatedAt: now,
        Content:   req.Content,
    }}

    threadID := req.ThreadID

    response, err := dynamodbattribute.Marshal(r)
    if err != nil {
        return internalServerError()
    }

    // レスの追加は、データの更新にあたるのでUpdateItem
    rInputParams := &dynamodb.UpdateItemInput{
        TableName: aws.String("responses"),
        Key: map[string]*dynamodb.AttributeValue{
            "thread_id": {S: aws.String(threadID)},
        },
        // dynamoDBでのリストへの追加はlist_append関数を使う。第一引数に第二引数を追加したリストを返却するのでそれをそのままSETする
        UpdateExpression: aws.String("SET #ri = list_append(#ri, :vals)"),
        // 多分名前をつけた方がいい
        ExpressionAttributeNames: map[string]*string{
            "#ri": aws.String("responses"),
        },
        // 同上
        ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
            ":vals": response,
        },
    }

    updatedAt, err := dynamodbattribute.Marshal(now)
    if err != nil {
        return internalServerError()
    }

    tInputParams := &dynamodb.UpdateItemInput{
        TableName: aws.String("threads"),
        Key: map[string]*dynamodb.AttributeValue{
            "part": {N: aws.String("0")},
            "id":   {S: aws.String(threadID)},
        },
        // 単純な値の上書きはSET a = bでできる
        UpdateExpression: aws.String("SET #ri = :vals"),
        ExpressionAttributeNames: map[string]*string{
            "#ri": aws.String("updated_at"),
        },
        ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
            ":vals": updatedAt,
        },
    }

    eg, ctx := errgroup.WithContext(context.Background())
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    eg.Go(func() error {
        return updateData(ctx, svc, rInputParams)
    })
    eg.Go(func() error {
        return updateData(ctx, svc, tInputParams)
    })

    if err := eg.Wait(); err != nil {
        return internalServerError()
    }

    return events.APIGatewayProxyResponse{
        StatusCode: 200,
        Headers: map[string]string{
            "Access-Control-Allow-Origin": "*",
        },
    }, nil
}

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

AWS Lambdaでプログラムを実行するためには、実行したいファイルをzip形式でアップロード、もしくはウェブ上のエディタで直接コードを編集する必要があります。
Goに関しては前者しかできないようなので、これらのプログラムをまとめてコンパイルしてzipにするMakefileもついでに作成しました。

all: get_threads.zip create_thread.zip get_responses.zip put_response.zip

get_threads: get_threads.go
    GOOS=linux GOARCH=amd64 go build get_threads.go

create_thread: create_thread.go
    GOOS=linux GOARCH=amd64 go build create_thread.go

get_responses: get_responses.go
    GOOS=linux GOARCH=amd64 go build get_responses.go

put_response: put_response.go
    GOOS=linux GOARCH=amd64 go build put_response.go

get_threads.zip: get_threads
    zip $@ $<

create_thread.zip: create_thread
    zip $@ $<

get_responses.zip: get_responses
    zip $@ $<

put_response.zip: put_response
    zip $@ $<

clean:
    rm get_threads create_thread get_responses put_response *.zip

完成した4つのバイナリのzipファイルを4つのAWS Lambdaの関数に登録すればLambda側の準備は完了です。
スクリーンショット 2019-12-23 6.06.31.png

zipは関数コードのパネルからアップロードできます。
ハンドラには実行するファイル名を入力します。ここではget_threads.goをget_threadsにコンパイルしてget_threads.zipにしたものをアップロードしました。
スクリーンショット 2019-12-23 6.07.08.png

API Gateway

AWS Lambdaはそのままだとhttpのリクエストをトリガーに発火できないため、リバースプロキシとしてAPI Gatewayを設置してそちらにアクセスされた場合にAWS Lambdaの特定の関数を発火させます。
また、この時パラメータをAWS Lambdaに渡すように設定する必要があります。
補足として、外部からのリクエストを受け付ける際はURLのオリジンが異なるためCORSを有効化する必要があります。現在はRest APIを作ると言う名目(=不特定多数からのアクセスを想定)のため制限はかけていませんが、実際のアプリケーションに組み込む際は適宜設定することをお勧めします。
ちなみに今回のAPIは不特定多数からのアクセスを想定してはいますが、悪戯されても嫌なのでスロットリングを有効化してレートとバーストに制限をかけています。
API Gatewayについての説明はたくさんあるので、省略気味に紹介します。

スクリーンショット 2019-12-23 6.16.39.png
ルート直下に試しにcreate-threadのAPIを作成します。
まずリソース作成で適当なリソース名(今回はcreate-thread)を入力し、API Gateway CORS を有効にします。
これでOPTIONSメソッドが自動的に作られます。

スクリーンショット 2019-12-23 6.20.55.png
次にメソッド作成ですが、ここで使用するlambdaの関数を選択します。
ここで、Lambda プロキシ統合の使用にチェックを入れるかどうかですが、これはevents.APIGatewayProxyRequestでデータを受け渡ししたい場合はチェックを入れてください。
今回はcreate-thread, put-responseについてはjsonでデータを渡すだけでいいのでチェックせず、get-responsesについてはAPI Gatewayを通してパスパラメータを渡したいのでチェックを入れています。
get-threadsは何もデータを渡さないのでどちらでも問題ありません。
いずれにせよ全てのデータをevents.APIGatewayProxyRequestで受け取るならば全部チェックで問題ありません。
出力については、とりあえずevents.APIGatewayProxyResponse型で返せばいい感じにAPI Gatewayの方で流してくれるみたいです。

各種設定を終えると以下のようになります。
スクリーンショット 2019-12-23 6.10.16.png
CORSを許可する設定になっていれば各種GETやPOSTなどのメソッドの下にOPTIONSメソッドが追加されます。
また、パスパラメータの設定ができていれば{id}のようにマッピングが表示されます。

Open API

API GatewayではswaggerやOpenAPI3用のjsonやyamlをエクスポートできます。
スクリーンショット 2019-12-18 22.05.37.png

ここでエクスポートしたファイルをswagger editorなどでインポートするとOpenAPI3でAPIの一覧が見られるようになります。
スクリーンショット 2019-12-18 22.14.34.png

実際にOpenAPI3上でメソッドの実行もでき、レスポンスが返ってくるのが確認できます。
スクリーンショット 2019-12-18 22.16.41.png

以上で、掲示板Rest APIが完成しました。

補足

今回はSDKを使ってGoでdynamoDBを操作しましたが、dynamoDBの癖の強さとGoの型ら辺が作用してかなりしんどいです。良い感じに面倒なところやってくれるライブラリがあるみたいなのでそっちを使った方がいいと思います。
https://github.com/guregu/dynamo

終わりに

以上でバックエンドの方が完成しました。
~元々のコンセプトとしてはこれでいいのですが、正直バックエンドだけ用意してクライアントは自由に作ってねと言ったところでどうせ誰も作らないので~
ついでにReact Hooks, Redux Hooksが使いたいのもあってReactでクライアントの方も作ろうとしたのですが間に合いませんでした。(某アプリが悪い)
そのうちクライアント側も作って記事公開します。

ちなみに実装は素のReactではなくReact staticを使っています。当初はgatsby.jsを使おうとしたのですが
どうもtypescriptで書くには早いかな感と、そもそもgraphQLを使ってないので止めました。
とは言え静的コンテンツとして配信するための静的化ができさえすればよく、肝心のコンテンツは動的に取得するので正直なんでもよかったのですが良い感じにシンプルそうなのでReact staticにしました。
React Hooks, Redux Hooksを使ったコンポーネントの作成やらなんやらはうまくいっているので、あとはcssがうまく書けたら公開になると思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした