Обзор систем сборки SCons и Waf

    image

    Я — разработчик и в качестве основного языка последние пару лет использую Python. Однако время от времени появляются задачи, когда нужно писать на C/C++. Существуют разные системы, с помощью которых можно собирать такие проекты. Классикой являются make и autotools. Я же хочу заострить внимание на таких альтернативах, как SCons и Waf. Целью поста не является доказательство того, что они лучше или хуже make. Хочется просто провести короткий экскурс, чтобы стало приблизительно понятно что это, зачем это и как с этим начать работать.

    Чтобы разговор был предметным, предлагаю рассмотреть системы на практике. Я решил использовать простенький проект, в котором необходимы типовые, но не всегда тривиальные задачи сборки. Будем делать простенький web-сервер, цель которого: выдавать статичную страницу, которая готовится в отдельном html файле, но которая в итоге должна быть встроена в исполняемый файл. То есть на стадии сборки по html-коду должен быть собран исходник с си-кодом. В качестве серверной библиотеки используем mongoose, исходники которого положим внутрь проекта и будем собирать их в статическую библиотеку, которую в последствии будем прилинковывать к исполняемому файлу. Думаю, задача понятна.



    Итак, почему для опыта выбраны именно эти две системы? Ведь существуют ещё Rake, CMake, Ant/NAnt и другие. Ответ в первом предложении: они основаны на Python, а я его хорошо знаю и люблю, поэтому порог вхождения для меня должен был оказаться довольно низким. Приступим…

    Подготовка



    Для того, чтобы проект можно было собирать через SCons, он должен быть установлен в системе, а в корне проекта должен быть скрипт SConsturct. По крайней мере в Ubuntu пакет SCons присутствует в стандартном репозитории и установка никаких сложностей не представляет: `apt-get install scons`.

    Чтобы собирать проект через Waf, понадобится сам Waf, который поставляется в виде одного файла и кладётся прямо в корень проекта. То есть, устанавливать Waf в систему не нужно, а пользователи кода получают из системы контроля версий проект уже с «батарейками». Кроме этого необходим сам сборочный скрипт, который должен называться wscript.

    Дерево нашего проекта выглядит так:

    ~/devel/stupid-server$ tree
    .
    |-- external
    | `-- mongoose
    | |-- mongoose.c
    | `-- mongoose.h
    |-- html
    | `-- index.html
    |-- html2c
    |-- SConscript
    |-- SConstruct
    |-- src
    | `-- main.cpp
    |-- waf
    `-- wscript


    Помимо описанных вы видите ещё два файла: html2c и SConscript. О них расскажу далее.

    Компиляция и линковка



    SCons


    Отложим пока что преобразование html и займёмся сборкой сервера. Пусть пока он выдаёт ответ, который жёстко зашит в нашем main.cpp.

    Нам необходимо следующее: система должна собирать mongoose в статическую библиотеку, затем компилировать src/main.cpp и всё это слинковывать в один исполняемый файл. При этом все артефакты неплохо бы класть в отдельную директорию, чтобы её в случае чего можно было бы без оглядки удалить.

    Начнём со SCons. Скрипт SConstruct для этого выглядит так:

    env = Environment(
        CPPPATH = 'external/mongoose',
        CFLAGS = '-O2',
    )

    mongoose = env.StaticLibrary(Glob('external/mongoose/*.c'))
    env.Program(Glob('src/*.cpp') + [mongoose], LIBS=['dl', 'pthread'])


    Здесь первой строчкой мы определяем Environment — центроболт системы SCons, в котором настраиваем необходимые инструменты. В нашем случае мы добавляем include path к mongoose и добавляем флаг для сборки с оптимизацией. Следующей строкой мы определяем как собирать mongoose. Мы говорим, что это статическая библиотека и компиляции подлежат все .c файлы в директории external/mongoose. В нашем случае файл один, но если было бы много, Glob избавил бы от необходимости их перечислять. Наконец, третьей строкой мы определяем как собирать исполняемый файл. Мы говорим, что он состоит из всех .cpp в директории src и предварительно определённой mongoose, а в дополнение необходимо прилинковать стандартные библиотеки dl и pthread.

    Я поставил акцент на то, что мы именно определяем как и что собирать. Сборка в SCons не идёт строка за строкой, как описано в скрипте. Вместо этого система пользуется декларациями и сама определяет последовательность, учитывая зависимости между целями и возможность ускорения сборки в несколько потоков за счёт перетасовки задач.

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

    Вернёмся к SCons. Теперь для сборки проекта нам остаётся в терминале набрать `scons` и вуа-ля: веб-сервер собран! Однако в лучших традициях make, артефакты сборки появляются непосредственно рядом с исходниками, чем засоряют структуру. Для решения этой проблемы придётся создать дополнительный файл SConscript, в который перенести декларации целей. А из основного SConstruct необходимо говорить: «Собирать этот SConscript в эту директорию». К сожалению это единственный способ, который однако в документации защищается как фича. Наши скрипты теперь выглядят так:

    # SConstruct

    env = Environment(
        CPPPATH = 'external/mongoose',
        CFLAGS = '-O2',
    )

    SConscript('SConscript', variant_dir='build-scons', exports=['env'])

    # SConscript

    Import('env')
    mongoose = env.StaticLibrary(Glob('external/mongoose/*.c'))
    env.Program(Glob('src/*.cpp') + [mongoose], LIBS=['dl', 'pthread'])


    Теперь все потроха будут аккуратно складываться в отдельную директорию build-scons.

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

    Waf


    Теперь то же самое на Waf:

    top = '.'
    out = 'build-waf'

    def set_options(opt):
        opt.tool_options('compiler_cc')
        opt.tool_options('compiler_cxx')

    def configure(conf):
        conf.check_tool('compiler_cc')
        conf.check_tool('compiler_cxx')
        conf.env.append_unique('CCFLAGS', '-O2')
        conf.env.append_unique('CXXFLAGS', '-O2')

    def build(bld):
        bld(
            target = 'mongoose',
            features = 'cc cstaticlib',
            source = bld.path.ant_glob('external/mongoose/**/*.c'),
            export_incdirs = 'external/mongoose',
        )

        bld(
            features = 'cxx cprogram',
            source = bld.path.ant_glob('src/**/*.cpp'),
            target = 'stupid-server',
            lib = ['dl', 'pthread'],
            uselib_local = ['mongoose'],
        )


    Первыми двумя строками определяется, что считать корнем проекта и куда складывать артефакты.

    Как видно, в Waf это устроено более логично. Далее следует функция set_options. Она вызывается Waf для того, чтобы дополнить имеющиеся стандартные параметры командной строки, пользовательскими. В нашем случае добавляется стандартный набор параметров для того, чтобы можно было повлиять на работу C и C++ компиляторов.

    Далее следует функция configure. Она вызывается, когда мы говорим `./waf configure` и предназначена для выставления всех переменных окружения и поиска всех инструментов, которые затем будут использоваться при сборке коммандой `./waf build`. Разделение на два этапа: конфигурация и сборка аналогично тому, что используется в autotools и служит для ускорения. При повседневном использовании конфигурирование нужно вызывать редко, а сборку постоянно. Таким образом экономится ощутимая часть времени. В нашем случае в конфигурировании мы просим определить есть ли на машине компиляторы C и C++, запомнить где они есть и затем добавить флаг оптимизации к их настройкам.

    Следом идёт функция build, которая в качестве параметра принимает некий build context — центроболт системы Waf. Этот контекст можно вызывать как функцию, тем самым определять как и что собирать. Мы вызываем его два раза: для сборки mongoose и для сборки самого исполняемого файла. В каждом из вызовов определяются исходники, имя цели и набор features. Этот набор сообщает Waf'у что из инструментов нужно использовать чтобы превратить исходники в результат. В первом случае мы используем си-компилятор (cc) и архивер (cstaticlib), во втором — c++-компилятор (cxx) и линковщик (cprogram). Кроме того для исполняемого файла аргументом lib мы указываем необходимые стандартные библиотеки. А с помощью аргумента uselib_local говорим о том, что нужно линковаться с нашим местным mongoose.

    Очень странно, что для связывания со своими же артефактами в Waf понаделано куча костылей вроде uselib_local вместо того, чтобы позволить добавлять объекты-цели в список исходников, как это сделано в SCons. Но уж есть как есть. С другой стороны, приятным преимуществом является то, что include путь до mongoose инкапсулирован в самой цели mongoose, а не объявлен где-то на верхнем уровне. Только те цели, которые зависят от mongoose получат этот дополнительный путь в качестве директивы компилятору.

    Теперь, имея такой скрипт, для сборки достаточно вызвать `./waf configure build`. А в последствие, при работе над исходниками можно ограничиться `./waf build`.

    Сборка html



    Мне не известны какие-то общепринятые способы получения си-кода из html, поэтому я написал крохотный скрипт html2c, который делает из такого html:

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/1999/xhtml">
    <html>
        <body>
            <h1>Hello world from stupid server!</h1>
        </body>
    </html>


    такой .c:

    // Autogenerated by html2c. Think before edit
    const char htmlString[] =
        "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/1999/xhtml\">\n"
        "<html>\n"
        "    <body>\n"
        "        <h1>Hello world from stupid server!</h1>\n"
        "    </body>\n"
        "</html>\n"
    ;


    Чтобы воспользоваться этой строкой из основного файла используется нехитрый приём:

    extern "C" const char htmlString[];


    С точки зрения программирования — не очень элегантно, но для наших целей — самое оно.

    SCons


    Чтобы SCons понял как получать из диковинных файлов одного типа другие, необходимо определить так называемый Builder. Тем самым наш Environment обретёт дополнительный метод вроде StaticLibrary или Program, но который умеет решать нашу задачу:

    # SConstruct
    env['BUILDERS']['Html2c'] = Builder(
        action = './html2c $SOURCES > $TARGET',
        src_suffix = '.html',
        suffix = '.c',
    )


    Теперь у нас есть env.Html2c, чем мы и воспользуемся в нашем сценарии SConscript:

    html_c = env.Html2c('html/index.html')
    mongoose = env.StaticLibrary(Glob('external/mongoose/*.c'))
    env.Program(Glob('src/*.cpp') + [html_c, mongoose], LIBS=['dl', 'pthread'])


    Метод возвращает нам декларацию объекта-цели, которую мы затем интуитивно добавляем к исходникам основной программы.

    Waf


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

    def configure(conf):
        # ...
        conf.find_program('html2c', var='HTML2C', mandatory=True, path_list=[conf.srcdir])


    Затем необходимо определить новое преобразование, т.н. chain, которое скажет Waf как из .html получить .c:

    import TaskGen
    TaskGen.declare_chain(name='html2c', rule='${HTML2C} ${SRC} > ${TGT}', ext_in='.html', ext_out='.c', before='cc')


    Здесь мы использовали переменную ${HTML2C}, которую до этого задали на шаге конфигурации.

    Приятным моментом является то, что Waf не добавляет никакой магии к импортам, как это делает SCons. Всегда примерно понятно откуда растут ноги, в данном случае из модуля TaskGen. Поэтому в случае необходимости всегда можно просто найти интересующее место в исходниках системы.

    Теперь, когда Waf знает как делать преобразование мы можем просто добавить html к списку исходников основной программы:

    def build(bld):
        #...

        bld(
            features = 'cc cxx cprogram',
            source = bld.path.ant_glob('src/**/*.cpp') + ' html/index.html',
            # ...
        )


    Наблюдательный читатель мог заметить, что для нового преобразования мы ничего не добавили в features. Всё из-за того, что механизм `TaskGen.declare_chain` — упрощённый, универсальный, высокоуровневый способ расширения Waf. Это имеет очень неприятную обратную сторону: после такой декларации все .html, которые waf увидит в исходниках будут перво-наперво преобразованы в .c. То есть даже если отдельной целью вы пожелаете залить html по ftp на какой-нибудь сервер, система любезно предварительно преобразует их в .c и именно их будет загружать.

    Полноценное решение, которое учитывает features на удивление сложно и требует глубокого понимания Waf. Я решил не приводить его здесь дабы окончательно вас не усыпить.

    Приятные фишки



    Обе системы обладают встроенными сканнерами зависимостей C/C++ исходников. То есть про декларации зависимостей от .h файлов можно забыть.

    В обеих системах есть встроенная clean-система. Вызов `scons -c` удалит все артефакты, а `./waf clean` и `./waf distclean` удалит артефакты сборки и файлы-кэши шага конфигурации соответственно.

    Обе системы кроссплатформенны.

    SCons лучше, чем Waf



    SCons имеет более богатую историю, имеет в своём сообществе больше людей, упоминается в большем количестве публикаций и используется в большем количестве проектов.

    Документация к SCons на уровень выше, чем документация к Waf. Обе системы имеют e-книги, но в случае с Waf её содержимое становится понятным только с двадцатого раза.

    Идеология скриптов SCons как правило более понятна и интуитивна. Зачастую когда не совсем уверен как что-то делается, пробуешь делать по аналогии и всё получается. В Waf для многих задач нужно искать свой костыль и разбираться с его эксклюзивным интерфейсом.

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

    Waf лучше, чем SCons



    Waf был создан относительно недавно человеком, который стоял у истоков SCons. Поэтому в нём учтены и обойдены большие концептуальные провалы SCons, которые в крупных и сложных проектах отражались на производительности, простоте сопровождения, кроссплатформенности и т.д.

    Waf сильно быстрее SCons. Я не проверял, но утверждается, что на проекте в несколько тысяч исходных файлов с большим количеством зависимостей, он превосходит SCons в 10-15 раз. Спасибо раздельной конфигурации и сборке, а также более интеллектуальной системе распределения низкоуровневых задач. Кстати, если сравнивать c обычным make, то идут они ноздря в ноздрю: Питон медленее, но make из-за рекурсивного спавна процессов самого себя по поддиректориям теряет преимущество.

    Вывод Waf в консоль несравнимо приятнее вермишели из-под SCons и make. Он цветной и наглядный, а ошибки и предупреждения находятся глазами очень легко.

    Waf мобильный. Вся система находится в одном файле размером менее 100Кб прямо в коде, а потому позволяет забыть о необходимости установки себя на сборочной машине. Нужен только Питон, который есть на большинстве Posix систем.

    Waf допускает работу с исходниками, которые находятся за пределами корневой директории проекта. Например, с библиотеками, устанавливаемыми в систему, но доступными только в форме исходного кода. Если подобная задача возникает в SCons, гораздо проще пойти удавиться.

    Оба «хороши»



    Документация по обоим системам — не пример для подрожания. Хотя для обоих систем есть e-книги, позволяющие начать работать, обычный референс/мануал по большому счёту есть только у SCons. И то он представляет собой одну огромную портянку, искать по которой можно только через Ctrl+F. При расширении Waf, в сложных сценариях, когда дело доходит до широко распространённом в нём аспектно-ориентированного программирования, практически непременно придётся обращаться к исходникам, чтобы найти концы. В документации как правило информации такого уровня просто нет.

    То, что скрипты сборки — это всего-навсего Питон: утка. В обоих случаях. В виду специфики, код исполняется не импирически: строка за строкой, а в произвольном порядке, как решит система. Вернее код, конечно же, исполняется последовательно, но если написано `build_this_thing`, то это вовсе не означает, что сейчас произойдёт сборка. Это означает лишь, что есть такая штука и вот так её в случае чего нужно собирать. Этакий XML на Питоне получается.

    Исходный код обоих систем также — не фонтан. Местами он написан так, что если бы я написал нечто подобное на работе, я бы просто уволился, чтобы хоть как-то загладить вину перед своими коллегами. Хотя создателей можно понять: их родная среда — это C/C++ и большинства деталей Pythonic-культуры они могут не знать.

    Материал



    Все файлы проекта, разобранного в этом посте доступны в репозитории на BitBucket.
    • +10
    • 10,5k
    • 9
    Поделиться публикацией
    Комментарии 9
      0
      Спасибо. А насколько серьезно вы разбирались с WAF — не сталкивались ли случайно с задачей изменения дерева целей при выполнении сборки?

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

      Вот, интересно, не лучше ли с этим у WAF.

        0
        На практике с описанной вами проблемой не сталкивался, но подозреваю, что в Waf это вполне достижимо. Взгляните на параграф 9.3 в е-книге. Сканнер в Waf срабатывает, насколько я понимаю, ближе к моменту непосредственного вызова инструмента.

        Это то, что касается автоматического скана зависимостей. А просто поменять список исходников/целей — это в Waf обыденость и используется повсеместно. Просто заводим свой @feature, понимаем в какой момент его дёргать и обозначаем это декораторами @after, @before. Фича будет дёргаться в обозначенный момент с аргументом «генератор тасков». С ним то уже можно делать что угодно.
        0
        Как насчет cmake?
          0
          До CMake руки пока не добрались. Хотел попробовать, но упёрся в практически полное отсутствие вводных статей. Может не там искал. Если таковые есть буду признателен за ссылочки.
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              Если честно, в голову не приходило поискать информацию на русском. Большое спасибо!
              • НЛО прилетело и опубликовало эту надпись здесь
          0
          Прошло много лет, вы бы не могли сказать к чему вы дошли сейчас? Покушаюсь на Waf пока с целью замены bash-скриптов. Специфика проекта такова, что С++ код уже собирается CMake, а вот Python пакеты, которые мы пакуем Nuitka и кладём собранные С++ библиотеки рядом, собираются bash-скриптами. bash-скрипты справляются на отлично (они хорошо написаны и достаточно небольшие и читабельные) до тех пор, пока наш зоопарк не уезжает на Mac OS и Windows, где поддержка кросплатформенности утилит вроде grep/sed начинает угнетать буквально с первых минут.
            0
            Прошу прощения за некрокомментарий :), но хочу уведомить, что PVS-Studio поддерживает Waf. Возможно, некоторым это будет интересно.

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

            Самое читаемое