QLITRE DIALY

AWS Lambda上でHonoを動かしてみる

2024年03月20日

先日AWSクラウドプラクティショナー試験に合格したこともあり、AWSを使って何かをやってみたいと思った。

そこでこのブログでも採用しているHonoをAWS Lambdaに設置して、動作をさせてみる。

今回やること

  • LambdaでHonoを動かす
  • LineBotと連携
  • 体重を受け取り前日との体重比を返す

まずはLambda上でHonoを動かすということを検証する。

その後、LineBotからイベントを受け取り、オウム返しするBotを作成する。

最終的にはLineBotから体重入力し、Xアカウントで毎日行っているような体重ツイート文字列を返す、ということを行っていく。

このように毎日体重を測定後に前日比とハッシュタグをつけてツイートしている。

ハッシュタグをクリック、前日比を頭の中で計算、投稿、と意外とやることが多く、ある程度の自動化をしてみたいと思った。

LambdaでHonoを動かす

まずはLambda上でHonoを動かしてみる。

基本的にHonoの公式チュートリアルに沿って行う。

https://hono.dev/getting-started/aws-lambda

mkdir my-app
cd my-app
cdk init app -l typescript
npm i hono
mkdir lambda

lambda/index.tsを作成する。

import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export const handler = handle(app)

lib/my-app-stack.tsを以下のようにする

import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as apigw from 'aws-cdk-lib/aws-apigateway'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'

export class MyAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const fn = new NodejsFunction(this, 'lambda', {
      entry: 'lambda/index.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_20_X,
    })
    fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    })
    new apigw.LambdaRestApi(this, 'myapi', {
      handler: fn,
    })
  }
}

デプロイする。Dockerエンジンがかかっていない怒られる。

cdk deploy

AWSのマネージメントコンソールからLambdaに移動し、関数URLを確認し、アクセスをする。

ブラウザにHello Hono!と出ていればオッケー。

LineBotと連携

続いてLineBotと連携し、オウム返しbotを作成する。

参考にした記事:

Cloudflare Worker + D1 + Hono + OpenAIでLINE Botを作る(YuheiNakasakaさん)

Line Developersにサインインをする。

チャネルをMessaging APIを始めよう | LINE Developersを参照して作成する。

Messaging APIを選択

適当な情報を入れてbotを作成する。

Messaging API設定タブに移動。

WebhookURLは以下のように先ほどのLambdaURLに/api/webhookを加えたものにする。

https://hogehogehogehogehoge.lambda-url.ap-northeast-1.on.aws/api/webhook

Webhookの利用をオン、あいさつメッセージ、応答メッセージはオフにする。

チャネルアクセストークンを発行し、コピーする。

AWSに移動し、Lambda関数の環境変数に設定する。

キーはCHANNEL_ACCESS_TOKENとする。

プロジェクトを編集する。

linebotのsdkをインストール。

npm install -D @line/bot-sdk

lambda/index.tsを以下のようにして再度デプロイする。

import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'
import {
    MessageAPIResponseBase,
    TextMessage,
    WebhookEvent,
} from "@line/bot-sdk";

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

app.post("/api/webhook", async (c) => {
    const data = await c.req.json();
    const events: WebhookEvent[] = (data as any).events;
    const accessToken: string = process.env.CHANNEL_ACCESS_TOKEN || '';

    await Promise.all(
        events.map(async (event: WebhookEvent) => {
            try {
                await textEventHandler(event, accessToken);
                return
            } catch (err: unknown) {
                if (err instanceof Error) {
                    console.error(err);
                }
                return c.json({
                    status: "error",
                });
            }
        })
    );
    return c.json({ message: "ok" });
});

const textEventHandler = async (
    event: WebhookEvent,
    accessToken: string
): Promise<MessageAPIResponseBase | undefined> => {
    if (event.type !== "message" || event.message.type !== "text") {
        return;
    }

    const { replyToken } = event;
    const { text } = event.message;
    const response: TextMessage = {
        type: "text",
        text,
    };
    await fetch("https://api.line.me/v2/bot/message/reply", {
        body: JSON.stringify({
            replyToken: replyToken,
            messages: [response],
        }),
        method: "POST",
        headers: {
            Authorization: `Bearer ${accessToken}`,
            "Content-Type": "application/json",
        },
    });
    return
};

export const handler = handle(app)

ボットにメッセージを送ってみる。

以下のようにオウム返しされればオッケー。

でかい穴は~の一節は柴田聡子さんのSide Stepより。

前日との体重比を返す

最後に体重の前日比の計算の自動化について考えてみる。

要件としては前日に61.7kgで、LINEから62.0みたいな体重の数値を受け取った時に以下のような文字列を返すこととする。

62.0kg(+0.3)

まぁ要件なんて偉そうな言葉を使ったが、要は簡単な引き算をして返すだけの処理に等しい。

Lambda関数上では値の保存はできないので、前日の体重をひとまずどこかに保存しておく必要がある。

今回はDynamoDBを使ってみることにする。

DynamoDBとはキーバリュー型でデータを保存できるNo SQLタイプのデータベース。

詳しいことは分からないのだが、25GBまでは無料枠がある。複雑な計算を必要としない今回のような要件で使える。

まずはAWSのマネジメントコンソールからDynamoDBへアクセスしテーブルを作成する。

名前は何でもいいがMyWeightとしてみる。パーティションキーと呼ばれるプライマリキーを設定する必要がある。

これも特にこだわりがないが、LINEメッセージから取得できるUserIdとする。

他は全てデフォルトでテーブルの作成をする。

以下の三つの関数を追加するイメージ。

  1. DynamoDBから最新の値の取り出し
  2. DynamoDBへの値の保存
  3. 体重比を計算して文字列を生成する

まずはDynamoDBを操作するsdkをインストールする。

npm install aws-sdk

lambda/index.tsに処理を追加する。

import { DynamoDB } from 'aws-sdk';

...
const dynamoDb = new DynamoDB.DocumentClient();


// 最新の体重を取得
const getLatestWeightFromDynamoDB = async (userId: string) => {
    const params = {
        TableName: 'MyWeight',
        KeyConditionExpression: 'UserId = :userId',
        ExpressionAttributeValues: {
            ':userId': userId
        },
        ScanIndexForward: false,
        Limit: 1
    }
    const data = await dynamoDb.query(params).promise();
    return data.Items?.[0]?.Weight;
}

// 体重を登録
const saveWeightToDynamoDB = async (userId: string, weight: number) => {
    const params = {
        TableName: 'MyWeight',
        Item: {
            UserId: userId,
            Weight: weight,
            CreatedAt: new Date().toISOString()
        }
    }
    await dynamoDb.put(params).promise();
}

// 体重を比較して文字列を返す
const compareWeight = (latestWeight: number | undefined, curWeight: number) => {
    // 初回は比較できないので±0で返す
    if (!latestWeight) return `${curWeight}kg(±0)`;
    const diff = curWeight - latestWeight;
    const diffStr = diff.toFixed(1);
    let msg = `${curWeight}kg`
    if (diff > 0) {
        msg += `(+${diffStr})`
    } else if (diff < 0) {
        msg += `(${diffStr})`
    } else {
        msg += `(±0)`
    }
    return msg
}

textEventHandlerにも処理を追加して、全体としては以下のようになる。

import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'
import { DynamoDB } from 'aws-sdk';

import {
    MessageAPIResponseBase,
    TextMessage,
    WebhookEvent,
} from "@line/bot-sdk";

const app = new Hono()
const dynamoDb = new DynamoDB.DocumentClient();

app.get('/', (c) => c.text('Hello Hono!'))

app.post("/api/webhook", async (c) => {
    const data = await c.req.json();
    const events: WebhookEvent[] = (data as any).events;
    const accessToken: string = process.env.CHANNEL_ACCESS_TOKEN || '';

    await Promise.all(
        events.map(async (event: WebhookEvent) => {
            try {
                await textEventHandler(event, accessToken);
                return
            } catch (err: unknown) {
                if (err instanceof Error) {
                    console.error(err);
                }
                return c.json({
                    status: "error",
                });
            }
        })
    );
    return c.json({ message: "ok" });
});

// 最新の体重を取得
const getLatestWeightFromDynamoDB = async (userId: string) => {
    const params = {
        TableName: 'MyWeight',
        KeyConditionExpression: 'UserId = :userId',
        ExpressionAttributeValues: {
            ':userId': userId
        },
        ScanIndexForward: false,
        Limit: 1
    }
    const data = await dynamoDb.query(params).promise();
    return data.Items?.[0]?.Weight;
}

// 体重を登録
const saveWeightToDynamoDB = async (userId: string, weight: number) => {
    const params = {
        TableName: 'MyWeight',
        Item: {
            UserId: userId,
            Weight: weight,
            CreatedAt: new Date().toISOString()
        }
    }
    await dynamoDb.put(params).promise();
}

// 体重を比較して文字列を返す
const buildLineMessage = (latestWeight: number | undefined, curWeight: number) => {
    // 初回は比較できないので±0で返す
    if (!latestWeight) return `${curWeight}kg(±0)`;
    const diff = curWeight - latestWeight;
    const diffStr = diff.toFixed(1);
    let msg = `${curWeight}kg`
    if (diff > 0) {
        msg += `(+${diffStr})`
    } else if (diff < 0) {
        msg += `(${diffStr})`
    } else {
        msg += `(±0)`
    }
    return msg
}

const textEventHandler = async (
    event: WebhookEvent,
    accessToken: string
): Promise<MessageAPIResponseBase | undefined> => {
    if (event.type !== "message" || event.message.type !== "text") {
        return;
    }
    const userId = event.source.userId;
    const curWeight = parseFloat(event.message.text); // 体重データのパース
    let lineMessage = '';
    if (!isNaN(curWeight) && userId) {
        // 最後の値を取得
        const latestWeight = await getLatestWeightFromDynamoDB(userId);
        // メッセージを作成
        lineMessage = buildLineMessage(latestWeight, curWeight);
        // 現在の値を保存
        await saveWeightToDynamoDB(userId, curWeight);
    } else {
        // 数値にできなかったケース
        lineMessage = '不正な値が入力されました。数値を入力してください。'
    }

    const { replyToken } = event;
    const response: TextMessage = {
        type: "text",
        text: lineMessage,
    };
    await fetch("https://api.line.me/v2/bot/message/reply", {
        body: JSON.stringify({
            replyToken: replyToken,
            messages: [response],
        }),
        method: "POST",
        headers: {
            Authorization: `Bearer ${accessToken}`,
            "Content-Type": "application/json",
        },
    });
    return
};

export const handler = handle(app)

ここでデプロイ…とする前に、Lambda関数に対してDynamoDBを操作する権限、IAMロールを与える必要がある。

デプロイ後にマネジメントコンソールからIAMロールを与えてもよいが、ソースコードで明示的に権限を与えたうえでデプロイする方式を取る。

lib/my-app-stack.tsを編集し、特定のテーブル(今回はMyWeight)のdynamodb:GetItem,dynamodb:PutItem,dynamodb:Queryの3つの権限のみ追加する。

import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as apigw from 'aws-cdk-lib/aws-apigateway'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import * as iam from 'aws-cdk-lib/aws-iam';

export class MyAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)
    // Lambda関数用のIAMロールを作成
    const lambdaRole = new iam.Role(this, 'MyLambdaExecutionRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
    });

    // DynamoDBテーブルへのアクセス権限を持つカスタムポリシーを作成
    const policy = new iam.Policy(this, 'MyLambdaPolicy', {
      statements: [
        new iam.PolicyStatement({
          actions: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:Query'],
          resources: [`arn:aws:dynamodb:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:table/MyWeight`],
        }),
      ],
    });

    // ロールにポリシーをアタッチ
    lambdaRole.attachInlinePolicy(policy);

    // Lambda基本実行ロールをアタッチ
    lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));

    const fn = new NodejsFunction(this, 'lambda', {
      entry: 'lambda/index.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_20_X,
      role: lambdaRole,
    })

    fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    })
    
    new apigw.LambdaRestApi(this, 'myapi', {
      handler: fn,
    })
  }
}

※権限を与えて再デプロイすると環境変数が初期化される現象があったため再確認をする。

うまくいってると、このように直前の体重と比較して返してくれる。

おわりに

以上、honoのAWS Lambda上での動作検証だった。

CloudFlareと同様にAWS Lambdaでもシンプルな記述ができたのでよかった。

LINE BotやDynamoDBとの連携も比較的すっきりと書けたので引き出しが広がった気もする。

今回のコードは下記リポジトリに置いている。

https://github.com/qlitre/hono-awslambda-linebot-dynamodb

また、実運用ではAPIを使ってXへの投稿も自動化している。

https://github.com/qlitre/kuritterweight-helper

ではでは。