Поддержка сборочных скриптов

Некоторые пакеты требуют компиляции стороннего кода написанного на других языках программирования, например СИ-библиотек. Другие пакеты предназначены для линковки с СИ-библиотеками. Они могут располагаться где-то в системе, или собираться из исходников. Третий вариант - это контейнеры, которые требуют дополнительной подготовки к сборке, например генерации кода перед сборкой.

Cargo не ставит целью заменить инструменты которые хорошо оптимизированы для своих задач. Вместо этого, данные инструменты можно интегрировать в процесс сборки посредством опции build сборочного манифеста.

[package]
# ...
build = "build.rs"

Rust файл, указанный в опции build (относительно корня пакета) будет скомпилирован и выполнен перед тем, как что-либо еще будет компилироваться в этом пакете. Это позволяет вашему rust-коду зависеть от других собранных или сгенерированных артефактов. Опция build не имеет значения по умолчанию, и должна быть указана явно, если это требуется.

Причание. В rust 1.17 этот механизм был доработан следующим образом. Если в корневой директории контейнера есть файл build.rs, то считается, что опция build = "build.rs" указана. Отключить это вы можете задав build=false.

Примеры случаев, для которых подходит опция build:

Каждый из этих случаев детально описан ниже для пояснения того, как работает опция build.

Входные данные для скрипта сборки

Когда вы запускаете скрипт сборки, можно задать параметры, которые передаются путем задания переменных окружения environment variables.

Вдобавок к переменным окружения, текущая директория скрипта сборки является директорией пакета скрипта сборки.

Выходной результат скрипта сборки

Все строки, которые будут напечатаны в stdout, будут записаны в файл target/debug/build/<pkg>/output (точное расположение зависит от вашей конфигурации). Все строки, которые начинаются с cargo: интерпретируются напрямую cargo. Эти строки обязаны задаваться в форме cargo:key=value, как показано в примере:

cargo:rustc-link-lib=static=foo
cargo:rustc-link-search=native=/path/to/foo
cargo:rustc-cfg=foo
cargo:root=/path/to/foo
cargo:libdir=/path/to/foo/lib
cargo:include=/path/to/foo/include

Ниже перечислены ключи, которые влияют на cargo:

Любой другой элемент является пользовательскими мета-данными, и передается зависимостям. Больше информации можно найти в секции links.

Зависимости для процесса сборки

Скрипт сборки может иметь зависимости на другие cargo-пакеты. Эти зависимости объявляются в секции build-dependencies.

[build-dependencies]
foo = { git = "https://github.com/your-packages/foo" }

Скрипту сборки НЕ ДОСТУПНЫ зависимости, перечисленные в секциях dependencies и dev-dependencies. Так же, все зависимости этапа сборки недоступны пакету, и если они нужны, то их следует указать явно.

В добавление к опции build, Cargo поддерживает опцию links для указания нативных библиотек, которые следует прилинковать.

[package]
# ...
links = "foo"
build = "build.rs"

Это означает, что пакет линкуется с нативной библиотекой libfoo, и он имеет скрипт сборки, который находит/собирает эту библиотеку. Cargo требует, чтобы опция build была указана, если вы используете опцию links.

Цель этого ключа - чтобы Cargo знал о наборе используемых нативных библиотек, и передавал эту метаинформацию между скриптами сборки пакетов.

Вы не можете слинковать несколько пакетов с одной нативной либой. Однако есть определенные случаи соглашений в которых это требование смягчено.

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

Например, если libbar зависит от libfoo, тогда если libfoo генерирует key=value как часть своих метаданных, то билд-скрипт libbar будет иметь переменную окружения DEP_FOO_KEY=value.

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

Явное указание метаданных

Ключ links включает поддержку явного указания метаданных для конкретной библиотеки. Цель этого действия может состоять в том, чтобы сократить время компиляции. Для явного указания расположите соответствующую секцию конфигурации в любом доступном для cargo месте .

[target.x86_64-unknown-linux-gnu.foo]
rustc-link-search = ["/path/to/foo"]
rustc-link-lib = ["foo"]
root = "/path/to/foo"
key = "value"

Эта секция декларирует, что для цели x86_64-unknown-linux-gnu бибилотека с именем foo имеет явно указанные метаданные. Эти метаданные являются такими же, как можно было бы получить запустив сборочный скрипт, предоставляющий набор пар ключ-значение.

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

Конкретный пример: кодогенерация

Некоторые пакеты cargo требуют, чтобы перед началом компиляции была произведена генерация кода. Изучим простой пример, который осуществляет генерацию кода в процессе сборки проекта.

Структура директории пакета выглядит следующим образом:

.
├── Cargo.toml
├── build.rs
└── src
    └── main.rs

1 directory, 3 files

Как мы можем видеть, имеется исходник скрипта build.rs и исходник бинарника main.rs. Содержимое Cargo.toml:

# Cargo.toml

[package]
name = "hello-from-generated-code"
version = "0.1.0"
authors = ["you@example.com"]
build = "build.rs"

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

// build.rs

use std::env;
use std::fs::File;
use std::io::Write;
use std::path::Path;

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("hello.rs");
    let mut f = File::create(&dest_path).unwrap();

    f.write_all(b"
        pub fn message() -> &'static str {
            \"Hello, World!\"
        }
    ").unwrap();
}

Несколько комментариев к сборочному скрипту:

Содержимое main.rs:

This example is not tested
// src/main.rs

include!(concat!(env!("OUT_DIR"), "/hello.rs"));

fn main() {
    println!("{}", message());
}

Как можно видеть, здесь не происходит никакой магии: мы подгружаем сгенерированный ранее код при помощи макроса include!. Макрос concat! осуществляет конкатенацию строк на этапе компиляции. Таким образом, получается путь к подгужаемому файлу.

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

Конкретный пример: сборка нативного кода

Иногда нужно собрать нативный СИ или СИ++ код и использовать его как часть пакета. Это еще одна возможность, которую предоставляет сборочный скрипт. Для примера создадим rust-программу, которая вызывает СИ-код, который печатает “Hello, World!”.

Содержимое директории проекта:

.
├── Cargo.toml
├── build.rs
└── src
    ├── hello.c
    └── main.rs

1 directory, 4 files

Содержимое Cargo.toml:

# Cargo.toml

[package]
name = "hello-world-from-c"
version = "0.1.0"
authors = ["you@example.com"]
build = "build.rs"

Контент сборочного скрипта выглядит следующим образом:

// build.rs

use std::process::Command;
use std::env;
use std::path::Path;

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();

    // note that there are a number of downsides to this approach, the comments
    // below detail how to improve the portability of these commands.
    Command::new("gcc").args(&["src/hello.c", "-c", "-fPIC", "-o"])
                       .arg(&format!("{}/hello.o", out_dir))
                       .status().unwrap();
    Command::new("ar").args(&["crus", "libhello.a", "hello.o"])
                      .current_dir(&Path::new(&out_dir))
                      .status().unwrap();

    println!("cargo:rustc-link-search=native={}", out_dir);
    println!("cargo:rustc-link-lib=static=hello");
}

Этот сборочный скрипт при запуске компилирует наш СИ файл в объектный файл (при помощи gcc) и затем конвертирует этот объектник в статическую библиотеку (при помощи ar). На финальном шаге устанавливаются опции cargo, которые предписывают искать библиотеку для линковки в out_dir и линковаться статически с библиотекой libhello.a (при запуске rustc будет использован ключ -l static=hello).

К данному скрипту имеется несколько замечаний:

Однако, не стоит отчаиваться. Нам поможет секция build-dependencies. Экосистема Cargo имеет пакеты, которые полностью или частично абстрагируют нас от платформы и делают этот этап сборки более простым, портабельным и стандартизованным. Для примера, сборочный скрипт может быть написан следующим образом:

This example is not tested
// build.rs

// Bring in a dependency on an externally maintained `gcc` package which manages
// invoking the C compiler.
extern crate gcc;

fn main() {
    gcc::compile_library("libhello.a", &["src/hello.c"]);
}

Это потребует зависимости на gcc контейнер. Добавить ее понадобиться в Cargo.toml:

[build-dependencies]
gcc = "0.3"

Контейнер gcc crate абстракция, позволяющая работать с различными вариантами, использующими С-код.

Here we can start to see some of the major benefits of farming as much functionality as possible out to common build dependencies rather than duplicating logic across all build scripts!

Вернемся к исследованию содержимого директории src:

// src/hello.c

#include <stdio.h>

void hello() {
    printf("Hello, World!\n");
}
This example is not tested
// src/main.rs

// аттрибут `#[link]` не является необходимым. Мы делегируем решение того,
// с какой либой линковаться в скрипт сборки, вместо того чтобы захардкодить
// его в исходном коде
 
extern { fn hello(); }

fn main() {
    unsafe { hello(); }
}

And there we go! This should complete our example of building some C code from a Cargo package using the build script itself. This also shows why using a build dependency can be crucial in many situations and even much more concise!

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

Конкретный пример: линковка с системными библиотеками

В финальном примере будет показано как cargo линкуется с системными библиотеками, и как скрипт сборки может управлять этим процессом.

Иногда для контейнеров rust требуются системные библиотеки.

Quite frequently a Rust crate wants to link to a native library often provided on the system to bind its functionality or just use it as part of an implementation detail. This is quite a nuanced problem when it comes to performing this in a platform-agnostic fashion, and the purpose of a build script is again to farm out as much of this as possible to make this as easy as possible for consumers.

В качестве примера рассмотрим одну из зависимостей самого cargo Cargo’s own dependencies, libgit2. Эта библиотека имеет некоторые ограничения:

Чтобы понять, как это происходит, рассмотрим билд-манифест Cargo.toml.

[package]
name = "libgit2-sys"
version = "0.1.0"
authors = ["..."]
links = "git2"
build = "build.rs"

[dependencies]
libssh2-sys = { git = "https://github.com/alexcrichton/ssh2-rs" }

[target.'cfg(unix)'.dependencies]
openssl-sys = { git = "https://github.com/alexcrichton/openssl-sys" }

# ...

Можно видеть, что в первой секции сборочного манифеста мы указываем сборочный скрипт опцией build. Также этот пример имеет опцию links, которая указывает нашему контейнеру (crate libgit2-sys) линковаться с нативной либой git2.

Контейнер имеет безусловную зависимость на libssh2 через указание libssh2-sys контейнера, и также платформо-специфичную зависимость на openssl-sys

Тут имеется следующий интуитивно-непонятный момент: у нас указана Си-зависимость в сборочном манифесте cargo. Это является частью соглашений для cargo, которому посвящена следующая глава.

*-sys пакеты

Для облегчения линковки с системными библиотеками cargo имеет соглашение по именованию пакетов и функциональности. Любой пакет с именем foo-sys будет предоставлять две основные функциональные возможности:

Основное множество *-sys пакетов предоставляют основное множество зависимостей для линковки с нативными либами. Этим достигаются следующие выгоды для контейнеров, имеющих отношение к нативным либам:

Сборка libgit2

Теперь, когда мы получили зависимости libgit2, нам нужно написать логику сборочного скрипта. Мы не будем разбираться в узко-специфичных моментах кода и сконцентрируем свое внимание лишь на высокоуровневых деталях сборочного скрипта контейнера libgit2-sys. Это не является рекомендацией для всех пакетов, а лишь является одной из возможных специфичных стратегий.

На первом шаге сборочный скрипт может попытаться найти место, в которое установлена libgit2. Это можно попытаться сделать при помощи предустановленного pkg-config (если он есть). We’ll also use a build-dependencies section to refactor out all the pkg-config related code (or someone’s already done that!).

Если у pkg-config не получилось найти libgit2, или если pkg-config не установлен, следующим шагом будет сборка libgit2 из встроенных исходников (которые распространяются как часть самого libgit2-sys). Есть несколько нюансов, которые стоит упомянуть.

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