🐕

自炊記録 LINE アプリを作ってみた。

2022/05/08に公開

はじめに

LINE に画像を送信して、自炊記録できる LINE アプリを作ってみました。
アプリについては、去年の 2021年6月 に「【祝】LINE DCイベント100回記念☆大・大LT大会!!【初登壇も大歓迎◎】」の LT で紹介させていただきました。資料はこちら

随分時間が経ってしまいましたが、本記事では、LT で説明しきれなかった技術的な部分について、記載します。

作ったもの

技術的な話に入る前に、どのようなアプリなのか少しだけ紹介させてください。
作ったきっかけと主な機能は、以下のスライドの通りです。

ネットやアプリでは、様々な料理レシピが溢れかえっており、この間参考にしたレシピなんだっけ?という体験を解決したく作成しました。

以下から友達登録できるので、興味ある方は実際に触ってみてください。

LINE アプリについて

LINE Developersから、LINEアプリケーション開発のためのAPIやサービスが提供されており、誰でも簡単に公式アカウントを作成し、LINE アプリを開発することができます。

今回の LINE アプリは、大きく分けて LINE Front-end Framework(以下、LIFF) 及び Messaging API の二つで構成されています。
これらについては LINE アプリ開発においては欠かせない機能なため、以下で簡単に触れておきます。

LIFF

LINE Front-end Framework(LIFF)は、LINEが提供するウェブアプリのプラットフォームです。このプラットフォームで動作するウェブアプリを、LIFFアプリと呼びます。
https://developers.line.biz/ja/docs/liff/overview/

つまり、自身で作成したウェブアプリのエンドポイントを提供すれば、 LINE アプリ上で動作させることができます。
また、LIFF アプリでは、LIFF SDK が提供されており、SDKを使用すれば、以下のことが可能になります。

  • アプリ上でLINEプロフィール情報の取得
  • ウェブ画面からトーク画面にメッセージを送信
  • LINEのQRコードリーダーの使用など
  • etc ...

「LINEのユーザー情報を活用したアプリケーション」や、「チャットボットのUIでは対応が困難(メッセージでのやり取りではユーザーのUXが悪い)」の場合に大きく効果を発揮することができます。

Playground も提供されているため、以下から実際の挙動を確認することができます。
https://liff-playground.netlify.app/

Messaging API

Messaging APIは、あなたのサービスとLINEユーザーの双方向コミュニケーションを可能にする機能です。
https://developers.line.biz/ja/services/messaging-api/

Messaging API は、LINE Bot の自動応答を実現するための欠かせない機能です。
例えば、ユーザーのメッセージ送信に対して、応答メッセージを送信したり、任意のタイミングでプッシュメッセージを送信できます。

単純なテキストメッセージに加え、スタンプ、画像、動画などの様々なメッセージタイプにも対応しています。
https://developers.line.biz/ja/docs/messaging-api/overview/#what-you-can-do

Messaging API はボットサーバ側に実装します。
ボットサーバとは、Webhook イベント の送信先として指定したエンドポイントを指します。

LINE のメッセージ送信や、友達追加などのイベントを発生すると、LINEプラットフォームからWebhook URL にHTTPS POSTリクエストが送信されます。
この Webhook を契機にボットサーバ側で Messaging API を使用すれば、双方向のやりとりが可能になるわけです。

アーキテクチャ

さて、ここからが本題です。
本アプリは、AWS のフルサーバレス構成で実現しています。
サーバレス構成にしたのは、ランニングコストを安くしたかったのが、一番の理由です。
全く利用されない月で、数十円で抑えられています。


アーキテクチャ

以降から、各構成のポイントについて苦労した点も交えながら紹介していきます。

自動応答(LINE Bot)の構成

アーキテクチャ上段の API Gateway, Lambda, SQS, Lambda の部分になります。

各AWSサービスの役割について簡単に記載します。
(AWS サービス自体の説明は割愛します。)

  • API Gateway
    • POST の RESTAPI を作成し、Webhook イベントの受信先として使用
    • リクエスト受信後は、Webhook 情報をパラメータに後続の Lambda をキック
    • API Gateway のリクエストバリデータ(※1)でx-line-signature のヘッダーがあるリクエストのみ受け付けるように設定
  • Lambda(SQS queuing)
    • Webhook に対してステータスコード 200 のレスポンスを返す
    • Webhook のイベント情報を SQS にキューイングする
  • SQS
    • メッセージの重複や、順序を厳密に管理する必要はないため、FIFO キューではなく、少なくとも一回の配信を保証している標準キューに設定
    • メッセージ保持期間は、最小の 1分に設定(デフォルトは4日間)
  • Lambda (Bot のロジック実装)
    • Webhook のイベントに対して、メッセージ返信処理のロジックを実装
    • line-bot-sdk-python を使用

ポイント①: 非同期構成

Webhook イベントの受信とボットメッセージへの返信 Lambda の間に SQS 挟むことによって、非同期構成にしています。

公式ドキュメントにも非同期構成を推奨することが記載されています。
https://developers.line.biz/ja/docs/messaging-api/receiving-messages/

ポイント②:SQS のメッセージ保持

Lambda が SQS のメッセージを処理する際は、以下の挙動になります。

関数が正常にバッチを処理すると、Lambda はそのメッセージをキューから削除します。デフォルトで、関数がバッチを処理しているときにエラーが発生すると、そのバッチ内のすべてのメッセージが再びキューに表示されます。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-sqs.html

関数が処理に失敗した場合は、キューにメッセージが残り続けることになります。
LINE Bot においては、即座のレスポンス以外は意味を持たないため、キューにメッセージが残り続けることは避けたいです。

上記を回避する方法として、以下の二つがあります。

  1. SQS にデットレターキューを設定する

リドライブポリシーは、ソースキューとデッドレターキューを指定し、さらにソースキューのコンシューマが一定回数でメッセージ処理に失敗した場合、Amazon SQS がメッセージをソースキューからデッドレターキューへ移動する条件を指定します。
https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html

デットレターキューを設定すると、処理に失敗したメッセージをキューからデッドレターキューに移動してくれます。こうすることで、キューに、メッセージが残留することを防ぎます。
何を持って失敗と判定するかの条件を、リトライポリシーで設定します。例えば、再試行回数が 2 回以上などです。

  1. Lambda を正常終了させる

Lambda のプログラム側で全ての例外を握り潰すやり方です。
正常終了として返すことで、処理に失敗しても、SQS からメッセージから削除されます。
1. の方法では、失敗したメッセージをキューに移動してくれるため、失敗したメッセージのデバッグやメッセージの再送(デッドレターキュー→キュー)ができます。
本アプリでの LINEメッセージは、失敗したメッセージを再送する必要もなく、デバッグはエラーログで十分なため、2. の方法を採用しています。

ポイント③:コールドスタート

Lambda のコールドスタート問題はよく聞きますが、今回の非同期構成の場合、キューイング Lambda と Bot 側の Lambda で二つのコールドスタートの影響を受けます。

コールドスタートは、Provisioned Concurrency を設定することで回避できます。

プロビジョニング済み同時実行 – プロビジョニング済み同時実行は、リクエストされた数の実行環境を初期化して、関数の呼び出しに即座に応答する準備をします。プロビジョニングされた同時実行を設定すると、AWS アカウントに課金が発生することに注意してください。https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/provisioned-concurrency.html

上記の通り、Lambda を常時起動することになり、その分の料金がかかります。コスト面がクリアできれば、設定しても良いですが、今回はなるべくお金をかけたくなかったので、設定していません。
設定した場合は、利用者の増減に伴って適宜チューニングする必要があることを念頭に置いてきましょう。

※1 API Gateway で基本的なリクエストの検証を設定する - Amazon API Gateway

LIFF(フロントエンド)

構成は以下の通りです。

  • フレームワーク: Vue 2.0
  • UI: Vuetify
  • ホスティング: Amplify Console

ここでは、Amplify の話をします。
Amplify は、静的ホスティングやCI/CD、サーバレスなバックエンドをセットアップするための機能など、Web 及びモバイルアプリ開発のフレームワークです。

https://aws.amazon.com/jp/amplify/

LIFF を実現させるためには、HTTPS のエンドポイントを提供する必要がありますが、Amplify の静的ホスティングの機能を使用すれば簡単に実現できます。
Ampify CLI が提供されているため、数コマンドで、静的ホスティングの環境を作成できます。
ホスティングは、以下二つの方法が提供されています。

  • Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
  • Amazon CloudFront and S3

Hosting with Amplify

The AWS Amplify Console provides a Git-based workflow for building, deploying, and hosting your Amplify web app — both the frontend and backend — from source control. Once you connect a feature branch, all code commits are automatically deployed to an amplifyapp.com subdomain or your custom domain
https://docs.amplify.aws/cli/hosting/hosting/#using-aws-amplify-console

Git ベースのCI/CDを提供しており、main ブランチにマージされると、自動的にデプロイするといった機能を簡単に実現できます。また、ブランチ毎に、サブドメインを切ってデプロイするため、機能ブランチごとにデプロイ環境で動作を確認するといったこともできます。
また、上記以外にも、カスタムドメインやアクセス制御(ベーシック認証)、なども提供されており、簡単に設定することが可能です。

デメリットというか、気になる点としては、自動ビルドの時間が長いです。
毎回、3 ~ 5 分程度かかるため、ちょっとデプロイして動作確認したい場合に若干のストレスを感じました。

Amazon S3 and Amazon Cloudfront

The Amplify CLI provides you the option to manage the hosting of your static website using Amazon S3 and Amazon Cloudfront directly as well. Following are the concepts you would encounter when adding S3 & Cloudfront as a hosting option for your Amplify app.
https://docs.amplify.aws/cli/hosting/hosting/#amazon-s3-and-amazon-cloudfront

S3 + Cloudfront の環境を自動で構築します。Cloudformation で静的ホスティング環境を構築していた方は、こちらの方がイメージつきやすいかもです。
Amplify Console の場合は、ホスティング環境に対してはブラックボックスでしたが、こちらについては、構築された S3 と Cloudfront をコンソールで確認できるため、ホスティング環境の設定をカスタマイズすることが可能です。
例えば、 Cloudfront のキャッシュ時間の設定や、WAF の利用したいといったパターンに採用することが多いです。

LIFF と Amplify については、LINE DCでいくつかハンズオンの動画がありますので、興味がある方はご参照ください。

https://www.youtube.com/watch?v=Y3shFWi-XNs
https://www.youtube.com/watch?v=i8aFuGLKFl8

Amplify Storage

本アプリの画面には、料理の画像をアップロードしたり、画像表示する機能があります。
これらについては、Amplify Storage を使用しました。

Amplify CLI's storage category enables you to create and manage cloud-connected file & data storage.
https://docs.amplify.aws/cli/storage/overview/

当該機能も Amplify CLI で提供されているため、amplify add storage からリソースを追加できます。
Amplify Storage を使用すれば、アプリにログインしている人が、自身の画像のみにアクセスできるといった実装を簡単に実現することができます。

例えば、ログインしているユーザーのみアクセスさせたい場合は、Auth users only、パブリックアクセスを許可する場合は、Auth and guest usersを選択します。

アクセス制御の仕組みは以下の通りです。

Granting access to authenticated users will allow the specified CRUD operations on objects in the bucket starting with the prefix /public/, /protected/{cognito:sub}/, and /private/{cognito:sub}/. {cognito:sub} is the sub of the Cognito identity of the authenticated user.
Granting access to guest users will allow the specified CRUD operations on objects in the bucket starting with the prefix /public/.

Cognito の ID プールの AuthRoleUnAuthRole を自動で作成してくれる感じです。
例えば、AuthRole には、/public/, /protected/{cognito:sub}/, and /private/{cognito:sub}/. {cognito:sub} の Prefix のアクセスを許可し、UnAuthRole には、/public/ の Prefix のみのアクセスを許可するといった具合です。
cognito:subには、Cognito のユーザーIDが入ります。

詳細は以下をご参照ください。
https://docs.amplify.aws/lib/storage/configureaccess/q/platform/js/

上記の通り、認証は Cognito 前提のため、Amplify の Auth 機能を有効にしておく必要があります。当該機能については、後述のバックエンドで説明します。

アクセス制御は、S3 の Prefix によって実現しています。そのため、アップロード時も S3 の Prefix のルールに則ってアップロードする必要があります。
ライブラリ経由で操作すれば、この辺を自動的にやってくれるので、難しくありません。
https://docs.amplify.aws/lib/storage/getting-started/q/platform/js/

例えばプライベートのアクセスレベルのファイルアップロードは以下の通りです。

const result = await Storage.put("test.txt", "Private Content", {
  level: "private",
  contentType: "text/plain",
});

https://docs.amplify.aws/lib/storage/upload/q/platform/js/

バックエンド

認証

Cognito に LINE のOIDC ID プロバイダーの追加し、LINE LOGIN と Cognito のユーザープールを連携しました。

Cognito についても amplify add auth から簡単に追加できます。OIDC 連携の設定まではCLIで提供されていないため、リソース作成後、コンソールから設定する形になります。
https://docs.amplify.aws/cli/auth/overview/

具体的な手順は以下記事を参考にさせていただきました。
https://blog.u-chan-chi.com/post/amplify-oidc-line-vue/

わざわざ Cognito のユーザープールと連携したのは、Amplify が提供しているアクセス制御の機能の恩恵を受けたかったからです。例えば、前述で説明した Storage のアクセス制御も Cognito で管理することによって実現できています。
後に説明する CRUD のアクセス制御(認可処理)も Cognito と連携することで簡単に実現することができます。

API(CRUD)

CRUD 処理を担う API は GraphQL のマネージドサービスの AppSync を使用しています。
amplify add api で追加することが可能です。

Amplify CLI's GraphQL API category makes it easy for you to create a new GraphQL API backed by a database. Just define a GraphQL schema and Amplify CLI will automatically transform the schema into a fully functioning GraphQL API powered by AWS AppSync, Amazon DynamoDB, and more.
https://docs.amplify.aws/cli/graphql/overview/#add-your-first-record

スキーマ定義をもとに、DynamoDB に対して CRUD できる リゾルバーを自動的に作成してくれます。
開発者は、スキーマを定義するのみで、バックエンドのロジック等は一切記載せずにクエリすることができます。

本アプリの料理情報を登録するスキーマは以下のように定義しました。

type Cooking 
  @model 
  @auth(rules: [
  {allow: owner, ownerField:"owner"}
  {allow: private, provider: userPools}
  ])
  @key(name: "BySpecificOwner", fields:["owner", "createdAt"], queryField: "listCookingsBySpecificOwner")
{
  id: ID!
  owner: String
  name: String
  link: String
  image: String
  foodstuff: [String]
  tag: [String]
  createdAt: AWSDateTime!
}

@model アノテーションを指定することで、トップレベルのエンティティになります。
DynamoDB のテーブルスキーマとして定義する場合は、@model をつけることで、CRUD のリゾルバーを自動生成してくれます。
https://docs.amplify.aws/cli-legacy/graphql-transformer/model/

@authアノテーションでアクセス権限を指定しています。上記例では、作成者及び認証済み(Cognito)ユーザーのみ CRUD ができる設定になります。

https://docs.amplify.aws/cli-legacy/graphql-transformer/auth/#public-authorization

@keyアノテーションでカスタムインデックスを定義できます。DynamoDB の用語で言うと GSI の設定になります。上記スキーマでデプロイすると、以下 GSI が作成されていました。

キー名 パーティションキー ソートキー 読み込みキャパシティー 書き込みキャパシティー
BySpecificOwner owner createdAt オンデマンド オンデマンド

クエリする側(create)のコードは以下のようになります

import API, { graphqlOperation } from '@aws-amplify/api';
import { createCooking } from '../graphql/mutations'; // 自動生成されるソース

const res = await API.graphql(graphqlOperation(createCooking, { input: {
            name: this.name,
            link: this.link,
            image: this.liffState(),
            foodstuff: this.selectedFood,
            tag: this.tags,
            }}));

id および、createdAt はリゾルバー側で自動生成してくれます。
backend -> graphql -> build 配下に リゾルバーの vtl ファイルを確認してみてください。
例えば、Mutation.createCooking.req.vtlは以下のように定義されています(一部抜粋)

## [Start] Set default values. **
$util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $util.autoId())))
#set( $createdAt = $util.time.nowISO8601() )
## Automatically set the createdAt timestamp. **
$util.qr($context.args.input.put("createdAt", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt)))
## Automatically set the updatedAt timestamp. **
$util.qr($context.args.input.put("updatedAt", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt)))

create Mutation のリクエストに対するリゾルバーですが、id が NULL であれば、$util.autoId()で ID を設定しているのが、何となくわかると思います。

詳しく知りたい方は、以下のドキュメントが参考になります。
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-overview.html

登録通知

LIFF 側で登録が完了すると、Bot 側から以下のような通知メッセージを送信する処理を実装しています。

通知イメージ

登録通知は、以下の要素で実現しています。

  • DynamoDB Streams
  • S3署名付きURL
  • 短縮URL

DynamoDB Streams

DynamoDB Streams と Lambda で 指定したテーブルの操作イベントに対して、 Lambda をフックできます。
詳細は以下チュートリアルをご参考ください。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Streams.Lambda.Tutorial.html

この機能を利用して、DynamoDB に Item が登録された際に Lambda を起動し、Lambda 側で 通知メッセージを作成し、Messaging API の Push 機能で通知を実現しています。

S3 署名付きURL

通知メッセージには画像を表示しています。通知メッセージ以外にも、トーク画面から料理一覧を確認する際は、画像を表示しています。
LINE のトーク上で、画像を表示するためには、HTTPS の画像リンクが必要になります。
https://developers.line.biz/ja/reference/messaging-api/#image-message

S3 は基本的にプライベートアクセスですが、LINE から画像を表示する際は、一時的にパブリックなアクセスを提供する必要があります。
署名付き URL を作成することで、URL を介して一時的にオブジェクトを共有することができます。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html

署名付きURLは、ボットサーバ側で発行するわけですが、権限には注意が必要です。
署名付き URL を発行する側の IAM Policy に署名を利用する側の権限を付与してあげる必要があります。
例えば、署名付きURLを使用して、オブジェクトにアクセスする場合は、s3:GetObjectを ボットサーバ側の IAM Policy に設定が必要になります。

詳しくは以下記事が参考になります。
https://uchimanajet7.hatenablog.com/entry/2019/03/29/000000

署名付きURL を発行し、画像リンクに設定すれば、LINEのトーク上で画像を表示できると思ったのですが、署名付きURL は署名部分の文字列が長く、画像のURLの最大文字数(2000)に引っかかってしまいました。。。

そこで、短縮URLの仕組みを実装しました。

短縮URL

以下記事を参考にさせていただきました。
https://ginyon.hatenablog.com/entry/2018/01/10/211638

短縮URL は Cloudfront と Lambda@Edge で実現しています。

大まかな流れは以下の通りです。
■ データ登録時

  1. DynamoDB に Item が登録
  2. DynamoDB Streams で指定した Lambda が起動
  3. Lambda で 署名付きURL と S3 の Prefix のマッピング情報を別テーブルに登録
  4. LINE メッセージの画像リンクには、Cloudfront のドメイン + S3 の Prefix (短縮URL)を設定する

■ データ参照時

  1. LINE 側から、短縮URLにアクセス
  2. Lambda@Edge で、短縮URL の S3 Prefix をもとに、テーブルから、対応する署名つきURL を取得して返す(リダイレクトされるイメージ)

検索

一覧画面から、料理名及びタグ名で検索できる機能を実装しています。

検索イメージ

検索機能は、Graphql のスキーマに @searchableを追加することで、全文検索が可能になりますが、別途 OpenSearch Service のインスタンスとストレージの料金がかかるため、コスト面で見送りました。

The @searchable directive handles streaming the data of an @model object type to the Amazon OpenSearch Service and configures search resolvers that search that information.
https://docs.amplify.aws/cli-legacy/graphql-transformer/searchable/

https://aws.amazon.com/jp/opensearch-service/pricing/

料金を安価に検索機能を検討していたところ、以下記事を参考に検索機能を実装しました。
https://dev.classmethod.jp/articles/dynamodb-inverted-index/
実装コストはかかりますが、コスト面でのメリットは大きいため、上記を採用しました。

実は、上記の Index を作成する Lambda を Amplify CLI のamplidy add functionから DynamoDB stream の関数を作成し、データ登録は、カスタムリゾルバーから実装したのですが、無駄にややこしくなったのでお勧めしません。
カスタムリゾルバーは、DynamoDB Streams は複数のデータがくるため、データ毎に Mutation リクエストするのが無駄だったので、一回のリクエストで複数データを登録するために作成しました。
ですが、カスタムリゾルバーの vtl ファイルを書くのが辛すぎました。後学と意地で実装しましたが、普通に boto3 から登録した方が早かったと思います。

詳しくは以下ブログをご参照ください。
https://blog.serverworks.co.jp/amplify-custom-resolver

デプロイ

デプロイライフサイクルは大きく三つに分かれています。

  1. Bot 機能 → Serverless Framework
  2. フロントエンド + バックエンド → Amplify
  3. その他(Lambda@Edge) → Serverless Framework

Serverless Framework は、クラウドのサーバーレスアプリケーションに特化した構成管理ツールです。プラグインが豊富に提供されているため、割りと何でもできます。

https://www.serverless.com/framework/docs

Bot 部分及び、lambda@Edge は Serverless Framework でデプロイしています。現状は、手動で sls deploy コマンドでデプロイしています。

参考テンプレートを公開していますので、興味ある方はご参照ください。
https://github.com/g-kawano/line-bot-sls-template

フロントエンド + バックエンド(Auth, Strage, api, function)は、Amplify Console の CI/CD 機能によって、ブランチにマージされた段階で自動的にデプロイするようにしています。

所感になりますが、Amplify の バックエンドのデプロイがまぁまぁの確率で失敗します。
少し変更を加えると、謎の原因で失敗し、トラブルシューティングする場面がいくつかありました。
主に、カスタムリゾルバーを追加したり、function を追加した辺りからだった印象があります。

フロントエンドのみのデプロイについては、ビルド時間が少し長くくらいで、特にストレスはなく、開発者体験としてはとても良かったです。

課題

上記に加えて アーキテクチャ の課題もいくつか感じています。

  1. Amplify のバックエンドのデプロイが安定していない → デプロイ失敗が怖くあんまり手をつけたくない状況になっている
  2. カスタムリゾルバーをメンテするのが辛い

今回は単純な CRUD 処理しか使用していないので、バックエンド側はほぼコード書かずに良かったのですが、バックエンド側でロジックを実装する必要が出てきた場合は、カスタムリゾルバー → Lambda の形で追加していくことになります。それだったら、RESTAPI で 直接API側にロジックを書きたくなるなーとぼんやり感じております。
確かに、スキーマファーストでの開発者体験は良かったのですが、個人的に RESTAPI の方がまだ安心する感があります。(←単純に GraphQL に対する私のスキル不足が原因ですが、、、)

今後

LT の発表で満足してしまい、開発が止まっていたのですが、これを機に少しづつ開発を進めていきたいと思います。

さいごに

行き当たりばったりで実装してしまったせいで、アーキテクチャ としては少し複雑になった気がします。アプリとして提供している機能は単純なのですが、実装してみると意外と大変な箇所が多く、仕事の開発だと大赤字になってたなーと反省もできました 笑

また、AWSを使ったLINEアプリの開発の一例として、どなたかの参考になれば幸いです。

Discussion