Товарищи! Эта статья не для 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