こんにちは、2022新卒のぱやぴです。
配属後初の業務として、社内用のSlackBot「デジタルコンシェルジュ こころちゃん」の開発を任されました。この「こころちゃん」は、社内での稟議、経費精算、労務などの規定・ルールに関する疑問点に対して、教えてくれるサポートを行います。また、社員が困ったことを解決するだけでなく、癒しを提供することもミッションとしています。
そんな「こころちゃん」を開発する際に起きた問題とその解決方法を共有したいと思います。
今回作成したSlackBotについて
使用技術
- TypeScript
- Slack API
- Google Sheets API
- Natural Language API
- Cloud Functions
- Cloud Pub/Sub(後述)
概要
単語、説明等をGoogleスプレッドシートに登録し、送信されたメッセージをNatural Language APIで分析してから検索を行い結果を返信…といった感じのbotです。
今回はbotの機能についてフォーカスした内容ではないため、詳しい機能の紹介や使用した技術の紹介等は省略します。(機会があれば紹介をしたいですね…)
発生した問題
ローカルでの動作で問題ないことを確認し、いざGoogle Cloud Funcitons上へデプロイを行い、動作の確認を行ったところbotにメッセージを送ってから返信が来るまで3~5分かかってしまいました。
これではさすがに活用できません。忘れたころに返信が来るような感じです。
原因
Slack APIのタイムアウト回避のためにすぐにレスポンス200を返していたことによってCloud Functionsが正常に動作を行うことができていなかったことが原因でした。
それぞれ説明をしていきます。
Slack APIのタイムアウト回避
Slack APIには失敗条件がいくつかあり、その中の一つに以下のものがあります。
we wait longer than 3 seconds to receive a valid response from your server
ざっくりと意訳すると、サーバーから3秒以内に有効な応答を受信をしないとエラーが発生するというものです。また、このエラーが発生した場合イベントがサーバーに再送信されます。
このエラーを回避するため、イベントを受信したらすぐにHTTP 200レスポンスを返していました。
Cloud Functionsの仕様
Cloud Functionsのドキュメントに以下の記述があります。
関数は、HTTP レスポンスを送信する必要があります。関数によってバックグラウンド タスク(スレッド、Future、JavaScript
Promise
オブジェクト、コールバック、システム プロセスなど)が作成される場合は、HTTP レスポンスが送信される前に、これらのタスクを終了します。あるいは、他の方法で問題を解決する必要があります。HTTP レスポンスが送信される前に終了しなかったタスクは完了しません。未定義の動作が行われる可能性があります。
HTTPレスポンスを最初に返していたことによってタスクが完了せず、正常な動作を行うことができなかったため時間がかかってしまっていたようです。
また、それぞれの処理の後にレスポンスを返す形に変更すると、動作の重さによってタイムアウトになったりならなかったりと運に依存した処理になってしまいます。
解決策
自分が行った解決策はCloud Pub/Subを用いるということでした。
Pub/Subについては自分では完全に説明しきれないため、公式ドキュメントを読んでいただければと思います。
簡単に説明をするとイベントを受け、Topicにメッセージを送信するサービス(パブリッシャー)とTopicを監視しイベントを処理するサービス(サブスクライバー)を切り離し非同期で実行されるというような感じです。
今回はこれを用いて、Slack APIからイベントを受けTopicに送信後HTTP レスポンスを返す関数と、Topicを監視し、イベントに応じた処理を行う関数の2つに分けました。
こうすることでイベントの処理とHTTPレスポンスの送信を切り離すことができ上記の二つの問題を回避することができました。
以下簡単に行ったことの説明をします。
コードの修正
解決前のエントリーポイントは以下のようなものでした。
const slackListener = async(req: Request, res: Response) => {
if(req.body.event.type === "***") {
res.status(200).send("OK");
// 以下イベントの処理
...
}
...
}
これだとSlackの3秒ルールは回避できても処理がうまくいかず…など上記の2つの問題をうまく回避できませんでした。
修正後は以下のように関数を二つに分けました。
// index.ts
~~~
import { Request, Response } from "express";
import { PubSub } from "@google-cloud/pubsub";
import { PubsubMessage } from "@google-cloud/pubsub/build/src/publisher";
// Slack APIからイベントを受け取りTopicにメッセージとして送信後、
// HTTPレスポンスを送信する(パブリッシャー)
export const slackListener = async(req: Request, res: Response) => {
~~~
const pubsubClient = new PubSub();
const dataBuffer = Buffer.from(JSON.stringify(req.body));
try {
const messageId = await pubsubClient
.topic("後に作成するTopic名")
.publishMessage({ data: dataBuffer });
} catch (error) {
process.exitCode = 1;
}
res.status(200).send("OK");
};
// 監視しているTopicにメッセージが送信された際に、処理を行う(サブスクライバー)
export const slackTopicListener = async(pubsubMessage: pubsubMessage) => {
const data = JSON.stringify(pubsubMessage.data)
? Buffer.from(JSON.stringify(pubsubMessage.data), "base64").toString()
: "Error";
const body = JSON.parse(data);
if(body.event === "***") {
// 以下イベント処理
...
}
...
}
GCPの操作
続いてGCPの方を触っていきます。
Pub/Sub Topicの作成
まずは新しくメッセージをパブリッシュするTopicの作成を行いました。 今回はgcloud CLIで操作を行います。ほかの方法や詳しい内容に関しては公式ドキュメントを参照してください。
gcloud pubsub topics create Topic名
これでTopicの作成ができました。
Cloud Functionsの関数の作成
続いてCloud functionsの関数の作成、デプロイを行いました。 パブリッシャーとサブスクライバーの二つの関数を作成します。
こちらもgcloud CLIで操作を行います。 今回、注意が必要な部分のみ記載します。他デプロイに必要な部分に関しては公式ドキュメントを参照してください。
パブリッシャー
gcloud functions deploy パブリッシャー名 \\
--entry-point=slackListener \\ 先ほど分けたメッセージ送信を行う方の関数名
--trigger-http \\
// 以下フラグは公式ドキュメント参照
サブスクライバー
gcloud functions deplay サブスクライバー名 \\
--entry-point=slackTopicListener \\ 先ほど分けた処理を行う方の関数名
--trigger-topic=Topic名 \\ 先ほど作ったTopic名
// 以下フラグは公式ドキュメント参照
これで処理を分けることができました。
最終的にはCloud Functionsにはパブリッシャーとサブスクライバーの二つの関数が作成されている状態になり、以下のような構成になります。
これで起きていた問題が回避でき、すぐにBotから返信が来るようになりました!
おわりに
公式ドキュメント等とにらめっこをしている時間が多く、このブログも公式ドキュメントのURLに頼りまくってしまい申し訳ないです。
今回、GCPもSlack APIも初めて触る状態からのスタートでしたが、思っていた以上に広くサービスを利用することになりました。知識が広がっただけでなく、配属後初めての仕事としてはとてもやりがいがあり良い経験でした。今後は言語系AIともつなげられたら良いなと思ってます!
この記事がなにかの参考になったら幸いです。