В PHP 7.4 появится FFI, т.е. можно подключать библиотеки на языке C (или, например, Rust) напрямую, без необходимости писать целый extension и разбираться в его многочисленных нюансах.
Давайте попробуем написать код на Rust, и используем его в PHP-программе
Идея реализации FFI в PHP 7.4 была взята из LuaJIT и Python, а именно: в язык встроен парсер, который понимает декларации функций, структур и т.д. языка Си. По факту туда можно подсунуть всё содержимое заголовочного файла и сразу начать использовать его.
Пример:
<?php
// вставляем декларацию функции printf на языке Си
$ffi = FFI::cdef(
"int printf(const char *format, ...);", // это синтаксис языка Си
"libc.so.6"); // указываем скомпилированную библиотеку
// вызываем printf
$ffi->printf("Hello %s!\n", "world");
Подключать чьи-то готовые либы — это просто и весело, но хочется и что-то своё написать. Например, нужно быстро распарсить какой-то файл, и результаты парсинга использовать из php.
Из трех системных языков (C, C++, Rust) лично я выбираю последний. Причина проста: у меня не хватит компетенций, чтобы сходу написать безопасную по памяти программу на C или С++. Rust сложноват, но в этом смысле выглядит надёжнее. Компилятор сразу указывает тебе, где ты неправ. Почти невозможно добиться Undefined Behavior.
Disclaimer: я не являюсь системным программистом, поэтому дальнейшее используйте на свой страх и риск.
Давайте для начала напишем что-то совсем простое, простую функцию для складывания чисел. Просто для тренировки. А потом перейдем к более сложной задаче.
Создаем проект как библиотеку
cargo new hellofromrust --lib
и указываем в cargo.toml, что это динамическая библиотека (dylib)
….
[lib]
name="hellofromrust"
crate-type = ["dylib"]
….
Сама функция на Расте выглядит так
#[no_mangle]
pub extern "C" fn addNumbers(x: i32, y: i32) -> i32 {
x + y
}
ну т.е. обычная функция, только к ней добавлено пара магических слов no_mangle и extern "C"
Далее, делаем cargo build, чтобы получить so-файл (под Линуксом)
Можно использовать из php:
<?php
$ffi = FFI::cdef("int addNumbers(int x, int y);", './libhellofromrust.so');
print "1+2=" . $ffi->addNumbers(1, 2) . "\n";
// 1+2=3
Складывать числа просто. Функция принимает целые аргументы по значению, и возвращает новое целое число.
А что если нужно использовать строки? А что если функция возвращает ссылку на дерево элементов? А как использовать специфические конструкции Раста в сигнатуре функций?
Эти вопросы меня замучили, поэтому я написал парсер арифметических выражений на Расте. И решил использовать его из PHP для изучения всех нюансов.
Полный код проекта здесь: simple-rust-arithmetic-parser. Кстати, туда же я положил docker образ, в котором есть PHP (скомпилированный с FFI), Rust, Cbindgen и т.д. Всё, что нужно для запуска.
Парсер, если рассматривать чистый язык Раст, делает следующее:
берет строку вида "100500*(2+35)-2*5
" и преобразовывает в выражение-дерево expression.rs:
pub enum Expression {
Add(Box<Expression>, Box<Expression>),
Subtract(Box<Expression>, Box<Expression>),
Multiply(Box<Expression>, Box<Expression>),
Divide(Box<Expression>, Box<Expression>),
UnaryMinus(Box<Expression>),
Value(i64),
}
это Растовый enum, а в Расте, как известно, enum — это не просто набор констант, но к ним можно еще привязать значение. Здесь если тип узла Expression::Value, то к нему записано целое число, например 100500. Для узла типа Add будем хранить также две ссылки (Box) на выражения-операнды этого сложения.
Парсер я написал довольно быстро, несмотря на ограниченное знание Rust, а вот с FFI пришлось помучиться. Если в C строки — это указатель на тип char *, т.е. указатель на массив символов, заканчивающихся на \0, то в Расте это совсем другой тип. Поэтому необходимо преобразовать входную строку в тип &str следующим образом:
CStr::from_ptr(s).to_str()
Это всё полбеды. Настоящая проблема в том, что ни Растовых енумов, ни безопасных ссылок типа Box в языке C нет. Поэтому пришлось сделать отдельную структуру ExpressionFfi для хранения дерева выражений в C-стиле, т.е. через struct, union и простые указатели (ffi.rs).
#[repr(C)]
pub struct ExpressionFfi {
expression_type: ExpressionType,
data: ExpressionData,
}
#[repr(u8)]
pub enum ExpressionType {
Add = 0,
Subtract = 1,
Multiply = 2,
Divide = 3,
UnaryMinus = 4,
Value = 5,
}
#[repr(C)]
pub union ExpressionData {
pair_operands: PairOperands,
single_operand: *mut ExpressionFfi,
value: i64,
}
#[derive(Copy, Clone)]
#[repr(C)]
pub struct PairOperands {
left: *mut ExpressionFfi,
right: *mut ExpressionFfi,
}
Ну и метод для преобразования в нее:
impl Expression {
fn convert_to_c(&self) -> *mut ExpressionFfi {
let expression_data = match self {
Value(value) => ExpressionData { value: *value },
Add(left, right)
| Subtract(left, right)
| Multiply(left, right)
| Divide(left, right) => ExpressionData {
pair_operands: PairOperands {
left: left.convert_to_c(),
right: right.convert_to_c(),
},
},
UnaryMinus(operand) => ExpressionData {
single_operand: operand.convert_to_c(),
},
};
let expression_ffi = match self {
Add(_, _) => ExpressionFfi {
expression_type: ExpressionType::Add,
data: expression_data,
},
Subtract(_, _) => ExpressionFfi {
expression_type: ExpressionType::Subtract,
data: expression_data,
},
Multiply(_, _) => ExpressionFfi {
expression_type: ExpressionType::Multiply,
data: expression_data,
},
Divide(_, _) => ExpressionFfi {
expression_type: ExpressionType::Multiply,
data: expression_data,
},
UnaryMinus(_) => ExpressionFfi {
expression_type: ExpressionType::UnaryMinus,
data: expression_data,
},
Value(_) => ExpressionFfi {
expression_type: ExpressionType::Value,
data: expression_data,
},
};
Box::into_raw(Box::new(expression_ffi))
}
}
Box::into_raw
превращает тип Box
в сырой "сишный" указатель
В итоге функция, которую мы будем экспортировать в PHP, выглядит так:
#[no_mangle]
pub extern "C" fn parse_arithmetic(s: *const c_char) -> *mut ExpressionFfi {
unsafe {
// todo: error handling
let rust_string = CStr::from_ptr(s).to_str().unwrap();
parse(rust_string).unwrap().convert_to_c()
}
}
Здесь куча unwrap(), что означает "паникуй при любой ошибке". В нормальном продакшен коде конечно же ошибки нужно обрабатывать нормально и передавать ошибку как часть возврата С-функции.
Ну и мы видим здесь вынужденный блок unsafe, без него бы ничего не скомпилировалось. К сожалению, в этом месте программы компилятор Rust не может отвечать за безопасность памяти. Это понятно и естественно. На стыке Rust и C такое будет всегда. Однако во всех других местах всё абсолютно контролируемо и безопасно.
Фуф, ну вроде всё, можно компилировать. Но вообще-то есть еще один нюанс: надо еще надо написать заголовочные конструкции, чтобы PHP понимал сигнатуры функций и типов.
К счастью, в Раст есть удобная тулза cbindgen. Она автоматически ищет в коде на Раст конструкции, которые помечены extern "C", repr(С) и т.д. и генерит заголовочный файлы
Мне пришлось немного помучиться с настройками cbindgen, они у меня получились такие (cbindgen.toml):
language = "C"
no_includes = true
style="tag"
[parse]
parse_deps = true
Не уверен, что я четко понимаю все нюансы, но это работает )
Пример запуска:
cbindgen . -o target/testffi.h
Результат будет такой:
enum ExpressionType {
Add = 0,
Subtract = 1,
Multiply = 2,
Divide = 3,
UnaryMinus = 4,
Value = 5,
};
typedef uint8_t ExpressionType;
struct PairOperands {
struct ExpressionFfi *left;
struct ExpressionFfi *right;
};
union ExpressionData {
struct PairOperands pair_operands;
struct ExpressionFfi *single_operand;
int64_t value;
};
struct ExpressionFfi {
ExpressionType expression_type;
union ExpressionData data;
};
struct ExpressionFfi *parse_arithmetic(const char *s);
Итак, сгенерировали h-файл, компилируем библиотеку cargo build
и можно написать наш php код. Код просто выводит рекурсивной функцией printExpression на экран то, что распаршено нашей Rust-библиотекой
<?php
$cdef = \FFI::cdef(file_get_contents("target/testffi.h"), "target/debug/libexpr_parser.so");
$expression = $cdef->parse_arithmetic("-6-(4+5)+(5+5)*(4-4)");
printExpression($expression);
class ExpressionKind {
const Add = 0;
const Subtract = 1;
const Multiply = 2;
const Divide = 3;
const UnaryMinus = 4;
const Value = 5;
}
function printExpression($expression) {
switch ($expression->expression_type) {
case ExpressionKind::Add:
case ExpressionKind::Subtract:
case ExpressionKind::Multiply:
case ExpressionKind::Divide:
$operations = ["+", "-", "*", "/"];
print "(";
printExpression($expression->data->pair_operands->left);
print $operations[$expression->expression_type];
printExpression($expression->data->pair_operands->right);
print ")";
break;
case ExpressionKind::UnaryMinus:
print "-";
printExpression($expression->data->single_operand);
break;
case ExpressionKind::Value:
print $expression->data->value;
break;
}
}
Ну вот и всё, спасибо за внимание.
Хрен там был "всё". Память надо еще очистить. Раст не может применить свою магию за пределами Раст-кода.
Добавляем еще одну функцию destroy
#[no_mangle]
pub extern "C" fn destroy(expression: *mut ExpressionFfi) {
unsafe {
match (*expression).expression_type {
ExpressionType::Add
| ExpressionType::Subtract
| ExpressionType::Multiply
| ExpressionType::Divide => {
destroy((*expression).data.pair_operands.right);
destroy((*expression).data.pair_operands.left);
Box::from_raw(expression);
}
ExpressionType::UnaryMinus => {
destroy((*expression).data.single_operand);
Box::from_raw(expression);
}
ExpressionType::Value => {
Box::from_raw(expression);
}
};
}
}
Box::from_raw(expression);
— преобразовывает сырой указатель в тип Box, а так как результат этого преобразования никем не используется, то происходит автоматическое уничтожение памяти при выходе из скоупа.
Не забываем сбилдить и сгенерить заголовочный файл.
и в php добавляем вызов нашей функции
$cdef->destroy($expression);
Вот теперь точно всё. Если вы хотите дополнить или рассказать, что я где-то был не прав, please feel free to comment.
Репозиторий с полным примером находится по ссылке: [https://github.com/anton-okolelov/simple-rust-arithmetic-parser]
# build
docker-compose build
docker-compose run php74 cargo build
docker-compose run php74 cbindgen . -o target/testffi.h
#run php
docker-compose run php74 php testffi.php
Больше полезного можно найти на telegram-канале о разработке "Cross Join", где мы обсуждаем базы данных, языки программирования и всё на свете!