こんにちは、フロントエンドエンジニアの高野です。
最近AWSで簡単なSlackアプリを作成したので、その記録をまとめていきます。
今回話すこと/話さないこと
話すこと
- Slack側のapp初期設定について
- 開発環境の構築について
- Lambdaのフォルダ構成について
話さないこと
- DynamoDBの詳しいテーブル構成 / 実装方法について
- Boltの細かい実装について
作りたいもの
Wizでは毎日の業務報告をSlack上で報告しています。
この報告をGitHubやタスク管理ツールとの連携、
指定期間の業務内容の抽出などをできるようにして楽した〜い! というのが主なモチベーションです。
まずはその足掛かりとして、今回は
毎日の業務報告内容をDBに保存する という簡単なアプリを作成しました。
※AWSについては初めての実装だったので、至らない点あるかもしれません...
コメントなどで指摘いただけると嬉しいです。
主な構成
バックエンドアプリケーションとしては SlackのフレームワークであるBoltを採用しました。サーバーレス!なシンプルな構成です。
CloudWatch Logsを使用して各種ログを保存しています。
また、開発環境/デプロイツールとしてはServerless Frameworkを起用しています。
www.serverless.com
各種コマンドがSlackで叩かれる-> API Gatewayを介してLambdaが走り応答する->場合によってdynamoDBからデータを受け取ったり保存したりする
というのが基本的な流れです。
前述したように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です。
デプロイや実行で課金されずにテストをすることができます。
github.com
serverless-dynamodb-local
serverless-offline
はLambdaのみのエミュレートなので、dynamoDBを動かす場合はこのままではローカルテストできません。
なので、また別でserverless-dynamodb-local
というプラグインを入れました。
www.serverless.com
serverless-dotenv-plugin
こちらのプラグインはenvファイルをserverless上で読み込むためのプラグインです。
www.serverless.com
また、SlackでAPIを叩くにはlocalhostは当然使用できないので、
ローカルのポートを外部公開するためにngrok
を使用します。
ngrok.com
ダウンロード後、
$ ngrok http 3000
と叩けば、 ローカルの3000番ポートを外部公開してくれます。
上記だと、localhost:3000の内容が伏せている部分(https://xxxxxxxxxx.ngrok.io) として外部公開されているということになります。
少し複雑になってきたので図式化します。
Slack側で必要な設定
Slack側ではまず、開発者専用のページを開きアプリを登録する必要があります。
api.slack.com
まずはBoltの認証部分で必要な
の二つを探し出します。
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'
const AWS = require('aws-sdk')
AWS.config.update({
region: 'ap-northeast-1',
})
const docClient = new AWS.DynamoDB.DocumentClient()
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,
})
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 }) => {
await say(`See ya later, <@${message.user}> :wave:`)
})
const awsServerlessExpress = require('@vendia/serverless-express')
module.exports.app = awsServerlessExpress({
app: expressReceiver.app,
})
また、実際にSlackのUIを構築する際は公式のBlock Kit Builderが非常に重宝しました。(閲覧にはログインが必要です。)
app.slack.com
ぽちぽち必要なコンポーネントを選択していくと、右にblockのコードが出力されます。
これをいい感じにリファレンスを見ながら加工しつつ、自分のコードに落とし込んでいきました。
一例として一つのslash commandのファイルを紹介しておきます。
require('date-utils')
const { checkRegist } = require('../validate/checkRegist')
const { putReport } = require('../db/report/putReport')
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)
}
})
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
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ではエンジニアを募集中です。
興味のある方は是非覗いてみてください!↓
careers.012grp.co.jp