こんにちは、フロントエンドエンジニアの高野です。
最近AWSで簡単なSlackアプリを作成したので、その記録をまとめていきます。
今回話すこと/話さないこと
話すこと
- Slack側のapp初期設定について
- 開発環境の構築について
- Lambdaのフォルダ構成について
話さないこと
- DynamoDBの詳しいテーブル構成 / 実装方法について
- Boltの細かい実装について
作りたいもの
Wizでは毎日の業務報告をSlack上で報告しています。
この報告をGitHubやタスク管理ツールとの連携、
指定期間の業務内容の抽出などをできるようにして楽した〜い! というのが主なモチベーションです。
まずはその足掛かりとして、今回は
毎日の業務報告内容をDBに保存する という簡単なアプリを作成しました。
※AWSについては初めての実装だったので、至らない点あるかもしれません...
コメントなどで指摘いただけると嬉しいです。
主な構成
バックエンドアプリケーションとしては SlackのフレームワークであるBoltを採用しました。サーバーレス!なシンプルな構成です。
CloudWatch Logsを使用して各種ログを保存しています。
また、開発環境/デプロイツールとしてはServerless Frameworkを起用しています。
各種コマンドがSlackで叩かれる-> API Gatewayを介してLambdaが走り応答する->場合によってdynamoDBからデータを受け取ったり保存したりする
というのが基本的な流れです。
AWS側の構築
前述したようにServerless Frameworkを用いて構築していくので、serverless.ymlに下記のように記述していきます。
service: antenna useDotenv: true frameworkVersion: '2' custom: dynamodb: start: port: 4000 inMemory: true migrate: true seed: true seed: development: sources: - table: ${env:INITIAL_TABLE} sources: [./migrations/firstTable.json] plugins: - serverless-offline - serverless-dynamodb-local - serverless-dotenv-plugin provider: name: aws runtime: nodejs12.x region: ap-northeast-1 lambdaHashingVersion: 20201221 environment: SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} INITIAL_TABLE: ${env:INITIAL_TABLE} ROOM_NAME: ${env:ROOM_NAME} functions: app: handler: handler.app events: - http: method: post path: /slack/events role: boltLambdaRole resources: Resources: usersTable: Type: AWS::DynamoDB::Table Properties: TableName: ${env:INITIAL_TABLE} AttributeDefinitions: - AttributeName: UserId AttributeType: S - AttributeName: SortKey AttributeType: N KeySchema: - AttributeName: UserId KeyType: HASH - AttributeName: SortKey KeyType: RANGE BillingMode: PAY_PER_REQUEST boltLambdaRole: Type: AWS::IAM::Role Properties: Path: /my/cust/path/ RoleName: boltLambdaRole AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: lambda PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:* - dynamodb:* Resource: - arn:aws:logs:*:*:* - arn:aws:dynamodb:*:*:*
※簡単のために、ポリシーを一部簡略化しています。
プラグインとして、以下の三つを使用しています。
plugins: - serverless-offline - serverless-dynamodb-local - serverless-dotenv-plugin
plugin / 開発環境について
serverless-offline
こちらは、ローカルマシン上ででlambdaのエミュレートを実行
できるようにしてくれるありがたいpluginです。
デプロイや実行で課金されずにテストをすることができます。
serverless-dynamodb-local
serverless-offline
はLambdaのみのエミュレートなので、dynamoDBを動かす場合はこのままではローカルテストできません。
なので、また別でserverless-dynamodb-local
というプラグインを入れました。
serverless-dotenv-plugin
こちらのプラグインはenvファイルをserverless上で読み込むためのプラグインです。
また、SlackでAPIを叩くにはlocalhostは当然使用できないので、
ローカルのポートを外部公開するためにngrok
を使用します。
ダウンロード後、
$ ngrok http 3000
と叩けば、 ローカルの3000番ポートを外部公開してくれます。
上記だと、localhost:3000の内容が伏せている部分(https://xxxxxxxxxx.ngrok.io) として外部公開されているということになります。 少し複雑になってきたので図式化します。
Slack側で必要な設定
Slack側ではまず、開発者専用のページを開きアプリを登録する必要があります。
まずはBoltの認証部分で必要な
- Signing Secret
- Bot Token
の二つを探し出します。
Signing Secretは先程のappページのトップをスクロールしていくと見つけることができます。
Showを押すと表示されるのでこちらをメモしておきます。
また、Bot Tokenは左サイドメニューの「OAuth & Permissions」を選ぶと
「Bot User OAuth Token」として表示されます。
こちらもメモしておきましょう。
また、Slack側に先程ngrokで発行したURLを登録していきます。
登録箇所は主に3つあります。
- Event Subscriptions (必須)
- Interactivity & Shortcuts (応答が必要な場合必要)
- Slash Commands(スラッシュコマンドを使う場合必要)
Event SubscriptionsではChallenge認証というものが行われ、これがパスされればURLが有効とみなされます。
Boltを使っていた場合、勝手にいい感じでやってくれるのであまり意識する必要はありません。
また、serverless + ngrokを使う場合は、 ngrok発行のURL + /ステージ名/slack/events となります。
ngrok発行以降のURLは、serverless offline
を実行したシェルから確認することができます。
以下の画像の赤枠、緑色の部分です。
Interactivity & Shortcutsでは、何かインタラクティブな動作を実行させる際に必要です。
例えばmodalによるフォーム送信、ボタンのアクション、セレクトメニューなどです。
slack側からAPI側に通信するのを許可します。
右上のトグルをOnにした後、URLを登録します。
Slash Commands では、各種コマンドとその実行先のURLを登録します。
スラッシュコマンドというのは、Slackのメッセージ上から /foo
で実行できるコマンドのことです。
また、Slackアプリのスコープ(権限)を登録していく必要があります。
OAuth & Permission を下にスクロールしていくとScopesというセッションがあるので、必要なスコープを追加していきます。
実行する目的から自動でスコープを選択したい場合は、Event Subscriptionsの Subscribe to bot eventsセクションから選択することも可能です。
Lambdaの構成
普段慣れ親しんでいるといる理由からNode.jsにて記述しました。
特に何もしないままServerlessでアップロードすると、不要なnode_modulesなども含めてZIP化されてしまいます。
Lambdaは一度のアップロードで50MBを超える場合直接のアップロードができなくなってしまうので、なるべくファイルサイズを抑えたいところです。
なので今回はWebpackを入れてバンドルするようにしました。
. ├── build // Serverlessでアップロードするフォルダ ├── node_modules ├── package-lock.json ├── package.json ├── readme.md ├── src // 開発用フォルダ │ ├── boltFunc │ ├── dynamo.js // ラムダ関数 │ └── handler.js // ラムダ関数 └── webpack.config.js
webpack.config.jsはこのように記述しています。
const path = require('path') const TerserPlugin = require('terser-webpack-plugin') module.exports = { mode: 'production', entry: { handler: './src/handler.js', dynamo: './src/dynamo.js', }, target: 'node', output: { filename: '[name].js', path: path.resolve(__dirname, 'build'), libraryTarget: 'commonjs2', }, optimization: { minimizer: [ new TerserPlugin({ terserOptions: { mangle: false, }, }), ], }, }
ポイントとしては、 TerserPluginを使用してWebpackの圧縮方法をカスタマイズし、
mangleをfalseにしています。
これは、API GatewayからLambdaを指定する際に関数名が変わってしまうことを防ぐためです。
例えば、handler.app
を指定した場合、Webpackによってhandler.a
などに関数名をリネームされると、エラーになってしまいます。
また、アウトプット形式をlibraryTarget: 'commonjs2'
にするのを忘れないようにします。
handler.js(ラムダ関数のトップ)では下記のように、Slack Boltで登録するイベントをまとめて実行しています。
'use strict' // ------------------------ // dynamoDb // ------------------------ const AWS = require('aws-sdk') AWS.config.update({ region: 'ap-northeast-1', }) const docClient = new AWS.DynamoDB.DocumentClient() // ------------------------ // Bolt App Initialization // ------------------------ const { App, ExpressReceiver } = require('@slack/bolt') const expressReceiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET, processBeforeResponse: true, }) const app = new App({ token: process.env.SLACK_BOT_TOKEN, receiver: expressReceiver, }) // ------------------------ // Application Logic // ------------------------ // bolt const funcRegister = require('./boltFunc/slash/register.js').default const funcDaily = require('./boltFunc/slash/daily').default const funcWeekly = require('./boltFunc/slash/weekly').default const funcMonthly = require('./boltFunc/slash/monthly').default const funcEditUser = require('./boltFunc/slash/edituser').default const funcEditReport = require('./boltFunc/slash/editreport').default funcRegister(app, docClient) funcDaily(app, docClient) funcWeekly(app, docClient) funcMonthly(app, docClient) funcEditUser(app, docClient) funcEditReport(app, docClient) app.message('goodbye', async ({ message, say }) => { // say() sends a message to the channel where the event was triggered await say(`See ya later, <@${message.user}> :wave:`) }) // ------------------------ // AWS Lambda handler // ------------------------ const awsServerlessExpress = require('@vendia/serverless-express') module.exports.app = awsServerlessExpress({ app: expressReceiver.app, })
また、実際にSlackのUIを構築する際は公式のBlock Kit Builderが非常に重宝しました。(閲覧にはログインが必要です。)
ぽちぽち必要なコンポーネントを選択していくと、右にblockのコードが出力されます。
これをいい感じにリファレンスを見ながら加工しつつ、自分のコードに落とし込んでいきました。
一例として一つのslash commandのファイルを紹介しておきます。
require('date-utils') const { checkRegist } = require('../validate/checkRegist') // DBにユーザー登録があるかをチェックする関数 const { putReport } = require('../db/report/putReport') // DBに業務報告を保存する関数 exports.default = (app, docClient) => { app.command('/aq-edituser', async ({ ack, body, client }) => { await ack() const query = await checkRegist(body, client, docClient, true) if (!Object.keys(query).length) return try { const result = await client.views.open({ trigger_id: body.trigger_id, view: { type: 'modal', callback_id: 'aq-edituser', title: { type: 'plain_text', text: 'ユーザデータの編集', }, submit: { type: 'plain_text', text: '編集📝', emoji: true, }, blocks: [ { type: 'input', block_id: 'type', element: { type: 'static_select', placeholder: { type: 'plain_text', text: 'ユウシャ', emoji: true, }, initial_option: { text: { type: 'plain_text', text: query.Item.JobType, emoji: true, }, value: query.Item.JobType, }, options: [ { text: { type: 'plain_text', text: 'フロントエンドエンジニア', emoji: true, }, value: 'フロントエンドエンジニア', }, { text: { type: 'plain_text', text: 'バックエンドエンジニア', emoji: true, }, value: 'バックエンドエンジニア', }, ], action_id: 'type', }, label: { type: 'plain_text', text: '種別', emoji: true, }, }, ], }, }) } catch (err) { console.error(err) } }) // モーダル情報のlisten app.view('aq-edituser', async ({ ack, body, view, client }) => { await ack() const values = view.state.values const user = body.user const typeString = values.type.type.selected_option.text.text /** * DB保存 */ const user_id = user.id const user_name = user.name const type = values.type.type.selected_option.value await editUser( { user_id, user_name, type, }, docClient ) /** * メッセージ送信 */ try { await client.chat.postMessage({ channel: process.env.ROOM_NAME, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `冒険の書を上書きしました📝 *【名前】*: ${user_name} *【種別】*: ${typeString} `, }, }, ], }) } catch (err) { console.log(err) } }) }
動くとこんな感じです。
デプロイ
serverless.ymlを配置しているフォルダにて、
$ serverless deploy
を実行すればOKです。Serverlessはデプロイがとてもお手軽ですね。
stageを指定したい場合は、$ serverless deploy --stage pro
というようにオプションを追加します。
特定のLambdaだけをデプロイする場合は$serverless deploy function -f 関数名
で実行できます。
API GatewayのURLをメモって、先程ngrokのURLを登録したSlack APIのURL認証に登録しなおします。
これで無事Slack上でアプリを稼働させることができました!
最後に
とても簡単なアプリですが、AWS初挑戦だったので覚えることが沢山あり、苦戦しましたがまた同時にとても楽しかったです。
今後外部サービス連携を強化してもっと実用的なアプリにしていきたいです。
また、Lambdaをモノリスで構成したので、アプリが大きくなってきたら切り分けなども要検討だと思っております。
お決まりの句にはなりますが、Wizではエンジニアを募集中です。
興味のある方は是非覗いてみてください!↓