Система мета-сборки GN: краткий обзор и подходы

bfc47a04ec275a0c3661aeea0391006c.jpg

Привет! Меня зовут Александр, я работаю в VK в команде браузера Atom. В его основе лежит open source-движок Сhromium. Сегодня хочу поговорить о системе мета-сборки GN. Её используют в крупных проектах Google (Chrome, Fuchsia, а также связанных с ними), и, например, когда разрабатывают браузеры на основе Chromium (то есть почти все браузеры, кроме Mozilla, Safari и совсем какой-то экзотики). Система мета-сборки GN используется для генерации ninja-файлов, описывающих этапы сборки проекта.

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

Как я познакомился с GN

Знакомство с любым продуктом, который требует стадию сборки, обычно начинается (барабанная дробь) с системы сборки. Обычно для этого в проектах на C++ используется CMake. Однако, когда я пришёл в команду Atom,(а это было почти три года назад), то понял, что никакого CMake в браузере не будет. Раньше мы использовали GYP, но с 2016 года перешли на GN. 

Наша разработка выстроена таким образом, чтобы мы могли минимизировать затраты ресурсов при обновлении на более свежую версию. Поэтому иногда приходится искать довольно неочевидные варианты решения. Одно из таких неочевидных решений сподвигло меня написать данную статью. Мы все уже привыкли к тому, что в браузерах и текстовых редакторах за тем, чтобы мы писали без ошибок следит «проверка правописания». Она делится на базовую проверку (по словарям) и расширенную (когда отправляется текст на сервера для его более глубокой проверки). Chromium скачивает эти словари для проверки правописания из интернета. Мы решили, что при установке браузера Atom этот словарь уже должен быть у пользователя и его не нужно было бы скачивать повторно. Это удобно, например, для корпоративных пользователей, потому что позволит использовать базовую проверку правописания даже в закрытом контуре.  

В Chromium скачивание словарей использует заранее прописанные значения. А передо мной стояла задача придумать способ, который позволил бы получить актуальный словарь и встроить его сразу в браузер. Для этого и я взял GN. 

Как работают GN-сборки

Сначала рассмотрим основной процесс сборки проектов, внутри которых применяется система GN. Как правило, все они используют в том или ином виде depot_tools — коллекцию инструментов Chromium, а также CIPD (Chrome Infrastructure Package Deployment) — инфраструктуру развёртывания пакетов, не привязанную к определённой ОС, в отличие от APT, Nuget, Brew и других.

Прежде чем начать собирать проект, его нужно клонировать. Для этого можно использовать Git, можно утилиту fetch или gclient из набора depot_tools. Рассмотрим последнюю подробнее. 

Gclient — утилита, которая позволяет cкачивать исходный код проектов из различных репозиториев. Команда gclient config --name создаёт файл .gclient, в котором будет прописан репозиторий, и папку, в которую его нужно выгрузить. Команда gclient sync запускает клонирование заданного репозитория и всех зависимостей указанных в файле DEPS, который лежит в корне проекта. Внутри файла DEPS можно описать, что клонировать и куда класть. Например, этот файл может выглядеть так:

Код

vars = {
 # This can be overridden, e.g. with custom_vars, to build clang from HEAD
 # instead of downloading the prebuilt pinned revision.
 'llvm_force_head_revision': False,


 'chromium_git': 'https://chromium.googlesource.com',
 'clang_format_revision':    'f97059df7f8b205064625cdb5f97b56668a125ef',
 'libcxxabi_revision':    '307bd163607c315d46103ebe1d68aab44bf93986',
 'libunwind_revision':    '2795322d57001de8125cfdf18cef804acff69e35',
 'reclient_package': 'infra/rbe/client/',
 'reclient_version': 're_client_version:0.101.0.6210d0d-gomaip',


 # If you change this, also update the libc++ revision in
 # //buildtools/deps_revisions.gni.
 'libcxx_revision':       'bff81b702ff4b7f74b1c0ed02a4bcf6c2744a90b',


 # GN CIPD package version.
 'gn_version': 'git_revision:5a004f9427a050c6c393c07ddb85cba8ff3849fa',


 # ninja CIPD package version.
 # https://chrome-infra-packages.appspot.com/p/infra/3pp/tools/ninja
 # This has to stay in sync with the version in src/third_party/ninja/README.chromium.
 'ninja_version': 'version:2@1.11.1.chromium.6',
}


deps = {
 'src/buildtools/clang_format/script':
   Var('chromium_git') +
   '/external/github.com/llvm/llvm-project/clang/tools/clang-format.git@' +
   Var('clang_format_revision'),
 'src/buildtools/linux64': {
   'packages': [
     {
       'package': 'gn/gn/linux-${{arch}}',
       'version': Var('gn_version'),
     }
   ],
   'dep_type': 'cipd',
   'condition': 'host_os == "linux"',
 },
 'src/buildtools/mac': {
   'packages': [
     {
       'package': 'gn/gn/mac-${{arch}}',
       'version': Var('gn_version'),
     }
   ],
   'dep_type': 'cipd',
   'condition': 'host_os == "mac"',
 },
 'src/buildtools/third_party/libc++/trunk':
   Var('chromium_git') +
   '/external/github.com/llvm/llvm-project/libcxx.git' + '@' +
   Var('libcxx_revision'),
 'src/buildtools/third_party/libc++abi/trunk':
   Var('chromium_git') +
   '/external/github.com/llvm/llvm-project/libcxxabi.git' + '@' +
   Var('libcxxabi_revision'),
 'src/buildtools/third_party/libunwind/trunk':
   Var('chromium_git') +
   '/external/github.com/llvm/llvm-project/libunwind.git' + '@' +
   Var('libunwind_revision'),
 'src/buildtools/win': {
   'packages': [
     {
       'package': 'gn/gn/windows-amd64',
       'version': Var('gn_version'),
     }
   ],
   'dep_type': 'cipd',
   'condition': 'host_os == "win"',
 },
 'src/buildtools/reclient': {
   'packages': [
     {
       'package': Var('reclient_package') + '${{platform}}',
       'version': Var('reclient_version'),
     }
   ],
   'dep_type': 'cipd',
 },
 'src/third_party/ninja': {
   'packages': [
     {
       'package': 'infra/3pp/tools/ninja/${{platform}}',
       'version': Var('ninja_version'),
     }
   ],
   'dep_type': 'cipd',
 },
}

Здесь можно скачать все необходимы инструменты и зависимости с указанием нужной ревизии, которые понадобятся нам в проекте.

Когда все нужные исходники получены, а ОС-зависимые компоненты установлены в систему (SDK для Windows, ряд зависимостей для Linux), переходим к подготовке сборки нашего продукта с помощью системы сборки Ninja. Для этого мы выполним команду gn args out/Debug, где out/Debug — директория, в которой будет собираться проект, а args — это аргумент, который будет требовать от нас ввода через текстовый редактор всех аргументов, применяемых при сборке. Через него можно, например, регулировать, какие фичи будем собирать, под какую систему и т. д.  После того, как утилита GN отработала, можно запускать сборку через Ninja привычным образом:

ninja -j80 -C out/Debug chrome

Общий конвейер для таких проектов выглядит следующим образом. Сначала создаем конфигурацию в файле .gclient с помощью команды:

$ gclient config --name gn_example git@github.com:ciberst/gn_example.git

Содержимое получившегося файла выглядит следующим образом:

solutions = [
  { "name"        : 'src',
    "url"         : 'git@github.com:ciberst/gn_example.git',
    "deps_file"   : 'DEPS',
    "managed"     : True,
    "custom_deps" : {
    },
    "custom_vars": {},
  },
]

Далее клонируем проект и зависимости:

$ gclient sync

Переходим в клонированную директорию:

$ cd gn_example

Создаём сборочную директорию и генерируем таргеты:

$ gn args out/Debug

Собираем проект целиком:

$ ninja -j4 -C out/Debug 

Собираем выбранные таргеты:

$ ninja -j4 -C out/Debug example_1 exmple_2

Отлично, мы запустили сборку. А как использовать систему GN в своих проектах? Что она вообще умеет, насколько гибка, какие языки программирования поддерживает?

Начинаем свой проект

По этим ссылкам вы найдёте сухую информацию о том, как начать использовать GN в своих проектах:

1. https://gn.googlesource.com/gn/+/refs/heads/main
2. https://gn.googlesource.com/gn/+/main/docs/reference.md

Но, как мы знаем, документация не всегда помогает на практике, и тогда мы обращаемся к сообществу, к которому я отношу и себя.   

Прежде чем мы попробуем написать собственный проект на основе сборки GN, давайте разберёмся, как будет устроена файловая структура проекта:  

├── build
├── BUILD.gn
├── buildtools
├── DEPS
├── example_1
├── example_2
├── example_3
├── example_4
├── .gn
├── out
└── third_party

В первую очередь нам интересен файл .gn, который лежит в корне. Внутри файла прописывается buildconfig — это переменная, которая принимает путь до gn-файла с описанием основных опций сборки. Помимо этого можно указать script_executable — утилиту, с помощью которой будут запускаться наши скрипты. Содержимое файла .gn будет выглядеть так:

# The location of the build configuration file.
buildconfig = "//build/BUILDCONFIG.gn"


# The python interpreter to use by default. On Windows, this will look
# for python3.exe and python3.bat.
script_executable = "python3"

Как видите, buildconfig ссылается на файл BUILDCONFIG.gn в директории build. Рассмотрим, его содержимое. В нём устанавливается набор инструментов для конкретной платформы или архитектуры. Внутри GN уже есть переменные с нужной информацией, такой как целевые и текущие архитектура и ОС   

В итоге получаем примерно такой файл:

Код

if (target_os == "") {
  target_os = host_os
}
if (target_cpu == "") {
  target_cpu = host_cpu
}
if (current_cpu == "") {
  current_cpu = target_cpu
}
if (current_os == "") {
  current_os = target_os
}

is_linux = host_os == "linux" && current_os == "linux" && target_os == "linux"
is_mac = host_os == "mac" && current_os == "mac" && target_os == "mac"

# All binary targets will get this list of configs by default.
_shared_binary_target_configs = [ "//build:compiler_defaults" ]

# Apply that default list to the binary target types.
set_defaults("executable") {
  configs = _shared_binary_target_configs
  # Executables get this additional configuration.
  configs += [ 
    "//build:executable_ldconfig",
  ]
}

set_defaults("static_library") {
  configs = _shared_binary_target_configs
}

set_defaults("shared_library") {
  configs = _shared_binary_target_configs
}

set_defaults("source_set") {
  configs = _shared_binary_target_configs
}

set_default_toolchain("//build/toolchain:gcc")

Рассмотрим строчку _shared_binary_target_configs = [ "//build:compiler_defaults" ]. Она добавляет описание конфигов для динамических библиотек. Дополнительные конфиги описываются в секции set_defaults("executable"):

  configs += [ 
    "//build:executable_ldconfig",
  ]

Как мы уже знаем, путь вида //build:compiler_defaults и //build:executable_ldconfig говорит о том, что в папке build расположен файл BUILD.gn, внутри которого есть описание таргетов compiler_defaults и executable_ldconfig, а выглядит файл так:  

config("compiler_defaults") {
 if (current_os == "linux") {
   cflags = [
     "-fPIC",
     "-pthread",
   ]
 }
 include_dirs = [
   "//",
   root_gen_dir,
 ]
}
config("executable_ldconfig") {
 if (!is_mac) {
   ldflags = [
     "-Wl,-rpath=\$ORIGIN/",
     "-Wl,-rpath-link=",
   ]
 }
}

set_default_toolchain устанавливает имя цепочки инструментов по умолчанию. Если нам надо задать разные инструменты, то можно сделать таким образом:

  if (target_cpu == "x64") {
    set_default_toolchain("//toolchains:64")
  } else if (target_cpu == "x86") {
    set_default_toolchain("//toolchains:32")
  }

Теперь рассмотрим строку //build/toolchain:gcc. Подстрока //build/toolchain предполагает, что в директории build/toolchain будет файл BUILD.gn. А подстрока gcc говорит о том, что в файле BUILD.gn будет описан таргет gcc.


Внутри описания набора инструментов можно описать все этапы сборки. Сам набор описывается так:

toolchain("gcc") {
}

где gcc — имя набора. Оно может быть любым, в этом примере буду использовать gcc, а вы можете выбрать любое. Внутри описываются основные инструменты, можно гибко всё настроить. Внутри таргета gcc описываются его инструменты (tool):

Код

tool("cc") {
   depfile = "{{output}}.d"
   command = "gcc -MMD -MF $depfile {{defines}} {{include_dirs}} {{cflags}} {{cflags_c}} -c {{source}} -o {{output}}"
   depsformat = "gcc"
   description = "CC {{output}}"
   outputs =
       [ "{{source_out_dir}}/{{target_output_name}}.{{source_name_part}}.o" ]
 }
 tool("cxx") {
   depfile = "{{output}}.d"
   command = "g++ -MMD -MF $depfile {{defines}} {{include_dirs}} {{cflags}} {{cflags_cc}} -c {{source}} -o {{output}}"
   depsformat = "gcc"
   description = "CXX {{output}}"
   outputs =
       [ "{{source_out_dir}}/{{target_output_name}}.{{source_name_part}}.o" ]
 }
 tool("alink") {
   command = "rm -f {{output}} && ar rcs {{output}} {{inputs}}"
   description = "AR {{target_output_name}}{{output_extension}}"
   outputs =
       [ "{{target_out_dir}}/{{target_output_name}}{{output_extension}}" ]
   default_output_extension = ".a"
   output_prefix = "lib"
 }
 tool("solink") {
   soname = "{{target_output_name}}{{output_extension}}"  # e.g. "libfoo.so".
   sofile = "{{output_dir}}/$soname"
   rspfile = soname + ".rsp"
   if (is_mac) {
     os_specific_option = "-install_name @executable_path/$sofile"
     rspfile_content = "{{inputs}} {{solibs}} {{libs}}"
   } else {
     os_specific_option = "-Wl,-soname=$soname"
     rspfile_content = "-Wl,--whole-archive {{inputs}} {{solibs}} -Wl,--no-whole-archive {{libs}}"
   }
   command = "g++ -shared {{ldflags}} -o $sofile $os_specific_option @$rspfile"
   description = "SOLINK $soname"
   # Use this for {{output_extension}} expansions unless a target manually
   # overrides it (in which case {{output_extension}} will be what the target
   # specifies).
   default_output_extension = ".so"
   # Use this for {{output_dir}} expansions unless a target manually overrides
   # it (in which case {{output_dir}} will be what the target specifies).
   default_output_dir = "{{root_out_dir}}"
   outputs = [ sofile ]
   link_output = sofile
   depend_output = sofile
   output_prefix = "lib"
 }
 tool("link") {
   outfile = "{{target_output_name}}{{output_extension}}"
   rspfile = "$outfile.rsp"
   if (is_mac) {
     command = "g++ {{ldflags}} -o $outfile @$rspfile {{solibs}} {{libs}}"
   } else {
     command = "g++ {{ldflags}} -o $outfile -Wl,--start-group @$rspfile {{solibs}} -Wl,--end-group {{libs}}"
   }
   description = "LINK $outfile"
   default_output_dir = "{{root_out_dir}}"
   rspfile_content = "{{inputs}}"
   outputs = [ outfile ]
 }

Инструменты cc, cxx, alink, solink, link и другие отвечают за описание сборки С/C++, линковку, формирования исполняемых файлов и библиотек.

Теперь об основной функциональности описания таргетов. Ими мы называем цели сборки или некоторые действия. В GN условно можно выделить два глобальных шага: генерацию проектных файлов и непосредственно сборку. Система мета-сборки генерирует файлы таким образом, что в дальнейшем можно работать с помощью Ninja или Autoninja. 

Из коробки GN поддерживает Rust, C++ и Objective-C++. Другие языки можно добавить через врапперы на Python. GN позволяет собрать сначала один проект, а потом его использовать для генерации других файлов. Например, мы можем сначала собрать protoc, а потом его использовать для генерации proto-файлов.

Практика

Для описания того и что нужно собирать существуют файлы BUILD.gn. Обычно в корне проекта создают общий BUILD.gn файл, в котором необходимо указать независимые цели, которые будут доступны для сборки. Это делают так: создают группу (group создает мета-таргет) all, в которой описывают расположение независимых таргетов:

group("all") {
 deps = [
   "//example_1:example_1",
   "//example_2",
   "//example_3",
   "//example_4",
 ]
}

Два слэша означают, что искать расположение нужно относительно корня проекта, а двоеточие после пути означает конкретную цель. Например, первая цель //example_1:example_1 говорит нам о том, что существует директория example_1, внутри которой описан таргет example_1. Давайте посмотрим, как его можно описать. Для этого необходимо создать файл BUILD.gn в директории example_1 и в нём перечислить нужные действия. Если нам нужен исполняемый файл, то это будет выглядеть так:

executable("example_1") {
 sources = [
   "main.cc"
 ]
}

Здесь мы описываем таргет example_1 и указываем все исходные файлы (заголовочные тоже нужно указывать). Таким образом, executable с переданным именем позволяет нам создать исполняемый файл  с именем example_1.

Если нам необходимо указать зависимости:  

executable("example_3") {
   sources = [
       "main.cc",
   ]


   deps = [
       "library:library"
   ]
}

Появляется секция deps. Дерево сборки будет построено таким образом, что сначала соберётся library из папки library. Для сборки статической библиотеки существует static_library:

static_library("library") {
   sources = [
       "library.h",
       "library.cc",
   ]
}

Для динамической — shared_library:

shared_library("library") {
   sources = [
       "library.h",
       "library.cc",
   ]
}

Помимо этого мы можем настроить кодогенерацию:

action("example_2_action") {
   script = "main.py"
   args = [
       rebase_path("$target_gen_dir/main.cc", root_out_dir)
   ]
   outputs = [
       "$target_gen_dir/main.cc"
   ]
}

Здесь нужно объявить переменную script, в которую необходимо передать Python-файл. Выше я уже указал, что для скриптов будем использовать Python 3. И укажем, какие данные получаем на выходе. 

Args — это аргументы которые пробрасываются в Python-файл, а outputs — это файлы, которые мы получаем на выходе. Если же никакие файлы мы не создаём, то таргет action нам не подойдёт, лучше рассмотреть функциональность exec_script

Здесь появляется ещё один интересный момент: используются предобъявленные переменные, встроенные в GN, в данном случае target_gen_dir и root_out_dir. Также можно легко использовать переменные внутри строки, просто с помощью знака $ перед переменной. 

Этот таргет будет пересобираться только тогда, когда мы поменяем исходники Python-файла или вручную поменяем сгенерированный файл, что делать, конечно, не очень хорошо.

executable("example_2") {
   sources = [
       "$root_build_dir/gen/example_2/main.cc"
   ]
   deps = [
       ":example_2_action"
   ]
}

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

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

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

Вместо заключения

GN — это очень гибкий инструмент, который после правильного конфигурирования предоставляет инструмент для кроссплатформенной разработки. А бонусом идёт возможность генерации проектов для Visual Studio, XCode и QtCreator с помощью команды gn gen и передачи в аргумент --ide нужной IDE.Привет! Меня зовут Александр, я работаю в VK в команде браузера Atom. В его основе лежит open source-движок Сhromium. Сегодня хочу поговорить о системе мета-сборки GN. Её используют в крупных проектах Google (Chrome, Fuchsia, а также связанных с ними), и, например, когда разрабатывают браузеры на основе Chromium (то есть почти все браузеры, кроме Mozilla, Safari и совсем какой-то экзотики). Система мета-сборки GN используется для генерации ninja-файлов, описывающих этапы сборки проекта.

© Habrahabr.ru