Свою первую работу программистом на языке PHP я нашёл в далёком 2011 году, имел опыт с разными версиями языка от 5.0 до 8.1 в коммерческих разработках. C тех пор иногда приходилось выходить в смежные области: Python, NodeJS, 1С, VBA и вот наконец Go. Для любого опытного профессионала не станет откровением, что одну и ту же вещь можно сделать разными средствами, и что для каждой задачи есть свой наиболее подходящий инструмент. В середине 2010-х особое распространение начинают получать такие направления, как High Load, микросервисная архитектура, и конечно модное тогда распиливание монолитов. Многие из проектов-монолитов были написаны на языке PHP, и из тех, кто стал делать первые шаги в Go в то время стали в том числе те самые программисты, которые с блеском в глазах могли, как мантру повторять «распилить монолит» и мечтать написать микросервис на Go под каждую задачу, требующей всего того, на что привычный PHP был не способен: неэффективный расход памяти, отсутствие асинхронности.
И в принципе, до выхода версии 7.4 с ее возможностью FFI (Foreign Function Interface) решить озвученные выше проблемы мог совсем не каждый программист, занимающийся разработкой "коробочной" CMS (WordPress, Drupal, Joomla! или Битрикс). В конце концов, мало кому хотелось, вспоминать полузабытый после университета язык C, браться за разработку собственного расширения для PHP (pecl).
Сказанное выше является моим субъективным мнением, единственным выводом из которых хотелось выделить одну простую мысль - есть некая аудитория программистов, которые знают и Go, и PHP. Если вы набрели на эту статью, вбив в поисковике слова "PHP FFI Go",
то эта статья для вас, тех кто ищет нетривиальные пути, тех кто пытается взять лучшее от каждой из технологий.
Моя мотивация в том, чтобы открыть новый аспект нашего терпеливого PHP, который может расширить репертуар тех профессионалов, кому это интересно.
Концепция использования своей библиотеки на Go из PHP через FFI
Основная идея в том, чтобы использовать свою кастомную библиотеку, написанную на Go, из PHP, вызывая методы из неё через механизм FFI. В качестве примера мы возьмём следующий синтетический пример, который может встретиться в коммерческой деятельности: нашему приложению на PHP нужно сделать запросы к API трёх других микросервисов A, B и C. Проблема в том, отвечают они нам крайне неохотно, приходится ждать целых три секунды. Таким образом, классический обход в цикле на PHP выполнит все эти операции последовательно, иными словами за 9 секунд.
В качестве варианта решения предлагается попросить библиотеку на Go выполнить за нас запросы к этим серверам параллельно. Никаких дополнительных библиотек и расширений помимо стандартного PHP-FFI нам не потребуется на сервере. Это расширение довольно часто оказывается установленным по умолчанию, но может при этом быть отключенным.
Демонстрационный проект
Для удобства читателей это статьи был создан небольшой проект на GitHub, который можно склонировать себе и поднять при помощи Docker compose. Всего в несколько команд вы поднимите все необходимое:
$ mkdir php-go-ffi && cd php-go-ffi
$ git clone https://github.com/christoforov/php-go-ffi.git .
$ docker compose up -d

Проверяем, что все пять сервисов проекта успешно поднялись:
$ docker compose ps

Давайте разберем структуру нашего проекта. Не будучи докой в docker, в проекте можно увидеть папки scripts, services и файл-конфиг проекта docker-compose.yml. В файле docker-compose.yml описаны сервисы, задействованные в нашем проекте, под каждый из который создана одноименная подпапка в папке services/ .

Начнем с конца, у нас есть три искусственно замедленных сервиса на PHP, каждый из которых ничего не делает первые три секунды, а потом пишет JSON-ответ. Порты для них не проброшены, но можно посмотреть, что они отвечают, например, вот так:
$ docker compose exec php_service_a curl 'http://localhost/'
// {"result":"AAAAAAA"}
$ docker compose exec php_service_b curl 'http://localhost/'
// {"result":"BBBBBB"}
$ docker compose exec php_service_c curl 'http://localhost/'
// {"result":"CCCCCCC"}
Во всех случаях мы видим, что ответ к нам приходит не сразу - где-то секунды через три. Посмотрев исходник php_service_a/src/service_a.php понимаем почему:
<?php
sleep(3);
echo json_encode(['result' => 'AAAAAAA']);
В сервисах B и C ситуация аналогичная.
Сервис go_our_library не является обязательным, но сделан для того чтобы упростить жизнь читателю. В данном сервисе мы можем собрать наши исходники на Go в C-shared библиотеку с расширением ".so" (своего рода аналог ".dll" от мира Linux). Сам по себе сервис ждет 1 час, а дальше сам выключается (время работы можно изменить, поменяв в entrypoint сервиса go_our_library "sleep 3600" на какое-то другое значение в секундах). Мы также можем зайти внутрь контейнера и запускать разные команды на Go.
Для тех, кого пугает слово "компиляция", есть папка scripts, которая находится рядом с папкой services. В ней есть скрипт build_library.sh, благодаря которому можно все сделать в одно действие. При подготовке статьи несколько раз пришлось сталкиваться с необходимостью перекомпилировать библиотеку, но при этом требовалось перезагрузить сервис нашего web-приложения php_our_app, о котором мы поговорим дальше.
Точка входа в проект будет располагаться на 8000 порту нашего компьютера - именно сервис php_our_app будет отвечать на запросы к нему. Если собрать все воедино, то получится примерно такая картинка.

Вызовем наше приложение на PHP, чтобы посмотреть, что оно делает:
$ curl http://localhost:8000

Конечно, этот адрес можно открыть и в браузере, а если вы решили открыть это изнутри контейнера, то поможет эта команда:
$ docker compose exec php_our_app curl 'http://localhost/'
Конфигурирование PHP для поддержки FFI
В данной статье используется PHP 8.4, однако с высокой долей вероятности у вас все запустится и на версии PHP 7.4. Не забудьте проверить, что на
вашем сервере установлено расширение php-ffi, а в одном из конфигов php (conf.d/ или php.ini) есть строчка:
ffi.enable=true
Если нет, то добавьте или раскомментируйте. Не забываем сделать перезапуск своего сервера PHP. В нашем проекте уже все настроено, поэтому делать ничего дополнительно не требуется. Внимательный читатель мог заметить файл config/50-add-ffi-support.ini, содержащий описанную выше настройку.
Точка входа в приложение на PHP
Самая интересная для нас папка в сервисе php_our_app - это src. Основная точка в хода в наш проект, на которую мы попадали через curl, находится в файле index.php. В нем подключается файл helpers.php с разными вспомогательными функциями, а также основные три функции, которые вызываются последовательно. Фрагмент кода из index.php приведен ниже:
<?php
//...
// Ссылки на наши сервисы. Все отвечают по 3 секунды
$serviceUrls = ["http://php_service_a/", "http://php_service_b/", "http://php_service_c/"];
// 1. Без Go
// Три последовательных вызова к нашим трем сервисам - итого 9 секунд
makeSyncCalls($serviceUrls);
// 2. Получаем "hello PHP" от Go
makeGoSayHelloPhp();
// 3. Запросы к сервисам через библиотеку Go - итого 3 секунды (запросы ушли параллельно)
makeGoAsyncCalls($serviceUrls);
Именно результат работы этих методов мы видели обращаясь через curl к нашему приложению на PHP. Вы можете закомментировать любую из этих строчек, чтобы акцентировать свое внимание на оставшихся инструкциях. Например, если не хочется ждать каждый раз 9 секунд, то можно закомментировать вызов функции makeSyncCalls(), поскольку что-то по-настоящему интересное начинается лишь во второй функции makeGoSayHelloPhp().
Go передает привет в PHP: "Hello, Php"
function makeGoSayHelloPhp()
{
$cLangHeader = "
typedef struct { const char *p; long n; } GoString;
GoString HandleRequestFromPhp(GoString requestStr);
";
$goLibrary = "/var/app/bin/handler-for-php.so";
$ffi = FFI::cdef($cLangHeader, $goLibrary);
$phpRequest = json_encode(["action" => "HelloPhp", "payload" => ""]);
$phpRequestGoString = stringToGoString($ffi->new("GoString"), $phpRequest);
$goResponse = $ffi->HandleRequestFromPhp($phpRequestGoString);
var_dump($goResponse->p);
}
Начнем с того, что PHP благодаря Foreign Function Interface может вызывать любую функцию или использовать любой тип данных, продекларированный в переданном заголовках библиотеки на C. Иными словами при получении переменной $ffi мы передаем в функцию
FFI::cdef декларацию (иными словами заголовки на C) и файл с имплементацией (библиотека .so, .dll и т.д).
Здесь у читателей могло возникнуть недоумение, поскольку глядя на "cdef" проще разглядеть "Language C definition" нежели что-то про Go. Поспешу успокоить тем, что программы на языке Go легко компилируются в формат c-shared библиотеки, ведь для этого достаточно добавить всего один дополнительный параметр при компиляции, о чем будет рассказано в разделе про сборку библиотеки на Go.
Возвращаясь, к разработчикам Go, они сделали динамически загружаемые библиотеки таким образом, что при генерации заголовков на языке C, в них попадали описания структур данных языка Go, переведенные на C. При компиляции библиотеки в папке "services/go_our_library/dist/handler-for-php.h" эти типы данных можно увидеть, но нас из них интересует только один - это GoString. Насколько мне известно, у языка C немного туго с таким типом данных, как строка, а в языке Go он есть. По сути GoString при переводе на C представляет собой структуру данных (struct), которая состоит из указателя на char (*p) и длины этой строки (n).
Функция stringToGoString() для создания GoString на PHP могла быть скопирована отсюда.
Однако в интернете могут быть и другие аналогичные, не менее интересные статьи, где встречал эту функцию без указания авторства.
И вот здесь для многих может встретиться первая проблема: мы хотим передать некий аргумент из PHP с типом данных, к которому у PHP вопросов нет, в язык программирования Go. И поскольку это делается через c-shared интерфейс. Это можно представить себе, как если бы русский и китаец, не зная языков друг друга, были бы вынуждены между собой общаться на ломаном английском. Здесь ситуация схожа, и использование сложных структур данных потребует их декларирование на языке C.
Для того, чтобы не погрузиться в сложности перевода с одного языка на другой и обратно, мне пришла в голову мысль сделать все через JSON. По сути и PHP, и Go умеют с ним работать. Для среды передачи (язык C) мы напишем (скопируем из интернета) метод для создания строки GoString со стороны PHP. Таким образом наш PHP и Go будут обмениваться JSON - почти как привычное HTTP API, но без HTTP.

Таким образом, идея в том, что мы могли бы научить нашу библиотеку на Go принимать JSON-строки от PHP через единый метод на Go, а затем в зависимости от параметров вызывать нужный нам метод внутри библиотеки. Сами параметры к нему мы передадим в JSON, который тоже будет раскодироваться уже где-то в функции-адресате на Go. По факту у нас возникает нечто, напоминающее паттерн Front Controller. Двойное оборачивание в JSON было принято для того, чтобы под каждый вызываемый метод смог бы распаковать эту "матрешку" в конкретную структуру данных, необходимую разработчику. Обратно же PHP ожидает получить JSON с двумя полями:
goal, который может иметь значение "ok" или "fail"
payload_json, где после json_decode() мы сможем забрать данные в PHP.
И все было бы замечательно, если бы не вторая проблема: функция на Go будет принимать строку, но возвращать она будет должна указатель на char*. Если сделать так, что функция будет возвращать GoString, то вас ждет фатальная ошибка вроде:
"panic: runtime error: cgo result is unpinned Go pointer or points to unpinned Go pointer"
Однако у меня она возникала не всегда при первом запуске, поэтому возвращаемся к нашему ломанному английскому, где в функцию мы передаем GoString,
а забираем указатель на char.
К слову, наша makeGoSayHelloPhp() всего лишь ждет от Go строчку "Hello, PHP.", но ради этого пришлось написать столько всего.
Функция makeGoAsyncCalls() или как все стало проще.
Конечно, каждый раз заниматься подобными низкоуровневыми операциями быстро наскучит, поэтому можно написать некую обертку, использование которой мы и видим в последней функции:
function makeGoAsyncCalls(array $serviceUrls)
{
$timeStarted = time();
$callParams = [
'service_urls' => $serviceUrls,
];
$response = callGo("CallServices", $callParams);
// Приводим ответ к тому же формату, что и в makeSyncCalls
$payload = json_decode($response['payload_json'], true);
$result = [];
foreach ($payload as $url => $responseJson) {
$urlResponse = json_decode($responseJson, true);
$result[$url] = $urlResponse['result'];
}
var_dump($result);
echo "Total time: " . time() - $timeStarted . PHP_EOL;
}
Пристального внимания заслуживает лишь вызов:
$response = callGo("CallServices", $callParams);
Именно вспомогательная функция callGo() взяла на себя работу над типами данных - нам остается лишь указать имя метода, и передать в него ассоциативный массив с параметрами вызова. Код после вызова этой функции всего лишь приводит данные к тому же формату, что был в makeSyncCalls(), и по сути могли бы быть пропущены. Таким образом, механизм общения со стороны становится довольно простым и понятным: передаем название метода и необязательный параметр с аргументами вызова этого внутреннего метода на Go.
function callGo(string $action, mixed $payload = null): array
{
// Готовим параметры перед отправкой в FFI
$payloadJson = $payload === null ? "" : json_encode($payload);
$phpRequest = json_encode(["action" => $action, "payload_json" => $payloadJson]);
// Получаем экземпляр FFI
$cLangHeader = "
typedef struct { const char *p; long n; } GoString;
GoString HandleRequestFromPhp(GoString requestStr);
";
$goLibrary = "/var/app/bin/handler-for-php.so";
$ffi = FFI::cdef($cLangHeader, $goLibrary);
// Делаем Go-строку из нашего JSON
$phpRequestGoString = stringToGoString($ffi->new("GoString"), $phpRequest);
// Запуск функции handler-for-php.so::HandleRequestFromPhp
$goResponse = $ffi->HandleRequestFromPhp($phpRequestGoString);
// Полученный ответ будет в JSON, поэтому декодируем его
return json_decode($goResponse->p, true);
}
Все что осталось - это написать функции на стороне Go, и тогда можно считать, что у нас все получилось.
Как собрать библиотеку на Go в первый раз
Про свой опыт на Go рассказывать особо нечего. Многие части брались с сайта gobyexample.com, слегка адаптировались, собирались в общее решение. Это было небольшое предостережение, чтобы горячие головы не копировали этот код бездумно
куда-то себе в production окружение, надеясь на несуществующие авторитет и компетентность в Go автора с Хабра.
Шаг 1. Заходим в контейнер с Go и инициализируем модуль - свой я назвал "christoforov/handler-for-php". После этого в папке services/go_our_library/src появится файл go.mod. Если он там уже есть то делать команду go mod init не нужно.
$ docker compose exec go_our_library bash
$ cd /var/app/src
$ go mod init "christoforov/handler-for-php"
Шаг 2. Пишем код. Про это мы посмотрим ниже отдельно, а пока будем считать, что в исходниках уже код материализовался сам.
Шаг 3. Обновляем импорты зависимостей и зовем сборку
$ go mod tidy
$ go build -buildmode=c-shared -o ../dist/handler-for-php.so
Без аргумента "-buildmode=c-shared" этой статьи могло бы и не быть. Спасибо разработчикам Go за эту возможность. Выполнять go mod tidy, если в импортах модуля ничего не менялось, не требуется.
Шаг 4. Проверяем: если все получилось, то в папке services/go_our_library/dist запишется файл "handler-for-php.so". Он же будет доступен нашему php_our_app внутри контейнера по пути /var/app/bin/handler-for-php.so . Легко проверить через команду, что библиотека обновилась внутри контейнера с нашим Web-приложением на PHP:
$ docker compose exec php_our_app bash -c 'ls -lisa /var/app/bin'

Дополнительная проверка, что в нашей библиотеке есть добавленный нами в библиотеку метод:
$ docker compose exec go_our_library bash -c 'nm /var/app/dist/handler-for-php.so' | grep HandleRequestFromPhp

Как собрать библиотеку в очередной раз?
Собрать библиотеку и перезапустить наше Web приложение на PHP можно через скрипт, добавленный в проект:
$ ./scripts/build_library.sh

Что в коде библиотеки на Go
Рассмотрим структуру сервиса go_our_library. В папку dist будет складываться скомпилированная библиотека handler-for-php.so и файл с заголовками handler-for-php.h. А еще там есть папка scripts, в которой есть важная для нас команда по сборке (она же была выше). В папке src нас ждут исходники кода на Go
Заглянув в main.go , мы видим вот такую функцию:
//export HandleRequestFromPhp
func HandleRequestFromPhp(requestStr string) *C.char {
request := PhpRequest{}
json.Unmarshal([]byte(requestStr), &request)
var callResult string
var err error
switch request.Action {
case "HelloPhp":
callResult = HelloPhp()
case "CallServices":
callResult, err = CallServices(request.PayloadJson)
default:
err = fmt.Errorf("Unknown action %s", request.Action)
}
response := PhpResponse{}
if err != nil {
response.Goal = "fail"
response.Payload = err.Error()
} else {
response.Goal = "ok"
response.Payload = callResult
}
responseString, _ := json.Marshal(response)
return C.CString(string(responseString))
}
В этой функции нужно обратить внимания всего на три вещи:
Без директивы "//export" нужная нам функция не будет доступна из библиотеки
Смотрим, чтобы никаких пробелов после слешей не было: "//export HandleRequestFromPhp" (см. выше про команду nm для проверки того, что функция в библиотеке все-таки есть)На вход приходит string, а на выход мы отдаем
C.char*
Про это было рассказано выше, но не теряя внимания смотрим на декларацию из PHP, которую мы передавали через FFI.
Там было:
GoString HandleRequestFromPhp(GoString requestStr);
Мы можем видеть, что функция HandleRequestFromPhp имеет на стороне Go совершенно другой возвращаемый тип данных, и это неспроста.
Смотрим на выражение
return C.CString(string(responseString))
Таким образом, у читателя может появиться понимание того, что является каркасом библиотеки. Весь оставшийся код в этой функции это по сути прием строки, декодирование JSON, вызов нужного метода, кодирование JSON ответа, выдача результата при помощи C.CString().
Функция CallServices()
Функция CallServices(request.PayloadJson) предназначена для одновременного вызова нескольких асинхронных запросов к нашим трем медленным сервисам A, B и C.
func CallServices(payloadJson string) (string, error) {
var params CallServicesParams
err := json.Unmarshal([]byte(payloadJson), ¶ms)
if err != nil {
return "", nil
}
var waitGroup sync.WaitGroup
var result ResultState
result.services = make(map[string]string)
for _, serviceUrl := range params.ServiceUrls {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
callResult := callService(serviceUrl)
result.mutex.Lock()
defer result.mutex.Unlock()
result.services[serviceUrl] = callResult
}()
}
waitGroup.Wait()
marshalledResult, _ := json.Marshal(result.services)
return string(marshalledResult), nil
}
В качестве общего состояния для goroutine мы используем mutex, расположенный в свойстве структуры result. Из неочевидного для меня было то, что если вызов result.mutex.Lock() случится до вызова CallService(), то мы получим снова последовательные вызовы по 3 секунды, поскольку остальным придется дожидаться момента, когда будет снята блокировка. Подробнее про mutex, WaitGroup, json можно посмотреть на сайте gobyexample.com.
Сам метод callService делаем http-запрос, а в ответ отдает string с ответом от сервера.
При реализации этой функции было ощущение, что где-то неподалеку постукивали костыли, но может показалось:
func callService(url string) string {
var result bytes.Buffer
response, err := http.Get(url)
if err != nil {
return fmt.Sprintf("error in %s", url)
}
defer response.Body.Close()
// Читаем HTTP ответ в одну длинную строчку
scanner := bufio.NewScanner(response.Body)
for scanner.Scan() {
result.WriteString(scanner.Text())
}
return string(result.String())
}
На этом, пожалуй, можно и остановиться. Если вы дочитали до этого момента, то эта статья и сопутствующий с ней код могут стать отправной точкой в мир интересных интеграционных решений, не требующих создания отдельных микросервисов или запуска сторонних программ. Если вы являетесь обладателем проекта-монолита на PHP, и вы столкнулись с проблемой, в которой возникает недостаток памяти (скаляры на Go будут сильно легче, чем аналогичные скаляры на PHP), либо требуется асинхронность/параллельное выполнение,
а может быть понадобилось скрыть какие-то секреты (пароли, ключи шифрования, сертификаты) из репозитория, то перед вами один из возможных интеграционных вариантов. У меня имеются некоторые опасения насчет того, что строки на JSON не могут быть бесконечно большими, однако есть много случаев, когда это не будет ограничением.