【†ちょっと尖ったハンネメーカー†】AWS S3 + API Gateway + Lambda + DynamoDB でアプリを作ってみる


# 作ったもの

「デーモン小暮」「ジミー大西」「ギャル曽根」「ヘルシェイク矢野」...
**カタカナ語＋名字**の芸名やハンドルネームって尖ってて憧れますよね。
今回は、そんなちょっと尖ったハンドルネームを自動で生成する**「†ちょっと尖ったハンネメーカー†」**を作成しました。

https://togattahanne.hamabe.info

<img width="600px" alt="hanne.gif名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547023/9b2d8a65-7419-366d-341f-d8e6797d75b1.gif">

# 目的

[先日始めてVue.jsに触れ](https://qiita.com/hamahamabe/items/465a8ac1a5f929e10fed)、フロントエンドについて少しだけわかったところで今回は引き続きAWSの学習の意味を込めて、**S3の静的ホスティング + CloudFrontで公開したページ(Vue.js)**と、**Lambda + API Gateway + DynamoDB という構成で作成したREST APIとの連携** でWEBアプリを作成してみることにしました。

# 使用技術など

簡単な構成図は以下のようになります。
![AWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547023/d693b1f4-7b94-baf0-1ecc-5b9825f51a89.png)

`Route53`でドメイン(今回は `togattahanne.hamabe.info` )を割当て、`CloudFront`で配信の安定化およびSSL対応、`S3`に静的ファイルを配置しホスティングを行います。また、`APIGateway`で`REST API`を定義しリクエストがあると`Lambda`が発火、`DynamoDB`へアクセスし取得した情報をレスポンスとして返しています。

また、`DynamoDB`に格納している**名字とカタカナ語**のデータは`Python(BeautifulSoup4)`でWEBスクレイピングを行いました。

## 多分かなり安い
AWSの料金体系はなかなか分かりにくいですが、この構成はおそらくかなり安いです。
参考：https://qiita.com/teradonburi/items/a382a17e1e0245b7d831
ちゃんと計算はしていないので感覚的な話になってしまいますが、同じようなWEBアプリを例えばEC2+RDSで作る場合と比べると、おそらく何十分の一にも安くなるような気がします。素晴らしい！

# 仕様

## DB設計
`DynamoDB`上に作成するテーブルは以下の２つです。
カタカナ語は[創作に使えるかもしれない用語集](https://kakkoii-yougosyuu.com/archives/1035333396.html)様、名字は[須﨑のホームページ](http://www2s.biglobe.ne.jp/~suzakihp/index40.html)様からスクレイピングさせていただきました。

- 名字テーブル(myoji)

| カラム名  | 項目名       | 型     | プライマリパーティションキー | 
| --------- | ------------ | ------ | ---------------------------- | 
| id        | 名字ID       | Number | ○                           | 
| name      | 名字         | String |                              | 
| name_read | 名字（読み） | String |                              | 
| rank      | 世帯数順位   | String |                              | 


- カタカナ語テーブル(second_name)

| カラム名  | 項目名       | 型     | プライマリパーティションキー | 
| --------- | ------------ | ------ | ---------------------------- | 
| id        | カタカナ語ID       | Number | ○                           | 
| second_name| カタカナ語         | String |                              | 
| second_name_original | カタカナ語（元言語） | String |                              | 
| caption      | 意味   | String |                              | 

## API設計
上記のDB内容を踏まえて、実装するAPIの仕様は以下の通りとなります。
パラメータを指定しない場合ランダムに、指定した場合該当レコードを返します。

- リクエストパラメータ

| パラメータ名 | 型     | 説明                                                                           | 
| ------------ | ------ | ------------------------------------------------------------------------------ | 
| name_id      | Number | 名字テーブルのID。指定しないor該当レコードが無い場合ランダムに取得する。       | 
| second_id    | Number | カタカナ語テーブルのID。指定しないor該当レコードが無い場合ランダムに取得する。 | 
| name         | String | 名字をIDでなく文字列で指定することもできる。                                                       | 

- レスポンス例

パラメータ無し
ランダムなレコードが帰ってくる。

```json
{
  name: {
    name_read: "おの",
    id: 2539,
    name: "尾野",
    rank: "2145"
  },
  second_name: {
    second_name: "ピュグマリオン",
    id: 497,
    caption: "ギリシャ神話に登場するキプロス島の王。自らが作った彫刻に恋し人間になることを願う。それを見た女神アフロディーテは彫刻に命を与え、王はその女性と結婚した。",
    second_name_original: "Pygmalion"
  }
}
```

パラメータ`{ name_id : 12, second_id : 542 }`
指定したIDに対応するデータが帰ってくる。

```json
{
  name: {
    name_read: "やまだ",
    id: 12,
    name: "山田",
    rank: "12"
  },
  second_name: {
    second_name: "ヘクサメトロス",
    id: 542,
    caption: "古代ギリシャの叙事詩などで用いられた詩の形式で、１行が６つの韻脚となっている詩を指す。ホメロスの「イリアス」「オデュッセイア」がこの形式で書かれている。",
    second_name_original: "hexameter"
  }
}
```

パラメータ`{ name : "壇ノ浦" }`
該当レコードの無い`name`を指定すると以下のようなデータが帰ってくる。

```json
{
  name: {
    name_read: "",
    id: 99999,
    name: "壇ノ浦",
    rank: ""
  },
  second_name: {
    second_name: "ヴァンダリズム",
    id: 80,
    caption: "建築物や公共の施設などに対して行われる落書きや破壊などの行為。景観破壊。",
    second_name_original: "vandalism"
  }
}
```

# 実装
## スクレイピング
前述のとおり今回用いたデータについて、カタカナ語は[創作に使えるかもしれない用語集](https://kakkoii-yougosyuu.com/archives/1035333396.html)様、名字は[須﨑のホームページ](http://www2s.biglobe.ne.jp/~suzakihp/index40.html)からスクレイピングさせていただきました。
今回`BeautifulSoup`(Pythonのスクレイピング用ライブラリ)について詳しく解説はしません。ソースは以下のような感じです。

```Python:scraping.py
from bs4 import BeautifulSoup
import urllib.request
import json
import re

def gen_katakana_json(urls):
    result = []
    for url in urls:
        katakana_soup = get_soup(url)
        katakana_json = katakanawords_to_json(katakana_soup)
        result.extend(katakana_json)
    return result

def gen_myoji_json(file):
    myoji_soup = BeautifulSoup(open(file), "lxml")
    myoji_json = myoji_to_json(myoji_soup)
    return myoji_json

def get_soup(url):
    print(url+" 取得中")
    req = urllib.request.Request(url)
    response = urllib.request.urlopen(req)
    html = response.read()
    soup = BeautifulSoup(html, "lxml")
    return soup

def katakanawords_to_json(soup):
    words = soup.find_all("p")[1]  # 2個目のpがワードリスト
    words_parsed = words.text.replace(
        "\u3000", "").split("\n")  # u3000を除去、改行で名前・キャプションをパース

    words_dictlist = []
    for (i, word) in enumerate(words_parsed):
        # 名前をカタカナと英語に整理
        if i % 2 == 0:
            kana_name = re.split("[（(]", word[1:])[0]
            en_name = re.split("[)）]", re.split("[（(]", word[1:])[1])[0]

        # 辞書型に格納しリストに追加
        elif i % 2 == 1:
            words_dict = {"second_name": kana_name,
                          "second_name_original": en_name,
                          "caption": word}
            words_dictlist.append(words_dict)

    return words_dictlist

def myoji_to_json(soup):
    rows = soup.find_all("tr")
    names_dictlist = []
    name_dict = {}
    for row in rows:
        try:
            rank = row.find_all("td")[0].text
            myoji = row.find_all("td")[1].text
            yomi = re.sub("[★☆]", "", row.find_all("td")[4].text)
        except:
            continue
        name_dict = {"name": myoji, "name_read": yomi, "rank": rank}
        names_dictlist.append(name_dict)

    return names_dictlist

if __name__ == "__main__":

    # カタカナ語スクレイピング
    katakana_urls = [
        "https://kakkoii-yougosyuu.com/archives/1035333396.html",
        "https://kakkoii-yougosyuu.com/archives/katakanakagyou.html",
        "https://kakkoii-yougosyuu.com/archives/1036149936.html",
        "https://kakkoii-yougosyuu.com/archives/katakanatagyou.html",
        "https://kakkoii-yougosyuu.com/archives/katakananagyou.html",
        "https://kakkoii-yougosyuu.com/archives/1036170595.html",
        "https://kakkoii-yougosyuu.com/archives/katakanamagyou.html",
        "https://kakkoii-yougosyuu.com/archives/katakanayarawa.html",
    ]
    katakana_words = gen_katakana_json(katakana_urls)

    # カタカナ語jsonをファイル出力
    with open("./json/katakana.json", 'w') as outfile:
        json.dump(katakana_words, outfile,  ensure_ascii=False)

    # 名字スクレイピング（htmlファイルから）
    myoji_file = "./myoji.html"
    myoji_words = gen_myoji_json(myoji_file)

    # 名字jsonをファイル出力
    with open("./json/myoji.json", 'w') as outfile:
        json.dump(myoji_words, outfile,  ensure_ascii=False)

```

スクレイピングはその時々html側の事情に対応していく感じになるのでコードを見てもいまいち何やってるか分かりにくいと思いますが、なんだかんだで以下のようなjsonが取得できました。

```json:myoji.json
[
      :
  { "name": "林", "name_read": "りん", "rank": "19" },
  { "name": "清水", "name_read": "きよみず", "rank": "20" },
  { "name": "清水", "name_read": "しみず", "rank": "20" },
  { "name": "山崎", "name_read": "やまさき", "rank": "21" },
  { "name": "池田", "name_read": "いけだ", "rank": "22" },
      :
]
```

```json:katakana.json
[
      :
 {
    "second_name": "モロトフ・カクテル",
    "second_name_original": "molotov cocktail",
    "caption": "英語で「火炎瓶」。"
  },
  {
    "second_name": "ヤハウェ",
    "second_name_original": "Yahweh",
    "caption": "ユダヤ教・キリスト教の唯一神の名前。ヤーヴェ、エホバとも。"
  },
  {
    "second_name": "ヤルダバオト",
    "second_name_original": "Jaldabaoth",
    "caption": "グノーシス主義において、この世を作った偽の神の名前。「混沌の子」の意味とされる。"
  },
      :
]
```


## DB格納

次にスクレイピングした内容をDynamoDBに格納していきます。
今回`boto3`というライブラリを用いてPythonを介してのAWS操作を行ってみます。

- boto3のインストール

```
$ pip install boto3
```

AWS CLIにて認証情報ファイルの設定をしていれば以下のようにプロファイル名からセッションを作成し、簡単にAWSリソースにアクセスすることができます。

```Python
session = Session(profile_name='PROFILE_NAME')
dynamodb = session.resource('dynamodb')
```

テーブルの作成もPython(boto3)から行うこともできますが、今回はAWSコンソールにて予めテーブルの作成を行っておきます。テーブル名とプライマリーキーを指定するだけです。画像は名字テーブルですが、同様の設定でカタカナ語テーブルも作成します。
![コメント 2020-09-03 181449.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547023/4f94aa29-a60e-09eb-b527-753572d2f998.png)


`boto3`の使用準備とテーブルの作成が完了したので、次は**データのインポート**を行っていきます。
`boto3`の`DynamoDB`クラスの`put_item`メソッドによりテーブルに要素を追加できるので、先程のjsonファイルを読み込み、1件ずつ連番(id)を付与してからテーブルへ`put_item`しています。ソースは以下のとおりです。

```Python:insert_myoji.py
import boto3
from boto3.session import Session
import json

profile = 'PROFILE_NAME'
session = Session(profile_name=profile)
dynamodb = session.resource('dynamodb')
table = dynamodb.Table('myoji')

with open("../json/myoji.json", 'r') as f:
    myojis = json.load(f)

    for (i, myoji) in enumerate(myojis):
        insert_item = {
            "id": i,
            "name": myoji["name"],
            "name_read": myoji["name_read"],
            "rank": myoji["rank"]
        }

        table.put_item(Item=insert_item)

```

実行するとみるみるレコードが入っていきます。しゅごい！
![コメント 2020-09-03 184735.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547023/729e7053-7062-658b-fc89-29ceb7bff44e.png)

`put_item`はあまり早くない？ようで、6000件弱の追加に十数分かかってしまいました。ここの処理については改善の余地がありそうです。DynamoDBについてもう少し勉強しないとな…。そもそもDynamoDBの良さ(NoSQLの利点など)を正直まだなにもわかっていません。勉強します…！

なにはともあれ、カタカナ語テーブルにも同様の処理を行い無事にDynamoDBに**スクレイピングしたデータを格納**することができました。

## Lambda関数作成

Lambdaコンソールから関数の作成を行います。
Lambda関数もPythonで記述しており、ここでも`boto3`を用いてDynamoDBへのアクセスを行っています。

Lambda関数が呼び出された時最初に実行されるのが`lambda_handler`関数です。ここに渡されたパラメータに応じた処理を記述します。

ここで注意すべき点として、下記のコードでは`event['name_id'] != ""`という書き方でパラメータが渡されたかのチェックを行っていますが、この記述だとLambdaコンソールで関数のテストをする際、パラメータを渡さなかったら`KeyError`が発生してしまいます。
後述の`APIGateway`から呼び出される場合は、例えばパラメータ`name_id`が渡されない場合でも`event['name_id']`には`""`が格納されるため問題無いのですが、Lambda単体で動作させる場合はパラメータ`name_id`が渡されない場合は`event['name_id']`自体が定義されません。

僕はこれに気づかずLambda単体でのテストでエラーが出まくって軽くハマったので皆さんは気をつけましょう！

```Python:lambda_functioin.py
import boto3
import random
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb')
myoji_table = dynamodb.Table('myoji')
secondname_table = dynamodb.Table('second_name')


def get_myoji_by_id(id):
    response = myoji_table.get_item(
        Key={
            'id': id
        }
    )

    try:
        return response['Item']
    except KeyError:
        return get_myoji_random()


def get_secondname_by_id(id):
    response = secondname_table.get_item(
        Key={
            'id': id
        }
    )
    try:
        return response['Item']
    except KeyError:
        return get_secondname_random()


def get_myoji_count():
    response = myoji_table.scan(Select='COUNT')
    return response['Count']


def get_secondname_count():
    response = secondname_table.scan(Select='COUNT')
    return response['Count']


def get_myoji_random():
    count = get_myoji_count()
    random_id = random.randrange(count)
    response = myoji_table.get_item(
        Key={
            'id': random_id
        }
    )
    return response['Item']


def get_secondname_random():
    count = get_secondname_count()
    random_id = random.randrange(count)
    response = secondname_table.get_item(
        Key={
            'id': random_id
        }
    )
    return response['Item']


def lambda_handler(event, context):
    if event['name_id'] != "":
        name = get_myoji_by_id(int(event['name_id']))
    elif event['name'] != "":

        response = myoji_table.scan(
            FilterExpression=Key('name').eq(event['name'])
        )

        try:
            name = response["Items"][0]
        except (KeyError, IndexError):
            name = {
                "name_read": "",
                "id": 99999,
                "name": event["name"],
                "rank": ""
            }
    else:
        name = get_myoji_random()

    if event['second_id'] != "":
        secondname = get_secondname_by_id(int(event['second_id']))
    else:
        secondname = get_secondname_random()

    result = {
        "name": name,
        "second_name": secondname,

    }

    return result

```

## API Gateway設定

求めたレスポンスを返してくれるLambda関数が作成できたので、次は`APIGateway`にてhttpリクエストをトリガーにLambda関数が実行されるよう設定を行います。また、GETパラメータがLambdaへ渡されるようマッピングも行います。

ここはAWSコンソールでの操作が大半になるので、画像がたくさん貼ってあるわかりやすい記事を参考にすることをおすすめします。
参考：API Gateway + Lambda + DynamoDB https://qiita.com/leomaro7/items/314a80b6d91f9e6b4060


## フロントエンド(Vue.js)

今回はVue.jsの話はしませんが、やっていることは上記で作成したAPIを叩き、その結果をいい感じに表示にしているだけです。その辺のWEBアプリっぽい感じにはなっているのではないでしょうか。
<img width="600px" alt="hanne.gif名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547023/9b2d8a65-7419-366d-341f-d8e6797d75b1.gif">

## 工夫した点

ちょっとした工夫として、結果をシェアする用のリンクはGETパラメータで結果を保持するようになっているので結果をDBなどに保持しておく必要が無く、カジュアルに（？）シェアして楽しめるようになっています。(そのためのAPI設計でした。)
また、**リンクを踏めばカタカナ語の意味を知ることができる**ということを強調することでいい具合にユーザの興味を引けるのではないかと思います。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">†ちょっと尖ったハンネメーカー†で新しい名前を生成しました。<br>今日から私は「アンストッパブル濱本」です！<br><br>アンストッパブルってどんな意味？→<br> <a href="https://t.co/yWttq7Zzj2">https://t.co/yWttq7Zzj2</a> <a href="https://twitter.com/hashtag/%E3%81%A1%E3%82%87%E3%81%A3%E3%81%A8%E5%B0%96%E3%81%A3%E3%81%9F%E3%83%8F%E3%83%B3%E3%83%8D%E3%83%A1%E3%83%BC%E3%82%AB%E3%83%BC?src=hash&amp;ref_src=twsrc%5Etfw">#ちょっと尖ったハンネメーカー</a></p>&mdash; はま (@ybybybmh) <a href="https://twitter.com/ybybybmh/status/1301482016857190400?ref_src=twsrc%5Etfw">September 3, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 雑感
API Gateway + Lambda + DynamoDBという構成でAPIの作成、及びそれを利用したSPAアプリということで、初めての構成でしたがそれなりにいい感じのものができたので良かったです。フロントエンドが少しできるようになるとぱっと見がいい感じになるので嬉しいですね。皆さんもぜひ使ってみて、ツイートなどしていただけるととても喜びます。

ここまでお読みいただきありがとうございました！今後も色々勉強して、なにか楽しいものを作っていければと思います。

# 参考
[AWS料金早見表](https://qiita.com/teradonburi/items/a382a17e1e0245b7d831)
[API Gateway + Lambda + DynamoDB](https://qiita.com/leomaro7/items/314a80b6d91f9e6b4060)
[創作に使えるかもしれない用語集](https://kakkoii-yougosyuu.com/archives/1035333396.html)
[須﨑のホームページ](http://www2s.biglobe.ne.jp/~suzakihp/index40.html)