前言
本篇文章将介绍 OPENALYSIS 一个开源社区数据分析服务的开发思路与实现。我们将先对服务部署后的效果进行预览,然后从基本思路到具体实现了解整个项目的开发过程。
效果展示
服务部署成功后的数据展示面板效果如下,这里对字节跳动的开源社区 CloudWeGo 的数据进行统计和展示,其中展示的面板中包含了开源社区的:
- 主要数据统计,包括:总 Issue 数,总 PR 数,总 Fork 数,总 Star 数和总 Contributor 数(已去重);
- 所有贡献者的贡献排行榜;
- 贡献者的地区和公司;
- 贡献者发起的 Issue,PR 数量;
- 贡献者发起的第一个 PR 至今的天数;
- 贡献者参与的项目列表和数量;
- 所有仓库的 Issue,PR assignees(OPEN Issue,OPEN PR);
- 主要仓库的 Star,Fork,Issue,PR,Contributor 的变化趋势;
基本思路
这个项目的基本思路为通过 GitHub 提供的 API 获取到对应的数据,将获取的数据持久化存储到数据库中,最后通过 Grafana Dashboard 对数据库中存储的数据进行展示,同时需要使用定时任务的方式定时获取到最新的数据并对已经持久化的数据进行更新。
-
数据获取: GitHub 提供了 REST (v3) 和 GraphQL (v4) 两种不同类型的 API,我们主要使用 GraphQL API 进行数据获取,在获取一些 v4 不支持获取的数据时使用 REST API;
GraphQL API 相比 REST API 可以更灵活的获取到我们想要的数据,这个例子清晰的展示了 GraphQL 相比 REST 的优点。
-
数据存储: 我们使用最常见的 MySQL 数据库来持久化存储我们的数据。
-
数据分析: 我们使用 Grafana Dashboard 来查询 MySQL 数据库并通过设置不同类型的 Panel 来对数据进行展示。
如何实现
组的概念
由于 CloudWeGo 社区的各个仓库(repository)横跨了多个 GitHub 组织,并且存在单独的仓库,包括:
- github.com/cloudwego (org)
- github.com/kitex-contr... (org)
- github.com/hertz-contr... (org)
- github.com/volo-rs (org)
- github.com/bytedance/s... (repo)
- github.com/bytedance/m... (repo)
所以如果我们想要获取整个社区的数据则需要建立一个新的粒度来包含上述的这些组织和仓库。
在项目中我们建立了 组(Group) 的概念,一个组可以包含多个组织或者仓库,这样我们就可以将整个 CloudWeGo 社区视为一个组并以组为单位进行数据的获取,存储,分析等操作。
数据获取
由于我们主要使用 GitHub GraphQL API (v4) 进行数据获取,通过 GitHub 提供的 Public schema 我们可以根据想要获取的数据编写好对应的 GraphQL query,然后向 GitHub 的 GraphQL endpoint api.github.com/graphql 发起请求即可。
例如我们想要获取一个仓库的 Issue 数,PR 数,Star 数和 Fork 数,则其对应的 GraphQL query 如下:
graphql
query RepoInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
owner {
id
}
issues {
totalCount
}
pullRequests {
totalCount
}
stargazers {
totalCount
}
forks {
totalCount
}
}
}
其中 $owner
和 $name
是这个查询需要传入的参数,可以通过 Variables 的形式传入:
json
{
"owner": "cloudwego",
"name": "hertz"
}
但是这种简单的单次的查询还不足以完成所有的数据获取的需求。
GitHub 为了防止用户对 GitHub 服务器发起过多或滥用请求,可以通过 GraphQL API 单次查询获取的条目数量被限制在了 100 条。
例如,如果我们想要获取一个组织下的所有仓库的名字列表,但是这个组织所拥有的仓库总数为 150,那么我们在单次 GraphQL query 中最多只能获取到 100 个仓库的名字而剩下的 50 个仓库的名字只能在下次查询中获取。(这可能不是一个很好的例子,因为很少会有多于 100 个仓库的组织,但是你可以想象一个获取仓库的所有 Issue 的查询,而大多数大型的项目的 Issue 数量都超过了 100 个)
为了处理这种单次查询获取不到所有数据的情况,GitHub GraphQL API 提供了 Pagination,通过使用 Pagination 我们可以发起一系列连续的请求来获取到完整的数据。
继续上面的例子,使用了 Pagination 的获取一个组织下的所有仓库的名字列表的 GraphQL query 为:
graphql
query QueryReposByOrg($login: String!, $first: Int!, $after: String) {
organization(login: $login) {
repositories(first: $first, after: $after) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
nameWithOwner
}
}
}
}
其中 $login
,$first
和 $after
是这个查询需要传入的参数,$first
为单次查询获取的条目的数量,$after
为 cursor
,通过设置 cursor
可以从某个位置开始获取条目,这里为从 $after
位置获取 $first
数量个条目。
第一次查询的 Variables 的设置如下,由于发起第一次查询时我们不知道 cursor
的值,所以不进行设置:
json
{
"login": "cloudwego",
"first": 100,
"after": null
}
在第一次查询结束后我们就可以使用查询返回的 pageInfo
中的 hasNextPage
和 endCursor
的值来发起之后的请求。例如当我们判断 hasNextPage
的值为 true
时则说明还存在没有获取到的数据,这时我们只需要将 endCursor
的值作为参数传入下次 query 的 $after
字段,这样下次查询就会从本次查询获取到的最后的一个条目开始获取之后的数据条目。
具体到代码实现中我们可以通过 for
循环来完成这种使用 Pagination 的系列请求,以下是上面例子的 go 语言实现:
go
type RepoName struct {
Organization struct {
Repositories struct {
PageInfo struct {
HasNextPage bool
EndCursor string
}
Nodes []struct {
NameWithOwner string
}
} `graphql:"repositories(first: $first, after: $after)"`
} `graphql:"organization(login: $login)"`
}
// QueryRepoNameByOrg return repos of the provided org in `org/repo` format
func QueryRepoNameByOrg(ctx context.Context, login string) ([]string, error) {
query := &RepoName{}
variables := map[string]interface{}{
"login": githubv4.String(login),
"first": githubv4.Int(100),
"after": (*githubv4.String)(nil),
}
var (
repos []struct {
NameWithOwner string
}
names []string
)
for {
if err := GlobalV4Client.Query(ctx, query, variables); err != nil {
return nil, err
}
repos = append(repos, query.Organization.Repositories.Nodes...)
if !query.Organization.Repositories.PageInfo.HasNextPage {
break
}
variables["after"] = githubv4.NewString(githubv4.String(query.Organization.Repositories.PageInfo.EndCursor))
}
for _, repo := range repos {
names = append(names, repo.NameWithOwner)
}
return names, nil
}
通过使用分页我们就可以处理大部分的数据获取需求了。
一些踩坑分享:
issues
有since
字段,pullrequests
没有,所以可以通过根据上次获取时的时间轻松获取到所有在这个时间点后更新的issues
,而只能通过保存上次查询最后的cursor
来获取之后更新的pullrequests
;- 当你使用
cursor
发起一次查询后,但是并没有数据更新时,查询返回的cursor
为空而不是你发起查询时使用的cursor
值;- 有一些数据只能通过 REST API 获取;
数据存储和定时任务
项目中所有需要执行的项目分为了 InitTask
和 UpdateTask
,其中 InitTask
用于在项目启动时获取并存储设置的组(group)中包含的 org 和 repo 的全量数据,而 UpdateTask
用于在项目启动之后的定时任务中获取并存储对比上次获取更新的数据。
例如在 InitTask
中需要获取一个仓库所有的 Issue,而在 UpdateTask
中只需要获取一个仓库中在上次获取时间后新的或有过更新的 Issue。
在 UpdateTask
中除了对数据库中一部分旧的数据进行更新,还有一部分的数据是不需要进行更新的,因为我们需要保存历史的数据从而形成一种 时间序列(time series),这样在之后的数据分析时就可以构建出数据的变化趋势图。
下表就是一个简单的单维度时间序列,展示了 LGA 和 BOS 两个地点的温度变化情况:
StartTime | Temp | Location |
---|---|---|
09:00 | 24 | LGA |
09:00 | 20 | BOS |
10:00 | 26 | LGA |
10:00 | 22 | BOS |
具体到我们的项目中,我们需要将各个仓库(repository)的数据存储为时间序列,于是我们需要再每次 UpdateTask
中都获取所有仓库的 Issue 数,Star 数等数据并进行存储。
通过存储的时间序列我们就可以很容易的得到下面这样的变化趋势图:
另外需要注意的一个点是在我们将数据存储进 MySQL 时需要将一次完整的操作放进一个事务中,这样如果在进行数据获取时遇到了由于网络或者其他原因导致的报错,我们可以对整个事务进行回滚并重试,或者如果你进行一次更大的操作时则可以回滚事务中的一部分并进行重试。
数据分析
在通过 GitHub GraphQL API 获取到数据并存储到 MySQL 后我们就可以使用 Grafana 对这些数据进行分析与展示了。
Grafana 提供了一系列的可视化(Visualization)方式,这方便我们根据存储的数据的类型和方式来进行选择。例如之前提到的以时间序列方式存储的各个仓库的数据就可以使用 Grafana 的 Time series
Visualization 来进行展示,而像社区贡献者的公司和地点情况我们就可以使用 Pie chart
Visualization 来进行展示。
在选择好可视化方式后我们就可以通过对数据进行查询并展示了,由于我们使用 MySQL 作为我们的数据持久化手段,所以我们主要的数据查询方式就是 SQL。通过编写不同的 SQL 以不同的方式查询出对应的数据,结合 Grafana 提供的多种可视化方式我们就可以很容易的得到在文章最开始展示的内种效果。
例如下面是社区的贡献排行榜的可视化面板:
其对应的 SQL 如下所示,我们根据每个贡献者的 contributions 统计进行降序排列,通过使用 DENSE_RANK()
函数可以确保在处理具有相同 contributions 的贡献者时以连续的方式分配排名:
sql
SELECT
DENSE_RANK() OVER (ORDER BY SUM(contributions) DESC) AS 'Rank',
login AS 'Name',
SUM(contributions) AS 'Contributions'
FROM
openalysis.contributors
GROUP BY
login
ORDER BY
SUM(contributions) DESC;
总结
以上就是本篇文章的所有内容了,我们从最终的效果展示开始,然后具体到基本思路转化为实现的各个过程,了解了整个项目的开发过程。
OPENALYSIS 仍存在许多不足,例如没有提供扩展性更强的 Grafana Dashboard 等,这将在之后进行开发与解决。
希望本篇文章可以对读者有所帮助,如果哪里写错了或者有问题欢迎评论或者私聊指出,以上!