シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

クロスブラウザテストツールに LambdaTest を導入しました

こんにちは。開発部の竹内です。

Web開発において、異なるブラウザやデバイスでの表示・動作確認は欠かせません。しかし、物理デバイスでの確認には限界があり、すべての環境を網羅するのは困難です。より効率的にクロスブラウザテストを実施するために、クラウドベースのテストツール LambdaTest を導入しました。本記事では、その導入の背景や運用、課題について紹介します。

背景

開発時に Web ページのクロスブラウザでの確認が十分にできていないことがありました。特に iOS や Android といったモバイル環境の確認は開発者が私物のデバイスを使って行っており、以下のような課題がありました。

  • 確認できる人が限られる
  • 一部のブラウザ・OS環境での確認が漏れる可能性がある
  • 物理デバイスを用意するコストと手間が発生する

このような課題を解決し、開発者やデザイナーが手軽にクロスブラウザテストを行える環境を整備することを目的として、クラウドベースのテストツールを導入することにしました。

ツール選定

クロスブラウザテストが可能なツールはいくつか存在しますが、以下の要件を重視して選定を行いました。

  • 手動テストに対応していること
    • 今回は自動テストはスコープから外し、手動での動作確認が可能であることを条件としました。
  • コストが安価であること
    • 他のクラウドテストツールと比較し費用対効果の高いものを選択しました。

この2点を重視し、最終的に LambdaTest を導入することに決定しました。

LambdaTest でできること

LambdaTest を導入することで、以下のような環境でのクロスブラウザテストが可能になりました。

  • Windows・MacOS の各種ブラウザでのテスト
  • Android・iOS の各種ブラウザでのテスト
  • Android・iOS のアプリのテスト

これにより、開発者やデザイナーが どの環境からでも一貫したクロスブラウザテストを実施できるようになりました。

実行イメージ

上記の画像のようにブラウザ内に対象環境が表示され、直感的に操作することができます。
開発者ツールの使用も可能です。

導入後の運用と課題

メリット

導入後、開発チーム全体でテストが容易に行えるようになり、クロスブラウザ対応の精度が向上しました。特に以下の点が大きな改善点です。

  • 持っている物理デバイスに依存せずテストが可能になった
  • 開発者・デザイナーの誰でも簡単に利用できるようになった
  • テストの見落としが減少した

課題・問題点

一方で、運用を開始してみると、以下のような課題も浮かび上がりました。

  • レスポンス速度の遅延
    • 操作時にレスポンスが遅く感じるという意見が多かったです。
  • 社内 VPN 経由での接続が必要
    • 現在、テスト環境へアクセスするには社内 VPN を介する必要があり、これがパフォーマンス低下の一因となっています。
    • しかし、現在 VPN 環境の見直しを進めており、今後の改善を計画しています。

まとめ

LambdaTest の導入により、開発チーム全体が手軽にクロスブラウザテストを実施できるようになりました。 今後も開発環境の改善に向けて取り組んでいきます!

Railsで稼働しているWebサービスをサブディレクトリから新規ドメインに移行しました

開発部の光永です。
店舗デザイン.COM求人@インテリアデザイン内装建築.comグルメバイトちゃんなどのサービスの開発に携わっています。

弊社が運営しているサービスの1つであるグルメバイトちゃんで新規ドメインに移行するという開発が行われました。1つのRailsアプリケーションで2つのドメインを利用する形となっていて、あまり見ない例だと思うので、開発の内容や、リリースの手順を紹介します。

グルメバイトちゃんについて

まず初めにグルメバイトちゃんがどんなサービスなのか紹介します。

グルメバイトちゃんは2023年から弊社で開発をしている新規サービスです。ショート動画を使ってアルバイト募集をするサービスになっています。

開発環境的な話をすると、新規サービスということもあり、リリース速度を重視していたので、既存サービスのRailsアプリケーションのサブディレクトリに実装する形で開発を進めています。グルメバイトちゃん以外にも自社サービスをサブディレクトリで開発するという方法をとっているためそれらと同じような流れで開発がスタートしました。

既存サービスのサブディレクトリを利用していた理由にWAFの費用を抑えるためというものがあります。弊社で利用しているWAFがドメイン単位で課金されるシステムなので、既存サービスのドメインを利用することで追加で費用がかからないようにしました。

なぜ新規ドメインに移行することになったのか

既存サービスのサブディレクトリでの運用から新規ドメインでの運用へと切り替えたわけですが、この理由としては大きく2つあります。

1つ目はブラウザ上で表示されるサイト名をグルメバイトちゃんにしたかったためです。
サブディレクトリで運用していると、下の画像のようにグルメバイトちゃんのページであってもグルメバイトちゃんと表示されずに別のサービス名が表示されてしまいます。

これを下の画像のような形でグルメバイトちゃんの名前で表示したいという理由です。

2つ目の理由は基本的に1サービス1ドメインにしたいという理由です。
これに関してはグルメバイトちゃんの開発開始当初から検討されていました。利用者の増加等にともなってサービスが大きくなってきたので、タイミングを早めて実施されました。

グルメバイトちゃんのサイト構成

グルメバイトちゃんは主に3種類のページから構成されます。

gourmet_baito_chan/
├── shops
├── videos
├── entries
├── companies/
│     └── shops/
│            └── entries
└── admin/
       ├── shops
       ├── videos
       └── entries

gourmet_baito_chan直下にあるページは求職者が使います。アルバイト募集のされている店舗や、その募集動画を見たり、アルバイトに応募したりすることができます。

companies下はアルバイトを募集する飲食店の方が使う管理画面です。応募のあった人の情報を確認したりすることができます。

admin下は弊社の社員が使う管理画面です。掲載される店舗や動画の情報を登録、編集したり、応募者の情報を見たりすることができます。

この3種類のページが飲食店ドットコムという弊社サービスのサブディレクトリにありました。
また、管理画面の認証部分は飲食店ドットコムのサブドメインに実装されているサービスを利用しています。

全体的な進め方

続いて、実際にどんな進め方で新規ドメインへの移行が進められたのかを紹介します。

概要

実際の対応は以下のような方針で行われました。

  • 管理画面、エラーページは既存ドメイン側をそのまま使用する
  • 1つのRailsアプリケーションで2つのドメインを利用する

管理画面、エラーページは既存ドメイン側をそのまま使用する

サイト構成の箇所で説明した弊社社員が使う管理画面と飲食店の方が使う管理画面に関しては新規ドメインへの移行の際に新ドメイン側に移行しませんでした。
理由はサイトの構成上、認証部分の移行が難しかったためです。サイト構成でも触れましたが、認証が既存ドメインのサブドメインに実装されています。その実装が既存ドメイン以下でしか動かせない実装になってるためです。そのため、ログインが必要なページは既存ドメイン側に残す判断となりました。

また、404等のエラーページに関しても既存ドメインと同じページを利用することにしています。
この影響で、ページ内のリンクを変更する必要があったのでその対応は別途しています。具体的には、サイト内のリンクのhrefが相対パス等で指定されているリンクを絶対URLに直すという対応です。これをしないと新ドメイン側のエラーページから旧ドメイン側のページへのページ遷移ができなくなってしまうため、対応が必要になりました。

1つのRailsアプリケーションで2つのドメインを利用する

上記の1部ページを既存ドメインに残すことになった影響で1つのRailsアプリケーション内で2つのドメインを用意する必要が出てきました。 その影響で以下の2つの対応が必要となります。

1つはディレクトリによってどちらのドメインで処理するかが異なるため、それらを適切にリダイレクトすることです。

新ドメイン側ではサブディレクトリが不要なためサブディレクトリを取り除いたり、別のドメインに来たアクセスを適切なドメインに流したりする必要があります。
具体的には、下の表のような形になります。グルメバイトちゃんのディレクトリは/gourmet_baito/、管理画面は/gourmet_baito/admin/下にあるものとします。

アクセス リダイレクト先
https://旧ドメイン/gourmet_baito/hoge https://新ドメイン/hoge
https://新ドメイン/gourmet_baito/hoge https://新ドメイン/hoge
https://新ドメイン/admin/foo https://旧ドメイン/gourmet_baito/admin/foo

もう1つはページ内に置くリンクをドメインとディレクトリを意識して指定することです。
新旧両方のドメインで使われる箇所は絶対URLで指定します。新ドメイン側へのリンクはサブディレクトリを除いた新ドメインへ向く形で指定します。旧ドメインのパスはサブディレクトリのついた旧ドメインへ向く形で指定する必要があります。

手順

次にリリースの手順を紹介します。基本的な流れとして、最初に新ドメイン側を使える状態にして新ドメイン側のテストをします。そのテストが完了した後で旧ドメイン側の対応をします。
これにより、新ドメイン側でのテスト中もユーザーはサービスを問題なく利用できるため、テストをした上でのドメイン移行が安全にできます。

リリースの流れとしては

  1. 新ドメイン側のリバースプロキシの設定
  2. 新ドメイン側のテスト
  3. 旧ドメイン側のリバースプロキシの設定
  4. Railsアプリケーションのデプロイ

という流れで進めました。

新ドメイン側のリバースプロキシの設定

まずは新ドメイン側を使えるようにする対応として、新ドメイン内でのページ遷移をできるようにします。対応内容としては以下のようなものがあります。

  • 不要なパス(/gourmet_baito_chan/)がついていたら、不要な部分を外したパスにリダイレクトする
  • このドメインへのアクセスに/gourmet_baito_chanをつけてRailsアプリケーションに処理させる
    • 既存のRailsアプリケーションで処理させるためにはサブディレクトリ付きのパスが必要になるため
  • 管理画面のパスにアクセスが来た時に旧ドメインにリダイレクトする

これらの対応により新ドメイン側でページ遷移できるようになります。

この開発段階では旧ドメインから新ドメインへのリダイレクトはしていないため、旧ドメイン側での利用には影響がありません。
また、新ドメイン側ではリダイレクトが設定されたことにより、以下のことができるようになります。

  • 新ドメインからのサブディレクトリなしのリクエストを既存アプリケーションで処理する
  • 新ドメイン内でのページ遷移

新ドメイン内でのページ遷移に関しては、リンクのパスが変更されていないため、1度サブディレクトリを外すためのリダイレクトが挟まる状態ではありますが、特に意識しなくても遷移ができるようになるという感じです。

新ドメインテスト

この段階で旧ドメインは影響がなく、新ドメイン側はページ遷移含め使用できるようになっています。なのでここで新ドメイン側のテストをします。

テスト内容としては、新ドメイン側のリダイレクトの設定や、Railsアプリケーションで新ドメイン側からのアクセスを正しく処理できているかの確認になります。

旧ドメイン側のリバースプロキシの設定

新ドメインが問題なく使えるようになっていることが確認できたので、旧ドメイン側のアクセスを新ドメイン側に向ける対応をします。

ここでの対応は新ドメイン側で処理したいアクセスが旧ドメイン側に来た時に新ドメイン側にリダイレクトすることになります。
この対応が完了すると、ページ内のリンクに不要なパスがついているので、リダイレクトが挟まれるものの画面に表示されるのは意図したドメインで表示されるような状態になります。

この時点で旧ドメインは管理画面以外のページが表示されることなく、リダイレクトされるようになります。新ドメインは特に変更を加えていないため、テストをした時と同じ状態でリダイレクトを挟むもののページ遷移しながら使えるような状態になっています。

railsアプリケーションのデプロイ

最後の手順として、ページ内のリンクを正しいものにする変更をしたRailsアプリケーションをデプロイします。この作業はリダイレクトの設定前に行なってしまうと、適切なページへのアクセスとならないことがあるので、最後に行う必要があります。

具体的な変更としては以下のようなものがあります。

  • サブディレクトリの/gourmet_baito_chan/が不要な箇所には含めないようにする
  • 旧ドメインと新ドメインを分けられるようにする

サブディレクトリを除く対応は新ドメイン側のリンクで対応が必要になります。この対応でリダイレクトせずに新ドメイン内のページ遷移ができるようになります。

ドメインを分けられるようにする対応はメールなどに必要です。Railsアプリケーションは共通なので、メールの送り先等によって適切なドメインが表示されるように変更します。

この対応が終わると、新ドメインも旧ドメインも不要なリダイレクトなしにページ遷移ができる状態となります。

新規ドメインへの移行をしてみて

新規ドメインへの移行を終えての振り返りを目的達成できたのかという点とSEO的な観点から振り返ります。

目的達成できたのか

どんな目的があったのかをもう1度書いておくと

  • ブラウザでの検索時のサイト名をグルメバイトちゃんにする
  • 1サービス1ドメインにする

の2点になります。

1つ目のサイト名の変更についてはサイトの一部ページではグルメバイトちゃんとなっていますが、他のページでは下の画像のようにドメインがそのまま出るような形となっています。

既存サービスの名前から変更するという点では達成していますが、指定した名前にならない点については引き続き調査や改善をしていきます。

2つ目の1サービス1ドメインにするということについては、Googleにインデックスされる箇所に関しては全て新ドメインで統一されているので達成したと言えるのではないでしょうか。

新規ドメインへの移行によるSEO的な影響

次に新規ドメインへの移行によるSEO的な影響についてですが、移行直後はサイトの評価が低い状態となりました。これに関しては新規ドメインを使うので避けられないと予想していたので、想定通りの結果でした。

現在では、元通り以上のアクセス数となっているため、サイト評価は回復したと判断しています。
サイト評価の回復の要因として考えられることに適切に301リダイレクトを設定したことが考えられます。

終わりに

グルメバイトちゃんの新規ドメインへの移行の中で1つのrailsアプリケーションで2つのドメインを利用するという対応をしたのでその手順や対応内容の紹介でした。

1つのアプリケーションで複数のドメインを利用する実装を見る機会があまりないと思うので、面白い開発だなと思っています。

今後もサービスの拡大や認知の拡大を進めるような開発が進んでいくので、そのような開発に努めていきたいと思います。

プッシュ通知一括送信をFCM Topic送信に置き換えた話

はじめまして、開発部の濱野です。
普段は飲食店ドットコム求人飲食店ドットコムといった弊社の「飲食店と求職者のマッチングサービス」に関わるサービスや業務システムの開発に携わっています。
今回はFirebase Cloud Messaging(以下FCM)のBatch Send API廃止に伴い、プッシュ通知の一括送信機能をFCM Topicへ移行した経緯と移行時の対応方針や検討したことを紹介します。

背景と課題

弊社では求人飲食店ドットコムのAndroid/iOSアプリを作成しており、ユーザの操作に伴って送信される通知のほか、メールマガジンのような一度に大量の通知を送信する機能にFCMを活用したプッシュ通知を利用しています。
2024/7/22にFCM レガシーAPIが廃止のアナウンスがあり、大量プッシュ通知を一括送信するのに使用していたBatch Send APIもFCMレガシーAPIに含まれていたため、HTTP v1 APIへの置き換えが必要になりました。

移行候補の検討

大量のプッシュ通知を送信する処理の置き換えにあたって、いくつかの処理方針が候補に挙がりました。

候補1) 1件ずつ同期的に送信する

既存の一括送信で使用していたBatch Send APIは廃止されるため、HTTP v1 APIの単一送信をループさせる形で一括送信を実現する方法を検討しました。
しかし、今回のような送信対象が多い場合、1件ずつ送信するやり方では、件数分のリクエストを送信することになるため、バッチ処理完了までの時間が長くなってしまうという欠点があります。

候補2) 1件ずつ非同期で送信する

非同期送信はバッチ処理完了までの時間が改善されますが、非同期送信を採用するにあたって検討すべき課題や処理が複雑になってしまう可能性があります。

候補3) Topic機能

FCMメッセージの大量送信に関しては調べたところ、FCMの公式ドキュメントにてTopic機能を利用することで複数デバイスにメッセージを送信できることがわかりました。 Topic機能に関して調査したところ、いくつか注意すべき点があるもののパフォーマンスを考慮しつつ大量送信が実現できることがわかりました。

結論

FCMを活用したプッシュ通知の大量送信に関する移行手法を検討した結果、今回はTopic機能を採用することになりました。同期送信方式は実装がシンプルである一方で処理時間の課題が大きく、非同期送信方式は処理時間の改善は見込めるものの、実装の複雑さやリソース管理の観点で課題がありました。
これに対し、Topic機能はFCMが公式に推奨する実装方式であり、大量送信時の処理効率が優れているという大きな利点があります。ただし、Topic機能の採用にあたっては、トピック数の制限や購読管理など、いくつかの考慮すべき点があります。これらの詳細な実装方法と対応策については、次章で説明していきます。

同期送信 非同期送信 Topic機能
実装のシンプルさ
導入の容易さ
処理時間
送信内容 ユーザーごとに変更可能 ユーザーごとに変更可能 全ユーザー共通

FCM Topic機能の概要

FCM Topic機能は、特定のトピックを購読している全デバイスに対して一括でメッセージを送信できる機能です。送信対象によってメッセージが変わらないため、Webマガジンやニュース配信などの用途で利用することが想定されています。

Topic機能は以下の3つの主要なアクションで構成されています。

  • トピックの購読登録: デバイスをトピックに関連付ける
  • トピックの購読解除: デバイスのトピック関連付けを解除する
  • トピックへのメッセージ送信: 登録されたデバイスへ一括配信を行う

Topic機能による配信の基本的な流れを図に示します。

Topic機能による配信の基本的な流れ

Batch Send APIとの違い

Batch Send APIでは送信対象のデバイストークンを指定し(最大1000件)、メッセージ内容を設定した上でFCMにプッシュ通知の送信をリクエストします。メッセージもリクエストのタイミングで送信されるので、送信件数が増えるほどメール送信などの機能と合わせてプッシュ通知したい時にメールが届いてからプッシュ通知が届くまでに間が空いてしまうという問題が発生します。
これに対し、Topic機能の場合は送信対象のデバイストークンの設定とメッセージ送信のリクエストが分かれているので、大量のプッシュ通知送信に対する送信リクエストのタイミングにはズレは生じません。

また、Topic機能には以下のような特徴があります。

  • 同一トークンの重複リクエストは自動的に1つにまとめられるので、無駄なリクエストを減らす仕組みを実装する必要がない
  • 既に購読済みのトークンに対する再登録も正常終了として扱われるので、安全に再実行できる
  • トピックの作成は明示的な操作なく、最初の購読登録時に自動的に行われる。以降はトピック名を指定することで、そのトピックを使い回すことができる

使用時の注意点

前述したようにTopic機能には以前のBatch Send APIよりメリットがある面もありますが、以下のような仕様上の注意点があります。

  • 作成できるトピックの上限数が2000件のため、配信ごとにトピックを使い捨てるようなことはできない
  • トピックの購読数の上限はないが、購読登録/購読解除は一括で1000件毎しかできない
  • トピックへの購読登録時にトピックが作成されるが、FCMにトピック登録が反映されるまでにタイムラグ(数秒程度)が発生する

プッシュ通知の一括送信実装の置き換えの実装要件

今回Topic機能は求職者の希望にあった求人情報をレコメンドする機能の通知に使用されます。このレコメンド通知は対象となるユーザーが毎日変わるため、配信対象ではないユーザーに通知を送らないように購読しているユーザーを適切に管理する必要があります。
なぜなら、Topic機能はユーザーが興味のあるトピックを自ら選び(購読:Subscribe)、送信者はトピックに対して送信(公開:Publish)することで購読しているユーザー全員にメッセージを送信するPublish/Subscribeメッセージングモデルでの用途を期待しており、今回の様な送信側が送信先を選んでメッセージを送信するという用途を想定していないからです。

以上の点からプッシュ通知の一括送信実装の置き換えでは以下の要件を満たすことを必須としました。

  • FCMプッシュ通知の一括送信をFCM Topicに置き換える
  • 複数トピックの購読/購読解除を管理したい
  • ユーザーの購読解除漏れを防ぎたい
  • 購読登録/解除やメッセージ送信時のエラーハンドリングを適切に行い、エラーログを記録する

プッシュ通知一括送信の処理フロー

プッシュ通知の一括送信は、以下のような処理フローでFCMのTopicを利用して配信しています。

プッシュ通知一括送信の処理フロー

※Platform-level message transportは、AndroidデバイスならGoogle Play 開発者サービス、iOSデバイスならAPNsに対し配信しています。

上記のシーケンス図ではわかりやすさを重視し、以下の処理を省略しています。

  • 購読登録、購読解除、メッセージ送信でのログ記録処理
  • 購読登録、購読解除時の特定エラーコードでのリトライ処理
  • トランザクション開始/コミットの設定

エラーハンドリングと例外処理

今回Topic送信への置き換えの対象となる機能は毎日定期実行され、前述したとおり都度送信先のユーザーが変わります。そのため、購読解除漏れが発生すると本来送信対象ではないユーザーにプッシュ通知が誤送信される問題が発生します。

誤送信される問題を防止するため、以下の3点の対策を行いました。

  • 各トークンの購読登録/購読解除の処理結果(ステータスコード/エラーコード)を管理すること
  • エラー発生時の対応フローを正しく行うこと
  • データ整合性を確保するためのトランザクション制御 各トークン処理結果の管理に関しては、トークンの管理テーブル作成対応など含まれますが、処理によって必要となるテーブル構造が異なるため今回は割愛しています。

FCMのエラーコードの扱い

トピックの購読登録/購読解除時に処理が失敗した場合には、処理に失敗したトークン毎にエラーコードが返されます。例えば、1000件の処理のうち10件がエラーだった場合、10件分のエラーを含むレスポンスが返されます。
エラーコードにはFCMサーバー側が原因以外のものがいくつか含まれています。そのようなエラーコードに関しては成功扱いとすることで、FCMサーバーへの購読登録/購読解除の送信が正常に完了しているかの判定の妨げにならないようにしました。
また、エラーコードの中にはunknown-errorのような場合にそのエラーが発生するのか分かりずらいものがありましたが、こちらはTopic機能での大量の購読登録/購読解除処理を検証していたところ、短期間で大量リクエストした場合に必ずunknown-errorが返されるという結果から判明しました。

以下エラーコードと対象コードの成功/失敗扱いの対応表となります。

エラーコード 説明 購読登録 購読解除
invalid-argument 無効な引数 失敗 成功
registration-token-not-registered トークンが登録されていない 失敗 成功
internal-error リクエストの処理中に FCM サーバーでエラーが発生 失敗 失敗
too-many-topics トピック数が上限に達している 失敗 成功
unknown-error FCMのバックエンドサーバーで失敗 失敗 失敗

FCMサーバー側エラー時のリトライ処理での考慮点

前述したエラーコードの中には、FCMサーバー側でエラーが発生したものが含まれていますが、そうしたエラーに関してはリクエストの再試行が推奨されています。
公式ドキュメントにてinternal-errorを考慮した場合、指数バックオフの最小間隔は60秒、最大間隔は60分までが推奨と記載されています。
また、internal-errorの場合はFCMからretry-afterヘッダーを含む情報が返されることがあります。その場合はretry-afterの秒数待機後にリトライ実行で問題ないとドキュメントに記載があります。
しかし、推奨どおりに指数バックオフを実装した場合、FCM側の状況次第では処理時間が長くなってしまうので最大間隔はある程度考慮する必要があります。
そこで一般的なFCM指数バックオフ処理のリトライ回数を知るためにFCMライブラリ(firebase-admin-java)を調べたところ、最大リトライ回数4回で実装されていたので今回は同じリトライ回数で実装することにしました。

一括処理をリトライするためのトランザクション制御

トピックの購読登録/購読解除/メッセージ送信の処理中に何かしらのエラーで処理が止まる可能性があります。この時、購読管理テーブルとFCMサーバー間でデータが不整合な状態になります。例えば、購読管理テーブルには未購読状態と記録されているのに、FCMサーバーでは購読状態となってしまいます。この場合、こちらが持っている購読状態が不整合なため購読解除時に購読解除漏れが発生してしまいます。
その問題を解消するため、購読登録処理の開始時点でトランザクションを開始し、購読登録処理が正常に終了した時点でトランザクションをコミットすることにしました(購読解除も同様の対応)
これにより購読管理テーブルとFCMサーバー間のデータ整合性が合う様になり、スピーディにリトライ対応ができるようなりました。

移行効果

FCM Topicのプッシュ通知に対応するために処理を見直した結果、以下の改善が見られました。

  • Batch Send APIを使用したプッシュ通知の一括送信処理では、送信件数やエラーをログファイルに記録していました。しかし、FCM Topicを使用した一括送信では、リトライ処理が必要かどうかを検知するために、購読状況やエラーをテーブルに記録する必要がありました。これにより、プッシュ通知の処理状況が以前よりも確認しやすくなりました。
  • Batch Send APIでは、メール送信処理とプッシュ通知の一括送信処理を1つのバッチ内で行っていました。しかし、FCM TopicのPublish/Subscribeメッセージングモデルを適用することで、プッシュ通知を単独のバッチ処理として切り出すことができました。

まとめ

FCMのレガシーAPI廃止に伴って、弊社で行っているプッシュ通知の大量送信の方式をFCMのTopic機能を用いて置き換えを行いました。今回の置き換えにより、一括送信の速度改善やメッセージ送信のタイムラグ改善、様々なエラーケースを想定した実装及びテストをすることで信頼性を可能な限り担保することができました。
今回は信頼性・安全性の確保をより意識しましたが、この経験を生かして今後のサービス開発でも信頼性・安全性の高いものを作っていきたいと思います。

アプリのローディングUIをスケルトンビューに変える対応で試行錯誤した話

はじめまして、モバイルアプリチームの佐々木です。普段は主に求人飲食店ドットコムアプリ(以下、求人アプリ)のiOS開発を担当しています。
「求人飲食店ドットコム」では飲食店専門で求人情報を掲載しており、アプリで求人情報の閲覧から応募、応募先とのやりとりまで行えます。

今回は、初めて調査からリリースまでを一貫して担当した、求人アプリのローディングUIを変更する対応でスケルトンビューを導入したので、その過程で悩んだことや学んだことについて共有します。

スケルトンビューとは

そもそもスケルトンビューとはどのようなものかご存知でしょうか?
アプリやウェブサイトでデータを読み込んでいる間にページのレイアウトだけを先に表示する、ローディングUIの一種です。スケルトンスクリーン(Skeleton Screen)やシマーエフェクト(Shimmer Effect)など、様々な呼び方があります。

スケルトンビュー

このUIのメリットとして、レイアウトが先に表示されることでユーザーはこれから表示される内容について心の準備をしやすくなり、待ち時間を短く感じさせる効果があると言われています。

導入の経緯

スケルトンビュー導入の話が出たきっかけは、WEB履歴書という画面でバグが見つかったことでした。

WEB履歴書とは応募に必要な情報をまとめた画面で、「プロフィール」「職務経歴」「経験職種」「自己PR」の四つのタブから構成されています。

WEB履歴書

この画面を開く時は各タブに対応するAPIから情報を取得しています。

求人アプリではAPI通信時にSVProgressHUDを使ってローディングUIを表示しており、WEB履歴書画面でも同様でした。
この画面では四つのタブの情報を取得するAPIへの通信を同時に並列で行い、全てが完了した時点でSVProgressHUDを非表示にする、という仕組みになっていました。
しかし、このSVProgressHUDの表示・非表示処理は、各APIで個別に制御していたり、PromiseKitを使用して一括で制御していたりと、複数APIを扱う際の実装が複雑になりがちで、今回のバグもその複雑さに起因するものでした。
そのため、以前から議論のあった別形式のローディングUIへの移行について、今回バグが見つかったことをきっかけに正式に対応することが決まりました。

WEB履歴書以外にも複数のAPIに対して並列に通信を行っている画面がいくつかあるため、これを機に求人アプリ全体で新しいローディングUIを取り入れようという話になり、影響の大きさを考えて、まずは調査から始めることになりました。

実装前の調査

スケルトンビューの導入に向けて、どういった対応が必要になるのか、どのくらい工数がかかりそうか、などを見積もるためにまず調査を行いました。

ライブラリの選定

まず最初に、ライブラリの選定を行いました。iOSで広く知られているJuanpe/SkeletonViewを候補に挙げつつ、他に適したライブラリがないかを洗い出しました。
その結果、他にも似たようなUIを実現できそうなライブラリはいくつか見つかりましたが、「Swiftで実装可能であること」「最近のリリースが古すぎないこと」「利用者が多く信頼性が高いこと」という観点から、最初に挙がったSkeletonViewが最も適していると判断し、仮実装へと進めることにしました。

仮実装での苦戦

SkeletonViewを使用した仮実装では、大きく二つの点で悩むことがありました。

①ライブラリのインストール方法

求人アプリではライブラリ管理ツールとしてCocoaPodsとCarthageを併用しており、新しいライブラリを導入する際にはビルド時間の短縮を重視してCarthageの使用を優先しています。
SkeletonViewはCocoaPods、Carthage、SPMに対応しているということで、まずCarthageでインストールを試みました。
しかし、インストール後にStoryboardを確認してもREADMEにあるようにisSkeletonableプロパティが表示されませんでした。
isSkeletonableは、UIViewのExtensionとして@IBInspectableで定義されたプロパティです。プロジェクト側で同様の@IBInspectableを追加した場合は問題なく機能するということは確認できましたが、何故かSkeletonView側で定義されたものはStoryboardに反映されませんでした。
コード上で設定する方法もありますが、SkeletonViewではスケルトン表示したい要素に対して親子すべての階層にこの設定をする必要があります。求人アプリでは、Storyboard上にある項目は可能な限りStoryboardで設定するようにしているため、再帰的にコード上で設定するのは避けたい方法でした。

調べると同様の事例が報告されており、CocoaPodsでインストールすることでisSkeletonableがStoryboardに表示されたとの情報を見つけ、CocoaPodsでのインストールを試したところ、この問題は解決しました。

isSkeletonableの設定方法

ライブラリのREADMEを参考に、showSkeleton()を呼び出す要素の下にある全階層に対してisSkeletonableをOnに設定しましたが、期待通りにスケルトン表示が動作しませんでした。showSkeleton()を呼び出す要素を変更したり、isSkeletonableを設定する階層を調整したり、実装をいくつか試した結果showSkeleton()を呼び出す要素自体もisSkeletonableをOnにする必要があることが分かりました。

こうして、READMEの記載通りに実装してもうまくいかない点があり苦戦しましたが、試行錯誤を経てスケルトンビューの仮実装を行うことができました。

仮実装を進めた結果

仮実装はWEB履歴書画面の「プロフィール」と「職務経歴」の二つのタブで行いました。
まず、比較的シンプルな構造の「プロフィール」でスケルトンビューが動作することを確認した後、UITableViewを使用した画面でも実装できるか「職務経歴」で検証しました。

まず「プロフィール」についての処理を簡易化したものが以下の通りです。

class ProfileViewController: UIViewController {
    private var profile: Profile!

    override func viewDidLoad() {
        super.viewDidLoad()
        // 画面表示と共にスケルトン表示を開始
        view.showSkeleton()
    }
    
    // その他画面の表示処理(省略)
    ...
    
    // ResumeTabViewControllerの表示処理で呼び出す
    func getDataByAPI() -> Promise<Void> {
        return Promise<Void> { resolver in
            ProfileAPI.fetchData { [weak self] result in
                guard let self else { return }
                // API通信終了時にスケルトン非表示
                view.hideSkeleton()
                switch result {
                case .success(let profile):
                    // 取得したデータをセット
                    self.profile = profile
                    resolver.fulfill(())
                case .failure(let error):
                    resolver.reject(error)
                }
            }
        }
    }
}
class ResumeTabViewController: ButtonBarPagerTabStripViewController {
    private var profileVc: ProfileViewController!
    private var workCareerVc: WorkCareerViewController!
    private var workExperienceVc: WorkExperienceViewController!
    private var selfPrVc: SelfPrViewController!

    // WEB履歴書画面の表示処理(省略)
    ...

    // WEB履歴書画面の表示時にこのメソッドを呼び出して四つのAPI通信を並列で行う
    func getChildrenTabData() {
        SVProgressHUD.show()
        firstly {
            // 各画面のデータ取得メソッドを呼び出して結果を取得
            when(fulfilled:
                [
                    profileVc.getDataByAPI(),
                    workCareerVc.getDataByAPI(),
                    workExperienceVc.getDataByAPI(),
                    selfPrVc.getDataByAPI()
                ]
            )
        }
        .done {
            // 各タブについての設定(省略)
            ...
        }
        .catch { _ in } // 各画面でエラー表示を行うのでここでは何もしない
    }
}

「プロフィール」ではデータをセットする前にhideSkeleton()を呼ぶことと、isSkeletonableの設定に気をつければshowSkeleton()hideSkeleton()を記述するだけで、スケルトン表示ができました。

UITableViewを使用した「職務経歴」でも、下記のようにデータの読み込み前後で処理を行うことでスケルトンを表示しました。

class WorkCareerViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var detailLabel: UILabel!
   
    var workCareers: [WorkCareer]?
    
    // ViewControllerの表示処理など(省略)
    ...

    // ResumeTabViewControllerの表示処理内で呼び出す
    func getDataByApi() -> Promise<Void> {
        return Promise<Void> { resolver in
            WorkCareerAPI.fetchData { [weak self] result in
                guard let self else { return }
                // API通信終了時にスケルトン非表示
                tableView.hideSkeleton()
                switch result {
                case .success(let workCareers):
                    // データに合わせて行数を可変にする
                    self.detailLabel.numberOfLines = 0
                    self.detailLabel.sizeToFit()
                    // 取得したデータをセット
                    self.workCareers = workCareers
                    resolver.fulfill(())
                case .failure(let error):
                    resolver.reject(error)
                }
            }
        }
    }
}

extension WorkCareerViewController : UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // 5はスケルトン表示時のセル数
        // workCareers?.countが取得できないときはデータの読み込み前なのでスケルトン表示をしたいセル数を指定
        return workCareers?.count ?? 5
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // セルの取得処理(省略)
        ...

        if workCareers == nil {
            // スケルトン表示時は3行に固定
            cell.detailLabel.numberOfLines = 3
            cell.detailLabel.sizeToFit()
            // tableView.showSkeleton()ができなかったためセル単位でshowSkeleton()
            cell.showSkeleton()
        } else {
            // データ取得後に関する設定
            cell.workCareer = workCareers[indexPath.row]
        }
    }
}

tableView単位でのshowSkeleton()が機能しない点で疑問を抱きつつ、SkeletonViewの実装方法として同様の方法を紹介しているサイトがいくつかあり、スケルトン表示も一応実現できたため、この方法で問題ないだろうと仮実装を終えました。
しかし、実際にはこの実装方法にはいくつかの大きな問題がありました。

実装時に判明した問題

前述の調査を踏まえて正式に対応することが決まりました。
最初はローディングUIによる問題が特に大きかったWEB履歴書画面に導入して、リリース後も特に問題が起きないようであれば他の画面にも徐々に導入していくという方針で設計をして、実装に取り掛かりました。

実装を一通り終えた段階で、レビュー時に指摘された内容も含め、仮実装の方法にいくつか問題があることが判明しました。
前章の実装例を元に、どのような問題があったかを見ていきましょう。

コードが複雑で可読性が低い

「職務経歴」の実装例ではスケルトン表示用に、UILabelの行数とセル数に関する設定をしています。
登録されたデータの長さによって行数が変わるdetailLabelについて、スケルトン表示時は三行に固定したいと、viewDidLoad()の初期表示処理の際にdetailLabel.numberOfLines = 3と設定し、データ読み込み完了時にdetailLabel.numberOfLines = 0と設定し直しています。
また、セル数についてはtableView(_:numberOfRowsInSection:)でデータ取得前には五つのセルを表示するように指定しています。

これを踏まえて全体を見ると、セルやdetailLabelという同じ項目に対する設定がコード上で離れた場所に定義されており、スケルトン表示に関する設定としても同様に離れているため、どこで何に対してどのような設定をしているのかが把握しにくい、可読性の低い実装になっています。

表示・非表示処理で単位が一致しない

「職務経歴」の実装例ではshowSkeleton()はセル単位で、hideSkeleton()tableView単位で呼び出しています。
理由としては、実装例にも記載したようにshowSkeleton()がセル単位でしか機能しなかったことに加えて、もう一つありました。
セル単位でhideSkeleton()を行うと、データ読み込み時にshowSkeleton()を五つのセルで呼び出しているため、実際の登録データが五件未満だった場合に一部のセルでhideSkeleton()を呼ぶタイミングがなくスケルトン表示が残ってしまいます。
この問題を回避するため、showSkeleton()と異なりhideSkeleton()tableView単位で行う実装にしました。

しかし、このように根本的な原因を解明せずに対処療法的な方法を取っているため、意図が分かりにくく、特に初めて実装を見た人には奇妙に感じる実装になっています。

タブを移動するとスケルトン表示が消えない

これは問題のある実装方法に加え、私自身のUIViewControllerのライフサイクルに対する理解不足も原因となって発生した問題でした。

WEB履歴書画面は、その他メニューで選択した項目がデフォルトのタブとして設定されて表示されます。
例えば、以下のGIFでは「経験職種」を選択したのでWEB履歴書画面が表示されたときには「経験職種」タブが開いた状態になっています。

「プロフィール」の実装例ではviewDidLoad()showSkeleton()を呼び出しているため、WEB履歴書画面表示時にデフォルトのタブでない場合、以下の順序で処理が行われます。

  1. 画面上に表示されていない状態でデータ読み込みを開始
  2. データ読み込みが完了してhideSkeleton()を実行
  3. 「プロフィール」タブを開いたタイミングでviewDidLoad()が呼ばれ、showSkeleton()を実行

このように、スケルトンの非表示処理が先に実行されてしまい、データの読み込みが完了した状態で表示処理が実行されるため、スケルトン表示を終了することができなくなります。

原因と修正

仮実装で起きた問題の原因

結局これらの問題の原因はSkeletonTableViewDataSourceプロトコルに準拠していなかったことでした。SkeletonViewのREADMEには、以下のようにUITableViewでスケルトン表示を行うための方法がしっかり記載されていました。

If you want to show the skeleton in a UITableView, you need to conform to SkeletonTableViewDataSource protocol.

一応言い訳をすると、調査時にこのプロトコルの存在は認識しており、実装も試したのですが、当時はなぜかうまく動作しなかったため、この方法を使わない方針で進めました。
しかし、前述の問題に直面して改めて試してみたところ、驚くほどすんなり動作しました。
おそらく、当時はisSkeletonableなど他の設定に問題があり、それが原因で正しく動作しなかったのを誤解したのだと思います。(余談ですがSkeletonViewのissueにも「原因はわからないがやり直したら上手く行った」というコメントがあり、とても親近感を覚えました)

修正後の実装

原因を踏まえて修正した、最終的な実装は以下の通りです。

class WorkCareerViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var detailLabel: UILabel!

    var workCareers: [WorkCareer]?

    // viewDidLoadだとデータ読み込み中に何度もタブの行き来があった場合に対応できないため移動
    override func viewWillLayoutSubviews() {
        if workCareers == nil {
            tableView.showSkeleton()
        } else {
            // タブ移動のタイミングによってはskeleton表示が残る場合があるので、ここでも非表示処理を行う
            tableView.hideSkeleton()
        }
    }

    // その他ViewControllerの表示処理など(省略)
    ...

    func getDataByAPI() -> Promise<Void> {
        return Promise<Void> { resolver in
            WorkCareerAPI.fetchData { [weak self] result in
                guard let self else { return }
                switch result {
                case .success(let workCareers):
                    // 取得したデータをセット
                    self.workCareers = workCareers
                    self.tableView.hideSkeleton()
                    self.tableView.reloadData()
                    resolver.fulfill(())
                case .failure(let error):
                    resolver.reject(error)
                }
            }
        }
    }
}

// UITableViewDataSourceの代わりにSkeletonTableViewDataSourceを継承
extension WorkCareerViewController: SkeletonTableViewDataSource {
    // 既存のUITableViewについての処理はそのまま(省略)

    // スケルトン表示時のセル数
    func collectionSkeletonView(_ skeletonView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }

    // スケルトン表示時にのみ適用したい設定
    func collectionSkeletonView(_ skeletonView: UITableView, skeletonCellForRowAt indexPath: IndexPath) -> UITableViewCell? {
        // スケルトン表示用の行数設定
        cell.detailLabel.skeletonTextNumberOfLines = 3
        cell.detailLabel.sizeToFit()
        return cell
    }

    // スケルトン表示したいセル
    func collectionSkeletonView(_ skeletonView: UITableView, cellIdentifierForRowAt indexPath: IndexPath) -> ReusableCellIdentifier {
        return "CellIdentifier"
    }
    
    // 以下UITableViewDataSourceに関する実装は基本的にスケルトン導入前のまま
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // データがないときはセルを表示しない
        return workCareers?.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // セルの取得処理(省略)
        ...
        if self.workCareers != nil {
            cell.workCareer = workCareers[indexPath.row]
        }
    }
}

ご覧の通り、スケルトン表示に関する処理が一箇所にまとめられたことで、コードの可読性が大幅に向上しました。それだけでなく、既存の処理にはほとんど手を加えていないため、今後職務経歴の仕様が変更されて修正が必要になった場合でも、スケルトンビューの実装には影響を与えることなく対応できるようになっています。

改善されたUX

紆余曲折はありましたが、スケルトンビューを導入したことで、当初のバグが解消されただけでなく、UXが大きく二つの点で改善されました。

①読み込み中に操作ができる

これまで、SVProgressHUDの表示中は画面の操作ができないようになっていたため、四つのタブすべてのローディングが完了するまでアプリの操作が阻害されていました。
特に、四つのタブのうち一つだけ確認したい場合でも、関係のないタブの読み込みに時間がかかるとユーザーに本来必要のない待ち時間を強いていました。
今回のスケルトンビュー導入によって、タブごとに独立してローディングを表示するようになったため、各タブの読み込みが完了次第、そのタブの操作が可能になりました。

before after

②エラーが起きても画面が閉じない

スケルトンビュー導入前は四つのタブについて、データ取得からエラー表示までをまとめて行っていたため、一つでもエラーが発生するとアラートを表示してWEB履歴書画面ごと閉じるという動きになっていました。この場合、ユーザーは再度WEB履歴書画面を開いて表示し直すという操作が必要で、少し手間のかかる仕様でした。

今回の対応でタブごとに処理できるようになったため、エラー時の動きについても従来のアラート表示から、各タブごとにエラー画面を表示するように改修しました。

エラー表示

これにより、一つのタブでエラーが発生しても、目的のタブが正常に読み込めていればそのまま利用することができます。
さらに、エラー画面に再読み込みボタンを設置しているため、目的のタブがエラーになった場合でもタブ単位でリロードすることが可能になりました。これまでのようにモーダルを閉じて再度開くといった余分な操作が不要になり、利便性が向上しました。

まとめ

WEB履歴書画面にスケルトンビューを導入してから一ヶ月以上が経過しましたが、今のところクラッシュなどのバグ報告はありません。苦労して試行錯誤を繰り返した分、安定して動作する機能をリリースできたことはとても嬉しいです。

失敗の原因を振り返ると単純なものでしたが、当初はCarthageでの導入やisSkeletonableの設定が上手くいかなかったことで、SkeletonViewのREADMEよりも、検索で見つけたサイトの実装例を過信してしまいました。
今回の経験を通じて、不慣れな対応をするときは特に、一度冷静に情報を整理することが大切だと実感しました。また、UITableViewのライフサイクルや求人アプリの仕様など、基本的な部分を改めて理解し直す良い機会にもなりました。

今後はWEB履歴書以外の画面にもスケルトンビューの導入を進めていく予定ですが、今回の失敗や学びをしっかりと活かしながら、より良い実装ができたらと思います。
このブログがスケルトンビューの導入を検討している方やローディングUIに悩みを抱える方にとって、少しでも参考になれば幸いです。
最後までお読みいただき、ありがとうございました。

Lambda(Ruby) + Slack スラッシュコマンドで社内ジョブをクラウド化

初めまして、SREチームの柴山です。
今回は 社内サーバーで動いていたデプロイ補助ツールを、 Lambda+Slack でクラウド化した際の作業を備忘録として共有します。

背景

このツールはオンプレミスの社内サーバー上の Jenkins のジョブとして用意され、都度ウェブブラウザからジョブを実行して実行結果をジョブのログとして確認していました。
この度オンプレミスの社内サーバーを廃しクラウドに移行するにあたり、 Slack からも見れるようにしたいとの要望があり、本対応を行いました。

構成概要

処理の流れは以下のようになっています。

  1. ユーザーがスラッシュコマンドを実行
  2. Slack から 認証用(一時応答用) Lambda を起動
  3. リクエスト認証後、本処理用 Lambda を非同期実行
  4. 一時応答を Slack へ返す
  5. 本処理での実行結果を投稿

Slack App の作成

Your Apps から Create New App を押下して Slack App を作成します。
スコープ・設定の構成方法について選択項目がありますが、From scratch で問題ありません。
ワークスペースの指定、Slack App名を設定します。※ワークスペースは通知先を指定してください。

Slack App からチャンネルへの投稿に必要な権限を設定します。
Your Apps -> <作成した App> -> OAuth & Permissions -> Scopes -> Bot Token Scopes

項目の下部にある Add as OAuth Scope を押下し、以下の権限を付与します。

  • chat:write
    • 必須
    • チャンネルへの投稿に必要
  • chat:write.customize
    • 必要に応じて追加
    • Slack API 経由での投稿時、アイコンやBot名をカスタマイズできます。
  • chat:write.public
    • 必要に応じて追加
    • 特定のチャンネルへ App 追加をしていなくても、投稿が可能になります。
  • commands
    • 手動での設定は必要ありません。
    • 後の手順で自動的に設定されます。

Lambda の作成

以下のLambda関数を作成します。

  • 前段の認証・一時応答用 Lambda
  • 本処理用の Lambda

Lambda側のランタイムやライブラリに依存しないように、いずれもコンテナイメージを作成・使用します。
今回紹介するコードの実行に最低限必要な Dockerfile、Gemfile を共有します。
必要に応じて記述の修正・バージョンの固定などをしてください。

FROM public.ecr.aws/lambda/ruby:2.7

COPY Gemfile ${LAMBDA_TASK_ROOT}/

# Gem のビルドに必要なライブラリをインストール
RUN yum -y install gcc-c++ make \
    && gem install bundler:2.4.22 \
    && bundle config set --local path 'vendor/bundle' \
    && bundle install \
    && yum -y remove gcc-c++ make \
    && yum clean all

COPY lambda_function.rb ${LAMBDA_TASK_ROOT}/

CMD [ "lambda_function.LambdaFunction::Handler.process" ]
source 'https://rubygems.org'

gem 'json'
gem 'net'
gem 'uri'
gem 'json'
gem 'set'
gem 'pp'
gem 'date'
gem 'time'
gem 'rack'
gem 'aws-sdk-lambda'
gem 'base64'
gem 'slack-ruby-client'
gem 'jwt'
gem 'faraday-retry'

前段のLambda

Lambda の IAM に lambda:InvokeFunction 権限が必要

先に全体のコードを共有します。

require 'json'
require 'pp'
require 'date'
require 'time'
require 'rack/utils'
require 'aws-sdk-lambda'
require 'base64'

module LambdaFunction
  class Handler
    def self.verify_slack_request(event)
      # Slackのリクエストを検証する
      timestamp = event['headers']['x-slack-request-timestamp']
      slack_signature = event['headers']['x-slack-signature']

      # リクエストがエンコードされている場合はデコードする
      if event['isBase64Encoded']
        body = Base64.decode64(event['body'])
      else
        body = event['body']
      end

      # 5分以上経過したリクエストは無効とする
      if (Time.now.to_i - timestamp.to_i).abs > 60 * 5
        return false
      end

      # リクエストの署名を検証する
      req_signature = "v0:#{timestamp}:#{body}"
      my_signature = "v0=" + OpenSSL::HMAC.hexdigest('sha256', ENV['SLACK_SIGNING_SECRET'], req_signature)
      Rack::Utils.secure_compare(my_signature, slack_signature)
    end

    def self.process(event:, context:)
      if verify_slack_request(event)
        # 本処理用lambda の呼び出し
        lambda = Aws::Lambda::Client.new
        lambda.invoke(function_name: '<function_name>',invocation_type: 'Event', payload: JSON.generate({event: event, context: context}))
        # 一時的に応答
        return { text: '処理中です...' }
      else
        # 認証に失敗した場合は 401 を返す
        return { statusCode: 401, body: 'Unauthorized' }
      end
    end
  end
end

こちらの Slack公式ドキュメントを参考に実装内容を解説していきます。 https://api.slack.com/authentication/verifying-requests-from-slack

timestamp = event['headers']['x-slack-request-timestamp']
slack_signature = event['headers']['x-slack-signature']

Slack からのリクエスト HTTPヘッダー に 署名シークレット が含まれているため、変数へ格納します。

require 'base64'

body = Base64.decode64(event['body'])

Slack からのリクエスト本文は base64 でエンコードされており、デコード処理を入れています。

require 'time'

if (Time.now.to_i - timestamp.to_i).abs > 60 * 5
  return false
end

The signature depends on the timestamp to protect against replay attacks.
引用: https://api.slack.com/authentication/verifying-requests-from-slack

リクエスト時のタイムスタンプを必須とし、5分以内のリクエストのみを通すようにします。

require 'rack/utils'

req_signature = "v0:#{timestamp}:#{body}"
my_signature = "v0=" + OpenSSL::HMAC.hexdigest('sha256', ENV['SLACK_SIGNING_SECRET'], req_signature)
Rack::Utils.secure_compare(my_signature, slack_signature)

3.Concatenate the version number, the timestamp, and the request body together, using a colon (:) as a delimiter.
4.Hash the resulting string, using the signing secret as a key, and taking the hex digest of the hash.
5.Compare the resulting signature to the header on the request.
引用: https://api.slack.com/authentication/verifying-requests-from-slack

リクエストに使用されている署名シークレットを検証します。

  1. バージョン番号(v0)、タイムスタンプ、リクエスト本文(token=hogefuga...) の三つを連結します。
  2. 連結した文字列をハッシュ化し、16進数のダイジェスト値を取得します。
  3. リクエストの署名とSlack App側の署名を比較します。
    • タイミングアタック防止のため、署名の比較は Rack ライブラリを利用します

ENV['SLACK_SIGNING_SECRET'] には Slack App の署名シークレットを格納しています。
署名シークレットは以下の項目で確認ができます。
Your Apps -> <作成した App> -> Basic Information -> App Credentials

require 'aws-sdk-lambda'

lambda = Aws::Lambda::Client.new
lambda.invoke(function_name: '<function_name>',invocation_type: 'Event', payload: JSON.generate({event: event, context: context}))
return { text: '処理中です...' } # 一時的な応答として表示される文言です。

AWS API 用のライブラリを使用し、後続の Lambda を呼び出します。
invocation_typeEvent にすることで、非同期での呼び出しが可能になります。

最後に 一時応答用のテキストを返します。Slack 側からは以下のように表示されます。

operation_timeout が返ってくる場合、Lambda のメモリを512 MB 以上に上げてみてください。

本処理用のLambda

Slack から呼び出された Lambda とは切り離されているため、後続での処理に関しては基本自由になります。 今回は メッセージ送信処理の例を紹介します。

require 'slack-ruby-client'

Slack.configure do |config|
  config.token = ENV['SLACK_ACCESS_TOKEN']
end
slack_client = Slack::Web::Client.new

channel = '#通知先チャンネル'
response = slack_client.chat_postMessage(channel: channel, text: body, mrkdwn: false)

ENV['SLACK_ACCESS_TOKEN'] には Slack App の署名シークレットを格納しています。
署名シークレットは以下の項目で確認ができます。
Your Apps -> <作成した App> -> OAuth & Permissions -> OAuth Tokens -> Bot User OAuth Token
Bot User OAuth Token は Slack App がワークスペースにインストールされたタイミングで発行されます。

関数URLの設定

以下の内容で 前段側 Lambda に関数URLを発行します。

  • 認証タイプ: NONE
  • 呼び出しモード: BUFFERED (デフォルト)

発行した URL はスラッシュコマンドの設定に使用します。

Slack App での スラッシュコマンド設定

Slack App の設定ページから、スラッシュコマンドの設定を行います。
Your Apps -> <作成した App> -> Slash Commands -> Create New Command

以下の項目を設定します。

  • Command: 自由に記述
    • 必須
  • Request URL: 前段 Lambda の関数URL
    • 必須
  • Short Description: 自由に記述
  • Usage Hint: 自由に記述

※変更が反映されない場合は、ワークスペースへの再インストールを実施してください。

Slackへ App の追加

※以下の作業は スラッシュコマンドを実行したいチャンネルで実施してください。

Slackのチャンネル名を押下すると、チャンネル詳細が表示されます。

インテグレーション -> App -> アプリを追加する
ワークスペースに存在する Slack App の一覧が表示されるため、作成した App を追加してください。

以上で、全ての設定が完了しました。
対象のチャンネルから作成したスラッシュコマンドが実行可能になります。

Lambdaの処理を二つに分けている理由

Slack の スラッシュコマンドの仕様として、リクエストを受けてから3秒以内に応答を返す必要があります。
少し複雑な処理を行おうとすると、応答までに3秒以上かかりリクエストがタイムアウトしてしまいます。
参考: https://api.slack.com/interactivity/slash-commands#responding_basic_receipt

また、分けることによってパブリック状態にある Lambda と 本処理を切り分ける意図もあります。
構成は一つ増えますが、基本的には分けることをオススメします。

まとめ

今回は 社内サーバー上のジョブを Lambda + Slack スラッシュコマンドへ移行した際の設定内容を共有させていただきました。
スラッシュコマンドを使用することで、簡易的なスクリプトの実行が容易になります。
対象となったジョブは デプロイ対象を一覧表示してくれる便利系のツールで、誰でも・いつでも 実行と確認が可能でした。
Slack へ移行したことで スマートフォンなどからも操作が可能になり、利便性も上がったと思います。

当記事が、同様の実装をされる方の役に立てれば幸いです。

Slackを活用したチームの課題解決と共有プロセス

こんにちは、開発部の椿です。
飲食店ドットコムモビマルなどのサービス開発・運用保守を担当する「会員企画開発チーム」のチームのリーダーを務めています。
今回は、チーム内で実践している「問題・課題をチーム全体で解決していく」という運用方法についてご紹介します。

運用を始めた経緯

最初のきっかけ

私たちのチームでは毎月バグの振り返り会を開催し、バグ抑制のための対策立案とその振り返りを行っています。 この過程で、技術不足に起因するバグを抑えるため、問題のあるコードをメモし、チームのコーディング規約としてまとめていく方針を立てました。

発展

まず、Slackに問題のあるコードをメモしていく運用を開始しました。弊社は開発部のチャンネルは dev という接頭辞をつけるルールがあり、「会員企画開発チーム」なので kaiin (ローマ字読みがダサいですが…)という言葉を使って「#dev_kaiin_review_notes」というチャンネルを作成しました。
当初は気軽に書き込めて良いという意見がある一方で、気になるコードがあってもまだ書き込んでいなかったり、個人用のチャンネル(times チャンネル)に書いてしまったりという意見もありました。

投稿数も週に1、2個程度と、あまり活発なチャンネルではありませんでした。確かに、問題のあるコードを毎日見かけるわけではありませんよね。
そこで、より気軽に投稿できるよう「#dev_kaiin_notes」にチャンネル名を変更し、投稿内容の制限をなくしました。
その結果、問題のあるコードだけでなく、サービス仕様の落とし穴や開発方針の相談など、さまざまな内容の投稿がされるようになりました。

壁の出現

多様な内容が書き込まれるようになり、1日に1件以上のペースで投稿が増えました。 すると今度は、確認する時間が足りなくなってしまいました。
開発の方針に関わる重要な相談をしたくても、投稿順に確認していくため、なかなか順番が回ってこない…という状況が発生しました。
毎日の朝会で確認していましたが、時間が足りず、確認のためのミーティングを増やすべきかという議論まで出るほどでした。

最終的な形

最終的に、各投稿には重要度の違いがあるはずで、重要度の高い投稿から確認していくべきだという結論に至りました。 重要度の低い投稿は、後回しになっても構わないという考えです。
緊急で話したい投稿には特定のリアクション(「会員企画相談」)をつけ、優先的に確認します。
その後、リアクションのついていないものを順に確認していく運用に落ち着きました。

#dev_kaiin_notes の運用方法

現在の具体的な運用方法をご紹介します。

チャンネルへの書き込み

現在の #dev_kaiin_notes チャンネルは「ちょっと気になったこと、共有したいこと、何でも良いのでメモとして残す場所」と定義しています。 例えば、以下のような内容が投稿されています。

  • コード上の問題点
  • リファクタリング案
  • チーム内への共有事項
  • 便利なツールの紹介
  • 相談事項
  • やりたいことの提案

意識しているのはとにかく気軽に投稿するということです。 「ここのインデントがずれている」くらいの些細なことも歓迎しています。 その結果、先月は1か月で48件、営業日数で割ると1日あたり2.5件の投稿がありました。

投稿の確認

毎朝開催しているチームの朝会で、全員で投稿を確認しています。 どんな些細な投稿でも議題に上げ、投稿者に内容を共有してもらいます。

朝会では #dev_kaiin_notes の確認以外にも議題があるため、通常は最後の余り時間で確認します。 ただし、「会員企画相談」のリアクションがついた投稿は、他の内容よりも優先して確認することで、重要な案件の確認が遅れないよう対策しています。

ここで意識しているのは全員で全ての投稿を確認することです。 投稿内容を全員で共有し、誰もが平等に意見を言える場を目指しています。

確認後

確認後は必ず「次にやること」を決めます。 些細な問題点で優先度が低くなることはあっても、タスクとして切り出し、手が空いた時に改善できるようにしています。 (もちろん、単純な共有など、確認だけで完結するものもあります。)

この運用で改善できたこと

当初の目的であった技術力向上については、#dev_kaiin_notes をきっかけにコーディング規約が充実し、全員がある程度のクオリティのコードを書けるようになってきました。 また、全員で確認することで各自が問題点を意識できるようになり、技術力の底上げにつながっています。

副次的な効果もいくつか現れました。 誰でも気軽に投稿でき、全員で確認して話し合うことを日常的に行うことで、チーム内の心理的安全性が高まり、活発な議論が生まれるようになりました。 さらに、気軽な共有を通じて、個人が持っていた暗黙知をチーム全体の形式知に広げられています。

弊社開発部は2020年のコロナ禍を機にフルリモート勤務となり、それ以前よりもコミュニケーションが取りづらくなっていました。 しかし今では、#dev_kaiin_notes をきっかけに、チーム内で活発なコミュニケーションが取れるようになったと実感しています。

おまけ

弊社ではドキュメントツールとして esa.io を利用しており、朝会の議事録は esa に残しています。 しかし、投稿は Slack で行うため、投稿と議論を結びつけることが難しくなっていました。
そこで、esa の内容を Slack に転記するスクリプトを作成し、esa のコメント webhook から AWS Lambda function でスクリプトを実行する仕組みを構築しました。 これにより、Slack だけで過去の議題と結論を確認できるようになりました。

まとめ

#dev_kaiin_notes という、誰でも気軽に書き込める Slack チャンネルを活用して問題点の吸い上げや知見の共有が促進され、活発なコミュニケーションを通じて継続的に改善を図るチームを形成できました。
繰り返しになりますが、大事なことは以下の3点です。

  1. 何でも良いのでとにかく気軽に書き込める場所にすること
  2. チーム全員が参加する場で確認すること
  3. 優先度をつけて確認ができるようにしておくこと

この取り組みはサービス開発を行う他のチームにも展開され、各チームで独自の notes チャンネルを作って運用しています。

Kaigi on Rails 2024に参加しました

アプリケーション基盤チームの深野です。
普段は社内の開発環境の整備やいくつかのアプリケーションのRuby/Railsのバージョンアップなど、SRE的な業務のインフラ寄りでない部分を主に行っています。
弊社では、業務時間内の扱いとして勉強会や技術カンファレンスに参加できる制度があるため、Ruby Kaigi 2024に続いて人生で2回目の大規模な技術カンファレンスとしてKaigi on Rails 2024に参加してきました。
Ruby Kaigi 2024とは異なり、Kaigi on Rails 2024は弊社は特にスポンサードなどはしていないのですが、快く送り出していただけました。

Kaigi on Railsとは

今回私の参加したKaigi on Rails 2024は、「初学者から上級者までが楽しめるWeb系の技術カンファレンス」をコアコンセプトにした技術カンファレンスです。
今年6月に弊社の大庭が参加レポートを書いたRuby Kaigi と異なるのは、名前の通りRuby KaigiがRubyという言語を中心にした技術カンファレンスであるのに対し、Kaigi on RailsはRailsを中心とした技術カンファレンスとなっています。
Ruby Kaigiに1回、Kaigi on Railsに1回しか参加していない技術カンファレンス経験の浅い自分がこれらを比較するなら、以下のようになるでしょうか。

  • Ruby Kaigi
    • 基本的に全てRubyに関する話題
    • 開催地は全国各地
    • セッションは基本的に30分
  • Kaigi on Rails
    • Railsに関する話題が中心だが、たまに直接はRailsと関係ないものもある
    • 開催地は今のところ全て東京都内(2025年は東京駅直通の建物内でやるそうです!)
    • セッションは15分の比較的短いものと30分のもので分かれている

ただこれらは2025,2026...と続いていくとどんどん変わっていくかもしれませんし、感想としては参加者の顔ぶれや全体的な雰囲気など2つのイベントは色々似ているところも多いと感じました。

講演感想

どの講演も面白かったのですが、個人的に特に印象に残ったものの感想が以下になります。

1日目

Rails Way, or the highway(オープニングキーノート)

非常に濃密で中身のある発表で、スライドの中で出てくる言葉がとても印象的でした。特に

  • Rails Way is a philosophy of building Rails applications
  • Think as a framework author, not a custom application developer

という2つは優れたRails開発者になるためのマインドセットを的確に言語化してくれたと思いました。また、これらを実現するためにキャッチーな一言で終わるのではなく、

  • 抽象化レイヤーの間に明確な境界を引いて、各抽象化は自身より下の情報しか見ないようにする
  • RailsでデフォルトのMVCだけでは対応できない要件にぶつかった時には、Railsフレームワークの製作者のように考えてベースのclassを作ることで抽象化を実現する(こちらは少し私の解釈も入っているかもしれないです)

と言った具体的な方法論まで述べられていました。
今後Railsで開発していて単純な方法論としてのRails Wayでは解決できない問題(MVCだけで完結しない種類の要件を実装しないといけない時)にぶつかった時にどう対処するべきか、というマインドセットが手に入ったのは本当に大きかったです。
Ruby Kaigi 2024もそうでしたが、Kaigi on Rails 2024は贅沢なことに同じ時間に複数のセッションがあるため、懇親会などで昼間のセッションの話をしようとしてもなかなか同じセッションを聞けておらずあまり深い話ができないということがありました。
そんな中で、オープニングのキーノートセッションは参加者のほぼ全員が聞いていることで話題の中心になることが多かったのですが、今回のこの発表は分かりやすく、面白く、それでいて他の人にこの話題について色々語りたくなるというオープニングキーノートとして欲しいものが全てあって大満足でした。

現実のRuby/Railsアップグレード

私が最近の業務でRuby/Railsのアップグレードをしていると言うこともあり、タイトルを見た時点でかなり気になっていました。
実際に聞いてみた感想としても、自分が業務で今実際にやっていることや、これからやろうとしていることが多く紹介されており、共感できるとともに自分の今やっていることが大枠では間違っていないという自信になりました。
一方でこれまでの自分にはなかった視点の内容も多く、これから意識したり導入したりしたいというように思えました。特にこれから参考にしようと思ったのは、

  • gemを導入するかどうかの判断基準として、もしそれの更新が止まったときに自分が保守したり、別のgemに置き換えられるかの視点を持つこと
  • gemを導入する時には、Railsをモンキーパッチしているものは一見とても便利に見えても基本的に避けること

というgemの導入判断基準を紹介されているところでした。
実際にアップグレード作業をしていても、古かったりサポートが切れているgemの扱いにはよく困るので、次からはgem導入時にこれらの視点を持っていきたいです。

2日目

都市伝説バスターズ「WebアプリのボトルネックはDBだから言語の性能は関係ない」

Ruby Kaigi 2024でのosyoyuさんが作成したRubyプロファイラpf2についての発表である「The depths of profiling Ruby」が個人的にとても面白かったので、今回の発表についても是非聞きたいと思っていました。

実際に、発表内容はRuby Kaigi 2024での内容からある程度関連性がありつつも、Kaigi on RailsということもありWebサーバーにフォーカスした内容になっていました。
内容としては、「WebアプリのボトルネックはDBだから言語の性能は関係ない」という都市伝説を根拠を持って打ち砕いた上で、具体的にRubyとGoという異なる言語で実装されたWebサーバーでは、現実的な条件下でどのようなパフォーマンスの違いがあるのかを実際に検証して考察するものになっていました。
私は不勉強だったため、Ruby Kaigi 2024に参加したことで初めてRubyの言語的な制約であるGVLの存在や、Rubyの言語開発者がパフォーマンス上の問題にどのように立ち向かおうとしているかを知りました。本発表を聞いたことでそこから発展して、それらのRubyの制約やこれから導入される新しい機能が具体的にRubyによって構築されたWebサーバーのパフォーマンスとどのように繋がっているのかというところの一端に触れることが出来ました。Ruby KaigiとKaigi on Railsの橋渡しをしてもらえるような発表内容だったと個人的には思いました。

WHOLENESS, REPAIRING, AND TO HAVE FUN: 全体性、修復、そして楽しむこと(基調講演)

Kaigi on Railsを締めくくるラストの基調講演であり、これから成長していきたいエンジニアに向けられた発表内容はとても印象に残りました。
最初にタイトルを見た時には正直、どちらかというと抽象的な話なのかなと思っていたのですが、実際にはこれから経験を積んでいく段階のエンジニアが良いWebシステムを作れるようになるためにやるべきことが具体的にまとめられている内容になっていて、偶然かもしれませんが初日のキーノートとも上手く対応しているような関係にもなっていると思いました。

特に、うまく機能するWebシステムを作るために必要なシステムのWHOLENESS(全体性)への理解を深めるために、

  • LB, CDN, キャッシュサーバー, Webサーバーなどのシステムコンポーネントがなぜそこにあり、どのように機能しているのかを知ることが重要
  • なぜなら、設計とは現実世界の問題を1つずつ解いていくことの積み重ねにあるものだから
  • 分散アーキテクチャやマイクロサービスなどは設計していった結果、「そうなる」もの

という主張がされていたのはとても納得感がありました。ただ流行りに飛びつくのではなく、基礎である要素技術への理解を積み重ねていったその先として新しいアーキテクチャを理解できることが優れたエンジニアへの道だと思うので、Webシステムの構成要素をそれぞれ地道に一歩一歩勉強していくことへの背中を押してもらえました。

おわりに

Kaigi on Railsは初参加でしたが、どの発表も面白かったり、共感できたり、ためになったりして色々な角度から楽しむことができました。
発表だけでなく懇親会でも以前同じ職場で働いていたエンジニアと話せたり、新しいRuby/Railsの機能を実際に運用している他社の人からリアルな感想を聞けたりしてとても充実感がありました。
Ruby KaigiもKaigi on Railsもチケットが売り切れるのがかなり早いので、来年以降に参加しようと思っている方はチケットが販売されたら早めに購入することがおすすめです!