3
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-09-03

作ったもの

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

hanne.gif名

目的

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

使用技術など

簡単な構成図は以下のようになります。
AWS.png

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

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

多分かなり安い

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

仕様

DB設計

DynamoDB上に作成するテーブルは以下の2つです。
カタカナ語は創作に使えるかもしれない用語集様、名字は須﨑のホームページ様からスクレイピングさせていただきました。

  • 名字テーブル(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でなく文字列で指定することもできる。
  • レスポンス例

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

{
  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に対応するデータが帰ってくる。

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

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

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

実装

スクレイピング

前述のとおり今回用いたデータについて、カタカナ語は創作に使えるかもしれない用語集様、名字は須﨑のホームページからスクレイピングさせていただきました。
今回BeautifulSoup(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が取得できました。

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" },
      :
]
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リソースにアクセスすることができます。

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

テーブルの作成もPython(boto3)から行うこともできますが、今回はAWSコンソールにて予めテーブルの作成を行っておきます。テーブル名とプライマリーキーを指定するだけです。画像は名字テーブルですが、同様の設定でカタカナ語テーブルも作成します。
コメント 2020-09-03 181449.png

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

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

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単体でのテストでエラーが出まくって軽くハマったので皆さんは気をつけましょう!

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アプリっぽい感じにはなっているのではないでしょうか。
hanne.gif名

工夫した点

ちょっとした工夫として、結果をシェアする用のリンクはGETパラメータで結果を保持するようになっているので結果をDBなどに保持しておく必要が無く、カジュアルに(?)シェアして楽しめるようになっています。(そのためのAPI設計でした。)
また、リンクを踏めばカタカナ語の意味を知ることができるということを強調することでいい具合にユーザの興味を引けるのではないかと思います。

雑感

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

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

参考

AWS料金早見表
API Gateway + Lambda + DynamoDB
創作に使えるかもしれない用語集
須﨑のホームページ

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2