Feed UIに関する一考
(UIデザインは正直素人なので、良い資料やブログなどあったら紹介してほしい)
Multi-column Feed UI
「ヘビーユーザ」というほどではないがTwitterに結構張り付きがち
https://gyazo.com/43b0739ef050f757e298a909b84e92c5
Multi-columnのFeed UIである。どうしてこのUIが便利なのか
横長・高解像度のPCディスプレイと相性が良い。多くの情報を一覧できる
何なら3カラムくらいの運用(縦置きディスプレイなど)でも恩恵はある
従って、異なる情報を見に行くために「操作」が不要
優先度・情報量などによって並び替え・取捨選択する余地がある
一つのFeedの中でやりくりしなくて良いので、情報を「分類」しながら眺めることが自然とできる
Single-columnなfeed UIは、翻って逆の特性を持つ
横長・高解像度のPCディスプレイに当てはめると非常に無駄が多い。左右に広大な余白が生じる(i.e. Twitter)
Feed自体がコンテンツの中心とならないサービスにおける1要素としてなら余白を埋める余地はある
また、縦長、低〜中解像度のモバイルディスプレイには自然とマッチする
情報の「絞り込み」「検索」「コンテキストスイッチ」には常に操作が伴う
例えばTwitterの公式モバイルAppだと、メインのfeed/trends&search/notification/DMは別tabになっている
Muteなどによる最低限のフィルタリングはできるが、それ以上の分類・分割はできない
メインのFeedは一つしかないので、必然的にその中でユーザに提示する情報を最適化する方向性になる
chronological feed => algorithmically ranked feed
Friendsのliked tweetも出してみたりする
広告を容赦なく入れてきたりする
特に後半の内容に関しては、Multi-column feed UIを使っている限りはあまり出てこない発想というか、「余計なお世話」感が強かったり、「かゆいところに手が届かない」UIに倒れていっている印象になる
お察しのとおり自分はモバイルよりもPCなユーザ
「選択の自由」と「選択の容易さ」を美徳と見る
察するに、世の中がモバイルファーストになって、そちらに向けた最適化が第一義に躍り出たことによるものか
広告を挟みたいし、ちゃんと見せたい
同じ単一Feedでもtile(card)-based UIなど、モバイルでもそれなりに見せ方を工夫しているものはある
モバイル(スマホ)も、初期の4インチ前後から5.5〜6インチ前後へと大画面化している影響もありそう
とはいえMulti-columnをPCにおけるメインUIに据えているサービスはあまり見られないように思う
モバイルがsingle-column以外の選択肢がないという前提だと、Web版をmulti-columnにしてしまうと必然的に分岐開発となる。これが大負担であることは想像に難くない
Tweetdeck以外だと、懐かしきkato.imというチャットサービスが同様の理由で非常に素晴らしかったのだが、Slackに押されて露と消えた https://gyazo.com/d2313ce6f2d01e8d0403b9e74d8b31c8
ということで、PCユーザ向けに最適化したFeed UIとして、Multi-column UIを何らか推進できないものか
いくらWebのメインストリームがPCではなくなったとはいえ、モバイル志向のUIを押し付けられる状況はおかしくない?
Feedの類型と特性
ところで、"Feed"と捉えられるものにはいくつか種類があると思う
Social Network Feed
Twitter, Facebookなど
RSS Feed
Blog/News siteなどが発行する更新通知
理想的にはNotification APIを用いたpush型通知に代替されていくのかもしれないが、既存サービスとの容易なつなぎこみを可能にするインターフェイスではある
push型通知にいろんなものが集約されるとたいそう鬱陶しいのが困り物
仮に代替されたとしても、↓のNotification feedの対象にはなる
Notifications
Social Network/Chatにおけるメンションやそれ以外の何らかのリアクション(Retweet/Like/NewFollowなど)の通知
サービス運営者からのブロードキャスト
その他諸々のpush-yな何かを発生順序で並べた履歴
Chat
少し来歴において異なるのだが、Twitterなどとの境界線は曖昧なケースも多い
ちなみにWebやComputing分野での"Feed"といった場合の語義はOxfordだと、
A facility for notifying the user of a blog or other frequently updated website that new content has been added.
共通するのは、
大体の場合はchronological orderで並べられる情報(ある意味時系列データ)
テキストが中心だが、mediaも当然含むコンテンツ
Readonlyなものと、Writableなものがある
例えば、Twitterのtimelineや、Chatは、自分自身も書き込めると言う意味でWritebleなfeedとも言える
一方で、同じTwitterでもNotification feedになると、あくまでtimelineで発生した出来事などの中で、特定の条件に当てはまるもの(自分へのメンションや自分のTweetのRetweetなど)が集約されたデータ列であり、直接的に書き込むことはできないという意味ではReadonly. RSS FeedなどもReadonly
Feed内のアイテムに対し、Interactできるものがある
LikeやRetweet/ShareできるTweet/Facebook post、reactionをつけられるSlack postなど
Postの著者ユーザを新たにフォローする、逆にMuteするなども広い意味ではFeed内のアイテムに対するinteractionの枠にくくれそう
といったところか
Feedの増殖
前節で見たように、Feed型のUIで閲覧可能なWebコンテンツは多々あり、にもかかわらずサービスごとにUIが別個で、面倒くさい問題がある => Feedの増殖と呼ぼう
しかもコミュニティや組織によっては似たような用途で別サービスを使っているケースがこれも多々有って、やってることは同じなのに応対しなければならないサービスが増えることになる
Twitter, Mastodon
Facebook, G+
Slack, HipChat, Chatwork
Hangout, Skype, Discord
このFeedの増殖に問題意識を持っている人はもちろんいて、ソリューションもいくつかあったりする
旧kato.imチームが今取り組んでいるのはsameroom.ioで、これはchatが対象だが、好みのチャットサービスを1つ選んだ上で、他のサービスのフィードはそこにすべて流し込む、というもの 初期の頃に試しはしたが、シンプルに使いづらかった。今はもうちょいマシになってるかも
Franzは、Electronベースの統一デスクトップアプリで、チャットサービス等を一括管理しようというもの これは単に特定サービスのみ対応のWebブラウザと行った趣で、正直旨味が薄い
いずれもMulti-column UIを活かそうとする猛者はいない
何にせよ、Feedの増殖を上手に解消できるよう、UIの統一化とデータ生成源の抽象化を行って、PCベースの日常業務・Web閲覧を楽で便利なものにしたいという需要が個人的に存在する
個々のサービスの初期設定など、分岐が大きいであろう部分を完全に巻き取ろうとまでは思わない
利用するサービスがある程度決まっている前提下で、定常的な作業を楽にしたい
Feedと"Stock"
ここまでで述べた個人的なモチベーションを元に、weekend projectを動かし始めようとは思っているが、ここで一つ考えておきたい機能がある
"Feed"はstreamやflowといった言葉で表現することもある通り、「流れていくUI」、「流れていってしまうUI」になる
こういったUIはチャット的な会話や流し見コンテンツには合致しているのだが、そこで発生した議論や考察などのtextコンテンツの中には「流れていってしまうには惜しい」ものが時々ある
偶発的な発見でなくて、chat上でちょっとした会議じみたことをしたときにいわば「議事録」として会話コンテキストを残したいとか、決定事項を抽出したいとかもある
つまりTextコンテンツを後々簡単に読み返せるような形に「永続化」したいという要求の発生。"Stock"的サービスとの関連である
Feedアプリが便利に使えるためにはStockアプリと容易に連携できるのが良さそうだ。Stockアプリ・サービスの例といえば、
Wiki (e.g. Confluence, Scrapbox)
Knowledge Base Service (e.g. StackOverflow)
特定サービスに直接結びついたクリッピングサービス、まとめサイト(e.g. Togetter)
こうしたStockメカニズムをビルトインで搭載してもいいし(Bookmark機能に近い?),別サービスと統合しても良さそう
Stockされた情報はFeed UIとは完全に別のコンテキストで眺めたいことも多そうなので、全く別サービスとなっている方が都合が良いかもしれない
個人的にはこの用途で最近使っているのはScrapbox
一方で、Scrapboxには「同時編集」というまた別の重要機能、評価側面もあり、これによって会議やブレストのような用途との親和性が高まっている。これはまた別で追求したいテーマでもある
そもそも「流れ」が作られてしまうChatで議論や会議するのではなく、Scrapboxのような「場」を提供する仕組みの上で行うほうが良いこともある、ということ
もちろん、Feed => Stockという情報永続化の流れを全く陳腐化するものではないが
Feed内のitemの特性
設計・実装の前段階として、Feedを流れる要素、itemが持っていてほしい特性を列挙してみる (Feedそのものの特性は上述した)
Thread概念、Item間のDAGを形成できる
Itemを個別にクリックした場合の詳細画面で前後のThreadを読めるのでもよいし、Feed自体でThreadがglanceできるような形でItemが表示されるのでも良い(i.e. Twitter for iOS)
Threadに含まれるItemが、既存の何らかのFeed(Timelineなど)には含まれない(直接follow/friend関係にないユーザ由来の)ItemだったりすることはTwitterなどではままある。
するとThreadを展開するときにオンデマンドでThreadに属するItemを取りに行くという追加リクエストが発生する
それ自体はいいのだが、別の問題として、抽象化が難しそうではある。"Thread"に類するリソースがサービス側から提供されていて、API経由で所属するItemのリストや順序を容易に特定可能であればそれを使えばいいだろうが、そうでないようなFeedにもThreadの概念を適用したいと思ったらいくらか制限が発生しそう
たとえば、Threadの概念があるメッセージングサービスと言うと、TwitterやSlack(なんならEmailも!)が思い浮かぶが、それ以外のサービスでも「このポストに対する返信」という行為を明確化できるインターフェイスがあればDAGを形成できるので、Threadになりうる。しかし、サービス側で"Thread"という形で参照可能なリソースとして定義していないかもしれない。となるとこちら側でDAGを自前形成することになるが、DAGに所属するItemを再帰的に全て取ってこようとするとリクエストが爆発して困る
max_depthのような制限を設けるとか、すでに何らかのFeedを通してローカルに取得済みのItemのみ対象とするとかいった工夫が必要そう
Optionalだが、Timestampを持つ
サービスごとに自由なメタデータを持てる
(Threadの概念以外はどれも大した話じゃない気がしてきた)
Proposed Architecture
そんなわけで「ぼくのかんがえたさいきょうのMulti-column Feedアプリ」を作りたい
頭の中で考えているアーキテクチャは以下の図のような感じ
https://gyazo.com/e988bbe0843cc29ddf7a08991cc51c72
なかなか雑だが、文章にすると:
Producer/Broker/Consumerモデルを取る
複数のProducerを導入できる
Producerが生成するデータ(Item)には種別typeが設定される(予約語回避のためにkindなどとするのも良い)
同じくItemの生成元を表すorigin属性も設定される
Itemは何らかtimestampを持ってもいいし、持たなくてもいい
持たない場合は到着時刻で補うことになる?
同じTypeのItemを生成するProducerが多数あっても良い
e.g. Twitterなら、UserTimelineProducerやListProducerなどの種類が考えられる
「どのProducerを経由して取得されたか」という属性producersも持つ
この属性は値を配列で持つことになる(複数のProducerから取得されるかもしれない)
これはどうやら持つべきでない。以下の画像参照
https://gyazo.com/cff84224c57abf38289cb05c912262d4
Producerが生成するデータはBrokerに集約される
Brokerは流入するデータに一意・単調増加の通し番号idを割り当てて整列させる => 実装ではOffsetとなった
この番号はItemのtimestampとは独立に割り当てられる、あくまでBrokerとConsumerが使用する値
MongoDBのBSONObjectIDあたりを真似したい
ただしFIFO Queueというわけではない
残っているデータに限り、古い方から順に辿ることも出来る
任意のCursorポジションから辿ることも出来る
↓にある通り、ある程度古いものをガバっと削除できる
Brokerはデータを特定規則に基づいて重複除去(de-duplicate)できる
例えばTwitterから何らかのtweet feedを得るProducerが複数ある場合、複数のProducerが同じTweetを取得する可能性がある。これはBrokerがdedupして、保持するデータとしては単一にしてしまう
Dedupするための評価関数はProducerがtype(kind)ごとに提供する形になるはず
もしくは、dedupに使用する識別子をtypeごとに指定して、<type><dedup_property>のかたちで連結した値としてdedup用のHashSetのようなデータ構造に保持(Get/DeleteがO(log(N))以下なデータ構造であればいい?)
DedupするためにBrokerが保持しているデータ全てと突合するのはかなりコストが大きいはずで、dedup対象はある程度新しいデータに対してのみ適用、とかでもいいかもしれない
例えば、「直近1000件だけdedup、1000件より前に出現したことのあるデータはその後は再出現しうる」とか。Twitterはほぼこう
いずれにせよ効率的なデータ構造を定義・維持できる範囲で仕様を決めたい
Dedupはconsumer側で行うという案もある。Brokerとしては重複云々を考えず受け取ってしまい、consumerが独自のロジックで好きにdedupするというもの。
このほうが筋が良いかも? 「しばらく時間をおいてまたRTされてきたTweet」とかを表示するかどうか、など、consumerの性質に依存することは結構多そう
Brokerは無限にItemを保持し続けるわけではない。何らかの尺度で古いデータからEvictする
Evictされても稀な要求に応じて古いデータを取得できるよう、Write-backでfileなどに吐き出す実装は有りうるかもしれないが、WebAppでそこまでやるかは微妙
ローカル永続化はAPI制限回避のためとか、オフライン時動作のためとか、いろいろ便利に使えそうな余地はあるので将来的には考えたい
現実装ではIndexedDBにBrokerのスナップショットは保管している。が、Evictされたsegmentは永遠に失われるので、「古いデータをさかのぼって取得」できるのには限界がある
Consumer側(=Column)の状態もIndexedDBに吐いているので、Columnにある限りのものは遡れるが、Columnのデータ保持数にも多分上限を設けることになるとは思われる
ConsumerはBrokerにsubscribeするが、データはBrokerからのpushでなくConsumerによるpullで取得する
Consumerは要は単一のfeed columnにあたる
Consumerは「どこまでBrokerのデータを読み取り済みか」というcursor(=Consumer Offset)を持ち、個別独立に動作する
Columnの設定変更をしたときなど、cursorはリセットされ、Brokerを最古から読み取り直す
Consumerを新たに追加した場合、Brokerに残っている最も古いデータからすべて読むようになっていれば便利かもしれない。が、Columnの初期化に時間がかかるかもしれない
現実装ではこうなっている。Brokerがすでにたくさんのデータを持っている場合、確かにColumnの初期化にはまあまあかかるが、「データを古い方から読み込んでいる」感がはっきり出るので、UX上そこまで悪くはないかも
全体的に、要はKafkaである。なぜこのArchitectureが適していると思うのか? Column(Consumer)とProducerが一対一、もしくは一対多対応するナイーブな実装だと、Columnを増やすに連れて動作するProducerの数と、システム内に流入するデータ量が不必要に増える
Webアプリを想定しているため、流入・保管データ量はメモリ消費量に直結する。長期間に渡ってブラウザのタブで表示させ続けるWebアプリであるため(日常の様々なfeedコンテンツを一元化するわけなので)、できるだけ省メモリで、長期間起動に耐えなければならない
とはいえナイーブな実装はそれはそれで対応サービスが少ない間はシンプルでもあるが、こちらのほうがスケールするはず
Producer/Broker/ConsumerというレイヤリングとDe-dupによって、Producer稼働数/Broker内部のデータ量を必要最小限に保てる
Producer/Brokerと分離されていることで、Consumerは複数の生成元から流入するデータをオーバーヘッド無しで自由に組み合わせられる
特定の話題に関する内容なら、サービス問わず一つのcolumnにまとめて表示、とかできて便利
Broker
当然、Kafka的なBrokerをElm(client side)で実装するにあたっては、冗長化や分散処理などは捨象できる。となるとindex(offset)アクセスでき、かつ無限にデータをappendできるが、古いものはpurgeされていく(つまり記憶領域の長さに限界がある)buffer的なものになりそう
実際のKafkaはfile systemの特性を上手に使っているところが重要で、purgeのタイミングもfile sizeベースで設定できたりとか、永続化されていてもOSのfile cacheに乗ることによって高速に読み出せるとかいった要素が入ってくる。が、JS内部での実装であれば要はin-memory circular buffer implemented using arrayということになりそうである
Circular bufferであればArrayを使って実装できる
あとはメタデータとしてwrite pointerとread pointerを用意すれば良い。
Read pointerはここではConsumer offsetのことで、Consumerごとに持てる。ただし、古すぎる(すでにpurgeされた)offsetは、それに対応する要素が返ってくることは保証されない
APIとしては、要素をMaybeでラップしておいてもいいし、古すぎるoffsetが指定された場合は、現存する最も古いoffsetに読み替えてもいいかもしれない。ここでやりたいのは要は「ここまで読んだから次をくれ」であるので、「次」にあたるのが「ここまで読んだ」時点での次(現時点からすると古すぎるもの)か、現時点で最古のものであるかは読み出し側にはそもそも区別できないはずで、よって提供するBroker側がよしなにしてしまっても実害はないはず
Write pointerはBrokerの内部的な値として導入すれば良い。Consumerの状態を勘案する必要は基本的にはないので、circular bufferを一周してしまった場合、任意のConsumerのoffset (read pointer)を追い越してしまっても良いし、何らかの救済策を導入しても良い
例えば、segment(circular bufferにおけるbuffer slot)が上書き・削除されるまで若干猶予をもたせるとか。Write pointerが一周したら、bufferの先頭slotは「削除待ち」queueに入り、queue長分だけは削除猶予される、queue内のsegmentはreadは通常通り可能、とか。
ちなみに、circular bufferそのものではないが、固定長queue自体はListを使ってもできる。ナイーブ実装だと非効率だが、ListベースのDouble-ended queueを使うことでいい感じになる。
Broker API考察
作り始めてわかったこととして、結局emptyのケースとout-of-boundのケースが有りうるので、返り値はMaybeでくくることになりそうではある(そもそもArray.getのシグネチャがMaybeを返すという事情もある)
Fallback valueをinitialize時に指定させるような実装にすればMaybeを剥がせそうではあるが、あえてやらないほうがいい気もしている
落とし所としては、readFrom :: Offset -> Int -> Broker a -> List ( a, Offset )のようになりそう
渡すOffsetは読み出し開始位置、返ってくるリストの中のOffsetは要素(tupleの左値のa)に対応するもので、何らか処理の失敗があったら一つ手前のOffsetから読み直せば良い
Array ( a, Offset )でもいいのだが、Brokerの用途を考えるとListでいい気がする。パターンマッチも楽
Intはいくつ読み出すか(howMany)
一度取り出したデータはすでにConsumer側にListとして存在することになるので、途中のretryに際してはBrokerに再度問い合わせる必要はない
Broker aが返らないのはちょっとしたミソ。Brokerは本質的にcircular bufferで、かつread pointer (Offset)はconsumer側が管理するので、読み出しを行った際にBroker側の状態は変化しない
ただ、Broker/Consumerという関係性を考えると、Offsetの値を取り出す→値を使ってなにか処理する→成功したら次のOffsetに進む、失敗したら今のOffsetをretryする、というワークフローになるので、APIはこれを前提としたiteratorっぽいものになるのが正しい道か?
そうすると上のreadFrom(i.e. bulk read)はイマイチに見える。まあdebug用途に、このようにデータを慣れたListにdumpするAPIはあっても良さそうだが。
後述する取得済み要素の事後更新を契機としたviewの更新を考えると、すべての要素に対応するOffsetをconsumer側に通知しておかないといけないことになるので、返り値はList ( a, Offset )か
howManyを指定してある程度の量を一気に取得し、それをConsumer側がこねくり回すというのはメモリ効率が悪い
Brokerの内部実装であるArray aからリクエストに基づくsliceを取り出してList aに変換した時点でcopyが発生するはずで、このcopyは当該Brokerを購読中のconsumerごとに異なるoffset rangeに対応して複数存在できてしまう。処理が完了してList aが使用されなく慣ればそのうちGCされるはずだが、不要な変換は避けられるなら避けたい
ただし、結局の所consumerごとに個別のデータ空間を持つことになりそうではある。後述
この前提だと、データは初めBrokerの中にのみ存在し、consumerは要素を1つ1つ処理していくのが良さそうに思える。なぜならBrokerは要はArray aなのでいかなるoffsetの要素にもO(1)アクセスできて読み出し効率が最高だからだ
APIとしては、read :: Offset -> Broker a -> Maybe ( a, Offset )
最後のOffsetは、なんらか値が取得できた場合(Just)は取得した値の次のOffsetとなる。
一方取得できなかった(新しいデータがまだ入ってきていない, or Brokerが空である)場合は、同じOffsetを次回も使えばいいので新しいOffsetを返す必要はなく、そのため値自体と次のOffsetはtupleでカップリングされる
前節にあるように、入力したOffsetがすでにevictされているような場合は、oldest itemが読み出されてOffsetが繰り上がる可能性もある
入力したOffsetが現在のwrite pointerよりも新しい(先走っている)という可能性は原理的にはありうるが、Offset自体はopaque typeとすることになりそうなので、正しくない値をユーザがforgeできるということはない。Brokerのreconfigurationを可能とする場合はこの状況が発生してしまうかもしれないが、「先走っていた場合は現在のwrite pointerで読み替える」でいい気がする
現実装ではこのreadAPIに落ち着いたが、実際にconsumeする際には流石に1個ずつだとあまりにオーバーヘッドが大きく、各Consumerがそこそこアイテムの入っているBrokerをoldestからconsumeしようとすると必要なサイクルが多すぎることになる。ということで、1サイクルごとにある程度まとまった量繰り返してreadするヘルパを間に噛ましている。Broker package自体にこのヘルパを入れてもいい(= つまりreadFrom的なものをやっぱり導入することになる。howManyは呼び出し側が節度を持って設定する)
実際今回作ろうとしているElmアプリケーションでは、consumerはUI中のカラムに対応することになる
カラム(Consumer)の設計
ナイーブに考えるとカラムが持つ状態は要はListで、先頭要素=最も上の表示アイテム、となるはず(ひたすら伸びていく場合)。この場合はBrokerからの読み出しがone-by-oneでも、Listの先頭に処理結果(=表示させるべきitem)をconsしていくだけなので問題なさそう
直ちに思い浮かぶこととして、複数のカラムに、例えば同じtweetなど、同一itemが重複存在する可能性は考えておくべきである。Dedup時のロジックと似たような何らかIDなどをkeyとしてHtml.Keyedを使うとか、あるいはOffsetをkeyとして使っても良さそう
ただしHtml.Keyedは完全にListベースでしか扱えないことには注意が必要
後述するが、無限スクロールなどを考えるとArrayでもったほうがいい可能性がある
TEAではすべてのviewはModelからの射だが、Kafka的アーキテクチャにおいて、consumerは「唯一の正たるデータ空間であるBrokerからの射」ではなく、別個の(個別の計算結果を保管している)データ空間と見ることになる。ここに少しギャップがある
データの二重管理はできるだけしたくないが、一方でTEAの毎サイクル(= updateによるModelの更新+view再評価)でBrokerの中身をすべてconsumer関数に再適用することはしたくない(Kafka的アーキテクチャの利点を完全にスポイルする)
雑に考えると、Brokerから要素をひとつずつ取り出して評価&VDOMを生成したあと、生成済みのVDOMを保持しておいて、次回のconsumer処理の際にはこのVDOMに対しインクリメンタルに要素を継ぎ足すようなことをしたい。が、ElmではVDOMの内部状態はexposeされないし、再計算時の入力として使うこともできない(Server-side Renderingがまだできないこととも少し関係する)
となるとどうすべきか?データはあくまでBrokerの中にのみ存在する仮定だと、あるカラムが表示対象とする要素をList Offsetとして保持しておき、Brokerから対象を取り出してきて描画する、というワークフローが一つ考えられる。この方式はあくまでデータはBrokerのみに存在し、更新もBrokerに対して適用すればいいだけなので話が早い。
評価済みのデータに関してはtakeByOffsets :: List Offset -> Broker a -> List aのようなdump APIを経由して取り出すことになりそう。内部的には、連続する区間の値の取り出しはArray.sliceが高効率なので、List Offset -> List ( Int, Int )のような変換を内部的に行い、Array.sliceの結果をconcatするといった最適化が考えられる
が、いまいち王道感がない
本来のKafkaの動作イメージに近づけて、(1)Brokerは比較的小さなbufferとして、(2)consumerごとにList ItemもしくはArray Itemを別で保持するというのも考えられる。Consumerごとに正となる永続化層があるイメージ。実際のKafkaではこの部分はconsumerごとにfileだったりDBだったりElasticsearchだったり、あるいはオンライン処理や集計処理しかしないconsumerだったり、となる
カラムごとに表示する内容が全く重複しないならこれでも良いが、重複はまず間違いなく存在するのでそれが悔しい。
重複があると、あるカラムでアイテムの更新が発生した場合に、同じアイテムを保持しているかもしれない別のconsumerに対して通知を行うのは面倒で、筋が悪い(下手すると、連続して同じアイテムに対する更新処理が走り、どの結果が最新か調停しなければならないなんてことも起こりうる)
が、総合的にこの方式が丸いような気がしなくもない。Itemの更新処理を導入するなら、所属する単一のconsumer(カラム)と、上流であるBrokerへの適用に留めるというサボりが落としどころか。
カラム間での重複は多少あっても、すべての要素があまねく全カラムに渡ってコピーされるほどではない、という前提は直観的に成り立つ
Brokerはそこそこのペースでevictが発生してよいが、consumerごとのArray Itemの保持量はconsumerごとに決めていいものとする。長期間保持してもいい
IndexedDBなどに永続化しても良い
現実装では全てのColumnが全期間永続化される
翻って、Brokerがあまり古いデータを保持しなくなるので、無限スクロールのようなことはしづらくなる。どこかで諦めは必要
現在の仮定を図にすると、
https://gyazo.com/4b0b1abbb1eab5240f07ccd9ec61e0ff
通常ケースでは恐らくカラム間のデータ重複はそこまで激しくないので、カラムごとにBrokerから取得したデータを分散保持することをそこまで忌避しなくても多分大丈夫
Broker=> カラムというデータの流れ、つまりconsumerによるBrokerからのデータpull・評価・カラムのデータ空間へのcopyが発生して、viewは各カラムのデータ空間からの射となる
カラムが無限に伸びてほしくない(長さを制限したい)場合や、最下部までスクロールダウンしたらより古い要素を読み出して加える(無限スクロール)、といった柔軟なappend/prepend挙動を実現したい可能性を考えると、カラム個別のデータ空間は恐らくArray Item的なものとなる。ベンチによるとpushが高効率、appendもまあ許容範囲なので、無限スクロールにも対応できそう このデータ空間は適当なretention periodもしくはbuffer sizeをもち、データが溜まりすぎたらpurgeするが、このときIndexedDBなどに逃すことが考えられる。無限スクロールにより古いデータが要求されたらIndexedDBへの問い合わせが走る
このデータ構造はBrokerとはまた別で抽象化してもよさそう
データ更新
Broker、あるいはカラムのデータ空間に保管されているデータが何らか更新される場合をどう扱うか
たとえば、tweetというitemならretweet_countとかlike_countみたいな、初回取得時から経時で変化しうるプロパティをもっているはず。これを効率よくUIに反映させるにはどうするか?
アイデアとしては、このような「初回取得時以降の更新」は自動的には行わず、何らかのユーザによるアクション(マウスオーバーやクリックなど)を契機としてオンデマンドで行うようにして最小限にとどめるのが良さそう。Itemの種類ごとにコールバックを提供することはできるだろう(なくても良い、つまりNoOpでも良い)。
コールバックが実行されたら、すでに前節で検討したように、当該カラムが保持するitemの情報と、まだevictされていないならBrokerにある情報とを両方更新したいはず。そのためにはBrokerのOffsetは不可欠、となるとやはり大本のBrokerが発行するOffsetをアプリケーション内におけるitemのglobal識別子とすべきか
Brokerは更新もO(1)でできるので、コールバックの実行結果に基づく更新は単にやれば良く、それ以降に導入されたconsumer(カラム)は新しいデータを読むことになる。
一方、すでにそのデータを一度読んだことのある別のカラムについてが問題で、前節で議論したように諦めるのも一つの手
自分の投稿にLike/Reactionがついたとか、share/retweetされた、といったイベントをタイムリーに見たいというのは当然のユーザ欲求だが、websocketなどでdownstream eventを受けられない限りは簡単ではない
Discordの場合単にメッセージを適当にAPI経由で再取得して更新するくらいしかなさそう
一般化するとしたら、カラムの先頭近くにあって、ユーザに明らかに見えていると思しき要素については定期更新処理を行い、ある程度古くなったらアクション契機での更新のみにする、とかになりそう
Repositories
関連して作ったものなど
Elmパッケージのdocker-based CIに最適なdocker image
0.19対応中
ElmのArrayをベンチマーク
0.19でだいぶ変わったはず
Broker。API的には一応動くものがすでに出来てるが、多分Arrayの使い方に改善の余地がある。
Publish済みだが、ベンチはまだ
アプリ作りはじめ
Elm-UIにツッパすることにした。感触は良い