Trabalho Final T21 Fundatec Detalhamento Parte 1
Nesse post vamos detalhar alguns aspectos do trabalho final requirido na disciplina de LPII para fundatec de Porto Alegre, no curso técnico em informática.
Esse post visa facilitar a elaboração do trabalho criando-se uma documentação que detalhe aspectos do desenvolvimento que serão reforçados em sala de aula.
Esse post não tem o intuito de ser um guia completo e substituto para as aulas
Vamos começar com a base feita em sala de aula disponível em giovannicandido/fundatec-trabalho-final (github.com)
Nessa base temos 3 modelos do nosso banco: Cliente, Endereco, Plano
Esses constituem o cadastro básico de clientes.
Falta: Tarifa, Veiculo, TarifaPorTipo, Estacionamento e TipoVeiculo
Vamos ver como persistir o cadastro básico de clientes primeiro. Para os demais itens teremos uma mistura de CRUD com logica de negócio, pois a tarifa é calculada.
Nossa logica para persistir esses itens pode ser feita de duas formas:
Primeiro se cria um endereço, depois se cria o cliente passando o id do endereço
Persiste o cliente com o objeto endereço
Na primeira abordagem um frontend teria um cadastro de endereços separado do de cliente, na segunda o cadastro do endereço pode ser feito junto ao cliente. Já que o endereço tem uma relação UmParaUm com cliente qualquer uma das abordagens é correta, mas a segunda tem a caracteristica de acoplar o ciclo de vida do endereço ao do cliente, ou seja, quando atualizarmos um cliente temos que atualizar seu endereço (se não usarmos atualização parcial de objeto)
Vamos abordar a primeira opção.
Primeiro criamos o endereço. É a operação mais simples já que não envolve mais de uma entidade:
@RestController
@RequestMapping(path = "/endereco")
public class EnderecoCtrl {
private final EnderecoService service;
public EnderecoCtrl(EnderecoService service) {
this.service = service;
}
@PostMapping
public ResponseEntity create(@RequestBody Endereco endereco) {
service.create(endereco);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
@Service
public class EnderecoService implements CrudService<Endereco> {
private final EnderecoRepository enderecoRepository;
public EnderecoService(EnderecoRepository enderecoRepository) {
this.enderecoRepository = enderecoRepository;
}
@Override
public Endereco create(Endereco entity) {
return this.enderecoRepository.save(entity);
}
@Override
public Endereco findById(Long idEndereco) {
return this.enderecoRepository.findById(idEndereco).orElse(null);
}
@Override
public Endereco update(Endereco entity) {
if (entity.getId() == null) {
throw new RuntimeException("Entidade precisa de um id para atualizar");
}
return enderecoRepository.save(entity);
}
}
public interface EnderecoRepository extends JpaRepository<Endereco, Long> {
}
Vemos que o service de Endereço apenas salva a entidade endereço, usando-se o repository. Por enquanto não usamos DTO passamos diretamente a entidade entre as camadas.
O Controller também é bem simples.
Agora para persistir um cliente é necessário 3 momentos:
Procurar o id do endereço passado no banco de dados. Caso não exista temos que parar o fluxo retornando alguma informação a quem chamou a API
Persistir o cliente
Atualizar o endereço com o ID do cliente.
Note que o Service e Repository para Cliente é basicamente identico ao de Endereço:
public interface ClienteRepository extends JpaRepository<Cliente, Long> {
}
@Service
public class ClienteService implements CrudService<Cliente> {
private final ClienteRepository repository;
public ClienteService(ClienteRepository repository) {
this.repository = repository;
}
@Override
public Cliente create(Cliente entity) {
return this.repository.save(entity);
}
@Override
public Cliente findById(Long idEndereco) {
return repository.getById(idEndereco);
}
@Override
public Cliente update(Cliente entity) {
if (entity.getId() == null) {
throw new RuntimeException("Entidade precisa de um id para atualizar");
}
return repository.save(entity);
}
}
@RequestMapping("/cliente")
public class ClienteCtrl {
private final ClienteService service;
private final EnderecoService enderecoService;
public ClienteCtrl(ClienteService service, EnderecoService enderecoService) {
this.service = service;
this.enderecoService = enderecoService;
}
@PostMapping
public ResponseEntity create(@RequestBody ClienteDTO clienteDTO) {
Endereco endereco = enderecoService.findById(clienteDTO.getIdEndereco());
if (endereco == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Não foi encontrado o endereco de id " + clienteDTO.getIdEndereco());
}
Cliente cliente = new Cliente();
cliente.setCpf(clienteDTO.getCpf());
cliente.setEndereco(endereco);
// todo setar plano
service.create(cliente);
// Atualiza endereco apontando para cliente
endereco.setCliente(cliente);
enderecoService.update(endereco);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
A logica descrita nos passos anteriores foi colocada no controlador. É uma boa ideia adicioná-la ao service, o service de Cliente trabalharia com o DTO ao inves da entidade, então o generic mudaria.
Note que para que o id do cliente seja associado ao endereço é necessário atualizar a entidade endereço também.
Para atualizar um cliente a logica é bem parecida com o criar um cliente. Bastando primeiro procurar o cliente pelo seu id no banco, setar as informações e salvar o cliente.
/**
* Atualizar um cliente no banco de dados
* @param clienteDTO Informações a atualizar
* @param id Id do cliente a ser atualizado passado na URL
* @return OK se atualizado com sucesso
*/
@PutMapping("/{id}")
public ResponseEntity update(@RequestBody ClienteDTO clienteDTO,
@PathVariable("id") Long id) {
Endereco endereco = enderecoService.findById(clienteDTO.getIdEndereco());
if (endereco == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Não foi encontrado o endereco de id " + clienteDTO.getIdEndereco());
}
Cliente cliente = service.findById(id);
if (cliente == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Não foi encontrado o cliente de id " + id);
}
// atualiza informações
cliente.setCpf(clienteDTO.getCpf());
// atualiza no banco
service.update(cliente);
// atualiza o endereço
endereco.setCliente(cliente);
enderecoService.update(endereco);
return ResponseEntity.status(HttpStatus.OK).build();
}
Não será detalhado o codigo para o delete nem para o update do endereço, mas seguem a mesma lógica acima. (Lembre-se o delete busca pelo id na URL)
Entidade Plano
A entidade plano possui uma api ligeiramente diferente das vistas até agora pois:
Um plano só existe com um cliente associado
Podemos considerar que um plano não pode ser deletado pois ele possui créditos que seriam perdidos
Trocar o assinate do plano NÃO deve ser possível, então atualizar o plano também não faz sentido.
O plano pode ser recarregado (um de nossos requisitos)
Com base nesses requisitos podemos modelar a API do plano da seguinte forma:
Criar um plano novo com uma carga inicial:
Recarrega um plano adicionando-se um valor ao mesmo:
Obtem informações de um plano de um cliente. O id passado no final da URL é o id do cliente:
Com base nas informações já passadas é possível criar essas 3 API
Notas:
Ao criar um novo plano valide se já não existe um para o cliente, caso contrario vai atualizar o valor e o cliente perderá créditos.
O mesmo DTO para recarga pode ser usado para criação de novo plano
Você não precisa atualizar o cliente com o plano igual feito ao salvar um endereço no cliente, pois está alterando o plano que possui a foreing key para o cliente.
Nas 3 API você precisará de um método no repository para retornar o plano dado o id do cliente. O método que faz essa query está abaixo em duas opções:
public interface PlanoRepository extends JpaRepository<Plano, Long> {
Plano findByAssinante(Cliente assinante);
@Query("select c from Plano c join c.assinante a where a.id = :id")
Plano findByAssinanteId(@Param("id") Long id);
}
Na primeira opção se passa o cliente como parametro e o spring vai criar a query com base no nome, ou seja um where com filtro by Assinante sendo Assinante o nome da propriedade na entidade.
Na segunda opção ao invez de passar o cliente completo, passamos o id e criamos uma query customizada com a anotação @Query
que filtra os planos cujo assinante tem o id passado como parametro. O parametro é o :id
que foi explicitado pela anotação @Param
Importante: Se você retornar a entidade Plano diretamente no controlador vai ter o seguinte erro:
java.lang.StackOverflowError: null
at java.base/java.lang.ClassLoader.defineClass1(Native Method) ~[na:na]
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017) ~[na:na]
at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:174) ~[na:na]
at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:800) ~[na:na]
at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:698) ~[na:na]
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:621) ~[na:na]
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:579) ~[na:na]
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178) ~[na:na]
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522) ~[na:na]
at com.fasterxml.jackson.databind.JsonMappingException.prependPath(JsonMappingException.java:445) ~[jackson-databind-2.13.4.2.jar:2.13.4.2]
Isso acontece porque repare que o Plano possui um Cliente, e um Cliente possui um plano (bidirecional). O Jackson (biblioteca JSON) vai tentar carregar o cliente do plano de id 1 por exemplo, quando ele carregar o cliente pelo hibernate, ele vai ver que tem um plano dentro do cliente e vai tentar carregá-lo novamente, assim achando o mesmo plano que por sua vez carrega o cliente novamente, e assim sucessivamente até a memória estourar.
Para solucionar o problema a melhor maneira é retornar um DTO com os dados. Exemplo:
public PlanoDTO findByClienteId(@PathVariable Long id) {
Plano plano = planoRepository.findByAssinanteId(id);
if (plano == null) {
return null;
}
PlanoDTO dto = new PlanoDTO();
dto.setValor(plano.getValor());
ClienteDTO clienteDTO = new ClienteDTO();
clienteDTO.setCpf(plano.getAssinante().getCpf());
clienteDTO.setIdEndereco(plano.getAssinante().getEndereco().getId());
dto.setCliente(clienteDTO);
return dto;
}
Repare que retornamos a seguinte informação:
{
"valor": 10.0,
"cliente": {
"cpf": "011100003",
"idEndereco": 1
}
}
Mas a pesquisa foi no banco 3 vezes:
Hibernate: select plano0_.id as id1_2_, plano0_.cliente_id as cliente_3_2_, plano0_.valor as valor2_2_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id where cliente1_.id=?
Hibernate: select cliente0_.id as id1_0_0_, cliente0_.cpf as cpf2_0_0_, endereco1_.id as id1_1_1_, endereco1_.bairro as bairro2_1_1_, endereco1_.cep as cep3_1_1_, endereco1_.cidade as cidade4_1_1_, endereco1_.cliente_endereco as cliente_8_1_1_, endereco1_.estado as estado5_1_1_, endereco1_.numero as numero6_1_1_, endereco1_.rua as rua7_1_1_, plano2_.id as id1_2_2_, plano2_.cliente_id as cliente_3_2_2_, plano2_.valor as valor2_2_2_ from cliente cliente0_ left outer join endereco endereco1_ on cliente0_.id=endereco1_.cliente_endereco left outer join plano plano2_ on cliente0_.id=plano2_.cliente_id where cliente0_.id=?
Hibernate: select plano0_.id as id1_2_2_, plano0_.cliente_id as cliente_3_2_2_, plano0_.valor as valor2_2_2_, cliente1_.id as id1_0_0_, cliente1_.cpf as cpf2_0_0_, endereco2_.id as id1_1_1_, endereco2_.bairro as bairro2_1_1_, endereco2_.cep as cep3_1_1_, endereco2_.cidade as cidade4_1_1_, endereco2_.cliente_endereco as cliente_8_1_1_, endereco2_.estado as estado5_1_1_, endereco2_.numero as numero6_1_1_, endereco2_.rua as rua7_1_1_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id left outer join endereco endereco2_ on cliente1_.id=endereco2_.cliente_endereco where plano0_.cliente_id=?
Isso acontece porque pesquisamos a entidade Plano diretamente, e as associações Cliente e Endereço são pesquisadas juntamente, pois esse é o padrão do @OneToOne
Se quisermos otimizar essa pesquisa podemos fazer o seguinte:
public interface PlanoRepository extends JpaRepository<Plano, Long> {
Plano findByAssinante(Cliente cliente);
@Query("select c from Plano c join c.assinante a where a.id = :id")
Plano findByAssinanteId(@Param("id") Long id);
@Query("select new br.org.fundatec.tfinal.tfinal.dto.PlanoDTO(c.valor, a.cpf, a.endereco.id) " +
"from Plano c join c.assinante a where a.id = :id")
PlanoDTO findPlanoDTOByAssinanteId(@Param("id") Long id);
}
Reparem no seguinte: A query retorna um new criando um dto e usando o construtor do mesmo. Dessa forma o select sabe o que seleciona e cria uma query especifica para ele.
No caso um select apenas é feito no banco de dados:
Hibernate: select plano0_.valor as col_0_0_, cliente1_.cpf as col_1_0_, endereco2_.id as col_2_0_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id cross join endereco endereco2_ where cliente1_.id=endereco2_.cliente_endereco and cliente1_.id=?
Isso é muito mais performático.
Restante das classes:
public class PlanoDTO {
private Double valor;
private ClienteDTO cliente;
public PlanoDTO() {
}
public PlanoDTO(Double valor, String clientCPF, Long clienteEnderecoId) {
this.valor = valor;
this.cliente = new ClienteDTO(clientCPF, clienteEnderecoId);
}
public Double getValor() {
return valor;
}
public void setValor(Double valor) {
this.valor = valor;
}
public ClienteDTO getCliente() {
return cliente;
}
public void setCliente(ClienteDTO cliente) {
this.cliente = cliente;
}
}
public PlanoDTO findByClienteId(@PathVariable Long id) {
// Plano plano = planoRepository.findByAssinanteId(id);
// if (plano == null) {
// return null;
// }
// PlanoDTO dto = new PlanoDTO();
// dto.setValor(plano.getValor());
// ClienteDTO clienteDTO = new ClienteDTO();
// clienteDTO.setCpf(plano.getAssinante().getCpf());
// clienteDTO.setIdEndereco(plano.getAssinante().getEndereco().getId());
// dto.setCliente(clienteDTO);
return planoRepository.findPlanoDTOByAssinanteId(id);
}
Com isso nós temos 4 requisitos do trabalho modelados:
O sistema deve permitir o cadastro, alteração e consultas nos dados de um assinante;
O cliente pode possuir apenas um plano em vigor
O cliente deve possuir um endereço
O sistema deve permitir que o assinante faça recarga do valor do seu crédito;
O requisito: "O endereço da pessoa deve estar completo". É fácil de fazer basta colocar todos os dados de endereço com exceção de complemento como obrigatórios. Isso pode ser feito no banco de dados e na validação do controlador
A api do trabalho final está no git repository
Num próximo artigo vamos modelar as outras entidades e falar de outros requisitos da aplicação