AWS Lambdaと LINEmessagingAPIを使って対話型飲食店検索botを作った話
※ 画像・動画等のコンテンツが上手く生成されないことがあります。その際はお手数ですが、ページの更新を複数回お願いします。
目次
はじめに
今回は、AWS LambdaとLINEmessagingAPIを用いて、対話型の飲食店検索botを作成した。
リポジトリ
https://github.com/YamatoKato/restaurant-line-bot
概要
このアプリケーションは、LINE Messaging API を活用した飲食店検索チャットボット。 ユーザーは LINE アプリを通じて、レストラン情報を検索し、詳細を表示することができる。
経緯
普段、友達や家族と新しい(馴染みのない)地域で飲食店を探すとき、「新橋 中華」のようにネット検索することがよくある。
ただ、この検索する工程って意外と面倒くさいし、しかも『新橋の中華 おすすめ20店!』のような広告まみれのブログに飛ばされるなんて事もあってスムーズに良さげなお店を探せないこともよくある。
この工程を無駄なくワンタップでポンポンと検索するサービス欲しいな〜て思って、
そこで、LINEを使えば対話型でスムーズに検索できるし、しかも検索履歴も簡単に残るじゃん!ということで、このサービス作ることにしました。
デモ
利用
リンク ※ LINEアカウントが必要です
アーキテクチャ
シンプルなので説明省略
使用技術
LINEMessagingAPI
LINEを利用したbot開発には必須といって過言では無いLINEmessagingAPI。
Webhookを介してユーザーからのメッセージを受け取り、イベントを発火からメッセージ返信を請け負う。メッセージの種類も豊富で、リッチメッセージの作成なんかも可能。
HOTPEPPER API
飲食店情報のAPIを無料で提供しているサービスが現状ホットペッパーのみ。
AWS Lambda
AWS Lambdaを採用した理由として、サーバレスでコストパフォーマンスが良い点。コードの実行に必要なだけのリソースを自動的に確保・調達してくれるため、不必要なリソースを消費する心配が無いのでbot作成と相性抜群。また、似たクラウドサービスにGCPのClaud Functionとかもあるが、料金体系とRuntime時間制限共にlambdaの方が魅力的だった。
RuntimeはGo1.x
AWS APIGateway
APIを公開して、LINEbotサーバーからのPOSTリクエストを受け取り、Lambdaに転送する。
AWS SAM
Serverless Application Modelの略で、Lambda,API Gatewayを使った開発とデプロイメントを効率的に行うためのフレームワーク。 今回は開発環境の立ち上げとデプロイに利用。爆速!
Go
今回、Lambda関数で実行するRuntimeにGoを選んだ。 LINEbot制作だとPythonを利用されがちだが、
この2点で採用。
Clean Architecture
クリーンアーキテクチャの原則に沿ってLINEbotを開発する。以下の利点を得たいと思い採用。
前例の少なさから探り探りでなるべく忠実に思想を再現していく。
開発話
ディレクトリ構成とか
Shell
. ├── Makefile ├── README.md ├── env.json ├── events │ └── event.json ├── functions │ └── restaurant-line-bot │ ├── di.go // a │ ├── domain │ │ └── model │ │ ├── condition.go │ │ ├── genre.go │ │ ├── hotpepper.go │ │ └── line.go │ ├── infrastructure //DB等の外部接続がないため、repositoryは無い │ │ └── router.go │ ├── interfaces │ │ ├── controllers │ │ │ └── linebot_controller.go │ │ ├── gateway │ │ │ └── hotpepper_gateway.go │ │ └── presenter │ │ └── line_presenter.go │ ├── main.go │ ├── usecases │ │ ├── dto │ │ │ ├── menudto │ │ │ │ ├── set_area_menu_input.go │ │ │ │ ├── set_area_menu_output.go │ │ │ │ ├── set_condition_menu_input.go │ │ │ │ ├── set_condition_menu_out.go │ │ │ │ ├── set_confirm_menu_input.go │ │ │ │ ├── set_confirm_menu_output.go │ │ │ │ ├── set_genre_menu_input.go │ │ │ │ ├── set_genre_menu_output.go │ │ │ │ ├── set_help_menu_input.go │ │ │ │ └── set_help_menu_output.go │ │ │ └── searchdto │ │ │ ├── input.go │ │ │ └── output.go │ │ ├── igateway │ │ │ └── ihotpepper_gateway.go │ │ ├── interactor │ │ │ ├── search_interactor.go │ │ │ ├── set_menu_interactor.go │ │ │ └── usecase │ │ │ ├── isearch_usecase.go │ │ │ └── iset_menu_usecase.go │ │ └── ipresenter │ │ └── iline_presentor.go │ └── utils │ └── strings.go ├── go.mod ├── go.sum ├── samconfig.toml └── template.yaml 21 directories, 41 files
参考
上2つの画像を参考に構成してみた。 あと実践クリーンアーキテクチャがわかりやすいと思った。
処理の流れ
1. main.go APIGatewayProxyRequestを受け取り、Infra層のrouterにまるごと渡す。 また、抽象クラスとそれに紐づける実装クラスを結びつける設定(DI)を行う。
コード
Go
func HandleRequest(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { r := InitDI() if err := r.CatchEvents(event); err != nil { return events.APIGatewayProxyResponse{ StatusCode: 500, }, nil } return events.APIGatewayProxyResponse{ StatusCode: 200, }, nil } func main() { lambda.Start(HandleRequest) } func InitDI() *infrastructure.Router { hotpepperGateway := gateway.NewHotpepperGateway() linePresenter := presenter.NewLinePresenter() searchInteractor := interactor.NewSearchInteractor(hotpepperGateway, linePresenter) setMenuInteractor := interactor.NewSetMenuInteractor(linePresenter) linebotController := controllers.NewLinebotController(searchInteractor, setMenuInteractor) router := infrastructure.NewRouter(linebotController) return router }
2. (infrastructure層)router.go 渡ってきたリクエストの署名の検証を行う。 リクエストに格納されたWebhookイベントをパースし、LINEのメッセージタイプやその内容に合ったイベントを判別し、それぞれのcontrollerに処理を流す。
コード
Go
// Router ルーティング type Router struct { lc *controllers.LinebotController } // NewRouter コンストラクタ func NewRouter(lc *controllers.LinebotController) *Router { return &Router{lc: lc} } // イベントごとにルーティング func (r *Router) CatchEvents(event events.APIGatewayProxyRequest) error { // 署名の検証 signature := event.Headers["x-line-signature"] if signature == "" { signature = event.Headers["X-Line-Signature"] } if !validateSignature(os.Getenv("LINE_SECRET_TOKEN"), signature, []byte(event.Body)) { logrus.Error("署名の検証に失敗しました") return fmt.Errorf("署名の検証に失敗しました") } webhook := model.Webhook{} // リクエストからイベントを取得 if err := json.Unmarshal([]byte(event.Body), &webhook); err != nil { logrus.Error(err, "router@CatchEvents_json.Unmarshal") return err } for _, we := range webhook.Events { // イベントがメッセージの受信だった場合 if we.Type == linebot.EventTypeMessage { switch message := we.Message.(type) { // メッセージがテキスト形式の場合 case *linebot.TextMessage: userMessage := message.Text if userMessage == model.INTRO_MESSAGE { // 最初の導入メッセージ if err := r.lc.SetAreaMenu(we); err != nil { logrus.Error(err, "router@SetAreaMenu") return err } return nil } else if utils.ContainsHyphen(userMessage) { // メッセージにハイフンを含む場合(エリア指定) if err := r.lc.SetConfirmMenu(we, model.PBD_PREFIX_IDENTIFY_GENRE, "", userMessage); err != nil { logrus.Error(err, "router@SetConfirmMenu") return err } return nil } else { // それ以外の場合(エリア未指定) if err := r.lc.SetHelpMenu(we); err != nil { logrus.Error(err, "router@SetHelpMenu") return err } return nil } // メッセージが位置情報の場合 case *linebot.LocationMessage: if err := r.lc.SetConfirmMenu(we, model.PBD_PREFIX_IDENTIFY_GENRE, "", ""); err != nil { logrus.Error(err, "router@SetConfirmMenu") return err } return nil } } else if we.Type == linebot.EventTypePostback { // 確認ボタン if model.PBD_PREFIX_IDENTIFY_CONFIRM == model.GetPrefix(we.Postback.Data) { if err := r.lc.SetConfirmMenu(we, "", we.Postback.Data, ""); err != nil { logrus.Error(err, "router@SetConfirmMenu") return err } } // ジャンルボタン if model.PBD_PREFIX_IDENTIFY_GENRE == model.GetPrefix(we.Postback.Data) { if err := r.lc.SetGenreMenu(we, we.Postback.Data); err != nil { logrus.Error(err, "router@SetGenreMenu") return err } } // 条件ボタン if model.PBD_PREFIX_IDENTIFY_CONDITION == model.GetPrefix(we.Postback.Data) { if err := r.lc.SetConditionMenu(we, we.Postback.Data); err != nil { logrus.Error(err, "router@SetConditionMenu") return err } } // お店一覧 if model.PBD_PREFIX_IDENTIFY_SEARCH == model.GetPrefix(we.Postback.Data) { if err := r.lc.GetRestaurantInfos(we, we.Postback.Data); err != nil { logrus.Error(err, "router@GetRestaurantInfos") return err } } return nil } else { if err := r.lc.SetHelpMenu(we); err != nil { logrus.Error(err, "router@SetHelpMenu") return err } return nil } } return nil } func validateSignature(channelSecret string, signature string, body []byte) bool { decoded, err := base64.StdEncoding.DecodeString(signature) if err != nil { logrus.Error(err, "validateSignature_base64.StdEncoding.DecodeString") return false } hash := hmac.New(sha256.New, []byte(channelSecret)) _, err = hash.Write(body) if err != nil { logrus.Error(err, "validateSignature_hash.Write") return false } return hmac.Equal(decoded, hash.Sum(nil)) }
3. (interfaces層)linebot_controller.go イベント内のデータ(postback)やテキスト(メッセージ)データ,位置情報データ等を加工。usecases層のInput構造体にそれらデータを格納して引数に渡した状態でusecases層のinteractorのインターフェースを呼ぶ。
ここで、直接interactorを呼ぶのではなく、インターフェースを噛ませる理由は、クリーンアーキテクチャの思想でもあるテスト可能性を実現するため。interactorの実装に依存せずに済み、controllerのテストが容易になる。
コード
Go
// LinebotController LINEBOTコントローラ type LinebotController struct { searchInteractor usecase.ISearchUsecase setMenuInteractor usecase.ISetMenuUsecase bot *linebot.Client } // NewLinebotController コンストラクタ func NewLinebotController(searchInteractor usecase.ISearchUsecase, setMenuInteractor usecase.ISetMenuUsecase) *LinebotController { bot, err := linebot.New( os.Getenv("LINE_SECRET_TOKEN"), os.Getenv("LINE_ACCESS_TOKEN"), ) if err != nil { logrus.Fatalf("Error creating LINEBOT client: %v", err) } return &LinebotController{ searchInteractor: searchInteractor, setMenuInteractor: setMenuInteractor, bot: bot, } } func (c *LinebotController) SetHelpMenu(e *linebot.Event) error { setHelpMenuInput := menudto.SetHelpMenuInput{ ReplyToken: e.ReplyToken, } if _, err := c.setMenuInteractor.SetHelpMenu(setHelpMenuInput); err != nil { logrus.Errorf("Error setMenuInteractor.SetHelpMenu(setHelpMenuInput): %v", err) return err } return nil } func (c *LinebotController) SetAreaMenu(e *linebot.Event) error { setAreaMenuInput := menudto.SetAreaMenuInput{ ReplyToken: e.ReplyToken, } if _, err := c.setMenuInteractor.SetAreaMenu(setAreaMenuInput); err != nil { logrus.Errorf("Error setMenuInteractor.SetAreaMenu(setAreaMenuInput): %v", err) return err } return nil } // 以下省略...
※usecases層のInput構造体とは、同階層のdto(Data Transfer Object)ディレクトリにあり、interactorが必要としているデータを固めた構造体である。
コード
Go
type SetAreaMenuInput struct { ReplyToken string }
4. (usecases層)xxx_interactor.go 具体的な実装(外部API使ってXXX)を持たず、アプリケーションとして何ができるかだけを記述している。そのため、テストのしやすさからinteractorのインターフェースを同階層のに置いとく。 何ができるか例えると、外部API利用の具体的実装が書かれているgatewayのインターフェース(同階層)を呼ぶとか、DB接続の具体的実装が書かれているrepositryのインターフェース(domain層)を呼ぶとか。 あとは今回のパターンもそうだが、アプリケーションとしてのレスポンス(今回はline-bot-sdkを利用したレスポンス)の具体的実装が書かれたpresenterのインターフェース(同階層)を呼ぶとかが挙げられる。 presenterのインターフェースを呼ぶときは、アプリケーションのレスポンスとして必要な情報を固めたOutput構造体を引数にとって呼んであげる。 さらに大事な事として、presenterそのものではなく、同階層にあるそのインターフェースを呼ぶことで、usecases層よりも外側にあるinterfaces層に書かれているpresenterを呼ぶことができる。(依存関係逆転の法則)
コード
Go
// SetMenuInteractor メニューインタラクタ type SetMenuInteractor struct { linePresenter ipresenter.ILinePresenter //依存方向を逆転させるためpresenterの<I>を呼ぶ } // NewSetMenuInteractor コンストラクタ func NewSetMenuInteractor( linePresenter ipresenter.ILinePresenter) *SetMenuInteractor { return &SetMenuInteractor{ linePresenter: linePresenter, } } func (interactor *SetMenuInteractor) SetHelpMenu(in menudto.SetHelpMenuInput) (menudto.SetHelpMenuOutput, error) { out := menudto.SetHelpMenuOutput{ ReplyToken: in.ReplyToken, TextMessageData: &model.TextMessageData{ Content: model.TM_CONTENT_SET_HELP_MENU, }, } if err := interactor.linePresenter.SetHelpMenuReplyMessage(out); err != nil { logrus.Errorf("Error linePresenter.SetHelpMenuReplyMessage(out): %v", err) return out, err } return out, nil } // SetAreaMenu エリアメニュー設定 func (interactor *SetMenuInteractor) SetAreaMenu(in menudto.SetAreaMenuInput) (menudto.SetAreaMenuOutput, error) { postbackData := "area_menu" out := menudto.SetAreaMenuOutput{ ReplyToken: in.ReplyToken, ButtonsTemplateData: model.ButtonsTemplateData{ ThumbnailImageURL: model.BT_THUMBNAIL_SET_AREA_MENU, Title: model.BT_TITLE_SET_AREA_MENU, Text: model.BT_MESSAGE_SET_AREA_MENU, }, PostbackActionData: model.PostbackActionData{ Label: model.PBA_LABEL_SET_AREA_MENU, Data: postbackData, Text: model.PBA_TEXT_SET_AREA_MENU, DisplayText: model.PBA_DISPLAY_TEXT_SET_AREA_MENU, InputOption: model.PBA_INPUT_OPTION_SET_AREA_MENU, FillInText: model.PBA_FILL_IN_TEXT_SET_AREA_MENU, }, URIActionData: model.URIActionData{ Label: model.UA_LABEL_SET_AREA_MENU, URI: model.UA_URI_SET_AREA_MENU, }, TemplateMessageData: model.TemplateMessageData{ AltText: model.TM_LABEL_SET_AREA_MENU, }, } if err := interactor.linePresenter.SetAreaMenuReplyMessage(out); err != nil { return out, err } return out, nil } // 省略...
5. (interfaces層)line_presenter.go はい、外部にレスポンス(データ)を渡す場所にやってきた。 interactorから渡ってきたOutput構造体を使ってレスポンスのためのデータ整形を行う。今回はline-bot-sdk for Goを利用し、LINEbotサーバーにレスポンス(Json)を投げて一連の流れは終わり。
コード
Go
// LinePresenter LINEプレゼンタ type LinePresenter struct { bot *linebot.Client } // NewLinePresenter コンストラクタ func NewLinePresenter() ipresenter.ILinePresenter { bot, err := linebot.New( os.Getenv("LINE_SECRET_TOKEN"), os.Getenv("LINE_ACCESS_TOKEN"), ) if err != nil { logrus.Fatalf("Error creating LINEBOT client: %v", err) } return &LinePresenter{bot: bot} } // SetHelpMenuReplyMessage リプライメッセージ func (p *LinePresenter) SetHelpMenuReplyMessage(out menudto.SetHelpMenuOutput) error { if _, err := p.bot.ReplyMessage(out.ReplyToken, linebot.NewTextMessage(out.TextMessageData.Content)).Do(); err != nil { logrus.Errorf("Error LINEBOT replying message: %v", err) return err } return nil } // SetAreaMenu リプライメッセージ func (p *LinePresenter) SetAreaMenuReplyMessage(out menudto.SetAreaMenuOutput) error { bt := linebot.NewButtonsTemplate( out.ButtonsTemplateData.ThumbnailImageURL, out.ButtonsTemplateData.Title, out.ButtonsTemplateData.Text, linebot.NewPostbackAction( out.PostbackActionData.Label, out.PostbackActionData.Data, out.PostbackActionData.Text, out.PostbackActionData.DisplayText, out.PostbackActionData.InputOption, out.PostbackActionData.FillInText, ), linebot.NewURIAction( out.URIActionData.Label, out.URIActionData.URI, ), ) tm := linebot.NewTemplateMessage(out.TemplateMessageData.AltText, bt) if _, err := p.bot.ReplyMessage(out.ReplyToken, tm).Do(); err != nil { logrus.Errorf("Error LINEBOT replying message: %v", err) return err } return nil } // 省略...
感想
自身でプロジェクト作ってハンズオン形式でクリーンアーキテクチャを理解するのがスムーズで良かった。 ぶっちゃけここまで細かく責務を分けるべきか?みたいなところもあるが、今回を機にクリーンアーキテクチャの思想を理解したかったため、なるべく細かく分けていった(つもり)。
苦労
どこにLINEbot特有の処理を置くか
たとえば、LINEbotサーバー(Webhook)から受け取ったメッセージのタイプが、テキストなのか、画像なのか、位置情報なのか。こういったメッセージタイプの仕分けをクリーンアーキテクチャ的ルールでどこに置くべきかなと。。 今回は、infrastructure層のrouter.goに置いてみた。 理由として、webでいうエンドポイントの仕分け処理はrouter.goで行うが、それって以上の仕分け作業と似ているなぁと思ったから。 LINEbotサーバーからのリクエストのエンドポイントは1つだけだが、その1つの中に色々なイベントタイプが分かれていることから、同じ仕分け作業という意味も込めてrouter.goに書いてみた。
はじめてのAWS利用
AWSのサービスを使うことが初めてだったのだが、環境変数の設定に困ったり、デプロイ時のエラーに困ったりで、ひたすら関連記事を読み漁った。中でもSAMを使ったデプロイエラーで日本語記事の解決法が見当たらず、Githubで該当issueを気合いで探したなんてこともあった。 てことで詰まったとこの解決を記事に書いた↓
工夫点
入力されたデータの一時保持(状態管理)をどうするか
状況: エリア,ジャンル,細かい条件の入力情報ボタン(botメッセージ)はそれぞれ1回ずつの提供となる。つまり3つの情報を同時に受け入れる体験はLINEのメッセージ上では表現できない。 ※LIFFアプリ(Webアプリ)ならできるが。。 これでは複数の条件をフィルターかけて検索できない。
せっかくAWS使ってるし、SAMとも互換性があるDynamoDBを条件の一時的なデータストアとして使うか悩んだ。ただ、こういった条件入力のの利用頻度って結構高いし、課金額が膨らみそう、、
解決策: ポストバックアクションを利用した。これは特定の文字列を含むポストバックイベントをサーバーに返すアクション。 例えば、このアクションをボタンに仕込むことで、そのボタンが押されたら、事前に仕込まれたポストバックデータ(文字列)をWebhookを介してLINEbotサーバーからポストバックイベントのリクエストが送られてくる仕組み。
例
上画像の場合、「条件に追加する」ボタンにポストバックアクションを「ペット同伴可」みたいなテキスト情報のポストバックデータと共に仕込む。そうすることで、ボタンが押すされたときに、「ペット同伴可」というポストバックデータをWebhookを介してリクエストが飛んでくる。
この仕組みを使って、ポストバックデータにそれぞれの条件を追加したJson形式のテキストデータを仕込んで、入力されたデータの状態管理を行なっている。Json形式のテキストデータにしている理由はGo側でJsonと構造体の相性が良いから。また、Json形式のデータの前に接頭辞を入れて、ポストバックイベントのリクエストのレスポンス内容を決めている。
今後の展望とまとめ
今後、controllerのテストやusecases層のテストを作っていきたい。また、お気に入り機能の導入も予定している。
LINEbot開発って意外と処理すべきことが多くて、これこそ関心ごとを分離できるクリーンアーキテクチャと相性いいなと感じたので今後LINEbotをするときには是非とも参考になればと思う。