blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2021.07.15 インターンレポート

日比谷音楽祭おさんぽアプリ2021 開発の裏側を語る / サーバー編

by higashi nagataaaas daishin

#hibiya-music-fes #server #algorithm #google-cloud

この記事では21新卒、22卒内定者が開発に参加した『 日比谷音楽祭公式おさんぽアプリ2021 』(以下、おさんぽアプリ)の開発の裏側をクライアント編、サーバー編の2回に分けてご紹介します。
今回は クライアント編 に引き続き、サーバー編をお届けします。


この記事の概要

  • コロナ禍でのイベント開催をアプリから支えるために行った取り組みの紹介
  • 密を防ぎつつ、無駄が無いように席を決めるチケット抽選アルゴリズムを実装した
  • 通知送信に関する事故を減らすため、送信済みの通知のみFirestoreへ記録した
  • アプリ上から混雑度を確認するために混雑度の計測と自動更新を行った

日比谷音楽祭2021とおさんぽアプリ

『日比谷音楽祭公式おさんぽアプリ2021』は2021年5月29日と30日の2日間で開催された「 日比谷音楽祭 」のために開発されたアプリです。
新型コロナウイルスの影響で今年は残念ながらオンラインでの開催となりましたが、日比谷音楽祭はトップアーティストのライブやさまざまな質の高い音楽体験を、無料で楽しめることを目的にしたイベントです。

おさんぽアプリの役割

おさんぽアプリの役割については永田が担当します。
新型コロナウイルス禍でのイベント開催ですから、感染対策は万全を期す必要がありました。実際に感染者を出さないということに加え、感染対策を行っているという心理的安心感を観客に感じさせるということも重要です。

また、日比谷音楽祭には親子孫3世代で楽しめるイベントにするという目標があったので、音楽を目的として日比谷音楽祭に来ていないと考えられる「親御さんに誘われてなんとなくついてきた子供」のようなイベント参加者が退屈しないような何かを提供することも求められました。

アプリが何を提供したか

上記の通り、アプリの役割として

  • ウイルス感染対策
  • 子供でも楽しめるイベントにする

という2つが主に求められ、アプリとしてそれぞれ

  • 混雑度情報をリアルタイムで確認できるようにする + 感染対策を考えたチケット抽選
  • 位置情報を用いた楽器収集と演奏

を実装することで提供することにしました。

リアルタイム混雑度情報

係員による手動計測とカメラによる自動計測を併用し、混雑することが予見される場所に対して混雑度を設定しました。
アプリ内に実装した地図でこの情報を確認できるようにすることで心理的安心感を与え、混雑している場所に行かないようにしてもらうことで感染対策としています。
こちらの画像では、左上にテスト用の座標調節ボタンが表示されています。

チケット抽選

日比谷音楽祭のセッション(演奏)を会場で聴くためにはチケット抽選で当選する必要があり、去年までのアプリの実装では各チケット応募者に対して当選なら応募分の入場券を与えるという形式をとっていましたが、今年は座ることのできる座席を割り当てることとしました。
これにより、1席ずつ間を空けて、かつ無駄な隙間のないように座って貰うといったことが可能になりました。

楽器機能

この機能は、音楽を目的にイベントに来ていない子供も楽しめるようにということで考えられました。
日比谷音楽祭が開催される日比谷公園の特定の場所に「音の結晶」が配置されており、アプリ内の地図から確認できるようになっています。音の結晶に一定以上近づいた際に位置情報機能を用いて判定し、音の結晶を獲得することで楽器を取得できるようになっています。
取得した楽器は、アプリ内の演奏画面から演奏することができます。

その他の実装内容

上記に挙げた3つの他にも、サーバー側は以下のような様々な機能を提供しました

  • クライアントへの楽器付与
    • どのユーザーがどの音の結晶を獲得しているか
  • クライアントへの楽器の位置提供
    • 音の結晶の位置情報
  • 通知機能
    • お知らせやチケットの当落結果の通知
  • 運営の方が操作する混雑度入力ツール
    • ツール自体の提供も行いました
  • プライバシーポリシー/利用規約配信
    • 新型コロナウイルスの関係で状況が変化することが予見されたため、サーバーで配信する形式にしました

フルリモートで開発を進めたサーバーチーム

はじめまして、サーバーチームで開発をしていた22卒内定者の比嘉です。
はじめにサーバーサイドがどのように開発をすすめていたかをお話したいと思います。

サーバーサイドはサポートエンジニアの社員1名と内定者6名(21卒内定者3名、22卒内定者3名)というチーム構成でした。
開発は主に内定者6人が進め、サポートエンジニアの方には技術的な質問以外にもプロジェクトの進め方や事務的な質問を聞いていました。

技術選定も内定者中心に行われ、去年の運用実績( 日比谷音楽祭おさんぽアプリ2021 開発の裏側を語る / サーバー編 )や新たに実装する機能のことを考えた上での選定しました。

## インフラ
- GCP
    - Google Cloud Run
    - Google Cloud Scheduler
    - Google Cloud Functions
    - Google Cloud BigQuery
    - Firestore
## push通知
- Firebase Cloud Messaging
## クライアントとの通信
- gRPC
## プログラミング言語
- Go
実際に使用された技術

次にチーム内のコミュニケーションですが、フルリモートということもあり、基本Slackを用いていました。
また、Slackでのやり取りだけでは不安な部分があったら、zoomを使って画面共有をしながら開発を進めました。
チャットも通話も雰囲気がいい意味でゆるく、気軽に質問できてよかったです。

バグフィックス中の様子

開発中のタスク管理にはGitHubのissuesを利用していました。
各々がself assignedしていたので、誰がどのタスクを進めているのかわかりやすかったです。

レビューの様子

密を防ぎつつ、席に無駄が出ないようにするチケット機能

どうも、永田です。
日比谷音楽祭のセッション(演奏)を会場で聴くためにはチケット抽選で当選する必要があります。 この項では、どのようにチケット抽選機能を実装したかを紹介します。

去年と今年の違い

  • 観客はそれぞれ指定された席を与えられること
  • 同じチケットに割り当てられた席は、1つの"隣り合った席のブロック"に収まること(=グループが離れ離れにならないこと)

以上2つが去年から追加された要件です

去年までのアプリの実装では各チケット応募者に対して当選なら応募分の入場券を与えるという形式をとっていましたが、 新型コロナウイルス感染対策のため、今年はどの席に着席できるというところまで割り当てる必要がありました。
感染拡大防止のため、席同士の間に間隔を空けるという配置の仕方をする可能性があったためです。 副次的にはなりますが、これにより、確実に無駄な隙間のないように座って貰うことが可能になりました。

実装方法の検討

同じチケットに割り当てられた席は、1つの"隣り合った席のブロック"に収まることという要件から、まずは会場内のすべての席を"隣り合う席のブロック"に変換することを考えます。

文字で説明するとわかりにくいですが、実際のイベントステージは下図のように複数個の席に相当するベンチが無数に配置されています。このベンチを埋めるようにして席を割り当てていきます。

イベントステージ 座席イメージ図

ところで、ベンチを1つの容器・n人分のチケット応募を重さnの荷物として考えると、複数ナップサック問題に一般化することができます。
複数ナップサック問題はGurobiなどのソルバで代数的に解くことが可能であることが知られていますが、アプリケーションの一部に組み込むとなると非常に面倒ですし、貪欲法やそれを多少改良したアルゴリズムでもほぼ最適解に近い解を導けます。

よって、今回は貪欲法をベースに改良していく方針で実装します。

実装の紹介

本項では簡単のために


type TicketEntry struct {  // 各応募を表す構造体
    amount   uint8         // 応募チケット枚数
}

var ticketEntries []*TicketEntry  // すべてのTicketEntryのslice

という形でticketEntriesが与えられるものとします。

さて、はじめに、それぞれのベンチを表す構造体を以下のように定義します

type bench struct {
    area     string // ベンチが含まれるエリア. "A" or "B" or "C"
    column   uint8  // ベンチの列番号. uint8
    number   uint8  // ベンチに含まれる席の中で、最も小さいの席の番号. uint8 
    // area, column, numberの3つの値で各blockを区別します

    max      uint8 // ベンチに何席含まれているか 
}

このように各ベンチを定義することで、ベンチの必要最低限の情報を表せます。

また、席を割り当てる際に必要な情報として

  • そのベンチにどのチケットがすでに割り当てられたか
  • 割り当てられた分を除いて、あと何席残っているか

という情報が必要になりますので、

type bench struct {
    area             string
    column           uint8
    number           uint8

    max              uint8
    remainingNumber  uint8 // あと何席残っているか. 初期値は max と同じ
    
    allocatedEntries []*TicketEntry // すでに割り当てられたTicketEntryのslice
    // blockedSeats     []*model.Seat
}

というように2つフィールドを追加しましょう。

これで抽選に必要な情報は揃いました。

ついでに、すべてのベンチ情報を格納するsliceを作っておきましょう。

var allBlocks []*bench

抽選ロジック

ナップサック問題の貪欲法を行う際には、空きの多いナップサックに、最も重い荷物を詰め込んでいくという操作をします。軽い荷物は重い荷物が入るナップサックにならどれでも入り、逆はそうとも限らないことを考えると、重いものから順に選択肢から外して行ったほうがスムーズにことが運ぶからです。

さあ!では、もっとも重い荷物=チケット4枚応募のチケットからベンチに詰め込んでいきましょう!と言いたいところですが、純粋にチケット4枚応募のチケットから手当たりしだいに割り当てていくと、応募チケット枚数によって当選率に大幅な偏りが出てしまいます。

これは避けたいところですね。

なので、それぞれの応募チケット枚数ごとに等しい割合で当選するようにし、なおかつ全席の合計数とチケットの応募席総数が大方同じ数になるようにしましょう。

幸いなことに、これは簡単な計算で求めることができます。

1, 2, 3, 4枚チケットの各応募件数をそれぞれt1, t2, t3, t4とし、空き席数の総数をnとします。 当選確率をkとすると

n = (1*t1 + 2*t2 + 3*t3 + 4*t4) * k
k = n / (1*t1 + 2*t2 + 3*t3 + 4*t4)

よって、上式によって求められたkを各チケット応募の枚数に乗じてceilをとることで、おおよそ正確な当選枚数になります。

このkを用いると

for i := 0; i < int(math.Ceil(t4*k)); i++ {
  seatRemain := allBlocksから残っている席がもっとも多いものをランダムに選ぶ関数()
  seatRemain.allocatedEntries.append(ticketEntriesから4枚チケットのエントリーをランダムにpopする関数())
  seatRemain.remainingNumber -= 4
}
... t3~t1に関しても同様に

というような実装ができます。 枚数の指定が非常に楽になりましたね!

しかし、この処理には問題があります。それは、しばらく割当が進むとベンチの余り席数が中途半端になり、うまくチケットがはまりきらなくなってくることです。 例えば、応募チケットが[]int{4, 4, 3, 2}の4枚の応募を、max[]uint8{8, 7}の2つのベンチに割り当てるとしましょう。 このとき

ticket           |          bench
-----------------|--------------------------------------------------
{4, 4, 3, 2}     |         {{max: 8, remain: 8, allocated: []},
                 |          {max: 7, remain: 7}, allocated: []}
->               |
                 |
{4, 3, 2}        |         {{max: 8, remain: 4, allocated: [4]},
                 |          {max: 7, remain: 7}, allocated: []}
->               |
                 |
{3, 2}           |         {{max: 8, remain: 4, allocated: [4]},
                 |          {max: 7, remain: 3}, allocated: [4]}
->               |
                 |
{2}              |         {{max: 8, remain: 1, allocated: [4, 3]},
                 |          {max: 7, remain: 3}, allocated: [4]}
->               |
                 |
{}               |         {{max: 8, remain: 1, allocated: [4, 3]},
                 |          {max: 7, remain: 1}, allocated: [4, 2]}
                 |

のような割り当てになり、それぞれが中途半端なあまりになります。 ここで、互いのallocatedを交換することで、片方があまりが0になり、片方はあまりに余裕ができます。

このような置換を以下のように適宜入れてやることで最後まで効率的に割り当てを行えるようになります。

for i := 0; i < int(math.Ceil(t4*k)); i++ {
  seatRemain := allBlocksから残っている席がもっとも多いものをランダムに選ぶ関数()
  if seatRemain.remainingNumber < 4 {
  	allocatedの置換()
  	if 置換により十分な余りを作れなかった {
  		break
  	}
  }
  seatRemain.allocatedEntries.append(ticketEntriesから4枚チケットのエントリーをランダムにpopする関数())
  seatRemain.remainingNumber -= 4
}

追加要件への対応

実装途中で以下のような追加要件が発生しました

  • 各席同士の間に間隔を空けるような席配置(市松配置)にできること

新型コロナウイルス感染対策のため、間隔を空ける必要があるとの判断による変更でした。 これに対応するのはさほど難しくありません。

そのベンチ内で空席として用いられることになる席数をmaxremainingNumberから削ればよいのです。 空席として用いられるかどうかは、その席のcolumn+numberが偶数であるかどうかでわかります。

そのベンチ内のもっとも番号の大きい席までに存在する空席から、そのベンチ内のもっとも番号の小さい席の1つ手前の席までに存在する空席を引けば空席の合計が求められます。


var allBlocks []*bench
for _, v := range allBlocks {
	spacerCount := (v.number + v.max + v.column - 1) / 2 - (v.number + v.column - 1) / 2
	v.max -= spacerCount
	v.remainingNumber -= spacerCount
}

そして、抽選後の実際に席番号をしていして割り当てる際に、空席を避ける形で割り当てをすれば完了となります。

GCPを活用し、正確かつ楽に送信できる通知機能

ここでは、おさんぽアプリの通知周りの機能について、22卒の梶原から紹介しようと思います。
通知送信の基本的な機能は21卒の先輩方が実装してくださっていたので、ここでは、主に梶原が実装した通知のスケジューリング機能や、管理者用のツールについて紹介していこうと思います。

イベント情報の連絡などをユーザーに届けるために、おさんぽアプリはpush通知機能を備えていました。 また、より正確に情報をお届けするために、通知送信者(以下、管理者)向けのツールの作成にも注力しました。 push通知という機能の性質上、誤送信や送信忘れなどがあったら大変ですからね。

push通知機能

push通知やその周辺機能を実装するにあたって、以下のサービスを利用しました。

サービス名 用途
Firestore 通知情報の保持
Firebase Cloud Messaging (以下 FCM) push通知の送信
Google Cloud Functions トピック購読処理
Google Cloud Scheduler push通知送信のスケジューリング

通知の送信にはFCMを利用しました。
抽選中かどうかや、特定チケットを所持しているかどうかなど、各ユーザーの状態によって、通知を送信するかどうかを分ける必要があったので、通知トピックをいくつか用意し、必要となるトピックをユーザーに購読させるというような実装になっています。
ユーザーがトピック購読が必要な行動を行った時に、Cloud Functionsが実行され、ユーザーに通知トピックを購読させます。
また、送信した通知の内容はFirestoreに記録されます。

今回、push通知は特定の時間に送られるものがほとんどでした。
そのため、送信の確実性や利便性を考えて、指定した時間にpush通知の送信が実行される機能をCloud Schedulerを利用して実装しました。

簡単な例を挙げると、push通知機能の主な流れは以下のようなイメージです。

  1. チケット申し込み(アプリ)
  2. チケット申込者のトピックを購読させる(Cloud Functions)
  3. 管理者が通知を送信(管理者ツール)
  4. Firestoreに記録 & 購読しているトピックの場合のみ通知を受け取る

この辺りの技術選定に関しては去年の記事に詳しく書かれています( 日比谷音楽祭おさんぽアプリ2020 開発の裏側を語る / サーバー編

管理者ツール

冒頭にも書きましたが、おさんぽアプリではアプリの性質上、 ‘通知が送信されない’、‘誤った内容の通知が送信された’、といった状況をなるべく避けなければいけません。
そこで、人為的なミスを限りなく減らすために、広く利用者を想定したシンプルな管理者ツールを作成しました。
また、正確な時間に送信が行えるように、スケジューリング機能も用意し、ツールから簡単に登録できるようにしました。

管理者ツールは以下の機能を備えています。

  • 通知の送信
  • 通知のスケジューリング
  • 送信済み通知の確認
  • 送信予定の通知の確認・削除
管理者ツール

スケジューリング

push通知送信APIを用意し、そのAPIのエンドポイントを叩くジョブをGoogle Cloud Schedulerに登録するというようにして実現しました。 具体的な流れは以下のような形になっています。

  1. 通知を登録(管理者ツール)
  2. Cloud Schedulerにジョブを作成
  3. 指定時間にAPIを叩く
  4. push通知送信

観客が密を避けるためのmap機能

map機能については比嘉が担当します。

緊急事態宣言が発令される前のおさんぽアプリはmap機能を備えており、

  • 音の結晶の位置表示
  • 各スポットの混雑度表示
  • 各スポットの入場制限状態確認

の3つの役割がありました。

実際の混雑度混雑度表示

また、 各スポットの混雑度表示 はスポーツ事業本部と連携しつつ実装しました。
スポーツ事業本部が行っている横浜スタジアムの定点カメラによる混雑度計測を日比谷音楽祭でも行うためです。

横浜スタジアム内における定点カメラ設置と録画映像の使用について

ここではスポーツ事業本部と連携し実装した、混雑度計測と混雑度更新についてお話したいと思います。

人数の計測

混雑度を計測するにあたって重要になる人数の計測ですが、一部のみ定点カメラでの計測、他の箇所はスタッフによる人力計測を行うことになりました。
定点カメラでの計測はスポーツ事業本部が用意してくれることになりましたが、人力計測用のツールは我々サーバーサイドが作成、提供することになりました。

スタッフ用計測ツールの作成

スタッフ用計測ツールはボタンをポチポチするだけでカウントできるものが好ましいというお話を頂いていました。
そのため、bootstrapを使ったレスポンシブ対応を行い、ボタン数を極力減らしたものを作成しました。

また、よりUIをシンプルなものにするために入場者の計測ページと退場者の計測ページは分けることにしました。

計測端末間の整合性確保

UIは上記の形で決定したのですが、次に問題になるのが入場者数と退場者数の整合性確保でした。

スポットの中には出入り口が複数ある場所があり、そういった場所では入場者と退場者を計測する人も複数人になります。

計測する人が複数人になるということは計測端末も複数台になります。 それまでの実装は
  1. ツール起動時にFirestoreから現在の入場人数/退場人数を取得
  2. ボタンが押されるたびにツール内部で入場人数/退場人数を増やしていく
  3. 一定時間経過後、ツール内部の合計人数でFirestoreの情報を上書き、2に戻る

というものでした。
この実装では複数端末から同時に入力した際に、片方の端末のカウントでもう片方のカウントを上書きしてしまいます。
ツール上で確認できる人数はそのエリアに入場制限をかけるかどうかの判断基準になるため、値に不備があることは避けなければいけませんでした。

これを解決するため、以下の実装に切り替えました。

  1. ボタンが押された回数を記録
  2. 一定時間 (テスト段階では5秒) 経過後、ボタンが押された回数をサーバーに送信、Firestoreの入退場者数に加える
  3. ツールでボタンが押された回数をリセット、カウントを0からやり直す

この方針に切り替えた事により、複数端末で同時に計測したとしても片方の値でもう片方の値が上書きされることがなくなりました。
また、ツール上の人数には (累計入場者人数 - 累計退場者人数) + ボタンを押された回数 を表示しています。
これにより、スタッフはエリアにいる人数をより正確に確認できるようになり、入場制限の判断が行いやすくなりました。

混雑度の更新

計測用のツールが実装できたので次は計測された値をもとに混雑度を更新する必要があります。
サーバー側ですべて行うことも可能でしたが、スポーツ事業本部のカメラ計測とその混雑度更新もあったため、それらと合わせて行う事になりました。
以下が入場者数/退場者数をもとに混雑度を更新するフロー図です。

webツール/計測用カメラで計測された入場者退場者数は一度BigQuery上に集められます。
その後、Cloud FunctionsでBigQueryに保存された人数をもとに混雑度をFirestoreに設定していました。

混雑度の閾値はエリアごとに変更可能で、広さに合わせた混雑度が設定できるようになっていました。

緊急事態宣言とおさんぽアプリ (サーバーサイドの場合)

皆さんご存知の通り、4月25日から発令されていた緊急事態宣言の延長が発表され、5月31日までとなりました。 それに伴い、当初は現地で開催する予定だった音楽祭も、オンラインでの開催へと変更せざるを得なくなり、おさんぽアプリもオンラインでの開催時に不必要となったデータ、及び以下の機能を削除してリリースされることになりました。

  • チケット機能
  • マップ機能

また、実際に公園を歩いて音の結晶を集める、ということは実現できなくなりましたが、楽器演奏機能自体はオンラインでも利用可能な機能でした。 なので、音の結晶を集めるという部分をなくし、初めから全ての音の結晶が利用できる状態で演奏が行えるように変更しました。

おわりにひとこと

三嶋哲也

gRPCとクリーンアーキテクチャなどモダンな開発環境を触ることができた & ほぼ内定者だけのチームのリーダーをやらせていただいた ので経験値爆上げな開発期間でした!

西澤佳祐

バックエンドの開発を業務としてしっかり取り組める良い機会になりました。gRPCやGCPなどを使った経験がほぼなかったのですが、手を動かしながら各技術の良い点や悪い点を知れたり、どう実装するかを深く議論したりすることができ、とても刺激的で楽しかったです。ありがとうございました!

鹿内 裕介

gRPCやGCP、クリーンアーキテクチャを採用し、技術面から設計面までモダンな開発経験を積むことができました。
また学生が主体でありながらも先輩社員にアドバイスをいただく機会もあり、充実した環境で伸び伸びとエンジニアリングできました!

比嘉 風

GCPやgRPCを本格的に使ったプロジェクトに参加するのは初めてでとてもいい経験が積めました!開発スタート時からイベント当日までチームで様々な問題を議論しながら解決していけてとても楽しかったです!ありがとうございました!!

梶原 大進

grpc、gcp、クリーンアーキテクチャといったモダンな開発環境でのプロジェクトに参加でき、とてもいい経験が積めました。また、21、22卒内定者という年が近い面々で、実装方法や設計などについて議論したり、アドバイスをしあったりなど、めちゃくちゃ刺激になりました! ありがとうございました!

永田 大和

GoやgRPCでの開発はほぼ未経験で開発当初はメンバーのみなさんに助けられてばかりでしたが、次第にできることが増え、非常に勉強になりました。一緒に開発ができて本当に楽しかったです!ありがとうございました!

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。