可視化探索開源項目的 contributor 關係

引語:作爲國內外最大的代碼託管平臺,根據最新的 GitHub 數據,它擁有超 372,000,000 個倉庫,其中有 28,000,000 是公開倉。分佈式圖數據庫 NebulaGraph 便是其中之一,同其他開源項目一樣,NebulaGrpah 也有自己的 contributor 們,他們是何時,通過哪個 pr 與 NebulaGraph 產生聯繫的呢?本文嘗試用可視化方式,來探索這些 contributor 的痕跡。

世界上有兩種需求,一種是能做的,另外一種是不能做的;當然按照合理不合理角度,大多數的需求都是合理但能做的,就像本文的需求一樣——用可視化的方式,來“窺探” nebula 開源社區中 contributor 同項目的關係,及他們留下的 pr 痕跡。

故事從兩個月前講起,有一天我司研發 liuyu 同學裝了一款名叫 ClickHouse 的數據庫,他發現 CK 有一個感人的 contributor 系統表,這不得讓我們的運營來“借鑑”下麼?

現在,我們來看看感動我司研發的 ClickHouse 是怎麼樣的存在。

讓人感動的 ClickHouse Contributor 系統表

簡單來說,只要你裝了 CK 數據庫,不需要連接任何數據庫,系統自帶一個數據表,你可以執行以下 SQL

select count() from system.contributors

就能得到一個現有的 CK contributor 總量(下面數據存在一定滯後性):

也可以按照下列方式隨機獲得 20 位 contributor 名單:

select * from system.contributors limit 20;

這種用 SQL 方式查看 contributor 的方式還挺 cool 的,畢竟 contributor 是一羣通過提交 pr 來完善、迭代產品的人,其中很大一部分的 contributor 是工程師,SQL 更是信手拈來。

現在問題來了,作爲一個不會寫 SQL 的運營,如何滿足我司研發提出的讓他感動一下的 contributor 系統表?冷靜下,ClickHouse 的這個 SQL 看 contributor 的方式固然很酷,但是終歸到底是要查看貢獻者同開源項目的關係。說到“搞關係”,還不得是我們的圖數據庫。巧的是,NebulaGraph 就是一款圖數據庫,雖然在本文的數據集過於簡單用,也不是什麼大規模數據,用圖數據庫有點“殺雞用牛刀”,但不妨一試。看看,不會寫 SQL 的運營怎麼用可視化的方式來查看 contributor 和項目關係。

看得見的 contributor 和 pr 關係

效果先行,在這個章節,我們來看下 NebulaGraph 開源社區的 contributor 和 pr 情況,而這些數據是如何生成、展示的實操部分在後面。

開源社區全覽

這裏收錄了所有 NebulaGraph 相關的公開倉的貢獻情況,大概是這樣的:

加上時序之後,能看到一個個 contributor(方形圖)出現在畫布上,同各個 repo(圓形圖)連接在一起。這裏僅僅展示了所有 contributor 第一次提交 pr,更多的查詢在後面的「可視化圖探索」部分。

下面的章節爲實操內容,一起看看如何生成可視化的 contributor 和開源項目的關係圖吧。

手把手帶你可視化探索數據

下面着重介紹下本文的可視化工具——NebulaGraph Explorer,具體介紹看文檔:https://docs.nebula-graph.com.cn/3.4.1/nebula-explorer/about-explorer/ex-ug-what-is-explorer/。對我而言,Explorer 有兩大特點:易上手所見即所得。我可以白嫖我司線上 Explorer 環境,不用搭建自己的數據庫就能直接用,當然你如果想和我一樣有個免費的線上環境,估計得用 NebulaGraph Cloud,它配有可視化圖探索工具 NebulaGrpah Explorer。

用來進行數據探索的工具有了,現在就是數據哪裏來的問題了。

簡單建模

在採集數據之前,我們需要簡單建模(我從未見過如此簡單的圖模型)瞭解需要採集的數據。下圖爲圖模型:

這個圖模型中有兩種點類型:repocontributor,它們之間由 pr 這個邊聯繫在一起構成了最基礎的點邊圖模型。在分佈式圖數據庫 NebulaGraph 中點的類型用 tag 來表示,邊類型有 edgetype,一個點可以有若干種 tag,點的 ID 爲 vid,像是你的身份證一樣爲唯一標識。

  • tag
    • repo,擁有倉庫名 name,主要編程語言 language 以及倉庫路徑 path 等三種屬性;
    • contributor,擁有貢獻者名 name,貢獻者編號 number,誕生日 anniversary,是否爲 NebulaGraph 開發商僱員 is_vesoft,第一個被合併 pr 所屬倉 first_repo。加入了判斷“是否爲 NebulaGraph 開發商僱員”的屬性是爲了避免超大節點,因爲一個企業僱員的 pr 產量不同於其他的非僱員貢獻者。(這點會在後面的可視化展示中體現)
  • edgetype
    • pr,擁有 pr 編號 number,提交時間 created_time,關閉時間 closed_time,合併時間 merged_time,是否被合併 is_merged,變更情況:ins_code_linedes_code_linefile_number。上面的時間字段可以用來篩選出某個時間區間裏的 pr 邊;

contributor 數據採集

下面這段代碼是拜託我司優秀的 IT 工程師喬治編寫的,那些需要配置、填上你自己信息的地方,我用註釋進行了標註:

# Copyright @Shinji-IkariG
from github import Github
from datetime import datetime
import sh
from sh import curl
import csv
import requests
import time

def main():
# 你的 GitHub ID
    GH_USER = 'xxx'
# 你的個人 token,可以前往 GitHub 設置中的 Developer settings 生成自己的 token
    GH_PAT = 'xxx'
    github = Github(GH_PAT)
# 你需要爬取的開源組織的組織名
    org = github.get_organization('vesoft-inc')
    repos = org.get_repos(type='all', sort='full_name', direction='asc')
# 命名存放爬下來的 pr 數據的文件
    with open('all-prs.csv', 'w', newline='') as csvfile:
# 爬取哪些數據
        fieldnames = ['pr num','repo','author', 'create date','close date','merged date','version','labels1','state','branch','assignee','reviewed(commented)','reviewd(approved)','request reviewer','code line(+)','code line(-)','files number']
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()


        for repo in repos:
            print(repo)
            Apulls = repo.get_pulls(state='all', sort='created')
            prs = []
            for a in Apulls:
                prs.append(a)

            for i in prs:
                github = Github(GH_PAT)
                print('rate_limite' , github.rate_limiting[0])
                if github.rate_limiting[0] < 500:
                    if github.rate_limiting_resettime - time.time() > 0:
                        time.sleep(github.rate_limiting_resettime - time.time()+900)
                    else:time.sleep(3700)
                else:
                    print(i.number)
                    prUrl = 'https://api.github.com/repos/'+ str(repo.full_name) + '/pulls/' + str(i.number)
                    pr = requests.get(prUrl, auth=(GH_USER, GH_PAT))



                    assigneesList = []
                    if pr.json().get('assignees'):
                        for assignee in pr.json().get('assignees'):
                            assigneesList.append(assignee.get('login'))
                    else: ""



                    reviewerCList = []
                    reviewerAList = []
                    reviewers = requests.get(prUrl + '/reviews', auth=(GH_USER, GH_PAT))
                    if reviewers.json():
                        for reviewer in reviewers.json():
                            if reviewer.get('state') == 'COMMENTED':
                                if reviewer.get('user'): 
                                    reviewerCList.append(reviewer.get('user').get('login'))
                                else: reviewerCList.append('GHOST USER')
                            elif reviewer.get('state') == 'APPROVED':
                                if reviewer.get('user'): 
                                    reviewerAList.append(reviewer.get('user').get('login'))
                                else: reviewerAList.append('GHOST USER')
                            else : print(reviewer.get('state'), 'TYPE REVIEWS')
                    else: ""


                    reqReviewersList = []
                    reqReviewers = requests.get(prUrl + '/requested_reviewers', auth=(GH_USER, GH_PAT))
                    if reqReviewers.json().get('users'):
                        for reqReviewer in reqReviewers.json().get('users'):
                            reqReviewersList.append(reqReviewer.get('login'))
                        print(reqReviewersList)
                    else: ""



                    labelList = []
                    if pr.json().get('labels'):
                        for label in pr.json().get('labels'):
                            labelList.append(label.get('name'))
                    else: ""



                    milestone = pr.json().get('milestone').get('title') if pr.json().get('milestone') else ""



                    writer.writerow({'pr num': i.number,'repo': repo.full_name,'author': pr.json().get('user').get('login'), 'create date': pr.json().get('created_at'),'close date': pr.json().get('closed_at'),'merged date': pr.json().get('merged_at'),'version': milestone,'labels1': ",".join(labelList),'state': pr.json().get('state'),'branch': pr.json().get('base').get('ref'),'assignee': ",".join(assigneesList),'reviewed(commented)': ",".join(reviewerCList),'reviewd(approved)': ",".join(reviewerAList),'request reviewer': ",".join(reqReviewersList),'code line(+)': pr.json().get('additions'),'code line(-)': pr.json().get('deletions'),'files number': pr.json().get('changed_files')})

if __name__ == "__main__":
    main()

#pip3 install sh pygithub

等你運行完上面代碼,便能得到一個名叫 “all-prs.csv”。腳本爬取的是 vesoft-inc(NebulaGraph 開發商)組織下的所有倉,這裏並沒有區分倉庫狀態,這就意味着它也會將私有倉的數據爬取下來。因此,我們要對數據進行二次處理。這裏略過我簡單處理數據的過程,處理完的 pr 數據中可以抽取相關的 contributor 數據。

上面提到過每個點都有 vid,因此將 contributor 的 vid 設定爲他/她的 GitHub ID,repo 的 vid 則採用縮寫,而邊的數據中起點和終點就爲上面的 contributor vid 和 repo vid。

現在我們有了,contributor.csv,pr.csv,repo.csv 三個文件,格式類似:

# contributor.csv
wenhaocs,haowen,148,2021-09-24 16:53:33,1,nebula
lopn,lopn,149,2021-09-26 06:02:11,0,nebula-docs-cn
liwenhui-soul,liwenhui-soul,150,2021-09-26 13:38:20,1,nebula
Reid00,Reid00,151,2021-10-08 06:20:24,0,nebula-http-gateway
...

# pr.csv
nevermore3,nebula,4095,2022-03-29 11:23:15,2022-04-13 03:29:44,2022-04-13 03:29:44,1,2310,3979,31
cooper-lzy,docs_cn,1614,2022-03-30 03:21:35,2022-04-07 07:28:31,2022-04-07 07:28:31,1,107,2,4
wuxiaobai24,nebula,4098,2022-03-30 05:51:14,2022-04-11 10:54:04,2022-04-11 10:54:03,1,53,0,3
NicolaCage,website,876,2022-03-30 06:08:02,2022-03-30 06:09:21,2022-03-30 06:09:21,1,4,2,1
...

#repo.csv
clients,nebula-clients,vesoft-inc/nebula-clients,Java
common,nebula-common,vesoft-inc/nebula-common,C++
community,nebula-community,vesoft-inc/nebula-community,Markdown
console,nebula-console,vesoft-inc/nebula-console,Go
...

數據導入

數據導入之前需要創建相關的 Schema 進行數據映射。

創建 Schema

現在我們需要把圖結構模型變成 NebulaGraph 能識別的 Schema,有兩種方式來創建 Schema:一是用查詢語言 nGQL 來編寫 Schama,另外一種則是用可視化圖探索工具 NebulaGraph Explorer 提供的可視化界面填寫信息完成。和我一樣對查詢語言不熟悉的小夥伴,建議首選後者。

登陸到 NebulaGraph Explorer 之後,先創建一個圖空間(類似 MySQL 中的 Table):

效果同下面的 nGQL 語言:

# nebula-contributor-2023 是這個圖空間名字,其他默認;
CREATE SPACE 'nebula-contributor-2023'(partition_num = 10, vid_type = FIXED_STRING(32))

創建完圖空間之後,再創建兩個點類型和一個邊類型,二者創建方式類似。

下面,以創建相對複雜的 contributor 點類型爲例:

同效於這條 nGQL 語句:

CREATE tag contributor (name string NULL, number int16 NULL, anniversary datetime NULL, is_vesoft bool NULL, first_merged string NULL) COMMENT = "貢獻者"

同樣的 repo 和 pr 邊可以用下面的 nGQL 或同上圖一樣用 Explorer。

# 創建 repo tag
CREATE tag repo (repo_name string NULL, language string NULL, path string NULL) COMMENT = "倉庫"

# 創建 pr edge
CREATE edge pr (number int NULL, created_time datetime NULL, closed_time datetime NULL DEFAULT NULL, merged_time datetime NULL DEFAULT NULL, is_merged bool NULL, ins_code_line int NULL, des_code_line int NULL, file_changed_num int NULL)

導入數據

因爲用了可視化工具 Explorer,所以上傳數據也可以用“看得見的方法”。在創建完 Schema 之後,點擊這個右上角的菜單欄“Import”,開始數據導入。

數據源選擇本地,找到上面準備的 3 個 csv 文件所在路徑,把文件上傳之後。開始【導入】過程,在這個步驟主要是完成本地數據文件同 Schema 的關聯。類似下圖:

在整個數據集中,我們有兩種點:vertices 1 關聯 repo 的 csv 數據,vertices 2 關聯 contributor 數據,指定各自的 VID 和相關屬性的所在列之後,就可以導入數據了。在邊數據關聯這塊,因爲我們之前已經在 csv 中加入了 repo 和 contributor 的各自 VID,所以這裏同點的關聯一樣,簡單勾選哪列是起點(Column 0)、哪列是終點(對應上圖的 Column 1)。

需要進行特殊說明的是,因爲一個 contributor 和一個 repo 會存在多次提交 pr 記錄,即:多條同 pr 邊類型的邊。而對同一類型邊的處理問題,圖數據庫 NebulaGraph 引入了 rank 字段來表示兩個點之間多條同一類型,但邊屬性不同的邊。如果你不設定 rank,插入多條同一類型邊,則會進行數據覆蓋操作,以最後成功插入的邊數據爲準。

爲了偷懶,這裏 rank 我直接用了 pr 編號 number 列,仔細看,上面的 ranknumber 都是讀取的同一列 Column 2 數據。

可視化圖探索

現在我們有數據了,可以進入到可視化圖探索模式了。

在“Visual Query”菜單下,拖拽兩個 tag:contributor 和 repos,選擇 pr 邊,【運行】,就能看到所有 contributor 提交的 pr 數據。它的效果等同於下面這句 nGQL 查詢語言:

match (v0:contributor) -[e:pr]-> (v1:repo) return e limit 15000

我們隨意加入一點像是下面這種小細節:

我們把點的頭像全部換下,這裏爲了節省時間找研發小哥龍仔開了個綠色通道批量上傳了 contributor 和 repo 點的頭像。現在,整圖的效果展示是這樣的:

因爲 nebula 最大的貢獻來源於其僱員(員工),所以這裏我們除去僱員,查看下非僱員的貢獻情況,效果同查詢語言:

match (v0:contributor) -[e:pr]-> (v1:repo) where (v0.contributor.is_vesoft == false) return e limit 15000

上圖是將 nGQL 查詢結果導入到畫布,對應的 NebulaGraph Explorer 操作爲點擊【導入圖探索】,再進行同類型邊合併,放大 contributor 點的大小,選擇輻射模式,就呈現了最終效果:

看看倉庫編程語言爲 C++、Python、Go、Java 各自的貢獻者情況

可以看到,內核倉 nebula 採用了 C++,不少相關的周邊工具也用了 C++。因此,整個開源項目中 C++ 的貢獻者(點)還是比較多的。反之,目前只有 Python 客戶端 nebula-python、同步工具 auto_sync 和安裝工具 nebula-ansible 使用 Python 語言開發,因此相較於其他編程語言,contributor 數量並不多。

說到內核倉,我們來看看內核倉 nebula 的非僱員貢獻者情況:

通過合併同類型 pr 邊,根據邊的粗細我們可以看到核心倉的活躍貢獻者。留意上面那個 Java logo 的圖像,並非是 nebula 同 Java 聯誼了,而是 2020 年的 Committer ChenXU 用了 Java 的 logo 作爲頭像(狗頭)。

再來看看 2021 年誕生的非僱員 contributor 他們的貢獻情況

最後,來看看有哪些 pr 還沒被 merge,這裏需要用到 pr 邊的 is_merged 屬性(記得創建個索引哦~):

祝上面所有未被 merged 的 pr 都能被合併(雖然這是不可能的)。

nGQL 合集

這裏是上面所有查詢結果的對應 nGQL 查詢語句:

# 查看各個查詢語言的開源倉庫貢獻情況
match (v0:contributor) -[e:pr]-> (v1:repo) where (v1.repo.language == "C++") return e

match (v0:contributor) -[e:pr]-> (v1:repo) where (v1.repo.language == "Python") return e

match (v0:contributor) -[e:pr]-> (v1:repo) where (v1.repo.language == "Go") return e

match (v0:contributor) -[e:pr]-> (v1:repo) where (v1.repo.language == "Java") return e

# 內核倉 nebula 的非僱員貢獻者

match (v0:contributor) -[e:pr]-> (v1:repo) where (v1.repo.repo_name == "nebula" and v0.contributor.is_vesoft == false) return e

# 2021 年誕生的非僱員 contributor
match (v0:contributor) -[e:pr]-> (v1:repo) where (v0.contributor.anniversary >= datetime("2021-01-01T00:00:00") and v0.contributor.anniversary < datetime("2022-01-01T00:00:00")  ) and v0.contributor.is_vesoft ==false return e

# 目前未被合併的 pr
match (v0:contributor) -[e:pr]-> (v1:repo) where (e.is_merged == false) return e

數據集

本數據集爲 NebulaGraph 公開倉數據,統計截止時間爲 2023.03.20。因爲部分 datetime 屬性不能爲空,爲空字段人爲填充了爲 2038-01-19 03:14:07(timestamp 類型上限)。如果你要使用該數據集,記得留意 datetime 屬性值的處理。

數據集下載地址:nebula-contributor-dataset

最後,以此文感謝所有 nebula 社區的 contributor 們 lol


謝謝你讀完本文 (///▽///)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章