SlackApp快速開発 - ローカル開発環境構築(with Bolt \+ TypeScript)

概要

SlackAppを快適にしかも(比較的)高速につくる知見がついてきたのでいったんまとめてみます。BoltというSlack公式のフレームワークをTypeScriptを使って書きます。SlackApp、Bolt、TypeScriptの詳細については各ドキュメントを参照してください。

今回は一歩づつ分けてどのように構築していくかを書いてみます。必須ではないですが、SlackAppで扱うリクエストとレスポンスの知見があるとスムーズです。

シリーズ:

Bolt is何?

BoltはSlackが公式で開発しているNode.js用SlackApp用のフレームワークです。内部的にはExpressが使われているようで、同様に薄くて軽量かつSlackからのリクエストに対応しやすいように作られています。特にinteractive componentsやmodalに関しての書きわけがしやすい印象です。

なぜBoltとTypeScript?

単純で簡素なものはサーバーレス構成で作って、CloudFunctionやLambdaなどのFaaSで動かすほうが楽で早いと思います。それでもあえてBoltを選択したのは

  • Slackの一部の機能には3秒レスポンスの壁があるので、FaaSのコールドスタートがキツかった
  • イベントリスニングに対しての書きわけがしやすい => モジュールによる分割がしやすい
  • エンドポイントは1つでまとまる => Slack側で変更が必要ない
  • 別途nodemonなどを使って開発時の変更適用が快適

という理由ですね。
さらにTypeScriptを使う理由は

  • ES6以降の書き方が簡単に導入できる
  • 長大で複雑になりやすいSlackへのリクエストが型を使うと書きやすくなる

という理由が大きいです。Webpackなどのビルドツールは使わず、TypeScriptのtscでコンパイルします。

ちなみにFasSでもBoltは使えますが、Function起動ごとにサーバーも起動させるので無駄が多く、当然反応遅くなります。書き方はシンプルでもやることが冗長になってしまいます。

構成と戦略の概要

今いちど構成をおさらいすると

  • サーバーはBolt(厳密に言えば含まれているExpress)
  • TypeScriptで書く
  • 開発環境ははtsnodeでTypeScriptを直で動かす
  • 開発中はnodemonで変更をwatchしてすぐにサーバーに反映
  • 動作確認もすぐできるようにserveoを使ってトンネリング
  • デプロイ時はtscのみ(の非Webpack)でJSにトランスパイル

ディレクトリ構成

基本のディレクトリ構造はこんな感じ

.
├── .env # 環境変数格納ファイル
├── package.json # プロジェクトやライブラリの設定
├── Procfile # 開発環境起動設定ファイル
├── nodemon.json # nodemon用設定ファイル
├── dist # buildされたコードの格納先
└── src
    ├── commands # スラッシュコマンド用コード ディレクトリ
    ├── index.ts # サーバー起動コード
    ├── initializers # Boltなどの初期化コードディレクトリ
    ├── listeners # ModalやAction,イベントのリスニング用コード ディレクトリ
    └── types # 型定義ディレクトリ

Gitまわりのファイル、ESLintやテストに関する設定は省略しています。

簡単に言えば、開発用のコードは/src配下に書いていき、tscコマンドでコンパイルしたものが/distに出力される流れになります。

TypeScript環境を構築する

まず

npm install typescript # or yarn add typescript

でTypeScriptを入れます。これによって.tsファイルをtscコマンドで.jsに変換できます。TypeScript自体でES6以上の書き方ができるので、型ガチガチに書かかずとも簡易Babel的に使うこともできます。

そのコンパイルにはtsconfigという設定ファイルが必要なので

npx tsc init

tsconfigファイルが設定できます。

1つの例として僕のconfigを紹介します

{
  "compilerOptions": {
    "outDir": "./dist",
    "module": "commonjs",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "noImplicitAny": false
  },
  "compileOnSave": true,
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}

書き方に関する部分は公式ドキュメントなどを参照してください。
ここでのポイントは

{
  "compilerOptions": {
    "outDir": "./dist"
  }
}

です。.ts.jsに変換したものを/distディレクトリに出力します。

もうひとつ注意点としては、モジュールのパス解決をしやすいように下記のようにとbaseUrlpathsの設定をしたくなるところですが、あえてやりません

// やらない例
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": "src/*",
    }
  }
}

これはエディタやTSでは問題なく解釈されるものの、コンパイル時に適切に変換されるわけではないのでJSにしたときに動作しません。
別のライブラリを使えば正しく扱えますが、今回はそこまで深くディレクトリを作っていく想定ではないので相対パスだけでなんとかしていきます。

SlackとBoltのセットアップ

まずSlackApp自体のセットアップに関しては

からCreateNewAppします。もちろんそれ以前に開発に使うSlackワークスペースがあることが前提です。
AppをSlackにインストールしたら、Sigining SecretBot Tokenのキーをそれぞれ発行してコピーしておきます。

次にBoltをイントールします

npm install @slack/bolt

そうしたら、まずBolt起動用のファイルsrc/index.tsに作ります。

const { App } = require(`@slack/bolt`)

// Initializes your app with your bot token and signing secret
const config = {
  token: "xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx",
  signingSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
}

const app = new App(config)

// Start your app
;(async () => {
  const server = await app.start(process.env.PORT || 3000)

  console.log(`⚡️ Bolt app is running! PORT: ${server.address().port}`)
})()

tokensigningSecretにはそれぞれSlackの設定で表示されたものを入力します。

そして起動用のコマンドをpakcage.jsonscriptに設定します

{
  "scripts": {
    "prestart": "tsc -p .",
    "start": "node ./dist/index.js",
  },
}

設定後、CLI上で

npm run start # or yarn start

を入力します。
prestartstart時に自動で実行されます。これによってTSファイルがJSに変換されて、startコマンドで変換されたものが実行されます。

開発環境をTypeScriptそのままで動かす

開発中に変更の度にコンパイルしなおすのは手間なので、TypeScriptそのままで動かします。そのためにtsnodeというライブラリを使います。

npm install --save-dev tsnode

そうしたら、開発用のサーバー起動コマンドとしてpackage.jsonscriptに追記します。

{
  "scripts": {
    "prestart": "tsc -p .",
    "start": "node ./dist/index.js",
    "dev": "node --inspect -r ts-node/register ./src/index.ts"
  },
}

これで、

npm run dev # or yarn dev

とコマンドを叩けば先ほどと同じように、でもTypeScriptを直接実行する形でサーバーが動きます。

Serveo経由でSlackとつなぐ

/echo '文字列'とスラッシュコマンドで発言したら、入力した文字列を返すだけの簡単コマンドを設定して動作確認までやってみましょう。

まず、先程のindexの初期化部分を分離しちゃいます。
initializers/bolt.tsを作成して、

const { App } = require(`@slack/bolt`)

// Initializes your app with your bot token and signing secret
const config = {
  token: "xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx",
  signingSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
}

export const app = new App(config)

と書きます。モジュールとしてexportするようにしました。

つぎに、commands/hello.tsを作り、

import { app } from '../initializers/bolt'
export default function() {
  app.command(`/echo`, async ({ command, ack, say }) => {
    ack()

    say(`${command.text}`)
  })
}

と書きます。先程のBolt初期化を使いまわし、Boltの文法で/echoに対する処理を書いています。

そして、index.tsを編集します。

import { app } from './initializers/bolt'
import echo from './commands/echo'

;(async () => {
  // Start your app
  const server = await app.start(process.env.PORT || 3000)

  console.log(`⚡️ Bolt app is running! PORT: ${server.address().port}`)
})()

echo()

同じく初期化を使いまわしつつ、echoコマンドを使えるようにします。

そしたらこれをServeoというlocalhostとトンネリングする仕組みで公開します。
Bolt起動中とは別のCLIを開いて

ssh -R 80:localhost:3000 serveo.net

を叩きます。その後に表示されるserveo.netのURLが公開されているURLです。Boltではデフォルトで/slack/eventsというエンドポイントが使われます。例えばServeoのURLがhttps://utilis.serveo.netならSlackAppの設定のSlashCommandsに登録するURLはhttps://utilis.serveo.net/slack/eventsになります。登録したら忘れずにreinstallしましょう。

これでインストールされたSlackワークスペースで/echo テストと入力すればAppからテストとメッセージが送信されるはずです。

autosshでつなぐ

serveoとのsshは切れたりするので、autossh経由でつないできれたら自動で再接続するようにします。
macOSなら

brew install autossh

でインストールできるはずです。serveoにautossh経由でつなぐなら

autossh -M -0 -R 80:localhost:3000 serveo.net

とします。

nodemonで開発中のコードをすぐにサーバーに反映する

さきほど、tsnodeでTypeScriptを直で読ませるようにしましたが、これでもコードを更新したらサーバーも立ち上げなおさないと反映されません。この手間を省くためにnodemonを使って、保存と同時に反映するようにします。

npm install --save-dev nodemon

そしてnodemon.jsonファイルを作成して

{
  "watch": [
    "src"
  ],
  "ext": "ts",
  "exec": "node --inspect -r ts-node/register ./src/index.ts"
}

とし、合わせてpackage.jsonのscriptも修正します

{
  "scripts": {
    "prestart": "tsc -p .",
    "start": "node ./dist/index.js",
    "dev": "nodemon",
  },
}

そして、Boltが起動中ならいったん終了します。再度

npm run dev

で立ち上げなおすと今度はnodemon経由で起動してるはずです。この状態でさきほどのechoコマンドを少し変更してみます。例えば、

import { app } from '../initializers/bolt'
export default function() {
  app.command(`/echo`, async ({ command, ack, say }) => {
    ack()

    say(`発言: ${command.text}`)
  })
}

のように。このファイルを保存してSlackで/echo テストと入力したら発言: テストとメッセージが送信されるはずです。

foremanで開発用サーバーとserveoをまとめて実行しつつ環境変数も扱う

ここまででもだいぶ快適に開発していけるようになっていますが、さらにもう一歩いきます。
node-foremanというライブラリを使うと、複数のサーバーを1つのコマンド上で立ち上げることができます。また、foremanは環境変数も.envファイルから読んでくれます。

まずは、秘匿情報を環境変数化します。最初にベタ書きしたBotTokenとSigningSecretですね。.envファイルを作り、

PORT=3000
SERVEO="slack-app-example"
SLACK_BOT_TOKEN= "xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx",
SLACK_SIGNING_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",

と書きます。PORTSERVEOを追加していますが、後述します。
Slackの秘匿情報を環境変数に移したので、コード側からは環境変数で呼ぶようにします。具体的にはinitializers/bolt.tsを編集します。

const { App } = require(`@slack/bolt`)

// Initializes your app with your bot token and signing secret
const config = {
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
}

export const app = new App(config)

そして、foremanで動作させる設定はProcfileに書きます

Bolt: npm run watch
Serv: autossh -M -0 -R ${SERVEO}:80:localhost:3000 serveo.net

とします。.envで設定したPORTがProcfileで実行される最初のポート番号になります。serveoはポート番号の前に任意の文字列をとることで、公開されるServeoサーバーのサブドメインを固定できます。この場合はhttps://slack-app-exmaple.serveo.netでの公開に固定できます(もちろん任意の文字列で良いので自由に環境変数SERVEOを設定して大丈夫です)。これをしておくとSlackApp側の設定をいちいち変更しなくて良くなります。これが可能なのでngrokではなくServeoを使います。

なので、先程まで自動で振られてたサブドメインをSlackAppに設定していたと思いますので、あらためてスラッシュコマンドの設定をhttps://slack-app-exmaple.serveo.netに変更しておきます。

あと1つ、これを実行するためのもの、Bolt用のwatchコマンドをpackage.jsonscriptsに設定します。

{
  "scripts": {
    "prestart": "tsc -p .",
    "start": "node ./dist/index.js",
    "watch": "nodemon",
    "dev": "nf start",
  },
}

ここまで設定ができたら、いったん全てのサーバーを終了させ、

npm run dev

を実行すると、Boltが立ち上がり同時にServeoとつなげて公開されます。ここまでやると、サーバー立ち上げ、接続はコマンド一発でおこなえて、毎回固定のURLでローカルサーバーとSlackを接続でき、さらにコードを変更しても保存するとすぐにSlack側から使うことができるようになります。

感想

ステップごとに説明してるのでかなり長い説明になってしまっていますが、やってることは最終的にはシンルです。いろいろ使ったことある方はすぐにセットアップできるでしょう。僕はあまり「爆速」という言葉が好きではないので使いませんが、少なくともセットアップした後はいろんなことを意識せず快適にかつ高速に、つまり「快速」で開発していくことができます。

次回はGAEにCircleCIにデプロイします
SlackApp快速開発 - デプロイ(GAE with CircleCI) | Trial and Spiral