RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

#1 スクラムマスターを雇う時に聞いてみるとよい38個の質問に答えてみる(全5回)

まえがき

レアジョブテクノロジーズの三上です。 最近メンバーからスクラムに関する質問や相談をもらうことが増えて、 私自身の経験を振り返ったり、質問の背景や実績を共有してもらう中でスクラムの理想と現場の課題感を議論しながら気付きや考え直す機会にもなっていてありがたいです。

少し古い記事ですが、「スクラムマスターを雇う時に聞いてみるとよい38個の質問」

www.ryuzee.com

を見かけてやってみたいなーと思ったので 面接をうける気持ちで直感で書いてみます。

38個をすべて書くと時間かかりそうなので今回は以下の5つの項目の中から

「プロダクトバックログのリファインメントと見積りについて」の項目を抜粋して回答します。

プロダクトバックログのリファインメントと見積りについて

7つの質問に答えていきます。

1.プロダクトオーナーはステークホルダーの要求をプロダクトバックログアイテムに落とし込んでその見積りをチームに求めることになる。その流れでよいか?

流れ自体は良いと思う。 ただ、背景や目指したいゴールは語って欲しいです。 特にうちのメンバーはプロダクト開発したいメンバーが多いと思うので、あるとよりモチベーションも上がると思う。むしろゴールは一緒に考えていきたい。

2.チームに最新情報やマーケット状況を伝えるためにプロダクトオーナーにどんな情報を要求するか?

プロダクトオーナーをどんな人が担っているかによって強みは変わると思いますが、 定量的な分析があると開発チームも納得感ありますよね。 実体験で言うとマーケティングにおけるKPIをもとにした仮説には納得感がありました。例えばユーザーの行動分析から想定したユーザーストーリーの追加!のような

これも背景があるとより良いと思っていて、どんな観点で分析したのかなど添えるとより納得感あるしメンバーの学びにもなると思います。

3.誰がユーザーストーリーを書くとよいか?

全員ですね。前提としてプロダクトはみんな触っているよね?と言いたい(私自身への圧でもある) 自分が書いたストーリー採用されて実績出たら一番ハッピーなはず。

4.よいユーザーストーリーとはどんなものか?どんな構造か?

これはシンプルにユーザー目線で5Wで書かれているものですね。 はじめはある程度は粒度が大きくても良いと思います。 起票のタイミングでは5Wのみで、リファインメントの場でHowとHow muchについて議論しながら 粒度を細かくして完了の定義を認識合わせしていけると良いと思います。

5.「Readyの定義」には何が含まれているべきか?

ゴールの定義、何を目指しているか、どう計測するのかですかね。 特にうちだと「どう計測するか」が疎かになりがちなので重要だと思います。 これをサボると謎の仕様みたいな積み上げになってしまうので、どのタイミングで再検討するのかも議論されていると良さそうです。

6.ユーザーストーリーを時間で見積もらないのはなぜか?

時間で見積もりするには調査が必須でかなり時間を取られます。 ポイント見積もりのメリットはコスト安く見積もることですよね。 前提としてそれに時間をかけてしまうのがまず勿体ない、むしろ価値を高めるプロダクト開発に時間を使いたい。 そこで相対的に安く見積もりが出来る、ポイント見積もりをするわけです。

ポイントはあくまでユーザー価値があってより安く作れるのはどれだ?の判断材料でしかないです。 ただポイント見積もりするには不確実性が高すぎる場合や不安が大きい時は調査タスク(Spike)を起票してtimeboxを決めて調査してから 見積もるのも良いプロセスだと思います。

一方でベロシティをチームの成長指標をして置くこともあると思うので、「精度の高い」ポイント見積もりは私自身も難しいと感じていますが、 チームの成熟度と共に上げていきたいものの一つでもあります。

7.プロダクトオーナーはあとになってから取り組むようないろんな種類のアイデアを追加してくる。結果的にいろんなタイミングで取り組む200個のチケットができたとする。それに対してどのように取り組むか?スクラムチームは200個のチケットに取り組めるか?

当然ながら一気に取り組むのは無理なので、リファインメントとスプリントプランニングの場を活用します。 タイミングがバラバラだと読み取れるので直近でやるべきものから優先順位をつけて計画を立てます。 ただ将来的にやりたい、のようなバックログで言う下であれば下のものほど状況が変わりうるし、それに調査やコストをかけると勿体ないので 議論するのは優先順位の高いものだけでも良いと思います。

続きはまた次回!

AWS CLI を大量に呼び出す

はじめに

こんにちは、DevOps グループの中島です。

AWS を利用していれば、誰もが AWS CLI にもお世話になったことがあると思います。
ただ、AWS CLI (あるいは API) ってレスポンスが早いとは言えないところがありますよね。
1 回呼び出すくらいなら良いですが、何回も呼び出すとなるとその遅さが気になります。
そこで今回はまとめて大量に呼び出す場合に、どのようにしたら早く結果を得られるかについて調査してみました。

並列に呼び出す

早く結果を得る方法といっても、 内部で AWSAPI を呼び出しているだけなので、
やれることとしては並列に呼び出すくらいしかありません。(当然アプリケーションの要件として並列に呼び出せる場合に限ります)
以下のように xargs のオプションで並列に実行してみます。

$ cat tables.txt
table-name1
table-name2
table-name3
.
.

# for で回す
$ for table_name in `cat tables.txt`
do
  aws dynamodb describe-table --table-name ${table_name} > ${table_name}.json
done

# xargs を使い、 5 並列で実行する
$ cat tables.txt | xargs -P 5 -I {} \
    sh -c 'aws dynamodb describe-table --table-name {} > {}.json'

xargs を使ったほうは、-P オプション (同時実行数) で指定した多重度で実行され、for で回すよりも 5 倍くらい早く実行が終わります。では同時実行数に 9999 と指定すれば 9999 倍速で終わるのかというと、そういうわけではありません。
API の呼び出し頻度の制限に引っかかります。

API の呼び出し制限

例えば、EC2 の場合は ドキュメント に制限のされ方が明記されています。
それによると、Token bucket algoritm と呼ばれる方法で制限しているようです。
細かい内容はドキュメントを参照いただきたいのですが、おおまかには以下のようになります。

  • Bucket には 1 秒ごとに Token が規定数供給される (Bucket に入る Token 数には上限がある)
  • API を呼び出したとき、 Bucket から Token を 1 つ消費する
  • Token が Bucket になかったら API は呼び出せずにエラーとなる

ということで、同時に大量に API を呼び出すとエラーとなってしまう仕様です。
するとうまくリトライしてやることを考えるわけですが、AWS CLI 自体にその機構が備わっています。

AWS CLI のリトライ

こちらも ドキュメント が存在します。
Legacy, Standard, Adaptive とリトライモードが紹介されているうちの標準的と思われる Standard を確認してみると以下のようにあります。

Any retry attempt will include an exponential backoff by a base factor of 2.

Exponential backoffAWSのドキュメント にも記載があるとおり、
失敗するたびにリトライまでの時間を指数関数的に伸ばしていくアルゴリズムです。

上記 AWSのドキュメント にある疑似コードにならうと、100, 200, 800... ミリ秒待ってから再度実行される形になります。
しかしよく考えてみると xargs で並列実行した場合ほぼ同時に初回アクセスされるので、
そのときエラーになったリクエストが同じ時間待ってまたほぼ同時にアクセスすることになってしまい、あまり効率的にリトライされているとはいえません。

そのため、同 AWS のドキュメントにも以下のように記載があり、一般的にはランダムにディレイを設けるような実装がされているようです。

Most exponential backoff algorithms use jitter (randomized delay) to prevent successive collisions.

それでは AWS CLI ではどのようになっているのか、調べてみましょう。
https://github.com/aws/aws-cli の v2 ブランチに 以下のような処理 があります。

class ExponentialBackoff(BaseRetryBackoff):

    _BASE = 2
    _MAX_BACKOFF = 20

    def __init__(self, max_backoff=20, random=random.random):
        self._base = self._BASE
        self._max_backoff = max_backoff
        self._random = random

    def delay_amount(self, context):
        """Calculates delay based on exponential backoff.
        This class implements truncated binary exponential backoff
        with jitter::
            t_i = min(rand(0, 1) * 2 ** attempt, MAX_BACKOFF)
        where ``i`` is the request attempt (0 based).
        """
        # The context.attempt_number is a 1-based value, but we have
        # to calculate the delay based on i based a 0-based value.  We
        # want the first delay to just be ``rand(0, 1)``.
        return min(
            self._random() * (self._base ** (context.attempt_number - 1)),
            self._max_backoff
        )

2^リトライ回数の値に random() をかけ算しているので、結構大きな範囲で待ちが発生する実装となっているようです。これなら xargs で同時並列に実行しても大きな問題にはならないでしょう。

一方で StepFunctions のドキュメント にはランダムなディレイについては記載がありませんし、GCP のドキュメント に記載の例では 2^リトライ回数 に対してランダムな数値を足し算する方法も紹介されています。

おわりに

AWS でリトライといえば Exponential Backoff と記憶している方も多いと思いますが、
今回はその細かい挙動を確認することができました。
最後までご覧いただきありがとうございました。

We're hiring!

rarejob-tech.co.jp

AWS SQS High Throughput FIFOキューへダウンタイム無しで移行しました

こんにちは、 プラットフォームグループの池田です。

実稼働しているAWS SQSをスタンダードキューからHigh Throughput FIFOキューへダウンタイム無しで移行しました。 本記事では移行時の進め方とシステム詳細ついて紹介します。

業務背景

レアジョブ英会話サービスの講師検索機能は専用の検索サービスAPIを通して提供されています。検索サービスAPIは検索に必要なデータをElasticsearch上にレプリケーションデータとしてストアしています。

検索サービスのデータベースには大きく分けてレッスンのデータと講師のデータがストアされており、データ作成更新はそれぞれ異なったライフサイクルでデータを連携させる必要があります。

今回実施した移行で扱ったSQSはレッスンデータの作成、更新、削除を検索サービスのデータベースへ連携する際に利用しているものです。

システム概要

SQSの移行に関して、検索サービスへ連携するためにSQSへメッセージを送信するシステム(この記事ではコンポーネントと呼ぶことにします)と、メッセージを受信し検索サービスのデータベースにストアするコンポーネントの2つが関係します。

[送信コンポーネント]
もともと対象のSQS送信を担当するコンポーネントを一元的にしていました。そのおかげで移行作業の負担を小さくできました。 AWS上のリソースとして送信コンポーネントAWS Beanstalk(Worker type)から構築したAuto Scaling設定されたEC2上で動いています。

[受信コンポーネント]
対象SQSのメッセージを受信しデータベースへストアするコンポーネントは、SQSをトリガーとするLambdaで動いています。

移行の動機

検索サービスへのレッスンデータ更新を上述のコンポーネントを利用し連携しています。 各レッスンデータは作成→予約→キャンセル→削除のようなステータス変更を伴い、リアルタイム同期が要件であるためそのステータス変更をストリーム処理させる必要があります。

既存ではスタンダードSQSを利用していました。システム構築当時には今回移行させたHigh Throughput FIFOキューがまだ提供されておらずスループット制限の観点からスタンダードSQSを採用することになりました。

しかしスタンダードSQSは送信タイムスタンプの順に対してSQS内部にて順番が前後しメッセージを受信することもある仕様です。このことにより、レッスンの作成→そのレッスンを予約する等の順番を保証するべき箇所が前後し、以下の図のようなエラーが低頻度であるものの発生していました。

上述のエラーが発生したままにしておくとビジネス的な問題になりますが、これまでは以下のリカバリーの仕組みで業務的に大きい影響を与えないようにしていました。

  • エラーが発生した場合はSQSへメッセージを戻し再送信するようにさせ同じエラーが3回発生した場合はメッセージをデッドレターキューへ格納させる。
  • 検索サービスのデータベースに送信タイムスタンプをつけてレッスンデータを格納することで、受信側コンポーネントにて順番の整合性を担保させるバリデーションを持たせる。
  • 上記2つの補填でも不整合が出た場合、レッスンデータのマスター側となるデータベースとの差分がある場合に検知し、別途検索サービスのデータベースへ埋め合わせをするバッチを定期的に実行させる。

上記の補填の仕組みにて最終的にはリカバリーはされますが、反映までのラグが出ることなりサービスにとって望ましくない状態でした。

その課題解決のためにHigh Throughput FIFOキューへ移行することにしました。

実際に今回の移行によって上記の問題が一切発生されなくなりました。

AWS SQS FIFOキューについて

FIFOキューは順番保証と重複排除をサポートしており順番保証はMessage Groupと呼ばれる単位で保証されます。

それぞれに対応するMessage Group IDとDeduplicate IDをどの値にするか設計し送信側実装にて指定する必要があります。今回の対応で指定した内容は後述のコード改修の箇所で言及しています。

FIFOキューの拡張という位置づけでHigh throughput FIFOキューが提供されており、ノーマルなFIFOキューに対して重複排除をMessage Groupごとに限定させることで全体のスループットが向上できるようになりました。

SQS上の設定によってHigh Throughput FIFOキューに変更します。具体的な設定内容は後述のtemplate.yml上の設定で記載しています。

今回の移行の事前確認としてHigh Throughput FIFOキューのパフォーマンスを測る検証を実施しました。詳細は記事の主旨から外れるので記載しませんが、検証の結果から性能面でもサービスの将来的なリクエスト密度の増加に対しても十分耐えうると判断できました。

移行ステップ

受信コンポーネントと対象SQSのAWSリソースはSAMを利用したCloudFormation管理で構築したのでtemplate.ymlファイルを更新しCIパイプラインを通したSAMデプロイで適用させてSQS移行を実施しました。

移行のステップとして以下のような流れで実施しました。

Step1: 受信コンポーネントにおいてHigh Throughput FIFOキューとLambdaのセットを新しく作成。送信コンポーネントにて環境変数に応じて新旧どちらのSQSを送信先にするか選択できるバージョンをデプロイする。

Step2: 送信コンポーネントにて送信先を新規SQSに指定する環境変数へと変更する。

このときの送信コンポーネント送信先の切り替えのタイミングで新旧2つの受信コンポーネントがそれぞれ受け付けることでダウンタイムを発生させませんでした。

Step3: 受信コンポーネントにおいて不要になったスタンダードSQSとLambdaのセットを削除する。

コード改修について

SQS送信コンポーネントのコード実装は以下のようにメッセージに対して上述のMessageGroupIdMessageDeduplicationIdを設定させる改修をします。

MessageGroupIdはレッスンごとのユニークIDを指定し、MessageDeduplicationId は 送信側コンポーネントがエンドユーザー側から受け取るリクエストIDを指定することで保証したい順序と重複を設定しています。

   if featureToggleOfWhichSQS.isFifoQueue {
        messageInput.MessageGroupId = aws.String(eventEncodedID)   // レッスンIDを message group ID に指定
        messageInput.MessageDeduplicationId = aws.String(requestID) // エンドユーザー側から受け取るリクエストIDを重複排除IDに指定
    }

High Throughput FIFOキューのtemplate.yml上の設定

新規に作成したHigh Throughput FIFOキューのリソース箇所はSAMのtemplate.ymlにて以下の設定で構築しました。

FIFOキューにするためにはQueueNameの末尾は.fifoにしないといけません。DeduplicationScope: messageGroupFifoThroughputLimit: perMessageGroupIdを指定することでHigh Throughput FIFOキューになります。

  SQSEventQueueFIFO:
    Type: 'AWS::SQS::Queue'
    Properties:
      QueueName: sqs_event.fifo  # must end with .fifo suffix
      FifoQueue: true # FIFO queue or not
      DelaySeconds : 0 # (default)
      MessageRetentionPeriod: 1209600 # 14 days (max)
      ReceiveMessageWaitTimeSeconds: 20 # 20 sec (max) : Long Polling
      VisibilityTimeout: 30 # 30 sec (default)
      ContentBasedDeduplication: false # For first-in-first-out (FIFO) queues, specifies whether to enable content-based deduplication.
      DeduplicationScope: messageGroup # messageGroup is required for High throughput for FIFO # whether message deduplication occurs at the message group or queue level
      FifoThroughputLimit: perMessageGroupId # perMessageGroupId is required for High throughput for FIFO # whether the FIFO queue throughput quota applies to the entire queue or per message group
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt SQSEventDLQFIFO.Arn
        maxReceiveCount: 3

感想

SAMとElastic Beanstalkの仕組みでAWSリソースを構築できていたことにより、テスト環境での検証の保証レベルとオペレーションの安全性が上がり対応工数を小さくできました。AWSが提供する機能の恩恵をできるだけ最大限に受けられるようにシステムを保っていくことが重要と再確認しました。

オンボーディング資料がv34まで来たよ!用意してよかった項目

ハロー、CTOの @jumboOrNot です。 最近は社員も業務委託の方も強力なメンバーが入ってきてくれて、毎日負けてられんぞという気持ちでがんばってます。感謝。

レアジョブテクノロジーズでは子会社として独立する前より新規参画者に対してConfluenceでオンボーディング資料を作る文化があり、そのテンプレートも改善を続けv34まで更新されました。 私もエンジニアからデザイナー、PdMまで広く部門を管掌している都合で結構オンボーディング資料を作ることが多いため、受入担当として個人的に良かったor参画者から評判が良かったと思われる項目を紹介します。

最初に言いますがだいぶ地味な内容です!

1. テンプレートを作る

新規でメンバーが増える場合に受入部門の担当者が事前に資料を作れるようになるにはテンプレートは必須です。 これがあることで人が増えるときの手間を減らしたり、アウトプットまでの時間を短くできます。 またこれが毎回使われることで、その都度状況に応じて必要なルールや情報を記載し今後の改善を続けることができます。 当たり前ですが意外と手が回らず作らないこともあるんじゃないでしょうか。

2.「一緒に働く人の説明」の記載 & 受入チャットグループの作成

リモートにもなり、MTGなどで徐々に人柄などがわかっていきますが、新規参画者が業務上コミュニケーションをとるであろう人達を予めリスト化しておき、組織図を交えて説明するようにしました。 これがあることで自発的な連携や相談をしてもらえるようにしました。 また主担当以外に副担当も明確にしチャットやチャンネルを作ることで、主担当がレスが遅くてもとりあえず toall で確認できる状況を作ってます。

3.「困ったときは」の記載

組織図を見た上でもケースによって質問すべき相手が決まってないとなかなか遠慮して質問は出ないものです。 なので予めよく起きるトラブルや質問はFAQ的なものを用意するだけでなく、誰に聞くべきかも記載をしています。

4. TODO形式のオンボーディング項目の管理

最初は項目の羅列だったのですが、漏れや手続きに差が出てしまうことがあったのでTODO形式としています。 抜け漏れを参画者・受入担当のどちらも進捗を把握するためです。

部ごとに開発環境の構築手順や権限の申請手順などが別れて記載されています。

5.「お願いしたいこと」の記載

期待役割や最初にお願いすることなどを参画者・受入担当だけが把握しているとフォローやすり合わせに時間がかかったりするので、明確にドキュメントに記載をしています。 たまにここを慎重にやりすぎてタスクをお願いするのが遅くなったりもするので、粒度や伝え方は注意も必要だなと感じています。

6. シャッフル1on1の実施

レアジョブテクノロジーズでは徐々にフルリモートの社員も受け入れ始めており、今回始めて遠方の方を社員として採用したので、関係するメンバーとの20分程度の1on1を実施いただきました。 リモートでのオンボーディングなどが難しい中、人となりや人柄に触れる機会は意図しないと作れないので時間や調整は必要ですが今後は積極的にやるべきだなと今回やってみて感じました。

最後に

色々なメンバーを受け入れてきて、オンボーディングのやり方や伝えるべきことは常に変化させていく必要があると思っています。 ただその中でも共通する部分や、改善ポイントをちゃんと引き継いで良くしていくために引き続きオンボーディング文化は強化できればと思います。 参画された方がもっともっと参画してすぐ挑戦やアウトカムを作るためにv100になろうとも改善を続けていきたいです。

Tenancy for Laravelを試してみる

こんにちは。
プロダクト開発部 PROGOS•SMART Method開発グループ
所属の奥山と申します。

レアテクは今年4月、株式会社レアジョブの子会社として誕生した会社です。
誕生と同時に自分も入社し、以降、SMART Methodの開発に携わらせていただいてます。

さて、テックブログを書くと分かったのが1週間前くらいなのですが、
(※これは自分がスケジュール把握してなかったせいです。突然割り振られるものではありません。)
その時、ちょうど少しばかり引っかかっていたキーワードが
SaaS、マルチテナント」でした。

理由を言語化すると複数、しかも意外と一つにまとまらなくて混乱したので、「その場のノリ」とします。

概観

書いてあることは下記の通りです。

  • マルチテナントアーキテクチャに関する用語
  • Tenancy for Laravelをハンズオン形式で紹介
  • ハンズオンをした所感

マルチテナントアーキテクチャの調査

用語:

  • SaaS:ライセンス購入ではなく、定期的にお支払いいただき、自社で開発しているソフトウェアにアクセスしてご利用いただくサービス。
    お客様のデータは自社で管理しているサーバ内で管理する。ビジネスモデルの文脈もあるが割愛する。
  • テナント:自社のSaaSに契約いただいているお客様1単位(企業単位、個人事業主単位、など)を表現する。
  • マルチテナント:契約しているお客様が複数いらっしゃる状態を指す。

マルチテナントについていくつか資料を読んだのですが、AWSの動画の内容がしっくりきたので、 その中からいくつか紹介いたします。 内容は全て英語のため、自分の方で理解した内容を書きます。

インフラ構成の方針:

  • サイロ:DBやサーバー、アプリケーションなどが単一のテナントで実行されている環境
  • プール:DBやサーバー、アプリケーションなどが複数のテナント間で共有されている環境
  • ブリッジ:サイロとプールを混ぜたもの

その他

  • お客さんを一意に識別する方法についての一例
  • テナント登録が実行されたら、どう実行環境を立ち上げるか?
  • メトリクスについて
  • アプリケーション・インフラをひっくるめたレベルで実装する方法の例
  • 顧客ごとの課金に関する話

などについても言及されてました。
理解した話ではないので細かく言及しません。

概観がなんとなく掴めたところで、実物を触ってみたいと思います。
候補として、AWSの動画の中で示されている実装パターンのハンズオンがあります。
しかし、まずは自分が慣れた道具でマルチテナントに触れてみたいと思いました。
Laravelでマルチテナントを実現するサードパーティライブラリはいくつかあるのですが、
こちらのドキュメントを読み、Tenancy for Laravelに手軽さを感じ、選択しました。

Tenancy for Laravel ハンズオン

方針:

  • テナントがどう作成されるか確認します
  • CentralとTenantの間でデータがどうシェアされるか確認します

前提となる開発環境:

  • Mac M1チップ macOS monterey 12.3
  • PHP8.1.10
  • Laravel8.83.23
  • MySQL8.0.30
  • composer が利用できる

概念説明:

インストール:

composer create-project laravel/laravel tenancy "8.*"
cd tenancy
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate

ここまでやると

  • database/migrations のなかにtenantディレクトリができます。
  • config/tenant.phpが作成されます。

ファイル構成

対象のDBには下記のようにテーブルが作成されます。

mysql> show tables;
+------------------------+
| Tables_in_tenancy      |
+------------------------+
| domains                |
| failed_jobs            |
| migrations             |
| password_resets        |
| personal_access_tokens |
| tenants                |
| users                  |
+------------------------+
7 rows in set (0.00 sec)

対象ファイルへの追記作業:

下記のようにファイルに対して指定した内容を記述していきます。

config/app.php

 /*
  * Application Service Providers...
  */
 App\Providers\AppServiceProvider::class,
 App\Providers\AuthServiceProvider::class,
 // App\Providers\BroadcastServiceProvider::class,
 App\Providers\EventServiceProvider::class,
 App\Providers\RouteServiceProvider::class,
 App\Providers\TenancyServiceProvider::class, // これを追加する

app/ModelsにTenant.phpを作成。

<?php

namespace App\Models;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

config/tenancy.php

<?php
// tenant_model のvalueの箇所を書き換える
'tenant_model' => \App\Models\Tenant::class, 

app/Providers/RouteServiceProvider.php

<?php

省略
protected function mapWebRoutes()
{
    foreach ($this->centralDomains() as $domain) {
        Route::middleware('web')
            ->domain($domain)
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    }
}

protected function mapApiRoutes()
{
    foreach ($this->centralDomains() as $domain) {
        Route::prefix('api')
            ->domain($domain)
            ->middleware('api')
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));
    }
}

protected function centralDomains(): array
{
    return config('tenancy.central_domains');
}

同ファイルのbootメソッド

<?php

public function boot()
{
    $this->configureRateLimiting();

    $this->routes(function () {
        $this->mapApiRoutes();
        $this->mapWebRoutes();
    });
}

database/migrations/2014_10_12_000000_create_users_table.php を database/migrations/tenant/2014_10_12_000000_create_users_table.php に移動する。

テナント作成を実体験する:

$ php artisan tinker
>>> $tenant1 = App\Models\Tenant::create(['id' => 'foo']);
=> App\Models\Tenant {#3653
     id: "foo",
     data: null,
     updated_at: "2022-09-07 12:48:59",
     tenancy_db_name: "tenantfoo",
   }

>>> $tenant1->domains()->create(['domain' => 'foo.localhost']);
=> Stancl\Tenancy\Database\Models\Domain {#4525
     domain: "foo.localhost",
     tenant_id: "foo",
     updated_at: "2022-09-07 12:49:03",
     created_at: "2022-09-07 12:49:03",
     id: 1,
     tenant: App\Models\Tenant {#4678
       id: "foo",
       created_at: "2022-09-07 12:48:59",
       updated_at: "2022-09-07 12:48:59",
       data: null,
       tenancy_db_name: "tenantfoo",
     },
   }

ここまでやってMySQLのコンソールで確認してみると

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| tenancy            |
| tenantfoo          |
+--------------------+

とtenantfooというデータベースが増えてます。 テーブル構成は下記の通りです。

mysql> show tables;
+---------------------+
| Tables_in_tenantbar |
+---------------------+
| migrations          |
| users               |
+---------------------+
2 rows in set (0.00 sec)

 テーブルの中身はそれぞれ下記の通りです。

mysql> select * from migrations;
+----+--------------------------------------+-------+
| id | migration                            | batch |
+----+--------------------------------------+-------+
|  1 | 2014_10_12_000000_create_users_table |     1 |
+----+--------------------------------------+-------+
1 row in set (0.00 sec)

mysql> select * from users;
Empty set (0.00 sec)

tinkerで引き続き下記のように実行してみます。

>>> $tenant2 = App\Models\Tenant::create(['id' => 'bar']);
=> App\Models\Tenant {#4680
     id: "bar",
     data: null,
     updated_at: "2022-09-07 12:49:08",
     tenancy_db_name: "tenantbar",
   }

>>> $tenant2->domains()->create(['domain' => 'bar.localhost']);
=> Stancl\Tenancy\Database\Models\Domain {#4672
     domain: "bar.localhost",
     tenant_id: "bar",
     updated_at: "2022-09-07 12:49:13",
     created_at: "2022-09-07 12:49:13",
     id: 2,
     tenant: App\Models\Tenant {#3688
       id: "bar",
       created_at: "2022-09-07 12:49:08",
       updated_at: "2022-09-07 12:49:08",
       data: null,
       tenancy_db_name: "tenantbar",
     },
   }

すると、tenantbarという名前のデータベースが増えてます。

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| tenancy            |
| tenantbar          |
| tenantfoo          |
+--------------------+
7 rows in set (0.00 sec)

試しにtenancyのdomainsとtenantsテーブルをみてみると、下記の通りとなります。

mysql> select * from domains;
+----+---------------+-----------+---------------------+---------------------+
| id | domain        | tenant_id | created_at          | updated_at          |
+----+---------------+-----------+---------------------+---------------------+
|  1 | foo.localhost | foo       | 2022-09-07 12:49:03 | 2022-09-07 12:49:03 |
|  2 | bar.localhost | bar       | 2022-09-07 12:49:13 | 2022-09-07 12:49:13 |
+----+---------------+-----------+---------------------+---------------------+
2 rows in set (0.00 sec)

mysql> select * from tenants;
+-----+---------------------+---------------------+----------------------------------+
| id  | created_at          | updated_at          | data                             |
+-----+---------------------+---------------------+----------------------------------+
| bar | 2022-09-07 12:49:08 | 2022-09-07 12:49:08 | {"tenancy_db_name": "tenantbar"} |
| foo | 2022-09-07 12:48:59 | 2022-09-07 12:48:59 | {"tenancy_db_name": "tenantfoo"} |
+-----+---------------------+---------------------+----------------------------------+
2 rows in set (0.00 sec)

ここまででお分かりのように、Tenantモデルを用いて作成処理を実行すると

  • 1テナントごとにデータベースが自動で作成され
  • マイグレーションファイルが実行済みとなりテーブルが作成される

ことがわかります。

アプリケーション起動前の準備:

routes/tenant.phpの該当箇所を下記のように変更します。 それぞれのテナントで/tenant-web にアクセス可能となります。

<?php

省略

Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::get('/', function () {
        return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
    });
    // ここを追加。フルパスで指定する必要あり。
    Route::get('/tenant-web', 'App\Http\Controllers\HandsOnController@index');
});

routes/web.phpに下記のように追記します。

routes/web.php

Route::get('/central-web', 'HandsOnController@index');

app/Http/Controllers/HandsOnController.phpを作成します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HandsOnController extends Controller
{
    public function index()
    {
        $tenantId = tenant('id') ?? 'Central';
        return "Now, you are in ".$tenantId;
    }
}

テナントにアクセスしてみる:

この時点で3つのURLを叩くことができます。

localhost:8000 // Central Route
foo.localhost:8000 // Tenant Route (foo)
bar.localhost:8000 // Tenant Route(bar)

アプリケーションを起動してアクセスしてみます。

php artisan serve

例えば

foo.localhost:8000/tenant-web

にアクセスすると、下記イメージのようになります。

foo.localhost:8000/tenant-web

localhost:8000/central-web

とすれば、下記イメージのようになります。

localhost:8000/central-web

状況整理:

  • 1つのドメインに対して1つのデータベースが割り当てられています。
  • AWSの動画で紹介される用語を使えば、サイロに近いと思われます。
    ※ただし、あちらは実行環境の方の話をしているので厳密には違います。

少しいじる:

Central Routeの方で、テナント情報を集約してみたいと思います。
ECサイトで言えば、「お客様の情報をまとめてリストとして表示したい」に近しい行為かと思います。

確認用データを準備します。
database/seeders/DatabaseSeeder.php を下記のように変更します。

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Tenant;
use App\Models\User;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        // \App\Models\User::factory(10)->create();
        Tenant::all()->runForEach(function () {
            User::factory()->create();
        });
    }
}

下記コマンドでテナントのDBのみにデータが入ります。

php artisan tenants:seed

実際にMySQLのコンソールでデータを見ると、下記の通りになります。

mysql> use tenancy;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
Empty set (0.01 sec)

mysql> use tenantbar;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
| id | name                   | email              | email_verified_at   | password                                                     | remember_token | created_at          | updated_at          |
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
|  1 | Mrs. Bryana Langosh MD | sean52@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | D4audYw0BN     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
|  2 | Alexane Murazik MD     | xdoyle@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | fcVff6ssPX     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
2 rows in set (0.00 sec)

mysql> use tenantfoo;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
| id | name                   | email                   | email_verified_at   | password                                                     | remember_token | created_at          | updated_at          |
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
|  1 | Ms. Shemar Stoltenberg | delta23@example.com     | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | JwdwYb4Wji     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
|  2 | Lambert Little         | dare.lester@example.net | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | CcjBpEFH9b     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
2 rows in set (0.00 sec)

先ほど作成したapp/Http/Controllers/HandsOnController.phpに下記のようにメソッドを追加します。

<?php
省略

public function aggregate()
    {
        $aggregate = collect();
        $tenants = Tenant::all();
        foreach($tenants as $tenant){
            tenancy()->initialize($tenant->id);
            $aggregate = $aggregate->merge(User::all());
        }

        dd($aggregate->pluck('name'));
    }

最後に、config/app.php

Route::get('/aggregate', 'HandsOnController@aggregate');

を追加します。

localhost:8000/aggregate にアクセスすると

Illuminate\Support\Collection {#342 ▼
  #items: array:4 [▼
    0 => "Mrs. Bryana Langosh MD"
    1 => "Alexane Murazik MD"
    2 => "Ms. Shemar Stoltenberg"
    3 => "Lambert Little"
  ]
  #escapeWhenCastingToString: false
}

と表示され、確かに集約できます。

補足すると、テナントの方に実装すれば、テナント側でも呼び出せてしまいます。

barテナントでfooテナントが見れる状態

この辺りはコーディング、インフラ両方の側面で縛りをかける必要があるとわかりました。

以上、ハンズオンでした。

所感

思いついた順に述べます。

  • テナントが別テナントにアクセスできないよう仕組みを考える重要性を擬似的に体験できて良かったです。
  • テナント登録からログインの導線をどう実装するのが良いか考える必要があります。
    公式ドキュメントにスポンサーコンテンツがあり、そこでシングルサインオンに関する言及がなされているようです。
  • インフラに関する知識と経験の重要性をさらに強く認識したため、消化不良感があります。
  • AWSの動画内で言及されている内容について少し消化できたので、実際のハンズオンを見つつ、他人に噛み砕いて説明しながら実装できることを目的として理解を深めてみようかと思います。

参考にしたコンテンツ

AWS re:Invent 2021 - SaaS architecture patterns: From concept to implementation - YouTube
Tenancy for Laravel

Firebaseでプッシュ通知・アプリ内メッセージでの訴求時の絞り込みtips

どうもCTOです。 最近は狂ったようにカレーを食べ、ビールを飲み、VALORANTをやっています。 突然ですが金曜日なのでFirebaseの話をします。

アプリ開発でのFirebase

Firebaseは言わずもがな、モバイル開発に特化したプラットフォームで、実際の機能開発に必要なものだけでなく、それを販促するためのツールがいくつか提供されています。 その中でもよく使われているものとして下記の2つがあります。

Firebase Cloud Messaging

Firebase In-App Messaging

非常に強力な機能で、準備も時間もかからず無料で利用できます。 どちらも「特定のイベントでプッシュ通知やアプリ内メッセージを訴求する」機能として提供されていますが、対象ユーザーの絞り込みやトリガーの設定などは仕込みが必要な難しいパターンもあるのでそこを今回は紹介します。ここを抑えておくことで、余計なリリースをすることなく事前に必要なイベントなどを想定できるようになると思います。

条件の設定

イベントでプッシュ通知やアプリ内メッセージを訴求する際には下記を設定します。

絞り込み条件

ユーザーを絞り込んで訴求したい場合に「利用国」「アプリバージョン」などFirebaseがよしなに処理してくれる条件もありますが、それ以外のサービス・アプリ固有な条件は自前で「ユーザーオーディエンス」などのようなユーザーの分類処理を実装する必要があります。この「ユーザーオーディエンス」には「サービスで利用しているコース」や「ユーザーの状態や何かしらのクラスタ」を登録しておくことで、この絞り込みをFirebaseのいろいろな機能で利用できます。

イベントトリガー

スケジュール設定において下記のように「特定のイベントをトリガー」とすることも可能です。

この2つを組み合わせて対象ユーザーを絞り込み特定のタイミングでプッシュ通知やアプリ内メッセージを表示しますが、この提供されている仕組みだとやりやすいものとそうじゃないものがあります。

絞り込みやすい

  • 国やアプリのバージョンなどFirebaseが実装することなく自動で集計してくれる条件
  • サービスのコースやプラン、オプションなど明確な条件(実装は必要)
  • 単一のイベント(ex.ボタンを押した)など明確な条件(実装は必要)

こういったパターンは事前の仕込みは必要なものの、Firebaseを入れておりanalyticsの機能を使ってログを見るような前提があれば特に気にするケースはないかなと思います。

絞り込みにくい

複雑なロジックの組み合わせ条件

SQLのように対象者を絞り込んでどうこうするようなことができないので、条件が組み合わさるとクライアント側で複雑な実装やキャッシュをする必要があるので、そもそもそこまでして実施すべきかどうかというところから検討が必要・・・

例えば「XXという条件の人が、YYのタイミングでZZを実施した」タイミングをトリガーとしたいときは「ZZを実施した」というイベントだけでなく、「YYのタイミングでZZを実施した」というイベントの実装が事前に必要となります。

「XXしてない」という条件

これは「XXしている」人を対象外とすることで、排他的に対象を絞り込むことが可能です。 弊社のアプリを例に取ると

例)スピーキングテストアプリのPROGOSアプリで「まだスピーキングテストを受験してない未受験の人」で絞り込みたい

  1. アプリ利用時にユーザーを「YY」というユーザーオーディエンスへ登録する処理を実施
  2. ユーザーがアプリでスピーキングテストを受験したときに、このユーザーオーディエンス「YY」からそのユーザーを除くような処理を実施

これで「YY」というユーザーオーディエンスは常に未受験の人のみとなる。

これにはあくまでイベントはトリガーであり、それの実施されたかどうかをセグメントとした送信はできないという現状のFirebaseの制約があるためこういった工夫が必要になります。 最近は Remote Config が進化し、ABテスト以上にFirebase上でのユーザーのセグメンテーション機能は進化していますが、まだまだβ版なのでとりあえずはユーザーオーディエンス機能を使うのが良いかなと思います。 (同じようにPredictionsというチャーン予測した機能がありましたが他機能にマージされました。)

firebase.google.com

さいごに

こういった運用課題とも向き合いながらメキメキ受験者数を伸ばしているプロダクトとして弊社のPROGOSアプリがあります。

progos.ai

あまり外で話していないこちらの開発のアレコレを8/29(月)のイベントでもお話するのでぜひ聞きに来てください。

globis.connpass.com

それでは良い週末を!プシュ!

Android アプリのプロキシ設定について

はじめに

こんにちは、まもなくサッカーのヨーロッパ主要リーグが開幕するということでワクワクしながら過ごしている APP/UX チームの杉山です。
私は特定のチームのサポではありませんが、プレシーズンマッチで絶好調のアーセナルがリーグ戦でどこまでいけるか楽しみにしています。

今回は、Android アプリのプロキシ設定について書いていきます。

さっそくプロキシ設定

Wi-Fi にプロキシ設定を行う場合

  1. 設定アプリを開く
  2. Wi-Fi 項目を選択する
  3. 利用可能なネットワーク一覧からプロキシを設定したいネットワークの詳細設定を開く
  4. 詳細設定内のプロキシ項目を選択する
  5. 「手動」を選択し、ホスト名、ポートなどの必要情報を入力する

*設定アプリ内の表示内容は端末により異なる為、名称などが違う場合がございます。

アプリ内でプロキシ設定を行う場合

利用するライブラリ

  • okhttp3
  • okhttp-digest

実際にコードを書いていく

val builder = OkHttpClient.Builder()

try {
    val proxy = 
        Proxy(
            Proxy.Type.HTTP, 
            InetSocketAddress.createUnresolved([プロキシのホスト名], [プロキシのポート名])
        )
    builder.proxy(proxy)
} catch (e:Exception) {
    Log.e(TAG, [error message])
}

val credentials = 
    Credentials([ユーザーネーム], [パスワード])
val digestAuthenticator = 
    object: DigestAuthenticator(credentials) {}
digestAuthenticator.isProxy = true
builder.proxyAuthenticator(digestAuthenticator)

return builder.addInterceptor {
    val build = 
        it.request()
          .newBuilder()
          .addHeader([name], [value])
          .build()
    return@addInterceptor it.proceed(build)
}.build()

何をやっているか見ていく

プロキシ接続用のエントリを作成し、このクライアントによって作成された接続で使用される HTTP プロキシを設定

val proxy = 
    Proxy(
        Proxy.Type.HTTP, 
        InetSocketAddress.createUnresolved([プロキシのホスト名], [プロキシのポート名])
    )
builder.proxy(proxy)

Authenticator に Credential を設定

val credentials = 
    Credentials([ユーザーネーム], [パスワード])
val digestAuthenticator = 
    object: DigestAuthenticator(credentials) {}

Authenticator にプロキシであるかを設定

digestAuthenticator.isProxy = true

プロキシサーバーからのチャレンジに応答するために使用する Authenticator を設定

builder.proxyAuthenticator(digestAuthenticator)

Interceptor に登録し、OkHttpClient を返す

return builder.addInterceptor {
    val build = 
        it.request()
          .newBuilder()
          .addHeader([name], [value])
          .build()
    return@addInterceptor it.proceed(build)
}.build()

アプリで組み込む際に行うと便利なこと

会社などでアカウントが発行される場合、ユーザーネーム、パスワードが違うと思われるので、アプリ起動時に情報を入力させ保持しておくと良きです。
入力方式はどのようなものでも構いませんが、情報を保持していない時だけ表示されるカスタムダイアログなどにすると実装コストも減ります。

終わりに

リモートワークも増え、このような実装をする機会があるエンジニアの方に有益な情報であれば幸いです。
最後までご覧頂きありがとうございました。

We're hiring!

rarejob-tech.co.jp