На страницах нашего блога мы уже писали о преимуществах организации репозитория крупного проекта способом, предполагающим возможность извлечения исходников в изменяемую структуру рабочей копии. Использование такого подхода вкупе с потребностями простого конфигурирования, фрагментарной сборки, поддержки несколько десятков ОС под широкий спектр аппаратных платформ стали причиной разработки нами собственной системы сборки. Эта статья рассказывает о найденных нами решениях, которые могут быть интересны разработчикам, сталкивающимися с трудностями поддержки инфраструктуры больших проектов.
Прежде чем перейти непосредственно к техническим деталям следует отметить два важных момента. Во-первых, система работает поверх разработанной нами 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>
L-- example
+-- example.c
+-- example.h
+-- makefile.lmk
L-- makelibs
Вариант содержимого modbld
<bldroot>
L-- example
+-- LINUX_AMD64_GCC4_MD_R_base60
¦ +-- charset.obj
¦ +-- example.cfl
¦ +-- example.h
¦ +-- example.lnk
¦ +-- example.obj
¦ +-- example.so
¦ L-- makefile
+-- LINUX_AMD64_GCC4_MD_R_full60
¦ +-- charset.obj
¦ +-- example.cfl
¦ +-- example.h
¦ +-- example.lnk
¦ +-- example.obj
¦ +-- example.so
¦ L-- makefile
+-- LINUX_AMD64_GCC4_MT_R_base60
¦ +-- charset.obj
¦ +-- example.a
¦ +-- example.cfl
¦ +-- example.h
¦ +-- example.lnk
¦ +-- example.obj
¦ L-- makefile
L-- LINUX_AMD64_GCC4_MT_R_full60
+-- charset.obj
+-- example.a
+-- example.cfl
+-- example.h
+-- example.lnk
+-- example.obj
L-- 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.
jreznot
Вы пробовали Gradle? На первый взгляд кажется, что он может решить задачи описанные в статье.
relexru
Потенциально может решать. И потенциально его можно было бы адаптировать к нашим потребностям (в частности, автономной сборке), но нам дешевле поддерживать интегрированную с другими нашими сервисами и моделью поддержки инфраструктуру, чем адаптировать Gradle к нем.
dim_s
Для него легко писать плагины, через плагины думаю вполне можно интегрировать все.
npechenkin
Согласен, но определенное значение играет и простота поддержки системы сборки. make-образный синтаксис, знакомый любому разработчику, позволяет свести к минимуму издержки на внесение правок в модуль.