AI Единый код валидаторов на фронте и бэке (PHP + FFI + Go + JS)

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 984
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1
Бывает полезно проводить валидацию данных из формы ввода и на фронте и на бэке, например чтобы не гонять лишний запрос с заведомо "плохими" данными. Отсюда появляется задача написания двух одинаковых валидаторов для фронта и бэка.

Если фронт и бэк написан на одном языке (привет js+node), то мы можем напрямую использовать один код валидатора и там и там.

В остальных случаях (js+php, java, python, go, dotnet) есть проблема. Во-первых придётся два раза писать примерно одно и то же на двух языках, во-вторых нужно убедиться, что написанное работает одинаково. Особенно печальны случаи, когда фронт ошибочно зарезает данные, валидные с точки зрения бэка и логики приложения.

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

Давайте будем использовать один ЯП для валидаторов даже если фронт и на бэк написаны на разных языках.

Создадим простое приложение из одной формы на PHP+Laravel, и добавим к нему немного фронта на JS. Пусть на фронте есть форма с полем "Имя". Корректное имя должно состоять из букв, первая буква должна быть заглавной, а остальные строчными.

Валидно: Иван, John
Не валидно: иван, ИВАН, ИваН, john jOHN JOHN

Реализуем валидацию на JS регулярными выражениями.

export function validateName(name) {
if(!name.match(/^[A-ZА-ЯЁ][a-zа-яё]*$/u)) {
return "Имя должно состоять из букв, "+
"первая буква должна быть заглавной.";
}
return "";
}

На фронте это работает.


На фронте это работает

Теперь прикрутим тот же валидатор к бэкэнду. Воспользуемся расширением FFI — создадим (конечно же берём готовый) интерпретатор JS в виде разделяемой библиотеки.

Мне показалось наиболее простым взять реализацию ECMAScript на чистом golang от Dmitry Panov.

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

Перехожу сразу к PHP. Итоговый валидатор Laravel выглядит так:

class ValidName implements ValidationRule
{
protected RunJs $runjs;
protected string $javascript;

public function __construct(RunJs $runjs)
{
$this->runjs = $runjs;
$javascript = file_get_contents(
resource_path('js/validator.js')
);
$this->javascript = preg_replace(
'#^export\sfunction#um',
'function',
$javascript
) . ";";
}

public function validate(
string $attribute, mixed $value, Closure $fail
): void
{
$code = $this->javascript .
'validateName("' .
addcslashes($value, '"') .
'");';
$error = $this->runjs->RunJs($code);
if (strlen($error > 0)) {
$fail($error);
}
}
}

Тут нюанс. Поскольку фронт у нас современный, то удобно использовать модули, но ECMAScript 5 на бэке вынуждает отказаться от синтаксиса export. Можно придумать и более красивое решение, но я решил просто вырезать слово export из исходника - в остальном js-код валидации на фронте и бэке идентичен.

Сервис запуска JS-кода выглядит так:

class RunJs
{
protected FFI $ffi;
protected FFI\CType $charPtr;

public function __construct(string $libDir)
{
$this->ffi = FFI::cdef(
file_get_contents($libDir . "/runjs_z.h"),
$libDir . "/runjs.so",
);
$this->charPtr = $this->ffi->type('char *');
}

public function RunJs(string $code): string
{
$arg = $this->ffi->new("GoString");
$arg->n = strlen($code);

$str = $this->ffi->new(
type: 'char[' . ($arg->n) . ']'
);
FFI::memcpy($str, $code, $arg->n);
$arg->p = $this->ffi->cast($this->charPtr, $str);

$res = $this->ffi->RunJs($arg);
if ($res === null) {
throw new Exception("JS Error");
}

$ret = FFI::string($res);
FFI::free($res);

return $ret;
}
}

Go-библиотека (runjs.go), которая вызывается по FFI, выглядит так:

package main
import "C"
import "github.com/dop251/goja"

var vm *goja.Runtime = nil

func init() {
vm = goja.New()
}

//export RunJs
func RunJs(script string) *C.char {
val, err := vm.RunString(script)
if err != nil {
return nil
}
return C.CString(val.String())
}

На бэке это тоже работает (фиолетовое сообщение было от фронта, а красное от бэка).


Бэк тоже работает

Успешная валидация:


Успешная валидация

Полный проект можно посмотреть здесь.

Выводы


Возможно использовать идею "единый код валидаторов для фронта и бэка", имея исходный код фронта на JS, а бэк не на node. Связка PHP + FFI + Go + JS — вполне рабочий вариант, хотя и не без недостатков.

О недостаках и проблемах


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

Второе. При первой попытке реализовать идею я использовал докер-контейнеры на базе alpine. Если кто-то пойдёт моим путём - остерегатесь этой проблемы: "runtime: c-shared builds fail with musllibc". Из-за неё сейчас (летом 2025) разделяемые библиотеки на golang не работают нормально в приложениях, слинькованных с mulibc.

Третье. К сожалению поддержка юникодных регэкспов в пакте goja оказалась недостаточно глубока, а то можно было бы последовть совету из статьи "Хватит использовать [a-zа-яё]" и сделать совсем красиво.

export function validateName(name) {
if(!name.match(/^\p{Lu}\p{Ll}*$/u)) {
return "Имя доложно состоять из букв, "+
"первая буква должна быть заглавной.";
}
return "";
}

Но увы-увы.

Спасибо за внимание, а теперь будут...

Ссылки


Репозиторий с кодом к этой статье
https://github.com/ein-gast/php-js-ffi

Вызываем функции Go из других языков
https://habr.com/ru/companies/vk/articles/324250/

Реализация ECMAScript на чистом golang
https://github.com/dop251/goja

Оптимизация размера Go-бинарника
https://habr.com/ru/companies/plesk/articles/532402/

Документация по модулю FFI в PHP
https://www.php.net/manual/ru/book.ffi.php

Туториал: использование Go из PHP через FFI
https://habr.com/ru/articles/902532/

Баг "runtime: c-shared builds fail with musllibc"
https://github.com/golang/go/issues/13492

Хватит использовать [a-zа-яё]: правильная работа с символами и категориями Unicode в регулярных выражениях
https://habr.com/ru/articles/713256/
 
Сверху Снизу