Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Sansan iOS アプリにおけるリリース作業自動化の仕組みを作り直した話 ~ Bolt 編 ~

こんにちは、Sansan 技術本部 Mobile Application Group に所属している iOS アプリエンジニアの相川です。

本記事は「Sansan iOS アプリにおけるリリース作業自動化の仕組みを作り直した話」の 2 作目にあたります。
仕組みを作り直すことになった詳しい背景などについては、弊チームの多鹿による 1 作目の「背景編」をご覧いただければと思います。

buildersbox.corp-sansan.com

本記事では Bolt という framework を用いて、Slack 上での対話的なリリース作業自動化の仕組みを作り直した話について紹介しようと思います。

目次

記事が長くなってしまったので、最初に目次を示しておきます。

どのようなリリース作業を実現したのか?

開発に関する話を紹介する前に、Bolt framework を用いて実現したリリース作業がどのようなものなのかについて簡単に紹介します。
リリース作業の全体像については、1 作目の「背景編」を参照して頂ければと思います。

前提として既存のリリース作業は、大きく以下のように分けることができます。

  • 「deliver」コマンドによって起動するリリース用の作業
  • 「cleanup」コマンドによって起動するリリース掃除用の作業

リリース作業自動化の仕組みを作り直す案件は、作業量が多かったため、第 1 弾と第 2 弾に分割して進めていくことになりました。
第 1 弾では、「cleanup」コマンドによって起動するリリース掃除用の作業の置き換えを行なったため、その部分についてのみ本記事では説明します。

とは言っても、置き換えを行なった「cleanup」コマンドによる対話的なリリース作業の流れは非常にシンプルです。

まずリリース作業担当者は以下の図のように、@AppleBoltDevelop cleanup(本番では @AppleBolt cleanup) というメッセージを Slack に入力します。

f:id:shiori_baba:20220228104653p:plain
@AppleBolt cleanup でブランチ選択 UI が表示される

そうすると、release から始まるブランチ一覧が Bolt アプリによって表示されます。
この一覧から任意のブランチを選択すると、以下の図のような状態になります。

f:id:kalupas:20220218093010p:plain
元のブランチ選択 UI がメッセージで置き換わる

「Release workflow's trigger succeeded!」というメッセージにより、元のブランチ選択 UI が置き換えられていることがわかります。
元の UI をそのままにしておくと、何度もブランチ選択ができてしまうため、メッセージで置き換えるようにしています。
この裏側では Bolt アプリの内部で GitHub Actions の workflow を呼び出す処理が行われていますが、インタラクション部分としては上記のように非常にシンプルなものとなっています。

Bolt framework とは?

どのような作業を実現したかについて、大まかに説明できたところで開発の話に移っていこうと思います。
本章では、ここまでに何度か登場している Bolt framework について紹介します。

Bolt framework は、Slack が提供している Slack bot を作成するための framework です。
SlackAPI のリポジトリ を見たところ、記事執筆時点では bolt-js , bolt-python , java-slack-sdk が提供されているため、JavaScript・TypeScript・Python・Java・Kotlin であれば framework の力を借りて Slack bot (以降 Bolt アプリ) を開発できそうです。

1 作目の「背景編」で多鹿が紹介しているように、Sansan iOS チームではメンバーの技術スタックなどから考えた結果、JavaScript を用いて Bolt アプリを開発することにしました。

Bolt を用いた対話的なリリース作業の実現

Sansan iOS チームでは、Bolt を用いることによって Slack 上での対話的なリリース作業を行うための Bot を置き換えました。 本章では、以下の流れで Sansan における Bolt の利用方法などについて説明します。

  • Bolt アプリ開発のための前準備
  • Bolt アプリの実装 Tips

また、Bolt 自体の基本的な開発環境構築方法や利用方法については、Slack が提供している Bolt 入門ガイドに詳しく記載されているため、そちらに譲らせて頂ければと思います。
本記事でこれから説明する内容は、上記入門ガイドの内容をある程度把握した前提のものになるため、もし記事中でよくわからない部分があれば入門ガイドを参照して頂けたら嬉しいです。

それでは少しずつ説明していきます。

Bolt アプリ開発のための前準備

本節では、Bolt アプリ開発のための前準備として行った以下について説明します。

  • Bolt アプリのローカル開発環境の構築
  • Bolt アプリのベースコードの準備

Bolt アプリのローカル開発環境の構築

まず、Bolt アプリのローカル開発環境の構築方法について説明します。

先ほど紹介した Bolt 入門ガイドの中では、ngrok というプロキシサービスを利用することによって、ローカル上で Bolt アプリの開発を実現する方法が紹介されています。
しかし、本案件内で ngrok を利用するとなると、ngrok がセキュリティ的に問題がないものか社内のセキュリティチームを通して確認する作業なども発生し、工数が多少かかることが予想できました。
リリース作業の時間を削減するための案件で、工数をかけることは避けたかったため、ngrok 以外の楽な方法があればそちらを採用したいと思っていました。

そこで、少し調査をしてみたところ Netlify Functions を利用すれば、ローカル上での Bolt アプリの開発が可能になるという記事を見つけることができました。
以下がその記事になります。

levelup.gitconnected.com

iOS チームで以前から利用していたリリース用 Bot は Netlify で動作させていたという背景があり、今回開発する Bolt アプリのデプロイ先も Netlify にしようと思っていました。
その上 Netlify を利用すればローカル上での開発を実現できることも判明したため、ローカル/本番 ともに Netlify を利用して Bolt アプリを開発していくことにしました。

上記で紹介した記事に Netlify 上で Bolt アプリを動作させるための基本的な方法については記載されているため、本記事ではその部分については割愛します。

Bolt アプリのベースコードの準備

参考記事では Bolt アプリを TypeScript によって開発し、それを Netlify にデプロイする方法について説明されています。
TypeScript による Bolt アプリについては、記事の著者が GitHub Template Repository まで用意してくださっているため、それを利用すればすぐにでも開発を進めることが可能になっています。

github.com

しかし、今回私たちのチームでは Bolt アプリを JavaScript によって開発する必要があったため、TypeScript の例をもとにコードを書き直そうかなと考えたりしました。
とは言え、それ自体もまあまあなコストであるため「もしかしたら JavaScript 用の Template も用意してくれてたりしないかな?」と考えて著者のリポジトリを漁ってみました。

すると期待通り JavaScript 用の Template Repository も作ってくださっていました!(本当にありがたい)

github.com

上記 JavaScript 製の Template をベースとすることによって、開発環境構築にかかる時間を大幅に削減することができました。
コードについては JavaScript 製のものを利用しましたが、開発環境の構築方法についてはほぼ 参考記事の内容に沿ったものにしたため、詳しくはそちらをご参照頂ければと思います。

Bolt アプリの実装 Tips

ここからは、Bolt アプリを開発した経験をもとに以下のような Tips を紹介したいと思います。

  • Bolt アプリで利用したライブラリについて
  • Lint とテストについて
  • Netlify による hot reload 機能を利用した開発方法について
  • Slack の interactivity に対応するために変更した実装について
  • Slack に送信するメッセージの簡単な作り方について
  • Slack に表示されているメッセージを置き換える方法

Bolt アプリで利用したライブラリについて

今回開発した中で利用したライブラリは以下の通りです。

  • @slack/bolt
    • Bolt framework。これがないと Bolt アプリを開発できません。
  • axios
    • 言わずと知れた HTTP client ライブラリ。Bolt アプリ内で GitHub Actions の workflow_dispatch*1を含む、いくつかの API を叩く必要があり、その処理を簡潔なものにするために導入しました。
  • dotenv
    • 環境変数をローカル/本番で楽に扱えるようにするために導入しました。参考記事の中でも利用しています。
  • eslint
    • 詳細は後述しますが、JavaScript のコードに Lint をかけるために導入しました。
  • chai
    • テストで利用している assertion ライブラリ。参考記事の中でも利用しています。
  • mocha
    • BDD でテストを記述できるようにするライブラリ。参考記事の中でも利用しています。

自分自身 JavaScript に精通しておらず、極力ライブラリには依存させたくはなかったため、メジャーなライブラリ && 最低限必要と思うもののみ導入するようにしました。

Lint とテストについて

メンバーの大半が JavaScript に精通しているわけではなく、Linter を導入せずに開発を行えるようにしてしまうと無法地帯となってしまう可能性がありました。
また、Bolt のコードはそこまで頻繁に触るものでもないため、開発者ごとのコードのバラつきはできる限り抑えてメンテナンスコストを下げたいという思いがありました。

そのため、eslint を導入する際に設定する rule としては、厳しめな eslint-config-airbnb を利用することにしました。

www.npmjs.com

eslint を導入したことによって、自分自身が書いた雑な JavaScript のコードを矯正することもできたので良かったと思います。

また、テストについては Template Repository 内で既に利用されていた mocha と chai によるテストコードを多少 Sansan 用に修正する形で整備しました。

Lint とテストについては、CI で実行できるようにしておきたかったため、以下のような非常に簡素な workflow を用意して push ごとに実行できるようにしました。

name: ESLint and test

on: push

jobs:
  lint-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set Node.js 17
        uses: actions/setup-node@v2
        with:
          node-version: '17'
      - run: npm install
      - run: npm run lint
      - run: npm run test

package.json 内で以下のように設定しているため、workflow ではそれを実行しているだけという仕組みになっています。

{
    "name": "apple-bolt",
    "description": "Netlify Functions integrating Slack using Bolt JS framework",
    "version": "1.0.0",
    "author": {
      // ...
    },
    "scripts": {
        "link": "netlify link",
        "build": "npm run test && netlify build",
        "test": "mocha tests/**/*.test.js",
        "go": "netlify dev --live",
        "lint": "eslint ."
    },
    "devDependencies": {
      // ...
    }
}

Netlify による hot reload 機能を利用した開発方法について

参考記事の中では、 Netlify Dev CLI というツールも紹介されています。
こちらのツールを導入していれば netlify dev --live というコマンド*2を利用することができるようになっています。

このコマンドを実行すると、開発用のサーバーを HTTPS 上に立ち上げることができます。
これにより、ローカル上のサーバーからインターネット上にトンネルを作ることができるため、立ち上げられた開発用のサーバーに対しては URL を知っていればアクセスできる public な状態となります。
Sansan では、このコマンドにより生成される URL を利用することによって、開発環境の Slack アプリと Bolt アプリを紐付けています。

このコマンドの良いところは、コマンド実行中に Bolt アプリのコードを変更した場合、コードを保存するだけで再ビルドの必要なく、その変更が実行中のアプリにも反映されるというところです。(いわゆる hot reload 的なものになります)

このコマンドを利用することによって、コード変更の度にビルドすることなく開発ができるようになったため、非常に効率良く開発を進めることができたと感じています。(いつも iOS アプリの長いビルド時間に苦しめられているので、hot reload を久しぶりに味わえてよかったです)

Slack の interactivity に対応するために変更した実装について

前述したように、Templete Repository の内容をベースに、適宜 JavaScript のコードを記述していけばほとんどの開発はスムーズに行うことができていました。

しかし Slack の interactivity という動作を Bolt アプリで処理するためには一工夫する必要がありました。
その工夫について説明する前に、Slack の interactivity について簡単に説明しようと思います。

参考記事の中で詳しくは解説されていますが、Bolt アプリは Slack 上のメッセージやアクション(メッセージ中の Button をタップしたり、Select menu を選択したりなど)に反応するために、Slack 上のイベントをリッスンしています。

f:id:kalupas:20220207085445p:plain
Slack 上のアクション

Bolt アプリは、それぞれのイベントの詳しい内容を特定の形式のレスポンスとして受け取ることができます。
しかし、それぞれのイベントのレスポンスの形式は、実は異なってしまっているという問題があります。
例えば 「Slack 上の単純なメッセージ(以降 message)」「Slack 上の Button や Select menu などのアクション(以降 interactivity)」「Slack 上のスラッシュコマンド(以降 slash command)」は全て異なるレスポンス形式になっています。

これらのイベントを Bolt アプリでどのようにパースしているかについて、少し見ていくことにします。
元々の GitHub Template のパース部分のコードは以下のようになっていました。

function parseRequestBody(stringBody, contentType) {
    try {
        if (!stringBody) {
            return "";
        }

        let result = {};

        if (contentType && contentType === "application/json") {
            return JSON.parse(stringBody);
        }

        let keyValuePairs = stringBody.split("&");
        keyValuePairs.forEach(function (pair) {
            let individualKeyValuePair = pair.split("=");
            result[individualKeyValuePair[0]] = decodeURIComponent(individualKeyValuePair[1] || "");
        });
        return JSON.parse(JSON.stringify(result));

    } catch {
        return "";
    }
}

上記の処理を言葉で説明すると以下のようになっています。

  • 受け取った Response Body が空であれば空文字を return する
  • contentTypeapplication/json であれば JSON.parse し return する
  • そうでなければ、& や = で split したりした後で JSON.parse したものを return する

上記の 2 番目に当たる処理が「message」を parse するための処理であり、3 番目に当たる処理が「slash command」を parse するための処理となっています。

なぜこのような処理になっているかは、実際に返却されるレスポンスを見てみると理解することができます。
まず、「message」のレスポンスは以下のようになっています。

body: '{"token":"some_token","team_id":"your_team_id","api_app_id":"your_api_id"}'

見てみるとわかりますが、単純な JSON の形式になっているため、受け取った Response Body をすぐに parse すれば問題ないということになります。

一方、「slash command」のレスポンスがどのようになっているかを見てみましょう。

body: 'token=some_token&team_id=your_team_id&team_domain=your_workspace'

単純な JSON の形式ではないことがわかります。
これを parse するために、先ほどの split による処理などを行っていたということがわかります。

では interactivity はどのようなレスポンスになっているかも見ていこうと思います。

body: payload=%3D%7B%22type%22%3A%22block_actions%22%2C%22user%22%3A%7B%22id%22%3A%22hoge%22%2C%22username%22%3A%22fuga%22%7D%7D

このように interactivity の場合はエンコードされた形式になっていることがわかります。
Sansan の Bolt アプリでは、現状「slash command」は利用しないため parse する必要がなく、parse したいのは「message」と「interactivity」のみだったため、処理を以下のように書き換えることで対応しました。

function parseRequestBody(stringBody, contentType) {
  try {
    if (!stringBody) {
      return '';
    }
    // message のための処理
    if (contentType && contentType === 'application/json') {
      return JSON.parse(stringBody);
    }
    // interactivity のための処理
    const payload = stringBody.split('=');
    const decodeResult = decodeURIComponent(payload[1] || '');
    return JSON.parse(decodeResult);
  } catch {
    return '';
  }
}

レスポンスの形式に応じて処理を変更しているため、少々不安定かもしれないという印象ではあります。
今後のために、parse 処理のテストを書いて parse 処理が少しでも変更しやすくなるように心がけました。

Slack に送信するメッセージの簡単な作り方について

次に Slack が提供している Block Kit Builder というツールを紹介します。

Bolt アプリでは、Slack からの message や interactivity への応答の方法として say という function を利用すると、Slack 上に任意のメッセージを送ることができます。
例えば、以下は Slack 上の knock, knock というメッセージに反応して、_Who's there?_ というメッセージを Slack に送信する例になります。

app.message('knock knock', async ({ message, say }) => {
  await say(`_Who's there?_`);
});

この say function には任意の形式の blocks というものを指定することによって、Slack 上に Button が付いたメッセージや Select menu が付いたメッセージなど、様々な種類のメッセージを送ることができます。
この blocks を簡単に構築できるようにしてくれるツールが Block Kit Builder というツールになります。

f:id:kalupas:20220207103914p:plain
Block Kit Builder の UI

上の図のように、左側にある項目からポチポチとアイテムを選択するだけで様々な種類の blocks を構築することができます。
Slack に送信するメッセージの UI に拘らないのであれば、基本的には事足りると感じるので blocks を構築する際には積極的に活用すると良さそうです。

Slack に表示されているメッセージを置き換える方法

記事の冒頭で紹介しましたが、リリース作業の中ではブランチ選択 UI をメッセージで置き換えるという処理を行なっていました。

f:id:kalupas:20220218093010p:plain
メッセージで置き換えている様子

こちらは、Bolt が提供している respond という function を利用することによって実現しています。

コードを見ながら respond の使い方について解説します。

まず、@AppleBolt cleanup のメッセージによって反応するコードは以下のようになっています。

// directMention を利用することによって Slack アプリのメンション (@AppleBolt) に反応することができる
app.message(directMention(), 'cleanup', async ({ say }) => {
  // リリースブランチ一覧を取得する
  const options = await createReleaseBranchListOptions();

  if (options.length > 0) {
    await say({
      blocks: [
        {
          text: {
            type: 'mrkdwn',
            text: 'Branch:',
          },
          type: 'section',
          accessory: {
            type: 'static_select',
            placeholder: {
              type: 'plain_text',
              text: 'Select an item',
              emoji: true,
            },
            options: options,
            action_id: 'select_action_in_release_branch_list',
          },
        },
      ],
    });
  } else {
    // ...
  }
});

これによって @AppleBolt develop という Slack 上のメッセージに反応して、リリースブランチ一覧からブランチを選択できる UI が Slack 上に投稿されます。

action_id として select_action_in_release_branch_list というものを指定しており、これによってブランチが選択された時の action を発火させます。
この action_id によって発火する action のコードは以下のようになっています。

app.action(
  'select_action_in_release_branch_list',
  async ({
    body, ack, say, respond,
  }) => {
    await ack();

    const selectedOptionValue = body.actions[0].selected_option.value;
    const responseStatus = await triggerReleaseWorkflow(selectedOptionValue);

    if (responseStatus === 204) {
      await respond({
        replace_original: true,
        text: "Release workflow's trigger succeeded!",
      });
    } else {
      // ...
    }
  },
);

app.action に同名の action_id を指定することによって、こちらのコードの action が発火します。

今説明しようとしている respond のコードを、上記のコードから以下に抜粋し説明していきます。

await respond({
  replace_original: true,
  text: "Release workflow's trigger succeeded!",
});

使い方はシンプルですが、replace_original という引数を true に指定することによって、action 発火の要因となったメッセージを置き換えることができます。
ここでは text を指定していますが、任意の blocks を指定すれば様々な種類の UI で元のメッセージを置き換えることができます。

おわりに

Slack アプリを開発するために Bolt framework を学ぶ際の学習コストは、比較的少ないかなと感じました。
なぜなら、基本的には Bolt framework が用意してくれる簡易な構文を利用しつつ、少量の JavaScript を記述すれば Slack アプリを開発することができるからです。
Bolt アプリは簡単に開発できるので、今後も Bolt アプリによって様々な業務を効率化してみたいなと思いました!

また、1 作目の「背景編」で紹介されているように Sansan の Bolt アプリは、リリース作業のうちの対話的な部分のみが責務となるように実装し、それ以外の責務は GitHub Actions に負ってもらうという仕組みにしています。
責務も切り分けられているため、今後自分以外の誰かが整備するとなった場合もそこまで苦労することはないのかなと想像していますが、もちろん Bolt に関する社内ドキュメントの整備も行いました。

本記事では Bolt アプリに焦点を当てた説明を行いましたが、次回は Bolt アプリを起点として発火する GitHub Actions についての記事が続く予定です。そちらもぜひご覧いただければ幸いです🙏

*1:API 経由で GitHub Actions の workflow を発火させることができる。詳細は https://docs.github.com/ja/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch を参照してください。

*2:コマンドについての詳細は https://docs.netlify.com/cli/get-started/?_ga=2.239970691.168044032.1644126468-1975190935.1640064409#share-a-live-development-server を参照してください。

© Sansan, Inc.