使用 Go 實作「GitHub 排名系統」(二)

後端實作

申請令牌

先至 GitHub 的 Personal access tokens 頁面申請一個存取令牌。

工具

文件

查詢語法

由於 GraphQL 的客戶端比較單純,因此直接使用字串替換的方式去改變一個 GraphQL 的請求。

比方說有一個 owners.graphql 檔,可以查詢一般使用者或組織:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
query Owners {
search(<SearchArguments>) {
edges {
cursor
node {
... on User {
imageUrl: avatarUrl
createdAt
followers {
totalCount
}
location
login
name
}
... on Organization {
imageUrl: avatarUrl
createdAt
location
login
name
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
rateLimit {
cost
limit
nodeCount
remaining
resetAt
used
}
}

將這個檔案讀取後,利用字串替換的方式把 <SearchArguments> 標籤替換掉就可以了。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (q Query) String() string {
query := q.Schema
query = strings.Replace(query, "<Type>", q.Type, 1)
query = strings.Replace(query, "<SearchArguments>", util.ParseStruct(q.SearchArguments, ","), 1)
query = strings.Replace(query, "<OwnerArguments>", util.ParseStruct(q.OwnerArguments, ","), 1)
query = strings.Replace(query, "<GistsArguments>", util.ParseStruct(q.GistsArguments, ","), 1)
query = strings.Replace(query, "<RepositoriesArguments>", util.ParseStruct(q.RepositoriesArguments, ","), 1)

payload := struct {
Query string `json:"query"`
}{
Query: query,
}
b, err := json.Marshal(payload)
if err != nil {
log.Fatal(err.Error())
}

return string(b)
}

蒐集資料

由於 GitHub 的「搜尋」(search)端點,並不允許開發者一次撈取所有的歷史資料,即使有分頁,最多也只有 10 頁。因此需要指定一個時間區間。如果要蒐集 GitHub 上所有的一般使用者或組織,就得從 GitHub 創始的時間開始蒐集。

以蒐集組織的資料為例,使用一個遞迴方法從 2007 年 10 月 1 日開始蒐集:

1
2
3
4
5
6
7
8
9
10
11
func (o *Organization) Travel() error {
if o.From.After(o.To) {
return nil
}

// do something

o.From = o.From.AddDate(0, 0, 7)

return o.Travel()
}

由於還需要蒐集每個組織各自的儲存庫(repository),因此還需要使用一個遞迴方法去蒐集。這個部分就沒有 10 頁的限制,只需要利用分頁指標(cursor)不斷切換下一頁就可以:

1
2
3
4
5
6
7
8
9
10
11
func (o *Organization) FetchRepositories(repositories *[]model.Repository) error {
// do something

if !res.Data.Organization.Repositories.PageInfo.HasNextPage {
o.RepositoryQuery.RepositoriesArguments.After = ""
return nil
}
o.RepositoryQuery.RepositoriesArguments.After = strconv.Quote(res.Data.Organization.Repositories.PageInfo.EndCursor)

return o.FetchRepositories(repositories)
}

速度限制

GitHub GraphQL API 有速度限制,因此需要稍微控制一下速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (r RateLimit) Throttle(collecting int64) {
logger.Debug(fmt.Sprintf("Rate Limit: %s", strconv.Quote(util.ParseStruct(r, " "))))
resetAt, err := time.Parse(time.RFC3339, r.ResetAt)
if err != nil {
log.Fatal(err.Error())
}
remainingTime := resetAt.Add(time.Second).Sub(time.Now().UTC())
time.Sleep(time.Duration(remainingTime.Milliseconds()/r.Remaining*collecting) * time.Millisecond)
if r.Remaining > collecting {
return
}
logger.Warning("Take a break...")
time.Sleep(remainingTime)
}

解析地理位置

由於每個一般使用者和組織的地理位置都是自由填寫的,所以需要去解析這個帳號所填寫的地理位置究竟是哪個國家和城市,因此需要自行準備一個地區列表和解析方法來處理。

排名語法

使用 MongoDB 的聚合(aggregation),可以利用各種管道(pipeline)完成排名。以一般使用者的排名管道為例,需要針對追蹤者數量、程式碼片段和儲存庫,在不同地理位置和程式語言的條件下進行排名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

func RankUser() (pipelines []*Pipeline) {
rankType := app.TypeUser
fields := []string{
"followers",
"gists.forks",
"gists.stargazers",
"repositories.forks",
"repositories.stargazers",
"repositories.watchers",
}
for _, field := range fields {
pipelines = append(pipelines, rankByField(rankType, field))
pipelines = append(pipelines, rankByLocation(rankType, field)...)
}
pipelines = append(pipelines, rankOwnerRepositoryByLanguage(rankType, "repositories.stargazers")...)
pipelines = append(pipelines, rankOwnerRepositoryByLanguage(rankType, "repositories.forks")...)
pipelines = append(pipelines, rankOwnerRepositoryByLanguage(rankType, "repositories.watchers")...)
return
}

每個排名管道都經過封裝過:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

func rankByField(rankType string, field string) *Pipeline {
return &Pipeline{
Pipeline: &mongo.Pipeline{
operator.Project(bson.D{
id(),
imageUrl(),
totalCount(field),
}),
operator.Sort("total_count", descending),
},
Type: rankType,
Field: field,
}
}

根據計算,一般使用者有 26,325 個管道,組織有 14,010 個管道,而儲存庫有 1,698 個管道需要被執行。

排名時間戳

當每一次執行排名時,都相當耗時。為了可以讓前端一直取得正確的排名資訊,當前的排名不可以被覆蓋或刪除。每一次有新的排名被存入資料庫,都會產生一個時間戳來指定這批排名資料已經完成,並且可以被使用。而這個時間戳在每一次排名完成後,就會被寫進環境變數檔裡,如此一來可以確保排名資料的原子性。

查詢

所有的排名資料都是相同的格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"_id" : "", // MongoDB 的內建 ID
"name" : "", // 資源名字
"image_url" : "", // 資源圖像
"rank" : 0, // 名次
"rank_count" : 0, // 名次總數
"item_count" : 0, // 排名欄位總數
"type" : "", // 指定是一般使用者、組織或儲存庫
"field" : "", // 指定排名欄位
"language" : "", // 語言
"location" : "", // 地理位置
"created_at" : "" // 時間戳
}

快取

快取的部分暫時使用 in-memory 類型的快取套件進行處理。

資料庫

資料庫使用 MongoDB,排名資料表的部分,有為以下 5 個欄位特別建立索引:

  • name
  • type
  • language
  • location
  • created_at

前端

前端的部分使用 Vue 和 Vuetify 進行實作。需要注意使用名字去查詢的時候,需要實作去抖(debounce),不要一直呼叫 API。由於後端有時回覆較慢,也需要特別去撤銷(cancel)被覆蓋的 HTTP 請求。

共用資源

前後端有一模一樣的共用資源,像是語言列表和地區列表,這個部分可以使用 Git Submodules 處理。

網站

程式碼