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

Прежде чем перейти непосредственно к техническим деталям следует отметить два важных момента. Во-первых, система работает поверх разработанной нами make-утилиты linmake, об особенностях которой будет рассказано отдельно. И, во-вторых, разработка велась для решения задач производства СУБД ЛИНТЕР (www.linter.ru), что привнесло определенную специфику, но не настолько существенную, чтобы решение не могло быть адаптировано к любому проекту.
Как это часто бывает, развитие и усложнение проекта в какой-то момент привело к тому, что поддержка инфраструктуры сборки стала слишком накладной и этому поспособствовало несколько причин, полное перечисление которых заняло бы неприлично много места, поэтому позволим себе выделить только те, которые вызывали большее число нареканий от участников проекта:
Конечно, помимо проблем были и пожелания по реализации новых «фич», поэтому, когда было принято решение о разработке новой унифицированной системы сборки, которую назвали unimake, мы вполне определенно представляли каких целей хотим достичь:
Сборка производится в отличной от исходников (srcroot) директории — директории сборки (bldroot). Каждая сборка проекта целиком определяется набором множеств:
Вариант конфигурации проекта
Комбинация перечисленных параметров определяет все возможные варианты, которые предварительно фильтруется системой сборки с целью отсеять ненужные и не имеющие смысла комбинации.
В свою очередь, каждый модуль расширяет параметры «для себя» с помощью двух файлов-описателей: для модуля и для процесса сборки, которые написаны в декларативном стиле и не содержат правил (за редким исключением). Описатель модуля содержит общую информацию о модуле: наименование и версии, поддерживаемые платформы, компиляторы и архитектуры, модели потоков, цели. Все объявления (кроме имени) не являются обязательными и в случае их отсутствия используются значения по умолчанию.
Вариант описателя модуля
Описатель сборки объявляет цели, их состав, директивы, директории поиска, внешние и внутренние зависимости модуля.
Вариант описателя сборки
В bldroot структура директорий повторяет srcroot до уровня корней каждого модуля (modsrc), но уже в них, содержатся все фактические варианты, задаваемые допустимыми комбинациями общепроектных и модульных конфигураций. Под каждый из таких вариантов создается директория вида $(MODULE)/$(PLT)_$(ARCH)_$(CMPL)$(CMPLV)_$(TYPE)_$(CFG) (например example/LINUX_AMD64_GCC4_MD_R_base60), будем именовать далее эти директории как modbld.
Вариант содержимого modsrc
Вариант содержимого modbld
В каждой допустимой modbld в процессе выполнения обхода директорий создается три файла: опций компилятора (*.cfl в нашем случае), опций компоновщика (*.lnk — в примере) и вспомогательный makefile, которые предназначены для проведения компиляции и компоновки целей в обход общей системы сборки, что бывает часто востребовано для задач отладки. Таким образом, существует два варианта использования системы:
Схема вызовов для обоих случаев приведены на иллюстрациях ниже.

Иллюстрация 1: Сборка всего проекта (1) приводит к формированию последовательности вызовов корневого make-файла (3) для всех возможных комбинаций опций сборки (2). В результате фильтрации (3) отсеиваются заведомо непригодные варианты. Файлы описатели модулей, (4) исходя из зависимостей и дополнительных параметров корректируют варианты. Описатели сборки (5) выполняют правила (6) и формируют целевые директории с результатами исполнения (7).

Иллюстрация 2: Обновление существующих модулей (1) работает по упрощенной схеме: вспомогательные правила в modbld (3) обновляют (4) свои цели без использования описателя модуля и фильтров.
Как уже упоминалось выше, все правила вынесены в отдельный модуль (unimake) на уровне проекта, который, помимо заданий самих правил, отвечает за хранение дерева зависимостей между модулями. При этом, каждый модуль из объявленных порождает отдельную цель с генерируемыми зависимыми целями.
Хранение и использование зависимостей между модулями
Благодаря встроенному парсеру файлов размещения модулей linmodules имеется возможность отслеживает текущее положение модулей в дереве исходников и использовать простое определение пути.
Чтение и регистрация модулей и путей
Описанный в предыдущем разделе подход был реализован нами для инфраструктуры проекта ЛИНТЕР. И, несмотря на то, что произошло это относительно недавно (около полугода назад) система уже положительно зарекомендовала себя с точки зрения простоты использования, масштабируемости и производительности.
Еще на ранних этапах реализации мы столкнулись с известными недостатками gnu make, поэтому решение базируется на make-утилите собственной разработки — linmake, в синтаксисе которой и приведены все листинги в этой статье. Вероятнее всего, в обозримом будущем мы на страницах блога вернемся к теме linmake и его особенностей, но пока этого не произошло публикация системы в том виде, как она используется в разработке не имеет смысла. Однако, было бы неправильно лишить читателя возможности апробировать предлагаемую модель, поэтому здесь (github.com) доступен рабочий прототип для gnu make.

Прежде чем перейти непосредственно к техническим деталям следует отметить два важных момента. Во-первых, система работает поверх разработанной нами make-утилиты linmake, об особенностях которой будет рассказано отдельно. И, во-вторых, разработка велась для решения задач производства СУБД ЛИНТЕР (www.linter.ru), что привнесло определенную специфику, но не настолько существенную, чтобы решение не могло быть адаптировано к любому проекту.
Зачем нужно было создавать новую систему сборки?
Как это часто бывает, развитие и усложнение проекта в какой-то момент привело к тому, что поддержка инфраструктуры сборки стала слишком накладной и этому поспособствовало несколько причин, полное перечисление которых заняло бы неприлично много места, поэтому позволим себе выделить только те, которые вызывали большее число нареканий от участников проекта:
- из-за того, что в далеком 1999 году не было приемлемого кроссплатформенного инструмента мы были вынуждены долгое время поддерживать две параллельные системы сборки: на основе wmake для windows и make для *nix;
- разнообразие поддерживаемых UNIX-like платформ привело к увеличению (а значит и усложнению) вариантов компиляции и компоновки в модулях проекта;
- в свою очередь, сборка windows версии усложнялась необходимостью поддержки большого количества компиляторов;
- не существовало простого механизма описания и разрешения как внешних и внутренних зависимостей проекта.
Конечно, помимо проблем были и пожелания по реализации новых «фич», поэтому, когда было принято решение о разработке новой унифицированной системы сборки, которую назвали unimake, мы вполне определенно представляли каких целей хотим достичь:
- система должна однообразно работать на всех поддерживаемых платформах;
- изменение положения модуля (здесь и далее под модулем мы будем понимать функционально самодостаточную часть проекта) в рабочем дереве не должно влиять на работоспособность;
- необходим простой механизм по добавлению новых целевых платформ, архитектур, компиляторов и их версий;
- следует хранить как типовые конфигурации для версий и редакций продукта, так и предоставлять возможность их настройки при необходимости;
- нужен простой способ автоматического учета внешних и внутренних зависимостей в проекте, который бы автоматически определял порядок операций;
- система должна предоставлять возможность простой сборки части проекта со всеми ее зависимостями.
Модель сборки, общие положения
Сборка производится в отличной от исходников (srcroot) директории — директории сборки (bldroot). Каждая сборка проекта целиком определяется набором множеств:
- конфигураций/версий продуктов (CONFIGS);
- целевых платформ (PLATFORMS);
- целевых архитектур (ARCHS);
- компиляторов (COMPILERS);
- версиями компиляторов ($(CMPL)_VERS);
- платформой сборки (HOST.PLT);
- архитектурой платформы сборки $(HOST.ARCH).
Вариант конфигурации проекта
...
CONFIGS = base60 full60
PLATFORMS = LINUX
ARCHS = AMD64 JAVA .NET
COMPILERS = GCC JAVAC MONO
JAVAC_VERS = 1.4 1.5 1.6
GCC_VERS = 4
MONO_VERS = 3
…
HOST.PLT = LINUX
HOST.ARCH = AMD64
DEBUG = RELEASE
Комбинация перечисленных параметров определяет все возможные варианты, которые предварительно фильтруется системой сборки с целью отсеять ненужные и не имеющие смысла комбинации.
В свою очередь, каждый модуль расширяет параметры «для себя» с помощью двух файлов-описателей: для модуля и для процесса сборки, которые написаны в декларативном стиле и не содержат правил (за редким исключением). Описатель модуля содержит общую информацию о модуле: наименование и версии, поддерживаемые платформы, компиляторы и архитектуры, модели потоков, цели. Все объявления (кроме имени) не являются обязательными и в случае их отсутствия используются значения по умолчанию.
Вариант описателя модуля
MODULE = example #наименование библиотеки
VERSIONS = #необходимы отдельные версии библиотеки для каждой версии проекта
VERSIONS_REQ:= $(CFG.VER) #версия библиотеки совпадает с версией проекта
LINK_TYPES = static dynamic #будут созданы статическая и разделяемая/динамическая библиотеки
THREAD_TYPES = mt #только многопоточная версия
DST_SRC = example.h #в целевую директорию помимо целей попадет и заголовочный файл
DONT_BUILD_WATCOM = # не выполнять сборку, если компилятор — watcom (любой версии)
DONT_BUILD_WINCE = # не выполнять сборку если целевая платформа — WinCE
Описатель сборки объявляет цели, их состав, директивы, директории поиска, внешние и внутренние зависимости модуля.
Вариант описателя сборки
...
TARGET = $(MODULE) #целевой файл библиотеки будет иметь имя, совпадающее с названием модуля + расширение, определяемое типом цели и платформой (.so, .a, .dll и т.д.)
DEFINES = _VER=$(CFG_VER) SOME_DEFINES #дефайны общие для всех платформ
DEFINES_WINNT = EXAMPLE_WIN #директива только для Windows
DEFINES_UNIX = EXAMPLE_POSIX #директива для всех *nix
CDIR = $(MODROOT);$(MODROOT)/utils; #директории с исходниками
INCLDIR = $(MODROOT);$(ANOTHER_MOD); #директории поиска
OBJS = &
example.obj # объектные файлы для всех платформ
OBJS_UNIX = &
charset.obj # дополнительные объектные файлы для *nix платформ
SLIBS_WINNT = $(ANOTHER_LIB) oldnames #статические библиотеки для windows платформы...
SLIBS_UNIX = $(ANOTHER_LIB) #статическая библиотека для *nix
...
В bldroot структура директорий повторяет srcroot до уровня корней каждого модуля (modsrc), но уже в них, содержатся все фактические варианты, задаваемые допустимыми комбинациями общепроектных и модульных конфигураций. Под каждый из таких вариантов создается директория вида $(MODULE)/$(PLT)_$(ARCH)_$(CMPL)$(CMPLV)_$(TYPE)_$(CFG) (например example/LINUX_AMD64_GCC4_MD_R_base60), будем именовать далее эти директории как modbld.
Вариант содержимого modsrc
<srcroot>
└── example
├── example.c
├── example.h
├── makefile.lmk
└── makelibs
Вариант содержимого modbld
<bldroot>
└── example
├── LINUX_AMD64_GCC4_MD_R_base60
│ ├── charset.obj
│ ├── example.cfl
│ ├── example.h
│ ├── example.lnk
│ ├── example.obj
│ ├── example.so
│ └── makefile
├── LINUX_AMD64_GCC4_MD_R_full60
│ ├── charset.obj
│ ├── example.cfl
│ ├── example.h
│ ├── example.lnk
│ ├── example.obj
│ ├── example.so
│ └── makefile
├── LINUX_AMD64_GCC4_MT_R_base60
│ ├── charset.obj
│ ├── example.a
│ ├── example.cfl
│ ├── example.h
│ ├── example.lnk
│ ├── example.obj
│ └── makefile
└── LINUX_AMD64_GCC4_MT_R_full60
├── charset.obj
├── example.a
├── example.cfl
├── example.h
├── example.lnk
├── example.obj
└── makefile
В каждой допустимой modbld в процессе выполнения обхода директорий создается три файла: опций компилятора (*.cfl в нашем случае), опций компоновщика (*.lnk — в примере) и вспомогательный makefile, которые предназначены для проведения компиляции и компоновки целей в обход общей системы сборки, что бывает часто востребовано для задач отладки. Таким образом, существует два варианта использования системы:
- сборка всего проекта/модуля впервые;
- обновление модуля.
Схема вызовов для обоих случаев приведены на иллюстрациях ниже.

Иллюстрация 1: Сборка всего проекта (1) приводит к формированию последовательности вызовов корневого make-файла (3) для всех возможных комбинаций опций сборки (2). В результате фильтрации (3) отсеиваются заведомо непригодные варианты. Файлы описатели модулей, (4) исходя из зависимостей и дополнительных параметров корректируют варианты. Описатели сборки (5) выполняют правила (6) и формируют целевые директории с результатами исполнения (7).

Иллюстрация 2: Обновление существующих модулей (1) работает по упрощенной схеме: вспомогательные правила в modbld (3) обновляют (4) свои цели без использования описателя модуля и фильтров.
Как уже упоминалось выше, все правила вынесены в отдельный модуль (unimake) на уровне проекта, который, помимо заданий самих правил, отвечает за хранение дерева зависимостей между модулями. При этом, каждый модуль из объявленных порождает отдельную цель с генерируемыми зависимыми целями.
Хранение и использование зависимостей между модулями
…
dep_example = another
dep_another =
…
module-deps = $(foreach name,$(DEP_$(1)), $(MOD_$(name)))
gen-module-deps = $(foreach name,$(DEP_$(1)), $(2)_$(MOD_$(name)))
!define gen-target
$(1): .SYMBOLIC
@$(MAKE) MODULE=$(1)
!endef
!define gen-targets
TARGETS_$(1) := $(foreach mod,$(ALL_MODULE_NAMES), $(1)_$(mod))
$(1): $$(TARGETS_$(1))
@%null
!endef
gen-targets-without-deps = $(foreach mod,$(ALL_MODULE_NAMES),$(gen-target ,$(mod)))
!eval $(gen-targets-without-deps)
!eval $(gen-targets dep)
Благодаря встроенному парсеру файлов размещения модулей linmodules имеется возможность отслеживает текущее положение модулей в дереве исходников и использовать простое определение пути.
Чтение и регистрация модулей и путей
#git modules
LINMODS=$(modlist $(SRCROOT)/.linmodule)
!define add-mod
MOD_$(1) = $$(modpath $(1))
!endef
!eval $(foreach i,$(LINMODS),$(add-mod $(i)))
Реализация
Описанный в предыдущем разделе подход был реализован нами для инфраструктуры проекта ЛИНТЕР. И, несмотря на то, что произошло это относительно недавно (около полугода назад) система уже положительно зарекомендовала себя с точки зрения простоты использования, масштабируемости и производительности.
Еще на ранних этапах реализации мы столкнулись с известными недостатками gnu make, поэтому решение базируется на make-утилите собственной разработки — linmake, в синтаксисе которой и приведены все листинги в этой статье. Вероятнее всего, в обозримом будущем мы на страницах блога вернемся к теме linmake и его особенностей, но пока этого не произошло публикация системы в том виде, как она используется в разработке не имеет смысла. Однако, было бы неправильно лишить читателя возможности апробировать предлагаемую модель, поэтому здесь (github.com) доступен рабочий прототип для gnu make.