Se o modelo está validando os dados, ele não deve lançar exceções em entradas ruins?

9

Lendo essa pergunta do SO , parece que lançar exceções para validar a entrada do usuário é desaprovado.

Mas quem deve validar esses dados? Nos meus aplicativos, todas as validações são feitas na camada de negócios, porque apenas a própria classe realmente sabe quais valores são válidos para cada uma de suas propriedades. Se eu copiasse as regras para validar uma propriedade para o controlador, é possível que as regras de validação mudem e agora existem dois locais onde a modificação deve ser feita.

Minha premissa é que a validação deve ser feita na camada de negócios errada?

O que eu faço

Então, meu código geralmente acaba assim:

<?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
}

No controlador, eu apenas passo os dados de entrada para o modelo e pego exceções lançadas para mostrar o (s) erro (s) para o usuário:

<?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);
}

Essa é uma metodologia ruim?

Método alternativo

Talvez eu deva criar métodos para isValidAge($a)esse retorno verdadeiro / falso e depois chamá-los do controlador?

<?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
}

E o controlador será basicamente o mesmo, apenas em vez de try / catch, existem agora 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);
}

Então, o que eu deveria fazer?

Estou muito feliz com meu método original e meus colegas a quem eu o mostrei em geral gostaram. Apesar disso, devo mudar para o método alternativo? Ou estou fazendo isso terrivelmente errado e devo procurar outro caminho?

Carlos Campderrós
fonte
Eu modificado o código "original" um pouco para lidar com ValidationExceptione outras exceções
Carlos Campderrós
2
Um problema ao mostrar mensagens de exceção para o usuário final é que, de repente, o modelo precisa saber qual idioma o usuário fala, mas isso é principalmente uma preocupação para o View.
Bart van Ingen Schenau
@BartvanIngenSchenau good catch. Meus aplicativos sempre foram de idioma único, mas é bom pensar em problemas de localização que podem surgir em qualquer implementação.
Carlos Campderrós
Exceções para validações é apenas uma maneira elegante de injetar tipos no processo. Você pode obter os mesmos resultados retornando um objeto que implementa uma interface de validação como IValidateResults.
Reactgular

Respostas:

7

A abordagem que usei no passado é colocar todas as classes de validação dedicadas à lógica de validação.

Você pode injetar essas classes de validação em sua camada de apresentação para validação de entrada antecipada. E nada impede que suas classes Model usem as mesmas classes para impor a Integridade de Dados.

Seguindo essa abordagem, você pode tratar os erros de validação de maneira diferente, dependendo da camada em que eles ocorrem:

  • Se a Validação de Integridade de Dados falhar no Modelo, lance uma Exceção.
  • Se a Validação de entrada do usuário falhar na camada de apresentação, exiba uma dica útil e adie o envio do valor ao seu modelo.
MetaFight
fonte
Então você tem classe PersonValidatorcom toda a lógica para validar os diferentes atributos de a Person, e a Personclasse que depende disso PersonValidator, certo? Qual é a vantagem que sua proposta oferece sobre o método alternativo sugerido na pergunta? Só vejo a capacidade de injetar diferentes classes de validação para a Person, mas não consigo pensar em nenhum caso em que isso seria necessário.
Carlos Campderrós
Concordo que adicionar uma nova classe para validação é um exagero, pelo menos nesse caso relativamente simples. Pode ser útil para um problema com muito mais complexidade.
Bem, para um aplicativo que você planeja vender para várias pessoas / empresas, pode fazer sentido, porque cada empresa pode ter regras diferentes para validar o que é um intervalo válido para a idade de uma Pessoa. Portanto, é possível útil, mas realmente exagero para as minhas necessidades. Enfim, +1 para você também
Carlos Campderrós
11
Separar a validação do modelo também faz sentido do ponto de vista de acoplamento e coesão. Nesse cenário simples, pode ser um exagero, mas será necessária apenas uma regra de validação de "campo cruzado" para tornar a classe Validator separada muito mais atraente.
Seth M.
8

Estou muito feliz com meu método original e meus colegas a quem eu o mostrei em geral gostaram. Apesar disso, devo mudar para o método alternativo? Ou estou fazendo isso terrivelmente errado e devo procurar outro caminho?

Se você e seus colegas estão satisfeitos com isso, não vejo necessidade urgente de mudar.

A única coisa que é questionável de uma perspectiva pragmática é que você está jogando em Exceptionvez de algo mais específico. O problema é que, se você capturar Exception, poderá acabar capturando exceções que nada têm a ver com a validação da entrada do usuário.


Agora, muitas pessoas dizem que coisas como "exceções devem ser usadas apenas para coisas excepcionais e XYZ não é excepcional". (Por exemplo, a resposta de @ dann1111 ... onde ele rotula os erros do usuário como "perfeitamente normais".)

Minha resposta a isso é que não há critério objetivo para decidir se algo ("XY Z") é excepcional ou não. É uma medida subjetiva . (O fato de qualquer programa precisar verificar se há erros na entrada do usuário não torna os erros de ocorrência "normais". Na verdade, "normal" não faz muito sentido do ponto de vista objetivo.)

Há um grão de verdade nesse mantra. Em alguns idiomas (ou mais precisamente, em algumas implementações de idiomas ), a criação de exceções, o lançamento e / ou a captura são significativamente mais caros do que os condicionais simples. Mas se você olhar dessa perspectiva, precisará comparar o custo de criação / lançamento / captura com o custo dos testes extras que talvez você precise executar se evitar o uso de exceções. E a "equação" deve levar em conta a probabilidade de que a exceção precise ser lançada.

O outro argumento contra as exceções é que elas podem dificultar o entendimento do código. Mas o outro lado é que, quando usados ​​de maneira apropriada, eles podem facilitar a compreensão do código .


Em resumo - a decisão de usar ou não usar exceções deve ser tomada após ponderar os méritos ... e NÃO com base em algum dogma simplista.

Stephen C
fonte
Bom argumento sobre o genérico Exceptionser jogado / capturado. Eu realmente jogo algumas subclasses de Exception, e o código dos setters geralmente não faz nada que poderia lançar outra exceção.
Carlos Campderrós
Eu modificado o código "original" um pouco para lidar com ValidationException e outras exceções / cc @ dan1111
Carlos Campderrós
11
+1, eu prefiro ter uma ValidationException descritiva do que voltar à idade das trevas de ter que verificar o valor de retorno de cada chamada de método. Código mais simples = potencialmente menos erros.
Heinzi 12/06
2
@ dan1111 - Embora eu respeite o seu direito de ter uma opinião, nada no seu comentário é outra coisa senão opinião. Não há conexão lógica entre a "normalidade" da validação e o mecanismo para lidar com erros de validação. Tudo o que você está fazendo é recitar o dogma.
Stephen C
@StephenC, refletindo, sinto que afirmei meu caso com muita força. Eu concordo que é mais uma preferência pessoal.
6

Na minha opinião, é útil distinguir entre erros de aplicativo e erros de usuário e usar apenas exceções para o primeiro.

  • Exceções destinam-se a cobrir coisas que impedem a execução correta do seu programa .

    São ocorrências inesperadas que o impedem de continuar, e seu design reflete isso: eles interrompem a execução normal e pulam para um local que permite o tratamento de erros.

  • Erros de usuário como entrada inválida são perfeitamente normais (da perspectiva do seu programa) e não devem ser tratados como inesperados pelo seu aplicativo .

    Se o usuário digitar o valor errado e você exibir uma mensagem de erro, seu programa "falhou" ou ocorreu algum erro? Não. Seu aplicativo foi bem-sucedido - dado um certo tipo de entrada, ele produziu a saída correta nessa situação.

    A manipulação de erros do usuário, porque faz parte da execução normal, deve fazer parte do fluxo normal do programa, em vez de ser tratada saltando com uma exceção.

Obviamente, é possível usar exceções para outros fins que não os propósitos pretendidos, mas isso confunde o paradigma e corre o risco de comportamento incorreto quando esses erros ocorrem.

Seu código original é problemático:

  • O responsável pela chamada do setAge()método deve saber muito sobre o tratamento interno de erros do método: o responsável pela chamada precisa saber que uma exceção é lançada quando a idade é inválida e que nenhuma outra exceção pode ser lançada no método . Essa suposição pode ser quebrada mais tarde se você adicionar funcionalidades adicionais setAge().
  • Se o chamador não capturar exceções, a exceção de idade inválida seria tratada posteriormente de alguma outra maneira, provavelmente opaca. Ou até causar uma falha de exceção não tratada. Não é um bom comportamento para a inserção de dados inválidos.

O código alternativo também tem problemas:

  • Um método extra, possivelmente desnecessário isValidAge(), foi introduzido.
  • Agora, o setAge()método deve assumir que o chamador já verificou isValidAge()(uma suposição terrível) ou validar a idade novamente. Se validar a idade novamente, setAge() ainda será necessário fornecer algum tipo de tratamento de erros e você voltará à estaca zero novamente.

Projeto sugerido

  • Torne o setAge()retorno verdadeiro em caso de sucesso e falso em caso de falha.

  • Verifique o valor de retorno setAge()e, se falhou, informe ao usuário que a idade era inválida, não com uma exceção, mas com uma função normal que exibe um erro para o usuário.


fonte
Então, como devo fazer isso? Com o método alternativo que propus ou com algo totalmente diferente em que não pensei? Além disso, minha premissa de que "a validação deve ser feita na camada de negócios" é falsa?
Carlos Campderrós
@ CarlosCampderrós, veja a atualização; Eu estava adicionando essas informações como você comentou. Seu design original teve validação no local correto, mas foi um erro usar exceções para executar essa validação.
O método alternativo força a setAgevalidação novamente, mas como a lógica é basicamente "se for válida, defina a idade para lançar uma exceção", não me leva de volta à estaca zero.
Carlos Campderrós
2
Um problema que vejo tanto no método alternativo quanto no design sugerido é que eles perdem a capacidade de distinguir POR QUE a idade era inválida. Poderia ser feito para retornar uma string verdadeira ou de erro (sim, o php é muuuuito sujo), mas isso pode levar a muitos problemas, porque as "The entered age is out of bounds" == truepessoas sempre devem usar ===, portanto, essa abordagem seria mais problemática do que o problema que ela tenta solucionar. resolver
Carlos Campderrós
2
Porém, a codificação do aplicativo é realmente cansativa, porque para cada coisa setAge()que você cria em qualquer lugar, é necessário verificar se realmente funcionou. Lançar exceções significa que você não deve se preocupar em lembrar de verificar se tudo correu bem. A meu ver, tentar definir um valor inválido em um atributo / propriedade é algo excepcional e vale a pena jogar o Exception. O modelo não deve se importar se está recebendo sua entrada do banco de dados ou do usuário. Ele nunca deve receber informações incorretas, portanto vejo que é legítimo lançar uma exceção lá.
Carlos Campderrós
4

Do meu ponto de vista (eu sou do Java), é totalmente válido como você o implementou da primeira maneira.

É válido que um objeto gere uma exceção quando algumas pré-condições não forem atendidas (por exemplo, sequência vazia). Em Java, o conceito de exceções verificadas é destinado a esse objetivo - exceções que devem ser declaradas na assinatura para serem lançadas de maneira adequada e o chamador explicitamente precisa capturá-las. Por outro lado, exceções não verificadas (também conhecidas como RuntimeExceptions), podem ocorrer a qualquer momento, sem a necessidade de definir uma cláusula catch no código. Enquanto o primeiro é usado para casos recuperáveis ​​(por exemplo, entrada incorreta do usuário, o nome do arquivo não existe), o último é usado para casos em que o usuário / programador não pode fazer nada (por exemplo, falta de memória).

Você deve, no entanto, como já mencionado por @Stephen C, definir suas próprias exceções e capturar especificamente aquelas para não capturar outras pessoas sem querer.

Outra maneira, no entanto, seria usar objetos de transferência de dados que são simplesmente recipientes de dados sem lógica. Em seguida, você entrega esse DTO a um validador ou ao próprio Objeto-modelo para validação, e somente se for bem-sucedido, faça as atualizações no Objeto-modelo. Essa abordagem geralmente é usada quando a lógica de apresentação e a lógica do aplicativo são camadas separadas (a apresentação é uma página da Web, o aplicativo é um serviço da Web). Dessa forma, eles são fisicamente separados, mas se você tiver os dois em uma camada (como no seu exemplo), verifique se não haverá nenhuma solução alternativa para definir um valor sem validação.

Andy
fonte
4

Com meu chapéu de Haskell, as duas abordagens estão erradas.

O que acontece conceitualmente é que você primeiro tem um monte de bytes e, depois de analisar e validar, pode construir uma Pessoa.

A Pessoa tem certos invariantes, como a precença de um nome e uma idade.

Ser capaz de representar uma Pessoa que tem apenas um nome, mas nenhuma idade é algo que você deseja evitar a todo custo, porque é isso que cria a complementaridade. Invariantes estritos significa que você não precisa verificar a presença de uma idade mais tarde, por exemplo.

Então, no meu mundo, Person é criado atomicamente usando um único construtor ou função. Esse construtor ou função pode novamente verificar a validade dos parâmetros, mas nenhuma Meia Pessoa deve ser construída.

Infelizmente, Java, PHP e outras linguagens OO tornam a opção correta bastante detalhada. Nas APIs Java apropriadas, os objetos do construtor são frequentemente usados. Em tal API, a criação de uma pessoa seria algo como isto:

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

ou o mais detalhado:

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

Nesses casos, não importa onde exceções são lançadas ou onde a validação ocorre, é impossível receber uma instância de Pessoa inválida.

user239558
fonte
Tudo o que você fez aqui foi mover o problema para a classe Builder, que você realmente não respondeu.
Cypher
2
Eu localizei o problema na função builder.build () que é executada atomicamente. Essa função é uma lista de todas as etapas de verificação. Há uma enorme diferença entre essa abordagem e abordagens ad-hoc. A classe Builder não possui invariantes além dos tipos simples, enquanto a classe Person possui fortes invariantes. Criar programas corretos tem como objetivo impor fortes invariantes em seus dados.
user239558
Ainda não responde à pergunta (pelo menos não completamente). Você poderia explicar como as mensagens de erro individuais são transmitidas da classe Builder até a pilha de chamadas para a View?
Cypher
Três possibilidades: build () pode gerar exceções específicas, como no primeiro exemplo do OP. Pode haver um conjunto público <>> validate () que retorna um conjunto de erros legíveis por humanos. Pode haver um conjunto público <Error> validate () para erros prontos para i18n. O ponto é que isso acontece durante a conversão em um objeto Pessoa.
user239558
2

Nas palavras dos leigos:

A primeira abordagem é a correta.

A segunda abordagem pressupõe que essas classes de negócios serão chamadas apenas por esses controladores e que nunca serão chamadas de outro contexto.

As classes de negócios devem lançar uma exceção sempre que uma regra de negócios for violada.

O controlador ou a camada de apresentação deve decidir se os lança ou executa suas próprias validações para impedir que as exceções aconteçam.

Lembre-se: suas aulas serão potencialmente usadas em diferentes contextos e por diferentes integradores. Portanto, eles devem ser inteligentes o suficiente para lançar exceções a insumos ruins.

Tulains Córdova
fonte