Бывает полезно проводить валидацию данных из формы ввода и на фронте и на бэке, например чтобы не гонять лишний запрос с заведомо «плохими» данными. Отсюда появляется задача написания двух одинаковых валидаторов для фронта и бэка.
Если фронт и бэк написан на одном языке (привет 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/
ссылка на оригинал статьи https://habr.com/ru/articles/941028/
Добавить комментарий