Пишем менеджер пакетов

Разработка пакетного менеджера — не самая частая задача в программировании, прямо скажем. Их и готовых более чем достаточно. И все же я обнаружил себя именно в такой ситуации.

Как так вышло

Я большой поклонник СУБД SQLite и ее расширений. Этих расширений люди написали уже вагон, так что мне хотелось более упорядоченно с ними работать. Обычно для этого используют как раз менеджер пакетов, но для SQLite его не было. Вот и пришлось написать свой.

Если вы раньше не сталкивались с расширениями SQLite — это просто библиотеки (.dll, .dylib или .so в зависимости от операционной системы). Чтобы расширение заработало, достаточно скачать файл с библиотекой и загрузить в SQLite.

Надо сказать, что разработка пакетного менеджера — не слишком простая задача. Сэм Бойер еще в 2016 году написал отличную статью о проблемах, с которыми придется столкнуться. Так что я не буду на этом останавливаться.

В этой статье я разберу архитектуру и детали реализации, благодаря которым удалось написать законченный менеджер пакетов за пару недель (правильнее сказать, «вечеров и ночей»). Я постарался оставить за скобками специфику SQLite, так что, надеюсь, вы сможете применить этот подход к любому менеджеру пакетов, который решите написать.

Архитектура

Управление пакетами в общем виде — довольно сложная задача, и серебряной пули для нее не существует. Но если ввести некоторые ограничения, то сложность можно и снизить. Так что давайте пройдемся по всем компонентам программы, решая проблемы по мере возникновения.

спецификациякаталогивидимостьреестрверсияlatest-версиялокфайлисточник правдыконтрольные суммызависимостиустановка и обновление

Спецификация

Чтобы работать с пакетами, менеджеру нужна информация о них. Как минимум, идентификатор пакета и путь к скачиванию. Поэтому спроектируем спецификацию, которая описывает пакет.

Вот простая спецификация:

{
    "owner": "sqlite",
    "name": "stmt",
    "assets": {
        "path": "https://github.com/nalgeon/sqlean/releases/download/incubator",
        "files": {
            "darwin-amd64": "stmt.dylib",
            "darwin-arm64": "stmt.dylib",
            "linux-amd64": "stmt.so",
            "windows-amd64": "stmt.dll"
        }
    }
}

owner + name — уникальный идентификатор (нам не нужны конфликты имен в глобальном пространстве, как случилось в Python).

assets.path — базовый URL для ресурсов (файлов) пакета. Сами ресурсы перечислены в assets.files. Когда менеджер устанавливает пакет, он выбирает название файла согласно операционной системе пользователя, объединяет с assets.path, и скачивает файл.

> install sqlite/stmt
   ↓
download spec
┌───────────────┐
│  sqlite/stmt  │
└───────────────┘
   ↓
check platform
  ↪ OS: darwin
  ↪ arch: arm64
   ↓
download asset
┌───────────────┐
│  stmt.dylib   │
└───────────────┘

Хорошее начало!

Структура каталогов

Предположим, автор разместил пакет где-нибудь на гитхабе. Я командую менеджеру (sqlpkg далее по тексту) установить его:

sqlpkg install sqlite/stmt

Менеджер скачивает пакет и сохраняет его локально в каталоге .sqlpkg:

.sqlpkg
└── sqlite
    └── stmt
        ├── sqlpkg.json
        └── stmt.dylib

(sqlpkg.json — это спецификация, а stmt.dylib — ресурс пакета)

Установим еще один:

sqlpkg install asg017/vss
.sqlpkg
├── asg017
│   └── vss
│       ├── sqlpkg.json
│       └── vss0.dylib
│
└── sqlite
    └── stmt
        ├── sqlpkg.json
        └── stmt.dylib

С такой структурой каталогов менеджер может легко управлять установленными пакетами.

Например, если выполнить sqlpkg update OWNER/NAME, вот как он поступит:

  1. Прочитает спецификацию из .sqlpkg/OWNER/NAME/sqlpkg.json.
  2. Скачает свежую версию ресурса, используя assets.path из спецификации.
  3. Заменит старый файл .dylib на новый.

Если выполнить sqlpkg uninstall OWNER/NAME, менеджер удалит соответствующий каталог.

А если выполнить sqlpkg list — найдет все пути, подходящие под маску .sqlpkg/*/*/sqlpkg.json.

Довольно просто, не так ли?

Область видимости

Некоторые менеджеры пакетов (например, npm) по умолчанию устанавливают пакеты для конкретного проекта, но разрешают установить и глобально, указав специальный флаг (npm install -g). Другие (например, brew) устанавливают проекты в глобальной области видимости.

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

  • Если в текущем каталоге есть папка .sqlpkg — использовать проектную область видимости.
  • Иначе использовать глобальную.

Если пользователям не нужны проекты, они будут просто запускать sqlpkg, и все пакеты попадут в домашний каталог (например, ~/.sqlpkg). А если нужны — предварительно создадут отдельный .sqlpkg для проекта (можно предусмотреть специальную команду init для этого).

Проектная область видимости:

$ cd /my/project
$ sqlpkg init
$ sqlpkg install sqlite/stmt
$ tree .sqlpkg

.sqlpkg
└── sqlite
    └── stmt
        ├── sqlpkg.json
        └── stmt.dylib

Глобальная область видимости:

$ cd /some/other/path
$ sqlpkg install sqlite/stmt
$ tree ~/.sqlpkg

/Users/anton/.sqlpkg
└── sqlite
    └── stmt
        ├── sqlpkg.json
        └── stmt.dylib

И никаких флагов!

Реестр пакетов

Чтобы менеджер пакетов действительно был полезен, он должен поддерживать уже написанные расширения (которые, разумеется, понятия не имеют о его существовании). Возможно, авторы расширений в итоге напишут спецификации, а возможно и нет — полагаться на это не стоит.

Так что добавим запасной вариант. Если пользователь выполнит sqlpkg install OWNER/NAME, менеджер сделает вот что:

  1. Попытается загрузить спецификацию из «родного» репозитория пакета https://github.com/OWNER/NAME.
  2. Если спецификации там нет — загрузит из общего реестра пакетов.
 owner/name
    ↓
┌─────────────────┐ found ┌───────────┐
│  owner's repo   │   →   │  install  │
└─────────────────┘       └───────────┘
    ↓ not found
┌─────────────────┐ found ┌───────────┐
│  pkg registry   │   →   │  install  │
└─────────────────┘       └───────────┘
    ↓ not found
 ✗ error

Реестр пакетов — это просто еще один гитхаб-репозиторий с двухуровневой структурой каталогов вида owner/name:

pkg/
├── asg017
│   ├── fastrand.json
│   ├── hello.json
│   ├── html.json
│   └── ...
├── daschr
│   └── cron.json
├── dessus
│   ├── besttype.json
│   ├── fcmp.json
│   └── ...
├── ...
...

Мы заранее заполним реестр известными пакетами, так что менеджер заработает «из коробки». А по мере того, как авторы пакетов будут добавлять sqlpkg.json в свои репозитории, менеджер будет автоматически переключаться на них вместо общего реестра.

Еще менеджер должен поддерживать полные ссылки на репозитории (на случай, если название репозитория отличается от названия пакета):

sqlpkg install github.com/asg017/sqlite-vss

И вообще любые другие ссылки (не все ведь используют гитхаб):

sqlpkg install https://antonz.org/downloads/stats.json

А также локальные пути:

sqlpkg install ./stats.json

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

Версия

Что за пакет без версии, верно? Добавим ее:

{
    "owner": "asg017",
    "name": "vss",
    "version": "v0.1.1",
    "repository": "https://github.com/asg017/sqlite-vss",
    "assets": {
        "path": "{repository}/releases/download/{version}",
        "files": {
            "darwin-amd64": "vss-{version}-macos-x86_64.tar.gz",
            "darwin-arm64": "vss-{version}-macos-aarch64.tar.gz",
            "linux-amd64": "vss-{version}-linux-x86_64.tar.gz"
        }
    }
}

Заодно добавили переменные вроде {repository} и {version} — чтобы авторам пакетов не приходилось писать одно и то же по три раза.

При обновлении пакета менеджер сравнит локальную и внешнюю (remote) версии по правилам семантического версионирования:

   local spec    │    remote spec
                 │
> update         │
┌─────────────┐  │  ┌─────────────┐
│   v0.1.0    │  <  │   v0.1.1    │
└─────────────┘  │  └─────────────┘
   ↓             │
updating...      │
┌─────────────┐  │
│   v0.1.1    │  │
└─────────────┘  │

Хорошо!

Latest-версия

Неплохо бы поддержать в спецификации latest-версию, которую менеджер пакетов будет автоматически превращать в конкретный номер через API гитхаба:

{
    "owner": "asg017",
    "name": "vss",
    "version": "latest",
    "repository": "https://github.com/asg017/sqlite-vss",
    "assets": {
        "path": "{repository}/releases/download/{version}",
        "files": {
            "darwin-amd64": "vss-{version}-macos-x86_64.tar.gz",
            "darwin-arm64": "vss-{version}-macos-aarch64.tar.gz",
            "linux-amd64": "vss-{version}-linux-x86_64.tar.gz"
        }
    }
}

Так авторам пакетов не придется прописывать конкретный номер версии в спецификации с каждым новом релизом. При установке пакета менеджер автоматически подтянет номер версии из гитхаба:

   local spec    │    remote spec    │    github api
                 │                   │
> update         │                   │
┌─────────────┐  │                   │
│   v0.1.0    │  │                   │
└─────────────┘  │                   │
   ↓             │                   │
wait a sec...    │                   │
┌─────────────┐  │  ┌─────────────┐  │  ┌─────────────┐
│   v0.1.0    │  ?  │   latest    │  →  │   v0.1.1    │
└─────────────┘  │  └─────────────┘  │  └─────────────┘
   ↓             │                   │
┌─────────────┐  │  ┌─────────────┐  │
│   v0.1.0    │  <  │   v0.1.1    │  │
└─────────────┘  │  └─────────────┘  │
   ↓             │                   │
updating...      │                   │
┌─────────────┐  │                   │
│   v0.1.1    │  │                   │
└─────────────┘  │                   │

Тут важно сохранить в локальной спецификации конкретный номер версии, а не «latest». Иначе при обновлении пакета командой update менеджер не поймет, какая версия установлена локально.

Локфайл (lockfile)

Каталога .sqlpkg со спецификациями и ресурсами достаточно, чтобы реализовать все команды менеджера. Мы можем установить, удалить, обновить или вывести список пакетов, руководствуясь исключительно сведениями из .sqlpkg.

.sqlpkg
├── asg017
│   └── vss
│       ├── sqlpkg.json
│       └── vss0.dylib
│
└── sqlite
    └── stmt
        ├── sqlpkg.json
        └── stmt.dylib

Но что делать, если пользователь захочет переустановить пакеты на другой машине или CI-сервере? Тут-то и пригодится локфайл (lockfile).

В локфайле перечислены все установленные пакеты. По каждому пакету хранится самый минимум информации — ровно столько, чтобы можно было переустановить, если потребуется:

{
    "packages": {
        "asg017/vss": {
            "owner": "asg017",
            "name": "vss",
            "version": "v0.1.1",
            "specfile": "https://github.com/nalgeon/sqlpkg/raw/main/pkg/asg017/vss.json",
            "assets": {
                // ...
            }
        },
        "sqlite/stmt": {
            "owner": "sqlite",
            "name": "stmt",
            "version": "",
            "specfile": "https://github.com/nalgeon/sqlpkg/raw/main/pkg/sqlite/stmt.json",
            "assets": {
                // ...
            }
        }
    }
}

Единственное новое поле здесь — specfile. Это путь к внешнему файлу спецификации, из которого можно подтянуть всю остальную информацию по пакету (описание, лицензию, авторов и тому подобное).

Теперь пользователь может закоммитить локфайл вместе с проектом, и выполнить install на другой машине, чтобы установить все пакеты, перечисленные в локфайле:

   local spec    │     lockfile      │    remote spec
                 │                   │
> install        │  ┌─────────────┐  │  ┌─────────────┐
   └─ (empty)    →  │ asg017/vss  │  →  │ asg017/vss  │
                 │  │ sqlite/stmt │  │  └─────────────┘
                 │  └─────────────┘  │  ┌─────────────┐
   ┌─            ←                   ←  │ sqlite/stmt │
installing...    │                   │  └─────────────┘
┌─────────────┐  │                   │
│ asg017/vss  │  │                   │
└─────────────┘  │                   │
┌─────────────┐  │                   │
│ sqlite/stmt │  │                   │
└─────────────┘  │                   │

Пока все логично.

Источник правды

Локфайл кажется очевидной штукой. Но на самом деле, он создает серьезную проблему: у нас больше нет единственного источника информации о пакете.

Рассмотрим одну из простых команд — list (печатает установленные пакеты). Раньше ей достаточно было просканировать .sqlpkg в поисках спецификаций:

> list
   ↓
glob .sqlpkg/*/*/sqlpkg.json
┌─────────────┐
│ asg017/vss  │
│ sqlite/stmt │
└─────────────┘

Но теперь у нас два источника информации о пакетах — каталог .sqlpkg и локфайл. Что делать, если по какой-то причине они противоречат друг другу?

   local spec    │     lockfile
                 │
> list           │
   ↓             │
let's see...     │
┌─────────────┐  │  ┌──────────────┐
│ asg017/vss  │  │  │ asg017/vss   │
└─────────────┘  │  │ nalgeon/text │
┌─────────────┐  │  └──────────────┘
│ sqlite/stmt │  │
└─────────────┘  │
   ↓             │
  ???

Вместо простого «выведи содержимое .sqlpkg» у нас теперь 4 возможных ситуации для каждого пакета:

  1. Пакет есть в .sqlpkg и в локфайле, причем версии совпадают.
  2. Пакет есть в .sqlpkg и в локфайле, но версии отличаются.
  3. Пакет есть в .sqlpkg, но не в локфайле.
  4. Пакет есть в локфайле, но не в .sqlpkg.

С ➊ все понятно, но что менеджеру делать с ➋, ➌ и ➍?

Вместо того чтобы придумывать хитрые стратегии разрешения конфликтов, договоримся о базовом правиле:

Единственный источник информации о пакете — каталог .sqlpkg.

Такое правило разом решает все проблемы, связанные с локфайлом. Для команды list менеджер будет смотреть только в .sqlpkg (как будто нет никакого локфайла). А затем синхронизировать с ним локфайл, добавляя недостающие пакеты, если потребуется:

   local spec    │     lockfile
                 │
> list           │
   ↓             │
glob .sqlpkg/*/*/sqlpkg.json
┌─────────────┐  │  ┌─────────────┐
│ asg017/vss  │  │  │ does not    │
└─────────────┘  │  │ matter      │
┌─────────────┐  │  └─────────────┘
│ sqlite/stmt │  │
└─────────────┘  │
   ↓             │
sync the lockfile
┌─────────────┐  │  ┌─────────────┐
│ asg017/vss  │  →  │ asg017/vss  │
│ sqlite/stmt │  │  │ sqlite/stmt │
└─────────────┘  │  └─────────────┘

Уфф.

Контрольные суммы

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

Чтобы посчитать контрольную сумму по скачанному файлу, используем алгоритм SHA-256. Но нужно еще значение, с которым будем сравнивать — эталонная контрольная сумма ресурса.

Можно указать эталонные контрольные суммы прямо в спецификации:

{
    "owner": "asg017",
    "name": "vss",
    "version": "v0.1.1",
    "repository": "https://github.com/asg017/sqlite-vss",
    "assets": {
        "path": "https://github.com/asg017/sqlite-vss/releases/download/v0.1.1",
        "files": {
            "darwin-amd64": "vss-macos-x86_64.tar.gz",
            "darwin-arm64": "vss-macos-aarch64.tar.gz",
            "linux-amd64": "vss-linux-x86_64.tar.gz"
        },
        "checksums": {
            "vss-macos-x86_64.tar.gz": "sha256-a3694a...",
            "vss-macos-aarch64.tar.gz": "sha256-04dc3c...",
            "vss-linux-x86_64.tar.gz": "sha256-f9cc84..."
        }
    }
}

Но так автору пакета придется редактировать спецификацию после каждого релиза, ведь контрольные суммы заранее не известны.

Намного лучше записывать контрольные суммы в отдельный файл (checksums.txt) по ходу сборки очередного релиза. И размещать этот файл по соседству с остальными ресурсами пакета:

https://github.com/asg017/sqlite-vss/releases/download/v0.1.1
├── checksums.txt
├── vss-macos-x86_64.tar.gz
├── vss-macos-aarch64.tar.gz
└── vss-linux-x86_64.tar.gz

При установке пакета менеджер загрузит checksums.txt, подставит контрольные суммы в спецификацию, и сверит с ними контрольную сумму скачанного ресурса:

    local assets      │      local spec        │     remote assets
                      │                        │
> install             │                        │  ┌──────────────────┐
   └─ (empty)         →        (empty)         →  │ asg017/vss       │
                      │                        │  ├──────────────────┤
                      │                        │  │ checksums.txt    │
   ┌─                 ←          ┌─            ←  │ macos-x86.tar.gz │
download asset        │  save spec w/checksums │  └──────────────────┘
┌──────────────────┐  │  ┌──────────────────┐  │
│ macos-x86.tar.gz │  │  │ asg017/vss       │  │
└──────────────────┘  │  ├──────────────────┤  │
   ↓                  │  │ macos-x86.tar.gz │  │
calculate checksum    │  │ sha256-a3694a... │  │
┌──────────────────┐  │  └──────────────────┘  │
│ sha256-a3694a... │  │                        │
└──────────────────┘  │                        │
   ↓                  │                        │
verify checksum       │                        │
  ↪ ✗ abort if failed │    asg017/vss          │
┌──────────────────┐  │  ┌──────────────────┐  │
│ macos-x86.tar.gz │  │  │ macos-x86.tar.gz │  │
│ sha256-a3694a... │  =  │ sha256-a3694a... │  │
└──────────────────┘  │  └──────────────────┘  │
   ↓                  │                        │
install asset         │                        │
┌──────────────────┐  │                        │
│ vss0.dylib       │  │                        │
└──────────────────┘  │                        │
✓ done!

Если checksums.txt отсутствует, менеджер может предупредить пользователя или вовсе отказаться устанавливать такой пакет.

Зависимости между пакетами

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

Прямая зависимость возникает, когда пакет A использует функции пакета B:

┌─────┐     ┌─────┐
│  A  │ ──> │  B  │
└─────┘     └─────┘

Транзитивная зависимость возникает, когда A зависит от B, а B зависит от C — тем самым A зависит от C:

┌─────┐     ┌─────┐     ┌─────┐
│  A  │ ──> │  B  │ ──> │  C  │
└─────┘     └─────┘     └─────┘

Зависимости, особенно транзитивные — большая головная боль (прочитайте статью Сэма, если не верите). К счастью, в мире SQLite расширения обычно полностью независимые. Так что и наш менеджер пакетов может зависимости не поддерживать — это радикально все упрощает.

┌─────┐     ┌─────┐     ┌─────┐
│  A  │     │  B  │     │  C  │
└─────┘     └─────┘     └─────┘

Раз все пакеты независимы, менеджер может устанавливать и обновлять их по отдельности, не беспокоясь о возможных конфликтах версий.

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

Установка и обновление

Мы рассмотрели все блоки, из которых строится менеджер пакетов. Теперь разберем две самые сложные команды: install и update.

Допустим, я командую менеджеру установить пакет asg017/vss:

   local spec    │     lockfile      │    remote spec
                 │                   │
> install asg017/vss                 │
   ↓             │                   │
read remote spec │                   │  ┌─────────────┐
   └─            →         →         →  │ asg017/vss  │
                 │                   │  │ latest      │
                 │                   │  └─────────────┘
                 │                   │    ↓
                 │                   │  resolve version
                 │                   │  ┌─────────────┐
                 │                   │  │ asg017/vss  │
   ┌─            ←         ←         ←  │ v0.1.0      │
download spec    │                   │  └─────────────┘
┌─────────────┐  │                   │
│ asg017/vss  │  │                   │
│ v0.1.0      │  │                   │
└─────────────┘  │                   │
   ↓             │                   │
download assets  │                   │
validate checksums                   │
  ↪ ✗ abort if failed                │
   ↓             │                   │
install assets   │                   │
┌─────────────┐  │                   │
│ vss0.dylib  │  │                   │
└─────────────┘  │                   │
   └─            →  add to lockfile  │
                 │  ┌─────────────┐  │
                 │  │ asg017/vss  │  │
                 │  │ v0.1.0      │  │
                 │  └─────────────┘  │
✓ done!

А теперь я услышал, что вышла новая версия, и командую менеджеру обновить пакет:

   local spec    │     lockfile      │    remote spec
                 │                   │
> update asg017/vss                  │
   ↓             │                   │
read local spec  │                   │
  ↪ abort if failed                  │
┌─────────────┐  │  ┌─────────────┐  │
│ asg017/vss  │  │  │ does not    │  │
│ v0.1.0      │  │  │ matter      │  │
└─────────────┘  │  └─────────────┘  │
   ↓             │                   │
read remote spec │                   │
resolve version  │                   │  ┌─────────────┐
   └─            →         →         →  │ asg017/vss  │
   ┌─            ←         ←         ←  │ v0.1.1      │
has new version? │                   │  └─────────────┘
  ↪ ✗ abort if not                   │
┌─────────────┐  │                   │  ┌─────────────┐
│ v0.1.0      │  <   is less than    <  │ v0.1.1      │
└─────────────┘  │                   │  └─────────────┘
   ↓             │                   │
download assets  │                   │
validate checksums                   │
  ↪ ✗ abort if failed                │
   ↓             │                   │
install assets   │                   │
add to lockfile  │                   │
┌─────────────┐  │  ┌─────────────┐  │
│ asg017/vss  │  →  │ asg017/vss  │  │
│ v0.1.1      │  │  │ v0.1.1      │  │
└─────────────┘  │  └─────────────┘  │
┌─────────────┐  │                   │
│ vss0.dylib  │  │                   │
└─────────────┘  │                   │
✓ done!

Не так уж и сложно, верно?

Детали реализации

Я написал менеджер пакетов на Go. Думаю, это отличный выбор: Go не только достаточно быстр и компилируется в машинный код, но еще и самый простой из популярных языков. Так что вы легко разберетесь в исходниках, даже если не знакомы с Go. Плюс портирование кода на другой язык не должно стать проблемой.

Еще одно преимущество Go — продуманная стандартная библиотека. Благодаря ей, получилось реализовать весь проект без единой внешней зависимости. Это всегда приятно.

specassetschecksumslockfilecmdкоманды

Пакет spec

Пакет spec предоставляет структуры данных и функции для работы со спецификацией.

  spec
┌─────────────────────────────────────┐
│ Package{}     Read()         Dir()  │
│ Assets{}      ReadLocal()    Path() │
│ AssetPath{}   ReadRemote()          │
└─────────────────────────────────────┘

Спецификация и связанные с ней структуры данных — сердце системы:

// A Package describes the package spec.
type Package struct {
    Owner       string
    Name        string
    Version     string
    Homepage    string
    Repository  string
    Specfile    string
    Authors     []string
    License     string
    Description string
    Keywords    []string
    Symbols     []string
    Assets      Assets
}

// Assets are archives of package files, each for a specific platform.
type Assets struct {
    Path      *AssetPath
    Pattern   string
    Files     map[string]string
    Checksums map[string]string
}

// An AssetPath describes a local file path or a remote URL.
type AssetPath struct {
    Value    string
    IsRemote bool
}

Мы уже обсудили основные поля Package в разделе «Архитектура». Прочие поля (Homepage, Authors, License, и так далее) содержат дополнительные метаданные пакета.

Структура Package предоставляет базовые методы для работы со спецификацией:

  • ExpandVars заменяет переменные в Assets на реальные значения.
  • ReplaceLatest устанавливает конкретную версию пакета вместо «latest».
  • AssetPath определяет путь к ресурсу для конкретной платформы (ОС + архитектура процессора).
  • Save записывает файл спецификации в указанный каталог.

Assets.Pattern позволяет избирательно распаковать файлы из архива. Оно принимает паттерн в стиле glob. Например, если пакет содержит несколько библиотек, и мы хотим извлечь только одну из них (text), то значение Assets.Pattern будет text.*.

Семейство функций Read загружает спецификацию по указанному пути (в локальной файловой системе или внешнему URL).

Наконец, функции Dir и Path возвращают каталог и путь к файлу спецификации для установленного пакета.

Пакет assets

Пакет assets предоставляет функции для работы с ресурсами (файлами) пакета.

  assets
┌──────────────────────┐
│ Asset{}   Download() │
│           Copy()     │
│           Unpack()   │
└──────────────────────┘

Asset (ресурс) — это бинарный файл или архив с файлами пакета для конкретной платформы:

type Asset struct {
    Name     string
    Path     string
    Size     int64
    Checksum []byte
}

Asset предоставляет метод Validate — он проверяет, что контрольная сумма ресурса совпадает с указанным эталонным значением.

Функции Download, Copy и Unpack выполняют соответствующие действия над ресурсом.

Пакеты assets и spec не зависят друг от друга, но оба используются пакетом более высокого уровня cmd (рассмотрим его ниже).

Пакет checksums

У пакета checksums одна задача — загружать контрольные суммы из файла (checksums.txt) в карту (которую можно присвоить полю spec.Package.Assets.Checksums).

  checksums
┌──────────┐
│ Exists() │
│ Read()   │
└──────────┘

Exists проверяет, существует ли файл с контрольными суммами по указанному пути. Read загружает контрольные суммы из локального или внешнего файла в карту (ключ карты — имя файла, значение — контрольная сумма для этого файла). Довольно просто.

Аналогично assets, пакеты checksums и spec не зависят друг от друга, но оба используются пакетом более высокого уровня cmd.

Пакет lockfile

Если spec работает со спецификацией, то lockfile аналогично работает с локфайлом.

  lockfile
┌──────────────────────────┐
│ Lockfile{}   ReadLocal() │
│              Path()      │
└──────────────────────────┘

Lockfile описывает коллекцию установленных пакетов:

type Lockfile struct {
    Packages map[string]*spec.Package
}

Предоставляет набор методов для работы с этой коллекцией:

  • Has проверяет, указан ли пакет в локфайле.
  • Add добавляет пакет в локфайл.
  • Remove удаляет пакет из локфайла.
  • Range обходит все пакеты в локфайле.
  • Save записывает локфайл в указанный каталог.

Поскольку Lockfile всегда расположен локально, есть только одна функция чтения — ReadLocal. А функция Path возвращает путь к локфайлу.

Пакет lockfile зависит от spec:

┌──────────┐   ┌──────────┐
│ lockfile │ → │   spec   │
└──────────┘   └──────────┘

Пакет cmd

Пакет cmd предоставляет отдельные шаги команд — кирпичики, из которых строятся команды верхнего уровня вроде install или update.

  cmd
┌─────────────────────────────────────────────────────────────────────────────┐
│ assets              spec                lockfile             version        │
├─────────────────────────────────────────────────────────────────────────────┤
│ BuildAssetPath      ReadSpec            ReadLockfile         ResolveVersion │
│ DownloadAsset       FindSpec            AddToLockfile        HasNewVersion  │
│ ValidateAsset       ReadInstalledSpec   RemoveFromLockfile                  │
│ UnpackAsset         ReadChecksums                                           │
│ InstallFiles                                                                │
│ DequarantineFiles                                                           │
└─────────────────────────────────────────────────────────────────────────────┘

Каждый шаг относится к конкретной категории, вроде «assets» или «spec».

Шаги используют пакеты spec, assets и lockfile, которые мы рассмотрели выше. Вот, например, шаг DownloadAsset (для наглядности опускаю обработку ошибок):

// DownloadAsset downloads the package asset.
func DownloadAsset(pkg *spec.Package, assetPath *spec.AssetPath) *assets.Asset {
    logx.Debug("downloading %s", assetPath)
    dir := spec.Dir(os.TempDir(), pkg.Owner, pkg.Name)
    fileio.CreateDir(dir)

    var asset *assets.Asset
    if assetPath.IsRemote {
        asset = assets.Download(dir, assetPath.Value)
    } else {
        asset = assets.Copy(dir, assetPath.Value)
    }

    sizeKb := float64(asset.Size) / 1024
    logx.Debug("downloaded %s (%.2f Kb)", asset.Name, sizeKb)
    return asset
}

Думаю, достаточно очевидно, что здесь происходит: создаем временный каталог и скачиваем (или копируем) в него файл ресурса.

Пакеты logx и fileio предоставляют вспомогательные функции для журналирования и работы с файловой системой. Есть еще httpx для работы с HTTP и github для взаимодействия с гитхабом.

Рассмотрим еще один шаг — HasNewVersion:

// HasNewVersion checks if the remote package is newer than the installed one.
func HasNewVersion(remotePkg *spec.Package) bool {
    installPath := spec.Path(WorkDir, remotePkg.Owner, remotePkg.Name)
    if !fileio.Exists(installPath) {
        return true
    }

    installedPkg := spec.ReadLocal(installPath)
    logx.Debug("local package version = %s", installedPkg.Version)

    if installedPkg.Version == "" {
        // not explicitly versioned, always assume there is a later version
        return true
    }

    if installedPkg.Version == remotePkg.Version {
        return false
    }

    return semver.Compare(installedPkg.Version, remotePkg.Version) < 0
}

Тут тоже достаточно просто: загружаем спецификацию установленного пакета и сравниваем ее версию с версией из внешней спецификации. За сравнение версий отвечает пакет semver.

Пакет cmd зависит от всех пакетов, которые мы рассмотрели выше:

┌────────────────────────────────────────────────┐
│                       cmd                      │
└────────────────────────────────────────────────┘
      ↓           ↓          ↓             ↓
┌──────────┐   ┌──────┐  ┌────────┐  ┌───────────┐
│ lockfile │ → │ spec │  │ assets │  │ checksums │
└──────────┘   └──────┘  └────────┘  └───────────┘

Пакеты команд

Для каждой команды менеджера предусмотрен отдельный пакет:

  • cmd/install устанавливает пакеты.
  • cmd/update обновляет установленные пакеты.
  • cmd/uninstall удаляет установленные пакеты.
  • cmd/list печатает список пакетов.
  • cmd/info печатает информацию о пакете.
  • и так далее.

Рассмотрим одну из самых сложных команд — update (для наглядности опускаю обработку ошибок):

func Update(args []string) {
    fullName := args[0]
    installedPkg := cmd.ReadLocal(fullName)

    pkg := cmd.ReadSpec(installedPkg.Specfile)
    cmd.ResolveVersion(pkg)
    if !cmd.HasNewVersion(pkg) {
        return
    }

    cmd.ReadChecksums(pkg)

    assetUrl := cmd.BuildAssetPath(pkg)
    asset := cmd.DownloadAsset(pkg, assetUrl)

    cmd.ValidateAsset(pkg, asset)
    cmd.UnpackAsset(pkg, asset)
    cmd.InstallFiles(pkg, asset)
    cmd.DequarantineFiles(pkg)

    lck := cmd.ReadLockfile()
    cmd.AddToLockfile(lck, pkg)
}

Благодаря функциям-кирпичикам из пакета cmd, логика обновления получилась простой и самодокументируемой. Линейная последовательность шагов с единственной развилкой «а есть ли новая версия?».

Вот полная диаграмма пакетов (опускаю некоторые стрелочки, чтобы не зашумлять):

┌─────────┐ ┌────────┐ ┌───────────┐ ┌──────┐
│ install │ │ update │ │ uninstall │ │ list │ ...
└─────────┘ └────────┘ └───────────┘ └──────┘
     ↓          ↓            ↓          ↓
┌─────────────────────────────────────────────────┐
│                       cmd                       │
└─────────────────────────────────────────────────┘
      ↓           ↓           ↓             ↓
┌──────────┐   ┌──────┐   ┌────────┐  ┌───────────┐
│ lockfile │ → │ spec │   │ assets │  │ checksums │
└──────────┘   └──────┘   └────────┘  └───────────┘
┌────────┐ ┌────────┐ ┌───────┐ ┌──────┐ ┌────────┐
│ fileio │ │ github │ │ httpx │ │ logx │ │ semver │
└────────┘ └────────┘ └───────┘ └──────┘ └────────┘

Вот и все!

Исходники

Резюме

Мы рассмотрели элементы простого менеджера пакетов общего назначения:

  • Спецификация, которая описывает пакет.
  • Двухуровневая иерархия каталогов для установленных пакетов.
  • Проектная и глобальная области видимости.
  • Поиск спецификаций в реестре пакетов.
  • Версионирование и latest-версии.
  • Локфайл и единственный источник правды.
  • Контрольные суммы ресурсов.
  • Зависимости между пакетами (точнее, их отсутствие).

Мы также рассмотрели конкретную реализацию на Go:

  • Пакет spec со структурами данных и функциями для работы со спецификацией.
  • Пакет assets для управления ресурсами пакета.
  • Пакет checksums для загрузки контрольных сумм ресурсов из файла.
  • Пакет lockfile для работы с локфайлом.
  • Пакет cmd с шагами-кирпичиками для вышестоящих команд.
  • Пакеты отдельных команд верхнего уровня.

Спасибо, что прочитали! Надеюсь, статья пригодится, если вам когда-нибудь понадобится написать менеджер пакетов (или отдельные его компоненты).

Подписывайтесь на канал, чтобы не пропустить новые заметки 🚀