пятница, 27 ноября 2015 г.

[prog.flame] Маргинальный взгляд на Semantic Versioning

Забавно наблюдать за тем, какой статус приобретают тривиальные, в общем-то, вещи, оказавшиеся мейнстримом. Взять, например, паттерны проектирования. Полагаю, многие разработчики в начале-середине 90-х годов использовали в своих проектах большую часть паттернов из GoF, даже не зная, что они используют паттерны (уж такие вещи как Factory, Proxy и Command, полагаю, каждый "переизобретал" по нескольку раз). Тем не менее, после выхода книги "банды четырех" паттерны получили статус чуть ли не священного писания, лишь за попытку критического осмысления которых следует подвергать анафеме, не говоря уже о преднамеренном пренебрежении ;)

Сейчас вот что-то подобное происходит по отношению к Semantic Versioning.

Любопытное явление, честное слово. Ничего сакрального в идее обозначать степень совместимости между версиями через изменение тех или иных чисел в номере версии нет. Если мне не изменяет склероз, то в проектах с моим участием такой подход используется уже лет 15, если не больше. А 10 лет назад этот же подход мой был описан в статье для RSDN Mag. Только лишь описан, речь ни в коем случае не идет о попытке присвоить себе статус изобретателя подобного подхода.

Забавно наблюдать, как сейчас ссылки на semver.org, причем в довольно-таки категоричной форме, всплывают на различных профильных ресурсах. Типа: "Ты не используешь semver? Да ты ламер, на помоечку вон из профессии!" Ну очень похоже на то, что происходило с паттернами проектирования в годах эдак 2001-2005 :)

Между тем, на систему нумерации версий у меня есть собственный, маргинальный взгляд. Несколько отличный от рекомендаций Semantic Versioning.

Как по мне, номер версии из трех компонент (major.minor.patch) более-менее хорошо подходит для приложений/утилит. Но в случае с библиотеками трех компонентов может оказаться недостаточно. И для библиотек лично я предпочитаю номер версии из четырех компонент -- generation.major.minor[.patch] (где patch не обязательный компонент).

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

Ну, действительно: библиотека -- это же не вещь в себе. Библиотека нужна для того, чтобы на ее основе сделать часть какого-то приложения. Чем больше приложение, тем больше вероятность, что разным его частям могут потребоваться разные версии одной и той же библиотеки.

Представим себе, что у нас есть библиотека для работы с конфигурационными файлами. Назовем ее app_config.

Допустим, что мы пишем свой собственный MQTT-сервер c преферансом и куртизанками возможностью долговременного хранения сообщений в реляционных СУБД. Допустим, что мы используем в нем две подсистемы: одна для общения с сетью (подсистема network), вторая для взаимодействия с СУБД (подсистема db_storage).

Каждая из этих подсистем завязана на app_config. Но подсистеме network нужен app_config-1.2.*, а подсистеме db_storage -- app_config-2.0.*. И как быть в этой ситуации?

Один из вариантов решения проблемы -- переделка network до работы с app_config-2.0. Но это время (а значит и деньги). Кроме того, внутри network могут использоваться сторонние компоненты (или свои собственные, но используемые в совсем других проектах), которые привязаны к app_config-1.2 и которые не так-то просто перевести на новую версию app_config.

Другой вариант -- это использовать понятие generation. Т.е. если версии библиотеки app_config различаются настолько, что переход от app_config-1.* к app_config-2.* требует практически полного переписывания работы с app_config, то следует говорить о разных поколениях библиотеки с тотальной несовместимостью между собой.

Фактически, речь идет о разных библиотеках. Скажем, была библиотека libxml. Ее полностью переделали и получилась библиотека libxml2. Была app_config, стала app_config2.

В принципе, самым лучшим решением было бы полностью сменить название библиотеки. Скажем, была библиотека "Солнышко", которая имела свои версии 1.0.*, 1.1.*, ..., 1.N.*. Потом появилась библиотека "Ручеек", со своими версиями 1.0.*, 1.1.*, ..., 1.M.*. Что и дает нам возможность смешать в одном приложении network, который зависит от "Солнышко"-1.2.*, и db_storage, который зависит от "Ручеек"-1.0.*

Но с переименованием есть одна большущая проблема: выбор хороших имен -- это одна из тяжелейших задач в программировании :)

Поэтому гораздо проще добавить порядковый номер к уже имеющемуся имени: app_config -> app_config2 -> app_config3 и т.д.

Но что тогда будет с нумерацией версий? Не кажется ли, что app_config-1.0.5 и app_config2-1.0.5 -- это, как сейчас говорят, так тонко, что даже толсто? ;) Не проще ли было бы использовать номер версии из четырех компонентов: app_config-1.0.0.5 и app_config-2.0.0.5?

Как по мне, так номер версии из четырех компонентов таки понятнее, чем изменение имени библиотеки и использование номера из трех компонентов вместе с новым именем.


У больших библиотек изменение номера версии может происходить не только из-за добавления функционала или исправления ошибок. У большой библиотеки, скорее всего, будут свои зависимости. И смена номера версии может потребоваться при переходе к обновленным версиям зависимостей. Например, компонент network может зависеть от библиотеки event_loop-1.2.*. Выходит новая версия event_loop-2.0.* с расширенным API и поломанной из-за этого совместимостью. Компонент network заинтересован в использовании нового API и, поэтому, переходит на event_loop-2.0. При этом API самого network не меняется и, вроде как, совместимость между версиями network не нарушается.

Но что, если в каком-то приложении кроме network еще и задействуется interprocess, который так же использует event_loop? И смена event_loop-а с версии 1.2 на 2.0 для interprocess не желательна? Если номер версии network существенным образом не изменился, то разработчик приложения может столкнуться с неприятным сюрпризом: казалось бы, накатил минорную версию network, а сборка сломалась из-за interprocess.


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

  • в C++ -- это будет имя пространства имен (например, app_config или app_config_1, app_config2 или app_config_2, app_config3 или app_config_3) + имя каталога для заголовочных файлов (например, #include <app_config/all.hpp> и #include <app_config_2/all.hpp>);
  • в Java -- имя пакета (например, com.megainc.app_config и com.megainc.app_config2);
  • в Ruby -- имя Gem-а и имя самого верхнеуровневого модуля (например, require 'app_config' и require 'app_config2', AppConfig::load_file и AppConfig2::load_file)...

Описанные в посте вещи опробованы на кошках себе. В больших проектах доводилось смешивать библиотеки разных поколений, например, SObjectizer-4 и SObjectizer-5, cls_2 и cls_3 и т.д. Кстати говоря, статья на RSDN-е как раз использует примеры из такого опыта (хотя, вроде бы, на момент ее написания проекта oess_2 еще не было, он появился чуть позже, но появился и использовался совместно с oess_1).

Комментариев нет: