Ruby で Lambda 入門 (1)

## 概要

- Lambda と DynamoDB の勉強がしたい
- API Gateway を使わなくとも Lambda に HTTP エンドポイントを作成できるようになったらしいので使ってみたい

以上の状況だったのでちょっとそのあたりを触ってみました。
なおタイトルにもある通り、ランタイムは Ruby 2.7 を選択しています。
※選択肢の中では自分が Ruby に慣れているというだけです

## やったこと

1. コンソールから Lambda 関数を作成
2. コンソールから DynamoDB のテーブルを作成
3. ローカルの apache にファイルを置いて、そこから Lambda にリクエスト
4. 受け取ったデータを使って DynamoDB のアイテムを作成
5. コンソールから追加されたアイテムを確認

## 手順詳細

### コンソールから Lambda 関数を作成
![](https://i.gyazo.com/9212952e70415079a6b90341a1d24676.png) 
![](https://i.gyazo.com/bbe861d6f1a9e9accc3fce4a817f37a6.png) 

今回は簡単に認証なしとします。
また、CORS をよしなにやってくれる設定も ON にしておきます。

:::note
「詳細設定」の **「関数 URL を有効化」** というのは比較的最近できたオプションです。

少し前までは、今回作成しようとしているように、関数に HTTP(S) でリクエストを送ろうと思うと API Gateway と組み合わせる必要がありました。

それがこのオプションを ON にするだけで関数にエンドポイントを割り当てることができるようになったので、随分お手軽に試せるようになりました。
:::

#### 実行ロールの調整

![](https://i.gyazo.com/9ff37471d4a6baef2dc777effd761044.png) 

設定 > アクセス権限 > 実行ロール
を確認します。

勝手にこの関数用のロールを作ってくれています。
このロールにポリシーを追加します。
追加するのは、DynamoDB にアクセス・書き込みするためのロールです。
- `AmazonDynamoDBFullAccess`
- `AWSLambdaDynamoDBExecutionRole`

この 2ポリシーを足しておけばOKでしょう。
![](https://i.gyazo.com/c10179dcde717782dd92d2339c45b82e.png) 

### コンソールから DynamoDB のテーブルを作成
`user` テーブルを作成します。

![](https://i.gyazo.com/dfcc162b0001cfb4dae33f9d2b44aaba.png) 

![](https://i.gyazo.com/96d36256fa84549e96252ae41a83c30c.png) 

コンソールから作るときは感覚的に作れるので特に迷わないと思います。
`user` テーブルのパーティションキーは数値型の id にしておきます。

パーティションキーの説明はここではしませんが、この場合はプライマリーキーと考えればいいと思います。
この`user` テーブルの id を 1 からの連番にしたいのですが、DynamoDB には MySQL のような連番を作ってくれるしくみがないらしいので、自分でうまいことやります。

その「うまいことやる」ために `sequence` テーブルを作成します。
こちらはパーティションキーを文字列型の `tablename` というのにしておきます。

続いて、この `sequence` テーブルに「項目を作成」します。
**「項目」はアイテム、RDB ではレコードに相当するもの**です。

![](https://i.gyazo.com/983389945f48b95a837312d093a5a577.png)

パーティションキーとして設定した `tablename` には `user` という文字列を登録します。
また、`seq` という属性（RDB で言うところのカラム的なもの）も追加したいので、「新しい属性の追加」で数値型の `seq` 属性を追加して、こちらは値 `0` として「項目を作成」します。

![](https://i.gyazo.com/eae5aed5826fee020feda6ba1bee0a1c.png) 

なんのためにこのようなことをしたのか　ですが、先ほど書いたように DynamoDB には MySQL のような連番を作ってくれるしくみがありません。

なので、この `sequence` テーブルのレコードで、テーブルごとの連番を管理しようとしています。
今作成した `tablename = "user", seq = 0` というアイテムは「`user` テーブルの現在の `id` の最大値は `0`」という事を表しています。

`user` テーブルに新しくレコードを追加しようとする（新しい連番が必要になる）ときに、この項目の `seq` を `+1` して、その数字を使うようにすればよい、という感じです。

そんなことしなくても `user` テーブルの一番大きな数字を確認して、`+1` すれば良いのでは？という気もしますが、それだと例えば同時に2つのレコードを作ろうとしたときに同じ id で作成しようとしてしまうことになり、プライマリーキーの重複 → エラー、となるためこういう方法を取るらしいです。
※「アトミックカウンター」というワードで調べると良いです


### ローカルの apache にファイルを置いて、そこから Lambda にリクエスト

ここは Lambda と関係ないのでサラッと。
ローカルの apache でアクセスできる場所に以下のようなファイルを配置してアクセスできるようにしました。

いくつか入力できる input があって、リクエスト送信できるだけです。

なんでもいいですが、jQuery が慣れてて楽なので CDN で利用しています。
見た目は最低限を作るために Bootstrap 5 をこちらも CDN で利用しています。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
      crossorigin="anonymous"
    />

    <title>lambda</title>
  </head>
  <body>
    <div class="container mt-2">
      <h1>Lambda Test</h1>
      <div class="mb-3">
        <label for="userNameInput">ユーザ名</label>
        <input
          type="text"
          class="form-control"
          id="userNameInput"
        />
      </div>
      <div class="mb-3">
        <label for="userAgeInput">年齢</label>
        <input
          type="number"
          class="form-control"
          id="userAgeInput"
        />
      </div>
      <div class="mb-3">
        <label for="userEmailInput">メールアドレス</label>
        <input
          type="email"
          class="form-control"
          id="userEmailInput"
        />
      </div>
      <div>
        <button type="button" id="userInfoSubmitButton" class="btn btn-primary">
          送信
        </button>
      </div>
    </div>

    <script
      src="https://code.jquery.com/jquery-3.6.0.min.js"
      integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
      crossorigin="anonymous"
    ></script>
    <script>
      $(function () {
        $("#userInfoSubmitButton").on("click", function () {
          $.ajax({
            // ↓ここだけ伏せています
            url: "https://xxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/",
            type: "POST",
            data: {
              name: $("#userNameInput").val(),
              age: $("#userAgeInput").val(),
              email: $("#userEmailInput").val(),
            },
          })
            .done(() => {
              alert("登録しました");
            })
            .fail(() => {
              alert("エラーが発生しました");
            });
        });
      });
    </script>
  </body>
</html>

```

### 受け取ったデータを使って DynamoDB のアイテムを作成

Lambda 関数の本体を書いていきます。

とりあえず今回の完成形が以下になりました。

```ruby
require 'json'
require 'aws-sdk-dynamodb'
require 'base64'
require 'uri'

TABLE_NAME_SEQUENCE = "sequence"
TABLE_NAME_USER = "user"

def lambda_handler(event:, context:)
  puts "event: #{event}"
  
  body = event["body"]
  puts "event['body']: #{body}"
  
  base64_decoded_body = Base64.decode64(body)
  puts "base64_decoded_body: #{base64_decoded_body}"
  
  decode_www_form = URI.decode_www_form(base64_decoded_body)
  puts "decode_www_form: #{decode_www_form}"
  
  data = decode_www_form.to_h
  puts "data: #{data}"
  
  resource = get_resource
  new_id = get_new_id(resource)
  puts "new_id: #{new_id}"

  user_table = get_table(resource)
  data["id"] = new_id
  put_item(user_table, data)
  
  { statusCode: 200, body: JSON.generate("count: #{user_table.item_count}") }
end

def get_resource
  Aws::DynamoDB::Resource.new(region: 'ap-northeast-1')
end

def get_table(resource, name: TABLE_NAME_USER)
  resource.table(name)
end

def add_item(table, data)
  table.put_item(data)
end

def get_new_id(resource)
  table = get_table(resource, name: TABLE_NAME_SEQUENCE)
  hash = {
    tablename: TABLE_NAME_USER
  }
  update_option = {}
  # アイテムを特定するための情報
  # よってプライマリキーである tablename を指定した Hash を渡している
  update_option[:key] = hash
  # これを指定することで、更新後の値が取得できる
  # インクリメント語の seq の値が欲しいので指定する必要がある
  update_option[:return_values] = "UPDATED_NEW"
  # この後で指定する :update_expression にて:increment という部分に 1 を代入することを表す
  # プレースホルダ的な感じ
  update_option[:expression_attribute_values] = {":increment": 1}
  # どういう変更を加えるのかを指定
  # この書き方で seq 属性を 1 インクリメント を表す
  update_option[:update_expression] = "ADD seq :increment"
  # 更新する
  # res には、更新後の情報が含まれる
  res = table.update_item(update_option)
  puts("res", res)
  res[:attributes]["seq"]
end

def put_item(table, data)
  table.put_item({item: data})
end

```

:::note
解説記事用・勉強用を意識しているためコメント多め、デバッグ用 `puts` をあえて残しています。
:::

:::note
**ログを確認**

![](https://i.gyazo.com/c58ba80fe817522d52e2a94cc0637327.png) 

ログの確認には
**モニタリング > CloudWatch のログを表示**
で飛ぶと早いでしょう。

ruby なら `puts` で出力した内容はここのログで確認できます。
手軽にデバッグするのに便利だと思います。

なお、ログストリームはチョイチョイ変わるので、ログストリームを開いて更新しているのに新しいログが表示されない、というときはいったんログストリーム一覧に戻ってみると、新しいログストリームが作られていたりします。

おそらく前回の実行から少し時間が経過して、新しく Lambda 実行環境が作り直されるタイミングでログストリームが変わるのだろうと推測します。
:::

#### 送信パラメータを整形する
![](https://i.gyazo.com/1c94d33c1a8b54e303d030db6891a892.png)

先ほどのフォームから以上のような値を送信してみます。

Lambda 側では、フォームから飛んできた値を受け取り、それを `body` に入れます。
このとき、リクエストの情報は `lambda_handler` の仮引数 `event` に入ってきます。
`event` は `Hash` で、body の情報は `event["body"]` で取得できます。

※この `event` がどんな値になっているかは `puts event` でログに記録できるので、そのようにして確認するとお手軽です
```rb
puts "event: #{event}"
```

ここで注意すべき事があり、この `event["body"]` は base64 でエンコードされています。
イマイチなんのためなのかよくわかりませんが、そういうものらしいです。

```rb
body = event["body"]
puts "event['body']: #{body}"
```
```text
event['body']: bmFtZT0lRTMlODMlODYlRTMlODIlQjklRTMlODMlODglRTMlODMlQTYlRTMlODMlQkMlRTMlODIlQjYmYWdlPTI2JmVtYWlsPXRlc3QlNDBmb29iYXIuY29t
```

:::note
**Base64 Encode されるのはどんなとき？**

「テキスト型ではない `Content-Type` (`text/` 以外) の場合、body 部分を Base64 Econde する」
というルールらしいです。

API Gateway がこうなっているようですが、「関数 URL を有効化」オプションを利用した場合にも同様のようですね。
:::

base64 でデコードすると `base64_decoded_body` が得られます。

```rb
base64_decoded_body = Base64.decode64(body)
puts "base64_decoded_body: #{base64_decoded_body}"
```
```txt
base64_decoded_body: name=%E3%83%86%E3%82%B9%E3%83%88%E3%83%A6%E3%83%BC%E3%82%B6&age=26&email=test%40foobar.com
```

今度はURLエンコードされています。
これもデコードが必要ですね。

```rb
decode_www_form = URI.decode_www_form(base64_decoded_body)
puts "decode_www_form: #{decode_www_form}"
```
```txt
decode_www_form: [["name", "テストユーザ"], ["age", "26"], ["email", "test@foobar.com"]]
```

ようやく送信した値がきれいに読めるようになりました。
ついでに `.to_h` でいい具合の Hash にできるのでしておきます

```rb
data = decode_www_form.to_h
puts "data: #{data}"
```
```txt
data: {"name"=>"テストユーザ", "age"=>"26", "email"=>"test@foobar.com"}
```

これでようやく送信したパラメータが扱いやすい形になりました。

#### 最新の id を取得する
前述したように、最新の id 番号は `sequence` テーブルを使って取得します。

ここからは DynamoDB へのアクセスが必要になるので、`require 'aws-sdk-dynamodb'` して SDK を利用しています。
[Module: Aws::DynamoDB — AWS SDK for Ruby V3](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB.html) 

- `Aws::DynamoDB::Resource`
- `Aws::DynamoDB::Client` 

これはどちらを使ってもいいですが、私はオブジェクト指向的に扱いやすくて分かりやすい `Resource` が好みなのでこちらを使います。

最新のid 取得用には `get_new_id` というメソッドを定義しました。
`Resource` を取得し、そこから `sequence` テーブルの `Table` オブジェクトを取得しています。
```rb
table = get_table(resource, name: TABLE_NAME_SEQUENCE)
```

`sequence` テーブルの `tablename = "user"` の項目の `seq` 属性の値が、今の `user` テーブルの最大 id 値を表している、ということは既に説明したとおりです。

[update_item](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Table.html#update_item-instance_method) メソッドを使って、**この `seq` 属性を 1 だけインクリメントし、インクリメント後の値を返してもらいます**。**この値こそが新しい `user` アイテムに設定すべき `id` を表します**。

update_item の使い方はドキュメントを参照すべきですが、今回は以下のようにして利用できます。
```rb
hash = {
  tablename: TABLE_NAME_USER
}
update_option = {}
# アイテムを特定するための情報
# よってプライマリキーである tablename を指定した Hash を渡している
update_option[:key] = hash
# これを指定することで、更新後の値が取得できる
# インクリメント語の seq の値が欲しいので指定する必要がある
update_option[:return_values] = "UPDATED_NEW"
# この後で指定する :update_expression にて:increment という部分に 1 を代入することを表す
# プレースホルダ的な感じ
update_option[:expression_attribute_values] = {":increment": 1}
# どういう変更を加えるのかを指定
# この書き方で seq 属性を 1 インクリメント を表す
update_option[:update_expression] = "ADD seq :increment"
# 更新する
# res には、更新後の情報が含まれる
res = table.update_item(update_option)
```

これで `res` には更新後の情報が入ってきます。
`return_values` に `"UPDATED_NEW"` を指定したので、更新された属性の情報だけが `res[:attributes]` の中に入ってきます。
```txt
res: 
{:attributes=>{"seq"=>0.1e1}, :consumed_capacity=>nil, :item_collection_metrics=>nil}
```

ここでほしいのは `seq` の値ですので `res[:attributes]["seq"]` で取得可能です。

:::note warn
このあたり、Hash の key が symbol だったり string だったりでややこしい点に注意。間違えると nil が取れちゃってハマる可能性があります。
:::

ちなみにここで期待していたのは `1` だったのですが、 `0.1e1` という謎の値が取れました。
これは `BigDecimal` です。
見かけはアレですが、値自体は 1 を表しているのでこのままで良いでしょう。
どうやら DynamoDB の数値型を利用すると Ruby のコード上では BigDecimal として扱うことになるようですね。

#### user テーブルにアイテムを追加する
- フォームから送信したデータ
- 最新の連番の id

これらの情報が準備できたので、いよいよ `user` テーブルにアイテムを追加します。
RDB でいうところのレコード追加に相当します。

[put_item](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Table.html#put_item-instance_method) メソッドを使えば可能です。

先ほど取得した id と、フォームから送信したデータ（を整形した `data`） をガッチャンコして `put_item` でポイします。
```rb
table.put_item({item: data})
```

ここまで問題なくできていれば、これにて DynamoDB の `user` テーブルにアイテムが追加されているはずです。

:::note warn
**備考：`put_item` でエラーになると id が連番ではなくなる**

今回のアトミックカウンタの手法を用いる場合、`user` テーブルにアイテムを追加する流れは、以下の2つの連続した処理で行っています。

1. `sequence` テーブルの `seq` を更新してその値を返す
2. `user` テーブルの新しい項目の id としてその値を利用する

2、つまり `user` の `put_item` でエラーになった場合は、1 の更新だけが行われた状態になります。

次の施行時には再度 1 が実行されるため、ここで id の連続性が失われることがわかります。

基本的には連番でなくなっても問題はないはず（重複しない値が欲しいだけなので）ですが、それが問題になるような場合はそれを保証するための方法を取る必要があります（1, 2 の処理をトランザクション操作するイメージ）。
→[条件付きの書き込み](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.ConditionalUpdate) 
:::

### コンソールから追加されたアイテムを確認

コンソール上から簡単に確認してみます。

![](https://i.gyazo.com/c58112a54fdbd9b55e11f220521c8fa3.png) 

テーブル > user > テーブルアイテムの探索

![](https://i.gyazo.com/bf037b7219e450a5910c983a6e6bf712.png) 

画像の場所にアイテムが追加されていることが確認できました。

ちなみに `sequence` テーブルも確認してみると、`tablename = user` のアイテムの `seq` 属性も `1` になっていることが確認できます。

この後続けてフォームから何度かリクエストを送信すると、その分だけ `seq` が増えて、`user` テーブルに登録されるデータの id もうまく連番が作成されていくと思います。


## 今後

- DynamoDB の API をもうちょい触りたい
- 次回はデータ取得とか
- コンソールからではなく、SAM 利用してデプロイしたい

## 参考文献

- [AWS Lambda実践ガイド](https://amzn.to/3vbHvhu)