Jeśli model sprawdza poprawność danych, czy nie powinien generować wyjątków w przypadku złych danych wejściowych?

9

Czytając to pytanie SO , wydaje się, że odrzucanie wyjątków w celu sprawdzania poprawności danych wejściowych użytkownika jest niezadowolone.

Ale kto powinien zweryfikować te dane? W moich aplikacjach wszystkie walidacje są wykonywane w warstwie biznesowej, ponieważ tylko sama klasa naprawdę wie, które wartości są prawidłowe dla każdej z jej właściwości. Gdybym miał skopiować zasady sprawdzania poprawności właściwości do kontrolera, możliwe jest, że reguły sprawdzania poprawności ulegną zmianie i teraz są dwa miejsca, w których należy dokonać modyfikacji.

Czy moje założenie, że walidacja powinna odbywać się w warstwie biznesowej, jest błędne?

Co robię

Więc mój kod zwykle kończy się tak:

<?php
class Person
{
  private $name;
  private $age;

  public function setName($n) {
    $n = trim($n);
    if (mb_strlen($n) == 0) {
      throw new ValidationException("Name cannot be empty");
    }
    $this->name = $n;
  }

  public function setAge($a) {
    if (!is_int($a)) {
      if (!ctype_digit(trim($a))) {
        throw new ValidationException("Age $a is not valid");
      }
      $a = (int)$a;
    }
    if ($a < 0 || $a > 150) {
      throw new ValidationException("Age $a is out of bounds");
    }
    $this->age = $a;
  }

  // other getters, setters and methods
}

W kontrolerze po prostu przekazuję dane wejściowe do modelu i wychwytuję zgłoszone wyjątki, aby pokazać użytkownikowi błąd:

<?php
$person = new Person();
$errors = array();

// global try for all exceptions other than ValidationException
try {

  // validation and process (if everything ok)
  try {
    $person->setAge($_POST['age']);
  } catch (ValidationException $e) {
    $errors['age'] = $e->getMessage();
  }

  try {
    $person->setName($_POST['name']);
  } catch (ValidationException $e) {
    $errors['name'] = $e->getMessage();
  }

  ...
} catch (Exception $e) {
  // log the error, send 500 internal server error to the client
  // and finish the request
}

if (count($errors) == 0) {
  // process
} else {
  showErrorsToUser($errors);
}

Czy to zła metodologia?

Metoda alternatywna

Czy powinienem utworzyć metody isValidAge($a)zwracania wartości prawda / fałsz, a następnie wywołać je ze sterownika?

<?php
class Person
{
  private $name;
  private $age;

  public function setName($n) {
    $n = trim($n);
    if ($this->isValidName($n)) {
      $this->name = $n;
    } else {
      throw new Exception("Invalid name");
    }
  }

  public function setAge($a) {
    if ($this->isValidAge($a)) {
      $this->age = $a;
    } else {
      throw new Exception("Invalid age");
    }
  }

  public function isValidName($n) {
    $n = trim($n);
    if (mb_strlen($n) == 0) {
      return false;
    }
    return true;
  }

  public function isValidAge($a) {
    if (!is_int($a)) {
      if (!ctype_digit(trim($a))) {
        return false;
      }
      $a = (int)$a;
    }
    if ($a < 0 || $a > 150) {
      return false;
    }
    return true;
  }

  // other getters, setters and methods
}

Kontroler będzie w zasadzie taki sam, tylko zamiast try / catch są teraz if / else:

<?php
$person = new Person();
$errors = array();
if ($person->isValidAge($age)) {
  $person->setAge($age);
} catch (Exception $e) {
  $errors['age'] = "Invalid age";
}

if ($person->isValidName($name)) {
  $person->setName($name);
} catch (Exception $e) {
  $errors['name'] = "Invalid name";
}

...

if (count($errors) == 0) {
  // process
} else {
  showErrorsToUser($errors);
}

Więc co powinienem zrobić?

Jestem bardzo zadowolony z mojej oryginalnej metody i podobają mi się moi koledzy, którym to pokazałem. Mimo to, czy powinienem przejść na alternatywną metodę? A może robię to okropnie źle i powinienem szukać innej drogi?

Carlos Campderrós
źródło
Zmodyfikowałem nieco „oryginalny” kod, aby poradzić sobie z ValidationExceptioninnymi wyjątkami
Carlos Campderrós,
2
Jednym z problemów z wyświetlaniem komunikatów o wyjątkach użytkownikowi końcowemu jest to, że model nagle musi wiedzieć, jakim językiem posługuje się użytkownik, ale to głównie dotyczy widoku.
Bart van Ingen Schenau
@BartvanIngenSchenau good catch. Moje aplikacje zawsze były w jednym języku, ale dobrze jest myśleć o problemach z lokalizacją, które mogą pojawić się w dowolnej implementacji.
Carlos Campderrós
Wyjątki dotyczące walidacji to tylko fantazyjny sposób wprowadzania typów do procesu. Możesz osiągnąć te same wyniki, zwracając obiekt, który implementuje interfejs sprawdzania poprawności, taki jak IValidateResults.
Reactgular

Odpowiedzi:

7

Podejście, które stosowałem w przeszłości, polega na umieszczeniu wszystkich dedykowanych klas logiki walidacji.

Następnie można wstrzyknąć te klasy sprawdzania poprawności do warstwy prezentacji w celu wczesnego sprawdzenia poprawności danych wejściowych. Nic nie stoi na przeszkodzie, aby klasy Model korzystały z tych samych klas do egzekwowania integralności danych.

Postępując zgodnie z tym podejściem, możesz następnie traktować Błędy Walidacji w różny sposób, w zależności od warstwy, w której się znajdują:

  • Jeśli sprawdzanie integralności danych nie powiedzie się w modelu, należy zgłosić wyjątek.
  • Jeśli sprawdzanie poprawności danych wejściowych przez użytkownika nie powiedzie się w warstwie prezentacji, wyświetl pomocną wskazówkę i opóźnij przekazanie wartości do modelu.
MetaFight
źródło
Więc masz klasę PersonValidatorz całą logiką do sprawdzania poprawności różnych atrybutów Personi Personklasy, która zależy od tego PersonValidator, prawda? Jaka jest Twoja przewaga nad alternatywną metodą, którą zasugerowałem w pytaniu? Widzę tylko zdolność do wstrzykiwania różnych klas walidacji dla Person, ale nie mogę wymyślić żadnego przypadku, w którym byłoby to potrzebne.
Carlos Campderrós
Zgadzam się, że dodanie zupełnie nowej klasy do walidacji jest przesadą, przynajmniej w tym stosunkowo prostym przypadku. Może być przydatny w przypadku problemu o znacznie większej złożoności.
Cóż, w przypadku aplikacji, którą planujesz sprzedawać wielu osobom / firmom, może to mieć sens, ponieważ każda firma może mieć inne zasady sprawdzania prawidłowego zakresu dla wieku Osoby. Jest to więc możliwe użyteczne, ale naprawdę przesadne jak na moje potrzeby. W każdym razie +1 dla ciebie
Carlos Campderrós
1
Oddzielenie walidacji od modelu ma również sens z punktu widzenia sprzężenia i spójności. W tym prostym scenariuszu może to być przesada, ale zajmie to tylko jedną regułę walidacji „cross-field”, aby osobna klasa Validatora była znacznie bardziej atrakcyjna.
Seth M.
8

Jestem bardzo zadowolony z mojej oryginalnej metody i podobają mi się moi koledzy, którym to pokazałem. Mimo to, czy powinienem przejść na alternatywną metodę? A może robię to okropnie źle i powinienem szukać innej drogi?

Jeśli ty i twoi koledzy jesteście z tego zadowoleni, nie widzę pilnej potrzeby zmiany.

Jedyną rzeczą, która jest wątpliwa z pragmatycznego punktu widzenia, jest to, że rzucasz, Exceptiona nie coś bardziej konkretnego. Problem polega na tym, że jeśli złapiesz Exception, możesz złapać wyjątki, które nie mają nic wspólnego z weryfikacją danych wejściowych użytkownika.


Teraz jest wielu ludzi, którzy mówią, że „wyjątki należy stosować tylko do wyjątkowych rzeczy, a XYZ nie jest wyjątkowy”. (Na przykład @ dann1111's Answer ... gdzie opisuje błędy użytkownika jako „całkowicie normalne”).

Odpowiadam na to, że nie ma obiektywnego kryterium decydującego o tym, czy coś („XY Z”) jest wyjątkowe, czy nie. Jest to miara subiektywna . (Fakt, że jakikolwiek program musi sprawdzić błędy w danych wejściowych użytkownika, nie powoduje, że błędy wystąpienia są „normalne”. W rzeczywistości „normalne” jest w dużej mierze bez znaczenia z obiektywnego punktu widzenia.)

W tej mantrze jest ziarno prawdy. W niektórych językach (a dokładniej w niektórych implementacjach językowych ) tworzenie wyjątków, rzucanie i / lub łapanie jest znacznie droższe niż proste warunki warunkowe. Ale jeśli spojrzysz na to z tej perspektywy, musisz porównać koszty tworzenia / rzucania / łapania z kosztami dodatkowych testów, które możesz potrzebować, jeśli unikniesz wyjątków. „Równanie” musi brać pod uwagę prawdopodobieństwo, że wyjątek musi zostać zgłoszony.

Innym argumentem przeciwko wyjątkom jest to, że mogą utrudniać zrozumienie kodu. Ale drugą stroną jest to, że gdy są właściwie używane, mogą ułatwić zrozumienie kodu .


W skrócie - decyzja o zastosowaniu lub nie wykorzystaniu wyjątków powinna być podjęta po rozważeniu zalet ... a NIE na podstawie jakiegoś uproszczonego dogmatu.

Stephen C.
źródło
Dobra uwaga na temat Exceptionrzucania / łapania ogólnych . Naprawdę wrzucam własną podklasę Exception, a kod seterów zwykle nie robi nic, co mogłoby rzucić kolejny wyjątek.
Carlos Campderrós
Zmodyfikowałem nieco „oryginalny” kod, aby obsługiwał ValidationException i inne wyjątki / cc @ dan1111
Carlos Campderrós
1
+1, wolałbym raczej opisowy ValidationException niż wracać do ciemnych czasów, kiedy trzeba sprawdzać wartość zwracaną przy każdym wywołaniu metody. Prostszy kod = potencjalnie mniej błędów.
Heinzi
2
@ dan1111 - Chociaż szanuję twoje prawo do wyrażania opinii, nic w twoim komentarzu nie jest niczym innym jak tylko opinią. Nie ma logicznego związku między „normalnością” walidacji a mechanizmem obsługi błędów walidacji. Wszystko, co robicie, to recytowanie dogmatu.
Stephen C
@StephenC, po zastanowieniu czuję, że wypowiedziałem się zbyt mocno. Zgadzam się, że to bardziej osobiste preferencje.
6

Moim zdaniem użyteczne jest rozróżnienie między błędami aplikacji i błędami użytkownika i stosowanie tylko tych wyjątków.

  • Wyjątki mają obejmować rzeczy, które uniemożliwiają prawidłowe działanie programu .

    Są to nieoczekiwane zdarzenia, które uniemożliwiają ci kontynuowanie, a ich konstrukcja odzwierciedla to: przerywają normalne wykonanie i przeskakują w miejsce, które umożliwia obsługę błędów.

  • Błędy użytkownika, takie jak nieprawidłowe dane wejściowe, są całkowicie normalne (z punktu widzenia programu) i nie powinny być traktowane jako nieoczekiwane przez aplikację .

    Jeśli użytkownik wpisze nieprawidłową wartość i wyświetli się komunikat o błędzie, to czy Twój program „zawiódł” lub wystąpił błąd w jakikolwiek sposób? Nie. Twoja aplikacja zakończyła się powodzeniem - biorąc pod uwagę pewien rodzaj danych wejściowych, w tej sytuacji wygenerowała poprawny wynik.

    Obsługa błędów użytkowników, ponieważ jest to część normalnego wykonywania, powinna być częścią normalnego przepływu programu, a nie obsługiwana przez wyskakiwanie z wyjątkiem.

Oczywiście możliwe jest stosowanie wyjątków do celów innych niż zamierzone, ale robi to mylący paradygmat i ryzykuje nieprawidłowe zachowanie w przypadku wystąpienia tych błędów.

Twój oryginalny kod jest problematyczny:

  • Osoba wywołująca setAge()metodę musi wiedzieć zbyt dużo o wewnętrznej obsłudze błędów metody: osoba dzwoniąca musi wiedzieć, że wyjątek jest zgłaszany, gdy wiek jest nieważny, i że żadne inne wyjątki nie mogą być zgłaszane w ramach metody . To założenie może zostać później złamane, jeśli dodasz w nim dodatkową funkcjonalność setAge().
  • Jeśli osoba dzwoniąca nie wyłapie wyjątków, niepoprawny wyjątek wieku zostanie później obsłużony w inny, najprawdopodobniej nieprzejrzysty sposób. Lub nawet spowodować awarię nieobsługiwanego wyjątku. Niewłaściwe zachowanie w przypadku wprowadzania nieprawidłowych danych.

Kod alternatywny ma również problemy:

  • Wprowadzono dodatkową, być może niepotrzebną metodę isValidAge().
  • Teraz setAge()metoda musi zakładać, że dzwoniący już sprawdził isValidAge()(straszne założenie) lub ponownie zweryfikować wiek. Jeśli ponownie potwierdzi wiek, setAge() nadal musi zapewnić obsługę błędów, a ty znów powracasz do normy.

Sugerowany projekt

  • Spraw, aby setAge()zwrot był prawdziwy w przypadku sukcesu, a fałszywy w przypadku niepowodzenia.

  • Sprawdź wartość zwracaną, setAge()a jeśli to się nie powiedzie, poinformuj użytkownika, że ​​wiek był nieprawidłowy, nie z wyjątkiem, ale z normalną funkcją, która wyświetla użytkownikowi błąd.


źródło
Jak mam to zrobić? Z alternatywną metodą, którą zaproponowałem, czy z czymś zupełnie innym, o czym nie myślałem? Czy moje założenie, że „sprawdzanie poprawności na warstwie biznesowej” jest fałszywe?
Carlos Campderrós
@ CarlosCampderrós, zobacz aktualizację; Dodałem te informacje, jak skomentowałeś. W Twoim oryginalnym projekcie sprawdzono poprawność we właściwym miejscu, ale błędem było użycie wyjątków w celu sprawdzenia poprawności.
Metoda alternatywna wymusza setAgeponowną walidację, ale ponieważ logika jest w zasadzie „jeśli jest prawidłowa, to ustaw wyjątek wieku wrzuć wyjątek”, nie zabiera mnie to z powrotem do punktu wyjścia.
Carlos Campderrós
2
Jednym z problemów, jaki widzę zarówno w przypadku metody alternatywnej, jak i sugerowanego projektu, jest to, że tracą one zdolność rozróżniania DLACZEGO wiek był nieprawidłowy. Może to spowodować zwrócenie wartości true lub łańcucha błędu (tak, php jest bardzo brudny), ale może to prowadzić do wielu problemów, ponieważ "The entered age is out of bounds" == trueludzie powinni zawsze używać ===, więc to podejście byłoby bardziej problematyczne niż problem, który próbuje rozwiązać rozwiązać
Carlos Campderrós
2
Jednak kodowanie aplikacji jest naprawdę męczące, ponieważ za każdym razem, setAge()gdy tworzysz w dowolnym miejscu, musisz sprawdzić, czy naprawdę działa. Zgłaszanie wyjątków oznacza, że ​​nie powinieneś się martwić pamięcią o sprawdzeniu, czy wszystko poszło dobrze. Moim zdaniem próba ustawienia niepoprawnej wartości w atrybucie / właściwości jest czymś wyjątkowym i dlatego warto ją wyrzucić Exception. Model nie powinien się przejmować, czy pobiera dane z bazy danych, czy od użytkownika. Nigdy nie powinien otrzymywać złych danych wejściowych, więc uważam, że uzasadnione jest wprowadzenie tam wyjątku.
Carlos Campderrós
4

Z mojego punktu widzenia (jestem facetem Java) jest całkowicie poprawne, jak zaimplementowałeś go w pierwszy sposób.

Prawidłowe jest, że obiekt zgłasza wyjątek, gdy pewne warunki wstępne nie są spełnione (np. Łańcuch pusty). W Javie koncepcja sprawdzonych wyjątków jest przeznaczona do takiego celu - wyjątki, które muszą zostać zadeklarowane w podpisie, aby zostały rzucone, a osoba dzwoniąca musi je wyraźnie złapać. Natomiast niesprawdzone wyjątki (znane również jako RuntimeExceptions) mogą wystąpić w dowolnym momencie bez potrzeby definiowania klauzuli catch w kodzie. Podczas gdy pierwsze są używane w przypadkach, które można odzyskać (np. Nieprawidłowe dane wprowadzone przez użytkownika, nazwa pliku nie istnieje), drugie są używane w przypadkach, w których użytkownik / programista nie może nic zrobić (np. Brak pamięci).

Powinieneś jednak, jak już wspomniano w @Stephen C, zdefiniować własne wyjątki i złapać te wyjątki, aby nie przypadkowo złapać innych.

Innym sposobem byłoby jednak użycie obiektów przesyłania danych, które są po prostu kontenerami danych bez logiki. Następnie przekazujesz takie DTO walidatorowi lub samemu obiektowi modelowemu w celu sprawdzenia poprawności i tylko w przypadku powodzenia dokonaj aktualizacji w obiekcie modelowym. Takie podejście jest często stosowane, gdy logika prezentacji i logika aplikacji są oddzielnymi poziomami (prezentacja to strona internetowa, aplikacja to usługa internetowa). W ten sposób są one fizycznie rozdzielone, ale jeśli masz oba na jednym poziomie (jak w twoim przykładzie), musisz upewnić się, że nie będzie obejścia, aby ustawić wartość bez sprawdzania poprawności.

Andy
źródło
4

Z założonym kapeluszem Haskell oba podejścia są błędne.

To, co dzieje się koncepcyjnie, polega na tym, że najpierw masz kilka bajtów, a po analizie i sprawdzeniu poprawności możesz następnie zbudować Osobę.

Osoba ma pewne niezmienniki, takie jak precense imienia i wieku.

Możliwość reprezentowania Osoby, która ma tylko imię, ale nie ma wieku, jest czymś, czego chcesz uniknąć za wszelką cenę, ponieważ to tworzy komplementarność. Surowe niezmienniki oznaczają, że nie musisz na przykład sprawdzać obecności wieku później.

Więc w moim świecie Osoba jest tworzona atomowo za pomocą jednego konstruktora lub funkcji. Ten konstruktor lub funkcja może ponownie sprawdzić poprawność parametrów, ale nie należy budować żadnej osoby.

Niestety, Java, PHP i inne języki OO sprawiają, że poprawna opcja jest dość szczegółowa. W odpowiednich interfejsach API Java często używane są obiekty konstruktora. W takim interfejsie API utworzenie osoby wyglądałoby mniej więcej tak:

Person p = new Person.Builder().setName(name).setAge(age).build();

lub bardziej szczegółowy:

Person.Builder builder = new Person.Builder();
builder.setName(name);
builder.setAge(age);
Person p = builder.build();
// Person object must have name and age here

W takich przypadkach, bez względu na to, gdzie zgłaszane są wyjątki lub gdzie odbywa się sprawdzanie poprawności, nie można uzyskać wystąpienia Osoby, które jest nieprawidłowe.

użytkownik239558
źródło
Wszystko, co tutaj zrobiłeś, to przenieś problem do klasy Builder, na którą tak naprawdę nie odpowiedziałeś.
Cypher
2
Zlokalizowałem problem w funkcji builder.build (), która jest wykonywana atomowo. Ta funkcja jest listą wszystkich kroków weryfikacji. Istnieje ogromna różnica między tym podejściem a podejściem ad hoc. Klasa Builder nie ma niezmienników poza prostymi typami, podczas gdy klasa Person ma silne niezmienniki. Budowanie poprawnych programów polega na wymuszaniu silnych niezmienników danych.
user239558,
Nadal nie odpowiada na pytanie (przynajmniej nie do końca). Czy możesz rozwinąć sposób przekazywania poszczególnych komunikatów o błędach z klasy Builder w górę stosu wywołań do widoku?
Cypher
Trzy możliwości: build () może generować określone wyjątki, jak w pierwszym przykładzie PO. Może istnieć publiczny zestaw <String> validate (), który zwraca zestaw błędów czytelnych dla człowieka. Istnieje publiczny zestaw <Error> validate () dla błędów i18n-ready. Chodzi o to, że dzieje się to podczas konwersji na obiekt Person.
user239558,
2

Słowami laika:

Pierwsze podejście jest prawidłowe.

Drugie podejście zakłada, że ​​te klasy biznesowe będą wywoływane tylko przez tych kontrolerów i że nigdy nie będą wywoływane z innego kontekstu.

Klasy biznesowe muszą zgłaszać wyjątek za każdym razem, gdy naruszona zostanie reguła biznesowa.

Kontroler lub warstwa prezentacji musi zdecydować, czy je wyrzuca, czy też wykonuje własne walidacje, aby zapobiec wystąpieniu wyjątków.

Pamiętaj: twoje klasy będą potencjalnie wykorzystywane w różnych kontekstach i przez różnych integratorów. Muszą więc być wystarczająco inteligentni, aby rzucać wyjątki od złych danych wejściowych.

Tulains Córdova
źródło