AMatch — проверка входных параметров в PHP

в 7:44, , рубрики: amatch, php, validation, ооп, Песочница, Программирование, метки: , , ,

Товарищи! Эта статья не для high-high-highload систем. Скорость работы представленных решений определённо меньше простейших проверок. На многотысячных или очень глубоких структурах применять предлагаемый подход крайне не рекомендуется. В этом топике побеждает быстрое кодирование, а не быстрый код.

Без длинных

Давайте без длинных вступлений, но всё же с предысторией. Однажды в рамках создания очередного очень важного компонента веб-сервиса нам понадобилось проверять уйму очень разных входных параметров (в данном случае, пришедших через $_REQUEST). Компонент был очень сложный, внутренняя и внешняя логика вызывала ежедневный баттхёрт между всеми участниками, а отдуваться приходилась немногим «избранным» программистам, которые писали, переписывали, выпиливали и запиливали заново. Когда на вход в систему с фронтенда падают десятки разных переменных, в том числе массивов, программисты при этом делают перекрёстные задачи (меняя логику) и мешают друг другу — код очень быстро разрастается, количество цепочек if-ов начинает занимать не одну страницу. Возвращаться к такому коду всё более и более чуждо ранимой душе. Тесты уже не очень помогают, т. к. каждое изменение логики приводит к изменению тех же тестов, в которых ещё надо вспомнить, понять и простить. Вот тогда и встал вопрос о создании удобного способа проверять весь входной поток каким-то приятным глазу способом, да чтоб всегда и везде получать фидбек про ошибки в однотипном виде. Акцент тут изначально стоял именно на удобстве для разработчиков, строго прошу в дальнейшем иметь.

Будем, по сути, к делу

Полный пример лежит на гитхабе.

Предположим, что у нас есть некий контроллер, который ещё не прошел стадию рефакинга и переосмысления вплоть до полного исчезновения. Первым делом он забирает всё, что пришло к нему с фронтенда ($params = $_REQUEST;). Для грязноты эксперимента представим, что никакой предварительной фильтрации нет — чем прислали, тем и рады. К примеру, содержимое массива $params будет следующим:

$params = array(
	'doc_id' => 133,
	'subject_id' => '64',
	'parent_id' => 32,
	'title' => 'New document',
	'data' => array(
		'flag' => 'experiment',
		'from_topic' => false,
	),
);

Теперь поставим себе задачу: нужно проверить, что некоторые ключи обязательно существуют, некоторые строго типизированы, что-то должно попадать в интервал (больше нуля etc). Дополнительно нельзя, чтоб в массиве было что-либо, кроме указанных ключей. А пока мы ищем просветления — неплохо бы получать достаточную отладочную информацию. Сначала попробуем представить это в виде цепочки условий с выбросом исключений.

// Обязательные условия
if (!isset($params['doc_id'])) {
	throw new Exception('Expected document id');
}
if (!isset($params['subject_id'])) {
	throw new Exception('Expected subject id');
}
if ($params['doc_id'] <= 0) {
	throw new Exception('Incorrect document id');
}
if ($params['subject_id'] == 0) { // Отрицательные можно
	throw new Exception('Incorrect document id');
}
if (isset($params['parent_id']) && $params['parent_id'] <= 0) { // Если существует — можно положительные
	throw new Exception('Incorrect parent id');
}
if (isset($params['data']) && (!is_array($params['data']) || empty($params['data']))) {
	throw new Exception('Incorrect document data');
}
//...if
//...if-else
//...if-or-if-else-if
//...the brain is burning

Знакомо? И так много-много раз в день. На каждое условие «if» по бизнес-процессу полагается писать юнит-тест с различными граничными значениями. Представьте, сколько строк теста понадобится только на этот код. А завтра что? — «Выпиливай тут, а здесь надо три документа сразу чтоб можно принимать и parent_id теперь массивом будет». А что делать с разношерстными ошибками, коих на каждую ситуацию отдельно написано? Как интегрироваться с новой модной платёжной системой, которой ошибки нужно в виде кодов поставить? Написать маппинг на такой винегрет — само по себе попоболь.

Наконец-то он показывает AMatch

Ну, а теперь десерт — проверка этого же массива на множество факторов с помощью AMatch:

require_once('class.AMatch.php');
$match = AMatch::runMatch($params)
	->doc_id(0, '<') // Левое значение меньше
	->subject_id(0, '!=') // Не равен нулю
	->subject_id('', '!float') // Не float
	->author_name(AMatch::OPTIONAL, 'string') // Необязательный или текст
	->author_name('Guest') // Гость сайта
	->parent_id(AMatch::OPTIONAL, 'int') // Необязательный или int
	->parent_id(0, '<') // Левое значение меньше
	->parent_id(array(32, 33), 'in_left_array') // Значение содержится в указанном слева массиве
	->data('', 'array') // массив
	->data('', '!empty') // не пустой
	->data('old_property', '!key_exists') // не должно быть ключа
	->data('experiment', 'in_array') // внутри массива есть значение 'experiment'
	->title() // существует в любом виде
	;
$result = $match->stopMatch();
if (!$result) {
	die(var_export($match->matchComments(), true)); // для наглядности умрём
}
echo 'Victory!';

Разберём приведённый код. Начало валидации (сопоставления условиям) начинается с передачи проверяемого массива в метод AMatch::runMatch(). Затем путём разыменовывания вызываются проверки по схеме:

->имя_ключа([ожидаемое_или_сравниваемое_значение], [условие])->…->stopMatch()

Метод stopMatch() возвращает общий результат — правду или ложь. Разумеется, что одного факта «не сработало» не всегда достаточно. Поэтому, если ссылку на объект перед вызовом stopMatch() сохранить в переменную $match, то после stopMatch() можно получить подробные комментарии о результате. Обращаю внимание, что в stopMatch() так же присутствуют проверки и формируются ошибки, так что если вы вызовите комментарии до этого метода — может оказаться, что там будет не полная информация.
Первый запуск показывает «Victory!».

Шеф, всё пропало

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

$params_bad = array(
	'doc_id' => -4,
	'subject_id' => null,
	'parent_id' => 30,
	'data' => array(
		'flag' => 'booom',
		'from_topic' => array(),
		'old_property' => true,
	),
	'wtf_param' => 'exploit',
);
$params = $params_bad;

На выходе получаем:

array (
  'doc_id' => 'Condition is not valid',
)

Почему только один элемент? Потому, что он был первым в цепочке. Далеко не всегда существует нужда перебирать весь набор параметров. По-умолчанию AMatch прерывает проверки, как только будет получено первое невыполненное условие. Прежде чем включить полную проверку, давайте посмотрим, какое именно условие не прошло. Для этого заменим вывод ошибок на:

die(
	var_export($match->matchComments(), true)
	. PHP_EOL
	. var_export($match->matchCommentsConditions(), true)
);

Теперь результат показывает выполненную операцию сравнения, которая выдала false:

array (
  'doc_id' => 'Condition is not valid',
)
array (
  'doc_id' => // Ключ, аналогичный ключу в комментариях
  array (
    0 => 0, // Ожидаемым значением был ноль
    1 => '<', // Условие было «ожидаемое меньше актуального»
  ),
)

По сути, это повтор исходной записи:

->doc_id(0, '<')

Все комментарии хранятся в константах (AMatch::KEY_CONDITION_NOT_VALID == 'Condition is not valid' ). Очевидно, что теперь можно настроить стандартный маппинг для выдачи ошибок пользователю, опираясь на константы ошибок и условия их возникновения.

Нужно больше дерева!

Вернёмся к прочим условиям. Чтобы повелевать AMatch проверять и проверять, пока условия не закончатся, — нужно использовать флаги (битовая маска). Флагов на данный момент три.

  • FLAG_STRICT_STRUCTURE — Проверять, что в проверяемом массиве отсутствуют ключи, не объявленные для сопоставления.
  • FLAG_DONT_STOP_MATCHING — Не останавливать сопоставление, даже если обнаружено условие несоответствия.
  • FLAG_SHOW_GOOD_COMMENTS — Показывать комментарии не только к проблемам, но и к прошедшим сопоставление ключам и условиям.

Эти флаги могут быть особенно полезны в режиме отладки приложения. Ну а флаг «FLAG_DONT_STOP_MATCHING» особенно полезен, например, если нужно проверить данные формы и вернуть все проблемы кучей. Дополним код:

$flags = AMatch::FLAG_DONT_STOP_MATCHING;
$match = AMatch::runMatch($params, $flags) …

Теперь результат раскрывает все проблемы:

array (
  'doc_id' => 'Condition is not valid',
  'subject_id' => 'Condition is not valid',
  'parent_id' => 'Condition is not valid',
  'data' =>
  array (
    'flag' => 'Condition is not valid',
  ),
  'title' => 'Expected parameter does not exist in the array of parameters',
)
… (ну и подробности)

Я люблю жестче!

Обратим внимание на особый флаг FLAG_STRICT_STRUCTURE. К примеру, он будет особенно полезен, в ситуации, когда вы создали API-интерфейс и поддерживаете его версии. Клиенты будут подключаться к API и посылать различные запросы. Очень важно вовремя заметить, что отправляемый запрос устарел по формату и отправляются не только невалидные значения, но и вообще лишние ключи.

$flags = AMatch::FLAG_DONT_STOP_MATCHING | AMatch::FLAG_STRICT_STRUCTURE;

Выполним код с этим флагом. В комментариях добавятся две новые строки:

'stopMatch' => 'Unknown parameters in the input data',
'Unknown parameters:' => 'wtf_param',
// Для всяких маппингов используйте значение ошибки AMatch::UNKNOWN_PARAMETERS_LIST и имя ключа AMatch::_UNKNOWN_PARAMETERS_LIST

В общем-то, отсюда и так видно, что обнаружен параметр, для которого нет условий валидации.

Дезинсекция

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

define('DEBUG_MODE', true);
if (DEBUG_MODE) {
	$flags |= AMatch::FLAG_SHOW_GOOD_COMMENTS;
}
… и после Victory:
if (DEBUG_MODE) {
	$comments = $match->matchComments();
	$comments_explanation = $match->matchCommentsConditions();
	echo PHP_EOL; var_export($comments);
	echo PHP_EOL; var_export($comments_explanation);
}

Теперь в комментариях будет многа букаф (все они есть в константах AMatch):

Victory!
array (
  'doc_id' => 'OK. Condition is valid',
  'subject_id' => 'OK. Condition is valid',
  'author_name' => 'Optional parameter, skipped bad condition result',
  'parent_id' => 'OK. Expected parameter type is valid',
  'data' => 'OK. Expected parameter type is valid',
  'title' => 'OK. Expected parameter exist in the array of parameters',
  'stopMatch' => 'The array does not contains unknown parameters',
)

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

Я не удовлетворён!

Добавлю конфетку. Перед $match->stopMatch() проверим вложенную структуру массива:

function checkDocumentData($data)
{
	$result = AMatch::runMatch($data)
	->flag('experiment') // Равно указанному
	->from_topic(specialValidation(), true) // Принять условие, если вызываемая пользовательская функция отработала с true
	->from_topic(false) // Равно false
	->link_id(AMatch::OPTIONAL, 'int') // Необязательный или int
	;

	return array($result->stopMatch(), $result->matchComments(), $result->matchCommentsConditions());
}
function specialValidation()
{
	return 1 < 2; // Некие особые внешние условия, от которых что-то зависит (например, наличие записи в базе)
}

$match->data('checkDocumentData', 'callback'); // проверить содержимое через пользовательскую функцию

Надеюсь, тут уже всё понятно.

Послебуквие

Спасибо за идеи Андрею Терещенко, Андрею Луговому и команде разработчиков «Право.ru».
Вы можете найти больше примеров (смотрите unittests) и скачать исходники здесь: https://github.com/KIVagant/AMatch
Буду очень рад, если ваши идеи превратятся в полезные коммиты. Не люблю критику, ну да ладно, жгите в комментарии. И увы, я не силён в английском, так граммар-наци велкам.

Автор: KIVagant

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js