пятница, 4 марта 2016 г.

[prog] Mxx_ru 1.6.7

Вышла версия 1.6.7 инструмента Mxx_ru с начальной поддержкой т.н. externals. Теперь в Mxx_ru есть средства для выкачивания внешних зависимостей из Git/Hg/Svn и установки частей этих зависимостей в нужные подкаталоги. Пока внешние зависимости берутся только из репозиториев Git/Hg/Svn. Поддержка закачки тарболлов/архивов будет добавлена в следующей версии. Надеюсь, получится выкатить ее достаточно быстро.

Установить Mxx_ru можно командой gem install Mxx_ru

Обновить Mxx_ru можно командой gem update Mxx_ru

Так же Mxx_ru можно загрузить с SourceForge (gem-файл).

PDF-ку с документацией пока не обновлял. Сделаю это уже после выпуска версии с поддержкой тарболлов. Посему единственная имеющаяся в наличии документация собрана в этом посте под катом.

Итак, что такое MxxRu::externals и как с ними бороться?

Mxx_ru -- это build-tool для C/C++ проектов. Для C/C++ управление внешними зависимостями отдано на откуп самим разработчикам. К сожалению, таких вещей, как Ruby-новый RubyGems, Haskell-евый Cabal, Rust-овский Cargo или Erlang-овый Rebar в C++ пока нет. Поэтому каждый C++ный проект решает эту задачу по-своему. Кто-то использует CMake-овский ExternalProject, кто-то разруливает зависимости через возможности VCS (submodules/subtrees в Git, subrepos в Hg, svn:externals в Svn), кто-то пытается создавать менеджер пакетов для C++ (см. conan.io). И, поскольку стандарта де-факто пока нет, а у каждого из перечисленных выше подходов есть свои недостатки, то для Mxx_ru был разработан свой очень простой механизм, который позволит разработчику подтянуть и развернуть внутри своего проекта нужные ему внешние зависимости.

При этом получившийся механизм к MxxRu не привязан. Т.е. можно использовать MxxRu::externals для работы с зависимостями, а make или SCons для управления сборкой проекта.

Допустим, мне нужно написать некую программу, в которой в качестве внешних зависимостей будут использоваться такие проекты, как asio, spdlog и eigen. Два первых проекта живут на GitHub, третий на BitBucket. Оттуда их исходники я и будут забирать.

Для своего проекта я выбираю следующую файловую структуру:

/
`- sources/
   `- my_prj/
      `- *.?pp
   `- tests/
      `- */
      `- Makefile
   `- Makefile
`- doc/
   `- src/
      `- *.tex
      `- Makefile
`- README
`- LICENSE,...

Т.е. все исходники я хочу иметь в подкаталоге sources. Там будет лежать исходный текст моей программы (в sources/my_prj), там же будут находится и тесты (в подкаталоге sources/tests). Там же, в sources, будут создаваться каталоги для результатов компиляции (например, с именами _gcc_5.3.0--x86_64-w64-mingw32--release или _vc_18.00.40629--x64--debug).

Мне нужно разместить у себя в проекте исходные тексты нужных мне подпроектов (т.е. asio, spdlog и eigen). Причем я хочу, чтобы их исходники оказывались у меня в sources. Т.е. чтобы получалось что-то вроде:

/
`- sources/
   `- asio/
   `- Eigen/
   `- my_prj/
      `- *.?pp
   `- spdlog/
   `- tests/
      `- */
      `- Makefile
   `- Makefile
...

Для этого я в корневом каталоге своего проекта создаю файл externals.rb, в котором будут находится описания внешних зависимостей для моего проекта. Этот файл будет представлять из себя что-то вроде makefile, но для штатного инструмента из стандартной библиотеки Ruby -- rake (это такой Ruby-овый make). Для получения и установки зависимостей мне нужно будет находясь в корневом каталоге своего проекта указать:

rake -f externals.rb

По этой команде MxxRu::externals скачает репозитории asio, spdlog и eigen, заберет оттуда нужные мне куски и разместит эти куски в соответствующих подкаталогах моего каталога sources.

Для того, чтобы удалить все зависимости, нужно будет запустить команду:

rake -f externals.rb remove

Для того, чтобы заново скачать и заново установить все зависимости нужно запустить команду:

rake -f externals.rb reget

Ну а теперь самое интересное: что же находится внутри externals.rb?

Вначале идет обычная шапка, которая указывает, что externals.rb нуждается в RubyGem-е с именем Mxx_ru и версией не меньше, чем 1.6.7. После чего из Mxx_ru подключается функциональность модуля externals.

gem 'Mxx_ru''>= 1.6.7'
require 'mxx_ru/externals'

Далее идут перечисления зависимостей. Каждой зависимости дается свое уникальное имя (это важно!) и указывается, какой тип у этой зависимости. В двух словах это выглядит вот так:

MxxRu::git_externals :asio do |e|
   ...
end

MxxRu::git_externals :spdlog do |e|
   ...
end

MxxRu::hg_externals :eigen do |ext|
   ...
end

Тут описываются три зависимости с именами :asio, :spdlog и :eigen (двоеточие перед именем -- это такая фишка Ruby, которое говорит, что имя представляется строкой специального типа, т.н. символом). Две из них будут браться из Git-а, о чем говорит использование MxxRu::git_externals при их описании. Третья зависимость берется из Mercurial, т.е. при ее описании используется MxxRu::hg_externals.

Вообще, запись MxxRu::git_externals name do...end -- это просто вызов вспомогательной функции с двумя аргументами. Первым аргументом является имя зависимости. Вторым -- лямбда-функция (code block в терминологии Ruby), которая будет получать один аргумент: объект, в котором нужно описать параметры зависимости. Если кому-то Ruby-новая запись кажется непривычной, то в C++ эта штука записывалось бы как-то вот так:

MxxRu::git_externals( "asio", []( MxxRu::git_externals_params & e ) {...} );
MxxRu::git_externals( "spdlog", []( MxxRu::git_externals_params & e ) {...} );
MxxRu::hg_externals( "eigen", []( MxxRu::hg_externals_params & e ) {...} );

Итак, мой externals.rb содержит цепочку вызовов функций MxxRu::*_externals. Каждая функция создает набор rake-целей для работы с внешней зависимостью. Так, вызов функции git_externals для зависимости asio создаст три rake-цели. Первая цель будет называться aiso. Она будет отвечать за скачивание asio и его установки в нужное мне место. Вторая цель будет называться asio:remove. Она будет отвечать за удаление внешней зависимости asio (включая те файлы и каталоги, которое мне создаст цель asio). Третья цель имеет имя asio:reget и она является комбинацией из целей asio:remove и asio (т.е. сначала удаление зависимости asio, затем добавление ее заново).

Дабы увидеть, какие цели будут построены внутри externals.rb можно запустить rake с ключиком -T и увидеть почти все цели, которые доступны для rake:

>rake -f externals.rb -T
rake asio           # Get asio external project
rake asio:reget     # Remove and get fresh asio external project
rake asio:remove    # Remove asio external project
rake eigen          # Get eigen external project
rake eigen:reget    # Remove and get fresh eigen external project
rake eigen:remove   # Remove eigen external project
rake reget          # Remove and get again all external projects
rake remove         # Remove all external projects
rake spdlog         # Get spdlog external project
rake spdlog:reget   # Remove and get fresh spdlog external project
rake spdlog:remove  # Remove spdlog external project

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

rake -f externals.rb eigen

То rake вытащит и установит только одну зависимость -- eigen. На остальные не обратит внимания. Если же зависимость eigen уже установлена, то rake ничего не будет делать.

Если же задать в командной строке:

rake -f externals.rb spdlog:remove

То rake удалит все, что связано с зависимостью spdlog, но не будет трогать ничего другого.

Так же можно увидеть еще две rake-цели: remove и reget. Цель remove включат в себя все цели *:remove (т.е. цели asio:remove, eigen:remove и spdlog:remove). Соотвественно, если при запуске rake указать цель remove, то все внешние зависимости из моего проекта будут изъяты. Цель reget выполняет такую же роль но для целей *:reget.

Однако, есть еще одна цель, которую rake в данном списке не показал -- это дефолтная цель. Именно она будет запускаться, если в командной строке rake не дано названия конкретной цели для пострения. В дефолную цель автоматически включаются все цели для загрузки и установки зависимостей. В данном случае дефолтная цель будет включать в себя цели asio, eigen и spdlog. Поэтому запуск:

rake -f externals.rb

автоматически устанавливает все нужные моему проекту зависимости.

Теперь можно посмотреть, как именно описываются параметры зависимостей. Начнем с самой простой зависимости -- с spdlog:

MxxRu::git_externals :spdlog do |e|
   e.url 'https://github.com/gabime/spdlog.git'
   e.map 'include/spdlog' => 'sources'
end

Тут все очень просто. Команда url задает URL git-репозитория, который нам нужен. Именно этот репозиторий будет клонироваться во вспомогательный каталог и затем из него будут браться исходники spdlog. А команда map указывает, какие именно каталоги из исходников spdlog нужно брать и куда их нужно помещать. Команда map может задаваться для внешней зависимости несколько раз (это полезно, когда из внешнего проекта нужно вытащить несколько каталогов и разместить их в разных местах моего проекта).

В случае с spdlog все просто: есть только один нужный мне каталог include/spdlog. Причем я хочу проигнорировать include, который пуст, и забрать к себе только spdlog. Поэтому у меня команда map выглядит как:

e.map 'include/spdlog' => 'sources'

Т.е. взять каталог spdlog из каталога include в исходниках и поместить его (только его) в мой каталог sources.

Ситуация с eigen чуть-чуть посложнее spdlog:

MxxRu::hg_externals :eigen do |ext|
  ext.url = 'https://bitbucket.org/eigen/eigen' 
  ext.tag = '3.2.5'
  ext.map 'Eigen' => 'sources'
end

Здесь, кроме уже знакомых команд url и map есть команда tag. Эта команда задает значение, которое будет передано в опцию --updaterev для hg clone. Команда tag не обязательна. Если она не используется, то при выполнении hg clone будет произведен апдейт до ревизии tip бранча default. Я же хочу взять ревизию, помеченную тегом 3.2.5, поэтому использую команду tag.

Команда map для зависимости eigen предписывает забрать каталог Eigen из исходников eigen-а и скопировать его в мой каталог sources.

Еще интереснее описание зависимости для asio:

MxxRu::git_externals :asio do |e|
   e.url 'https://github.com/chriskohlhoff/asio.git'
   e.tag 'asio-1-11-0'
   e.map_dir 'asio/include' => 'sources/asio'
   e.map_file 'asio/src/asio.cpp' => 'sources/asio/src/asio.cpp'
   e.map_file 'asio/src/asio_ssl.cpp' => 'sources/asio/src/asio_ssl.cpp'
end

Здесь есть уже знакомые url и tag (значение tag передается в опцию --branch при выполнении git clone). А вот с map веселее: вместо привычного map используются map_dir и map_file.

Сразу скажу, что map и map_dir -- это одно и то же. Если не нужно доставать из исходников внешней зависимости одиночные файлы, достаточно просто map. Но если приходится доставать и каталоги, и отдельные файлы, то лучше использовать имена map_dir и map_file. Так меньше путаницы.

Команда map_file позволяет взять один файл из исходников внешней зависимости и разместить его туда, куда мне нужно.

В данном случае для asio каталог asio/include переносится целиком в sources/asio (и получает имя sources/asio/include). А вот каталог asio/src целиком переносить я не хочу, т.к. там кроме двух файлов asio.cpp и asio_ssl.cpp лежит еще куча всего, что мне не нужно. Поэтому я захотел взять из asio только два этих файла. Для чего записал две соответствующие команды map_file.

Итого, файл externals.rb для моего проекта имеет вид:

gem 'Mxx_ru''>= 1.6.7'
require 'mxx_ru/externals'

MxxRu::git_externals :asio do |e|
   e.url 'https://github.com/chriskohlhoff/asio.git'
   e.tag 'asio-1-11-0'
   e.map_dir 'asio/include' => 'sources/asio'
   e.map_file 'asio/src/asio.cpp' => 'sources/asio/src/asio.cpp'
   e.map_file 'asio/src/asio_ssl.cpp' => 'sources/asio/src/asio_ssl.cpp'
end

MxxRu::git_externals :spdlog do |e|
   e.url 'https://github.com/gabime/spdlog.git'
   e.map 'include/spdlog' => 'sources'
end

MxxRu::hg_externals :eigen do |ext|
  ext.url = 'https://bitbucket.org/eigen/eigen' 
  ext.tag = '3.2.5'
  ext.map 'Eigen' => 'sources'
end

В итоге всех этих манипуляций у меня получается следующее дерево в проекте:

/
`- .externals/
   `- asio/
      `- .git
      `- *
   `- eigen/
      `- .hg
      `- *
   `- spdlog/
      `- .git
      `- *
`- sources/
   `- asio/
      `- include/
         `- *
      `- src/
         `- asio.cpp
         `- asio_ssl.cpp
   `- Eigen/
      `- *
   `- my_prj/
      `- *.?pp
   `- spdlog/
      `- *
   `- tests/
      `- */
      `- Makefile
   `- Makefile
...

Где .externals -- это служебный подкаталог, который автоматически создается MxxRu::externals и в котором размещаются исходники внешних зависимостей, получаемых посредством git clone, hg clone и svn export. Оттуда уже нужные куски копируются в целевые каталоги в соответствии с командами map/map_dir и map_file.

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

Отмечу несколько вещей:

  • работа с внешними зависимостями реализована в рамках MxxRu просто потому, что мне так удобнее: вместо установки двух gem-ов Mxx_ru и CppExternals нужно будет ставить всего один, Mxx_ru. Однако, как говорилось в самом начале, MxxRu::externals вовсе не требует, чтобы MxxRu использовался для сборки проекта. Система сборки может быть любой. Так, можно доставать зависимости через MxxRu, а собирать проекты через make или какой-нибудь GYP;
  • это самая первая версия, поэтому что-то где-то наверняка может работать не так, как того хотелось бы. Все выявленные проблемы будем устранять максимально оперативно. Жаловаться можно сюда или на SourceForge;
  • далее на очереди реализация загрузки архивов с внешними зависимостями. Пока в планах поддержка tar с в сочетании с разными способами упаковки (gz, bz2, xz), а так же архивы zip и 7z. Для распаковки будет запускаться внешняя утилита (т.е. tar для tar-ов, unzip для zip-ов или 7z для 7z-архивов). Если у кого-то есть другие идеи -- делитесь, посмотрим, как будет удобнее;
  • для скачивания архивов планируется так же задействовать внешние утилиты -- curl и wget (по очереди, если нет одной, то пробуется вторая). Можно было бы воспользоваться и средствами стандартной библиотеки Ruby, но там есть неприятная штука -- если идет переадресация URL на зеркало и меняется протокол (с http на https или наоборот), то Ruby-новая библиотека бросает исключение. В принципе, можно было бы потрахаться с его перехватом и извлечением оттуда нового URL... Но, боюсь, все это будет сильно завязано под конкретную версию Ruby, а потому проще и надежнее звать curl и wget. Правда, под Windows с наличием этих утилит могут быть проблемы. Так что здесь нужно еще подумать. Опять же, если у кого-то есть опыт или какие-то соображения -- милости прошу в комментарии;
  • у команд map и map_dir есть важное отличие от map_file: каталоги при копировании не переименовываются. Т.е. команда map 'some/path/name' => 'other/path' всегда создает итоговый каталог с именем other/path/name. Это не позволяет сменить имя name на другое, например, на my_name. Тогда как для файлов запись map_file 'some/path/name' => 'other/path/my_name' указывает на копирование файла name из some/path в каталог other/path под именем my_name. Это может пригодится, например, для команд вида map_file 'INSTALL' => 'INSTALL.asio' или map_file 'COPYING.README' => 'COPYING.README.eigen'.
    Т.е. получается, что каталоги переименовывать нельзя, а отдельные файлы -- можно. Мы посчитали, что так удобнее всего. Однако, если кто-то увидит, что для его сценариев работы нужно уметь переименовывать каталоги, то дайте знать. Пока не поздно, можно поведение map/map_dir поменять.

В общем, такие дела. Если есть вопросы, соображения, замечания или предложения -- то прошу отписываться в комментариях.

PS. Пока писал этот пост нашел баг и выкатил обновление. Так что сейчас у Mxx_ru крайняя версия не 1.6.7, а 1.6.7.1 :)

Отправить комментарий