Статьи

Социальные графы GitHub с Groovy и GraphViz

Цель

Использование GitHub API, Groovy и GraphViz для определения, интерпретации и отображения графика отношений между пользователями GitHub на основе наблюдателей их репозиториев. Конечный результат может выглядеть примерно так.

GitHub V3 API

Вы можете найти полную документацию по GitHub V3 API здесь. Они отлично справляются с документированием различных конечных точек и их поведением, а также демонстрируют интенсивное использование API с помощью curl. Для целей этого поста API-вызовы, которые я делаю, представляют собой простые запросы GET, которые не требуют аутентификации. В частности, я нацеливаюсь на две конкретные конечные точки: репозитории для конкретного пользователя и наблюдатели для репозитория.

Ограничения API

Несмотря на значительное повышение скорости передачи запросов по API-интерфейсу V2 до 50 запросов в час, я обнаружил, что довольно просто исчерпать 5000 запросов в час, предоставляемых API V3, при сборе данных. К счастью, в каждый ответ от GitHub входит удобный заголовок X-RateLimit-Remaining, который мы можем использовать для проверки нашего лимита. Это позволяет нам остановить обработку до того, как у нас закончатся запросы, после чего GitHub будет возвращать ошибки для каждого запроса. Для каждого пользователя мы проверяем один URL, чтобы найти свои репозитории, и для каждого из этих репозиториев выполняем отдельный запрос, чтобы найти всех наблюдателей. Выполняя эти запросы, используя свою учетную запись GitHub в качестве центральной точки, я смог собрать информацию о репозитории о 1143 пользователях и найти 31142 наблюдателя, 18023 из которых были уникальными в собранных данных. Это несколько не точная цифра, так как последовательно, после достижения предела скорости, в очереди осталось гораздо больше узлов для обработки, чем уже встречалось. У меня всего 31 наблюдатель за репозиторием, но на графике мы видим таких пользователей, как igrigorik, сотрудник Google с 529 наблюдателями за репозиторием, и это имеет тенденцию несколько искажать результаты. Конечным результатом является то, что данные, представленные здесь, далеки от завершения, извините, но это не значит, что их не интересно представлять.

Groovy и HttpBuilder

Groovy и HttpBuilder dsl абстрагируют большинство деталей обработки HTTP-соединений. График, который я строю, начинается с одного центрального пользователя GitHub и связывает этого пользователя со всеми, кто в настоящее время просматривает одно из своих хранилищ. Для этого требуется один запрос GET для загрузки всех репозиториев для данного пользователя и запрос GET для каждого репозитория для поиска наблюдателей. Эти две операции HTTP очень легко инкапсулируются с помощью Closures с помощью оболочки HttpBuilder вокруг HttpClient. Каждый вызов возвращает значение X-RateLimit-Remaining и запрошенные данные. Вот как выглядит конфигурация HttpBuilder:

1
2
final String rootUrl = 'https://api.github.com'
final HTTPBuilder builder = new HTTPBuilder(rootUrl)

Объект компоновщика создается и фиксируется в URL-адресе GitHub, что упрощает синтаксис для будущих вызовов. Теперь мы определяем два замыкания, каждое из которых нацеливается на определенный URL и извлекает соответствующие данные из (уже автоматически разобранного HttpBuilder) ответа JSON. В FindWatchers Closure есть немного больше логики для удаления повторяющихся записей и исключения самого пользователя из списка, так как по умолчанию GitHub записывает ссылку на себя для всех пользователей со своими собственными репозиториями.

01
02
03
04
05
06
07
08
09
10
11
final String RATE_LIMIT_HEADER = 'X-RateLimit-Remaining'
final Closure findReposForUser = { HTTPBuilder http, username ->
    http.get(path: "/users/$username/repos", contentType: JSON) { resp, json ->
        return [resp.headers[RATE_LIMIT_HEADER].value as int, json.toList()]
    }
}
final Closure findWatchers = { HTTPBuilder http, username, repo ->
    http.get(path: "/repos/$username/$repo/watchers", contentType: JSON) { resp, json ->
        return [resp.headers[RATE_LIMIT_HEADER].value as int, json.toList()*.login.flatten().unique() - username]
    }
}

Исходя из этих данных, мы заинтересованы (пока) в том, чтобы сохранить простую карту имени пользователя -> наблюдателей, которую мы можем легко собрать как объект JSON и сохранить в файле. Полный код скрипта Groovy для загрузки данных можно запустить из командной строки, используя следующий код, или выполнить удаленно из командной строки GitHub в командной строке, вызвав groovy https://raw.github.com/gist/2468052/5d536c5a35154defb5614bed78b325eeadbdc1a7/ repos.groovy {имя пользователя}. В любом случае вы должны указать имя пользователя, на котором вы хотите центрировать график. Результаты будут выведены в файл с именем ‘reposOutput.json’ в рабочем каталоге. Пожалуйста, будьте терпеливы, так как это займет немного времени; Прогресс выводится на консоль, поскольку каждый пользователь обрабатывается, чтобы вы могли следить за ним.

01
02
03
04
05
06
07
08
09
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.5.2')
import groovy.json.JsonBuilder
import groovyx.net.http.HTTPBuilder
import static groovyx.net.http.ContentType.JSON
  
final rootUser = args[0]
final String RATE_LIMIT_HEADER = 'X-RateLimit-Remaining'
final String rootUrl = 'https://api.github.com'
final Closure<Boolean> hasWatchers = {it.watchers > 1}
final Closure findReposForUser = { HTTPBuilder http, username ->
    http.get(path: "/users/$username/repos", contentType: JSON) { resp, json ->
        return [resp.headers[RATE_LIMIT_HEADER].value as int, json.toList()]
    }
}
final Closure findWatchers = { HTTPBuilder http, username, repo ->
    http.get(path: "/repos/$username/$repo/watchers", contentType: JSON) { resp, json ->
        return [resp.headers[RATE_LIMIT_HEADER].value as int, json.toList()*.login.flatten().unique() - username]
    }
}
  
LinkedList nodes = [rootUser] as LinkedList
Map<String, List> usersToRepos = [:]
Map<String, List<String>> watcherMap = [:]
boolean hasRemainingCalls = true
final HTTPBuilder builder = new HTTPBuilder(rootUrl)
while(!nodes.isEmpty() && hasRemainingCalls)
{
    String username = nodes.remove()
    println "processing $username"
    println "remaining nodes = ${nodes.size()}"
  
    def remainingApiCalls, repos, watchers
    (remainingApiCalls, repos) = findReposForUser(builder, username)
    usersToRepos[username] = repos
    hasRemainingCalls = remainingApiCalls > 300
    repos.findAll(hasWatchers).each{ repo ->
        (remainingApiCalls, watchers) =  findWatchers(builder, username, repo.name)
        def oldValue = watcherMap.get(username, [] as LinkedHashSet)
        oldValue.addAll(watchers)
        watcherMap[username] =  oldValue
        nodes.addAll(watchers)
        nodes.removeAll(watcherMap.keySet())
        hasRemainingCalls = remainingApiCalls > 300
    }
    if(!hasRemainingCalls)
    {
        println "Stopped with $remainingApiCalls api calls left."
        println "Still have not processed ${nodes.size()} users."
    }
}
  
new File('reposOutput.json').withWriter {writer ->
    writer << new JsonBuilder(watcherMap).toPrettyString()
}

Файл JSON содержит очень простые данные, которые выглядят так:

01
02
03
04
05
06
07
08
09
10
11
12
13
"bmuschko": [
        "claymccoy",
        "AskDrCatcher",
        "roycef",
        "btilford",
        "madsloen",
        "phaggood",
        "jpelgrim",
        "mrdanparker",
        "rahimhirani",
        "seymores",
        "AlBaker",
        "david-resnick", ...

Теперь нам нужно взять эти данные и превратить их в представление, которое может понять GraphViz. Мы также собираемся добавить информацию о количестве наблюдателей для каждого пользователя и ссылку на его страницу GitHub.

Генерация файла GraphViz в точечном формате

GraphViz — это популярный фреймворк для генерации графов. Краеугольным камнем этого является простой формат для описания ориентированного графа в простом текстовом файле (обычно называемом «точечным» файлом) в сочетании с множеством различных макетов для отображения графика. Для целей этого поста, я после описания следующего для включения в график:

  • Преимущество каждого наблюдателя для пользователя, чей репозиторий они наблюдают.
  • Метка на каждом узле, которая включает имя пользователя и количество наблюдателей для всех их репозиториев.
  • Встроенная HTML-ссылка на страницу GitHub пользователя на каждом узле. Выделите начального пользователя на графике, закрасив этот узел красным.
  • Назначение атрибута rank для узлов, которые связывают всех пользователей с одинаковым количеством наблюдателей.

Сценарий, который я использую для создания файла ‘dot’, в значительной степени является просто обработкой грубой силы, а полный исходный код доступен в виде гистограммы, но вот интересные части. Во-первых, загрузка в файл JSON, который был выведен на последнем шаге; преобразовать его в структуру карты очень просто:

1
2
3
4
def data
new File(filename).withReader {reader ->
   data = new JsonSlurper().parse(reader)
}

Из этой структуры данных мы можем извлечь конкретные детали и сгруппировать все по количеству наблюдателей на пользователя.

1
2
3
4
5
6
7
8
9
println "Number of mapped users = ${data.size()}"
println "Number of watchers = ${data.values().flatten().size()}"
println "Number of unique watchers = ${data.values().flatten().unique().size()}"
  
//group the data by the number of watchers
final Map groupedData = data.groupBy {it.value.size()}.sort {-it.key}
final Set allWatchers = data.collect {it.value}.flatten()
final Set allUsernames = data.keySet()
final Set leafNodes = allWatchers - allUsernames

Учитывая эти данные, мы создаем отдельные узлы с деталями стиля, например:

01
02
03
04
05
06
07
08
09
10
11
12
13
StringWriter writer = new StringWriter()
groupedUsers.each {count, users ->
    users.each { username, watchers ->
        def user = "\t\"$username\""
        def attrs = generateNodeAttrsMemoized(username, count)
        def rootAttrs = "fillcolor=red style=filled $attrs"
        if (username == rootUser) {
            writer << "$user [$rootAttrs];\n"
        } else {
            writer << "$user [$attrs ${extraAttrsMemoized(count, username)}];\n"
        }
    }
}

И это генерирует описания узлов и ребер, которые выглядят так:

1
2
3
4
5
6
7
8
9
...
 "gyurisc" [label="gyurisc = 31" URL="https://github.com/gyurisc" ];
 "kellyrob99" [fillcolor=red style=filled label="kellyrob99 = 31"
                 URL="https://github.com/kellyrob99"];
...
 "JulianDevilleSmith" -> "cfxram";
 "rhyolight" -> "aalmiray";
 "kellyrob99" -> "aalmiray";
...

Если вы уже создали данные JSON, вы можете запустить эту команду в том же каталоге, чтобы сгенерировать точечный файл GraphViz: groovy https://raw.github.com/gist/2475460/78642d81dd9bc95f099e0f96c3d87389a1ef6967/githubWatcherDigraphGenerator.groovy {имя пользователя} .json. Это создаст файл с именем ‘reposDigraph.dot’ в этом каталоге. Оттуда последний шаг должен интерпретировать определение графа в изображение.

Превращение точечного файла в изображение

Я искал быстрый и простой способ быстрой генерации нескольких визуализаций из одной и той же модели для сравнения и остановился на использовании GPars для их одновременной генерации. Здесь нужно быть немного осторожнее, поскольку для некоторых комбинаций макета / формата может потребоваться немало памяти и процессора — в худших случаях — до 2 ГБ памяти и время обработки в диапазоне часов. Я рекомендую придерживаться макетов sfdp и twopi (см. Онлайн-документацию здесь) для графиков, размер которых аналогичен описанному здесь. Если вам нужна огромная, потрясающая графика с полной детализацией, ожидайте, что изображение png будет весить где-то к северу от 150 МБ, тогда как соответствующий файл SVG будет меньше 10 МБ. Этот скрипт Groovy зависит от наличия уже установленного исполняемого файла ‘точка’ командной строки GraphViz, использует шесть доступных алгоритмов компоновки и генерирует файлы png и svn, используя четыре одновременно.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
import groovyx.gpars.GParsPool
  
def inputfile = args[0]
def layouts = [ 'dot', 'neato', 'twopi', 'sfdp', 'osage', 'circo' ] //NOTE some of these will fail to process large graphs
def formats = [ 'png', 'svg']
def combinations = [layouts, formats].combinations()
  
GParsPool.withPool(4) {
    combinations.eachParallel { combination ->
        String layout = combination[0]
        String format = combination[1]
        List args = [ '/usr/local/bin/dot', "-K$layout", '-Goverlap=prism', '-Goverlap_scaling=-10', "-T$format",
                '-o', "${inputfile}.${layout}.$format", inputfile ]
        println args
        final Process execute = args.execute()
        execute.waitFor()
        println execute.exitValue()
    }
}

Вот галерея с несколькими примерами изображений, созданных и уменьшенных для удобства работы в Интернете. Полноразмерные графики, которые я сгенерировал, используя эти данные, весили до 300 МБ для одного файла PNG. Формат SVG занимает значительно меньше места, но все же занимает более 10 МБ. У меня также были проблемы с поиском средства просмотра для формата SVG, которое а) могло бы отображать большой график с помощью навигации и б) не вызывало сбой в работе моего браузера из-за использования памяти.

И просто для удовольствия

Первоначально я намеревался опубликовать эту функцию в качестве приложения на Google App Engine с использованием Gaelyk, но, поскольку ограничение API сделало бы его подходящим для одного запроса в час, и, вероятно, у меня возникли проблемы с GitHub, я в итоге отказался от этого немного. Но на этом пути я разработал очень простую страницу, которая будет загружать все общедоступные списки для определенного пользователя и отображать их в виде таблицы. Это довольно чистый пример того, как вы можете быстро и грязно создать приложение и сделать его общедоступным с помощью GAE + Gaelyk. Это включало в себя настройку инфраструктуры с использованием gradle-gaelyk-plugin в сочетании с gradle-gae-plugin и использованием Gradle для создания, тестирования и развертывания приложения в Интернете — все это потребовало около часа усилий. Попробуйте эту ссылку, чтобы загрузить все мои общедоступные списки Gists — замените параметр username, если вы хотите проверить кого-то еще. Пожалуйста, дайте ему секунду, поскольку GAE будет отменять развертывание приложения, если оно не было запрошено какое-то время, поэтому первый вызов может занять несколько секунд.
http://publicgists.appspot.com/gist?username=kellyrob99

Вот реализация Groovlet, которая загружает данные и затем перенаправляет их на страницу шаблона.

1
2
3
4
5
6
7
def username =  request.getParameter('username') ?: 'kellyrob99'
def text = "https://gist.github.com/api/v1/json/gists/$username".toURL().text
log.info text
request.setAttribute('rawJSON', text)
request.setAttribute('username', username)
  
forward '/WEB-INF/pages/gist.gtpl'

И сопровождающая страница шаблона, которая представляет простое табличное представление запроса API.

01
02
03
04
05
06
07
08
09
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
<% include '/WEB-INF/includes/header.gtpl' %>
<% import groovy.json.JsonSlurper %>
<%
   def gistMap = new JsonSlurper().parseText(request['rawJSON'])
%>
<h1>Public Gists for username : ${request['username']} </h1>
  
<p>
    <table class = "gridtable">
        <th>Description</th>
        <th>Web page</th>
        <th>Repo</th>
        <th>Owner</th>
        <th>Files</th>
        <th>Created at</th>
        <%
        gistMap.gists.each { data ->
            def repo = data.repo
        %>
            <tr>
                <td>${data.description ?: ''}</td>
                <td>
                    <a href="https://gist.github.com/${repo}">${repo}</a>
                </td>
                <td>
                    <a href= "git://gist.github.com/${repo}.git">${repo}</a>
                </td>
                <td>{data.owner}</td>
                <td>${data.files}</td>
                <td>${data.created_at}</td>
            </tr>
        <% } %>
    </table>
</p>
<% include '/WEB-INF/includes/footer.gtpl' %>

Ссылка: GitHub Social Graphs с Groovy и GraphViz от нашего партнера по JCG Келли Робинсон в блоге The Kaptain на… материале .