Антон ЖияновGolang, SQL и разработка софтаhttps://antonz.ru/https://antonz.ru/assets/favicon/favicon.pngАнтон Жияновhttps://antonz.ru/Hugo -- gohugo.ioru-ruMon, 04 Sep 2023 16:00:00 +0000Интерактивная API-документацияhttps://antonz.ru/interactive-api-tutorials/Mon, 04 Sep 2023 16:00:00 +0000https://antonz.ru/interactive-api-tutorials/В дополнение к OpenAPI.Не вся документация одинаковая. Есть популярная классификация, по которой выделяют четыре типа доки: туториалы (tutorial), how-to руководства (how-to guides), справочная документация (technical references) и разборы (explanations).

Четыре типа документации

Де-факто стандарт справочной документации для API — OpenAPI. Вполне удобная штука. Но совсем не подходит для how-to и туториалов.

В этой статье я предложу краткий и удобный формат интерактивной API-документации для любых HTTP API (REST, RPC и что угодно еще). И для этого (сюрприз, сюрприз) мы используем сам протокол HTTP.

Краткий курс по HTTP-сообщениям

HTTP/1.x — это текстовый протокол обмена сообщениями между клиентом и сервером. Клиент посылает сообщения такого вида:

POST /anything/chat HTTP/1.1
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0

{
    "message": "Hello!"
}

И получает такие сообщения в ответ:

HTTP/1.1 200 OK
date: Mon, 28 Aug 2023 07:51:49 GMT
content-type: application/json

{
    "message": "Hi!"
}

HTTP/2 (следующая версия стандарта) — бинарный протокол. Но все утилиты (вроде DevTools браузера или curl) показывают сообщения HTTP/2 в текстовом виде (аналогично HTTP/1.1), так что мы можем эту бинарность спокойно игнорировать.

HTTP запросы и ответы
После небольшой тренировки читать HTTP-сообщения легко и приятно.

HTTP-запрос состоит из трех секций:

  1. Строка запроса:
POST /anything/chat HTTP/1.1
  • Метод (POST) задает операцию, которую хочет выполнить клиент.
  • Путь (/anything/chat) задает URL запрошенного ресурса (без протокола, домена и порта).
  • Версия (HTTP/1.1) указывает версию HTTP-протокола.
  1. Заголовки запроса:
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0

Каждый заголовок — пара вида «ключ-значение», которая сообщает серверу некоторую полезную информацию о запросе. В нашем случае это доменное имя сервера (httpbingo.org), тип содержимого (application/json) и самоидентификация клиента (user-agent).

  1. Тело запроса:
{
    "message": "Hello!"
}

Данные, которые клиент передает серверу.

HTTP-протокол не предусматривает отдельное сохранение состояния. Так что любое состояние должно передаваться прямо в заголовках или теле запроса.

HTTP-ответ тоже состоит из трех секций:

  1. Строка статуса:
HTTP/1.1 200 OK
  • Версия (HTTP/1.1) указывает версию HTTP-протокола.
  • Код статуса (200) сообщает, выполнился запрос успешно или нет, и почему (протокол предусматривает разные статусы для разных ситуаций).
  • Статусное сообщение — расшифровка кода статуса. В HTTP/2 не передается.
  1. Заголовки ответа:
date: Mon, 28 Aug 2023 07:51:49 GMT
content-type: application/json

Аналогично заголовкам запроса, сообщают клиенту полезную информацию об ответе сервера.

  1. Тело ответа:
{
    "message": "Hi!"
}

Данные, которые сервер передает клиенту.

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

Используем HTTP для API-документации

Возьмем HTTP-запрос:

POST /anything/chat HTTP/1.1
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0

{
    "message": "Hello!"
}

И немного изменим:

  • поставим полный URL вместо пути;
  • уберем версию протокола.
POST http://httpbingo.org/anything/chat
content-type: application/json

{
    "message": "Hello!"
}

Такой формат идеально подходит для примеров использования API. Он краткий и читаемый, но при этом достаточно формальный, чтобы можно было выполнить программно (прямо из документации, как мы вскоре увидим).

Пишем интерактивное API-руководство

Вместо того чтобы рассказывать, как писать интерактивные руководства, я покажу готовый пример. Будем использовать Gists API — это небольшой полезный сервис гитхаба для работы с примерами кода (gists).

GitHub Gists
Gists пригодится, когда городить полноценный Git-репозиторий неохота.

Даже если вы не пользуетесь гитхабом, доступ к Gists API у вас все равно есть.

Читаем примеры

Посмотрим на публичные примеры одного моего знакомого (пользователь rednafi). Ответ сервиса довольно громоздкий, так что выведем 3 последних примера (per_page = 3):

GET https://api.github.com/users/rednafi/gists?per_page=3
accept: application/json

Пачка нестандартных заголовков x-ratelimit показывает, как гитхаб ограничивает запросы:

  • Всего доступно x-ratelimit-limit запросов в час.
  • Уже использовали x-ratelimit-used запросов.
  • Осталось x-ratelimit-remaining запросов.

За ними стоит следить, чтобы не превысить лимит.

Чтобы получить страницу примеров, используем комбинацию параметров page и per_page. Например, вот примеры 10-15:

GET https://api.github.com/users/rednafi/gists?page=3&per_page=5
accept: application/json

Гитхаб описывает постраничную навигацию в заголовке link:

link:
    <https://api.github.com/user/30027932/gists?page=2&per_page=5>; rel="prev",
    <https://api.github.com/user/30027932/gists?page=4&per_page=5>; rel="next",
    <https://api.github.com/user/30027932/gists?page=7&per_page=5>; rel="last",
    <https://api.github.com/user/30027932/gists?page=1&per_page=5>; rel="first"

Весьма предусмотрительно!

Теперь посмотрим на конкретный пример с идентификатором 88242fd822603290255877e396664ba5:

GET https://api.github.com/gists/88242fd822603290255877e396664ba5
accept: application/json

Видим среди файлов (files) файл greet.py на языке (language) Python с таким содержимым (content):

class Greeter:
    def __init__(self, greeting):
        self.greeting = greeting

    def greet(self, who):
        print(f"{self.greeting}, {who}!")

gr = Greeter("Hello")
gr.greet("world")

(да, примеры на Python тоже могут быть интерактивными!)

Интересно — у примера есть история (history). Оказывается, гитхаб создает новую версию файла при каждом изменении, и старые версии тоже хранит.

Давайте получим самую раннюю версию, с идентификатором version = 4c10d27cfb163d654745f1d72f2c7ce14225b83b (ну и длиннющие они, конечно):

GET https://api.github.com/gists/88242fd822603290255877e396664ba5/4c10d27cfb163d654745f1d72f2c7ce14225b83b
accept: application/json

Код в примере тогда был сильно проще:

msg = "Hello, world!"
print(msg)

Изменяем примеры

Итак, мы знаем, как выбирать несколько примеров, конкретный пример, и даже конкретную версию. Теперь давайте создадим новый пример!

POST https://api.github.com/gists
content-type: application/json
accept: application/json

{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"Hello, world!"
        }
    }
}

Что это? Мы получили ошибку 401 Unauthorized. Тело ответа поясняет: требуется аутентификация (requires authentication) и даже предоставляет ссылку на документацию (до чего же хороши API у гитхаба).

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

Чтобы следующие примеры работали, укажите API-токен в текстовом поле ниже. Перед этим создайте его с областью действия 'gist' в настройках гитхаба.

Когда укажете токен, он сохранится локально в браузере и никуда не будет отправлен (кроме как в API гитхаба, когда нажмете Run).


Попробуем снова, на этот раз с заголовком authorization.

Обратите внимание на параметр public. Сервис поддерживает «секретные» примеры (public = false), но это так себе секретность. Секретные примеры не показываются в результатах запроса GET /gists, но по-прежнему доступны по идентификатору, даже для анонимных пользователей.

POST https://api.github.com/gists
content-type: application/json
accept: application/json
authorization: bearer {token}

{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"Hello, world!"
        }
    }
}
У меня нет токена, покажите результат
HTTP/1.1 201 
cache-control: private, max-age=60, s-maxage=60
content-length: 3758
content-type: application/json; charset=utf-8
etag: "819f6b4f728843abcb50ad63da200a4c110245585b3eb1c0f59a5ebe86c8ecf5"
location: https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:511F73:51AC33:64EE0266
x-oauth-scopes: gist
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4997
x-ratelimit-reset: 1693323114
x-ratelimit-resource: core
x-ratelimit-used: 3

{
  "url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9",
  "forks_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/forks",
  "commits_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/commits",
  "id": "b17474320a629af38255c0a6efbc72b9",
  "node_id": "G_kwDOACz0htoAIGIxNzQ3NDMyMGE2MjlhZjM4MjU1YzBhNmVmYmM3MmI5",
  "git_pull_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "git_push_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "html_url": "https://gist.github.com/nalgeon/b17474320a629af38255c0a6efbc72b9",
  "files": {
    "README.md": {
      "filename": "README.md",
      "type": "text/markdown",
      "language": "Markdown",
      "raw_url": "https://gist.githubusercontent.com/nalgeon/b17474320a629af38255c0a6efbc72b9/raw/5dd01c177f5d7d1be5346a5bc18a569a7410c2ef/README.md",
      "size": 13,
      "truncated": false,
      "content": "Hello, world!"
    }
  },
  ...
}

HTTP-статус 201 Created означает, что в результате запроса был создан новый пример.

Теперь попробуем изменить пример по идентификатору (не забудьте заменить {gist_id} в строке запроса на реальное значение id):

PATCH https://api.github.com/gists/{gist_id}
content-type: application/json
accept: application/json
authorization: bearer {token}

{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"¡Hola, mundo!"
        }
    }
}
У меня нет токена, покажите результат
HTTP/1.1 200 
cache-control: private, max-age=60, s-maxage=60
content-type: application/json; charset=utf-8
etag: W/"989eaec7cdb50ba6441e77ea2defba257b98a535f26c2ba6062f152ceffb2d77"
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:5188AA:52163F:64EE027F
x-oauth-scopes: gist
x-ratelimit-limit: 100
x-ratelimit-remaining: 98
x-ratelimit-reset: 1693323129
x-ratelimit-resource: gist_update
x-ratelimit-used: 2

{
  "url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9",
  "forks_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/forks",
  "commits_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/commits",
  "id": "b17474320a629af38255c0a6efbc72b9",
  "node_id": "G_kwDOACz0htoAIGIxNzQ3NDMyMGE2MjlhZjM4MjU1YzBhNmVmYmM3MmI5",
  "git_pull_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "git_push_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "html_url": "https://gist.github.com/nalgeon/b17474320a629af38255c0a6efbc72b9",
  "files": {
    "README.md": {
      "filename": "README.md",
      "type": "text/markdown",
      "language": "Markdown",
      "raw_url": "https://gist.githubusercontent.com/nalgeon/b17474320a629af38255c0a6efbc72b9/raw/95975f3d0bac707ce4355dfc4a7955310d212fac/README.md",
      "size": 14,
      "truncated": false,
      "content": "¡Hola, mundo!"
    }
  },
  ...
}

Теперь код приветствует всех на испанском 🇪🇸

Очень хорошо. Наконец, удалим пример:

DELETE https://api.github.com/gists/{gist_id}
accept: application/json
authorization: bearer {token}
У меня нет токена, покажите результат
HTTP/1.1 204 
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:51E584:5273CC:64EE027F
x-oauth-scopes: gist
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4996
x-ratelimit-reset: 1693323114
x-ratelimit-resource: core
x-ratelimit-used: 4

HTTP-статус 204 No Content означает, что мы удалили пример, поэтому гитхаб больше ничего не может о нем сообщить. Немного жаль с ним расставаться, но всегда ведь можно создать новый, верно?

У Gists API есть и другие полезные возможности, но мы не будем их рассматривать. Вот функции, которые мы попробовали:

  • Получить список примеров.
  • Получить конкретный пример или конкретную версию примера.
  • Создать новый пример.
  • Изменить существующий пример.
  • Удалить пример.

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

Реализация

Чтобы вызывать API прямо из документации (как в предыдущем разделе), понадобится JavaScript код, который делает вот что:

  1. Парсит описание HTTP-запроса.
  2. Вызывает API.
  3. Показывает результат.
Песочница Fetch API
Всегда приятно, когда песочница работает без сервера.

Песочница поддерживает только небольшую часть возможностей HTTP, так что парсинг не слишком сложный:

// parse parses the request specification.
function parse(text) {
    const lines = text.split("\n");
    let lineIdx = 0;

    // parse method and URL
    const methodUrl = lines[0].split(" ").filter((s) => s);
    const [method, url] =
        methodUrl.length >= 2 ? methodUrl : ["GET", methodUrl[0]];
    lineIdx += 1;

    // parse headers
    const headers = {};
    for (let i = lineIdx; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line === "") {
            break;
        }
        const [headerName, headerValue] = line.split(":");
        headers[headerName.trim()] = headerValue.trim();
        lineIdx += 1;
    }

    // parse body
    const body = lines.slice(lineIdx + 1).join("\n");

    return { method, url, headers, body };
}

const spec = parse(`GET https://httpbingo.org/uuid`);
console.log(JSON.stringify(spec, null, 2));

Вызов API и показ результатов и того проще — используем браузерное Fetch API и выводим ответ как текст:

// execCode sends an HTTP request according to the spec
// and returns the response as text with status, headers and body.
async function execCode(spec) {
    const resp = await sendRequest(spec);
    const text = await responseText(resp);
    return text;
}

// sendRequest sends an HTTP request according to the spec.
async function sendRequest(spec) {
    const options = {
        method: spec.method,
        headers: spec.headers,
        body: spec.body || undefined,
    };
    return await fetch(spec.url, options);
}

// responseText returns the response as text
// with status, headers and body.
async function responseText(resp) {
    const version = "HTTP/1.1";
    const text = await resp.text();
    const messages = [`${version} ${resp.status} ${resp.statusText}`];
    for (const hdr of resp.headers.entries()) {
        messages.push(`${hdr[0]}: ${hdr[1]}`);
    }
    if (text) {
        messages.push("", text);
    }
    return messages.join("\n");
}

const spec = {
    method: "GET",
    url: "https://httpbingo.org/uuid",
};

const text = await execCode(spec);
console.log(text);

Fetch API работает прямо в браузере, промежуточный сервер не требуется. Единственный нюанс: либо документация должна быть на том же домене, что API, либо API должно разрешать кросс-доменные запросы. Но даже если это не так, всегда можно проксировать запросы — это не так сложно.

Если хотите готовое решение — я написал небольшую библиотеку, которая поддерживает JavaScript и Fetch API песочницы:

codapi-js

Вообще я хотел бы, чтобы вся документация была интерактивной. Не только API-туториалы, но все от алгоритмов (hashing) до языков программирования (Go, Odin), баз данных (SQLite), фреймворков, утилит и даже отдельных пакетов.

И (наглая самореклама!) я сейчас разрабатываю платформу, которая дает именно это — встраиваемые песочницы для документации, онлайн-обучения и просто развлечения. Посмотрите, если интересно:

codapi

И попробуйте написать интерактивный туториал в следующий раз, когда будете разрабатывать API!

]]>
Пишем менеджер пакетовhttps://antonz.ru/writing-package-manager/Thu, 10 Aug 2023 13:00:00 +0000https://antonz.ru/writing-package-manager/Так, чтобы не потратить на это целый год.Разработка пакетного менеджера — не самая частая задача в программировании, прямо скажем. Их и готовых более чем достаточно. И все же я обнаружил себя именно в такой ситуации.

Как так вышло

Я большой поклонник СУБД 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 с шагами-кирпичиками для вышестоящих команд.
  • Пакеты отдельных команд верхнего уровня.

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

]]>
Язык Odinhttps://antonz.ru/trying-odin/Tue, 01 Aug 2023 17:30:00 +0000https://antonz.ru/trying-odin/Простая и мощная альтернатива языку C.Давно подыскиваю для себя альтернативу языку C. Посмотрел на днях Rust, Nim, Zig, Hare и Odin. На первый взгляд, Odin — именно то что нужно. Единственный из всех не перегружен фичами (кроме Hare — но тот, напротив, слишком минималистичный даже для меня).

Может возникнуть вопрос: зачем мне вообще альтернатива C, если у меня уже есть Go? Go действительно прекрасен всем, кроме взаимодействия с C (которое мне иногда нужно). Кроме того, в нем есть сборщик мусора (который мне иногда не нужен).

Для меня Go — это замена Java. А хотелось бы еще замену C.

У Odin уникальный набор качеств:

  • Простой язык без лишних прибамбасов.
  • Ручное управление памятью с настраиваемыми аллокаторами.
  • Продуманная стандартная библиотека.
  • Лаконичный и спокойный синтаксис.

Тур по языку

Классический «привет, мир»:

package main

import "core:fmt"

main :: proc() {
    fmt.println("Hellope!")
    // Hellope!
}

Динамические массивы и сортировка:

package main

import "core:fmt"
import "core:slice"

main :: proc() {
    list := [dynamic]int{11, 7, 42}
    defer delete(list)

    append(&list, 2, 54)
    slice.sort(list[:])

    fmt.println(list)
    // [2, 7, 11, 42, 54]
}

Управление памятью ручное, так что выделенную память надо явно освобождать. Есть два встроенных аллокатора (heap и arena), но их можно менять прямо на лету:

// трассирующий аллокатор для отладки
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)

// context - неявная переменная,
// доступна в любой области видимости
context.allocator = mem.tracking_allocator(&track)

// можно поменять аллокатор
// хоть на уровне отдельного выражения
list := make([]int, 6, context.allocator)

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

Person :: struct {
    name: string,
    age: int,
}

person_to_str :: proc(p: Person) -> string {
    return fmt.tprintf("%v - %v", p.name, p.age)
}

people := []Person{
    Person{"Alice", 25},
    Person{"Bob", 24},
    Person{"Cindy", 26},
}

for p, idx in people {
    fmt.println(idx, person_to_str(p))
}
// 0 Alice - 25
// 1 Bob - 24
// 2 Cindy - 26

Нет ни функций, ни методов — только процедуры.

Указатели объявляются символом каретки перед названием переменной, а разыменовываются кареткой после названия:

val := "Hellope!"

ptr: ^string
ptr = &val

fmt.println(ptr^)
// Hellope!

Немного непривычно, но выглядит логично.

Ошибки — просто значения:

Error :: enum {
    None,
    Insufficient_Funds,
}

withdraw :: proc(balance, amount: int) -> (int, Error) {
    if amount > balance {
        return balance, .Insufficient_Funds
    }
    return balance - amount, .None
}

balance, err := withdraw(42, 1000)
if err != nil {
    fmt.println(err)
}
// Insufficient_Funds

Есть сахарок для нелюбимого многими if err != nil return:

balance := withdraw(42, 1000) or_return

Дженерики (параметрический полиморфизм):

Pair :: struct($T: typeid) {
    first: T,
    second: T
}

pair_to_str :: proc(p: $T/Pair) -> string {
    return fmt.tprintf("%v-%v", p.first, p.second)
}

p1 := Pair(int){1, 2}
p2 := Pair(string){"one", "two"}

fmt.println(pair_to_str(p1))
// 1-2

fmt.println(pair_to_str(p2))
// one-two

Здесь pair_to_str принимает только значения типа Pair или производных от него.

Обзор языка

Как попробовать

Проще всего попробовать Odin в песочнице, которую я подготовил (именно в ней запускаются примеры из этой заметки).

Если предпочитаете локальную установку, на официальном сайте есть инструкция. Предоставляются бинарники для Windows, Linux и macOS — но, к сожалению, только amd64 (x86_64).

Если у вас arm64 Mac, можно использовать докер-образ (авторства Yeongju Kang с моими правками).

Специализация

Odin — язык общего назначения, но есть у него некоторая склонность к геймдеву и программированию визуальных эффектов. Наверно потому, что автор языка (Ginger Bill) — физик, который занимается как раз программированием визуальных эффектов.

Odin нативно предоставляет тип matrix и операции над матрицами, а также функции, связанные с SIMD/SIMT-программированием (о котором я ничего не знаю, так что лучше почитайте официальную доку).

Насколько я могу судить, многие программисты используют Odin именно для разработки игр.

Статус языка

Odin пока не добрался до версии 1.0. Нет даже роадмапа до этой версии. К тому же, он не так популярен, как Zig или Nim (скорее ближе к Hare).

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

Надеюсь, у языка большое будущее!

]]>
Реестр и менеджер пакетов для SQLitehttps://antonz.ru/sqlpkg/Mon, 03 Jul 2023 10:00:00 +0000https://antonz.ru/sqlpkg/Ищет, скачивает и обновляет расширения.Расширений для SQLite становится все больше, и я решил, что пришло время сделать менеджер пакетов!

Встречайте sqlpkg — это реестр пакетов с веб-интерфейсом и одноименный консольный менеджер пакетов.

Реестр пакетов работает отдельно от менеджера и не требует его. Достаточно найти нужный пакет на сайте, скачать под свою ОС и загрузить стандартными средствами.

Реестр пакетов
Для начала добавил в реестр те расширения, которые пробовал сам, получилось около 60 пакетов.

А менеджер пакетов работает отдельно от реестра и тоже не требует его. Чтобы установить пакет, достаточно указать менеджеру на файл спецификации. Его можно захостить где угодно или даже разместить локально.

Менеджер пакетов
Менеджер пакетов работает как pip в Python или brew на macOS.

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

Надеюсь, вам пригодится.

]]>
Как установить расширение для SQLitehttps://antonz.ru/install-sqlite-extension/Sun, 02 Jul 2023 12:00:00 +0000https://antonz.ru/install-sqlite-extension/Чтобы добавить недостающие функции.SQLite — это миниатюрная, но мощная встраиваемая СУБД. У нее есть один недостаток: маловато встроенных функций по сравнению с PostgreSQL или Oracle. К счастью, авторы заложили в SQLite механизм расширений, на котором можно сделать почти все что угодно.

Обычно добавить расширение в SQLite несложно. Скачиваете файл, выполняете одну команду — и готово. Но иногда люди сталкиваются с проблемами, поэтому я решил написать это руководство.

Чтобы использовать расширение, его надо скачать и подключить. Мы разберем каждый из шагов по отдельности.

Скачать: вручную

Предположим, вы нашли интересное расширение. Теперь надо его скачать.

Расширение для SQLite — это одиночный файл:

  • *.dll для Windows (например, stats.dll)
  • *.so для Linux (stats.so)
  • *.dylib для macOS (stats.dylib)

Скачайте файл и сохраните его на диске. Мы будем использовать папку «Загрузки» (Downloads):

  • %USERPROFILE%\Downloads на Windows (например, C:\Users\anton\Downloads)
  • ~/Downloads на Linux или macOS (/home/anton/Downloads или /Users/anton/Downloads)

Теперь можно подключать!

Я буду использовать расширение stats в качестве примера; не забудьте поменять название и путь к файлу, если будете копировать команды.

Скачать: через менеджер пакетов

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

Чтобы скачать расширение через sqlpkg, выполните команду install.

Windows:

sqlpkg.exe install nalgeon/stats

Linux/macOS:

sqlpkg install nalgeon/stats

nalgeon/stats — это идентификатор расширения. Его можно узнать в реестре пакетов. Загляните туда — там полно расширений!

sqlpkg сохраняет все расширения в специальный каталог:

  • %USERPROFILE%\.sqlpkg в Windows
  • ~/.sqlpkg в Linux/macOS

Для нашего расширения nalgeon/stats получится так:

  • C:\Users\anton\.sqlpkg\nalgeon\stats\stats.dll в Windows
  • /home/anton/.sqlpkg/nalgeon/stats/stats.so в Linux
  • /Users/anton/.sqlpkg/nalgeon/stats/stats.dylib в macOS

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

Подключить: консоль

Консоль SQLite (CLI, она же shell) — это утилита командной строки sqlite3.exe для Windows или sqlite3 для Linux/macOS).

Запустите ее и подключите расширение командой .load.

Windows:

.load c:/Users/anton/Downloads/stats

Linux/macOS:

.load /Users/anton/Downloads/stats

Теперь расширение можно использовать! Например, stats добавляет функции median и generate_series:

select median(value) from generate_series(1, 99);

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

xattr -d com.apple.quarantine /Users/anton/Downloads/stats.dylib

А еще стандартная консоль SQLite на макоси не поддерживает расширения вовсе. Используйте специальную сборку, чтобы они заработали.

Подключить: GUI или IDE

Чтобы подключить расширение в SQLiteStudio, SQLiteSpy, DBeaver и других аналогичных программах, используйте функцию load_extension.

Windows:

select load_extension('c:\Users\anton\sqlite\stats');

Linux/macOS:

select load_extension('/Users/anton/Downloads/stats');

Подключить: Python

Используйте стандартный модуль sqlite3.

Windows:

import sqlite3

conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True)
conn.load_extension(r"c:\Users\anton\sqlite\stats")
conn.execute("select median(value) from generate_series(1, 99)")
conn.close()

Linux/macOS:

import sqlite3

conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True)
conn.load_extension("/Users/anton/Downloads/stats")
conn.execute("select median(value) from generate_series(1, 99)")
conn.close()

Примечание для macOS. Стандартный модуль sqlite3 на макоси не умеет загружать расширения. Используйте пакет sqlean.py — он полностью совместим со стандартным и поддерживает расширения.

Подключить: Node.js

Используйте пакет better-sqlite3.

Windows:

const sqlite3 = require("better-sqlite3");
const db = new sqlite3(":memory:");
db.loadExtension(`c:\Users\anton\sqlite\stats`);
db.exec("select median(value) from generate_series(1, 99)");
db.close();

Linux/macOS:

const sqlite3 = require("better-sqlite3");
const db = new sqlite3(":memory:");
db.loadExtension("/Users/anton/Downloads/stats");
db.exec("select median(value) from generate_series(1, 99)");
db.close();

Подключить: Go

Используйте пакет mattn/go-sqlite3.

Windows:

package main

import (
    "database/sql"
    "fmt"

    sqlite3 "github.com/mattn/go-sqlite3"
)

func main() {
    sql.Register("sqlite3_with_extensions",
        &sqlite3.SQLiteDriver{
            Extensions: []string{
                `c:\Users\anton\sqlite\stats`,
            },
        })

    db, err := sql.Open("sqlite3_with_extensions", ":memory:")
    db.Query("select median(value) from generate_series(1, 99)")
    db.Close()
}

Linux/macOS:

package main

import (
    "database/sql"
    "fmt"

    sqlite3 "github.com/mattn/go-sqlite3"
)

func main() {
    sql.Register("sqlite3_with_extensions",
        &sqlite3.SQLiteDriver{
            Extensions: []string{
                "/Users/anton/Downloads/stats",
            },
        })

    db, err := sql.Open("sqlite3_with_extensions", ":memory:")
    db.Query("select median(value) from generate_series(1, 99)")
    db.Close()
}

Обратите внимание: мы используем один и тот же идентификатор sqlite3_with_extensions в sql.Register и sql.Open.

Что дальше

Если вам не хватает функций в SQLite, загляните в реестр пакетов — там наверняка найдется что-нибудь подходящее.

И попробуйте менеджер пакетов, если предпочитаете консольные утилиты.

И реестр, и менеджер — новые проекты, так что в них есть некоторые шероховатости. Но свою задачу они выполняют.

Всем SQLite!

]]>
SQLite-песочница с расширениямиhttps://antonz.ru/sqlime-extensions/Tue, 27 Jun 2023 18:00:00 +0000https://antonz.ru/sqlime-extensions/В Sqlime приехали 70 дополнительных функций.Sqlime — это опенсорсная SQLite-песочница для отладки запросов и обучения.

Она запускает настоящий экземпляр SQLite прямо в браузере, поэтому поддерживает большинство ее возможностей, в том числе разнообразные функции: общего назначения, дату/время, математические, агрегации и оконные.

У SQLite заметно меньше этих функций чем, скажем, у PostgreSQL. Поэтому у меня есть отдельный проект — Sqlean — целиком посвященный добавлению недостающих.

Sqlime
Полноценная SQLite в браузере, теперь с дополнительными функциями.

Я всегда хотел подружить оба проекта, и добавить расширения из Sqlean в Sqlime. И вот наконец это удалось! Теперь Sqlime включает больше 70 функций из следующих расширений:

  • crypto: хеш-функции, кодирование и декодирование.
  • define: пользовательские функции и динамический SQL.
  • fuzzy: нечеткое сравнение строк, фонетические алгоритмы, транслитерация.
  • ipaddr: манипуляция IP-адресами и подсетями.
  • regexp: регулярные выражения.
  • stats: статистика — медиана, процентили, стандартное отклонение.
  • text: работа со строками.
  • uuid: генерация уникальных идентификаторов.

Попробуйте!

Запустить песочницу  или посмотреть функции

]]>
Встроенные функции в Go 1.21https://antonz.ru/go-1-21-builtins/Fri, 23 Jun 2023 14:00:00 +0000https://antonz.ru/go-1-21-builtins/Выбираем минимальное/максимальное значение и очищаем контейнеры.Go 1.21 собрал множество приятных штук, от оптимизации по профилю (profile-guided optimization) до пакетов стандартной библиотеки для работы со срезами и картами (см. подробности в заметках к релизу). Я же их хладнокровно проигнорирую и сосредоточусь на фичах, которые привлекли мое внимание: новых встроенных функциях (builtins).

Builtins — это функции, которые не требуют импорта пакета, вроде len или make. В Go 1.21 добавили три новых: min, max and clear. Давайте разберем их.

Тренды Go
У нас тут тренд намечается.

min/max

Делают ровно то, что вы от них ожидаете — выбирают наименьшее или наибольшее значение из переданных:

n := min(10, 3, 22)
fmt.Println(n)
// 3

m := max(10, 3, 22)
fmt.Println(m)
// 22

Обе функции принимают значения упорядоченных типов (ordered type): целые числа, вещественные числа или строки (а также производные от них):

x := min(9.99, 3.14, 5.27)
fmt.Println(x)
// 3.14

s := min("one", "two", "three")
fmt.Println(s)
// "one"

type ID int

id1 := ID(7)
id2 := ID(42)
id := max(id1, id2)
fmt.Println(id)
// 42

Обе функции принимают один или более аргументов:

fmt.Println(min(10))
// 10
fmt.Println(min(10, 9))
// 9
fmt.Println(min(10, 9, 8))
// 8
// ...

Но при этом не являются вариационными (variadic):

nums := []int{10, 9, 8}
n := min(nums...)
// invalid operation: invalid use of ... with built-in min

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

// так можно
max := "My name is Max"
min := 4 - 1
make := func() int {
    return 14
}
fmt.Println(max, min, make())
// My name is Max 3 14

А вот любопытный вопрос:

Зачем «замусоривать» общее пространство имен и делать встроенные min и max вместо одноименных дженерик-функций в пакете cmp?

Ответ есть, но не факт, что вам он понравится. Говорит Расс Кокс (Russ Cox):

Мы несколько раз обсуждали, должны ли функции min/max быть встроенными или находиться в пакете cmp.

Есть веские аргументы в пользу обоих позиций. С одной стороны, min и max — базовые арифметические операции, как сложение. Это оправдывает их существование в виде встроенных функций.

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

Мнения разошлись даже среди авторов языка.

полная цитата

Так что уж как смогли, так и сделали.

clear

min и max кажутся достаточно очевидными. С clear интереснее. Функция работает со срезами, картами и значениями параметрических типов (type parameter values — о них чуть позже).

Из карты clear удаляет все элементы, оставляя карту пустой:

m := map[string]int{"one": 1, "two": 2, "three": 3}
clear(m)

fmt.Printf("%#v\n", m)
// map[string]int{}

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

s := []string{"one", "two", "three"}
clear(s)

fmt.Printf("%#v\n", s)
// []string{"", "", ""}

С чего бы это, спросите вы. Разве не логично для функции с названием clear было бы очищать срез? Вообще-то нет.

Срез в Go — это значение, и длина среза — часть этого значения:

// https://github.com/golang/go/blob/master/src/runtime/slice.go
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

Так что функция, которая принимает срез, работает с копией этого значения. Изменять копию нет смысла — вызывающая сторона не увидит изменений. Именно поэтому append возвращает новый срез вместо того, чтобы менять длину исходного.

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

Карта же, с другой стороны — это указатель на структуру вида:

// https://github.com/golang/go/blob/master/src/runtime/map.go
type hmap struct {
	count int
	// ...
	buckets unsafe.Pointer
	// ...
}

Поэтому вполне логично, что clear удаляет элементы из карты.

Теперь насчет «значений параметрических типов»:

func customClear [T []string | map[string]int] (container T) {
    clear(container)
}

func main() {
    s := []string{"one", "two", "three"}
    customClear(s)
    fmt.Printf("%#v\n", s)
    // []string{"", "", ""}

    m := map[string]int{"one": 1, "two": 2, "three": 3}
    customClear(m)
    fmt.Printf("%#v\n", m)
    // map[string]int{}
}

customClear принимает аргумент container, который может быть либо срезом, либо картой. clear внутри функции обрабатывает container в соответствии с фактическим типом: карты очищает, у срезов зануляет элементы.

Да, и еще. clear не работает с массивами:

arr := [3]int{1, 2, 3}
clear(arr)
// invalid argument: argument must be (or constrained by) map or slice

Итого

Получить аж три новых встроенных функции в одном релизе несколько неожиданно. Но может и неплохо.

Вот полный список встроенных функций начиная с Go 1.21:

append     добавляет значения в срез
clear      удаляет или зануляет элементы контейнера

close      закрывает канал

complex    создают и разбирают комплексные числа
real
imag

delete     удаляет элемент карты по ключу

len        возвращает длину контейнера
cap        возвращает вместимость контейнера

make       создает новый срез, карту или канал
new        выделяет память под переменную

min        выбирает минимальный из переданных аргументов
max        выбирает максимальный из переданных аргументов

panic      создают и обрабатывают панику
recover

print      печатают аргументы
println

Возможно, не стоит добавлять в него новые 🤔 По правде говоря, я до сих пор не могу смириться с «принтами».

]]>
Виды JOIN в SQLhttps://antonz.ru/sql-join/Thu, 22 Jun 2023 10:00:00 +0000https://antonz.ru/sql-join/Разбираемся с многоликой конструкцией соединения таблиц.В SQL-джойнах скрыто больше, чем можно подумать. Давайте разберем их.

Будем использовать две простые таблицы: компании companies и их вакансии jobs.

Есть три вымышленные компании — Hoogle, Emazon и Neta — которые предлагают на удивление мало вакансий:

  jobs                                   companies
┌────────┬─────────┬──────────────┐    ┌─────────┬───────────┐
│ job_id │ comp_id │   job_name   │    │ comp_id │ comp_name │
├────────┼─────────┼──────────────┤    ├─────────┼───────────┤
│ 1      │ 10      │ Data Analyst │    │ 10      │ Hoogle    │
│ 2      │ 20      │ Go Developer │    │ 20      │ Emazon    │
│ 3      │ 20      │ ML Engineer  │    │ 30      │ Neta      │
│ 4      │ 99      │ UI Designer  │    └─────────┴───────────┘
└────────┴─────────┴──────────────┘

(свайпайте влево, чтобы увидеть компании)

Hoogle интересуется аналитиками данных. Emazon нанимает Go-разработчиков и ML-инженеров. У Neta нет вакансий. А какая-то ноунейм-компания с идентификатором 99 отчаянно разыскивает UI-дизайнера.

Время джойнить!

Квалифицированный JOIN

Квалифицированный джойн (qualified join) — это общий термин, которым обозначают всем известные типы джойнов: inner, left, right и full. Если вы про них не слышали или подзабыли — посмотрите SQL-шпаргалку.

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

table [join-type] JOIN table join-specification

Таблица (table) — не обязательно прямо вот таблица. Это может быть представление, подзапрос или любая табличная структура данных. Но для краткости будем называть ее таблицей.

Тип джойна (join-type) может быть таким:

Тип джойна
Четырех типов соединения таблиц должно хватить всем.
  • inner включает только совпадающие записи из обеих таблиц.
  • left включает совпадающие записи из обеих таблиц и несовпадающие из левой таблицы.
  • right включает совпадающие записи из обеих таблиц и несовпадающие из правой таблицы.
  • full включает совпадающие и несовпадающие записи из обеих таблиц.

Для left, right и full можно использовать необязательное слово outer, которое ничего не меняет (как будто SQL недостаточно сложный):

LEFT OUTER  = LEFT
RIGHT OUTER = RIGHT
FULL OUTER  = FULL

Да и вообще join-type можно не писать. По умолчанию джойн считается внутренним (inner).

Спецификация джойна (join-specification) задает правила соответствия между таблицами. Она бывает двух сортов.

Спецификация джойна
Джойн по явным условиям (on) или по общему столбцу (using)? Выбор за вами.

Первый вариант использует ключевое слово on, которое вы наверняка не раз встречали. Например, выберем вакансии вместе с соответствующими названиями компаний:

select
  job_name, comp_name
from jobs
  join companies on jobs.comp_id = companies.comp_id;

Второй вариант более редкий. Он использует ключевое слово using и работает, только если целевой столбец в обеих таблицах называется одинаково:

select
  job_name, comp_name
from jobs
  join companies using(comp_id);

С on можно использовать любые условия, а using проверяет только на равенство.

Удобно!

Естественный JOIN

Естественный джойн (natural join) — это как квалифицированный через using, только мы вообще не указываем столбцы, по которым соединяются таблицы:

select
  job_name, comp_name
from jobs
  natural join companies;

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

Аналогично using, естественный джойн проверяет на равенство. Аналогично квалифицированному джойну, он может быть внутренним (inner, по умолчанию) или внешним (left, right или full):

table NATURAL [join-type] JOIN table
Естественный джойн
Естественный джойн похож на автоматический, бездушный «using». Довольно неестественно, как по мне.

Использовать естественный джойн — сомнительная идея. Допустим, у нас есть столбец name в обеих таблицах:

create table jobs (
  job_id integer primary key,
  comp_id integer,
  name text
);

create table if not exists companies (
  comp_id integer primary key,
  name text
);

Вполне нормальная структура. Но естественный джойн между jobs и companies вернет пустой результат, потому что неявно сравнивает по такому условию:

jobs.comp_id = companies.comp_id and jobs.name = companies.name

Так что лично я без раздумий выбираю using вместо естественного соединения.

Перекрестный JOIN

Третья и последняя разновидность — перекрестный джойн (cross join), также известный как «декартово соединение» (Cartesian join):

select
  job_name, comp_name
from jobs
  cross join companies;

Перекрестный джойн игнорирует значения столбцов. Он берет каждую строку из левой таблицы (N строк) и соединяет с каждой строкой из правой таблицы (M строк), выдавая в результате N×M строк.

Перекрестный джойн
Отдельное название для самого редкого типа джойнов. Очень логично! Или нет. Спросите SQL-комитет.

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

Перекрестный джойн — то же самое, что внутренний джойн (inner join, он же просто join) без критерия совпадения:

select
  job_name, comp_name
from jobs
  join companies on true;

Иногда перекрестный джойн записывают так:

select job_name, comp_name
from jobs, companies;

Хотя в запрос вообще отсутствует слово join, это все то же самое перекрестное соединение.

Секционированный JOIN

Не я ли чуть выше говорил, что существует всего три вида джойнов — квалифицированный, естественный и перекрестный? Ну да.

Но тут вот какая штука. Помните определение квалифицированного джойна?

table [join-type] JOIN table join-specification

Согласно стандарту SQL, вместо table здесь можно использовать секционированную джойн-таблицу (partitioned join table):

select ...
from table_x partition by (col_1, col_2, ...)
  join table_y on ...
;

Допустим у нас есть таблица продуктов (products) и продаж (sales) по дням:

  sales                                          products
┌────┬────────────┬────────────┬──────────┐    ┌────┬───────┐
│ id │  sale_dt   │ product_id │ quantity │    │ id │ name  │
├────┼────────────┼────────────┼──────────┤    ├────┼───────┤
│ 1  │ 2023-06-01 │ 10         │ 30       │    │ 10 │ Alpha │
│ 2  │ 2023-06-01 │ 20         │ 60       │    │ 20 │ Beta  │
│ 3  │ 2023-06-01 │ 30         │ 90       │    │ 30 │ Gamma │
│ 4  │ 2023-06-02 │ 20         │ 60       │    └────┴───────┘
│ 5  │ 2023-06-03 │ 10         │ 30       │
│ 6  │ 2023-06-03 │ 30         │ 90       │
└────┴────────────┴────────────┴──────────┘

(свайпайте влево, чтобы увидеть продукты)

Выберем продажи с соответствующими названиями продуктов:

select
  sale_dt, products.name, quantity
from sales
   join products on sales.product_id = products.id
order by sale_dt;

Пока все просто. Но что если я хочу увидеть продажи каждого продукта за каждый день? Включая дни, в которые не было продаж отдельных продуктов:

┌────────────┬───────┬──────────┐
│  sale_dt   │ name  │ quantity │
├────────────┼───────┼──────────┤
│ 2023-06-01 │ Alpha │ 30       │
│ 2023-06-01 │ Beta  │ 60       │
│ 2023-06-01 │ Gamma │ 90       │
│ 2023-06-02 │ Alpha │ 0        │
│ 2023-06-02 │ Beta  │ 60       │
│ 2023-06-02 │ Gamma │ 0        │
│ 2023-06-03 │ Alpha │ 30       │
│ 2023-06-03 │ Beta  │ 0        │
│ 2023-06-03 │ Gamma │ 90       │
└────────────┴───────┴──────────┘

Эту задачу никак не решить без дополнительных ухищрений. Разве что использовать секционированный джойн (partitioned join).

Join types
Секционированный джойн работает независимо по каждой секции.

Секционированный джойн говорит движку СУБД выполнять соединение отдельно по каждой секции, которую мы задали для таблицы. Поэтому, если мы определим секции продаж по дате:

select
  sale_dt, name, quantity
from sales partition by (sale_dt)
  right join products on sales.product_id = products.id
order by sale_dt, name;

То движок независимо соединит продажи за 01.06 со всеми продуктами, затем продажи за 02.06 со всеми продуктами, затем продажи за 03.06 со всеми продуктами, и наконец объединит промежуточные результаты. В результате получим искомый набор данных (разве что вместо 0 здесь null):

┌────────────┬───────┬──────────┐
│  sale_dt   │ name  │ quantity │
├────────────┼───────┼──────────┤
│ 2023-06-01 │ Alpha │ 30       │
│ 2023-06-01 │ Beta  │ 60       │
│ 2023-06-01 │ Gamma │ 90       │
│ 2023-06-02 │ Alpha │ (null)   │
│ 2023-06-02 │ Beta  │ 60       │
│ 2023-06-02 │ Gamma │ (null)   │
│ 2023-06-03 │ Alpha │ 30       │
│ 2023-06-03 │ Beta  │ (null)   │
│ 2023-06-03 │ Gamma │ 90       │
└────────────┴───────┴──────────┘

Надо сказать, что странноватая фича. Я удивлен, что она вообще вошла в стандарт (подозрительно связано с тем, что она реализована в Oracle). Другие производители СУБД так никогда и не реализовали секционированный джойн. Не могу их за это осуждать.

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

Если вам интересно, как решить задачу без секционированного джойна (знаю, что нет), то вот:

-- выбираем все даты
with dates as (
  select distinct sale_dt
  from sales
)
-- перекрестный джойн дат с продуктами
-- дает все пары «дата-продукт»,
-- а их уже соединяем с продажами
select
  dates.sale_dt,
  name, quantity
from dates
  cross join products
  left join sales on sales.sale_dt = dates.sale_dt
    and sales.product_id = products.id
order by dates.sale_dt, name;

Проще простого.

Латеральный JOIN

SQL — странная штука. Стандарт одновременно включает и секционированный джойн (который никто кроме Oracle не поддержал), и намного более мощную и распространенную разновидность — латеральный (lateral) джойн.

Латеральное соединение, в противоположность обычному, разрешает коррелированные подзапросы. Сейчас разберемся, что это.

Вернемся к примеру с продуктами и продажами:

  sales                                          products
┌────┬────────────┬────────────┬──────────┐    ┌────┬───────┐
│ id │  sale_dt   │ product_id │ quantity │    │ id │ name  │
├────┼────────────┼────────────┼──────────┤    ├────┼───────┤
│ 1  │ 2023-06-01 │ 10         │ 30       │    │ 10 │ Alpha │
│ 2  │ 2023-06-01 │ 20         │ 60       │    │ 20 │ Beta  │
│ 3  │ 2023-06-01 │ 30         │ 90       │    │ 30 │ Gamma │
│ 4  │ 2023-06-02 │ 20         │ 60       │    └────┴───────┘
│ 5  │ 2023-06-03 │ 10         │ 30       │
│ 6  │ 2023-06-03 │ 30         │ 90       │
└────┴────────────┴────────────┴──────────┘

(свайпайте влево, чтобы увидеть продукты)

Посмотрим, как каждый продукт продавался 2 июня:

select
  '2023-06-02' as sale_dt, name, sales.quantity
from products
  left join sales on products.id = sales.product_id
  and sales.sale_dt = '2023-06-02';

А теперь посмотрим продажи каждого продукта за каждый день (как делали в примере с секционированным джойном). Было бы здорово выбрать даты отдельным подзапросом и соединить с подзапросом по конкретной дате из примера выше:

Lateral join
Подзапрос с продажами использует дату из подзапроса с датами. Можно сказать, что подзапрос продаж коррелирует с подзапросом дат.
select
  d.sale_dt, ps.name, ps.quantity
from
  (select distinct sale_dt from sales) as d
  join (
    select d.sale_dt, name, sales.quantity
    from products
      left join sales on products.id = sales.product_id
      and sales.sale_dt = d.sale_dt
  ) as ps on true
order by sale_dt, name;

Но нет. Нельзя использовать столбец sale_dt из подзапроса d в следующем за ним подзапросе ps. А вот если использовать латеральный джойн — можно:

select
  d.sale_dt, ps.name, ps.quantity
from
  (select distinct sale_dt from sales) as d
  join lateral (
    select d.sale_dt, name, sales.quantity
    from products
      left join sales on products.id = sales.product_id
      and sales.sale_dt = d.sale_dt
  ) as ps on true
order by sale_dt, name;

Подзапрос d выбирает даты, а подзапрос ps соединяется с ним по столбцу sale_dt. Тем самым ps выбирает продажи каждого продукта для каждой конкретной даты. Все благодаря латеральному джойну. Удобно!

Может возникнут вопрос насчет условия on true. Дело в том, что фактически джойн по sale_dt уже произошел внутри подзапроса ps, поэтому повторять его снаружи не требуется. Можете поменять true на d.sale_dt = ps.sale_dt и убедиться, что ничего не изменилось.

Латеральные соединения поддерживаются в PostgreSQL, MySQL и Oracle. MS SQL Server не поддерживает синтаксис lateral, но предоставляет аналогичную функциональность с собственным синтаксисом cross apply (= join lateral) и outer apply (= left join lateral).

Итого

Стандарт SQL описывает три варианта JOIN:

  • квалифицированный (соединение по указанным критериям);
  • естественный (автоматически выбирает критерии);
  • перекрестный (внутреннее соединение без критериев).
Джойны в SQL
«Стандарт SQL в картинках» стал бы хитом, как считаете?

Квалифицированный джойн предусматривает четыре типа: inner, left, right и full.

Тип джойна

Он разрешает задать критерии соединения через on или using.

Спецификация джойна

Большинство производителей СУБД поддерживают все виды JOIN. Заметное исключение — MS SQL Server, который знать не знает о using и естественных джойнах.

Есть еще модификаторы, которые изменяют поведение джойна:

  • lateral разрешает коррелированные подзапросы в join-части запроса. Поддерживается в PostgreSQL, MySQL и Oracle (и MS SQL Server с другим синтаксисом).
  • partition by производит независимое соединение по каждой из заданных секций. Поддерживается только в Oracle.

И еще MySQL не поддерживает full-джойны. Просто чтобы жизнь сахаром не казалась.

Вот и все!

──

P.S. Хотите освоить современный SQL? Обратите внимание на Оконные функции

]]>
Мне не нужен ваш язык запросовhttps://antonz.ru/fancy-ql/Sat, 17 Jun 2023 11:30:00 +0000https://antonz.ru/fancy-ql/Серьезно, не нужен. Я бы предпочел SQL.Эта заметка может показаться резковатой. Но я устал от SQL-шейминга, который вижу в отрасли. Я имею право не соглашаться, так ведь?

Новые движки баз данных появляются чуть ли не ежегодно. И это замечательно! Они приносят новые подходы, архитектуры и инструменты. К тому же, разрабатывать СУБД — увлекательное занятие.

Часто в комплекте с новым движком идет язык запросов. Это тоже хорошо, наверно. Или нет.

Простой и элегантный

Элегантный язык запросов для цивилизованного века
Ого, элегантный и цивилизованный? Дайте два.

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

У нас уже есть широко распространенный язык запросов к СУБД общего назначения. Это SQL. Я бы предпочел видеть в вашей базе именно его.

📝 Я не говорю здесь об софте, который работает с какой-то узкой предметной областью. Отдельный доменный язык для конкретного набора сценариев использования — это разумно.

Конечно, ваш язык очень элегантный. Только мне от этого не легче. Во-первых, проще написать чуть больше кода на SQL, чем учить новый язык. Во-вторых, ваш предположительно простой язык моментально окажется сложным, как только я начну решать на нем реальные рабочие задачи. Зачем мне это?

Лучше, чем SQL

Сравнение, которое говорит само за себя
Вы только посмотрите на этого SQL-уродца.

Иногда авторам нового языка нравится преподносить SQL как нечто ужасно сложное. Посмотрим на примере одной из «пост-SQL» СУБД. Как говорят сами авторы, это сравнение не требует пояснений.

📝 Я здесь использую конкретную «пост-SQL» СУБД (не называя ее), чтобы проиллюстрировать мысль заметки. Промо-страница этой СУБД — такой яркий пример SQL-шейминга, что сложно было пройти мимо. Это не критика самой СУБД или ее авторов. Я уверен, что это отличный продукт.

FancyQL:

select Movie {
  title,
  actors: {
   name
  },
};

SQL (как его видят авторы FancyQL):

SELECT
  title,
  Actors.name AS actor_name
FROM Movie
 LEFT JOIN Movie_Actors ON
  Movie.id = Movie_Actors.movie_id
 LEFT JOIN Person AS Actors ON
  Movie_Actors.person_id = Person.id

SQL (каким он может быть):

select
  title,
  actors.name
from movies
  join movies_actors using(movie_id)
  join actors using(actor_id)

Хм. Еще пример?

FancyQL:

select Movie {
  title,
  actors: {
   name
  },
  rating := math::mean(.reviews.score)
} filter "Zendaya" in .actors.name;

SQL (как его видят авторы FancyQL):

SELECT
  title,
  Actors.name AS actor_name,
  (SELECT avg(score)
    FROM Movie_Reviews
    WHERE movie_id = Movie.id) AS rating
FROM
  Movie
  LEFT JOIN Movie_Actors ON
    Movie.id = Movie_Actors.movie_id
  LEFT JOIN Person AS Actors ON
    Movie_Actors.person_id = Person.id
WHERE
  'Zendaya' IN (
    SELECT Person.name
    FROM
      Movie_Actors
      INNER JOIN Person
        ON Movie_Actors.person_id = Person.id
    WHERE
      Movie_Actors.movie_id = Movie.id)

SQL (каким он может быть):

select
  title,
  actors.name,
  (select avg(score) from reviews
   where movie_id = movies.movie_id) as rating
from movies
  join movies_actors using(movie_id)
  join actors using(actor_id)
where movie_id in (
  select movie_id
  from actors join movies_actors using(actor_id)
  where actors.name = 'Zendaya'
)

movies.sql

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

Современный

Для разработчиков, не пиджаков
Добавим немного пиджак-шейминга!

Еще один распространенный аргумент:

SQL разработали в 1970-х с прицелом на бизнес, а не разработчиков.

Да, SQL разработали в 1970-х. С каких пор это стало недостатком? Все знают SQL. Все основные СУБД поддерживают SQL. SQL достаточно выразителен, чтобы решать любые задачи, связанные с данными. У SQL есть комитет (собранный из представителей основных СУБД), который заботится и развивает его. А что может предложить ваш язык? Кроме того, что изобретен в 2020-х.

Могу продолжать, но, думаю, моя мысль ясна.

Мне не нужен ваш модный язык запросов. Я предпочту SQL.

Может дело во мне, конечно.

──

P.S. Хотите мастерски освоить SQL вместо того, чтобы учить очередной новый язык? Обратите внимание на Оконные функции SQL

]]>
sqlite3 + расширения в Pythonhttps://antonz.ru/sqlean-py/Thu, 15 Jun 2023 21:30:00 +0000https://antonz.ru/sqlean-py/Замена стандартного модуля sqlite3 с набором расширений.Подключить расширение для SQLite с помощью стандартного модуля sqlite3 довольно просто. Скачиваете файл, вызываете пару функций, и готово. Если только вам не повезло использовать macOS, в который sqlite3 не поддерживает расширения.

Мне хотелось еще упростить процесс (и заодно решить проблему с макосью). Поэтому я создал пакет sqlean.py: полностью совместимую замену стандартному sqlite3, которая заодно включает полезные расширения.

Расширения

Установка и использование

Установите пакет через pip install:

pip install sqlean.py

И используйте вместо sqlite3:

import sqlean as sqlite3

# такой же интерфейс, как в стандартном `sqlite3`
conn = sqlite3.connect(":memory:")
conn.execute("create table employees(id, name)")

# плюс сразу подключены расширения из `sqlean`
cur = conn.execute("select median(value) from generate_series(1, 99)")
print(cur.fetchone())
# (50.0,)

conn.close()

Обратите внимание: пакет называется sqlean.py, а в импортах просто sqlean. Имя пакета sqlean было уже занято каким-то зомби-проектом, так что мне пришлось добавить суффикс .py.

Расширения

sqlean.py включает 12 расширений для SQLite:

  • crypto: хеш-функции, кодирование и декодирование.
  • define: пользовательские функции и динамический SQL.
  • fileio: чтение и запись файлов.
  • fuzzy: нечеткое сравнение строк и фонетические алгоритмы.
  • ipaddr: работа с IP-адресами.
  • math: математические функции.
  • regexp: регулярные выражения.
  • stats: математическая статистика.
  • text: работа со строками.
  • unicode: поддержка юникода.
  • uuid: уникальные идентификаторы.
  • vsv: работа с CSV-файлами.

Платформы

Пакет доступен для следующих операционных систем:

  • Windows (64 бита)
  • Linux (64 бита)
  • macOS (процессоры Intel или Apple)

Если у вас другая ОС — можно собрать из исходников.

Подробности в репозитории пакета.

]]>