Свой артефактори для Android библиотек

25 minute read

Мне понадобилось публиковать библиотеки для Android (и iOS, но это потом). Очевидно, для этого нужен maven репозиторий.

Я не профи в maven, но обнаружил, что репозиторий устроен довольно просто. Самый легкий способ выложить библиотеку - опубликовать ее локально и скопировать в публичную папку на сервере. Тут нет никакой магии, все очевидно и в 90% случаев этого достаточно.

Но очень хочется иметь возможность запустить команду из грейдла и получить опубликованную библиотеку на сервере. Нужна возможность удалять артефакты, добавлять какое-нибудь описание для артефакта и версии. Желательно, красиво выводить список артефактов на веб-страничке

Конечно, задача не новая. Сейчас есть много инструментов, которые можно установить на свой сервер. Можно использовать доступные публичные сервисы и паблишить сколько угодно библиотек. Что я сходу нагуглил:

  • JFrog - классика, которая используется в больших компаниях. Но вам такой комбайн вряд ли понадобится
  • nexus - это самая универсальная штука.Тут есть вообще все и даже свой собственный docker hub
  • gitflic - да, можно не заморачиваться с селфхостед, а выкладывать все в реджистри на gitflic
  • github - тож самое как и c gitflic
  • reposilite - вот это очень классный аналог. Простой и понятный сервис, который позволяет опубликовать библиотеки одной gradle командой. Есть подготовленный docker контейнер. Но! У него один критический недостаток - сделан не мной

В результате моего небольшого исследования я написал селфхотстед сервис - depot. Построен на базе PocketBase с несколькими дополнительными ручками и главной страницей со списком артефактов

Структура данных

Сначала определимся с терминами. В контексте статьи репозиторий и реджистри - это одинаковые штуки, источник откуда вы можете скачать нужную версию. Артефактом я называю саму библиотеку, а версией - конкретную сборку библиотеки. Те нельзя просто скачать артефакт, это некоторая абстракция. Скачать можно только конкретную версию

Для мой задачи нужно всего две коллекции - артефакты и версии.

Для артефакта нам нужно:

  • name - название библиотеки
  • description - описание библиотеки
  • group - группа или неймспес для библиотеки, например ru.kovardin
  • type - можно выбрать ios или android
  • last - дата последнего обновления
  • enabled - доступность библиотеки

Поля будут заполняться автоматически при публикации библиотеки. По умолчанию библиотеки будут доступны и enabled будет выставлен в true. Поле description нужно заполнять руками для каждого артефакта

С версиями тоже никаких сложностей

Для артефакта нам нужно:

  • name - название библиотеки
  • notes - описание что изменилось в этой версии
  • version - версия
  • enable - доступна ли версия для установки

Notes - нужно заполнять для каждой версии самостоятельно

Создав две описанные выше коллекции, мы сделали половину сервиса для публикации библиотек. Осталось добавить логику загрузки и красивый список артефактов

Загрузка артефактов

Перед тем как начать загружать артефакты, нужно разобраться как добавить репозиторий в настройки Android проекта

В файле build.gradle для модуля который хотим опубликовать(например, somelib) добавляем код

 1repositories {
 2    // ...
 3    maven {
 4        Properties properties = new Properties()
 5        properties.load(project.rootProject.file('local.properties').newDataInputStream())
 6
 7        def uploadToken = properties.getProperty('uploadToken')
 8
 9        name = "depot"
10        url = "https://depot.kovardin.ru/packages"
11        credentials(HttpHeaderCredentials) {
12            name = "Authorization"
13            value =  uploadToken
14        }
15        authentication {
16            header(HttpHeaderAuthentication)
17        }
18    }
19}

Конечно, мы не хотим дать всем возможность опубликовать что угодно в нашем реджестри, поэтому мы будем указывать токен для авторизации. Сам токен указан в файле local.properties, а тут мы получаем его из файла и передаем в заголовок Authorization. Чуть позже укажу как получить эту самую авторизацию

  • depot.kovardin.ru/packages - это адрес моего реджистри
  • depot - название репозитория, может быть любое. От этого название будет зависеть название gradle команды для публикации

Указав все необходимые настройки, в gradle появиться команда вида:

./gradlew somelib:publishAllPublicationsToDepotRepository

После запуска этой команды, gradle соберет необходимые артефакты и отправит их на сервер в PUT запросе

Пример такого запроса

1PUT /packages/ru/kovardin/somelib/0.1.1/somelib-0.1.1.aar
2PUT /packages/ru/kovardin/somelib/maven-metadata.xml
3...

Название файла будет доступно в path, а содержимое каждого файла прилетит в теле запроса. Для работы с такими запросами, создаем новую ручку artifacts.Publish и добавляем ее для обработки PUT запросов

1e.Router.PUT("/packages/*", artifacts.Publish, apis.RequireAdminAuth())

apis.RequireAdminAuth() - это миделваря, которая делает ручку доступной только для администратора.

В методе artifacts.Publish нужно получить название файла, его содержимое и сохранить все на сервере.

 1file := strings.Replace(c.Request().URL.Path, "/packages/", "", -1)
 2
 3defer c.Request().Body.Close()
 4
 5uploadFolder := path.Join(h.settings.UploadFolder(""), filepath.Dir(file))
 6uploadFile := path.Join(uploadFolder, filepath.Base(file))
 7
 8if err := os.MkdirAll(uploadFolder, os.ModePerm); err != nil {
 9    return err
10}
11
12dst, err := os.Create(uploadFile)
13if err != nil {
14    return err
15}
16
17defer dst.Close()
18
19if _, err := io.Copy(dst, c.Request().Body); err != nil {
20    return err
21}

Нужно создать новый файл на сервере в локации ru/kovardin/somelib/ и записать в него все содержимое из c.Request().Body

Теперь нужно создать сущности в базе. Сначала создаем запись о артефакте. Чтобы узнать название и группу артефакта - распарсим файл maven-metadata.xml. Кроме артефакта, в этом файле указаны названия доступных версий:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<metadata>
 3  <groupId>ru.kovardin</groupId>
 4  <artifactId>boosty</artifactId>
 5  <versioning>
 6    <latest>0.1.1</latest>
 7    <release>0.1.1</release>
 8    <versions>
 9      <version>0.1.1</version>
10    </versions>
11    <lastUpdated>20240818151425</lastUpdated>
12  </versioning>
13</metadata>

Парсим файл и сохраняем все в базу:

 1if filepath.Base(uploadFile) == "maven-metadata.xml" {
 2    metaFile := uploadFile
 3    data, err := os.ReadFile(metaFile)
 4    if err != nil {
 5        return err
 6    }
 7
 8    metadata := Metadata{}
 9    if err := xml.Unmarshal(data, &metadata); err != nil {
10        return err
11    }
12
13    artifact, _ := h.app.Dao().FindFirstRecordByFilter(
14        "artifacts",
15        "group = {:group} && name = {:name}",
16        dbx.Params{"group": metadata.GroupId, "name": metadata.ArtifactId},
17    )
18    if artifact == nil {
19        collection, err := h.app.Dao().FindCollectionByNameOrId("artifacts")
20        if err != nil {
21            return err
22        }
23
24        artifact = models.NewRecord(collection)
25    }
26
27    artifact.Set("name", metadata.ArtifactId)
28    artifact.Set("group", metadata.GroupId)
29    artifact.Set("type", "android")
30    artifact.Set("enabled", true)
31
32    if err := h.app.Dao().SaveRecord(artifact); err != nil {
33        return err
34    }
35// ...
36}

Осталось добавить в базу версии, указанные в файле maven-metadata.xml

 1for _, ver := range metadata.Versioning.Versions.Version {
 2    version, _ := h.app.Dao().FindFirstRecordByFilter("versions",
 3        "name = {:name} && version = {:version} && artifact = {:artifact}",
 4        dbx.Params{"name": metadata.ArtifactId, "version": ver, "artifact": artifact.Id},
 5    )
 6    if version == nil {
 7        collection, err := h.app.Dao().FindCollectionByNameOrId("versions")
 8        if err != nil {
 9            return err
10        }
11
12        version = models.NewRecord(collection)
13    }
14
15    version.Set("name", metadata.ArtifactId)
16    version.Set("version", ver)
17    version.Set("artifact", artifact.Id)
18    version.Set("enabled", true)
19
20    if err := h.app.Dao().SaveRecord(version); err != nil {
21        return err
22    }
23}

Отлично, новая версия библиотеки загружена на сервер. Нужно реализовать возможность скачивать эти версии. Gradle для скачивания отправляет два запроса GET и HEAD. Реализуем очень простые ручки, которые позволяют отдать любой файл по запросу

1e.Router.GET("/packages/*", apis.StaticDirectoryHandler(os.DirFS(settings.UploadFolder("")), true))
2e.Router.HEAD("/packages/*", apis.StaticDirectoryHandler(os.DirFS(settings.UploadFolder("")), true))

Скачивать и устанавливать библиотеки могут все пользователи, поэтому тут нет миделвари apis.RequireAdminAuth()

Авторизация

Теперь о том, как получить токен администратора для загрузки библиотеки. В PocketBase уже есть API для работы с авторизацией для администратор. Чтобы получить токен, нужно оправить POST запрос на ручку /api/admins/auth-with-password

1POST https://127.0.0.1:8080/api/admins/auth-with-password
2Content-Type: application/json
3
4{
5    "identity": "example@yandex.ru",
6    "password": "password"
7}

В ответ вернется JSON с полем token

 1{
 2  "admin": {
 3    "id": "zf8ehix5ljg2fu5",
 4    "created": "2024-08-04 23:10:15.851Z",
 5    "updated": "2024-08-04 23:10:15.851Z",
 6    "avatar": 0,
 7    "email": "example@yandex.ru"
 8  },
 9  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjUyMDU1NTEsImlkIjoiemY4ZWhpeDVsamcyZnU1IiwidHlwZSI6ImFkbWluIn0.j7utkMnSHvd9jNLKCZJIoZ9SKhPt2Tcj1DuuIZCeUOo"
10}

Этот token нужно использовать для авторизации при загрузке библиотеки

Список артефактов

Уже сейчас достаточно функциональности, чтобы использовать сервис для публикации и использования библиотек в ваших Android проектах. Но добавим чуть-чуть красоты и выведем список библиотек на главной странице сервиса.

Для этого получаем список артефактов и последнюю версию:

 1records, err := h.app.Dao().FindRecordsByFilter(
 2    "artifacts",
 3    "enabled = true",
 4    "-created",
 5    100,
 6    0,
 7)
 8
 9if err != nil {
10    h.app.Logger().Error("failed fetch artifacts", "err", err)
11}
12
13aa := []Artifact{}
14for _, record := range records {
15    versions, err := h.app.Dao().FindRecordsByFilter(
16        "versions",
17        "enabled = true && artifact = {:artifact}",
18        "-created",
19        1,
20        0,
21        dbx.Params{"artifact": record.Id},
22    )
23
24    if err != nil {
25        h.app.Logger().Error("failed fetch versions", "err", err)
26
27        continue
28    }
29
30    v := ""
31    if len(versions) > 0 {
32        v = versions[0].GetString("version")
33    }
34
35    aa = append(aa, Artifact{
36        Name:        record.GetString("name"),
37        Group:       record.GetString("group"),
38        Description: record.GetString("description"),
39        LastVersion: v,
40    })
41}
42
43html, err := h.registry.LoadFS(views.FS,
44    "layout.html",
45    "home/home.html",
46).Render(map[string]any{
47    "artifacts": aa,
48})

В результате получаем красивую страничку со списком всех доступных библиотек

Конечно, еще много всего нужно улучшить и добавить:

  • Сделать докер образ для быстрой установки
  • Добавить список всех доступных версий
  • Доработать удаление артефактов
  • Добавить возможность закрывать доступ к артефактам по тоглу enabled
  • Переключать репозиторий в приватный публичный режим
  • Добавить регистрацию пользователей
  • Написать подробную документацию

Надеюсь, у меня хватит времени добраться до всех этих задач. А сейчас проект доступен по на gitflic - depot

Ссылки