Twitterが𝕏になってしまったので、自分でTwitterみたいなSNSを作ってみた（その２）

[その１（インフラ設計）はこちら](https://qiita.com/nr_ck/items/3d5a009d984e8048a2be)

# どうやって作ったかまとめてみた
このエントリは個人で作成したサービスの「[ツイハイ！](https://www.twi-high.com/)」について、インフラからバックエンド、フロントエンドまで一気通貫でどんな設計で動いてるかまとめたものになります。

ツイハイ！は現在のところアーリーアクセス版というステータスで、かつ実験的なプロジェクトという建付けとしています。まだ基本的な機能のみのため、10年前くらいのTwitterを思い出すかもしれません。小さいSNSで、機能もユーザー数も少ないサービスですが、その裏側はスケールしやすいように考えた設計があります。この設計についてフロントエンド設計、 **バックエンド設計（このエントリ）** 、[インフラ設計（この前のエントリ）](https://qiita.com/nr_ck/items/3d5a009d984e8048a2be)という3つの視点でQiitaに残したいと思います。
これらのエントリを通して、SNSを提供するために必要なサービスやAPIの構成などを感じ取ってもらえると幸いです。

:::note info
本エントリは「[Azure完全に理解した！～ツイハイ！の設計で学ぶSNSに必要なエンジニアリング～](https://booth.pm/ja/items/4963518)」からの抜粋になります。
:::

# バックエンド設計

バックエンドは役割に応じて機能を分割して各リソースに配置するように設計しています。フロントエンドにはREST APIを提供し、各機能間はキューを使用した通信を行うよう設計しました。
まずはサービスの根幹となるAPI部分から解説していきたいと思います。

# API設計

APIは **「ユーザ」「ツイート」「タイムライン」「フォロー」** の4つの大枠があり、その中でそれぞれに関する処理をしています。各APIはフロントエンドとJSON形式でデータの送受信を行っています。APIエンドポイントURLは現状カオスで、秩序がなされていません。サードパーティ製クライアントの開発を開放するまでにこのあたりの整理はしたいと思います。

## ユーザ
ユーザではアカウントの作成やログイン、対象アカウントの情報取得など、ユーザ情報に関わる処理を担っています。

### ログイン
フロントエンドからPOSTで送られてきたアカウントIDとパスワードをもとに、ユーザコンテナ内からユーザ情報を取得します。一致するユーザ情報があった場合、他のAPIの通信に必要なJWTを発行し、それをフロントエンドにレスポンスします。

```json:フロントエンドからサーバへ送る情報
{
  "displayId": "Fusianasan",
  "plainPassword": "nullpo"
}
```

```json:一致するユーザ情報があった場合レスポンスされるJWT
{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6IjAxMjM0NTY3LTg5YWItY2RlZi1naGlqLWtsbW5vcHFyc3R1diIsIkRpc3BsYXlJZCI6IkZ1c2lhbmFzYW4iLCJEaXNwbGF5TmFtZSI6ImxvY2FsaG9zdCIsIkF2YXRhclVybCI6Imh0dHBzOi8vbG9jYWxob3N0L-OCpOOCseOBpuOCi-eUu-WDjy5wbmciLCJleHAiOjE4MDAwMDAwMDAsImlzcyI6Ind3dy50d2ktaGlnaC5jb20iLCJhdWQiOiJUd2lIaWdoVXNlcnMifQ.kus7NPXpPemvzsilAFKF85drhA9eOyaV37McpV0ny_Q"
}
```

このトークンをHTTPヘッダーの `Authorization` に `Bearer <JWT>`  として設定し、他の認証情報を必須とするAPIを叩けるようになります。

### アカウント作成
ユーザが入力した情報をもとに新しいアカウントを作成します。アカウントの作成に必要な情報は `アカウントID` 、 `アカウント名` 、 `メールアドレス` です。`アカウントID` は大文字小文字区別なく、サービス内で重複が許されていないため、仮に重複している `アカウントID` がリクエストされた場合は `409 Conflict` がレスポンスされます。アカウントの作成に成功した場合は、作成後のアカウント情報がレスポンスされます。

```json:フロントエンドからサーバへ送る新規作成アカウント情報
{
  "displayId": "Fusianasan",
  "displayName": "ふしあなさん",
  "password": "nullpo",
  "email": "null@twi-high.com"
}
```

```json:作成に成功した場合レスポンスされるアカウント情報
{
  "displayId": "Fusianasan",
  "lowerDisplayId" "fusianasan",
  "displayName": "ふしあなさん",
  "email": "null@twi-high.com"
  "biography": "",
  "tweets": 0,
  "avatarUrl": "https://********",
  "follows": [],
  "followers": [],
  "id": "01234567-89ab-cdef-ghij-klmnopqrstuv",
  "createAt": "2023-07-22",
  "updateAt": "2023-07-22"
}
```

### プロフィールの更新
ユーザが入力した情報に基づいてプロフィール情報を更新します。更新できる情報は `アカウント名` 、 `アカウントID` 、 `自己紹介文` 、 `アイコン` になります。プロフィール更新は変更のある項目のみをAPI側に送信するパッチ方式にしました。

### アカウント情報取得
URLパラメータで指定されたアカウントデータID、もしくはアカウントIDに対応するアカウント情報を取得します。対象のアカウントが存在しない場合は `404 NotFound` をレスポンスします。

### フォローアカウント取得　GET：/twihighuser/twihighuser/{id}/Follows
URLパラメータで指定されたアカウントデータID、もしくはアカウントIDに対応するアカウントのフォローアカウントを取得します。対象のアカウントが存在しない場合は `404 NotFound` をレスポンスします。フォローアカウントが無い場合は空の配列 `[]` がレスポンスされます。フォローアカウントが存在する場合はアカウント情報を配列にしレスポンスします。

```json:サーバからレスポンスされるフォローアカウント一覧
    [
        {
            "id": "00000000-0000-0000-0000-000000000000",
            "displayId": "Fusianasan",
            "displayName": "ふしあなさん",
            "biography": "ツイハイ始めてみました！",
            "tweets": 0,
            "avatarUrl": "https://localhost/hogehoge.jpeg",
            "follows": [
                "00000000-0000-0000-0000-000000000000",
                ...略...
            ],
            "followers": [
                "00000000-0000-0000-0000-000000000000",
                ...略...
            ],
            "createAt": "2023-07-15T15:00:00.0000000+00:00"
        }
    ]
```

## ツイート
ツイートではサービスの根幹になるツイートの投稿や削除、ユーザの最新ツイート取得などツイートに関わる処理を行います。あくまでもツイートAPIはツイートのCRUDに徹し、各々のタイムラインの更新はタイムラインAPIにて実施しています。それぞれのAPI間はHTTPでの通信ではなく、キューでやり取りを行います。

### 投稿
フロントエンドからツイートデータを受け取り、それをCosmos DBに書き込みます。書き込みに成功した場合、フォロワーのタイムラインにツイートを追加する処理をおこなうため、タイムラインAPIに向けてキューを送信します。

```json:サーバへ送信するツイートデータ
    {
      "text": "@Fushianasan こんにちは！！",
      "replyTo": {
        "tweetId": "00000000-0000-0000-0000-000000000000",
        "userId": "00000000-0000-0000-0000-000000000000"
      }
    }
```
```json:サーバからレスポンスされる作成後のツイートデータ
    {
      "userId": "00000000-0000-0000-0000-000000000000",
      "userDisplayId": "nullpo",
      "userDisplayName": "ぬるぽ",
      "userAvatarUrl": "https://localhost/hogehoge.jpeg",
      "text": "@Fushianasan こんにちは！！",
      "isDeleted": false,
      "replyTo": "00000000-0000-0000-0000-000000000000",
      "replyFrom": [],
      "id": "00000000-0000-0000-0000-000000000000",
      "updateAt": "2023-07-25T12:59:33.3938075+00:00",
      "createAt": "2023-07-25T12:59:33.3938075+00:00"
    }
```

### 削除
削除対象のツイートデータIDを含むURLに対してDELETEメソッドでフロントエンドからのリクエストを受け取ります。対象のツイートがない場合や自分のツイートではない場合、 `404 NotFound` をレスポンスします。削除に成功すると `200 OK` をレスポンスします。

### ユーザツイート取得
対象ユーザのアカウントデータIDを含むURLに対してGETメソッドでフロントエンドからのリクエストを受け取ります。このAPIはログインしていない場合でも使用できます。また、クエリストリングに取得対象の期間を指定することができます。対象期間の開始時刻を `since` 、 終了時刻を `until` に設定します。対象期間の中から最新の50件を取得することができます。対象期間を指定しなかった場合は最新の50件を取得します。

### ツイート取得
対象のツイートデータIDを含むURLに対してGETメソッドでフロントエンドからのリクエストを受け取ります。対象のツイートのリプライ先、リプライ元のツイートをそれぞれ取得し関連するツイートを配列でレスポンスします。対象のツイートが存在しない場合は、`404 NotFound` をレスポンスします。


## タイムライン
タイムラインはツイハイ！の中で一番リクエスト数が多いAPIになります。アカウントごとのタイムラインを管理し、リクエストされたアカウントに応じたタイムラインのデータをレスポンスします。

### タイムライン取得
ログインしているアカウントに紐づくタイムラインを取得します。ログインしていない場合は、`401 Unauthorized` がレスポンスされます。またクエリストリングに取得対象の期間を指定することができます。対象期間の開始時刻を `since` 、 終了時刻を `until` にDatetimeOffset型で設定します。具体的には「since=2023-07-25T12:13:56.1550257+00:00&until=9999-12-31T23:59:59.9999999+00:00」のようになります。この設定された対象期間の中から最新の100件を取得することができます。指定がない場合は現在の最新の100件を取得します。ツイートデータがある場合は `200 OK` を返し、ツイートデータと、そのデータの中で最新と最古の作成時刻を明示的に示したメタデータを含むデータを返します。ツイートデータがない場合は `204 NoContent` をレスポンスします。

```json:ツイートデータがある場合のレスポンス
    {
      "latest": "2023-07-25T12:13:56.1550257+00:00",
      "oldest": "2023-07-19T02:02:00.9209175+00:00",
      "tweets": [
        {
          "userId": "00000000-0000-0000-0000-000000000000",
          "userDisplayId": "Fusianasan",
          "userDisplayName": "ふしあなさん",
          "userAvatarUrl": "https://localhost/hogehoge.jpeg",
          "text": "ぬるぽ",
          "isDeleted": false,
          "replyTo": null,
          "replyFrom": [],
          "id": "00000000-0000-0000-0000-000000000000",
          "updateAt": "2023-07-25T12:13:56.1550257+00:00",
          "createAt": "2023-07-25T12:13:56.1550257+00:00"
        },
        // 略
      ]
    }
```

## フォロー
フォローはフォロー、リムーブに関わるAPIです。

### フォロー
フォロー対象のアカウントデータIDに対してPUTメソッドでリクエストを受け取ります。ボディは不要です。ログインしていない場合は、`401 Unauthorized` がレスポンスされます。問題なくフォローすることができた場合、 `204 NoContent` をレスポンスします。レスポンスと平行して、タイムラインにフォローしたアカウントのツイートを追加するキューを送信します。このキューはタイムラインFunctionによって処理されます。

### リムーブ
フォロー対象のアカウントデータIDに対してDELETEメソッドでリクエストを受け取ります。ログインしていない場合は、`401 Unauthorized` がレスポンスされます。問題なくリムーブすることができた場合、 `204 NoContent` をレスポンスします。レスポンスと平行して、タイムラインからリムーブしたしたアカウントのツイートを削除するキューを送信します。このキューはタイムラインFunctionによって処理されます。

# キュートリガー設計

リクエストを受けてからレスポンスするまでに、重い処理を実行してしまうとUXに影響を与えてしまいます。そのため、時間がかかることが予想される処理についてはバッチ的に実行する設計をとっています。これを実現するため、API内でキューを発行し別のFunctionで処理する実装にしております。具体的には次の4つのイベントでキューを発行し、処理を実行しています。

## ツイート新規投稿イベント
ツイートを投稿するだけであればおおよそ50msもあれば完了しますが、そのツイートをフォロワーのタイムラインに反映するとなると、APIの処理時間として許容できるものではなくなります。極端な例ですが、フォロワーが1000人居た場合、1人あたり10msの処理を実行したら全体で10秒かかってしまいます。ユーザは、ツイートする度に10秒かかってしまうサービスを使いたいとは思わないでしょう。
そのため、ツイートAPI内でCosmos DBにツイートを追加できた段階でレスポンスを返し、それと並行してタイムラインへの追加キューを発行しています。発行されたキューはタイムラインFunctionで処理されます。これにより、ツイート投稿のタイミングですべてのフォロワーのタイムラインに即時反映されませんが、ツイート投稿リクエストに対するレスポンスを高速に返すことができます。

## ツイート削除／ツイート更新イベント
ツイートの削除や更新（本文は更新できませんが、ツイートに対してリプライが付いた場合はそのデータIDをリプライ元一覧に追加するため、更新処理が発生します）はツイート新規投稿イベントと似ていますが、Cosmos DBへのデータの反映の仕方を変えています。削除や更新では対象のツイートの一部のパラメータしか更新しない（削除の場合は削除フラグを立て、論理削除としている）ため、差分更新（PatchOperation）としています。こうすることで、バックグラウンドの処理を効率的に実施し、CosmosDBへのコスト削減と処理時間の短縮を図っています。

## フォローイベント／リムーブイベント
フォロー、リムーブもツイート新規投稿イベントと同様にタイムラインデータの更新が発生します。ツイート新規投稿イベントと異なる点は、複数のアカウントのタイムラインではなく、自身のタイムラインに対して大量のツイートの追加、削除が発生する点です。
フォローした場合は対象のアカウントのツイートデータを自身のタイムラインに追加する必要がでてきます。例えば10,000ツイート分のデータをタイムラインに取り込む必要があった場合、フォローボタンが押されたときにすべてを処理しきることは難しいでしょう。そのため、アカウントのフォロー一覧情報に対象のアカウントのデータIDを追加できた時点でレスポンスを返し、並行してタイムラインへの追加キューを発行しています。反対に、リムーブの場合はフォロー一覧情報から対象のアカウントのデータIDを削除できた時点でレスポンスを返し、並行してタイムラインからの削除キューを発行しています。
こうすることで、タイムラインの更新をバックグラウンドで処理することができるため、フォロー／リムーブのリクエストに対するレスポンスを高速に返すことができます。

## アカウント情報更新イベント
アカウントIDやアカウント名、アカウントアイコンURLはツイート時に設定されている情報がツイートデータにもそのまま設定されます。SQL Serverなどのリレーショナルデータベースに慣れ親しんだ人にとっては冗長なデータ構成に見えます。しかし、ツイートやタイムラインは圧倒的にGET（SQLで言えばSELECT）が多いです。GETに偏ったアクセスであるため、アカウント情報をマスタとして都度ツイートの情報とアカウント情報を結合していると、SELECTの実行速度が落ち、性能劣化に繋がります。そのため、冗長ではありますががツイート情報にツイートを画面に表示するために必要なアカウントID、アカウント名、アカウントアイコンURLをそのまま設定しているのです。
しかし、アカウントアイコンや、アカウント名を変更する場合、アカウント情報だけではなく、ツイートに設定されている冗長な情報も一緒に更新する必要がでてきます。この処理を1リクエスト内ですべてこなすことは不可能であるため、アカウントの更新が行われたら自分音アカウント情報の差分更新だけを行います。差分更新に成功したらリクエストへのレスポンスと、ツイート情報の更新キューを発行します。
このキューをツイートFunctionで受け取り、新しいアカウント情報を自身の全ツイートに反映します。反映に成功したら、フォロワーのタイムラインの更新キューを発行します。2つ目のキューはタイムラインFunctionで処理されます。具体的な処理の内容はツイート削除／ツイート更新イベントと同様の処理になります。

# データ設計

データベースにはNoSQLのCosmos DBを使用しています。SQL ServerのようなRDBMSとは異なり、スキーマがなくすべてのレコード（Cosomos DBではアイテム）はJSONの形式で保存されます。データを高速で取得できる利点があるものの、データの結合等が苦手であったり、パーテーションキーを意識したデータ設計が難しかったりします。SNSという性質上、データの作成・更新より読み取りの方が圧倒的に多くなることが予想できたため、CosmosDBの利点を最大限引き出せると考え、ツイハイ！ではCosmos DBを採用しています。
それではCosmos DBで使用するデータの設計をしていきましょう。Cosmos DBではRDBMSでいうテーブルを「コンテナ」、レコードを「アイテム」と呼んでいます。本書でもこのように記載しています。

## 共通して各アイテムに設定する項目
次の3項目は各アイテムに共通して設定することにしています。


- データID：Guid
- 更新日時：DatetimeOffset
- 作成日時：DatetimeOffset

データIDはコンテナ内の主キーとしてGuidで設定します。シーケンス番号にしたいところですが、Cosmos DBにはSQL Serverのように自動でシーケンス番号を振ってくれることはありません。デフォルトでGuidが設定されるため、それにならってツイハイでもGuidを設定しています。
また更新日時、作成日時などの時刻表記はすべてDatetimeOffsetとしています。これはAzureというグローバルな空間でデータを管理する関係上、タイムゾーンも含めて記録に残しています。仮にDatetime型で保存する場合はUTCに統一して保存することが望ましいです。

## ユーザーコンテナ
ユーザーコンテナ内のアイテムは以下の内容を設定します。主キーは「データID」、パーテーションキーも「データID」としています。Cosmos DBは主キーとパーテーションキーを同一の項目としても問題ありません。これはよくあるデザインパターンの1つです。


- アカウントID：string
- アカウントID（小文字）：string
- アカウント名：string
- ハッシュパスワード：string
- 自己紹介文：string
- メールアドレス：string
- ツイート数：long
- アカウントアイコンURL：string
- フォローアカウントデータID：Guid[]
- フォロワーアカウントデータID：Guid[]

アカウントIDはデータIDとは異なり、画面上に表示されるIDになります。この値が `@` の後ろに表示されます。例えば `@twihigh` のように表示されます。
アカウントID（小文字）はアカウント作成時のアカウントID重複チェックに使用しています。

## ツイートコンテナ
ツイートコンテナ内のアイテムは以下の内容を設定します。主キーは「データID」、パーテーションキーは「アカウントデータID」としています。


- アカウントデータID：Guid
- アカウントID：string
- アカウント名：string
- アカウントアイコンURL：string
- ツイート本文：string
- 削除済みフラグ：bool
- リプライ先ツイートデータID：Guid?
- リプライ元ツイートデータID：Guid[]

アカウントデータIDは不変のためパーテーションキーに採用しています。複数のツイートに対してはリプライを送れない仕様のため、リプライ先ツイートデータIDは現在のところ1つとしています。逆に複数のツイートからリプライされる可能性があるため、リプライ元ツイートデータIDはGuidの配列となっています。削除済みフラグが `true` の場合は、ユーザがツイートを削除したことを表しています。

## タイムラインコンテナ
タイムラインコンテナ内のアイテムは以下の項目を設定します。主キーは「データID」、パーテーションキーは「タイムラインの所有者のアカウントデータID」としています。


- タイムラインの所有者のアカウントデータID：Guid
- ツイートデータID：Guid
- アカウントデータID：Guid
- アカウントID：string
- アカウント名：string
- アカウントアイコンURL：string
- ツイート本文：string
- 削除済みフラグ：bool
- リプライ先ツイートデータID：Guid?
- リプライ元ツイートデータID：Guid[]

タイムラインの所有者のアカウントデータID以外はツイートコンテナのアイテムと同じ内容になります。冗長になりますが、ツイートコンテナとタイムラインコンテナの2つを用意するのは読み込み速度を向上させるためです。CosmosDBは単一のパーテーションキー内からのデータ読み取りの速度を保証しているため、アカウントごとにツイートを複製し、タイムラインデータとして保存しておくことで、高速なタイムラインの読み取り及びレスポンスを実現しています。

# まとめ

今回紹介したAPI設計は他のフレームワークでも一般的なもなため、理解しやすいかと思います。キュートリガーやCosmosDBを使った設計は少しテクニックが必要になります。キュートリガーをうまく使うことで、負荷を分散したり、フロントエンドからのレスポンスをより早く返すことができるようになります。また、CosmosDBを使うことで大量のツイートデータを取り扱っても性能が劣化しにくい仕組みを取っています。
バックエンド設計部分はここまでです。引き続き、別のエントリで「フロントエンド設計」について書いていきたいと思います。より詳細な内容は **コミックマーケット102で頒布予定の「Azure完全に理解した！　～ツイハイ！の設計で学ぶSNSに必要なエンジニアリング～」** を読んでいただけると幸いです。

# ご紹介（PR）
きじのしっぽはコミックマーケット102に参加します！　**8/13は西2ホールの「お37a」で会いましょう！**
よろしくお願いします🙇
- 頒布情報
  - [[BOOTH] Azure完全に理解した！～ツイハイ！の設計で学ぶSNSに必要なエンジニアリング～](https://k-s.booth.pm/items/4963518)
  - [[C102当日] 2023/08/13 東京ビッグサイト 西2ホール「お37a」きじのしっぽ](https://webcatalog.circle.ms/Circle/17305442)

コミックマーケット開催当日以外に、BOOTHでも頒布していますので、ぜひご覧ください！

👇下の表紙が目印です👇
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160980/bf22823b-1524-adfe-29f6-a7e2249d5f20.png" width="260px">