Пишем менеджер пакетов
Разработка пакетного менеджера — не самая частая задача в программировании, прямо скажем. Их и готовых более чем достаточно. И все же я обнаружил себя именно в такой ситуации.
Как так вышло
Я большой поклонник СУБД 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
, вот как он поступит:
- Прочитает спецификацию из
.sqlpkg/OWNER/NAME/sqlpkg.json
. - Скачает свежую версию ресурса, используя
assets.path
из спецификации. - Заменит старый файл
.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
, менеджер сделает вот что:
- Попытается загрузить спецификацию из «родного» репозитория пакета
https://github.com/OWNER/NAME
. - Если спецификации там нет — загрузит из общего реестра пакетов.
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 возможных ситуации для каждого пакета:
- Пакет есть в .sqlpkg и в локфайле, причем версии совпадают.
- Пакет есть в .sqlpkg и в локфайле, но версии отличаются.
- Пакет есть в .sqlpkg, но не в локфайле.
- Пакет есть в локфайле, но не в .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 — продуманная стандартная библиотека. Благодаря ей, получилось реализовать весь проект без единой внешней зависимости. Это всегда приятно.
spec • assets • checksums • lockfile • cmd • команды
Пакет 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
с шагами-кирпичиками для вышестоящих команд. - Пакеты отдельных команд верхнего уровня.
Спасибо, что прочитали! Надеюсь, статья пригодится, если вам когда-нибудь понадобится написать менеджер пакетов (или отдельные его компоненты).
★ Подписывайтесь на новые заметки.