🦁

AWSを用いてWeb初心者がサーバーレスアプリケーションを作ってみた

2022/11/19に公開約16,500字

AWSを用いてWeb初心者がサーバーレスアプリケーションを作ってみた

少し前まで、AWSは知識だけは知っていても実際に手を動かして何かを製作した事がなかった為、勉強も兼ねてサーバレスwebアプリケーションを製作する事にしました。
他の初心者の方々の参考になりましたら幸いです(拙い部分等あるかと思いますが、その際はご指摘いただけますと幸いです)。

スペック

-非ITの社会人5年目エンジニアです(メーカ職)。
-先月、SAAを取得しました。

アプリの概要

朝の寝ぼけた状態で天気予報を見忘れ、外出先から帰る際に雨が降ってきて傘を持ってきておらず焦る事がたまにあったので、翌日の天気予報をslackに自動通知するアプリを作成しようと思いつきました。情報を自ら確認するのでは無く、向こうから情報がやってくる形にすることで、天気の確認忘れを防ごうという意図です。

なぜサーバーレスにしたのか

AWSでアプリケーションを作るのが初めての為、聞きかじりの知識ではありますが、

  • サーバありと比較して、構築・運用がかなり簡単である点
  • 拡張性に富んでいる点
  • 従量課金制の為、コストが抑えられる点

等が挙げられると思います。特に、3点目の、アプリが継続的に動作する必要がない場合コストが削減できるというのは非常に大きなメリットであると感じました。

主な構成・機能詳細

アーキテクチャ図は、以下の通りです。

主なアーキテクチャの流れは以下の2点です。

  • ブラウザからドメインにアクセスし画面から都道府県名が入力される-> API Gatewayを介してLambdaが走りdynamoDBに入力データ(都道府県名)を格納
  • 設定時刻になるとEventBridgeをトリガーにしてlambdaがdynamoDBから入力データを参照し、該当する都道府県の天気予報をAPIを用いて取得-> 天気予報をslackに通知

各機能の詳細を以下に記載します。

Route 53

今回は、freenomというサービスを使用し、無料でドメインを取得しました(ドメインの取得方法等に関しては、割愛します)。
Route 53を使用しドメイン設定を行い、取得したドメインでS3に配置したhtmlにアクセスできるようにしました。

dynamoDB

作成したテーブルは以下の2つです。

  • 「places」テーブル
  • 「sequences」テーブル

「places」というテーブルで、選択された都道府県の名前を格納するテーブルを作成しました。テーブルの詳細は以下の様になっています。

属性名 意味
post_id String プライマリーキー
places String 都道府県名

また、「sequences」テーブルでは、DynamoDBに自動的に連番を採番する仕組みが存在しない代わりに、連番を管理しています。
テーブルの詳細は以下の様になっています。

属性名 意味
name String 対象テーブルの名前
current_number Number 現在のシーケンスの値

lambda ⓵

このlambdaの役割は、以下の通りです。

  • 画面から入力されたデータを、dynamoDBに格納する

IAMロールで、許可ポリシーに「AmazonDynamoDBFullAccess」、「AWSLambdaBasicExecutionRole」を入れ、lambdaに付与した上で、実行する関数を以下のコードにします。

lambda_function_1.py
import json
import os
import boto3

TableName = os.environ['TableName']
dynamodb = boto3.resource("dynamodb", region_name='ap-northeast-1')


def next_id(table_name, num):

    table = dynamodb.Table("sequences")
    data = table.update_item(
        Key={
            'name': table_name
        },
        UpdateExpression='ADD current_number :incr',
        ExpressionAttributeValues={
            ':incr': num
        },
        ReturnValues="UPDATED_NEW "
    )
    return data['Attributes']['current_number']


def getItem(post_id):

    table = dynamodb.Table(TableName)

    response = table.get_item(
        Key={
            'post_id': post_id
        }
    )

    if "Item" in response:
        item = response['Item']
    else:
        # 存在しないプライマリーキーで取得する場合、メタデータが返される
        item = {}
        item['prefecture'] = None

    return item


def getLatestID():

    table = dynamodb.Table("sequences")
    response = table.get_item(
        Key={
            'name': TableName
        }
    )
    if "Item" in response:
        id = response['Item']['current_number']
    else:
        id = ''
    return id


def getPlaces(event):

    # 1) 記事IDの取得
    post_id = event['queryStringParameters']['post_id']
    print(post_id)

    # 最新の記事IDを取得
    latest_id = getLatestID()

    # 2) 記事IDの判定 + 記事の詳細を取得
    if post_id == 'first':
        new_post_id = str(latest_id)
        item = getItem(new_post_id)
    elif post_id == 'second':
        new_post_id = str(latest_id - 1)
        item = getItem(new_post_id)
    elif post_id == 'third':
        new_post_id = str(latest_id - 2)
        item = getItem(new_post_id)
    else:
        item = getItem(post_id)

    return item


def putPlaces(event):

    body = json.loads(event['body']) 
    prefecture = body['prefecture'] 

    try:
        # 1) オートインクリメントで新しい記事IDを取得
        post_id = str(next_id(TableName, 1))

        dynamo = boto3.client('dynamodb', region_name='ap-northeast-1')

        # 2) 取得した記事IDでアイテムを追加
        response = dynamo.put_item(
            TableName=TableName,
            Item={
                "post_id": {"S": post_id},
                "prefecture": {"S": prefecture}, 
            }
        )
        return response
    except Exception as e:
        print(e)
        return 0


def lambda_handler(event, context):

    # 1) HTTPメソッドの判定
    httpMethod = event['httpMethod']
    print(httpMethod)

    if(httpMethod == 'GET'):
        result = getPlaces(event)
    elif(httpMethod == 'POST'):
        result = putPlaces(event)

    return {
        'statusCode': 200,
        'body': json.dumps(result, ensure_ascii=False),
        'isBase64Encoded': False,
        'headers': {
            "Access-Control-Allow-Origin": '*'
        }
    }

コードの一部の大まかな詳細を以下に記載します。

  • 以下の部分で、入力画面から取得した都道府県データをdynamodbの「places」テーブルに格納しています。
    messageの'body'に格納されているJSONデータをloadして取り出した上で、テーブルの形にデータを整え、格納するという流れです。
def putPlaces(event):

    body = json.loads(event['body']) 
    prefecture = body['prefecture'] 

    try:
        # 1) オートインクリメントで新しい記事IDを取得
        post_id = str(next_id(TableName, 1))

        dynamo = boto3.client('dynamodb', region_name='ap-northeast-1')

        # 2) 取得した記事IDでアイテムを追加
        response = dynamo.put_item(
            TableName=TableName,
            Item={
                "post_id": {"S": post_id},
                "prefecture": {"S": prefecture}, 
            }
        )
        return response
    except Exception as e:
        print(e)
        return 0

lambda ⓶

このlambdaの役割は、以下の通りです。

  • 設定時刻にEventBridgeをトリガーにして、dynamoDBの格納データを参照して都道府県名を取得、天気予報APIから天気情報を取得後slackに通知する

IAMロールで、許可ポリシーに「AmazonDynamoDBReadOnlyAccess」を入れ、lambdaに付与しておきます。
今回使用している天気予報APIは、2021年に利用可能となった気象庁の天気予報情報で、JSON形式で取得しています。

lambda_function_2.py
import urllib3
import os
import boto3
import requests
import json

http = urllib3.PoolManager()

dynamodb = boto3.resource("dynamodb", region_name='ap-northeast-1')

table = boto3.resource('dynamodb').Table('places')
response = table.scan(Limit=3, ReturnConsumedCapacity='TOTAL')




# エリアコード
area_dic = {'北海道/釧路':'014100',
            '北海道/旭川':'012000',
            '北海道/札幌':'016000',
            '青森県':'020000',
            '岩手県':'030000',
            '宮城県':'040000',
            '秋田県':'050000',
            '山形県':'060000',
            '福島県':'070000',
            '茨城県':'080000',
            '栃木県':'090000',
            '群馬県':'100000',
            '埼玉県':'110000',
            '千葉県':'120000',
            '東京都':'130000',
            '神奈川県':'140000',
            '新潟県':'150000',
            '富山県':'160000',
            '石川県':'170000',
            '福井県':'180000',
            '山梨県':'190000',
            '長野県':'200000',
            '岐阜県':'210000',
            '静岡県':'220000',
            '愛知県':'230000',
            '三重県':'240000',
            '滋賀県':'250000',
            '京都府':'260000',
            '大阪府':'270000',
            '兵庫県':'280000',
            '奈良県':'290000',
            '和歌山県':'300000',
            '鳥取県':'310000',
            '島根県':'320000',
            '岡山県':'330000',
            '広島県':'340000',
            '山口県':'350000',
            '徳島県':'360000',
            '香川県':'370000',
            '愛媛県':'380000',
            '高知県':'390000',
            '福岡県':'400000',
            '佐賀県':'410000',
            '長崎県':'420000',
            '熊本県':'430000',
            '大分県':'440000',
            '宮崎県':'450000',
            '鹿児島県':'460100',
            '沖縄県/那覇':'471000',
            '沖縄県/石垣':'474000'
            }




def lambda_handler(event, context):
    url = "slack着信用webhookURL"
    items = response['Items']
    for item in items:
        place = item['prefecture']
        place_id = area_dic[place]
        
        jma_url = f'https://www.jma.go.jp/bosai/forecast/data/forecast/{place_id}.json'
        jma_json = requests.get(jma_url).json()
        jma_weather = jma_json[0]["timeSeries"][0]["areas"][0]["weathers"][1]

        if '雨' in jma_weather:
            msg = {
                "channel": "#general",
                "username": "user_name",
                "text": '明日、{}の天気は{}です。傘が必要です:umbrella:!。'.format(place, jma_weather),
                "icon_emoji": ""
        }
        else:
            msg = {
                "channel": "#general",
                "username": "user_name",
                "text": '明日、{}の天気は{}です。傘は必要ないです!:smile:'.format(place, jma_weather),
                "icon_emoji": ""
        }
            

        encoded_msg = json.dumps(msg).encode('utf-8')
        resp = http.request('POST', url, body=encoded_msg)
        print({
            "message": "Hello From Lambda", 
            "status_code": resp.status, 
            "response": resp.data
        })

コードの大まかな詳細を以下に記載します。

  • scanを用いて、dynamoDBの格納データを参照します。今回は、最大3つの都道府県の天気予報を通知できるようにしたかった為、Limit=3としています。
response = table.scan(Limit=3, ReturnConsumedCapacity='TOTAL')
  • slackに通知する際のコードです。予報に「雨」と含まれていた場合とそれ以外の場合で表示するメッセージを異なるものにしています。また、一目で分かりやすくするために、雨だった場合メッセージに傘の絵文字をつけています。また、今回はslackにデフォルトで存在する「general」というチャンネルにメッセージを送信するように設定しました。
def lambda_handler(event, context):
    
    
    items = response['Items']
    for item in items:
        place = item['prefecture']
        place_id = area_dic[place]
        
        jma_url = 'slack着信用webhookURL'
        jma_json = requests.get(jma_url).json()
        jma_weather = jma_json[0]["timeSeries"][0]["areas"][0]["weathers"][1]

        if '雨' in jma_weather:
            msg = {
                "channel": "#general",
                "username": "user_name",
                "text": '明日、{}の天気は{}です。傘が必要です:umbrella:!。'.format(place, jma_weather),
                "icon_emoji": ""
        }
        else:
            msg = {
                "channel": "#general",
                "username": "user_name",
                "text": '明日、{}の天気は{}です。傘は必要ないです!:smile:'.format(place, jma_weather),
                "icon_emoji": ""
        }

S3

バケット名を設定するドメイン名と同じにした状態にして、以下の構成でファイルをアップロードします。そして、S3バケットのプロパティから、静的ウェブサイトホスティングを「有効化」します。

freenomで取得したドメイン
├── index.html
└── css
     └── training-dev-weather-to-slack.css

Cloudfront

cloudfront内にOAI(オリジンアクセスアイデンティティ)と呼ばれる、cloudfront経由でしかアクセスできない様に制限するための機能です。今回はアクセスが集中しないのでそこまで必要ないかもしれませんが、勉強も兼ねて入れました。

EventBridge

lambda②のコンソールから「トリガーを追加」をクリックして、EventBridgeを追加します。

今回は、平日午後10時に通知が届くように設定したいので、
cron式で、以下の様にイベントを設定します(UTCである点に注意します)。

入力画面

入力画面では、都道府県名をタブの中から選び、決定ボタンを押します。選べる都道府県は、天気予報APIにある都道府県名に合わせてあります(なので北海道と沖縄がそれぞれ3つと2つの地域に分かれています)。
html, cssを扱うのも今回がほぼ初めてでしたので、デザイン性は追求せずに最低限の機能が使えるようにしました(Webデザインは難しいなと痛感しました)。
画面を小さくする(拡大する)

以下、htmlのコードです。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>翌日天気slack通知アプリ</title>
    <link href="/css/training-dev-weather-to-slack.css" rel="stylesheet">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
    <script>
        $(function() {
        $('#btnSend').on('click', function() {
            const prefecture = document.getElementById("pref").value;
	        const prefe = document.getElementById("myId_2");
            const myp = document.getElementById("myId");
            const apiUrl = "API gatewayのエンドポイント";
            const data = {
                'prefecture': prefecture,
            };

            prefe.innerHTML = '「' + prefecture + '」' + 'を選択しました!';
            myp.innerHTML = "入力が完了しました!";


            $.ajax({
                    url:apiUrl, //送信先のURL
                    type: 'POST',
                    dataType: 'json',
                    data: JSON.stringify(data)
                })

                .done(function(response, textStatus, jqXHR) {
                    alert('成功しました');
                })
                .fail(function(jqXHR, textStatus, errorThrown) {
                    alert('エラーです');
                })

        });
        });   
    </script>
</head>

<body>
    <header>
        <h1 class="headline">
            <a>翌日天気slack通知アプリ</a>
        </h1>
    </header>
    
    <p id="myId">都道府県を選択してください</p>
    <p id="myId_2">選択した都道府県がここに表示されます</p>

    <form action="#" method="post" name="form1">
    <p>
      <select name="prefecture" id="pref">
          <option value="北海道/釧路">北海道/釧路</option>
          <option value="北海道/旭川">北海道/旭川</option>
          <option value="北海道/札幌">北海道/札幌</option>
          <option value="青森県">青森県</option>
          <option value="岩手県">岩手県</option>
          <option value="宮城県">宮城県</option>
          <option value="秋田県">秋田県</option>
          <option value="山形県">山形県</option>
          <option value="福島県">福島県</option>
          <option value="茨城県">茨城県</option>
          <option value="栃木県">栃木県</option>
          <option value="群馬県">群馬県</option>
          <option value="埼玉県">埼玉県</option>
          <option value="千葉県">千葉県</option>
          <option value="東京都">東京都</option>
          <option value="神奈川県">神奈川県</option>
          <option value="新潟県">新潟県</option>
          <option value="富山県">富山県</option>
          <option value="石川県">石川県</option>
          <option value="福井県">福井県</option>
          <option value="山梨県">山梨県</option>
          <option value="長野県">長野県</option>
          <option value="岐阜県">岐阜県</option>
          <option value="静岡県">静岡県</option>
          <option value="愛知県">愛知県</option>
          <option value="三重県">三重県</option>
          <option value="滋賀県">滋賀県</option>
          <option value="京都府">京都府</option>
          <option value="大阪府">大阪府</option>
          <option value="兵庫県">兵庫県</option>
          <option value="奈良県">奈良県</option>
          <option value="和歌山県">和歌山県</option>
          <option value="鳥取県">鳥取県</option>
          <option value="島根県">島根県</option>
          <option value="岡山県">岡山県</option>
          <option value="広島県">広島県</option>
          <option value="山口県">山口県</option>
          <option value="徳島県">徳島県</option>
          <option value="香川県">香川県</option>
          <option value="愛媛県">愛媛県</option>
          <option value="高知県">高知県</option>
          <option value="福岡県">福岡県</option>
          <option value="佐賀県">佐賀県</option>
          <option value="長崎県">長崎県</option>
          <option value="熊本県">熊本県</option>
          <option value="大分県">大分県</option>
          <option value="宮崎県">宮崎県</option>
          <option value="鹿児島県">鹿児島県</option>
          <option value="沖縄県/那覇">沖縄県/那覇</option>
          <option value="沖縄県/石垣">沖縄県/石垣</option>
        </select>
    </p>
    <button type="button" id='btnSend'>決定</button>
    </form>  
</body>
</html>

コードの一部の大まかな詳細を以下に記載します。

  • 今回少々時間を費やしたWebフォームの入力内容をAjaxで通信する部分ですが、特に以下の「apiUrl」で定義されている、API gatewayのエンドポイントをどのように設定するのか少し苦戦しました。具体的には、以下のApi GatewayのステージエディタのURLに、さらにテーブル名である「/places」を後ろにつけて初めて送信に成功しました。(探し方が足りなかったかもしれませんがこのあたりがあまりググっても出てこなかった為、備忘録として記載してます)。
$(function() {
        $('#btnSend').on('click', function() {
            const prefecture = document.getElementById("pref").value;
	        const prefe = document.getElementById("myId_2");
            const myp = document.getElementById("myId");
            const apiUrl = "API gatewayのエンドポイント";
            const data = {
                'prefecture': prefecture,
            };

            prefe.innerHTML = '「' + prefecture + '」' + 'を選択しました!';
            myp.innerHTML = "入力が完了しました!";


            $.ajax({
                    url:apiUrl, //送信先のURL
                    type: 'POST',
                    dataType: 'json',
                    data: JSON.stringify(data)
                })

                .done(function(response, textStatus, jqXHR) {
                    alert('成功しました');
                })
                .fail(function(jqXHR, textStatus, errorThrown) {
                    alert('エラーです');
                })

        });
        });  

実際に動かしてみる

入力画面のタブから試しに都道府県を東京都に設定し、「決定」ボタンを押します。
「入力が完了しました!」と表示され、設定された都道府県の確認がされます。
画面を小さくする

午後10時になるまで待ちます。
無事、午後10時にslackに通知が届きました。

試しに都道府県名の入力を3つにするため、新たに「北海道/釧路」と「沖縄県/那覇」を入力してみます。

こちらも無事に3つの都道府県の天気予報情報がslackに届きました(testで行った為時間帯は午後10時と異なります)。

個人的に便利なので暫く使用していきたいと思います。

改善点

  • ログイン機能を入れる
  • 入力した都道府県の参照
  • 市町村まで選択できる(天気予報APIでは可能)
  • 入力画面のデザイン性の改善

等、色々あると思われます。今回は勉強も兼ねて作成してみましたが、より力が付いたら再チャレンジしてみたいです。

参考資料

以下のudemy講座はこのアプリケーションを作成する上で大変役に立ちました。
https://www.udemy.com/course/aws-beginner-lecture-and-handson/

天気予報のAPI取得方法に関しては、以下のサイトを参考にさせて頂きました。
https://www.gis-py.com/entry/weather-json

最後まで読んで頂いた皆さん、ありがとうございました。

Discussion

ログインするとコメントできます