フロントエンド、バックエンド、モバイル開発など、いくつかのチームがプロジェクトに取り組みました。 ほとんどの場合、Redditの既存のテクノロジーに実装されていました。 この記事では、技術的な面でPlaceがどのように作成されたかを見ていきます。 Placeコードを表示したい場合は 、 ここにあります。
必要条件
まず、エイプリルフールのプロジェクトの要件を決定することは非常に重要でした。なぜなら、すべてのRedditユーザーがすぐにアクセスできるように、「オーバークロック」せずに起動する必要があるからです。 彼が最初から完璧に働いていなかったら、彼は多くの人々の注目を集めることはほとんどなかっただろう。
ボードは非常に大きく見えるように1000x1000のサイズにする必要があります。
すべてのクライアントを同期し、単一のボードステータスを表示する必要があります。 結局のところ、異なるユーザーが異なるバージョンを持っている場合、それらが相互作用することは困難です。
一度に少なくとも100,000人のユーザーをサポートする必要があります。
ユーザーは5分間で1つのタイルを投稿できます。 したがって、5分間で100,000タイルの平均リフレッシュレートを維持する必要があります(1秒あたり333更新)。
プロジェクトは、サイトの残りの部分と機能の動作に悪影響を与えてはなりません(たとえr / Placeへのトラフィックが多い場合でも)。
- 予期しないボトルネックや障害が発生した場合、柔軟な構成が必要です。 つまり、データボリュームが大きすぎる場合やリフレッシュレートが高すぎる場合は、ボードのサイズと許容される描画頻度をオンザフライで調整できる必要があります。
バックエンド
実装の決定
バックエンドを作成する際の主な困難は、すべてのクライアントのボードのステータスの表示を同期することでした。 クライアントがタイル配置イベントをリアルタイムでリッスンし、すぐにボード全体のステータスを要求することが決定されました。 この完全な状態が生成される前に更新をサブスクライブする場合、わずかに古い完全な状態を持つことは許容されます。 クライアントは完全な状態を受信すると、待機中に受信したすべてのタイルを表示します。 後続のタイルはすべて、受け取ったらすぐにボードに表示する必要があります。
このスキームが機能するためには、ボードの完全な状態のリクエストをできるだけ早く完了する必要があります。 最初は、ボード全体をCassandraの1行に保存して、各リクエストがその行を読み取るようにしました。 この行の各列の形式は次のとおりです。
(x, y): {'timestamp': epochms, 'author': user_name, 'color': color}
しかし、ボードには100万個のタイルが含まれているため、100万列を読み取る必要がありました。 作業中のクラスターでは、最大30秒かかりましたが、これは受け入れられず、Cassandraに過度の負荷がかかる可能性がありました。
その後、ボード全体をRedisに保存することにしました。 100万個の4ビット数のビットフィールドを取得し、それぞれが4ビットカラーをエンコードでき、xおよびy座標はビットフィールドのオフセット( offset = x + 1000y
)によって決定されました。 ボードの完全な状態を取得するには、ビットフィールド全体を読み取る必要がありました。
タイルは、特定のオフセットで値を更新することで更新できます(読み取り/更新/書き込み手順全体をブロックまたは実行する必要はありません)。 しかし、すべての詳細をCassandraに保存する必要があるため、ユーザーは各タイルを誰がいつ投稿したかを知ることができます。 Redisがクラッシュした場合、Cassandraを使用してボードを復元することも計画しました。 ボード全体を読み取るのに100ミリ秒もかかりませんでした。
ここでは、例として2x2ボードを使用してRedisに色を保存した方法を示します。
Redisで読み取り帯域幅が発生するのではないかと心配していました。 多くのクライアントが同時に接続または更新された場合、すべてのクライアントがボードの完全な状態に対する要求を同時に送信しました。 ボードは全体的にグローバルな状態であったため、明らかな解決策はキャッシュを使用することでした。 CDN(Fastly)レベルでキャッシュすることを決定しました。実装が簡単で、キャッシュがクライアントに最も近く、応答を受信する時間が短縮されたためです。
ボードステータス要求は、1秒あたりのタイムアウトで高速にキャッシュされました。 タイムアウトの期限が切れたときに大量のリクエストを防ぐために、 stale-while-revalidate
を使用しました。 キャッシュを個別にキャッシュする約33のPOPを高速にサポートしているため、ボードの完全な状態について1秒あたり最大33のリクエストを受信することが予想されました。
すべての顧客に更新を公開するために、 Webソケットサービスを使用しました 。 これに先立ち、 Reddit.Liveが100,000人以上の同時ユーザーと連携して、Liveおよびその他の機能のプライベートメッセージの通知を確実に行うために使用しました。 サービスは、過去のエイプリルフールのプロジェクト-The Button and Robinの基礎でもありました。 r / Placeの場合、クライアントはWebソケット接続をサポートして、リアルタイムのタイル配置更新を受信します。
API
ボードの完全な状態を取得する
最初、クエリはFastlyに分類されました。 ボードの有効なコピーがあれば、彼はRedditアプリケーションサーバーに接続せずにすぐにそれを返しました。 そうでない場合、またはコピーが古すぎる場合、RedditアプリケーションはRedisからボード全体を読み取り、Fastlyに返して、キャッシュしてクライアントに返しました。
Redditアプリケーションによって測定された要求頻度と応答時間:
要求頻度が1秒あたり33に達したことがないことに注意してください。つまり、Fastlyによるキャッシングは、ほとんどの要求からRedditアプリケーションを保護する非常に効果的な手段でした。
そして、リクエストがアプリケーションに届くと、Redisは非常に迅速に対応しました。
タイルを描く
タイルを描画する手順:
- Cassandraから、ユーザーのタイルの最後の配置のタイムスタンプが読み取られます。 これが5分未満の場合、何もせず、エラーがユーザーに返されます。
- タイルの詳細はRedisとCassandraに記録されます。
- 現在の時間は、ユーザーによる最後のタイル配置としてCassandraに記録されます。
- Webソケットサービスは、接続されているすべてのクライアントに新しいタイルに関するメッセージを送信します。
厳密な一貫性を維持するために、Cassandraでのすべての書き込みおよび読み取りは、 QUORUM一貫性レベルを使用して行われました。
実際、ここでは、ユーザーが一度に複数のタイルを投稿できるため、レースがありました。 ステージ1〜3では、ブロッキングは発生しなかったため、タイルを同時に描画しようとすると、最初の段階でテストに合格し、2番目の段階で描画されます。 一部のユーザーはこのバグを発見したようです(またはリクエストを送信する頻度の制限を無視したボットを使用しました)-その結果、約15,000タイルが配置されました(全体の〜0.09%)。
Redditアプリケーションによって測定された要求頻度と応答時間:
タイルの配置頻度のピークは、1秒あたりほぼ200でした。 これは333タイル/秒の推定制限を下回っています(5分ごとに100,000人のユーザーがタイルを投稿した場合の平均値)。
特定のタイルの詳細を取得する
特定のタイルを要求するとき、データはCassandraから直接読み取られました。
Redditアプリケーションによって測定された要求頻度と応答時間:
このリクエストは非常に人気があることが証明されています。 通常のクライアントリクエストに加えて、人々は一度に1タイルずつボード全体を取得するスクリプトを作成しました。 この要求はCDNにキャッシュされていないため、すべての要求はRedditアプリケーションによって処理されました。
これらのリクエストに対する応答時間は非常に短く、プロジェクトの全期間を通じて同じレベルに保たれました。
Webソケット
r / PlaceがWebソケットサービスの操作にどのように影響したかを示す個別のメトリックはありません。 しかし、プロジェクトの開始前と完了後にデータを比較することにより、値を推定できます。
Webソケットサービスへの接続の総数:
r / Placeが起動する前の基本負荷は約20,000接続で、ピークは100,000接続でした。 そのため、ピーク時には、おそらく約80,000人のユーザーが同時にr / Placeに接続していました。
Webソケットサービスの帯域幅:
r / Placeの負荷のピーク時に、Webソケットサービスは4ギガビット/秒(各インスタンスで150メガビット/秒、24インスタンスのみ)を送信しました。
フロントエンド:Webおよびモバイルクライアント
Placeのフロントエンドを作成するプロセスでは、クロスプラットフォーム開発に関連する多くの複雑なタスクを解決する必要がありました。 iOSおよびAndroidのデスクトップPCやモバイルデバイスなど、すべての主要なプラットフォームでプロジェクトが同じように動作することを望んでいました。
ユーザーインターフェイスには3つの重要な機能がありました。
- ボードの状態をリアルタイムで表示します。
- ユーザーがボードと対話できるようにします。
- モバイルアプリケーションを含むすべてのプラットフォームで動作します。
インターフェイスの主なオブジェクトはキャンバスであり、 Canvas APIはそれにぴったりでした。 サイズが<canvas>
要素を使用し、各タイルは単一ピクセルとして描画されました。
キャンバス描画
Canvasは、ボードの状態をリアルタイムで反映することになっています。 ページをロードするときにボード全体を描画し、Webソケットを介した更新を完了する必要がありました。 CanvasRenderingContext2D
インターフェイスを使用するキャンバス要素は、 CanvasRenderingContext2D
3つの方法で更新できます。
-
drawImage()
を使用して、既存のキャンバス画像を描画します。 - さまざまなフォームレンダリング方法を使用してフォームを描画します。 たとえば、
fillRect()
は長方形をある色で塗りつぶします。 -
ImageData
オブジェクトを作成し、putImageData()
を使用してキャンバスに描画します。
最初のオプションは、完成したイメージの形のボードを持っていなかったため、私たちには適していませんでした。 オプション2と3がありました。最も簡単な方法は、 fillRect()
をfillRect()
個々のタイルを更新することfillRect()
た。更新がWebソケット経由で到着すると、位置(x、y)に1x1の長方形を描画します。 一般に、この方法は機能しましたが、ボードの初期状態を描画するにはあまり便利ではありませんでした。 putImageData()
メソッドの方がはるかに優れていましたputImageData()
つのImageData
オブジェクトで各ピクセルの色を定義し、一度にキャンバス全体を描画できました。
ボードの初期状態を描く
putImageData()
を使用するには、ボードの状態をUint8ClampedArray
の形式で決定する必要があります。各値は、0〜255の範囲の8ビット符号なし数値です。各値は、カラーチャンネル(赤、緑、青、アルファ)を表し、各ピクセルには4つ必要です配列内の要素。 2x2キャンバスには、最初の4バイトがキャンバスの左上のピクセルを表し、最後の4バイトが右下を表す16バイト配列が必要です。
キャンバスピクセルがUint8ClampedArray表現にどのように関連付けられるかを以下に示します。
プロジェクトのキャンバスには、400万バイト-4 MBの配列が必要でした。
バックエンドでは、ボードの状態は4ビットのビットフィールドとして保存されます。 各色は、0〜15の数値で表されます。これにより、各バイトに2ピクセルを詰めることができます。 これをクライアントデバイスで使用するには、次の3つのことを行う必要があります。
- APIからクライアントにバイナリデータを転送します。
- データを解凍します。
- 4ビットカラーを32ビットに変換します。
バイナリデータを転送するために、それをサポートするブラウザでFetch API
を使用しました。 そして、サポートしていない人のために、値“arraybuffer”
を持つresponseType
でXMLHttpRequest
を使用しました。
APIから受信したバイナリデータには、各バイトに2つのピクセルが含まれています。 最小のTypedArray
コンストラクターにより、シングルバイト単位の形式でバイナリデータを操作できます。 ただし、クライアントデバイスで使用するには不便であるため、データを解凍して作業しやすくしました。 プロセスは簡単です。パックされたデータを反復処理し、古いビットと新しいビットを取り出し、それらを別のバイトに別の配列にコピーしました。
最後に、4ビットの色を32ビットに変換する必要がありました。
putImageData()
を使用するために必要なImageData
構造体は、最終結果が、RGBA順序でカラーチャネルをエンコードするバイトを持つUint8ClampedArray
の形式であるUint8ClampedArray
があります。 これは、別のアンパックを実行して、各色をコンポーネントチャネルバイトに分割し、正しいインデックスに配置する必要があることを意味します。 ピクセルごとに4つのエントリを作成するのはあまり便利ではありません。 しかし、幸いなことに、別のオプションがありました。
TypedArray
オブジェクトは、基本的に配列の形式のArrayBuffer
表現です。 注意点が1つあります。複数のTypedArray
インスタンスが同じArrayBuffer
インスタンスをArrayBuffer
ます。 4つの値を8ビット配列に書き込む代わりに、1つの値を32ビットの配列に書き込むことができます! 書き込みにUint32Array
を使用すると、配列の1つのインデックスを更新するだけで、タイルの色を簡単に更新できました。 Uint8ClampedArray
、 Uint8ClampedArray
読み取るときにバイトが自動的に正しい場所に落ちるように、カラーパレットを逆バイト順(ABGR)で保存する必要がありUint8ClampedArray
。
Webソケットを介して受信した更新の処理
drawRect()
メソッドは、受信した個々のピクセルによる更新のレンダリングに適していましたが、1つの弱点がありました。同時に大量の更新が行われると、ブラウザーの速度が低下する可能性があります。 また、取締役会の状態の更新は非常に頻繁に行われる可能性があることを理解していたため、問題を何らかの形で解決する必要がありました。
Webソケットを介して更新を受信するたびにキャンバスをすぐに再描画する代わりに、同時に到着するWebソケットの更新をバンドルしてすぐに一括でレンダリングできるようにすることにしました。 このために2つの変更が行われました。
-
drawRect()
使用をputImageData()
を使用して一度に多くのピクセルを更新する便利な方法を見つけました。 - キャンバスの描画をrequestAnimationFrameループに転送します。
レンダリングをアニメーションループに転送したおかげで、実際のレンダリングを遅らせながら、 ArrayBuffer
へのWebソケットの更新をすぐに記録することができました。 フレーム間で到着するすべてのWebソケット更新(約16ミリ秒)がバンドルされ、同時にレンダリングされました。 requestAnimationFrame
おかげで、描画に時間がかかりすぎた場合(16ミリ秒より長い場合)、キャンバスのリフレッシュレートにのみ影響を与えます(ブラウザー全体のパフォーマンスを低下させません)。
キャンバスの相互作用
ユーザーがシステムと対話するのをより便利にするために、キャンバスが必要であったことに注意することが重要です。 主な相互作用のシナリオは、キャンバスにタイルを配置することです。
しかし、各ピクセルを1:1のスケールで正確にレンダリングすることは非常に難しく、エラーを回避することはできません。 そのため、ズームが必要でした(大きな!)。 さらに、ユーザーはキャンバスを簡単にナビゲートできる必要がありました。ほとんどの画面ではキャンバスが大きすぎるためです(特にズームを使用する場合)。
ズーム
ユーザーは5分ごとにタイルを投稿できるため、配置エラーは特に不快です。 タイルが十分に大きく、適切な場所に簡単に配置できるような倍率のズームを実装する必要がありました。 これは、タッチスクリーンデバイスでは特に重要でした。
40xズームを実装しました。つまり、各タイルのサイズは40x40でした。 <canvas>
を<div>
にラップし、CSS transform: scale(40, 40)
を適用しました。 これはタイルを配置するための優れたソリューションでしたが、ボードの表示が難しくなりました(特に小さな画面で)ので、2段階のズームを行いました。
CSSを使用してキャンバスをスケーリングすると、ボードを描画するためのコードとスケーリングのためのコードを簡単に分離できました。 しかし、このアプローチにはいくつかの欠点がありました。 画像(キャンバス)をスケーリングする場合、ブラウザはデフォルトで画像平滑化アルゴリズムを適用します。 場合によっては、これは不便さを引き起こしませんが、単にピクセルグラフィックを破壊し、せっかくの混乱に変えます。 良いニュースは、CSSプロパティimage-rendering
があることです。これにより、ブラウザにアンチエイリアスを適用しないように「求める」ことができました。 残念なことに、すべてのブラウザがこの機能を完全にサポートしているわけではありません。
ズームぼかし:
これらのブラウザでは、別のスケーリング方法を見つける必要がありました。 前述したように、キャンバスに描画する方法は3つあります。 最初のdrawImage()
は、既存の画像または他のキャンバスのレンダリングをサポートします。 また、レンダリング中の画像の拡大縮小もサポートしています(増加または減少)。 この増加には、前述のCSSと同じぼかしの問題がありますが、 CanvasRenderingContext2D.imageSmoothingEnabled
フラグを削除することにより、ブラウザーサポートの観点からより普遍的な方法で解決できます。
そこで、レンダリングプロセスに別のステップを追加することで、キャンバスのぼかしに関する問題を解決しました。 これを行うために、別の<canvas>
を作成しました。この<canvas>
サイズと位置は、コンテナ要素(つまり、ボードの表示領域)と一致します。 新しいキャンバスでdrawImage()
を使用してキャンバスを再描画した後、表示されている部分が目的の縮尺で描画されます。 この追加手順によりレンダリングのコストがわずかに増加するため、CSS image-rendering
プロパティをサポートしないブラウザーでのみ使用しました。
キャンバスの動き
キャンバスは、特におおよその形式の、かなり大きな画像です。そのため、キャンバスを移動できるようにする必要がありました。 画面上のキャンバスの位置を調整するために、スケーリングの場合と同じアプローチを使用しました。CSS transform: translate(x, y)
を適用した別の<div>
で<canvas>
をラップしましたtransform: translate(x, y)
。 別のdivのおかげで、キャンバスに変換を適用する順序を簡単に制御することができました。これは、ズームを変更するときに「カメラ」の動きを防ぐために必要でした。
その結果、「カメラ」の位置を調整するさまざまな方法のサポートを提供しました。
- 「クリックしてドラッグ」(クリックしてドラッグ、またはタッチしてドラッグ);
- 「クリックして移動」(クリックして移動);
- キーボードナビゲーション。
これらのメソッドはそれぞれ異なる方法で実装されます。
クリックしてドラッグ
これが主なナビゲーション方法です。 mousedown
イベントのx
座標とy
座標をmousedown
ました。 これらの各イベントについて、初期位置に対するマウスカーソルの位置の変位を検出し、この変位をキャンバスの既存のオフセットに追加しました。 カメラの位置はすぐに更新されたため、ナビゲーションは非常に反応が良かったです。
「クリックして移動」
タイルをクリックすると、画面の中央に配置されます。 このメカニズムを実装するには、「クリック」と「動き」を分離するために、 mouseup
イベントとmouseup
イベント間の距離を追跡するmousedown
ありmouseup
。 マウスが移動した距離が「移動」と見なすのに十分でない場合、「カメラ」の位置は、マウスの位置と画面の中央のポイントとの差に基づいて変化します。 以前のナビゲーション方法とは異なり、「カメラ」位置は平滑化機能を使用して更新されました。 すぐに新しい位置を設定する代わりに、それを「ターゲット」として保存しました。 アニメーションサイクル(キャンバスの再描画に使用されたものと同じ)内で、スムーズ機能を使用した「カメラ」の現在の位置がターゲットの近くに移動しました。 これにより、急激な動きの影響を取り除くことができました。
キーボードナビゲーション
キーボードの矢印またはWASDを使用してキャンバスをナビゲートすることができました。 これらのキーは、内部モーションベクトルを制御しました。 どのキーも押されていない場合、デフォルトのベクトルには座標(0、0)があります。 いずれかのナビゲーションキーを押すと、 x
またはy.
1が追加されますy.
たとえば、「右」と「上」を押すと、ベクトルの座標は(1、-1)になります。 次に、このベクトルをアニメーションループ内で使用して、「カメラ」を移動しました。
アニメーション中に、次の式を使用して、近似のレベルに応じて移動速度が計算されました。
movementSpeed = maxZoom / currentZoom * speedMultiplier
ズームをオフにすると、ボタンの制御が速くなり、はるかに自然になりました。
次に、動きベクトルが正規化され、動きの速度が乗算され、「カメラ」の現在の位置に適用されました。 正規化は、対角線と直交の動きの速度が一致するように使用されました。 最後に、動きベクトル自体の変化に滑らかさ関数を適用しました。 , «» .
iOS- Android- . -, , . -, , OAuth: WebView . OAuth JS- WebView. . API:
r.place.injectHeaders({'Authorization': 'Bearer <access token>'});
iOS , . WebView, . , iOS 8 JS-:
webkit.messageHandlers.tilePlacedHandler.postMessage(this.cooldown / 1000);
.
-
. , . . . , , - . そう?
. r/Place :
. , . , . , , :
, , . «» ? , Place , . , , Live- . , , , . .
, : – . .
:
, . , production- RabbitMQ, -, , reddit.com. . .
, . - , , Rabbit Diamond collector . , , -, , , Rabbit, . – .
, :
$ cat s****y_diamond.sh #!/bin/bash /usr/sbin/rabbitmqctl list_queues | /usr/bin/awk '$2~/[0-9]/{print "servers.foo.bar.rabbit.rabbitmq.queues." $1 ".messages " $2 " " systime()}' | /bin/grep -v 'amq.gen' | /bin/nc 10.1.2.3 2013
, - , : . - .
, :
. r/place/new:
, .
. , . , , . そしてまた。 そしてまた。 - , , , .
Place, , , « » . . , Fastly.
-
r/Place , . u/gooeyblob, u/egonkasper, u/eggplanticarus, u/spladug, u/thephilthe, u/d3fect , .