Найти тему
Одиночная палата

Begin /* Валидация данных

Оглавление

Какая бывает

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

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

Программа практически никогда не является вещью в себе. Так или иначе в программу должны поступать внешние аргументы, параметры окружения, сведения о состоянии среды - данные из реального мира. Проблема с данными из реального мира такова, что они всегда носят вероятностный характер. Никогда нет гарантии, что в функцию изъятия корня квадратного не прилетит отрицательное число, если предыдущие манипуляции с этим числом связаны с данными извне. Из этой проблемы возникает другая - проблема реакции на данные несоответствующие ожидаемым. Что должна вывести в ответ функция корня квадратного из минус единицы: ошибку или мнимую единицу?

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

Что касается уровней валидации, то это еще более запутанный набор методик и решений. Кидаться ли ошибками в любой ситуации, пытаться предвидеть какие-то отдельные случаи, делать ли какие-то допущения, подсказывать ли пользователю правильный порядок? Можно, разумеется, озаботиться всем перечнем существующих приемов, но это как правило избыточно и приводит скорее к конфликтным ситуациям, чем к радикальному решению. Постараюсь разобрать все по порядку.

Фильтрация ввода

Самый вероятный источник некорректных данных это человек. Более того, человек, в силу своей природы, обязательно окажется источником непредвиденной ситуации. Если поле формы допускает какие-то варианты, то эти варианты обязательно будут реализованы. Самый, пожалуй, распространенный вариант некорректного ввода исходит из полей подразумевающих под собой ввод чисел. Можно числовые поля заменить на разнообразные ползунки, списки и прочие элементы интерфейса, как в случае с датами, но далеко не все типы данных можно ограничить таким способом. Если поле возраст можно с натяжкой заменить на список от нуля до ста пятидесяти, то вес ингредиента в кулинарном рецепте невозможно определить дискретным рядом или конечным отрезком. Пять грамм соли, 350 миллилитров вина, десять тонн картофеля - это все валидные значения.

Самое веселье начинается когда пользователю позволено вводить разряды, дроби и отрицательные значения. То что попытается ввести пользователь вместо какого-то экономического показателя ограничивается только кодовой таблицей символов. К счастью, современные средства UI, являясь фронтиром в защите от дурака, в значительной степени продвинулись и просто не позволяют вводить совсем уж что попало. Но и их, к сожалению, не достаточно. Вы можете придумать сколь угодно продвинутое поле ввода, но таким образом рискуете слишком усложнить простое действие для большинства аккуратных пользователей, в угоду защиты от единиц. Иногда, к примеру, когда это поле ввода денежной суммы, наверное, не стоит сильно заморачиваться запятая стоит в качестве разделителя дроби или дефис, а просто заменить его на точку. Но тут же возникает вопрос, что же делать, если это не дефис, а минус и стоит он спереди числа? А если пользователь запятыми делит разряды?

Общего рецепта, как и везде по отношению к человеку, нет. Тут всегда приходится балансировать между принуждением и удобством. В конце концов, если это форма клиентского приложения, то существует ненулевая вероятность, того что кто-то попробует обойти непосредственный ввод и попытается напрямую отправить данные используя протокол обмена данными. Взломщик это будет или просто тот, кто изменит строку GET запроса к веб-серверу - не очень важно. То есть проверка данных заполняемых в полях форм это лишь снижение вероятности, а никак не исключение ошибок в данных. Соответственно, все проверки на уровне форм должны так или иначе дублироваться на уровнях принимающего сервера: API, конкретных функций или базы данных, куда эти данные должны в итоге попасть.

Интерфейсы и документация

На стороне сервера как правило не реализуется право на ошибку. Все входящие некорректные данные должны как бы отсекаться на уровне разбора параметров, например, HTTP заголовка и его содержимого. В результате интерфейс отправляет на любое отклонение в данных от строгого описания ошибку. Но это не всегда, опять же, гарантия.

Во-первых, само описание может оказаться недостаточным. Что для компьютера дата? Это строка в формате ISO? Либо это дата реально существующая в календаре, а не 30 февраля? Или может быть это строго дата в будущем или в прошлом? Относительно чего? Та же петрушка и с числами, и с длинами строк, и с допустимыми символами. Получение в результате проверки дробного числа вовсе не означает, что оно имеет смысл в контексте дальнейших операций. Является ли арабская вязь валидной в имени обладателя российского паспорта? Является ли приемлемой строка с SQL инъекциями? Держим в памяти ситуацию с корнем от минус единицы.

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

Обязательным, но недостаточным приемом для решения таких ситуаций является документация API сервера. Должны быть описаны все форматы, наборы символов, допустимые значения, справочники. И, что очень часто игнорируется, должны быть четкие описания всех вариантов ответной реакции. Так же весьма желательно, что бы реакция была максимально индивидуальной в каждом из вероятных случаев. Отправка в любой ситуации единственной ошибки 500 Internal Server Error не является хорошим решением.

Возникает и другая проблема - избыточная гибкость. Обработка входящих данных может стать настолько сложной и подробной, что начинает сама становиться источником проблем. Разумеется, нужно где-то в таких случаях останавливаться и соизмерять потребность. Одно дело, если это API формы оформления клиентского заказа, где пользователя нужно всячески ублажать, совсем другое - расчет баллистической траектории снаряда, когда программа должна подавать разряд тока при вводе некорректных данных. Если кому-то кажется, что проблема сильно раздутая, предлагаю вспомнить как по разному реагируют разные системы при вводе неверного пароля.

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

Итогом верификации данных на уровне сервера должны стать относительно чистые данные, в полном соответствии с документацией. Как этого добивается API, путем ли морального изнасилования пользователя, при помощи ли искусственного интеллекта и распознавания жестов, не важно - внутренней логики программы это касаться не должно. В программу должны поступать строго типизированные, строго ограниченные в пределах данные. Но и этого как мы уже понимаем не всегда достаточно.

Строгая типизация

Прежде чем разбираться дальше, нужно отойти немного в сторону и озвучить пару мыслей о том что я думаю по отношению PHP vs Java, JavaScript vs TypeScript и прочих версусов. Так сложилось, что в веб-технологиях прочно обосновалась парадигма динамической типизации данных. Хотя, казалось бы, это именно та область программирования, где взаимодействие с пользовательским вводом не то что стоит во главе угла, а непосредственно порождается им. Тем удивительнее понимать, что именно в JavaScript и PHP скриптах уделяется меньше всего внимания работе с вышеописанными ситуациями. Насколько было бы проще работать, если бы было заранее известно, что где-то в недрах бизнес-логики в переменной находится не строка со значением '-1', а отрицательная единица.

Каждый нормальный скрипт должен предварительно проверить тип, допустимые значения, определено ли вообще значение, стоит там null, 'NULL', undefined, '', [] и прочая тому подобная пустота. Более того, никогда нет гарантии, что именно кусок работы с параметрами запроса все проверил. Проверка нужна на любом этапе и на каждом участке кода критичном к значению переменной.

К счастью, люди постепенно пришли к пониманию, что проверять isset() ли каждая переменная в каждой строчке кода довольно утомительное занятие. PHP теперь позволяет строго определять ограничения входных данных. То же самое пытаются приделать в JavaScript. Тем не менее врожденная динамическая типизация никуда не делась. Глубоко внутри компиляторов все-равно сидит алгоритм угадывания что же это такое зашифровано внутри переменной, то ли это дефис перед единицей, то ли минус.

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

Языки же со строгой типизацией как Java, скажем так, слишком придирчивы. Надо обязательно понимать long перед нами или int, Float или Double. Конструкции для работы со строками и привидением их к необходимым типам неуклюжи и многословны. С одной стороны это действительно гарантирует то, что аргументом к счетчику не попадет дробь или строка. С другой, влечет за собой увеличение количества кода, которое так же не является фактором уменьшающим ошибки.

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

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

Область определения функции

Как нас учат еще в школе каждая функция имеет свою область определения. Например, функция f(x) = 1/х определена на множестве вещественных чисел от минус бесконечности до нуля и от нуля до плюс бесконечности. В программировании существует тип представляющий в каком-то ограниченном виде множество вещественных чисел - это double или float. Функция, принимающая аргументом double и не проверяющая самостоятельно валидность аргумента, очевидно, в нуле вызовет сбой. Хотя все предыдущие шаги валидации максимально гарантировали ей попадание внутрь именно double, причем не пустого.

Нужно ли было делегировать API или форме ввода запрет на ввод нуля? Или может ли он получиться не при вводе, а при внутренних вычислениях? Может быть это предыдущая функция округлила 0.001 до нуля копеек? Виноват ли пользователь, который пытался рассчитать процент от одной копейки? Очевидно, что в данном случае проверка аргумента самой функцией необходима. Программа должна не просто вывалиться с выгрузкой стека вызовов, а например, вежливо сообщить пользователю, что в итоге получается бесконечность с неизвестно при этом с каким знаком.

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

Таким образом, функция так же должна проверять валидность аргумента на предмет принадлежности своей области определения. А в документации к API появляется пункт о ситуации, когда независимо от того корректно ли введены данные может возникнуть такая-то дополнительная неприятность.

Но, как говорится, и это еще не все. Обычно, программа так или иначе должна сохранить или вывести куда-то как входящие, так и полученные данные. Если это HTML или текстовый журнал работы, то все относительно нестрашно. Если же данные нужно хранить в строго структурированном виде в базе данных, то проблема валидации данных имеет дальнейшее продолжение.

Целостность данных

Независимо от того правильно ли отработала программа данные часто имеют собственную структуру. Какие-то данные должны быть уникальными, какие-то должны быть связаны с другими, отличаются немного и типы данных. Если в языке программирования строка размером в 10 гигабайт это в общем нормально, то для поля базы данных это многовато. Может быть наоборот, что база данных умеет хранить значительно большие числа, чем программа может в принципе получить на выходе.

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

Серьезные проблемы начинаются когда база данных либо недостаточно хорошо спроектирована, что допускает какие-то логические противоречия, либо эти логические противоречия сложнее чем встроенные возможности СУБД исключить их. При всем старании вряд ли можно заложить в СУБД понимание того, что у человека не может быть больше чем сто детей. Ну или во всяком случае научить её предупреждать программу что-то с этими данными явно не так.

Пример и здесь может быть сильно упрощен, но можете поверить на слово: появление в результате запроса нескольких строк там, где подразумевалась одна уникальная, явление которое появляется намного чаще чем хотелось бы. Поэтому такие проверки типа того, что по одному адресу не может проживать два человека с одним и тем же именем и датой рождения, должны каким-то хитрым образом проверяться на уровне логики программы. Родителей близнецов, которые решили таким образом приколоться над программистами ЗАГС-а, в расчет не берем.

В сухом остатке

Конечно, это далеко не все проблемы связанные с валидацией данных в программировании. Разумеется, есть и множество описанных случаев и способов с ними бороться. Есть и глобальные методики у тех же ученых. Вопрос всегда стоит в некоем балансе и допущении. Ни одна программа работающая с внешними данными не может быть надежна на все сто. Всегда есть возможность прямо в ТЗ объявить приемлемую надежность в каких-нибудь единицах качества. Плохо что этой возможностью часто предпочитают не пользоваться как разработчики, так и заказчики. Подспудно подразумевается именно стопроцентная надежность. А когда из-за этого возникают разногласия есть соблазн переложить вину на конкретный кусок программы и его конкретного исполнителя. И тут важно нам - программистам - не попадать под раздачу.

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

Во-вторых, если этого прямым образом не указано в ТЗ, то следует изучить вопрос валидации данных заведомо с заказчиком, а не пытаться строить свои теории поведения конечного пользователя. Это позволит вам очертить конкретные границы между гибкостью и строгостью по отношению к данным.

Ну и в третьих - это тестирование. Полное и всеобъемлющее. Акты тестирования так же являются вашей дополнительной подушкой безопасности, в которую можно смело тыкать пальцем при возникновении споров.

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

*/

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

End;