Hello Tech

AutoReserve 等を開発する株式会社ハローのテックブログです。スタートアップの最前線から本質的な価値を届けるための技術を紹介します。

Twilio Flex による多言語コールセンターシステム構築

uiu です。

株式会社ハローでは AutoReserve を運営していますが、サービスの裏側で飲食店やユーザーのサポートのためコールセンターを運用しています。直近では日本語だけでなく英語等多言語でのカスタマーサポートを提供しています。

昨年、カスタマーサポートが利用するコールシステムを、外部のIP電話サービス(MiiTel) から Twilio Flex を使った内製のコールセンターシステムに切り替えました。

Twilio Flex 製システムを運営し始めてから半年以上経つなかで、カスタマイズの知見などが見えてきたので、今回紹介しようと思います。

背景

カスタマーサポートではIP電話サービスを利用していましたが、当時以下のような課題がありました。

  • サポートの品質向上のため、ダッシュボードでの可視化や分析をしたいができることが限られていた
  • 社内CRMとの連携や自動化をしたいがカスタマイズ性や拡張性が限られていた
  • 将来的に、受電時等に日本語だけでなく英語など多言語に対応できるようにしたいが、そのための機能が不足していた

外部IP電話サービスの契約更新タイミングがあったため、そのタイミングでTwilio Flex を使って社内コールセンターシステムを構築することに踏み切りました。

Twilio Flex を選んだのは以下のような理由でした:

  • ダッシュボードでの分析・可視化など、サポート品質改善のために必要な機能がビルトインで揃っている
  • React でプログラマブルに拡張ができ、API連携することにより細かい部分までカスタマイズできる
  • サービス側ですでに Twilio を活用しており、サービスの挙動との連携が比較的簡単だった

Twilio Flex とは

Twilio Flex はコンタクトセンターシステムです。社内では音声での電話通話でのみ利用しています。

Webブラウザ上で使える架電や受電をするIP電話の機能に加えて、リアルタイムにメンバーの稼働状況を可視化するためのダッシュボード機能などが統合されています。

エンジニア目線での最大の特徴は、React を使ってダッシュボードを拡張できて、大部分の要素がプログラマブルにカスタマイズできる、という点です。

Twilio Flex は Programmable Voice, TaskRouter, Twilio Studio といった Twilio が提供している技術要素、API群から構成されており、ほぼすべてがAPI経由でアクセス可能になっています。

ほぼすべてがAPIの形で提供されていて、やろうと思えばどの要素もカスタマイズ可能なので、コールシステム界のAWSといって過言ではないと思います。

カスタマイズに使う React 環境はモダンな開発環境で、Webフロントエンドの現場で React を書いているエンジニアにとっては馴染みやすい開発環境だと思います。

たとえば、Flex Plugin は以下のようなディレクトリ構造の npm package になっており、Plugin 開発時に使う @twilio/flex-ui 等のライブラリも npm package として提供されています。

カスタマイズ

デフォルト設定だけでも Twilio Flex は十分使えるサービスですが、社内でカスタマイズをすることで使い勝手を大幅に改善することができます。

開発してカスタマイズしている内容について紹介します。

プラグインによるCRM連携

社内CRMを連携して Twilio Flex 内に表示するためにプラグインを開発しました。

以下のような機能を実装しています

  • 架電・受電時に、CRMビューに社内CRMを自動的に表示する
  • 通話時に dual channel で録音する
  • 着信時に呼び出し音を再生する
  • 通話時にサービスDBに通話ログを作成する

いくつかピックアップして詳しく説明します。

CRMを自動的に表示する

架電・受電時に作業内容の対応するCRMの画面を自動的に表示する機能を実装しています。

以前は、問い合わせや電話番号に応じてCRMを手で検索する作業が必要でしたが、自動的に表示されることでその手間を削減できるようになりました。

別ドメインで立てている管理画面を iframe で Flex 内に埋め込む仕組みですが、違和感少なく使えています。

左側が Flex のデフォルト画面で、右側がプラグインで表示している社内管理画面です。

着信時に呼び出し音を再生する

デフォルトでは着信時に呼び出し音が鳴らず、ブラウザの別タブで作業していると着信に気づかない可能性があります。

そのため、着信時にピロピロした音を再生するようにしています。以下記事で書かれている方法と同じですが、Flex plugin 内にJSで着信時に発生するイベントにイベントハンドラーを立てることで実装しています。

FlexプラグインTips(着信時に音を鳴らす) - Qiita

Studio Flow によるノーコード拡張

受電時の挙動をノーコードでも拡張できるのは大きなメリットです。

Studio Flow で様々な条件に応じた分岐をノーコードでポチポチするだけで実装できます。

営業時間外でのメッセージ読み上げ、受電時のSlack通知、言語別のルーティングなどを実装しています。

ドキュメントなどではコードを書いて実現されていることも、工夫すれば Studio Flow で簡単に実装できることもありました。

たとえば、営業時間外の判定では現在時刻の時間部分を取り出し、時差を計算した上で営業時刻の範囲と match するかどうか判定することで分岐を実装できます。

Slack の通知も HTTP Request ウィジェットを使い、incoming webhook URLを設定すればコードを書くことなく実装できます。

TaskRouter による多言語コールセンター化

TaskRouter を組み合わせると、電話をスキルに応じてメンバーに振り分け・ルーティングすることができます。

メンバーに言語スキル(例: 日本語 → ja, 英語 → en )を振り、呼び出し元の電話番号等の情報から対応言語を判断することで、自動的に多言語での対応が可能なコールセンターを実現しています。

TaskRouter の全体像はやや理解に時間がかかる面もありますが、以下の記事を参照すればわかるようにはなると思います:

Twilio Flexの始め方(TaskRouter基礎編) - Qiita

設定操作はAWSやGCPのコンソールをポチポチするような感覚に近いです。

開発 tips

ドキュメント

1点、最初困った点を挙げるなら、公式ドキュメントがわかりやすくはないという点です。

TaskRouter など Flex の構成要素を1つ1つ理解しないと全体像が掴めなかったり、Flex plugin を作る際の API の使い方が十分にドキュメント化されてなかったりして、最初の開発作業では戸惑うことが多かったです。

一般的な開発の際には推奨しませんが、Flex での開発の際には Twilioエバンジェリストの方などが書いている Qiita の記事を読むことをおすすめします。時間を大幅に節約できます。

qiita.com

その他

Twilio Flex はバージョンのアップデートを自分たちで管理できるようになっていますが、早く改善を取り込みたいため自動アップデートをONにしています。

開発時には Dev Phone が便利でした。ローカル開発環境のブラウザから電話をかけることができるので、検証・デバッグ時に便利です。電話料金も自前の携帯電話からかけるのと比べて安いです。

導入効果

当初サポートメンバーがスムーズに利用できるかどうか不安でしたが、大きな問題なく日々利用してもらえています。

ダッシュボードでの可視化・リアルタイムでの稼働状況可視化は特にメリットが大きかったです。これまでできなかった細やかな品質向上に取り組むことができていると感じています。サポートメンバーがリモートで働いている環境では特に大きく活用できると思います。

分析画面は以下のように見れます (公式ドキュメントから引用したスクリーンショット)

見ている指標

サポート品質担保・改善のため、見ている指標・メトリクスは多数ありますが、社内で活用している代表的な指標をピックアップすると以下になります:

  • Wrap up Time - 電話対応後のCRMでの処理時間の Agent(メンバー)ごとの平均時間
  • 架電数 - Agent ごとの架電数

たとえば、Wrap up Time が小さすぎる場合は作業が早いですがちゃんと登録作業を行えてない可能性があります。あくまで数字なので、実際の対応内容も合わせてチェックすることで適切なフィードバックができるようにしています。

その他のコールセンター指標やKPIに関しては以下の記事にも書いてあります:

21+ Call Center Metrics to Track | Twilio

まとめ

今回は Twilio Flex を活用して社内でコールセンターシステムを開発運用している話を紹介しました。

フルリモートでコールセンターを運用しているため、対応品質をチェックしたり、しっかり稼働しているかの確認をする上でレポートは必要不可欠な機能です。良い人が正しく評価されるためにも Flex を活用し、細かいデータが出せることは大変ありがたいと感じています。

Web サービスの表側から見えにくい部分だからこそ、サポートは重要だと考えています。今後も品質向上を技術で支援するために、カスタマイズや連携等の開発を進めていきたいと思っています。

また、導入や運用にあたっては Twilio や KDDI Web Communications の皆さんにサポートして頂きました。ありがとうございました。

ハローではサービスを良くするためにあらゆる技術を尽くすエンジニアを募集しています。

hello.ai

PostgreSQL + Rails へ PgBouncer を導入してDBメモリ使用量を大幅に改善した話

uiu です。ハローでは普段バックエンド開発をメインに担当していますが、創業以来片手間でインフラも担当しています。

ハローでは、少数精鋭のメンバーの意識をプロダクト開発に集中するため、インフラ面では Cloud Run などマネージドなサービスを最大限に活用しています。

今回は、久しぶりにインフラに意識の一部を捧げ、いくつかの眠れない夜を過ごす機会があったので、インフラ面の話について紹介しようと思います。

スタートアップと PostgreSQL

AutoReserve はサービス立ち上げ以来、DB は PostgreSQL、APPサーバーは Ruby on Rails のバックエンド構成で運用してきています。

特に PostgreSQL は立ち上げ以来安心して使い続けられている技術要素です。サービス運用から(ある規模までの)分析まで PostgreSQL だけで回せる点は、少人数でプロダクト開発するスタートアップにとって利点が大きかったと思っています。

また、Rails + PostgreSQL 構成の運用に関しての知見は、GitLab HandbookHeroku など質の高いドキュメントにまとまっていて、困ったときに頼りになることも多いです。

Google Cloud の Cloud SQL for PostgreSQL 上で運用しており、トラフィックに応じてスケールアップすることでこれまでサービスを成長させてきました。

大きいテーブルでは、1億行を超えるレコードを持つテーブルもありますが、Declarative Partitioning などの機能を利用することで問題なく運用できています。

課題

PostgreSQL でサービスをスケールさせていく上で、1点ぶち当たる問題は DB コネクション数の上限に到達することです。

PostgreSQL はベストプラクティスとされるコネクション上限が小さいことで知られており、たとえば Cloud SQL のメモリが100GB以上ある環境でもデフォルト設定では高々 1000 コネクション程度です。

たとえば、オートスケール等でAPPサーバーのインスタンス数が増えたタイミングで、PostgreSQL 側のDBコネクション数の枠が足りなくなり、エラーが発生します。

もちろん Rails 側で connection pooling の仕組みはありますが、1 worker 辺り 1 connection を消費することを考えると一定以上のトラフィックが発生するとコネクションが足りなくなります。

キャッシュすることでスループットを高めるなどのアプリケーション側で対処ができるケースもありますが、write が多いケースでは持続的な解決策ではありません。

AutoReserve でもピークタイムで以下のような問題が発生していました:

  • コネクション上限に到達し、新しいDBコネクションが作れないことにより、一部リクエストがサーバーエラーになる
  • 同時コネクション数が増えることで、メモリ使用率が上がり、それと伴うようにDBプロセスがクラッシュする

当初は max_connections 設定を上げることでエラーを回避していましたが、DBプロセスがクラッシュする等の現象が発生するなど不安定性が増したため、解決になりませんでした。
max_connections 設定を上げるだけで、スループットが下がることを報告している記事 もあります。

PgBouncer 導入

Standalone Connection Pooler として PgBouncer を導入することにしました。

PgBouncer は、DBサーバーやAPPサーバーとは独立したサーバーとして動作します。 APPサーバーからDBに直接コネクションを貼る構成では、APPサーバーの数に比例してコネクション数が増えますが、PgBouncer が中間で connection を保持することでDBサーバーに貼られるコネクション数を一定数に抑えられます。

https://devcenter.heroku.com/articles/best-practices-pgbouncer-configuration より引用

DBとAPPサーバーの間に新しいレイヤーを導入することになるため、PgBouncer 自体の不具合によってシステム全体が不安定性になるリスクがあります。

そのため、当初は導入を先延ばしにしていましたが、他の回避策では思ったような効果がなかったため、デメリットはあったものの PgBouncer の導入をトライすることにしました。

PgBouncer の導入後の効果

当然ですが、導入日(1/20辺り)以降、想定通りコネクション数が一定に抑えられています:

メモリ使用量についても大幅に改善しており、ピークタイムでもメモリ使用率が上がることはなくなりました:

これにより、DBクラッシュ等の大きな問題の頻度も減りました。

特にメモリ使用率については驚くほど改善しました。DB全体の安定性が体感でも増したため、僕個人としても睡眠の質が向上しました。

PgBouncer サーバー自体も現状安定して動作しています。その他、APIレスポンスタイム等のメトリクスが劣化することもなかったため、導入は成功だと言ってよさそうです。

PgBouncer デプロイ

デプロイ構成

以下のような構成でデプロイしています:

  • Managed instance group 上に PgBouncer を docker で複数台立ち上げる
  • internal tcp load balancer で複数台の PgBouncer にAPPサーバーからの接続を分散させる
  • docker image は edoburu/pgbouncer を使用

app サーバーからDBまでの接続は以下のような経路になります:

app → internal tcp load balancer → pgbouncer (複数台) → db

app: Rails アプリケーションサーバー
db: PostgreSQL DB

PgBouncer 設定

transaction pooling mode を利用しています。

今回のケースでは、Rails 側ですでに session pooling を行っているため、transaction pooling mode でなければ効果がありません。

全体の connection が200程度になるように調整しています。

注意点

transaction pooling mode では session に依存する機能は使えないことに注意する必要があります。

advisory locks や prepared statements などの機能を使うことができなくなります。

https://www.pgbouncer.org/features.html#sql-feature-map-for-pooling-modes

Rails では、デフォルトで prepared statements が利用されたり、migration で advisory lock が取得されるため、これらの機能を無効にする必要があります。

そのため、database.yml で以下のように無効にしています:

production: &production
  <<: *default
  # ...
  prepared_statements: false
  advisory_locks: false

prepared statements を無効にすることでクエリに若干のオーバーヘッドが発生することが想定されますが、導入後の影響は軽微でした。

参考リンク

最後に、参考になったリンクをまとめておきます。

connection pooling については以下を参照しました:

www.craigkerstiens.com

devcenter.heroku.com

Rails 開発では GitLab Handbook をお手本としてよく参考にしています。GitLab はフルリモート組織のため、開発ドキュメントが綺麗に整理されており、ドキュメントの書き方自体も学ぶ点が多いです。

docs.gitlab.com

Rails の本番運用でのベストプラクティスについては以下も参考にしています。author が Instacart での運用経験から書いたもので、著者のOSSライブラリを利用したり、アイデアをよく参考にしています。

github.com

まとめ

PostgreSQL のコネクション数上限と不安定性の問題を PgBouncer を導入して解決した事例を紹介しました。

PostgreSQL を長年運用している方にとっては常識的な内容ではあるかもしれませんが、誰かの参考になれば嬉しいです。

これからは、段階に応じて AlloyDB などよりスケーラビリティの高いDBへの乗り換えも計画していく予定です。

これからも開発生産性をMAXに保ちつつサービスを成長させていくために、インフラは極力シンプルに保ち、RDBMSを最大限に活用してプロダクト開発を進めていこうと思ってます。

株式会社ハローでは、シンプルに保つこと、高速にプロダクト開発をすること、を愛するエンジニアを募集しています。
業務委託や副業の形でも募集しているので、話を聞いてみたい方は uiu にぜひ気軽にお声がけください。

hello.ai

React NativeとExpoを活用したネイティブビルド不要のE2Eテストの導入

はじめに

はじめまして、株式会社ハローで業務委託として開発をしている@0906kokiです。 今回の記事では、React Nativeで開発されているAutoReserve for Restaurantsで、Expoを最大限に活かしたE2Eテストの導入実装について書きたいと思います。

背景

飲食店向けにセルフオーダーや予約台帳の機能を提供するAutoReserve for Restaurantsは、React Nativeで開発されております。 今回、AutoReserve for RestaurantsにE2Eを導入した目的に関しては、以下のような点が挙げられます。

  • 手動テストの場合、テストをスキップ or 見逃していたケースがあったので、毎回網羅的にテストできるようにし、QAの質を上げたい
  • 頻繁に本番デプロイできるようにする
  • コミットごとにテストできるようにすることで、QAを待たず事前にデグレを検知できる
  • (今後) ライブラリのアップデートなど、全てのテストが通ったら自動でマージできるようにしていきたい
    • 例えば、react-navigationやreact-native-reanimatedなどの全体に影響あるライブラリのパッチバージョンアップなど

これまでも、AutoReserveなどweb側のプロジェクトにはcypressによるテストが導入されていましたが、ネイティブアプリ側のE2Eテストは導入できていませんでした。

理由として一番大きかったのは、

  • ウェブ側のテストと比べ、アプリ側のテストはネイティブビルドのみで数十分以上かかるため、E2Eテストを回すのに時間がかかり、開発のスピード感が損なわれる

という点です。前提の部分に詳しく記載しましたが、今回、ExpoのOTAリリースの仕組みを取り入れることで、ビルドバイナリを再利用することで上記課題を解決し、ウェブと同じようなスピード感でPRのコミットごとにE2Eテストを走らせることができるようになっています。

前提

今回のE2E実装について詳細をお伝えする前に、AutoReserve for RestaurantsのReact Nativeにおける技術的な前提条件について書きたいと思います。

冒頭に AutoReserve for Restaurantsでは React Nativeを使っていると紹介しましたが、React Nativeの中でも、ExpoのManaged Workflowで開発しており、EAS Buildの仕組みを使ってネイティブビルド、ストアリリース等を行っています。 Expoのワークフロー上で開発しているため、ネイティブモジュールに変更が加わらないJavaScript部分の修正に関しては、OTA (Over The Air Update)で、ストアリリースを通さずにリリースを行っており、逆にネイティブに変更が加わり、ストアリリースが必要な際には、EAS Build を使ってネイティブビルドを走らせてから各ストアへのリリースを行います。また、開発時においてもPR単位でExpoのリリースチャンネルを生成してOTAを走らせるため、PRへのpushごとでのJavaScript部分の動作確認やテストが可能となっております。

いずれの場合も、GitHub Actionsでワークフローは自動化されており、Expoを利用する上でのメリットを最大限に享受している形となっています。

実装内容

これらの技術的な前提を置いた上で、E2E を導入した際の実装内容について書きたいと思います。

今回、E2E のライブラリとして、Detox を使用しました。Detoxを採用した理由としては、非同期処理を内部で監視してflakiness(テストの不安定さ)を回避し、実行の安定性を担保してくれる点や React Native用 E2Eツールとして開発されている点などが挙げられます。 前者に関して補足すると、Detoxには同期の仕組みがあり、アプリのネットワークリクエストやアニメーション、タイマーを監視し、自動で完了を待機してくれるため、テストコード側でsleepなどを入れる必要がなく、高速かつ安定的にテストを走らせることができるそうです。

E2E を CI で実行する上で考えたいこと

CI上でE2Eを実行し、事前にデグレを検出したいので、以下のような条件であることが望ましいです。

  • E2E のセットアップ・実行にかかる時間が短いこと
  • 最新の変更に対する E2E テストであること

E2Eの実行速度が速いこと

CIでは基本的に実行時間に対する課金となるため、E2E自体の実行時間は速ければ速いほどハッピーです(イテレーションを速く回すという意味でも)。

通常Detoxは最初にdetox buildという、シミュレータでテストを実行できるようにするためのネイティブビルドを行い、そこで生成したビルドファイルを元にテストを実行します。

このdetox buildは、普通に行うと約10分 ~20分ほど実行に時間がかかり、それをCIで毎回コミットごとに行うことは避けたいことであります。 なので、今回は事前にシミュレータ用のEAS Buildを実行してビルドファイルを生成しておき、 テスト時はそのビルドファイルをExpo上からフェッチしてシミュレータ用のバイナリから最新のJavaScriptのbundleを読み込み、E2Eを実行することとしました。

EAS Buildした最新のビルド結果のリストは、以下のコマンドで取得できます。

$ eas build:list --status finished --platform ios --distribution simulator --non-interactive --json

※ 吐き出される JSON

[
  {
    "id": "...",
    "status": "FINISHED",
    "platform": "IOS",
    "artifacts": {
      "buildUrl": "ビルドファイルのURL",
      "xcodeBuildLogsUrl": "ログURL"
    },
    "initiatingActor": {
      "id": "...",
      "displayName": "..."
    },
    "project": {
      // ...
    }
    // ...
  }
]

CI上でこのコマンドを実行し、上記のJSONから配列の先頭にある artifacts.buildUrl から最新のシミュレータ用ビルドファイルを取得できます。 そのため、 artifacts.buildUrl をフェッチし、プロジェクト内に配置することで、CIで毎回 detox build を行わず、最新のビルド結果フェッチするだけで E2Eを実行できるようになります。

具体的に、以下が取得するスクリプトとなります。

const axios = require("axios");
const path = require("path");
const { existsSync, writeFileSync } = require("fs");
const { execSync } = require("child_process");

const OUTPUT_TAR_PATH = path.resolve(__dirname, "Sample.tar.gz");
const OUTPUT_SIMULATOR_BUILD_BINARY_PATH = path.resolve(__dirname, ".");

// 最新のシミュレータ用ビルドファイルを取得する
const getLatestSimulatorBuildBinary = async (platform) => {
  if (existsSync(`${OUTPUT_SIMULATOR_BUILD_BINARY_PATH}/Sample.app`)) {
    return;
  }

  try {
    // 最新のシミュレータ用ビルドの結果リストを取得する
    const buildJson = execSync(
      `eas build:list --status finished --platform ${platform} --distribution simulator --non-interactive --json`
    );
    // 最新のシミュレータ用ビルドファイルのURLを取得
    const buildUrl = JSON.parse(buildJson.toString())[0]?.artifacts?.buildUrl;
    const response = await fetchBinary(buildUrl);
    writeFileSync(OUTPUT_TAR_PATH, response.data);

    if (!buildUrl) {
      throw new Error(
        "simulator buildしたバイナリが存在しません。simulator buildを実行して、再度試してください。"
      );
    }

    // Sample.appファイルに展開する
    execSync(
      `tar -xf ${OUTPUT_TAR_PATH} -C ${OUTPUT_SIMULATOR_BUILD_BINARY_PATH} && rm -rf ${OUTPUT_TAR_PATH}`
    );

    return console.log("success: バイナリの取得が完了しました。");
  } catch (e) {
    throw new Error(`error log: ${e}`);
  }
};

const fetchBinary = async (url) => {
  return axios.get(url, {
    responseType: "arraybuffer",
    headers: { Accept: "application/x-tar" },
  });
};

// 一旦iOSのみ
(async () => await getLatestSimulatorBuildBinary("ios"))();

先程書いた通り、このシミュレータ用ビルドファイルは 事前にEAS Buildを行ってExpo上から取得できるようにする必要があるため、beta版のネイティブビルドをCIから作成するタイミングでシミュレータ用ビルドも同時に行っています。 これは、beta版をビルドするタイミングは基本的にJavaScriptのbundle以外のネイティブ部分に変更が入るタイミングで、それに合わせてシミュレータビルドも最新にする必要があるためです。

上記のように、detox build を行わずに、最新のシミュレータビルドしたバイナリを取得する方針にしたことで、約10~20分かかっていた時間が、1分未満(フェッチする時間)に短縮することができました。

最新の変更に対する E2E テストであること

CIを実行するたびにE2Eを実行して事前のデグレを検出したいので、当然ではありますが、最新の変更コミットが含まれたものに対して E2Eを実行させる必要があります。 これを実現する方法として、最新のOTAされた JavaScriptの bundleを取得し、その bundleを元にテストを実行する方法です。

前提での部分でも紹介した通り、AutoReserve for RestaurantsではPRごとに 動作確認ができるようにするため、PR単位でリリースチャンネルを作成してOTAを行っております。Expoでは、リリースチャンネルにOTAされたJavaScriptのbundleUrlを取得することができるため、まずは、そのbundleを取得するために、以下のURLからリリースチャネルごとのマニフェストJSONを取得します。

※ URL

https://exp.host/@sample-org/sample/index.exp?release-channel=${対象PRのリリースチャネル}&runtimeVersion=${アプリのバージョン}

※ 返されるJSON

{
  "ios": {
    "jsEngine": "jsc"
    // ...
  },
  "bundleUrl": "OTAされたJavaScriptのbundleファイルのUrl"
}

Manifestにある bundleUrl はOTAされたJavaScriptのbundleファイルのURLであるので、このURLからbundleを取得します。

フェッチからアプリ起動までの流れとしては、以下のような形となります。

  1. bundleUrlをフェッチする
  2. 1でフェッチした bundleUrlを、development buildのdeepLink URLであるexp+app-slug://expo-development-client/?url=のurlに指定する
  3. 上記deepLinkをアプリ起動後に開くURLとして指定する

スクリプトで見た方が分かりやすいと思うので、テストのbeforeAll時 に実行しているlaunchAndOpenApp関数を下記に記載します。

// リリースチャンネルごとの最新のManifestUrl
// RELEASE_CHANNELはGitHub Actions等で環境変数として取得できるようにする
const LATEST_PUBLISHED_MANIFEST_URL = `https://exp.host/sample/sample/index.exp?release-channel=${process.env.RELEASE_CHANNEL}&runtimeVersion=${appConfig.version}`;

const developmentBundleUrl = (platform: PlatformType): string =>
  `http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`;

const fetchLatestPublishedBundleUrl = async (): Promise<string> => {
  try {
    const response = await axios.get(LATEST_PUBLISHED_MANIFEST_URL);
    const data = response.data;
    return data.bundleUrl;
  } catch (e) {
    throw new Error(e);
  }
};

// 対象のbundleUrl
const getTargetUrl = async (platform: PlatformType): Promise<string> => {
  if (!!process.env.RELEASE_CHANNEL) {
    return fetchLatestPublishedBundleUrl();
  } else {
    // 開発環境の場合は、ローカルのbundleを参照する
    return developmentBundleUrl(platform);
  }
};

const getDeepLinkUrl = (url) =>
  `sample://expo-development-client/?url=${encodeURIComponent(url)}`;

// 指定したdeepLink先を開く
export const openDeepLinkUrl = async () => {
  // 一旦iOSのみ
  const url = await getTargetUrl("ios");
  await device.openURL({
    url: getDeepLinkUrl(url),
  });
};

// アプリを起動する
export const launchAndOpenApp = async () => {
  await device.launchApp({
    delete: true,
    newInstance: true,
    permissions: {
      notifications: "YES",
    },
  });
  await openDeepLinkUrl();
};

アプリ起動後にCI上であればaxiosを使って最新のOTAされたbundleUrlを取得、開発環境であれば、ローカルのbundleUrlを取得します。 取得できたら、deepLinkのqueryStringにそのURLを指定し、device.openURLでdeepLinkを開きます。

このように、アプリ起動時に最新のbundleを取得してdeepLinkを開く関数を用意することで、常に最新の変更に対してのE2E実行が可能となります。

まとめ

Expoを最大限に活用して、React NativeアプリにE2Eを導入する方法を書きました。まだ導入したばかりであるので、テストケースの作成やログイン周りの制御など探りながら整備していく必要があるものの、E2Eのデメリットである実行時間やアプリならではの最新の変更に対するテスト実行の難しさなどは解決することができました。

ハローでは一緒に働く仲間を募集しています。少しでも気になる方は気軽にお問い合わせください!

ウェブとReact Nativeアプリのコード共通化による同時展開

javascripterです。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。今回の記事では、AutoReserveでおこなっているコード共通化の取り組みについて紹介します。

背景

AutoReserveのネイティブアプリはReact Nativeで書かれており、またウェブ版は、Reactで書かれています。

ウェブ版では、React Native for Webという、React上でReact NativeのコンポネントのAPIを使えるようにするライブラリを使用しています。

React Native for Webを採用したことで、ハローでは現在、エンジニア1人でiOS、Android、ウェブの全てのプラットフォームに同時展開できるようになりました。 また、不具合修正やデザインの修正も、一箇所を修正するだけで同時にできるようになりました。それぞれのプラットフォームでの実装・デザインの乖離が起こりづらいこともメンテナンス性の向上に繋がっています。

今回の記事では、AutoReserveでどのようにアプリとウェブのコードの共通化をおこなっているか説明します。

何を共通化しているのか

以前の記事(なぜNext.jsをやめたのか?)でも軽く紹介していますが、アプリの機能実装を含む、下記のコードを共通化しています。

  • ボタン、チェックボックスなどのUIコンポネント
  • アプリケーション画面のコード
    • レストランの画面
    • 予約の表示画面
    • レストランの予約フォーム画面
  • eslint、prettier、TypeScriptの設定ファイル
  • TypeScriptの型
    • Protocol Buffersで自動生成したAPIの型
  • 定数
    • 色などのテーマの定義 - レスポンシブデザインのbreakpointの幅定義
    • 税率など定数
  • モジュール
    • usePrevious / useStable / useMediaQueryなどよく使うhooks
    • アプリ・ウェブで共通して使えるalert関数の実装

これらのコードを、@hello-ai/ar_sharedという名前でパッケージ化して社内で利用しています。

現在、フロントエンドはmonorepo化を進めており、一部アプリを除き、共通ライブラリの変更とアプリケーションコードの変更をまとめて行えるようになっています。

まだmonorepo化できていないリポジトリに関しては、

  • 共通コードに変更があった場合、GitHub Packagesにnpmパッケージを自動でpublishする
  • 各リポジトリの@hello-ai/ar_sharedの依存関係のバージョンを自動であげる

というGitHub Actionsのアクションを書き、対処しています。パッケージをまたぐ変更が行いやすくなるよう、いずれ全てmonorepoに集約される予定です。

再利用可能なUIライブラリの共通化

まず、再利用可能なUIコンポネント集・hooksなどのモジュールを用意しています。

  • フォーム関連のコンポネント
  • グリッドレイアウトのコンポネント
  • チェックボックス
  • テキスト入力のコンポネント

などです。UIライブラリはドキュメントとしてstorybookを用意しています。

AutoReserveのUIコンポネント集のstorybook

UIコンポネント集を作成する取り組みはよく見かけますが、AutoReserveではさらに一歩進んで、アプリケーションコード自体も共通化しています。

アプリケーションコードの共通化

アプリケーションコードを共通化するには、アプリとウェブのスクロールの仕組みの違いや、ナビゲーションの構造の違いなどを吸収する必要があり、工夫が必要です。この章では、 アプリケーション画面のコードを共通化する方法を紹介します。

先行の取り組みとして、solitoというNext.jsとReact Nativeでの実装の共通化を行うライブラリがあり、一部参考にしていますが、AutoReserveではReact Routerを使っているため、内部で新規ライブラリを実装しています。

共通化しているもの

現在、アプリ、ウェブの両方で共通化しているアプリケーションコードは下記です。

  • アプリケーションの機能画面全体
  • リンク、画面遷移のコード
AutoReserveウェブサイトのレストラン画面
AutoReserve Androidアプリのレストラン画面

ナビゲーションの共通化の仕組み

アプリ、ウェブで使用しているライブラリはそれぞれ異なり

を使用しています。

アプリとウェブの主な構造の違いとして、通常、アプリはhooksを使い現在のスタックに対して画面をpushするという操作を行うのに対して、ウェブはURLによるリンクで遷移するということが挙げられます。

アプリ:

import { useNavigation } from '@react-navigation/native'

function Component() {
  const navigation = useNavigation()
  const onPress = () => {
    navigation.navigate('Restaurant', { restaurantId: restaurantId })
  }
  return <Button onPress={onPress}>レストラン</Button>
}

ウェブ:

import { Link } from 'react-router-dom'

function Component() {
  return <Link href={'/restaurants/xxxx'}>レストラン</Link>
}

ウェブでは、SEOやアクセシビリティの観点から、onClickによる遷移ではなく、必ず<a>要素としてレンダリングされるようにしたい、という要件があります。

これらの遷移の仕組みの違いを吸収するため、AutoReserveでは下記のような仕組みを採用しています。

  • ウェブでは、通常のリンクとなるようurlを指定して遷移
  • アプリではhooksを使用するが、遷移先の指定はdeep linkで行う

共通UIライブラリ側:

import { Platform, Linking, Text } from 'react-native'

const prefix = 'autoreserve://autoreserve'

export function useLinkTo() { // アプリでdeep linkによる遷移を行うhooks
  if (Platform.OS === 'web') {
    return () => {
      throw new Error('useLinkTo is not implemented for web. You should only use it in native.')
    }
  } else {
    return (to) => Linking.openURL(prefix + to)
  }
}

// ウェブではtoでリンク先を指定、アプリではonPressが作動するコンポネント
export function LinkText({ to, onPress, ...rest }) {
  return <Text
    href={to} // webではhrefを指定しリンク化
    onPress={
      Platform.select({
        web: () => React_Router_navigate(to), // React Routerによる遷移
        default: onPress // アプリでは通常のonPressを指定
      })
    }
  />
}

アプリケーションコードでの使用例

function Component() { // 使用例:
  const linkTo = useLinkTo()

  return <LinkText
    to="/restaurants/slug" // toを指定するとウェブでのみリンクになる(アプリでは無視)
    onPress={() => { // onPressはアプリでのみ実行される
      linkTo('/restaurants/1234')
    }}>
    レストランへのリンク
  </LinkText>
}

まとめると、ウェブではリンク、アプリではonPressのイベントハンドラーを渡し、deep linkによる遷移をおこなっているという形になります。

アプリ側ではdeep linkのpathを渡すのではなくonPressを渡すよう実装にした理由は、アプリとウェブでURLのpathnameの構造が一致しておらず、またアプリでは現状deep linkで表現できない遷移がまだあり、JSコードを間に挟めるようescape hatchを用意したかったためです。

スクロール周りの挙動の違いの吸収

また、アプリとウェブで挙動が大きく異なる部分として、スクロール周りがあります。

  • React Nativeのアプリでは<ScrollView>というコンポネントを使用すると任意の箇所を スクロール可能にできる
  • ウェブの場合、ページのスクロールは通常<body>要素によって行われる

例えば、ウェブでbodyの内側のdivoverflow-y: autoを当てメインのスクロール要素にすると、モバイルでURLバーが自動で隠れなくなるなどUX上の問題があるため、基本的には必ずbodyでスクロールを発生させる必要があります。

これらの問題に対処するため、アプリケーションコードの共通化を行う際は、スクロール要素そのものはコンポネントに含めず、内部のコンテンツのみをコンポネント化しています。

共通化部分:

function RestaurantContent(props: RestaurantContentProps) {
  return <View>
    {
      /* レストランページの中身 */
    }
  </View>
}

アプリ:

import { RestaurantContent } from '@hello-ai/ar_shared/...'

function RestaurantScreen() {

  return <View style={{ flex:1 }}>
    <Header /> // ヘッダは上部固定
    <ScrollView style={{ flex: 1 }}>
      <RestaurantContent .../> // レストラン画面の中身だけをスクロール可能に
  </ScrollView>
  </View>
}

ウェブ:

import { RestaurantContent } from '@hello-ai/ar_shared/...'

function RestaurantPage() {
  return <body>
    <Header style={{
      // body全体をスクロール可能にしているためposition: fixedでレイアウト
      position: 'fixed',
      top: 0,
      left:0,
      right: 0
    }}/>
    <View style={{
      marginTop: headerHeight
    }}>
      // レストラン画面の中身はbody内にそのまま広げ
      // bodyでスクロール可能に
      <RestaurantContent .../>
    </View>
  </body>
}

ページ/画面のコンポネント自体はアプリ/ウェブで別に実装し、そこからの共通コンポネント実装を呼び出すようにしています。この方法には

  • ウェブ/アプリで最適なUI構造となるよう構造を変えられる
  • アプリ/ウェブ側から共通ライブラリ側にpropsを渡せるので、プラットフォームの差異がある場合も共通ライブラリ内で分岐せずに済む

といったメリットがあります。

今後の課題

コード共通化の仕組みは途中で導入したため、まだ共通化ができていない箇所がいくつかあります。現時点での課題として

  • ウェブとアプリで、URLパスとdeep linkの構造が一致していないため、リンク先を複数記述する必要がある
  • データ取得のコードをアプリ・ウェブで共通化できていない

があります。

まとめ

React Native for Webを使用し、少人数でiOS, Android, ウェブの全プラットフォームに同時展開できるコード共通化基盤を構築しているという事例を紹介しました。

ハローでは、生産性を追求し、積極的に技術的な挑戦に取り組める本物のエンジニアを募集しています。

少しでも気になる方は気軽に javascripter にDMでお声がけください!

エンジニア採用情報 | Hello, Inc.

電話音声の自動機械判定モデルとサービス活用

はじめまして、Xです!私は普段アメリカのカーネギーメロン大学に在学していて、機械学習関連の研究をしています。ハローでは主に音声と関係する機械学習の開発をしています。

この記事では、AutoReserveの中で機械学習を使っている自動機械判定というコンポーネントについて紹介します。

自動機械判定とは

ご存知の方も多いと思いますが、電話をかけるときに相手が電話に出られないときは留守番につながることがよくあります。その際に自動音声が流れることがよくあります。AutoReserveのサービスでも、自動音声で予約する際によく店側の電話の自動音声につながることが多くあります。そのときに店側に予約を取ることが難しいため、再び時間をおいてから自動音声をかけ直す必要があります。そのため、電話をかけた際に店側が自動音声で出ているのか、店員が実際に出ているのかを知りたいことがたくさんあります。自動機械判定はこれを分類する機械学習のモデルを指します。

先行研究

意外かもしれませんが、音声合成をする研究はかなりメジャーな研究分野である一方で、自動で機械音声かどうかを判別する研究は多くありません。その原因は色々考えられますが、主な原因はこの判定タスクの定義自体がかなり曖昧で、具体的なシナリオによっては難易度が大きく変わるからだと思います。

たとえば、オーディオブックをアナウンサーが読んだ音声と機械が読んだ音声を区別するシナリオであれば、このタスクはかなり難しいと思います。その理由はそもそも音声合成は人間の声を忠実に再現することを目指しているため、よいモデルであればあるほど区別することが難しいです。一例として、発音の韻律を考慮した最近のこの研究では、人間の主観でも機械かどうかを統計的に有意に判定することが難しいです。一方で、私達は店側に電話をかけたときに留守番の自動音声かどうかを判定するシナリオについてですが、幸いこのタスクはかなり簡単な部類に入ります。

機械学習モデル

まずどんなタスクであってもある程度データを集める必要があります。このタスクは前述のようにあまり研究がなされていないもので、既存のデータはありません。そのために私達自身で、AutoReserveのサービスで集めた音声のうち、ランダムに数百個のサンプルを選んで自動かどうかを手でラベリングしました。

次に実際にどうモデリングできるかを考えてみましょう。このタスクは二値分類の問題で、入力された音声に対して、自動音声かどうかを判定します。ここ10年の音声技術分野ではさまざまな深層学習の手法が開発されていますが、どれもある程度の規模なデータセット(数百時間レベル)を想定しているが、われわれのデータセットは数時間程度しかありませんので、大半の深層学習の手法は使えません。最近流行りのself-supervised learningの深層学習の手法では、データセットは小規模で良いケースもありますが、モデルが大きいため、デプロイする際のコスト問題が発生します。

そこで、私達は深層学習ではなく、もっとシンプルな線形モデル(ロジスティック回帰)を使いました。古典的な線形モデルはシンプルでデータ量が比較的に必要としないかわりに、特徴量の選択が重要になってきます。私達は色々な特徴量をつくって実験してみましたが、そのうちから、重要かつ面白い特徴量を3つ取り上げて紹介します。

  • Callee Overlap Feature
  • Noise Feature
  • Duration Feature

Callee Overlap Feature

自動留守番の機械音声の場合、相手を無視して音声が流れます。一方で私たちも自動で予約音声を話しているため、電話の両方で自動音声が同時に流れます。この状況は実際に人が電話に出ている場合では、なかなか起きません、っていうのはこちらの音声を聞いている間はとくになにも話さないことが多いからです。そのため、通話時間のうち、どれだけ音声がかぶっているかを調べることで、相手が機械かどうかのヒントを与えてくれます。私達の実験では、この特徴量だけでもかなりの精度(75%)を達成することができました。

Noise Feature

音声生成に詳しい方なら知っているかもしれませんが、生成された音声は基本的にノイズが少なく、かなりきれいな声で話されています。これは音声のスペクトル図を見てもはっきりわかります。そのため留守番の機械音声ですと、ノイズがほとんど乗りません。一方で店側で人が出ている場合は、店側の環境音がかなり混じっていることが多く、ノイズが多く乗ります。そのため、店側の音声にノイズがどれだけ混じっているかを調べることで、機械音声かどうかがわかるケースも多くあります。

Duration Feature

通話時間の長さからもかなり多くのことがわかります。留守番電話のケースですと、いくつかの決まった留守音声のテンプレートがあって、そのテンプレートが流れ終わると自動的にその秒数で電話が切られるか、あるいはその後ずっとこちらの留守メッセージを聞き続けて、タイムアウトがくるまでにずっと通話するかのパターンが多いです。そのため留守番の通話時間は特徴的な秒数をとっていることが多くあります。この特徴的な秒数をとっているかどうかを調べること(たとえば混合ガウスモデル)で、機械音声かどうかがわかります。

これらの特徴量に加えて、いくつかほかの典型的な特徴量(MFCC特徴量やクラスタリング特徴量など)を組み込んで線形モデルをつくりました。機械学習のライブラリは最近はやりのpytorchではなく、scikit-learnを採用しています。音声の特徴量抽出はpython_speech_featuresというライブラリを使っています。callee overlapの有無はpy-webrtcvadというモデルを改造して判別しています。すべての実装はnumpyベースであるため、軽量に動かすことができます。

下がおおよそのメインクラスの実装になります。モデルを予め訓練してpickle形式に保存しておき、デプロイ時はそれをロードして使います。毎回新しいリクエストに対して判別を行うときに、まず上にあげた特徴量などを計算しておき、線形モデルに入れて判別します。

class AutomaticMachineDetection:

    def __init__(self):

        # sklearnのロジスティック回帰を使います
        self.model = LogisticRegression(C=100)

        # 音声から典型的なMFCC特徴量を抽出するモデルを準備します
        self.pm = create_pm_model("mfcc_hires")

        # クラスタリング特徴量
        self.cluster = BowCluster(100)

    def feature(self, audio):
        """
        音声から特徴量を抽出します。

        :param audio:
        :return:
        """

        # クラスタリング特徴量など
        cluster_feat = self.get_cluster_feature(audio)

        # 上のcallee overlapの特徴量など
        stat_feat = self.get_stats_feature(audio)

        feat = np.concatenate([cluster_feat, stat_feat])

        return feat

    def predict(self, audio_or_wav_path):

        # only use the first channel which is corresponding to the callee audio (The caller side is ignored for now)
        if isinstance(audio_or_wav_path, str) or isinstance(audio_or_wav_path, Path):
            audio = read_wav(audio_or_wav_path, channel=0)
        else:
            audio = audio_or_wav_path

        # 特徴量抽出
        feat = self.feature(audio)

        # 推論
        return self.model.predict([feat])[0]

さまざまなパラメーターや特徴量をチューニングして実験した結果、98%ぐらいの精度を達成することができました。

このタスクのように、機械学習モデルは必ずしも深層学習を使う必要がなく、簡単なモデルで物足りるケースも多々あります。実際に深層学習を使おうとすると、大量のデータを集める必要があるのに加えて、訓練やデプロイする際に多くの計算資源を必要とします。しかし、この簡単なモデルは個人のノートPCで数分程度で訓練することができて、簡単に開発することができます。ただ、シンプルなモデルの性能を最大限に引き出すために、特徴量を注意深く作る必要があります。

まとめ

今回のモデルは線形モデルであるため非常に軽量なインスタンスで動かすことができて、最終的にこのモデルをFlask に乗せて Google Cloud Functions のサーバーレス環境にデプロイしています。自動音声予約の数は増え続けていますが、ほぼメンテなしで安定してスケールしています。ハローの電話料金コストを削減するのに役立っています。

ハローではこのように音声に関する知見を深めて、フロントエンド、バックエンドの開発にとどまらず、これからも音声の機械学習分野で技術開発を続けていきます。

プログラミング未経験ビジネス職メンバーがSQL覚えたら最強になった件

こんにちは!

株式会社ハロー、マーケティングチームです。日頃は、サービス全体の設計やAutoReserveの問い合わせ数増加施策などグロース全般を担当しています。

2022年2月頃からハローでは、開発職以外の希望するメンバーがSQLを勉強することになりました。実際に学んだ過程や学んだ感想について今回はご紹介したいと思います。

知らない人のために...「SQL」とは?

データベース言語の1つで、データを検索・挿入する際に利用します。GoogleAnalyticsではアクセスデータを確認することができますが、SQLを活用することでサービスが保有しているデータを確認することができます。

例えば、

  • ユーザーの月次登録数
  • ユーザーのアカウントが保有している情報一覧
  • ユーザーのうち、日本人以外のユーザー一覧
  • レストランの都道府県ごとの登録数
  • 店舗向けに販売してるSaasの都道府県ごとの利用数

などをチェック可能です。

データベースにはたくさんのデータが保存されていますが、それを効率的に確認し、マーケティング活動や現在の状況を確認するために利用します。

SQLを勉強することになったきっかけ

ハローは、データを元に施策を考える文化自体はあったものの、ビジネス職メンバーが取得したいデータを自分で取得する方法はわからないためエンジニアに依頼し、Redashで作成してもらうという流れが一般的でした。

ただ、この運用フローだとエンジニアの業務量が増えてしまうことはもちろん、軽い気持ちで社内のデータをチェックしたい時にチェックができないという点もあり、社内でSQL勉強チャンネルを設置し希望者を中心に勉強することにしました。

勉強方法は?

ハローでは、エンジニアが自社のサービスが保有するデータをどの様に活用すべきかをまとめた「SQL道場」を準備し、それを順番に進めていくことである程度のSQLを書ける様になる環境を準備しました。

SQL道場を見ながら、実際にredashを触って少しずつ情報の取得方法を勉強していきます。最初は簡単なものが多くスラスラと進めていけますが、徐々に難しくなりエラーが出てきたり、そもそもSQLの書き方がわからなかったりとスムーズに行かないことも増えてきました。

実際に勉強してみて気づいたことですが、最初にSQLの仕組み自体を理解するため、本を読むこともおすすめです。

社内で実際のデータを元に勉強ができないという方には以下のサイトもおすすめです。回答はgoogle検索で調べるといろんな方が解説しているので独学でも進めることができます。 https://sqlzoo.net/wiki/SQL_Tutorial

大変だったこと

最初は思い通りのデータを取得することができず、エラーをたくさん出しました。数時間詰まることも・・・。その度に、エンジニアにフォローしてもらいながら学習を進めていきました。すぐに聞ける環境を会社全体で作ってくれたのはとても助かりました。

また、取得したデータが思ったデータとは違うことも最初のうちはよくあり、エラーなく表示できたとしてもレビューをお願いすることが大切だと学びました。(そのままその値を元に分析すると、間違った意思決定をするリスクもあるためです)

これからSQLを勉強しようと思っている人で、もしアドバイスをもらえる方が身近にいるというラッキーな方はぜひお願いすることを推奨します。

やってみた結果、どうなったか?

ハローのメンバーが実際に学んでみてどのような効果があったかについても、ご紹介したいと思います。

意思決定速度が向上、時間も作れる様になった

今までエンジニアがデータ分析のためにSQLを書いていましたが、メンバーが取得したいデータを自分で確認することができる様になったことでエンジニアが稼働しなくても、数値を元にアクションを決定したり議論することが可能になりました。

また、エンジニアが時間を取られることも少なくなったため開発により集中することができるようになりました。プロダクトに集中してくれる時間が増える方が会社としても早く成長することができるためすごくいい取り組みでした。

「このデータが気になったのでRedashで出してみました!」という発言が社内で増えたのもいい変化だと思います。

<実際にあった事例>

ハローでは、店舗向けにオートリザーブオーダーというモバイルオーダーシステムを提供しています。現在は、店舗が新規導入する際にメニューの登録を代行していますが、その業務の中で商品リストが正しく登録されたか2重チェックするフローが存在します。

今までだと、目視でダブルチェックをするだけで1−2時間かかっていました。今ではSQLを活用し、すでに登録されたメニューをデータベースから取得することで、ダブルチェックが50%以下の時間で終わるようになっています。

分析脳が身に付く

取得するデータは、数字であることが多いため

この施策は、◯◯の結果からわかる様に非効率である ユーザーには△△の傾向があるので、次はサービスをA案で改善した方がいい

という分析を元に議論できる様になりました。

今までは感覚で議論する場面もありましたが、メンバーが少しでもSQLを書ける様になったことでチーム全体として数字を根拠としてやりとりできる様になったこともいい変化でした。マーケティングチームとしても、セールスやその他関係者に、データを元に会話をしてもらうことは「無意味な施策を打たない」「データを大事にする人が報われる文化ができる」という副次効果があるため、今後が楽しみです。

まとめ

マーケティングチームはもちろんのこと、セールスやバックオフィスに関わるメンバーもこの取り組みに参加し、簡単なSQLなら書ける様になりました。

セールスメンバーは、売り上げや問い合わせ数、サービスが導入されている店舗の一覧を自分で出したり、分析したりすることが可能になり、エンジニアはより開発に集中するいいサイクルができています。

ちなみにハローでは各職種が同じ様な勉強をできる環境を準備しており、「figma」や「SEO」についても部活があります。これからさらにメンバー一同で成長を続けていきたいと思います。

現在ハローでは新しい仲間を募集中です!少しでも、ハローに興味を持った方はぜひお気軽に採用のフォームからご応募ください!

Reactでロジックをhooksにまとめないという選択肢

javascripterです。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。

突然ですが、Reactを使用する際、コンポネントのロジックや状態が増えてきたとき、みなさんはどうされてるでしょうか。

関数コンポネントでは、一般にcustom hooksとしてまとめて切り出すことが多く行われていると思います。

今回の記事では、useState/useRef + custom hooksという単位で切り出すのではなく、 クロージャを使いロジックや状態をコンポネントの外に持たせるようにリファクタリングすることで、コードの見通しが良くなる、という事例を紹介します。

JavaScriptにおけるクロージャとは、関数が外側のスコープの変数などへの参照を保持できる機能のことです。ここではクロージャとして実装しましたが、同等のことはclassを使っても実装できます。

AutoReserveでの複雑なロジックの例

概要

状態管理やロジックが複雑になる例として、非同期処理があります。

ハローでは、レストラン向けにAutoReserve For RestaurantsというReact Native製のレストラン管理システムを提供しています。アプリには予約台帳の機能があります。

予約台帳とは、レストランが予約を管理するための記録システムで、

  • 来店の日時
  • 来店人数
  • 客の名前
  • 予約する席

などの情報を記録できるようになっています。 ある日の予約状況を一覧して見れるよう、チャート表示のUIを実装しています。

予約台帳チャート表示画面

レストランの営業時間は曜日によって異なる場合があり、また祝日の場合など変則的な営業時間の日もあります。よって、クライアント側で特定の日の営業時間を知りたい場合、日付を指定してバックエンド側から動的にとってくる必要があります。

予約が入るのは基本的に営業開始時間以降なため、デフォルトで営業開始時刻までスクロールさせた方が親切です。ここで、画面をスクロールさせるときの実装を考えてみます。

基本的な実行順序

基本的なスクロールの実行順序はこのようになります。ここに、別の日付に移動した時のキャンセル処理などが入ります。

  1. チャート画面を開く
  2. 開いている日付の営業時間を取得し、取得後にスクロールするコールバックを設定
  3. 営業時間の取得が終わったら、コールバックを実行しスクロールを行う

具体的な実装方法

このようなチャートのコンポネントを用意したとします。

// チャート
const Chart = React.forwardRef(({
  dateString,
  onLoadStart,
  onLoadEnd
}, ref) => {
  // ここでは、コンポネント内でswrを使いAPIからデータを取得することを想定
  // https://swr.vercel.app/
  const { data } = useSWR(`/reservations/by_date?date=${dateString}`)

  // 初回マウント、日付が変わったタイミングでonLoadStart()を呼ぶ
  // データのロードが終わったらonLoadEnd()を呼ぶ

  if (!data) return <Loading />

  return <View ref={ref}>
    { /* ... */ }
  </View>
})

このような場合、ざっくり下記のように書き始めることが多いのではないでしょうか。

function Screen() {
  const ref = useRef()

  // dateのstateの定義やヘッダ実装は省略
  const [loadingState, setLoadingState] = useState('loading')

  const requestScroll = () => {
    // 営業日を非同期で取ってきた後、開始時間にスクロールする
    getBusinessTimesByDate(date).then((data) => {
      // この時点でloadingStateがまだloadingの場合はスクロールできないので
      // キューに入れるなどする必要があるが一旦無視
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
    })
  }
  const onLoadStart = () => {}
  const onLoadEnd = () => {}

  return (
    <Chart
      ref={ref}
      date={date}
      onLoadStart={onLoadStart}
      onLoadEnd={onLoadEnd}
    />
  )
}

ここから、

  • loadingStateはレンダリングのタイミングと関係ないためrefを使う
  • requestScrollの実行は日付が変わったら前の分はキャンセルしなければいけないのでcancel処理を追加
  • コンポネントのアンマウント後、スクロール処理はキャンセル
  • ロジックが複雑になってきたため、custom hooksに切り出す
  • useEffect内で使う関数があるため、stableにするためuseCallbackで囲う
  • Chartのロードが完了するまでスクロールを待機させる

のようなことを考え、実装を進めていくと

function useChartResponder(ref) {
  const loadingStateRef = useRef('loading')
  const cancelRef = useRef(null)
  const scrollPayloadRef = useRef(null)

  const requestScroll = useCallback(() => {
    let canceled = false

    // 前回のスクロールが実行中ならキャンセル
    cancelRef.current?.()
    cancelRef.current = () => {
      canceled = true
    }

    getBusinessTimesByDate(date).then((data) => {
      if (canceled) return
      if (loadingStateRef.current === 'loading') {
        // ロード中であればスクロールを待機させる
        scrollPayloadRef.current = { x: getScrollX(data) }
      } else {
        ref.current?.scrollTo({
          x: getScrollX(data)
        })
      }
    })
  }, [ref])

  const onLoadStart = useCallback(() => {
    loadingStateRef.current = 'loading'
    reset()
  }, [loadingStateRef, reset])

  const onLoadEnd = useCallback(() => {
    loadingStateRef.current = 'loaded'
    if (scrollPayloadRef.current) {
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
      scrollPayloadRef.current = null
    }
  }, [ref])

  // unmount時に呼ぶ
  const reset = useCallback(() => {
    cancelRef.current?.()
    cancelRef.current = null
    scrollPayloadRef.current = null
  }, [cancelRef])

  return useMemo(() => {
    return {
      requestScroll,
      onLoadStart,
      onLoadEnd,
    }
  }, [onLoadEnd, onLoadStart, requestScroll])
}

のように切り出すことになるかもしれません。 しかし、このように実装していくと、複数のrefが存在し、それぞれをcurrentで参照しないといけなかったり、useCallbackが増えたり、だんだんと見通しが悪くなっていきます。

状態をhooksの外に出す

そこで、hooksに状態を持たせるのではなく、クロージャを利用し普通の関数に処理をまとめてみると、こうなります。

function createChartResponder(ref) {
  let loadingState = 'loading'
  let cancel = null
  let scrollPayload = null

  const requestScroll = () => {
    let canceled = false
    cancel?.()
    cancel = () => {
      canceled = true
    }
    getBusinessTimesByDate(date).then((data) => {
      if (canceled) return
      if (loadingState === 'loading') {
        scrollPayload = {
          x: getScrollX(data)
        }
      } else {
        ref.current?.scrollTo({
          x: getScrollX(data)
        })
      }
    })
  }

  const onLoadStart = () => {
    loadingState = 'loading'
    reset()
  }

  const onLoadEnd = () => {
    loadingState = 'loaded'
    if (scrollPayload) {
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
      scrollPayload = null
    }
  }

  const reset = () => {
    cancel?.()
    cancel = null
    scrollPayload = null
  }

  return {
    requestScroll,
    onLoadStart,
    onLoadEnd,
    reset
  }
}

この関数を使用する際は

function Screen() {
  const [chartResponder] = useState(() => createChartResponder())

  useEffect(() => {
    return () => {
      chartResponder.reset()
    }
  }, [chartResponder])

  // ...
}

のように使います。こうすることで、

  • createChartResponder内ではrefを使わず普通の変数を自由に使ってコードを書ける
  • useCallbackも使用する必要がない
  • ロジック自体をReactの外に出せるため、テストが容易になる

といったメリットがあります。コード自体も短くなり、メンテナンス性も向上します。

まとめ

Reactを使用していると、useStateやuseRef、useReducer、useEffectなどを使用しcustom hooksを活用してコードを書く場面が多いと思います。

もちろん、custom hooksで簡単に解決できるものも多くありますが、記事で取り上げたようにプレーンな関数・クロージャにロジックをまとめ、hooksを使ってReactとの繋ぎ込みを行うことも検討すると良いと思います。

ハローでは、単純明快なコードを追求する本物のプログラマを募集しています。

少しでも気になる方は気軽に javascripter にDMでお声がけください!

エンジニア採用情報 | Hello, Inc.