従来のTwitterのニュースフィードファンアウトアルゴリズムとは異なり、P2P APIコールで構成された従来のユーザー画面をシステムに発展させた過程を紹介します。
一般的なモバイルサービスは、上記のような無限スクロール形式で、クライアントサイドでさまざまなAPIを呼び出して画面を描画し構成する方法です。ただし、ユーザーUIの観点からこの画面を構成しているコンテンツ/コンポーネントは、多様であったり同じように見える場合があります。
- 同じように見える例:Twitter / Instagramなど
しかし、ユーザーが見ているコンテンツやコンポーネントの見た目が同じでも、論理的には全く異なる文脈(コンテキスト)でのコンテンツである場合があります。例えば、ユーザーがニュースを集めたサービスを利用している時、ニュースの見た目は同じでも、あるものは広告であり、あるものはおすすめのコンテンツであり、あるものはユーザーが購読している新聞社のニュースであることがあります。
このような理由から、クライアントサイドは1つのAPIではなく、さまざまなAPIを呼び出して画面を構成し、結果として多くのネットワーク通信を生み出すことになります。
ある地点から別の地点までの往復時間をRTT(Round-Trip Time)と言います。特に、ユーザーのデバイスであるスマートフォンやノートパソコンとサーバー間の通信であるインターネットを通じた通信では、このRTTが非常に高い傾向があります。
APIオーケストレーション(またはAPIゲートウェイ)の性質を持つシステムを構築して、さまざまなAPIを組み合わせて提供するAPIを作成すれば、最終的には以下のようなイメージとなり、E2E(エンドツーエンド)通信回数を減らしてユーザー向けアプリケーションのパフォーマンスを最適化できます。
この時に必要なのが、APIの応答と画面の同期、いわゆる最近よく言われるServer Driven UIの形態です。すると、APIの応答は次のような形になります。
// View
sealed interface View { ... }
// View Impemtnation
class ArticleView : View { ... }
class GalleryView : View { ... }
// Controller Example
@RestController
class SampleController {
@GetMapping("/feed")
fun feed( params.. ): FeedPage<View> { ... }
}
上記のコードを見ると、APIからインターフェースが返されていることがわかります。一般的にOpenAPIやProtocol BufferなどのIDL(Interface Definition Language)で言うOneOfタイプで、ViewインターフェースのEnumクラスのような特定のプロパティ値により分岐して画面を構成することができます。
応答は上記のような形で通信できるという基本コンセプトを持ち、次に、どのようにしてさまざまな文脈(コンテキスト)を柔軟かつ拡張可能な形で開発できる状態にするか、私の考えを共有します。
先ほどお見せした実装では、推薦コンテンツ、広告コンテンツ、運営者が直接選んだコンテンツを簡単に推薦/広告/運営という言葉で抽象化できます。
ユーザーのライフサイクル、つまりモバイル画面でユーザーが下にスクロールしながら(またはより複雑なインタラクションがあっても)、上記の三つの文脈(コンテキスト)はそれぞれ異なる文脈でのコンテンツを提供します。
つまり、複数の文脈からコンテンツを希望の順序で継続的に抽出できる必要があり、コードで実装すると次のような形になります。
// Contextを持ってコンテンツを抽出するオブジェクト
interface FeedComponent(
val context: FeedComponentContext,
) {
// 次のコンテンツを返す
fun next(): List<...>
}
class RecommendFeedComponent( ... ) { ... } : FeedComponent
class AdFeedComponent( ... ) { ... } : FeedComponent
class ManagedFeedComponent( ... ) { ... } : FeedComponent
// FeedComponentの順序を定義するオブジェクト
interface FeedStrategy( ... ) {
// 順序に従ってFeedComponentを返す
fun fetch( ... ): List<FeedComponent>
}
// FeedComponentからコンテンツを抽出してアグリゲーションするオブジェクト
class FeedLoader {
fun load(): Feed {
// 次の順序のコンテキストを取得
val components: List<FeedComponent> = feedStrategy.fetch()
// 各コンテキストからコンテンツを抽出してアグリゲーションする
return aggregation { components.flatMap((component) => { component.next() }) }
}
}
- FeedComponentContext: Context Object Pattern[Alur02]
- FeedComponent: Command Pattern[GoF]、Contextを持つオブジェクトで、コンテンツを抽出する
- FeedStrategy: FeedComponentの順序を定義する
- FeedLoader: 複数のFeedComponentからコンテンツを抽出して集計する
上記のコードを見ると、FeedComponentはすべて自身のContextでコンテンツを抽出できるため、FeedLoaderがFeedComponentから並行してコンテンツを抽出する動作も可能です。これをシーケンス図で表すと以下のようになります。
上記のようにコンテキストを抽象化し、FeedComponentというインターフェースを実装して各コンテキストを設定し、コンテンツを取得できるようにしました。そして、FeedLoaderを通じて複数のFeedComponentからコンテンツを取得し、最終的に整理して応答値を生成しました。
クラス図で描くと上記のような形になり、次の投稿ではクライアントサイドと通信しながら、どのように文脈ごとのページネーション情報を維持したかをご紹介します。
私たちはコンテンツの文脈(コンテキスト)を抽象化して、1つのAPIで提供する方法について一緒に見てきました。ですが、以下の画像をよく見ると、文脈ごとにコンテンツが異なるため、文脈ごとにページネーションを維持する必要があることがわかります。
伝統的なファンアウト方式のフィードシステムであれば、コンテンツリストをユーザーごとに保持・管理するため問題にならないかもしれませんが、現在のオンデマンド方式のフィードシステムでは、上記のように文脈ごとにページネーション情報を維持する必要があります。
そのため、次のように「全体フィード」に対するページネーション情報をFeedPaginationとして定義して管理し、「各文脈」に対するページネーション情報はcomponentPaginationというプロパティにMapで管理します。
typealias ComponentId = String
data class FeedPagination(
val offset: Int,
val size: Int,
val hasNext: Boolean,
val componentPagination: MutableMap<ComponentId, String>,
)
上記のようにFeedPaginationというオブジェクトを持ち、クライアントと通信する際にはエンコードして文字列としてFeedPaginationオブジェクトをやり取りします。
- クライアント : FeedSystemを使用するオブジェクト(別名:API)
- FeedSystem : フィードシステムの実装体
- FeedLoader : 複数のFeedComponentからコンテンツを抽出して集計する
- FeedPaginationEncoder : 「全体フィード」に対するページネーションを担当し、プロトコルに従ってエンコード/デコードロジックを変換できる
しかし、FeedPaginationのcomponentPagination Mapプロパティを見ると、値を文字列として持っていることがわかります。FeedLoaderで各コンテキストを含むFeedComponentに各コンテキストごとのページネーション情報に対するエンコードおよびデコードの責任を委譲することで、以下のようにコードを記述することができます。
interface FeedComponent<Context : FeedComponentContext, Pagination : FeedComponentPagination> {
val id: String
val context: Context
val encoder: FeedComponentEncoder<Context, Pagination>
val loader: FeedComponentLoader<Context, Pagination>
suspend fun next(feedContext: FeedContext, feedPagination: FeedPagination): FeedComponentResult {
val loadResult = loader.load(
feedContext,
context,
encoder.decodePagination(feedPagination.componentPagination[id])
)
return FeedComponentResult(
id = id,
items = loadResult.items,
hasNext = loadResult.nextPagination.hasNext,
nextPagination = encoder.encodePagination(loadResult.nextPagination),
)
}
}
- FeedComponent : コンテキストを抽象化して含むオブジェクト
- FeedComponentContext : コンテキストに含まれる情報(ステートフル)
- FeedComponentEncoder : FeedComponentのページネーション情報をエンコード/デコードするオブジェクト
- FeedComponentLoader : 該当コンテキストのコンテンツを読み込むオブジェクト
また、FeedComponentのエンコード/デコードとコンテンツを読み込むオブジェクトを分離することで、コンテンツを読み込むロジックの可視性を高め、ジェネリックタイプを利用することで、エンコードされた文字列タイプのページネーションオブジェクトがコードのあちこちに散らばらないように隔離しました。
結局、複雑なページネーション情報をシリアライズしてクライアントと単純な方式で通信できるように変更し、さまざまな文法を利用してコンテンツを読み込むロジックをできるだけ隔離することで、拡張性を持たせることができました。簡単なコンセプトですが、実際に使用することで強力な拡張性と柔軟性を持つことができました。