Як автоматизувати валідацію даних в PHP
Якось мені необхідно було написати класи з багатьма властивостями. Здебільшого програмісти особливо цим не переймаються — наклонують гетери разом з сетерами та й закриють це питання. До того ж сучасні IDE мають необхідні засоби для автоматизації цього процесу. Але мене осяйнула думка про те, що можна значно спростити створення класів, відмовившись від написання нудних однотипних операцій.
Я змоделював яка саме на моєму проєкті необхідна валідація та розклав її на частини. Перша частина — це валідація згідно з регулярним виразом. Цей вид валідації настільки універсальний, що може задовольнити майже всі перевірки. Хоча він має один суттєвий недолік: з його допомогою важко перевіряти допустимі діапазони чисел. Тобто зробити це можливо, але зазвичай дуже складно та незручно.
Тому ми підходимо до другої частини — звірка згідно з мінімальним або максимальним обмеженням числового значення. Вона компенсує недоліки попередньої частини й розширить можливості майже до ідеалу. Але й ця перевірка не зможе зробити валідацію максимально універсальною, тому що всі можливі ситуації передбачити неможливо.
На цей випадок впроваджуємо третю частину — користувацьку валідацію за допомогою методу. А от у методі ви зможете перевіряти значення властивості як вашій душі буде завгодно.
Є й четверта частина — перевірка на обов’язкову присутність значення властивості сутності. Але вона настільки проста, що я особливо зупинятися на ній не буду.
Базова сутність
Тепер саме час перейти до практичної частини та написати майже порожній базовий клас сутності для наслідування. А згодом я буду поступово додавати в нього необхідний функціонал, супроводжуючи власними скупими коментарями.
Клас із прикладами та шаблонами
Почнемо так, як я люблю, з простого — напишемо абстрактний клас Entity
з прикладом опису властивостей та шаблонами регулярних виразів.
use \Exception\Validation as ValidationException; abstract class Entity { /* Приклад опису властивостей сутності protected $propertyExample1Short1 = 'PatternName'; protected $propertyExample1Short2 = '/^.*$/'; protected $propertyExampleFull = [ 'pattern' => 'PatternName', // Ключ масиву з необхідним шаблоном або сам шаблон 'minimum' => 0, // Мінімальне обмеження для числового значення 'maximum' => 100, // Максимальне обмеження для числового значення 'required' => false, // Обов'язкова наявність значення [TRUE] 'value' => 10 // Значення за замовченням ]; */ protected const PATTERNS = [ 'String' => '/^.*$/', 'Float' => '/^-?(0\.\d*[1-9]\d*|[1-9]\d*(\.\d+)?)$/', 'Integer' => '/^(-?[1-9]\d*|0)$/', 'IntegerUnsigned' => '/^([1-9]\d*|0)$/', 'IntegerPositive' => '/^[1-9]\d*$/', 'Time' => '/^\d{2}:\d{2}:\d{2}$/', 'Date' => '/^\d{4}-\d{2}-\d{2}$/', 'DateTime' => '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', 'Title' => '/^[^\n\r\t]{1,64}$/', 'Alias' => '/^[a-z0-9-]{1,64}$/', 'Description' => '/^[^\n\r\t]{1,128}$/', 'Text' => '/^.{0,8192}$/', 'Phone' => '/^\d{10}$/', 'Domain' => '/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/', 'Email' => '/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$/', 'Path' => '/^(/[^/ ]*)+/?$/i', 'Boolean' => '/^[01]$/' ]; }
Спочатку я навів, як шпаргалку, приклад типового опису властивостей сутності та їхні значення в дочірньому класі. Такий класичний підхід, на противагу зберіганню всіх властивостей в окремому масиві, дасть можливість вибірково перезавантажувати деякі властивості в разі успадкування класу. А от значення властивостей записується трохи інакше. Для цього необхідно створити маленьку конфігурацію для автоматичної валідації, у якій визначено, як саме її здійснювати.
Якщо для валідації необхідно тільки звірити нове значення із шаблоном регулярного виразу використовуємо короткий запис значення. У значення властивості необхідно прописати назву ключа масиву константи PATTERNS
з необхідним шаблоном або конкретно сам шаблон.
У випадку, коли регулярного виразу для перевірки буде недостатньо, необхідно використати повний запис конфігурації автоматичної валідації у вигляді асоціативного масиву. Більша частина записів конфігурації вказує на те, яку саме перевірку необхідно здійснити з новим значенням перед його збереженням. Це може бути описана вище перевірка за шаблоном (pattern
), за числовим обмеженням (minimum
та maximum
) або за обов`язковою присутністю значення (required
). Також у ній можна вказати значення за замовчуванням для властивості (value
).
Але якщо й цих перевірок виявиться недостатньо необхідно створити класичні гетери та сетери у вигляді методів із назвою властивості у звичному вигляді getPropertyExample()
та setPropertyExample()
.
Типові валідації
А зараз додамо декілька простих та подібних методів для здійснення перевірок згідно з конфігурацією автоматичної валідації.
protected function isPresent(string $name) { if (!isset($this->$name)) throw new ValidationException( sprintf('Невідома властивість "%s" для сутності "%s"', $name, get_class($this)) ); } protected function isRequired(string $name) { if (!isset($this->{$name}['required']) || ($this->{$name}['required'] !== false)) throw new ValidationException( sprintf('Відсутнє обов`язкове значення (%s->%s)', get_class($this), $name) ); } protected function validateByPattern(string $name, $value, string $pattern) { if (preg_match($pattern, $value) != 1) throw new ValidationException( vsprintf( 'Значення "%s" не відповідає шаблону "%s" (%s->%s)', [$value, $pattern, get_class($this), $name] ) ); } protected function validateByMinimum(string $name, int $value, int $limit) { if ($value < $limit) throw new ValidationException( vsprintf( 'Значення "%s" менше дозволеного мінімуму "%s" (%s->%s)', [$value, $limit, get_class($this), $name] ) ); } protected function validateByMaximum(string $name, int $value, int $limit) { if ($value > $limit) throw new ValidationException( vsprintf( 'Значення "%s" більше дозволеного максимуму "%s" (%s->%s)', [$value, $limit, get_class($this), $name] ) ); }
Перший метод необхідний для перевірки присутності властивості в сутності, другий — для перевірки обов’язкової наявності значення властивості. Решта потрібні для перевірки згідно з шаблоном регулярного виразу, числового обмеження згідно з мінімумом або максимумом.
Універсальні гетери та сетери
А зараз додамо в наш клас конструктор для ініціації сутності та універсальні гетери/сетери, які грають роль інтерпретатора для команд керування процесом валідації, записаних у конфігурації властивості.
public function __construct(array $entity) { $properties = get_object_vars($this); foreach($properties as $name => $value) { if (!is_array($value)) $this->$name = isset($this->$name) ? ['pattern' => $value] : []; if (isset($this->{$name}['pattern']) && (strlen($this->{$name}['pattern']) > 0)) if (substr($this->{$name}['pattern'],0, 1) !== '/') $this->{$name}['pattern'] = self::PATTERNS[$this->{$name}['pattern']]; if (isset($entity[$name])) { $this->set($name, $entity[$name]); } else if (!isset($this->{$name}['value'])) { $this->set($name); } } } public function get(string $name) { $this->isPresent($name); $value = $this->{$name}['value']; $method = 'get' . ucfirst($name); if (method_exists($this, $method)) call_user_func_array([$this, $method], [&$value]); return $value; } public function set(string $name, $value = null) { $this->isPresent($name); if (!isset($value)) { $this->isRequired($name); $this->{$name}['value'] = null; return; } if (isset($this->{$name}['pattern'])) $this->validateByPattern($name, $value, $this->{$name}['pattern']); if (isset($this->{$name}['minimum'])) $this->validateByMinimum($name, $value, $this->{$name}['minimum']); if (isset($this->{$name}['maximum'])) $this->validateByMaximum($name, $value, $this->{$name}['maximum']); $method = 'set' . ucfirst($name); if (method_exists($this, $method)) call_user_func_array([$this, $method], [&$value]); $this->{$name}['value'] = $value; }
Конструктор здійснює імпорт даних із масиву в сутність з попередньою адаптацією конфігурації валідації. Гетер перевіряє тільки наявність властивості та запускає виконання метода з іменем властивості в разі її наявності. Сетер здійснює послідовні перевірки згідно з конфігурацією автоматичної валідації.
Зверніть особливу увагу на те, що перевірки не виключають одна одну, а доповнюють і ви можете в одній конфігурації використовувати одночасно декілька з них. Уявімо, ніби вам необхідно перевірити, щоби значення властивості було простим числом у певному діапазоні. Тоді ви можете в конфігурації валідації задати шаблон регулярного виразу для перевірки значення на додатне число, потім вказати дозволений числовий діапазон. Після цього написати метод для перевірки чи число ділиться тільки на себе. Усі ці валідації виконаються послідовно.
Користувацькі валідації та модифікації
Наведу декілька прикладів для демонстрації реалізації користувацьких перевірок чи модифікацій за допомогою методів. Доцільність виконання модифікації значення властивості сутності в самій сутності питання доволі неоднозначне. Але я, про всяк випадок, додав таку можливість.
/** @noinspection PhpUnused */ protected function setDateTime(string $value) { list($date, $time) = explode(' ', $value); list($years, $months, $days) = explode('-', $date); list($hours, $minutes, $seconds) = explode(':', $time); $this->validateByMinimum('dateTime->years', $years, 2000); $this->validateByMaximum('dateTime->years', $years, date('Y')); $this->validateByMinimum('dateTime->months', $months, 1); $this->validateByMaximum('dateTime->months', $months, 12); $this->validateByMinimum('dateTime->days', $days, 1); $this->validateByMaximum('dateTime->days', $days, 31); $this->validateByMinimum('dateTime->hours', $hours, 0); $this->validateByMaximum('dateTime->hours', $hours, 23); $this->validateByMinimum('dateTime->minutes', $minutes, 0); $this->validateByMaximum('dateTime->minutes', $minutes, 59); $this->validateByMinimum('dateTime->seconds', $seconds, 0); $this->validateByMaximum('dateTime->seconds', $seconds, 59); if ($value != date('Y-m-d H:i:s', strtotime($value))) throw new ValidationException( vsprintf( 'Неправильне значення %s (%s->%s)', [$value, get_class($this), 'dateTime'] ) ); } /** @noinspection PhpUnused */ protected function setImage(string &$value) { $value = substr($value, 7); } /** @noinspection PhpUnused */ protected function getImage(string &$value) { $value = '/images' . $value; }
Користувацькі перевірки з допомогою методів, які розміщенні в базовому класі, будуть здійснюватись у будь-якому успадкованому класі за наявності однойменної властивості. Тому я додаю тут тільки перевірки для поширених властивостей, а в наслідуваному класі — тільки специфічні для нього.
Успадкована сутність
Нарешті ми підійшли до найцікавішого, заради чого ми все це вище створювали — до автоматизації валідації в успадкованій сутності.
/** @noinspection PhpUnused PhpMissingFieldTypeInspection */ namespace Entity; use \Entity; class Article extends Entity { protected $id = 'IntegerPositive', $dateTime = 'DateTime', $title = 'Title', $description = 'Description', $text = 'Text', $image = '/^\/images\/articles\/[0-9a-z]{32}\.(jpg|png|gif)$/', $alias = 'Alias', $category = ['pattern' => 'IntegerPositive', 'maximum' => 8], $user = ['pattern' => 'IntegerPositive', 'maximum' => 255], $hits = ['pattern' => 'IntegerUnsigned', 'required' => false, 'value' => 0], $comments = ['pattern' => 'IntegerUnsigned', 'required' => false], $location = ['required' => false], $status = ['pattern' => '/^[012]$/', 'required' => false, 'value' => 1]; protected function setLocation(string $value) { list($latitude, $longitude) = explode(' ', $value); $this->validateByPattern('location->latitude', $latitude, self::PATTERNS['Float']); $this->validateByPattern('location->longitude', $longitude, self::PATTERNS['Float']); $this->validateByMinimum('location->latitude', $latitude, -90); $this->validateByMaximum('location->latitude', $latitude, 90); $this->validateByMinimum('location->longitude', $longitude, -180); $this->validateByMaximum('location->longitude', $longitude, 180); } }
Зверніть увагу, як лаконічно ми описуємо все, що нам необхідно — немає нічого зайвого. Навіть якщо кількість властивостей збільшити в декілька разів сприйматися сутність буде легко та зрозуміло. Також як приклад я додав метод для перевірки значення властивості location
. Вона, на відміну від перевірок у батьківському класі, використовується тільки в цьому конкретному класі.
Приклад використання сутності
А зараз ми розглянемо простенький приклад використання сутності з автоматичною валідацією.
use \Entity\Article as ArticleEntity; use \Repository\Article as ArticleRepository; use \Exception\Validation as ValidationException; use \Log\Validation as ValidationLog; try { // Імітація надходження даних з форми $article = [ 'id' => 853951, 'dateTime' => '2020-07-19 20:47:34', 'title' => 'Заголовок ...', 'description' => 'Опис ...', 'text' => 'Текст ...', 'image' => '/images/articles/a93d205e85f71c4b6c0db6ccef5b8e3d.jpg', 'alias' => 'zagolovok-tri-krapki', 'category' => 3, 'user' => 14, 'location' => '21.3280193 -157.8691133' ]; dbg($article); $articleEntity = new ArticleEntity($article); // Імітація роботи зі значеннями властивостей сутності dbg($articleEntity->get('hits'), 'hits1'); $articleEntity->set('hits', 123); dbg($articleEntity->get('hits'), 'hits2'); dbg($articleEntity->get('comments'), 'comments1'); $articleEntity->set('comments', 5); dbg($articleEntity->get('comments'), 'comments2'); dbg($articleEntity->get('image'), 'image'); dbg($articleEntity); // Приклад подальшого використання сутності $mapper = new Mapper(); $articleRepository = new ArticleRepository($mapper); $articleRepository->set($articleEntity); } catch (ValidationException $exception) { /** @noinspection PhpUndefinedFieldInspection */ echo '<table>' . $exception->xdebug_message . '</table>'; ValidationLog::addException($exception); } function dbg($value, string $name = null) { if (is_array($value) || is_object($value)) $value = '<pre>'. print_r($value, true) . '</pre>'; echo ((is_null($name)) ? "$value" : "$name = $value") . "<br />\n\r"; }
Спочатку в прикладі створюється асоціативний масив із довільними даними $article
, який імітує масив $_POST
. Потім створюється об’єкт сутності $articleEntity
, у який передається масив зі значеннями для властивостей сутності. Вони відразу, під час створення сутності, проходять обов’язкову автоматичну валідацію та зберігаються. Після цього сутність із даними передається в створений об’єкт $articleRepository
для імітації збереження її в БД.
У проміжках між цими операціями, для зручності демонстрації прикладу, виводимо деякі дані за допомогою функції dbg()
. Раджу також додатково реалізувати можливість запису в log-файл перехоплених винятків, що виникають під час валідації. Це допоможе ефективніше контролювати процес перевірки та своєчасно виявляти рідкісні помилки.
Array ( [id] => 853951 [dateTime] => 2020-07-19 20:47:34 [title] => Заголовок ... [description] => Опис ... [text] => Текст ... [image] => /images/articles/a93d205e85f71c4b6c0db6ccef5b8e3d.jpg [alias] => zagolovok-tri-krapki [category] => 3 [user] => 14 [location] => 21.3280193 -157.8691133 ) hits1 = 0 hits2 = 123 comments1 = comments2 = 5 image = /images/articles/a93d205e85f71c4b6c0db6ccef5b8e3d.jpg Entity\Article Object ( [id:protected] => Array ( [pattern] => /^[1-9]\d*$/ [value] => 853951 ) [dateTime:protected] => Array ( [pattern] => /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/ [value] => 2020-07-19 20:47:34 ) [title:protected] => Array ( [pattern] => /^[^\n\r\t]{1,64}$/ [value] => Заголовок ... ) [description:protected] => Array ( [pattern] => /^[^\n\r\t]{1,128}$/ [value] => Опис ... ) [text:protected] => Array ( [pattern] => /^.{0,8192}$/ [value] => Текст ... ) [image:protected] => Array ( [pattern] => /^\/images\/articles\/[0-9a-z]{32}\.(jpg|png|gif)$/ [value] => /articles/a93d205e85f71c4b6c0db6ccef5b8e3d.jpg ) [alias:protected] => Array ( [pattern] => /^[a-z0-9-]{1,64}$/ [value] => zagolovok-tri-krapki ) [category:protected] => Array ( [pattern] => /^[1-9]\d*$/ [maximum] => 8 [value] => 3 ) [user:protected] => Array ( [pattern] => /^[1-9]\d*$/ [maximum] => 255 [value] => 14 ) [hits:protected] => Array ( [pattern] => /^([1-9]\d*|0)$/ [required] => [value] => 123 ) [comments:protected] => Array ( [pattern] => /^([1-9]\d*|0)$/ [required] => [value] => 5 ) [location:protected] => Array ( [required] => [value] => 21.3280193 -157.8691133 ) [status:protected] => Array ( [pattern] => /^[012]$/ [required] => [value] => 1 ) )
Виводимо результат виконання прикладу: дані масиву, деяких властивостей сутності та структура з даними сутності загалом.
Недоліки автоматичної валідації
Перший недолік, який відразу впадає в око, це додаткова складність програмного коду. І це цілком зрозуміло — використання для валідації звичних гетерів та сетерів рішення однозначно простіше та зрозуміліше. До того ж це універсальний підхід, який використовується не тільки в PHP, а й у багатьох інших мовах програмуваннях.
Хоча з іншого боку, складний тут лише базовий клас, а успадковані класи зазвичай суттєво простіші, ніж у класичному підході. Тобто внаслідок ускладнення базового класу, який потрібно вивчити лише один раз, ми значно спрощуємо написання успадкованих класів сутностей.
Як результат першого недоліку автоматично з’являється другий — збільшення часу виконання запиту. Використання наслідування, звернення до декількох методів, перебір даних та з десяток порівнянь явно не пришвидшують його роботу.
Але з іншого боку, валідація необхідна тільки перед записом даних у БД — під час читання вона зайва. А оскільки зазвичай у вебсайтів кількість записів у БД суттєво менша за кількість зчитування з неї, вплив валідації на швидкодію сайту в загальному мінімальний. І чим більше це співвідношення читання/запис, тим більше нівелюється недолік швидкодії. До того ж сам процес запису в БД відбувається довше, ніж читання. А якщо врахувати, що цей запит відбувається через інтернет та додати час завантаження запиту з даними на сервер, то збільшення часу валідації не сильно вплине на загальний час виконання запиту.
Останній недолік — некоректна робота перевірок IDE, якій важко вирахувати правильність написання класу, який створює об’єкт динамічно. Ця проблема легко вирішується за допомогою спеціальних команд у коментарях, які вкажуть IDE, яку частину коду перевіряти не варто. До того ж я глибоко переконаний, що IDE мають підлаштовуватися під програмістів, а не навпаки.
Висновок
Маю зізнатись, що саме цю версію автоматичної валідації я ще не перевіряв на робочому проєкті. Але попередня морально застаріла версія чудово працює на одному моєму маленькому проєкті вже багато років.
І взагалі не варто ставитися до неї як до готового рішення, яке можна без застережень інтегрувати в готовий продукт. Сприймайте її радше як експериментальне дослідження — прототип, розроблений мною для власних потреб. А вам, можливо, доведеться адаптовувати його під себе.
Під кінець статті варто розглянути питання доцільності застосування автоматичної валідації. Якщо у вас маленький проєкт із невеликою кількістю властивостей у сутностях або цих властивостей багато, але вони водночас складні та різні — тоді, вочевидь, використання автоматичної валідації не принесе вам багато користі. З іншого боку, за наявності великої кількості властивостей та в разі їхньої великої подібності — доцільність цілком очевидна. Скажімо так, чим більше властивостей у вашому проєкті та чим більше вони подібні, тим більше користі від використання автоматичної валідації.
115 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів