這應該是這系列最後一篇了吧, 雖然回頭看, 可能有些漏寫, 不過, 以後想到再補吧, 如果還沒看過前兩篇, 可以再複習一下: 概念篇,多服務彙整

這篇主要要著眼在如何做Timeline這東西(怎又突然把它叫Timeline了? 好吧, 複習了一下很多文章, 發現這還是比較通用的名詞)

先借用一下以前跟老闆報告時, 老闆問的

"做這很難嗎?"

這不是啥記恨啦(雖然我還蠻有印象這問題跟我的回答的), 而是開始做這個的時候, 我也覺得應該沒什麼難的, 不過, 實作這個功能本身的確不難, 倒是要讓它可以擴展(scale)這件事, 的確會比較麻煩, 也不止一個方法做, 先從基本來看一下

什麼構成一個Timeline (Social feed)

Twitter和Facebook主要把這東西分為兩類 - User timeline和Home timeline

User timeline主要就是單一使用者的近況更新, 也就是所有的內容都是由那個使用者產生, 並以時間排序(不然怎叫Timeline), 這部分倒沒什麼困難的, 因為它就是單一個人的流水帳(從軟體的角度也可以說成他活動的”log”)

Home timeline包含的則不是單一個人的, 而是包含你關注的人所有的動態, 在Twitter就是你follow的人, 而Facebook則是你的朋友加上你關注的人(follow)和粉絲頁, 講”follow”其實蠻貼切的啦, 有點偷窺(光明正大吧?), 又有點跟蹤狂的感覺, 但現今的Home timeline大多(尤其是Facebook)不是用時間排序, 好像也不能真的叫Timeline (ㄠ回來了, 這樣我叫Social feed好像比較貼切 XD)

假設我follow了user1, user2, user3等三個人, 那我的Home timeline就會變成這樣:

Ok, 其實這有點像前一篇講的多服務彙整那種, 不同的是, 這些feeds是來自於同一個來源, 並不是多個不同的服務, 比較沒資料異質性問題

1 9 90 理論

在切入實作面之前, 先提一下這個理論, 這邊有Mr. Jamine對這個的解釋: “網路內容的 1/9/90 定律” (他用”定律”, 但定律是比較恆常的, 但這比例並不是那麼的絕對, 所以我比較覺得用理論或假設比較適合)

這理論說的是, 大約有90%(甚或以上)的人是屬於讀者, 9%的人會參與進一步互動(比如說按讚或回文), 只有1%的創作者(你可以看一下你自己是屬於哪類的人), 根據我的經驗, 讀者可能會多於90%, 創作者甚至可能少於1%

那對於開發者來說, 知道這些有什麼用? 我個人是覺得一個開發者或是架構設計者, 必須要清楚暸解所做的東西所會產生的行為才能產出一個好的架構, 以這個來說, 如果要設計一個高度可擴展的架構的話, 我們可知道, 絕大部分的request其實都是讀取(read), 高併發(highly concurrent)的寫入機會並不大(反而比較容易發生在like, comment)

比較直覺的實作方式

好, 難免的, 我一開始也是選用這種方式 - 都交給資料庫(database), 這邊的資料庫, 不管SQL或No-SQL, 差不多原理啦, 雖然說針對Feed這種time squences看起來像比較適合No SQL, 但Facebook不也是用My SQL(雖然用的方式比較是key-value的方式)

依照前面的說法, 我們可以簡單的假設有兩種資料 - 使用者(User)和內容(Feed)

  1. 使用者可以跟隨(Follow)其他使用者(這邊引用Twitter的設定), 因此每個使用者會有n個”follower”
  2. 使用者可以發文(Feed/Post), 每則發文都有(只有)一個作者(author)
  3. 每個使用者的Homeline是由他跟隨的所有人的發文所組成
  4. 每次client來要求homeline最多給m則(比如說25則)

按照這樣的說法, 我們可以想像Query或許長得像這樣(No SQL版本自行想像):

SELECT * FROM FEED WHERE AUTHOR IN (SELECT FOLLOWERS FROM USER WHERE ID='myid') ORDER BY TIME LIMITS m

這邊暫時先省略掉query出來你還要再query出user大頭照跟人名的部分, 但加上這部分每次至少要有兩次queries

這樣不就搞定了? 有什麼問題? 有, 後面就會撐不住了! (切身之痛), 先來看看什麼問題

查詢效率

從上面的query來說, 它還包含了個sub query去取出所有的followers, 所以這整串在資料庫裡可能的做法是, 把所有相關使用者的feed取出, 在記憶體中排序, 取前m個, 前面有提到, Feed就像流水帳, 全部的人加起來可能不少, 這聽起來就像是耗時耗CPU的查詢

因此在兩個狀況下就慘了:

  1. 讀取高併發時
  2. 使用者follow了”一大堆”人!!!

關於第一點, 這很容易發生呀! 90%的人一天到晚窺探…ㄟ …關心…人家在幹嘛, 當一堆這些queries湧入, 資料庫會非常忙碌的, 因為沒有不同的兩個人會follow同樣的人, 根本無法cache

第二點其實更慘了, follow個幾個人還好, 幾十個人還搞得定, 偏偏這個社群網路時代的, 幾百人是標配, 上千人的也不少, 如果有上萬, 可能更跑不動了(Facebook限制你只能交5000個朋友, Twitter超過5000也是選擇性的讓你少量follow, 所以上萬目前應該還比較少見)

Materialized View

有些資料庫, 像是MySQL, Postgresql, Cassandra (3.0+)都有支援Materialized view, materialized view就像是一個query的snapshot, join的部分是發生在create或是refresh時, 因此用來解決讀取高併發可能是可行的, 因為讀取時只有單純的query, 直到有更新時再呼叫refresh

但對於更新比較頻繁, 比較熱門的social network service, 資料庫的負擔還是不算小

Sharding

如果以時間為基準來做sharding, 或許可以解決這兩個問題, 因為不是所有的人不時都在更新狀態, 所以在含有最新的shard裡面包含的可能只有少數人的feed, 這減少了遍訪所有人的feed的工, 而且不用排序所有的feed

但還是有幾個問題:

  1. 無法join, 如果根據feed的時間去做sharding, feed跟user就不見得在同一資料庫, 這樣就無法join了
  2. 邊界問題, 有可能你需要的資料剛好就在時間間界的附近, 導致一開始query不到足夠的資料

其實上面問題寫程式解決都不是問題啦, 這邊想說的只是, 沒辦法以一兩個簡單的queries就搞定了

大家都怎麼做

這邊講的”大家”就那些大咖囉, Facebook, Twitter, Pinterest, Tumblr …等等, 關於這個問題, 其實Yahoo曾經出過一篇論文:

“Feeding Frenzy: Selectively Materializing Users’ Event Feeds”

如果沒耐心看完論文(我也沒耐心), 這邊先簡單提一下兩種模式:

  1. Push model
  2. Pull model

Push model又被稱為Megafeed(根據某場Facebook的分享), 而Pull model則是Facebook使用的Multifeed, 其實不管哪種模式, 大多不是直接存取資料庫增加資料庫的負擔, 而是大量的應用快取(cache), 像是Memcached, Redis等等

簡單的來說, Push model在整合feed的時間發生在寫入, 而Pull model則是發生在讀取

Push model (Megafeed)

這是Twitter所採用的方式, 也是我以前採用過的做法, 我自己則是把它稱為Inbox model, 比較詳細的內容推薦可以參考Twiter的:

“Timelines at Scale”

先來看看他們這張架構圖

Home timeline的部分主要是中間的那個流程, 這做法比較像是E-mail一樣(所以我才稱它為inbox), 當使用者發表了一則新的動態後, 系統會根據有訂閱這則動態有那些人(也就是follow這個使用者的人), 然後把這則動態複製到各個訂閱者的Home timeline (Inbox)上

這方法的優點是, 對於讀取相當之快, 因為Home timeline已經在寫入期間就準備好了, 所以當使用者要讀取時, 不需要複雜的join就能取得, 在做pagination也相當簡單, 因為本來就是依時序排下去的

但缺點是, 很顯而易見的, 非常耗費空間, 因為每個timeline都要複製一份, 假設你被上千人follow, 就要上千份, 因此Twitter只有存ID和flag, 詳細的內容跟Meta data, 是後來才從cache去合併來的, 另外Twitter也只存了最近的八百則, 所以你不可能得無窮無盡的往前滑

另一點就是耗時, 這種寫入通常是非同步的, 使用者發布動態後, 他只知道他動態發布成功了, 但系統還需要在背後寫到各個Inbox中, 因此他不會知道別人其實可能還看不到的, 對於一個follower數量不多的不是問題, 但如果像是Lady gaga那種大人物, 有幾百萬粉絲, 那就是大問題了! 寫入幾百萬的timeline即使只寫入memcached也是相當耗時的事, 而且這會產生時間錯亂的問題, follower比較少得很快就做完了, 所以很容易看到比較熱門的人物的貼文比較晚出現

Twitter是把follower多的人另案處理, 也就是讀取時段再合併(那就是類似下面要講的multifeed了), 這樣可以省下一些空間跟時間, 另一種可行的做法(我們之前的做法), 就是不寫到所有人的timeline, 而是只cache最近有上線的人的timeline, 這樣就算Lady gaga有幾百萬粉絲, 實際上最近才有上線的可能才幾十萬或更少, 處理這部分就好了, 如果cache裡面並沒有現在上線這個人的timeline, 就在從資料庫讀取合成就好

不過總歸來說, 這方法讀取快, 但寫入慢, 耗費空間, 較適合讀比寫多上許多的應用

此外其實也有不同的變形, 像是Pinterest:

Building a smarter home feed

Pull model (Multifeed)

Facebook採用了一個完全不同的方式, 叫做Multifeed, 這方式從2009開始在Infoq就一直被提到:

  1. Facebook: Science and the Social Graph 2009, by Aditya Agarwal
  2. Scale at Facebook 2010, by Aditya Agarwal
  3. Facebook News Feed: Social Data at Scale 2012, by Serkan Piantino (Aditya Agarwal這時候應該跑到Dropbox去了)

這跟Push model有什麼不同? 其實說起來跟前面一開始用DB的方式比較像, 就是在讀取時, 才取得所跟隨的人的feed, 合併並排序, 但這樣不是讀取很沒效率嗎?先來看看圖:

  1. 在寫入時, feed資料只會寫入”一個”leaf server, 應該是根據user去分流的
  2. leaf server主要是memcached, 所以都是in memory的
  3. 在memory裡面不可能保存所有動態, 只會保存最近一段時間的 (所以不可能包含所有人所有的動態, 在做整合時就輕鬆多了)
  4. 前端跟Aggregator query後, Aggregator會去跟”所有”的leaf server問所有相關的人的feed再回來整合

因為資料存儲跟處理都在memory, 所以可以很快, 但還是要考慮到網路的部分, 因此leaf server跨區的話效率就不會高了, 自然空間需求會比Pull model來得少, 但home timeline的讀取時間就較長了(因為是read time aggregation的關係),也不會有名人問題, 不會因為follower多, 複製耗時耗空間 另一個優點是, 排序的方式控制在Aggregator, 因此很容易立刻更動規則, 不像pull model, 當home timeline組好後要去變動它就較麻煩

混搭風

當然沒有絕對的好壞, 兩種模式各有優缺, 所以也有人採用的是混合模式, 根據使用者使用頻率來決定, 這就跟穿衣服一樣, 每個人怎搭衣服都是不一樣的, 端看你要怎混搭

REST API的問題

在前面一篇多服務彙整裡有提到REST API都是輪詢(polling)的模式, 不管資料有沒更新, Client都是會常常來server查詢資料, 這對server可能會是夢靨, 因為只有1%在努力創作, 所以搞不好有很大量的查詢都是浪費的, 而這些查詢通常是造成系統多餘負擔的元兇

關於這問題, 我有兩個想法, 不過都還沒實際去實證過

  1. 增加HEAD的API, 大部分REST API是以GET直接抓取資料, 所以針對個別資源(Resource), 應可實作HEAD, 讓Client在實際去查詢資料前先確訂一下資源的更新時間, 資源的更新時間在資料更新時就可以放在cache內了, 相對的可以省傳輸的數據量跟處理時間
  2. 利用PUSH, 現在大部分的應用都在手機上, 也大多有實作PUSH, 當有資料更新且App在前景時, 利用PUSH通知有資料更新, Client收到後才會真的去抓取, 不過這比較起來感覺相對負擔較重

另外這篇也是值得去參考(只是這個還要帶入XMPP):

Beyond REST?Building data services with XMPP PubSub

出去放空玩一陣子了, 也該接下來整理一下剩下的東西了, 這篇主要要來談一下彙整式的social feed (aggregation feeds), 這功用是什麼呢? 由於現代人擁有了很多社群網路的帳號, 但如果要一個個網站或App開著看才能看到所有的動態, 未免太累了, 因此變有這種彙整式的服務出現, 讓使用者在一個地方可以看到所有的社群動態

這種形態的應用, 有幾個有代表性的, 前一篇的概念篇裡所提到的Friendfeed, 還有就是Flipboard, 另外就是HTC的Friend streamBlinkfeed (私心提這兩個我所參與過的)

Friendfeed Flipboard

前兩者跟後兩者的差異是在於, 前兩者在彙整social feeds是在server端, 所以client僅需要從server抓取彙整過的資料下來就好, 複雜度在於服務端並不是在client app, 而Blinkfeed跟Friendstream的差異點則是在Friendstream整合了跟社群網路相關的動態, Blinkfeed則是多了新聞的部分, 後來實作的方式為了有更多的彈性, 底層架構的部分也有所改變, 這篇會稍微提到如果是在Server端實作的一些可能做法, 但主要還是著重在client端實作的問題

做這樣一個東西, 不就是去呼叫各社群網路的API抓資料, 回來全部混在一起就好了? 如果單純只是實作一個”可以用的”, 那這樣可能就可以了, 但實際上還是有些問題存在, 如

  1. 各家API雖類似但個家還是有很多不同
  2. 各家server回應時間不盡相同
  3. 資料時間線交錯問題
  4. 資料更新問題
  5. 網路流量問題

在看這些東西之前, 先來看看各家API的部分

Social Feed API

很多社群網站都有提供公開的API讓你去獲取使用者的Social feeds, API定義各家各有不同, 但特色都一致的, 也就是大家都是以REST API設計為主, 採資料拉取(Pull)的方式, 分頁(pagination)的方式

REST, Polling, and Pagination

因為採用了REST API的定義方式, 所以資料傳輸上大多(幾乎是全部了啦)以JSON為主, 用REST + JSON的好處是簡單, 彈性, 資料也比較適合閱讀, 不過, 別騙人了啦, 你有多少次會直接去閱讀JSON, 除非你要做啥hacking, 這連帶的也有人在實作client直接把JSON資料拿來儲存或暫存, 不過, 帶來的壞處是, 解析JSON其實並不是很經濟(後面會再稍提一下)

前面也有提到, 這樣的API設計是以”拉取”(Pull)為主, 也就是server並不會主動給你最新的資料, 而是必須你的client自行呼叫API去取得, 因此要隨時保持有最新的動態, 必須要不斷的輪詢(polling) server, 這其實也是相當不經濟的做法, 就算在熱門如Facebook, Twitter, 使用者的Social feeds不見得隨時會有最新的動態, 因此多久的輪詢間隔才是最好的, 這會是一個很頭痛的事, 太過頻繁易造成浪費, 也會造成server的負擔, 太久則會造成使用者看到的動態並不總是會是最新的, 其實少數像是Twitter, 有提供所謂的Streaming API, 這種就不是以拉取為主, 而是server會主動更新資料

一般來說, 社群網站上面”跟隨”(Follow)了越多人, 看得到的動態越多, 總不可能每次抓取資料就從頭一筆給到最新的一筆, 這樣的話, 不但花時間, 浪費流量, 也增加了server的負擔, 所以絕大部分的API, 都是從最新的往回給一定數量(比如說25則)的內容, 這樣稱之為一頁, 因此如果使用者需要往回捲回之前的資料, 就再抓取再前面一個分頁, 這樣的設計就是分頁(Pagination), 分頁的問題點在於, 社群動態的最頂頭的部分常常會再有更新的動態加入, 導致分頁會整個位移, 像下圖, 這樣會導致client有可能抓取到重複的資料, 甚至時間線錯亂

Twitter API

先來看看Twitter API的例子, 這邊就有很詳細地解釋前述的Pagination的問題, 並且講解如何用max_id和since_id來解決這一問題

Twitter在這部分經驗豐富, 他們用的解法是以”id”, 有些社群網站的since會用”時間”, 用時間是不好的做法, 因為就算你時間記錄到微秒, 還是很有可能有兩則以上的動態可能是相同時間的, 這樣錯亂的問題一樣存在

Twitter在抓取使用者的social feed用的API是“GET statuses/home_timeline.json”, 回傳的資料是一個Tweets的陣列如下:

[
  {
    "coordinates": null,
    "truncated": false,
    "created_at": "Tue Aug 28 21:16:23 +0000 2012",
    "favorited": false,
    "id_str": "240558470661799936",
    "in_reply_to_user_id_str": null,
    "entities": {
      "urls": [

      ],
      "hashtags": [

      ],
      "user_mentions": [

      ]
    },
    "text": "just another test",
    "contributors": null,
    "id": 240558470661799936,
    "retweet_count": 0,
    "in_reply_to_status_id_str": null,
    "geo": null,
    "retweeted": false,
    "in_reply_to_user_id": null,
    "place": null,
    "source": "OAuth Dancer Reborn",
    "user": {
      "name": "OAuth Dancer",
      "profile_sidebar_fill_color": "DDEEF6",
      "profile_background_tile": true,
      "profile_sidebar_border_color": "C0DEED",
      "profile_image_url": "http://a0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg",
      "created_at": "Wed Mar 03 19:37:35 +0000 2010",
      "location": "San Francisco, CA",
      "follow_request_sent": false,
      "id_str": "119476949",
      "is_translator": false,
      "profile_link_color": "0084B4",
      "entities": {
        "url": {
          "urls": [
            {
              "expanded_url": null,
              "url": "http://bit.ly/oauth-dancer",
              "indices": [
                0,
                26
              ],
              "display_url": null
            }
          ]
        },
        "description": null
      },
      "default_profile": false,
      "url": "http://bit.ly/oauth-dancer",
      "contributors_enabled": false,
      "favourites_count": 7,
      "utc_offset": null,
      "profile_image_url_https": "https://si0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg",
      "id": 119476949,
      "listed_count": 1,
      "profile_use_background_image": true,
      "profile_text_color": "333333",
      "followers_count": 28,
      "lang": "en",
      "protected": false,
      "geo_enabled": true,
      "notifications": false,
      "description": "",
      "profile_background_color": "C0DEED",
      "verified": false,
      "time_zone": null,
      "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/80151733/oauth-dance.png",
      "statuses_count": 166,
      "profile_background_image_url": "http://a0.twimg.com/profile_background_images/80151733/oauth-dance.png",
      "default_profile_image": false,
      "friends_count": 14,
      "following": false,
      "show_all_inline_media": false,
      "screen_name": "oauth_dancer"
    },
    "in_reply_to_screen_name": null,
    "in_reply_to_status_id": null
  },
  {
    "coordinates": {
      "coordinates": [
        -122.25831,
        37.871609
      ],
      "type": "Point"
    },
    "truncated": false,
    "created_at": "Tue Aug 28 21:08:15 +0000 2012",
    "favorited": false,
    "id_str": "240556426106372096",
    "in_reply_to_user_id_str": null,
    "entities": {
      "urls": [
        {
          "expanded_url": "http://blogs.ischool.berkeley.edu/i290-abdt-s12/",
          "url": "http://t.co/bfj7zkDJ",
          "indices": [
            79,
            99
          ],
          "display_url": "blogs.ischool.berkeley.edu/i290-abdt-s12/"
        }
      ],
      "hashtags": [

      ],
      "user_mentions": [
        {
          "name": "Cal",
          "id_str": "17445752",
          "id": 17445752,
          "indices": [
            60,
            64
          ],
          "screen_name": "Cal"
        },
        {
          "name": "Othman Laraki",
          "id_str": "20495814",
          "id": 20495814,
          "indices": [
            70,
            77
          ],
          "screen_name": "othman"
        }
      ]
    },
    "text": "lecturing at the \"analyzing big data with twitter\" class at @cal with @othman  http://t.co/bfj7zkDJ",
    "contributors": null,
    "id": 240556426106372096,
    "retweet_count": 3,
    "in_reply_to_status_id_str": null,
    "geo": {
      "coordinates": [
        37.871609,
        -122.25831
      ],
      "type": "Point"
    },
    "retweeted": false,
    "possibly_sensitive": false,
    "in_reply_to_user_id": null,
    "place": {
      "name": "Berkeley",
      "country_code": "US",
      "country": "United States",
      "attributes": {
      },
      "url": "http://api.twitter.com/1/geo/id/5ef5b7f391e30aff.json",
      "id": "5ef5b7f391e30aff",
      "bounding_box": {
        "coordinates": [
          [
            [
              -122.367781,
              37.835727
            ],
            [
              -122.234185,
              37.835727
            ],
            [
              -122.234185,
              37.905824
            ],
            [
              -122.367781,
              37.905824
            ]
          ]
        ],
        "type": "Polygon"
      },
      "full_name": "Berkeley, CA",
      "place_type": "city"
    },
    "source": "Safari on iOS",
    "user": {
      "name": "Raffi Krikorian",
      "profile_sidebar_fill_color": "DDEEF6",
      "profile_background_tile": false,
      "profile_sidebar_border_color": "C0DEED",
      "profile_image_url": "http://a0.twimg.com/profile_images/1270234259/raffi-headshot-casual_normal.png",
      "created_at": "Sun Aug 19 14:24:06 +0000 2007",
      "location": "San Francisco, California",
      "follow_request_sent": false,
      "id_str": "8285392",
      "is_translator": false,
      "profile_link_color": "0084B4",
      "entities": {
        "url": {
          "urls": [
            {
              "expanded_url": "http://about.me/raffi.krikorian",
              "url": "http://t.co/eNmnM6q",
              "indices": [
                0,
                19
              ],
              "display_url": "about.me/raffi.krikorian"
            }
          ]
        },
        "description": {
          "urls": [

          ]
        }
      },
      "default_profile": true,
      "url": "http://t.co/eNmnM6q",
      "contributors_enabled": false,
      "favourites_count": 724,
      "utc_offset": -28800,
      "profile_image_url_https": "https://si0.twimg.com/profile_images/1270234259/raffi-headshot-casual_normal.png",
      "id": 8285392,
      "listed_count": 619,
      "profile_use_background_image": true,
      "profile_text_color": "333333",
      "followers_count": 18752,
      "lang": "en",
      "protected": false,
      "geo_enabled": true,
      "notifications": false,
      "description": "Director of @twittereng's Platform Services. I break things.",
      "profile_background_color": "C0DEED",
      "verified": false,
      "time_zone": "Pacific Time (US & Canada)",
      "profile_background_image_url_https": "https://si0.twimg.com/images/themes/theme1/bg.png",
      "statuses_count": 5007,
      "profile_background_image_url": "http://a0.twimg.com/images/themes/theme1/bg.png",
      "default_profile_image": false,
      "friends_count": 701,
      "following": true,
      "show_all_inline_media": true,
      "screen_name": "raffi"
    },
    "in_reply_to_screen_name": null,
    "in_reply_to_status_id": null
  },
  {
    "coordinates": null,
    "truncated": false,
    "created_at": "Tue Aug 28 19:59:34 +0000 2012",
    "favorited": false,
    "id_str": "240539141056638977",
    "in_reply_to_user_id_str": null,
    "entities": {
      "urls": [

      ],
      "hashtags": [

      ],
      "user_mentions": [

      ]
    },
    "text": "You'd be right more often if you thought you were wrong.",
    "contributors": null,
    "id": 240539141056638977,
    "retweet_count": 1,
    "in_reply_to_status_id_str": null,
    "geo": null,
    "retweeted": false,
    "in_reply_to_user_id": null,
    "place": null,
    "source": "web",
    "user": {
      "name": "Taylor Singletary",
      "profile_sidebar_fill_color": "FBFBFB",
      "profile_background_tile": true,
      "profile_sidebar_border_color": "000000",
      "profile_image_url": "http://a0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg",
      "created_at": "Wed Mar 07 22:23:19 +0000 2007",
      "location": "San Francisco, CA",
      "follow_request_sent": false,
      "id_str": "819797",
      "is_translator": false,
      "profile_link_color": "c71818",
      "entities": {
        "url": {
          "urls": [
            {
              "expanded_url": "http://www.rebelmouse.com/episod/",
              "url": "http://t.co/Lxw7upbN",
              "indices": [
                0,
                20
              ],
              "display_url": "rebelmouse.com/episod/"
            }
          ]
        },
        "description": {
          "urls": [

          ]
        }
      },
      "default_profile": false,
      "url": "http://t.co/Lxw7upbN",
      "contributors_enabled": false,
      "favourites_count": 15990,
      "utc_offset": -28800,
      "profile_image_url_https": "https://si0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg",
      "id": 819797,
      "listed_count": 340,
      "profile_use_background_image": true,
      "profile_text_color": "D20909",
      "followers_count": 7126,
      "lang": "en",
      "protected": false,
      "geo_enabled": true,
      "notifications": false,
      "description": "Reality Technician, Twitter API team, synthesizer enthusiast; a most excellent adventure in timelines. I know it's hard to believe in something you can't see.",
      "profile_background_color": "000000",
      "verified": false,
      "time_zone": "Pacific Time (US & Canada)",
      "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png",
      "statuses_count": 18076,
      "profile_background_image_url": "http://a0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png",
      "default_profile_image": false,
      "friends_count": 5444,
      "following": true,
      "show_all_inline_media": true,
      "screen_name": "episod"
    },
    "in_reply_to_screen_name": null,
    "in_reply_to_status_id": null
  }
]

眼花撩亂了吧? 這邊先不細部探討每個部分的意義, 你只需要先知道, 這雖然看來很複雜, 但有一大半你做client時”並用不上!!!” 通常只會需要內文, 連結, 回文數量, 喜愛數量, 使用者基本資料(大概就ID, 名字, 圖像就已經差不多了)

Facebook API

Facebook有相當多的功能, 因此相較於Twitter, 他的API自然複雜很多, 這邊要看的還是只有User Feed這部分, 不過似乎Facebook Graph API已經不再允許存取home timeline了, User Feed其實只能存取自己po的文

在處理Pagination上, Facebook API並不是很一致, 從這篇來看, 有幾種分頁的形式:

  1. 游標型分頁
  2. 時間型分頁
  3. 位移型分頁

並不是所有的API節點都支援這三種分頁型態, 例如”/user/albums”用的是游標型, 但User Feed用的是時間型, 時間型的缺點就是有可能會發生有相同時間的動態的問題, 但不管是哪一個類型 在Paging的資訊都會有一個previuos跟next的連結(如下), 因此不用太擔心要根據不同型態去組出URL這部分

//游標型分頁
{
  "data":[
     
  ],
  "paging":{
    "cursors":{
      "after":"MTAxNTExOTQ1MjAwNzI5NDE=",
      "before":"NDMyNzQyODI3OTQw"
    },
    "previous":"https://graph.facebook.com/me/albums?limit=25&before=NDMyNzQyODI3OTQw"
    "next":"https://graph.facebook.com/me/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
  }
}
//時間型分頁
{
  "data":[
    {
      "message": "真專業,還有空橋耶",
      "created_time": "2016-10-30T11:41:36+0000",
      "id": "1129283437_10210116929056272"
    },
    {
      "message": "兄弟藍瘦香菇了",
      "created_time": "2016-10-29T12:45:58+0000",
      "id": "1129283437_10210105741976602"
    },
    {
      "message": "又被拖著跑了",
      "created_time": "2016-10-29T06:39:29+0000",
      "id": "1129283437_10210103402598119"
    }
  ],
  "paging":{
    "previous":"https://graph.facebook.com/me/feed?limit=25&since=1364849754",
    "next":"https://graph.facebook.com/me/feed?limit=25&until=1364587774"
  }
}

在資料內容方面, Facebook回應的資料比起Twitter反而就相當的精簡, 這是有助於減低回應時間的(response time)

時間線回朔問題

剛提到Pagination時有講到在Client的設計上, 在使用者往回滑完一頁時必須要再獲取上一頁的資料, 這在單一資料源的時候問題不大, 但對彙整式的social feed, 尤其完全在Client端實作的, 會有這樣一個問題

先來看看下圖:

假設我們有S1, S2, S3, S4四個服務的動態要整合, 垂直每個線段是每次API call抓取到的資料的時間段(每個分頁的匙間段)

如果我們先不考慮S2-S4, 而是只有一個S1, 第一次載入的分頁內容的時間點在t1-t2間, 所以照理來說當使用者滑到快t2時, 要再發出一個新的API call抓取下一個分頁, 也就是t3-t5這段, 但如果把S2-S4列入考慮後, 會發現, 當第一次從四個服務那邊取得資料, 資料涵括的時間是從t1-t9, 如果什麼都不做處理而是照前面的邏輯來看, 必須要使用者滑到快t9時, 才會第二次抓取資料, 但由於第一次抓取時, S1缺了t2-t9間的資料, S2缺了t3之後的, S4的資料也只到t4, 因此第二次抓取資料時, 會造成這三者的資料往前回填, 這在UI顯示上會是一個比較大的災難

因此, 以這圖來說, 第一次抓取完畢後, UI上只能顯示t1-t2間的資料, 使用者滑到t2時, 就必須觸發第二次資料的抓取, 但以這例子來說, 其實第二次是不需要包含S3 的, 因為它第一次抓取的資料時間還在t5之後, 所以這邊如果能夠做省略, 而不是每次抓取資料都包含了S1-S4, 那可以省卻不少回應時間

資料格式的一致化(Data normalization)

剛剛看了Twitter, Facebook兩個API, 它的資料格式雖然都是JSON, 但內容差異很大, 但實際上來說, 以上次那篇的一張圖來做解釋:

從這張圖可以發現, 我們需要的資料並不多, 即使各家型態有所不同(例如Twitter重文字, Instagram是以圖為主), 我們還是可以歸納出我們UI所需要顯示的型態有哪些類別, 因此我們需要的也只是顯示UI所需要的部分而已, 所以我們可以把這些不同來源的資料格式一致化成同一種我們所需要的資料格式

那為何需要先一致化呢? 每次API抓回來的資料解析完直接顯示到UI上不就好了? 一般這樣的App的UI設計上, 會讓使用者一直捲頁, 因此你還是會需要把舊資料先暫存, 不管是在記憶體或是在資料庫內, 這樣使用者回捲再多也不需要再從server抓取舊資料, 但你如果把原本資料原封不動的拿來存, 像Twitter那個就會存了很多不必要的資料, 如果更偷懶直接存JSON, 那就會是效率問題了, 解析JSON過程中其實會產生不少垃圾, 效率也不高, 可能會影響UI的效能, 因此如果能夠在一開始把解析完的資料序列化到資料庫內, 會是比較理想, 因為你只需要從資料庫取相對應解析完的資料就好, 不用再一次解析JSON

背景更新

偷偷在背景更新動態是一種解決時間線回朔這個問題的方法, 因為所有資料都預先抓回資料庫, 所以也不需要煩惱什麼時後該去抓下一個分頁, 每個服務都可以獨立抓取, 丟到本地的資料庫彙整就好 ,在使用者使用程式時, 也不需要解析太多JSON, 但這帶來一個缺點是因為在背景抓資料, 會有浪費頻寬的問題, 假設使用者一天最多只看了50則動態, 但一天其實會產生一百則, 那就會有50則的資訊量是浪費了, 再加上Social API都是用輪詢(polling)的方式, 並不是會時時有資料, 所以常常很多API calls其實是不需要也浪費的

那Friendfeed的做法?

Facebook的前CTO, 也是Friendfeed創辦人之一的Brett Taylor在這篇 How do sites such as FriendFeed and Flipboard scale out their social data-aggregators? 的回答可窺知一二

在server端的做法就像背景更新差不多, 定期用crawler發api去抓取各服務的資料, 然後塞到資料庫內, 因此就沒有時間線回朔的問題了, 但問題就在於抓取的間隔, 因此在server端實作的難題就是該怎樣去取這間隔, 在OSCON 2008有一篇Beyond REST? Building Data Services with XMPP PubSub有提到:

on july 21st, 2008, they friendfeed crawled flickr 2.9 million times. to get the latest photos of 45,754 users of which 6,721 of that 45,754 visited Flickr in that 24 hour period, and could have *potentially* uploaded a photo. 
- Beyond REST? Building data services with XMPP PubSub (Evan Henshaw-Plath, ENTP.com, Kellan Elliott-McCrea, Flickr.com)

但實際上來說, 並沒有那麼多的更新, 也不需要那麼頻繁的去抓取, 但這是這類型的API先天的缺陷, 也不是Friendfeed的問題, 或許在下一篇講一下怎麼設計API來改善這狀況

如果要找出一個我過去幾年工作中比較具有代表性的東西, 想了一下, 應該就是social feed這東西了(寫這篇時, 想了一下該用啥名詞, 以往我會叫Timeline, 不過Social feed應該更為貼切一點), 趁現在才剛離職有些時間, 把這些東西整理一下, 主要還是以以前做過的東西的概念為主, 希望沒忘掉太多

這系列打算由三篇來構成, 除了這篇概念篇外, 另外還會有兩篇比較細節一點的內容, 分為client和server的部分(之後寫完會再更新鏈結):

  1. 淺談Social Feed - 多服務彙整式的social feed (Client)
  2. 淺談Social Feed - 後端架構實作 (Server)

什麼是Social feed?

如果要簡單的來解釋, 應該可以說是一條依時間線排序的社群動態, 來看看下面這張Twitter的畫面:

這算是一個簡單的例子, 基本上就是把社群動態一條線的排下來(所以之前我也比較習慣的把它叫做Timeline, 不過這邊會改叫Social feed是因為考慮到也有不是依時間排序的), 我不太確定最早是誰採用這樣的設計, 找了一些早期社群網站(Frienster, Myspace), 最早都還沒有這樣的設計:

Friendster (2002 原來那時候有繁中版?!):

Myspace (2003):

即使是Facebook也要到2008年才有這樣的雛形(看下面這段video還蠻有趣的, 我好像也是那段時間開始跟這東西結下了孽緣)

再看看2006年的Twitter, 似乎就比較像是一個雛形的樣了, 不過那時似乎還像是一個粗劣的網站

當然也不是所有的Social feed都是由上而下的, 另一個有名的變形就是創了河道的Plurk, Plurk在2008創立, 在台灣紅了一陣子, 但在智慧型手機的浪潮沒跟的很好, 到了手機上就很難發揮河道這樣的特色了

現今的設計

現在這時代, 智慧型手機當道, mobile first曾經流行過一陣子, 大家放很多心力在手機上, 但Social feed這東西, 到了手機上, 各家變化就不太大了, 大致上都很類似, 來看幾個手機上的範例:

Facebook:

Twitter:

Google+:

Linkedin:

Instagram

新浪微博

Pinterest

這邊可以見到的是Pinterest採取了一個跟其他人不同的呈現方式, 但, 大體上的構成還是跟大家都相似的

另外可以發覺的是, 從2006在PC Web到現在2016手機上, 內容變複雜了, 從純粹文字到多媒體內容, 我個人其實不是那麼愛這種轉變, 因為要接收的資訊變多了, 雖然畫面變豐富更多, 但另外帶來的一個缺點, 尤其是在手機上, 螢幕已經不夠付載一則動態的資訊量了

使用者介面構成與行為

先以Facebook的介面當做例子來解釋(其他各家都大同小異啦):

大致上可以分做為三部分 - 作者資訊(藍色, 黃色區域), 內文資訊(綠色, 紅色, 咖啡色區域), 社群互動功能(紫色區域)

作者資訊 (藍色, 黃色區)

光字面上意思就已經表達完這部分了, 大致上都是放作者的圖像跟ID(或名字), 在Twitter因為還有轉推的動作, 所以還包含了轉貼人的資訊, 近年比較流行的設計是會用圓形的頭像(像上面例子的Instgram, 微博, Google+), 圓形的頭像大半在client app要顯示時處理掉就好, 只是一個圓形的mask, 就如同我以前寫過這篇:

圓形大頭貼 - 使用Picasso的Transformation

發布時間 (綠色區域)

一般說來, 發布時間這部分, 不會直接用絕對時間(幾年幾月幾日幾時幾分), 而是用”3分鐘前”, “4天前”這樣的絕對時間, 這樣的顯示方式似乎就已經是一個約定俗成的默契了

這種時間格式有個好處, 不用管時差問題, social feed的內容可能來自於世界各地不同的朋友, 每個人時區不同, 與其轉成當地時區的時間格式, 還不如以這種方式表示來得直接一點, 也不用管字串會不會有太長的問題

做這樣一個東西, 也是不用重新造輪子啦, 已經有了moment.js這樣方便的東西可以用了, 當然他也是有被移植到Javascript以外的, 比如說SwiftMoment

內文以及相關資訊(紅, 橘, 咖啡區域)

早期的social feed, 內容大多只有文字, 就算有連結的轉貼, 也只是多一個hyper link, 整體上讀起來還是文字, 接下來圖片被帶入後, 就變成有圖文夾雜格式出現, Facebook這種通用的social network服務, 內容種類較多, 因此就會夾雜不同格式的內容, 除了純文字內容外, 還有圖片, 影像, 甚至, 現在多了個直播, 而像是Instagram這類以影像為主的, 格式就較為統一, 不過, 基本上也只是不同內容的內文顯示格式略有不同外, 在後面的資料結構理應大同小異

後來可能人們(不見得是使用者吧)不再滿足於單調的內容, 尤其是在社群網路上分享文章鏈結(像是分享新聞的)越來越多, 一堆超連結看起來也醜, 後來就出現了Twitter Card 和 Open graph這類的東西:

  1. Open graph
  2. Twitter Card

這進一步讓你可以去定義你自己的網站, 而社群服務像是Facebook再把你的網站當作一個物件, 以物件的類別來決定怎麼去呈現這個鏈結, 在視覺上就再更加的豐富

不過不管內容有多少種, 差別真的就是呈現方式的多寡, 呈現方式也是有限的, 在Client顯示設計上是可以設計靈活點可以擴展, 不過倒也不用考慮到會有無窮無盡的形式

另外跟內文相關的資訊常見的還會有喜歡這個動態的數目(Facebook還有多種情緒表示), 回文數目, 分享數目(不見得每個服務都有), 使用者可以透過這些數字來了解到這篇貼文的熱門程度, 但這些數字, 其實在大部分的服務裡都只能單參考用, 數字未必準確, 這是因為一來很難及時地把某則貼文按讚的狀態更新給所有人(對Server的負擔大, Client實作也複雜), 或許在視覺上可以用一些比較相對的表示方式而非絕對數量的表示

另外有些服務會節錄幾則(通常最多三則)回文跟著文章下面一起顯示, 像Facebook網頁版, 但一樣, 它也是難於即時的更新

社群互動功能 (紫色區域)

一般常見就是“喜歡(like)”, “回文(comment)”, “分享(share)”, 社群網路的精神主要還是在互動跟分享, 因此這幾個也差不多是最精簡也必備的了

更進階一點的內容

通常還會有所謂的 hashtag和mention (這邊以Twitter用詞)

所謂的hashtag是由User自訂, 跟這則內文相關的關鍵詞, 以”#”開頭, 差不多也是個約定俗成, 最早應該早在IRC時代就有在使用了, 什麼?沒聽過IRC?沒關係, 知道從很古早時代就有了, 設計上通常會把 #hashtag 作成連結的形式, 點下去顯示相關的文章

而mention指的是”提到”某某人, 所以通常的形式都是”@”後面加User ID, 這也已經是一種約定俗成的方式了, 設計上也都會是一個點下去就到那個人的資訊頁面的連結

時間線回朔

這部分我們以前都把它稱作”load more”, 不過覺得這樣講好像很難知其所以然, 先看一下圖:

一般來說, 從Server端抓回來的文章不會一次傳回所有的, 因為那會實在太多了, 尤其對重度使用者來說, 從開始使用以來到現在可能為數不少, 因此當我們把整social feed (或time line或說stream)往下一直拉時, 總是會見底的

在以往的UI設計上大多會放一條”touch to load more”之類的讓使用者再讀取舊一點的資料(所以以往我們都會把它叫做load more), 但這樣的缺點是使用者體驗不會太好, 通常看到這條後就跳掉不看了, 因此後來就流行做成上圖那樣, 快拉到最下面時就預先抓取, 來不及抓完, 使用者就會看到轉圈圈的進度

最好的體驗應該是讓使用者無縫接軌, 可以一直一直往下拉不用中斷, 但這邊就存在有調整的空間了, 太晚觸發的話, 使用者滑到最下面還是會有等待時間, 等待時間只要一長了, 常常就沒耐性跑了, 所以如果可以提早一點抓取, 是可以減少拉到最下面的等待時間, 但到底要提早多少? 太早也不是一件好事, 可能會導致client太過頻繁跟server索取資料, 但實際上又用不到那麼多, 以至於浪費了太多的網路傳輸量, 以及增加了server的負擔, 但使用者滑動的速率每個都不同, 所以這是一件不好拿捏的事, 可能要經過多次試驗才會有比較好的體驗

資料更新

這比較會出現在有背景更新的場合, 如果每次使用者要看最新內容要自己觸發更新, 更新結束前他不能做任何動作, 那就沒這問題, 這問題主要出現在使用者在瀏覽過程中, 背景更新有了新資料進來, 輕微的話, 資料從最上頭插入導致他正在看的位置跑掉了, 嚴重的話, 可能整個刷新後, 內容都不同了, 這當然對使用者體驗很不好, 現在大部分的設計都會設計成非同步更新, 也就是就算是由使用者觸發, 更新時, 使用者還是有機會做動作, 更新時間如果太長了, 就容易發生這狀況

這部分的解法通常像下圖Twitter的做法:

不直接刷新頁面, 而是先提示使用者有新的貼文, 這樣的感覺就好多了

聚合式的social feed

所謂的聚合式的, 就是把一堆Social service的feeds全部串在一起, 因為多數的使用者擁有不止一個社群網路帳號, 把所有放在一起在瀏覽上就不用一個個網站跑或一個app跳過一個app

最早著名的有Friendfeed, 它早已經被Facebook買下不存在了, 不過它就是這樣一個概念:

另外手機上還有HTC的Friend stream, 不過這個血和汗做來的產品也不在了 orz

(好, 我拿chacha的畫面的確是故意的 :P)

另外還有一個叫做HootSuite的也是類似:

不過HootSuite跟前兩者有所不同的地方是它並未把Social feed全部整合在一個時間線, 而只是並列顯示, 全部整合的難度稍高, 這就留待下一篇來說明了

小結

整理了這麼多, 是後面兩篇的前置, 這邊的概念等於是設計一個social feeds的”需求”, 後面兩篇會再用到這些概念

做一個網路服務, 使用者驗證是蠻麻煩的一件事, 我們是可以裝作沒看到, 不做驗證, 但這樣的下場就是有假使用者, 有殭屍, 最簡單的方式是信任第三方服務像是Google, Facebook, 現在的人大多數都有Google, Facebook帳號了, 這樣其實也沒多大的問題, 但還是還是有人沒有, 而且也不是每個人都放心把Facebook帳號交給我們, 因此退而求其次就是用E-mail, 用E-mail認證雖然也是一個好方式, 但還是要建置整套發信機制(或是花錢買mailgun來送信), 而且在手機上就麻煩了, 來回在App跟e-mail間切換很不方便, 因此就會想用簡訊認證, 至於簡訊認證, 除了一個”貴”字以外, 要搞定各個國家的也是一個麻煩(當然, 花錢可解, 有Twilio這種服務可以用)

所幸有Facebook提供的這個可以用Account Kit, 在初期使用者不太多的時候, 不收費的確很吸引人呀(雖然他不是唯一一個這樣的服務, 之後再介紹其他的), 但由於他iOS的範例是用Objective C寫的, 我的Objective C實在不太行, 加上, 要了解一個東西, 寫一遍就知道了, 所以順手翻譯了一個Swift的版本, Source如下:

Account Kit samples for Swift

原本的版本, 我是覺得寫的不是太好, 花了好一些功夫看, 自己翻過來的這個版本, 也還沒debug過, 基礎的應該堪用啦! 至於iOS的account kit文件可以參考: iOS 專用 Account Kit

註: plist裡面寫的app id不是我的喔!是原本Sample用的

雖然很久沒用box.com的服務了, 不過既然老婆大人問起, 就來寫一下這解法吧

box.com是一個像Dropbox一樣的網路磁碟, 不過它目標客戶跟Dropbox不同, 是比較傾向企業用戶, 可以讓用戶很簡單的分享檔案, 存取box.com除了一般使用Web介面的方式外, 還有其他的方式, 像是透過它的REST API, 另外還有一種就是透過WebDav, 如果要寫程式去存取它, 一般可以用這兩種方式, 用REST稍微複雜一點, 還要搞定OAuth2的部分, 但透過WebDav的話就簡單多了, 可以掛載成為你作業系統底下的目錄, 當本地檔案來處理

安裝davfs2

首先, 你會需要的是davfs2, 在Ubuntu下用apt-get安裝:

sudo apt-get install davfs2

設定帳號密碼

修改/etc/davfs2/secrets, 加入

https://dav.box.com/dav box.com帳號 密碼

掛載

執行底下指令掛載

sudo mkdir /mnt/box.com
sudo -t davfs https://dav.box.com/dav /mnt/box.com

成功之後,就會在/mnt/box.com底下看到你的檔案了(包含人家分享給你協作的檔案根目錄), 之後當本地端檔案存取即可, 設定好auto mount即可在開機後掛載