Skip to content

4. Ações: o modelo

Voltemos à arquitetura de uma aplicação Spring MVC:

No capítulo anterior, analisámos o processo que leva a solicitação [1] ao controlador e à ação [2a] que a irão processar, um mecanismo a que chamamos de encaminhamento. Apresentámos também as diferentes respostas que uma ação pode enviar ao navegador. Até ao momento, apresentámos ações que não exploravam a solicitação que lhes era apresentada. Uma solicitação [1] transporta consigo diversas informações que o Spring MVC apresenta à ação sob a forma de um modelo. Não se deve confundir este termo com o modelo M de uma vista V [2c] que é produzida pela ação:

  • a solicitação HTTP do cliente chega a [1];
  • em [2], as informações contidas na solicitação serão transformadas no modelo de ação [3] — frequentemente, mas não necessariamente, uma classe — que servirá de entrada para a ação [4];
  • em [4], a ação, a partir deste modelo, irá gerar uma resposta. Esta terá duas componentes: uma vista V [6] e o modelo M dessa vista [5];
  • a vista V [6] utilizará o seu modelo M [5] para gerar a resposta HTTP destinada ao cliente.

No modelo MVC, a ação [4] faz parte do C (controlador), o modelo da vista [5] é o M e a vista [6] é o V.

Este capítulo analisa os mecanismos de ligação entre as informações transportadas pela solicitação, que são, por natureza, cadeias de caracteres, e o modelo da ação, que pode ser uma classe com propriedades de vários tipos.

Nota: o termo [Modèle d'action] não é um termo reconhecido.

Criamos um novo controlador para estas novas ações:

  

O controlador [ActionModelController] será, por enquanto, o seguinte:


package istia.st.springmvc.controllers;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class ActionModelController {

}
  • linha 5: recorde-se que a anotação [@RestController] faz com que a resposta enviada ao cliente seja a serialização, sob a forma de cadeia de caracteres, do resultado das ações do controlador;

4.1. [/m01]: parâmetros de um GET

Adicionamos a seguinte ação [/m01]:



    // ----------------------- recuperar parâmetros com GET------------------------
    @RequestMapping(value = "/m01", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m01(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • linha 4: a ação aceita dois parâmetros denominados [nom] e [age]. Estes serão inicializados com parâmetros com os mesmos nomes na solicitação HTTP GET;

Os resultados no Chrome para [1-3] são os seguintes:

  • em [1], a consulta GET com os parâmetros [nom] e [age];
  • em [3], verifica-se que a ação [/m01] recuperou efetivamente esses parâmetros;

4.2. [/m02]: parâmetros de um POST

Adicionamos a seguinte ação [/m02]:



    // ----------------------- recuperar parâmetros com POST------------------------
    @RequestMapping(value = "/m02", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m02(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • linha 4: a ação aceita dois parâmetros denominados [nom] e [age]. Estes serão inicializados com parâmetros com os mesmos nomes na consulta HTTP POST;

Os resultados com [Advanced rest Client] são os seguintes:

  • em [1-3], a consulta POST com os parâmetros [nom] e [age];
  • em [4-5], define-se o cabeçalho HTTP [Content-Type] da consulta POST. Deve ser [Content-Type: application/x-www-form-urlencoded];
  • em [6], [Form Data] apresenta a lista de parâmetros de uma operação POST. Aqui vemos os parâmetros [nom] e [age];
  • em [7], a resposta do servidor que mostra que a ação [/m02] recuperou corretamente os parâmetros [nom] e [age]; ;

4.3. [/m03]: parâmetros com os mesmos nomes

Vimos no parágrafo 2.5.2.8 que a lista de seleção múltipla podia enviar ao servidor parâmetros com os mesmos nomes. Vejamos como uma ação pode recuperá-los. Adicionamos a seguinte ação [/m03]:


    // ----------------------- recuperar parâmetros com os mesmos nomes-----------------
    @RequestMapping(value = "/m03", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m03(String nom[]) {
        return String.format("Hello [%s]!, Greetings from Spring Boot!", String.join("-", nom));
}
  • linha 2: a ação aceita um parâmetro denominado [nome[]]. Este será inicializado aqui com todos os parâmetros com esse nome, quer se trate de um GET ou de um POST, uma vez que, neste caso, o tipo do pedido não foi especificado;

Os resultados são os seguintes:

  • através de um POST [1], enviam-se os parâmetros [2];
  • também se inserem parâmetros no URL [3];
  • no [4], os quatro parâmetros com o mesmo nome [nom]: [Query String parameters] são os parâmetros do URL, [Form Data] são os parâmetros enviados;
  • em [5], verifica-se que a ação [/m03] recuperou os quatro parâmetros denominados [nom];

4.4. [/m04]: mapear os parâmetros da ação para um objeto Java

Considere a seguinte nova ação [/m04]:


    // ------ mapear os parâmetros num objeto (Command Object) ---------------
    @RequestMapping(value = "/m04", method = RequestMethod.POST)
    public Personne m04(Personne personne) {
        return person;
}
  • linha 3: a ação tem como parâmetro uma pessoa do seguinte tipo:

public class Personne {

    // identificador
    private Integer id;
    // nome
    private String nom;
    // idade
    private int age;
....
    // getters e setters
...
}
  • para criar o parâmetro [Personne personne], o Spring MVC cria um [new Personne()];
  • depois, se existirem parâmetros com os nomes dos campos [id, nom, age] do objeto criado, instancia-o com os campos através dos respetivos setters;
  • linha 4: a ação devolve um tipo [Personne], que será, portanto, serializado como uma cadeia de caracteres antes de ser enviado ao cliente. Vimos que, por predefinição, a serialização efetuada era uma serialização jSON. O cliente deverá, portanto, receber a cadeia jSON de uma pessoa;

Eis um exemplo:

  • em [1], os parâmetros [id, nom, age] para construir um objeto [Personne];
  • em [2], a cadeia jSON dessa pessoa;

O que acontece se não enviarmos todos os campos de uma pessoa? Vamos experimentar:

  • em [2], apenas o parâmetro [id] foi inicializado;

4.5. [/m05]: recuperar os elementos de um URL

Ou seja, a nova ação [/m05] seguinte:


    // ----------------------- recuperar os elementos do URL ------------------------
    @RequestMapping(value = "/m05/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, String> m05(@PathVariable("a") String a, @PathVariable("b") String b) {
        Map<String, String> map = new HashMap<String, String>();
        map.put("a", a);
        map.put("b", b);
        return map;
}
  • linha 2: o URL processado tem o formato [/m05/{a}/x/{b}], em que {param} é um elemento de parâmetro do URL;
  • linha 3: os elementos de parâmetro do URL são recuperados com a anotação [@PathVariable];
  • linhas 4-6: os elementos [a] e [b] recuperados são colocados num dicionário;
  • linha 7: a resposta será a cadeia jSON desse dicionário;

Os resultados são os seguintes:

 

4.6. [/m06]: recuperar elementos de URL e parâmetros

Ou seja, a nova ação [/m06] a seguir:


    // -------- recuperar elementos do URL e parâmetros---------------
    @RequestMapping(value = "/m06/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, Object> m06(@PathVariable("a") Integer a, @PathVariable("b") Double b, Double c) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("a", a);
        map.put("b", b);
        map.put("c", c);
        return map;
}
  • linha 3: recuperam-se simultaneamente elementos de URL e [Integer a, Double b], bem como um parâmetro (GET ou POST) de [Double c];
  • linhas 4-7: estes elementos são colocados num dicionário;
  • linha 8: que forma a resposta do cliente, que receberá, portanto, a cadeia jSON deste dicionário;

Eis os resultados:

 

Repare-se no / no final do caminho [http://localhost:8080/m06/100/x/200.43/]. Sem ele, obtém-se o seguinte resultado incorreto:

 

4.7. [/m07]: aceder à totalidade da consulta

Considere a seguinte nova ação [/m07]:


    // ------ aceder à consulta HttpServletRequest ------------------------
    @RequestMapping(value = "/m07", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m07(HttpServletRequest request) {
        // os cabeçalhos do HTTP
        Enumeration<String> headerNames = request.getHeaderNames();
        StringBuffer buffer = new StringBuffer();
        while (headerNames.hasMoreElements()) {
            String name = headerNames.nextElement();
            buffer.append(String.format("%s : %s\n", name, request.getHeader(name)));
        }
        return buffer.toString();
}
  • linha 3: solicita-se ao Spring MVC que injete o objeto [HttpServletRequest request], que encapsula todas as informações que se podem obter sobre a solicitação;
  • linhas 5-10: recuperam-se todos os cabeçalhos HTTP da solicitação para os reunir numa cadeia de caracteres que é enviada ao cliente (linha 11);

Os resultados são os seguintes:

  • em [1], os cabeçalhos HTTP da consulta;
  • em [2], a resposta. Aqui encontram-se, de facto, todos os cabeçalhos HTTP da solicitação.

4.8. [/m08]: acesso ao objeto [Writer]

Consideremos a seguinte ação:


    // ----------------------- injeção do writer ------------------------
    @RequestMapping(value = "/m08", method = RequestMethod.GET)
    public void m08(Writer writer) throws IOException {
        writer.write("Bonjour le monde !");
}
  • linha 3: o Spring MVC injeta o objeto [Writer writer], que permite escrever no fluxo da resposta ao cliente;
  • linha 3: a ação devolve um tipo [void], o que indica que deve construir ela própria a resposta para o cliente;
  • linha 4: adição de um texto no fluxo da resposta ao cliente;

Os resultados são os seguintes:

  • em [2], verifica-se que o cabeçalho HTTP [Content-Type] não foi enviado;
  • em [3], a resposta;

4.9. [/m09]: aceder a um cabeçalho HTTP

Consideremos a seguinte ação:


    // ----------------------- injeção de RequestHeader ------------------------
    @RequestMapping(value = "/m09", method = RequestMethod.GET)
    public String m09(@RequestHeader("User-Agent") String userAgent) {
        return userAgent;
}
  • linha 3: a anotação [@RequestHeader("User-Agent")] permite recuperar o cabeçalho HTTP [User-Agent];
  • linha 4: apresenta-se o texto deste cabeçalho;

Os resultados são os seguintes:

  • em [2], o cabeçalho HTTP [User-Agent];
  • em [3], a ação [/m08] recuperou corretamente este cabeçalho;

Um cookie é, geralmente, um cabeçalho HTTP que o:

  • servidor envia pela primeira vez ao cliente;
  • o cliente reenvia, em seguida, sistematicamente ao servidor;

Vamos, em primeiro lugar, criar uma ação que crie o cookie:


    // ----------------------- criação de cookie ------------------------
    @RequestMapping(value = "/m10", method = RequestMethod.GET)
    public void m10(HttpServletResponse response) {
        response.addCookie(new Cookie("cookie1", "remember me"));
}
  • linha 3: inserimos o objeto [HttpServletResponse response] para termos controlo total sobre a resposta;
  • linha 4: criamos um cookie com a chave [cookie1] e o valor [remember me] (Nota: os caracteres acentuados no valor de um cookie provocam erros);
  • linha 3: a ação não devolve qualquer resultado. Além disso, não escreve nada no corpo da resposta. Por conseguinte, o cliente irá receber um documento vazio. A resposta é utilizada apenas para adicionar o cabeçalho HTTP de um cookie;

Vejamos os resultados:

  • em [1]: o pedido;
  • em [2]: a resposta está vazia;
  • em [3]: o cookie criado pela ação;

Agora, vamos criar uma ação para recuperar este cookie que o navegador passará a enviar em cada pedido:


    // ----------------------- injeção de cookie ------------------------
    @RequestMapping(value = "/m11", method = RequestMethod.GET)
    public String m10(@CookieValue("cookie1") String cookie1) {
        return cookie1;
}
  • linha 3: a anotação [@CookieValue("cookie1")] permite recuperar o cookie com a chave [cookie1];
  • linha 4: este valor será a resposta enviada ao cliente;

Vejamos os resultados:

  • em [2], vemos que o navegador devolve o cookie;
  • em [3], a ação recuperou-o corretamente;

4.11. [/m12]: aceder ao corpo de um POST

Os parâmetros enviados são normalmente acompanhados pelo cabeçalho HTTP [Content-Type: application/x-www-form-urlencoded]. É possível aceder à totalidade da cadeia enviada. Criamos a seguinte ação:


    // ----------- recuperar o corpo de um POST do tipo String------------------------
    @RequestMapping(value = "/m12", method = RequestMethod.POST)
    public String m12(@RequestBody String requestBody) {
        return requestBody;
}
  • linha 3: a anotação [@RequestBody] permite recuperar o corpo do POST. Aqui, partimos do princípio de que este é do tipo [String];
  • linha 4: este corpo é devolvido ao cliente;

Eis um primeiro exemplo:

  • em [2], os valores lançados;
  • em [3], o cabeçalho HTTP [Content-Type] da solicitação;
  • em [4], a resposta do servidor;

Os parâmetros enviados nem sempre têm o formato simples [p1=v1&p2=v2] que temos vindo a utilizar frequentemente até agora. Vejamos um caso mais complexo:

  • em [2-3]: introduzimos os valores enviados no formato [clé:value];
  • em [5], a cadeia que foi enviada;

Com o tipo [Content-Type: application/x-www-form-urlencoded], a cadeia a enviar deve ter o formato [p1=v1&p2=v2]. Se se quiser enviar qualquer coisa, deve-se utilizar o tipo [Content-Type: text/plain]. Eis um exemplo:

  • em [2-3], cria-se o cabeçalho HTTP [Content-Type]. Por predefinição, [5] é o que será utilizado em vez do definido em [6]. O atributo [charset=utf-8] é importante. Sem ele, perdem-se os caracteres acentuados da cadeia de caracteres enviada;
  • em [4], a cadeia enviada é recuperada corretamente em [7];

4.12. [/m13, /m14]: recuperar valores enviados em jSON

É possível enviar parâmetros com o cabeçalho HTTP [Content-Type: application/json]. Criamos a seguinte ação:


    // ----------------------- recuperar o corpo jSON de um POST
    @RequestMapping(value = "/m13", method = RequestMethod.POST, consumes = "application/json")
    public String m13(@RequestBody Personne personne) {
        return personne.toString();
}
  • linha 2: [consumes = "application/json"] especifica que a ação espera um corpo jSON;
  • linha 3: [@RequestBody] representa esse corpo. Esta anotação foi associada a um objeto do tipo [Personne]. O corpo jSON será automaticamente deserializado neste objeto;
  • linha 4: utiliza-se o método [Personne].toString() para devolver algo que não seja a cadeia jSON enviada;

Eis um exemplo:

  • em [2], a cadeia jSON enviada;
  • em [3], o [Content-Type] da solicitação;
  • em [4], a resposta do servidor;

É possível fazer o mesmo de outra forma:


    // ----------------------- recuperar o corpo jSON de um POST 2 -------------------
    @RequestMapping(value = "/m14", method = RequestMethod.POST, consumes = "text/plain")
    public String m14(@RequestBody String requestBody) throws JsonParseException, JsonMappingException, IOException {
        Personne personne = new ObjectMapper().readValue(requestBody, Personne.class);
        return personne.toString();
}
  • linha 2: indicou-se que o método esperava um fluxo do tipo [text/plain]. O Spring MVC tratará então o corpo da solicitação como um tipo [String] (linha 3);
  • linha 4: a cadeia jSON é deserializada num objeto [Personne] (ver parágrafo 9.7, página 542);

Os resultados são os seguintes:

  • em [3], deve-se colocar corretamente [text/plain];

4.13. [/m15]: recuperar a sessão

Voltemos à arquitetura de execução de uma ação:

A classe do controlador é instanciada no início do pedido do cliente e destruída no final do mesmo. Por isso, não pode ser utilizada para armazenar dados entre dois pedidos, mesmo que seja chamada repetidamente. Podemos querer armazenar dois tipos de dados:

  • dados partilhados por todos os utilizadores da aplicação web. Trata-se, geralmente, de dados de leitura única;
  • dados partilhados pelas solicitações de um mesmo cliente. Estes dados são armazenados num objeto denominado «Sessão». Fala-se então de «sessão do cliente» para designar a memória do cliente. Todas as solicitações de um cliente têm acesso a esta sessão. Podem armazenar e ler informações nessa sessão.

Acima, mostramos os tipos de memória aos quais uma ação tem acesso:

  • a memória da aplicação, que na maioria das vezes contém dados de leitura única e que é acessível a todos os utilizadores;
  • a memória de um utilizador específico, ou sessão, que contém dados de leitura/gravação e que está acessível às solicitações sucessivas do mesmo utilizador;
  • não representada acima, existe uma memória de pedido, ou contexto de pedido. O pedido de um utilizador pode ser processado por várias ações sucessivas. O contexto do pedido permite que uma ação 1 transmita informação a uma ação 2.

Vejamos um primeiro exemplo que ilustra estas diferentes memórias:


    // ----------------------- recuperar a sessão ------------------------
    @RequestMapping(value = "/m15", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m15(HttpSession session) {
        // recuperamos o objeto-chave [compteur] na sessão
        Object objCompteur = session.getAttribute("compteur");
        // converte-se num inteiro para o incrementar
        int iCompteur = objCompteur == null ? 0 : (Integer) objCompteur;
        iCompteur++;
        // coloca-se de volta na sessão
        session.setAttribute("compteur", iCompteur);
        // retorna-se como resultado da ação
        return String.valueOf(iCompteur);
}

O Spring MVC mantém a sessão do utilizador num objeto do tipo [HttpSession].

  • linha 3: solicita-se ao Spring MVC que injete o objeto [HttpSession] nos parâmetros da ação;
  • linha 5: recupera-se, a partir deste, um atributo denominado [compteur]. Uma sessão comporta-se como um dicionário, um conjunto de pares [clé, valeur]. Se a chave [compteur] não existir na sessão, recupera-se um ponteiro null;
  • linha 7: o valor associado à chave [compteur] será do tipo [Integer];
  • linha 8: incremento do contador;
  • linha 10: atualização do contador na sessão;
  • linha 12: o valor do contador é enviado ao cliente;

Quando [/m15] for executada pela:

  • primeira vez, na linha 12, o contador terá o valor 1;
  • pela segunda vez, na linha 5, esse valor 1 será recuperado e alterado para 2;
  • ...

Eis um exemplo de execução:

  • em [1], obtém-se efetivamente o primeiro valor do contador;
  • em [2], o servidor enviou um cookie de sessão. Este tem a chave [JSESSIONID] e, como valor, uma cadeia de caracteres única para cada utilizador. Recorde-se que o navegador reenvia sistematicamente os cookies que recebe. Assim, quando solicitarmos a ação [/m15] uma segunda vez, o cliente reenviará esse cookie, o que permitirá ao servidor reconhecê-lo e associá-lo à sua sessão. É desta forma que a memória do utilizador é mantida;

Vejamos o segundo pedido:

  • em [3], vemos que o cliente reenvia o cookie de sessão. Podemos observar que, na resposta do servidor, já não existe esse cookie de sessão. Agora é o cliente que o envia para ser reconhecido;
  • em [4], o segundo valor do contador. Este foi, de facto, incrementado;

4.14. [/m16]: recuperar um objeto do escopo [session]

Podemos querer colocar todos os dados da sessão de um utilizador num único objeto e colocar apenas esse objeto na sessão. Seguimos esta abordagem. Colocamos o contador no seguinte objeto [SessionModel]:

  

package istia.st.sprinmvc.models;

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionModel {

    private int compteur;

    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }

}
  • linha 7: a anotação [@Component] é uma anotação Spring (linha 5) que transforma a classe [SessionModel] num componente cujo ciclo de vida é gerido pelo Spring;
  • linha 8: a anotação [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] é também uma anotação Spring (linhas 3-4). Quando o Spring MVC a encontra, a classe correspondente é criada e colocada na sessão do utilizador. O atributo [proxyMode = ScopedProxyMode.TARGET_CLASS] é importante. É graças a ele que o Spring MVC cria uma instância por utilizador e não uma única instância para todos os utilizadores (singleton);
  • linha 11: o contador;

Para que este novo componente Spring seja reconhecido, é necessário verificar a configuração da aplicação na classe [Application]:


package istia.st.springmvc.main;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan({"istia.st.springmvc.controllers"})
@EnableAutoConfiguration
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • linha 9: os componentes Spring são procurados no pacote [istia.st.springmvc.controllers]. Isto já não é suficiente. Alteramos esta linha da seguinte forma:

@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })

Adicionámos o pacote onde se encontra a classe [SessionModel].

Agora, adicionamos a seguinte ação:


    @Autowired
    private SessionModel session;
    
    // ------ gerir um objeto de âmbito (scope) da sessão [Autowired] -----------
    @RequestMapping(value = "/m16", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m16() {
        session.setCompteur(session.getCompteur() + 1);
        return String.valueOf(session.getCompteur());
}
  • linhas 1-2: o componente Spring [SessionModel] é injetado como [@Autowired] no controlador. Recorde-se aqui que um controlador Spring é um singleton. É, portanto, paradoxal injetar nele um componente de âmbito inferior, neste caso com âmbito [Session]. É aqui que entra a anotação [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] do componente [SessionModel]. Sempre que o código do controlador acede ao campo [session] da linha 2, é executado um método proxy para disponibilizar a sessão do pedido atualmente a ser processado pelo controlador;
  • linha 6: já não é necessário o objeto [HttpSession] nos parâmetros da ação;
  • linha 7: recupera-se/incrementa-se o contador;
  • linha 8: devolve-se o seu valor;

Eis um exemplo de execução:

Na primeira vez

Na segunda vez

Agora, vamos utilizar outro navegador que representará um segundo utilizador. Neste caso, vamos utilizar o navegador Opera:

Acima, em [1], este segundo utilizador obtém um valor de contador igual a 1. O que demonstra que a sua sessão e a do primeiro utilizador são diferentes. Se observarmos as trocas cliente/servidor (Ctrl-Shift-I também no Opera), vemos em [2] que este segundo utilizador tem um cookie de sessão diferente do do primeiro utilizador. É isto que garante a independência das sessões.

4.15. [/m17]: recuperar um objeto de âmbito [application]

Voltemos à arquitetura de execução de uma ação:

Sabemos como construir a sessão do utilizador. Vamos agora construir um objeto de âmbito [application] cujo conteúdo será de leitura única e acessível a todos os utilizadores. Introduzimos a classe [ApplicationModel], que será o objeto de âmbito [application]:

 

package istia.st.springmvc.models;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Component;

@Component
public class ApplicationModel {

    // contador
    private AtomicLong compteur = new AtomicLong(0);

    // getters e setters
    public AtomicLong getCompteur() {
        return compteur;
    }

    public void setCompteur(AtomicLong compteur) {
        this.compteur = compteur;
    }

}
  • linha 5: a anotação [@Component] faz com que a classe [ApplicationModel] seja um componente gerido pelo Spring. A natureza por predefinição dos componentes Spring é do tipo [singleton]: o componente é criado numa única instância quando o contentor Spring é instanciado, ou seja, geralmente no arranque da aplicação. Podemos utilizar este ciclo de vida para armazenar no singleton informações de configuração que estarão acessíveis a todos os utilizadores;
  • linha 11: um contador do tipo [AtomicLong]. Este tipo possui um método [incrementAndGet] denominado «atómico». Isto significa que um thread que execute este método tem a garantia de que outro thread não irá ler o valor do contador (Get) entre a sua leitura (Get) e o seu incremento (increment) pelo primeiro thread, o que provocaria erros, uma vez que dois threads iriam ler o mesmo valor do contador e este, em vez de ser incrementado em dois, seria incrementado em um;

Criamos a seguinte nova ação [/m17]:


@Autowired
    private ApplicationModel application;

    // ----- gerir um objeto com âmbito de aplicação [Autowired] ------------------------
    @RequestMapping(value = "/m17", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m17() {
        return String.valueOf(application.getCompteur().incrementAndGet());
    }
  • linhas 1-2: inserimos o componente [ApplicationModel] no controlador. Trata-se de um singleton. Assim, cada utilizador terá uma referência ao mesmo objeto;
  • linha 7: devolvemos o contador de âmbito [application] após o termos incrementado;

Eis dois exemplos, um com o Chrome e outro com o Opera:

Acima, vemos que os dois navegadores trabalharam com o mesmo contador, o que não era o caso com a sessão. Estes dois navegadores representam dois utilizadores diferentes, ambos com acesso aos dados do escopo [application]. De um modo geral, deve-se evitar colocar nos objetos de âmbito [application] informações de leitura/gravação, tal como foi feito acima com o contador. Com efeito, os threads de execução dos diferentes utilizadores acedem simultaneamente aos dados do âmbito [application]. Se houver informações de escrita, é necessário sincronizar os acessos de escrita, tal como foi feito acima com o tipo [AtomicLong]. Os acessos concorrentes são fonte de erros de programação. Por isso, é preferível colocar apenas informações de leitura nos objetos de âmbito [application].

4.16. [/m18]: recuperar um objeto de âmbito [session] com [@SessionAttributes]

Existe outra forma de recuperar informações do escopo [session]. Vamos colocar o seguinte objeto na sessão:


package istia.st.springmvc.models;

public class Container {
    // o contador
    public int compteur=10;

    // os getters e setters
    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }
}

Vamos utilizar este objeto com as duas ações seguintes:


    // utilização de [@SessionAttribute] ----------------------
    @RequestMapping(value = "/m18", method = RequestMethod.GET)
    public void m18(HttpSession session) {
        // aqui insere-se a chave [container] na sessão
        session.setAttribute("container", new Container());
    }

    // utilização de [@ModelAttribute] ----------------------
    // a chave [container] da sessão será inserida aqui
    @RequestMapping(value = "/m19", method = RequestMethod.GET)
    public String m19(@ModelAttribute("container") Container container) {
        container.setCompteur(1 + container.getCompteur());
        return String.valueOf(container.getCompteur());
    }
  • linhas 3-6: a ação [/m18] não produz qualquer resultado. Serve apenas para criar um objeto na sessão com a chave [container];
  • linha 11: na ação [/m19], utiliza-se a anotação [@ModelAttribute]. O comportamento desta anotação é bastante complexo. O parâmetro [container] desta anotação pode designar várias coisas e, em particular, um objeto da sessão. Para tal, é necessário que este tenha sido declarado com uma anotação [@SessionAttributes] na própria classe:

@RestController
@SessionAttributes({"container"})
public class ActionModelController {
  • a linha 2 acima indica que a chave [container] faz parte dos atributos da sessão;

Resumindo:

  • em [/m18], a chave [container] é colocada na sessão;
  • a anotação [@SessionAttributes({"container"})] faz com que esta chave possa ser inserida num parâmetro anotado com [@ModelAttribute("container")];
  • embora não seja visível no exemplo de execução que se segue, uma informação anotada com [@ModelAttribute] passa automaticamente a fazer parte do modelo M transmitido à vista V;

Eis um exemplo de execução. Em primeiro lugar, insere-se a chave [container] na sessão com a ação [/m18] [1]. Em seguida, chamamos duas vezes a ação [/m19] para ver o contador a aumentar.

4.17. [/m20-/m23]: inserção de informações com [@ModelAttribute]

Consideremos a seguinte nova ação:


    // o atributo p fará parte de todos os modelos de visualização [Model] ----------------
    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
    }

    // ---------------instanciação de @ModelAttribute --------------------------
    // será injetado se estiver na sessão
    // será injetado se o controlador tiver definido um método para este atributo
    // pode provir dos campos do URL, caso exista um conversor de String para o tipo do atributo
    // caso contrário, é construído com o construtor por predefinição
    // em seguida, os atributos do modelo são inicializados com os parâmetros do GET ou do POST
    // o resultado final fará parte do modelo produzido pela ação
    
    // o atributo p é inserido nos argumentos------------------------
    @RequestMapping(value = "/m20", method = RequestMethod.GET)
    public Personne m20(@ModelAttribute("p") Personne personne) {
        return personne;
}
  • linhas 2-5: definem um atributo de modelo denominado [p]. Trata-se do modelo M de uma vista V, modelo representado por um tipo [Model] no Spring MVC. Um modelo comporta-se como um dicionário de pares [clé, valeur]. Aqui, a chave [p] está associada ao objeto [Personne] construído pelo método [getPersonne]. O nome do método pode ser qualquer um;
  • linha 17: o atributo de modelo da chave [p] é injetado nos parâmetros da ação. Esta injeção é feita de acordo com as regras das linhas 8 a 12. Neste caso, estamos perante a situação definida na linha 9. Assim, na linha 17, o parâmetro [Personne personne] será o objeto [Personne(7,'abcd',14)];
  • linha 18: devolvemos o objeto [personne] para verificação. Este será serializado como jSON antes de ser enviado ao cliente.

Eis um exemplo:

 

Agora, analisemos a seguinte ação:


    // --------- o atributo p passa automaticamente a fazer parte do modelo M da vista V
    @RequestMapping(value = "/m21", method = RequestMethod.GET)
    public String m21(Model model) {
        return model.toString();
}

Uma ação que pretende apresentar uma vista V tem de construir o modelo M dessa vista. O Spring MVC gere este modelo com um tipo [Model], que pode ser injetado nos parâmetros da ação. Inicialmente, este modelo está vazio ou contém as informações marcadas com a anotação [@ModelAttribute]. A ação pode ou não enriquecer este modelo antes de o transmitir a uma vista.

  • linha 3: injeção do modelo M;
  • linha 4: queremos ver o que está lá dentro. Serializamo-lo numa cadeia de caracteres para o enviar ao cliente. Aqui, será utilizado o método [Personne.toString]. Por isso, é necessário que ele exista;

Eis uma execução:

 

Acima, vemos que as instruções:


    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
}

criaram uma entrada [p, Personne(7,'abcd',14)] no modelo. É sempre assim.

Consideremos agora o seguinte caso:


    // caso contrário, é construído com o construtor por predefinição
    // em seguida, os atributos do modelo são inicializados com os parâmetros do GET ou do POST

com a seguinte ação:


    // --------- o atributo do modelo [param1] faz parte do modelo, mas não está inicializado
    @RequestMapping(value = "/m22", method = RequestMethod.GET)
    public String m22(@ModelAttribute("param1") String p1, Model model) {
        return model.toString();
}
  • linha 3: o atributo de modelo de chave [param1] não existe. Neste caso, o tipo associado deve ter um construtor por predefinição. É o que acontece aqui com o tipo [String], mas não é possível escrever [@ModelAttribute("param1") Integer p1], pois a classe [Integer] não possui um construtor por predefinição;
  • linha 4: devolve-se o modelo para verificar se o atributo de modelo de chave [param1] faz parte do mesmo;

Eis um exemplo de execução:

 

O atributo de modelo [param1] está efetivamente presente no modelo, mas o método [toString] do valor associado não fornece qualquer indicação sobre esse valor.

Consideremos agora a seguinte ação, em que inserimos explicitamente uma informação no modelo:


    // --------- o atributo do modelo [param2] é explicitamente incluído no modelo
    @RequestMapping(value = "/m23", method = RequestMethod.GET)
    public String m23(String p2, Model model) {
        model.addAttribute("param2",p2);
        return model.toString();
}
  • linha 4: o valor [p2] recuperado na linha 3 é inserido no modelo, associado à chave [param2]:

Eis um exemplo de execução:

 

As regras alteram-se se o parâmetro da ação for um objeto. Eis um primeiro exemplo:


    // ------ o atributo do modelo [unePersonne] é inserido automaticamente no modelo
    @RequestMapping(value = "/m23b", method = RequestMethod.GET)
    public String m23b(@ModelAttribute("unePersonne") Personne p1, Model model) {
        return model.toString();
}

A ação não altera o modelo que lhe foi fornecido. O resultado é o seguinte:

Verifica-se que a anotação [@ModelAttribute("unePersonne") Personne p1] inseriu a pessoa [p1] no modelo, associada à chave [unePersonne].

Consideremos agora a seguinte ação:


    // --------- a pessoa p1 é automaticamente inserida no modelo
    // -------- com a chave correspondente ao nome da sua classe, com o primeiro caractere em minúscula
    @RequestMapping(value = "/m23c", method = RequestMethod.GET)
    public String m23c(Personne p1, Model model) {
        return model.toString();
}
  • linha 4: não foi inserida a anotação [@ModelAttribute];

O resultado é o seguinte:

Verifica-se que a presença do parâmetro [Personne p1] inseriu a pessoa [p1] no modelo, associada à chave [personne], que é o nome da classe [Personne] com o primeiro carácter em minúscula.

4.18. [/m24]: validação do modelo da ação

Consideremos o seguinte modelo de ação [ActionModel01]:

 

package istia.st.springmvc.models;

import javax.validation.constraints.NotNull;

public class ActionModel01 {

    // dados
    @NotNull
    private Integer a;
    @NotNull
    private Double b;

    // getters e setters
...
    }
  • linhas 8 e 9: a anotação [@NotNull] é uma restrição de validação que indica que o dado anotado não pode ter o valor null;

Analisemos agora a seguinte ação:


    // ----------------------- validação de um modelo ------------------------
    @RequestMapping(value = "/m24", method = RequestMethod.GET)
    public Map<String, Object> m24(@Valid ActionModel01 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // erros?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            // percurso pela lista de erros
            for (FieldError error : result.getFieldErrors()) {
                buffer.append(String.format("[%s:%s:%s:%s:%s]", error.getField(), error.getRejectedValue(),
                        String.join(" - ", error.getCodes()), error.getCode(),error.getDefaultMessage()));
            }
            map.put("errors", buffer.toString());
        } else {
            // sem erros
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
}
  • linha 3: um objeto [ActionModel01] será instanciado e os seus campos [a, b] serão inicializados com parâmetros com os mesmos nomes. A anotação [@Valid] indica que as restrições de validade devem ser verificadas. Os resultados desta verificação serão colocados no parâmetro de tipo [BindingResult] (segundo parâmetro). Serão realizadas as seguintes verificações:
    • devido às anotações [@NotNull], os parâmetros [a] e [b] têm de estar presentes;
    • devido ao tipo [Integer a], o parâmetro [a], que por natureza é do tipo [String], deve ser convertível para o tipo [Integer];
    • devido ao tipo [Double b], o parâmetro [b], que por natureza é do tipo [String], deve ser convertível para o tipo [Double];

Com a anotação [@Valid], os erros de validação serão reportados no parâmetro [BindingResult result]. Sem a anotação [@Valid], os erros de validação provocam uma falha na ação e o servidor envia ao cliente uma resposta HTTP com o estado 500 (Erro interno do servidor).

  • linha 3: o resultado da ação é do tipo [Map]. Será a cadeia jSON deste resultado que será enviada ao cliente. Construem-se dois tipos de dicionário:
    • em caso de falha, um dicionário com uma entrada ['errors', value], em que [value] é uma cadeia de caracteres que descreve todos os erros (linha 13);
    • em caso de sucesso, um dicionário com uma entrada ['data',value], em que [value] é, por sua vez, um dicionário com duas entradas: ['a', value], ['b', value] (linha 19);
  • linhas 9-12: para cada erro [error] detetado, constrói-se a cadeia [error.getField(), error.getRejectedValue(), error.Codes, error.getDefaultMessage()]:
    • o primeiro elemento é o campo com erro, [a] ou [b],
    • o segundo elemento é o valor rejeitado, por exemplo, [x],
    • o terceiro elemento é uma lista de códigos de erro. Veremos as suas funções em breve;
    • o quarto elemento é o código do erro. Faz parte da lista anterior;
    • o último elemento é a mensagem de erro por predefinição. É possível, de facto, ter várias mensagens de erro;

Eis alguns exemplos de execução:

No exemplo acima, verifica-se que:

  • a atribuição de «x» ao campo [ActionModel01.a] falhou e a mensagem de erro indica o motivo;
  • a atribuição de «y» ao campo [ActionModel01.b] falhou e a mensagem de erro explica o motivo;

Repare-se nos códigos de erro no campo [a]: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Voltaremos a abordar estes códigos de erro quando for necessário personalizar a mensagem de erro. Note-se que o código do erro é [typeMismatch].

Outro exemplo:

Aqui, não foram passados os parâmetros [a] e [b]. Os validadores [@NotNull] do modelo de ação [ActionModel01] desempenharam então o seu papel;

Finalmente, valores corretos:

4.19. [m/24]: personalização das mensagens de erro

Voltemos a uma captura de ecrã do exemplo anterior:

Vemos acima as mensagens de erro predefinidas. É evidente que não podemos mantê-las numa aplicação real. É possível definir estas mensagens de erro. Para tal, vamos recorrer aos códigos de erro. Acima, vemos que o erro no campo [a] tem os seguintes códigos: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Estes códigos de erro vão do mais específico ao menos específico:

  • [typeMismatch.actionModel01.a]: erro de tipo no campo [a] do tipo [ActionModel01];
  • [typeMismatch.a]: erro de tipo num campo denominado [a];
  • [typeMismatch.java.lang.Integer]: erro de tipo num tipo Integer;
  • [typeMismatch]: erro de tipo;

Observa-se também que o código de erro no campo [a], obtido a partir de [error.getCode()], é [typeMismatch] (ver captura de ecrã acima).

Vamos colocar as mensagens de erro num ficheiro de propriedades:

  

O ficheiro [messages.properties] acima ficará da seguinte forma:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.model01.a=Le paramètre [a] doit être entier

Cada linha tem o seguinte formato:

    clé=message

Aqui, a chave será um código de erro e a mensagem será a mensagem de erro associada a esse código.

Recorde-se que os códigos de erro para os dois campos são:

  • [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch], quando o parâmetro [a] é inválido;
  • [typeMismatch.actionModel01.b - typeMismatch.b - typeMismatch.java.lang.Double - typeMismatch:typeMismatch ], quando o parâmetro [b] é inválido;
  • [NotNull.actionModel01.a - NotNull.a - NotNull.java.lang.Integer - NotNull] quando o parâmetro [a] está ausente;
  • [NotNull.actionModel01.b - NotNull.b - NotNull.java.lang.Double - NotNull] quando o parâmetro [b] estiver ausente;

O ficheiro [messages.properties] deve incluir uma mensagem de erro para todos os casos de erro possíveis. No caso de:

  • os parâmetros [a] e [b] estiverem ausentes, será utilizado o código [NotNull];
  • no caso de o parâmetro [a] estar incorreto, definimos mensagens para dois códigos [typeMismatch.actionModel01.a, typeMismatch]. Veremos qual deles é utilizado;
  • se o parâmetro [b] estiver incorreto, será utilizado o código [typeMismatch];

Para que o ficheiro [messages.properties] seja utilizado, é necessário configurar o Spring:

  

Removemos as anotações de configuração da classe [Application]:


package istia.st.springmvc.main;

import org.springframework.boot.SpringApplication;

public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Config.class, args);
    }
}
  • linha 8: a aplicação Spring Boot é iniciada. O primeiro parâmetro do método estático [SpringApplication.run] é a classe que passa a configurar a aplicação;

A classe [Config] é a seguinte:


package istia.st.springmvc.main;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }
}
  • linhas 11-13: encontram-se aqui as anotações de configuração que anteriormente estavam na classe [Application];
  • linha 14: para configurar uma aplicação Spring MVC, é necessário estender a classe [WebMvcConfigurerAdapter];
  • linha 15: a anotação [@Bean] introduz um componente Spring, um singleton;
  • linha 16: define-se um bean denominado [messageSource] (o nome do método). Este bean serve para definir os ficheiros de mensagens da aplicação e deve obrigatoriamente ter este nome;
  • linhas 17-19: indicam ao Spring que o ficheiro de mensagens:
    • se encontra na pasta [i18n] no Classpath do projeto (linha 18),
    • chama-se [messages.properties] (linha 18). Na verdade, o termo [messages] é a raiz dos nomes dos ficheiros de mensagens, e não o próprio nome. Veremos que, no âmbito da internacionalização, podem existir vários ficheiros de mensagens, um por cada cultura suportada. Assim, podemos ter [messages_fr.properties] para a língua francesa e [messages_en.properties] para a língua inglesa. Os sufixos adicionados à raiz [messages] são padronizados. Não se pode colocar qualquer coisa;

No projeto STS, é necessário colocar a pasta [i18n] na pasta de recursos, uma vez que esta é incluída no Classpath do projeto:

  

Para utilizar este ficheiro, criamos a seguinte nova ação:


// validação de um modelo, gestão de mensagens de erro ------------------------
    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
        // o dicionário de resultados
        Map<String, Object> map = new HashMap<String, Object>();
        // o contexto da aplicação Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // local
        Locale locale = RequestContextUtils.getLocale(request);
        // erros?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            for (FieldError error : result.getFieldErrors()) {
                // pesquisa da mensagem de erro com base nos códigos de erro
                // a mensagem é procurada nos ficheiros de mensagens
                // os códigos de erro apresentados numa tabela
                String[] codes = error.getCodes();
                // na forma de cadeia de caracteres
                String listCodes = String.join(" - ", codes);
                // Pesquisa
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // foi encontrada?
                if (msg == null) {
                    throw new Exception(String.format("Indiquez un message pour l'un des codes [%s]", listCodes));
                }
                // foi encontrado - adiciona-se a mensagem de erro à lista de mensagens de erro
                buffer.append(String.format("[%s:%s:%s:%s]", locale.toString(), error.getField(), error.getRejectedValue(),
                        String.join(" - ", msg)));
            }
            map.put("errors", buffer.toString());
        } else {
            // ok
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
    }

Este código é semelhante ao da ação [/m24]. Explicamos as diferenças:

  • linha 3: injetamos a solicitação [HttpServletRequest request] nos parâmetros da ação. Vamos precisar dela;
  • linhas 7-8: recuperamos o contexto do Spring. Este contexto contém todos os beans Spring da aplicação. Permite também aceder aos ficheiros de mensagens;
  • linha 10: recuperamos a localização da aplicação. Este termo é explicado mais adiante;
  • linhas 15-31: para cada erro, procuramos uma mensagem correspondente a um destes códigos de erro. Estes são pesquisados pela ordem dos códigos encontrados em [error.getCodes()]. Assim que uma mensagem for encontrada, paramos;
  • linha 26: como recuperar uma mensagem no [messages.properties]:
    • o primeiro parâmetro é o código procurado no [messages.properties],
    • o segundo é um conjunto de parâmetros, pois, por vezes, as mensagens são configuradas. Não é esse o caso aqui,
    • o terceiro é a localização utilizada (obtida na linha 10). A localização indica o idioma utilizado: [fr_FR] para o francês da França, [en_US] para o inglês dos USA. A mensagem é procurada em messages_[locale].properties, por exemplo, em [messages_fr_FR.properties]. Se este ficheiro não existir, a mensagem é procurada em [messages_fr.properties]. Se esse ficheiro não existir, a mensagem é procurada em [messages.properties]. É este último caso que nos servirá;
  • linhas 25-29: de forma um pouco inesperada, quando se procura um código inexistente num ficheiro de mensagens, obtém-se uma exceção em vez de um ponteiro nulo;
  • linhas 33-35: tratamos o caso em que não existe mensagem de erro;
  • linhas 37-38: construímos a cadeia de erro. Nesta, incluímos a localização e a mensagem de erro encontrada;

Eis alguns exemplos de execução:

 

Vemos que:

  • a localização da aplicação é [fr_FR]. Trata-se de um valor por predefinição, uma vez que não fizemos nada para a inicializar;
  • que a mensagem utilizada para os dois campos é a seguinte:

NotNull=Le champ ne peut être vide

Outro exemplo:

 

Vemos que:

  • a mensagem de erro utilizada para o parâmetro [a] é a seguinte:

typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
  • a mensagem de erro utilizada para o parâmetro [b] é a seguinte:

typeMismatch=Format invalide

Por que razão existem duas mensagens diferentes? Para o parâmetro [a], havia duas mensagens possíveis:


typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

Os códigos de erro foram explorados pela ordem da tabela [error.getCodes()]. Verifica-se que esta ordem vai do código mais específico para o código mais geral. É por isso que o código [typeMismatch.model01.a] foi encontrado em primeiro lugar.

4.20. [/m25]: internacionalização de uma aplicação Spring MVC

Agora que sabemos personalizar as mensagens de erro em francês, gostaríamos de as ter também em inglês, o que nos leva à internacionalização de uma aplicação Spring MVC. Para gerir esta internacionalização, vamos expandir a classe de configuração [Config], que passa a ter o seguinte aspeto:


package istia.st.springmvc.main;

import java.util.Locale;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }

    @Bean
    public CookieLocaleResolver localeResolver() {
        CookieLocaleResolver localeResolver = new CookieLocaleResolver();
        localeResolver.setCookieName("lang");
        localeResolver.setDefaultLocale(new Locale("fr"));
        return localeResolver;
    }
}
  • linhas 28-32: criamos um interceptor de pedidos. Um interceptor de pedidos estende a interface [HandlerInterceptor]. Esta classe inspeciona o pedido recebido antes de este ser processado por uma ação. Aqui, o interceptor [localeChangeInterceptor] irá procurar um parâmetro denominado [lang] na solicitação recebida, GET ou POST e alterará a localização da aplicação de acordo com esse parâmetro. Assim, se o parâmetro for [lang=en_US], a localização da aplicação passará a ser o inglês de USA;
  • linhas 34-37: redefine-se o método [WebMvcConfigurerAdapter.addInterceptors] para adicionar o interceptor anterior;
  • linhas 39-45: servem para definir a forma como a localização será encapsulada num cookie. Sabe-se que um cookie pode servir de memória do utilizador, uma vez que o navegador do cliente o reenvia sistematicamente para o servidor. O interceptor [localeChangeInterceptor] anterior cria um cookie que encapsula a localização. A linha 42 atribui o nome [lang] a este cookie. O cookie também é utilizado para alterar a localização;
  • linha 43: indica que, na ausência do cookie [lang], a localização será [fr];

Em resumo, a localização de uma solicitação pode ser definida de duas formas:

  • passando um parâmetro denominado [lang];
  • enviando um cookie denominado [lang]. Este cookie é criado automaticamente após a execução do método anterior;

Para utilizar esta localização, vamos criar ficheiros de mensagens para as localizações [fr] e [en]:

 

O ficheiro [messages_fr.properties] é o seguinte:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

O ficheiro [messages_en.properties] é o seguinte:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer

O ficheiro [messages.properties] é uma cópia do ficheiro [messages_en.properties]. Recorde-se que o ficheiro [messages.properties] é utilizado quando não é encontrado nenhum ficheiro correspondente à configuração regional do pedido. No nosso caso, se o utilizador enviar um parâmetro [lang=en], como o ficheiro [messages_en.properties] não existe, será utilizado o ficheiro [messages.properties]. O utilizador receberá, portanto, mensagens em inglês.

Vamos experimentar. Em primeiro lugar, no ambiente de desenvolvimento do Chrome (Ctrl-Shift-I), verifique os seus cookies:

 

Se tiver um cookie chamado [lang], elimine-o. Em seguida, no Chrome, solicite o URL e o [http://localhost:8080/m25]:

 

O navegador enviou os seguintes cabeçalhos HTTP:

GET /m25 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0,8
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36
Referer: http://localhost:8080/m25
Accept-Encoding: gzip, deflate, sdch
Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.6,en;q=0.4

Vemos que, nestes cabeçalhos, não existe nenhum cookie [lang]. Neste caso, o nosso código utiliza a localização [fr]. É isso que mostra a captura de ecrã. Vamos tentar outro caso:

  • em [1], passámos o parâmetro [lang=en] para alterar a localização para [en];
  • em [2], vemos a nova configuração regional;
  • em [3], a mensagem foi alterada para inglês;

Vejamos agora as trocas em HTTP:

 

Vemos acima que o servidor devolveu um cookie [lang]. Isto tem uma consequência importante: a localização da próxima solicitação será novamente [en] devido ao cookie [lang] que será devolvido pelo navegador. Por isso, devemos manter as mensagens em inglês. Vamos verificar isso:

 

Acima, vemos que a localização permaneceu como [en]. Devido ao cookie que o navegador envia sistematicamente, permanecerá assim enquanto o utilizador não a alterar, enviando o parâmetro [lang] da seguinte forma:

 

4.21. [/m26]: injeção da localização no modelo da ação

No exemplo anterior, vimos uma forma de recuperar a localização da solicitação:


    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
...
        // local
        Locale locale = RequestContextUtils.getLocale(request);
// erros?

A localização pode ser inserida diretamente nos parâmetros da ação. Eis um exemplo:


    @RequestMapping(value = "/m26", method = RequestMethod.GET)
    public String m26(Locale locale) {
        return String.format("locale=%s", locale.toString());
}
 

Como se pode ver acima, não há verificação da validade da localização solicitada. No entanto, o pedido seguinte do navegador provoca uma exceção no lado do servidor, uma vez que o cookie de localização que recebe está incorreto.

4.22. [/m27]: verificar a validade de um modelo com o Hibernate Validator

Consideremos a seguinte nova ação:


    //validação de um modelo com o Hibernate Validator ------------------------
    @RequestMapping(value = "/m27", method = RequestMethod.POST)
    public Map<String, Object> m27(@Valid ActionModel02 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // erros?
        if (result.hasErrors()) {
            // navegação pela lista de erros
            for (FieldError error : result.getFieldErrors()) {
                map.put(error.getField(),
                        String.format("[message=%s, codes=%s]", error.getDefaultMessage(), String.join("|", error.getCodes())));
            }
        } else {
            // sem erros
            map.put("data", data);
        }
        return map;
}

Temos aqui código que já vimos várias vezes:

  • linha 3: a ação [/m27] é solicitada através de um POST;
  • linhas 8-11: cada erro será identificado por [champ, message] com:
    • campo: o campo com erro,
    • mensagem: a mensagem de erro associada, bem como a lista de códigos de erro;
  • linha 14: se não houver erros, devolve-se a cadeia jSON com os valores lançados;

Na linha 3, utiliza-se o modelo de ação [ActionModel02] seguinte:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel02 {

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
    private Boolean assertFalse;
    
    @NotNull(message = "La donnée est obligatoire")
    @AssertTrue(message = "Seule la valeur [true] est acceptée")
    private Boolean assertTrue;
    
    @NotNull(message = "La donnée est obligatoire")
    @Future(message = "Il faut une date postérieure à aujourd'hui")
    private Date dateInFuture;
    
    @NotNull(message = "La donnée est obligatoire")
    @Past(message = "Il faut une date antérieure à aujourd'hui")
    private Date dateInPast;
    
    @NotNull(message = "La donnée est obligatoire")
    @Max(value = 100, message = "Maximum 100")
    private Integer intMax100;
    
    @NotNull(message = "La donnée est obligatoire")
    @Min(value = 10, message = "Minimum 10")
    private Integer intMin10;
    
    @NotNull(message = "La donnée est obligatoire")
    @NotBlank(message = "La chaîne doit être non blanche")
    private String strNotBlank;
    
    @NotNull(message = "La donnée est obligatoire")
    @Size(min = 4, max = 6, message = "La chaîne doit avoir entre 4 et 6 caractères")
    private String strBetween4and6;
    
    @NotNull(message = "La donnée est obligatoire")
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$", message = "Le format doit être hh:mm:ss")
    private String hhmmss;
    
    @NotNull(message = "La donnée est obligatoire")
    @Email(message = "Adresse invalide")
    private String email;
    
    @NotNull(message = "La donnée est obligatoire")
    @Length(max = 4, min = 4, message = "La chaîne doit avoir 4 caractères exactement")
    private String str4;
    
    @Range(min = 10, max = 14, message = "La valeur doit être dans l'intervalle [10,14]")
    @NotNull(message = "La donnée est obligatoire")
    private Integer int1014;
    
    @URL(message = "URL invalide")
    private String url;

    // getters e setters

...
}

A classe utiliza restrições de validação provenientes de dois pacotes:

  • [javax.validation.constraints] nas linhas 5-13;
  • [org.hibernate.validator.constraints] nas linhas 15-19;

As dependências Maven destes dois pacotes estão presentes no projeto:

  

Aqui, não vamos utilizar mensagens internacionalizadas, mas sim mensagens definidas no interior da restrição com o atributo [message]. Para testar esta ação, vamos utilizar o [Advanced Rest Client]:

  • em [1-2], a consulta POST;
  • em [3], o cabeçalho HTTP [Content-Type] a utilizar;
  • em [4], a ligação [Add new value] permite adicionar um par [paramètre, value];
  • em [5], inserir um campo de [ActionModel02], neste caso o campo [assertFalse]:

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
private Boolean assertFalse;
  • em [6], introduza um valor errado para ver uma mensagem de erro. Acima, a restrição [@AssertFalse] exige que o campo [assertFalse] tenha o valor [false];
  • em [7], a resposta do servidor: a restrição [@NotNull] relativa aos campos vazios foi acionada e a mensagem de erro associada foi apresentada;
  • em [8], a mensagem do campo [assertFalse] para o qual a restrição [@AssertFalse] não foi verificada, bem como os códigos desse erro. Recorde-se que estes códigos podem estar associados a mensagens internacionalizadas;

Eis outro exemplo:

 

Image

Convidamos o leitor a testar os diferentes casos de erro até ao POST, cujos dados são todos válidos:

Nota: o formato das datas é o formato anglo-saxónico: mm/dd/aaaa.

4.23. [/m28]: externalização das mensagens de erro

Na classe [ActionModel02], colocámos as mensagens «fixas». É preferível externalizá-las para ficheiros de mensagens. Seguimos o exemplo da ação [/m25]. Criamos o seguinte novo modelo de ação [ActionModel03]:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel03 {

    @NotNull
    @AssertFalse
    private Boolean assertFalse;
    
    @NotNull
    @AssertTrue
    private Boolean assertTrue;
    
    @NotNull
    @Future
    private Date dateInFuture;
    
    @NotNull
    @Past
    private Date dateInPast;
    
    @NotNull
    @Max(value = 100)
    private Integer intMax100;
    
    @NotNull
    @Min(value = 10)
    private Integer intMin10;
    
    @NotNull
    @NotBlank
    private String strNotBlank;
    
    @NotNull
    @Size(min = 4, max = 6)
    private String strBetween4and6;
    
    @NotNull
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$")
    private String hhmmss;
    
    @NotNull
    @Email
    private String email;
    
    @NotNull
    @Length(max = 4, min = 4)
    private String str4;
    
    @Range(min = 10, max = 14)
    @NotNull
    private Integer int1014;
    
    @URL
    private String url;

    // getters e setters
        ...
}

As mensagens de erro são externalizadas para os ficheiros [messages.properties]:

  

O ficheiro [messages_fr.properties] é o seguinte:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
Range.actionModel03.int1014=La valeur doit être dans l'intervalle [10,14]
NotBlank.actionModel03.strNotBlank=La chaîne doit être non blanche
AssertFalse.actionModel03.assertFalse=Seule la valeur [false] est acceptée
Pattern.actionModel03.hhmmss=Le format doit être hh:mm:ss
Past.actionModel03.dateInPast=Il faut une date antérieure ou égale à celle d'aujourd'hui
Future.actionModel03.dateInFuture=Il faut une date postérieure à celle d'aujourd'hui
Length.actionModel03.str4=La chaîne doit avoir 4 caractères exactement
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Seule la valeur [true] est acceptée
Email.actionModel03.email=Adresse invalide
Size.actionModel03.strBetween4and6=La chaîne doit avoir entre 4 et 6 caractères
URL.actionModel03.url=URL invalide

As mensagens de erro foram adicionadas às linhas 4-16. Apresentam-se da seguinte forma:

code=message

Os códigos não podem ser quaisquer. São os que foram apresentados na ação anterior [/m27]. Por exemplo:

Image

Nos ficheiros de mensagens, é necessário utilizar um dos quatro códigos acima referidos no campo [int1014].

O ficheiro [messages_en.properties] é o seguinte:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer
Range.actionModel03.int1014=Value must be in [10,14] interval
NotBlank.actionModel03.strNotBlank=String can't be empty
AssertFalse.actionModel03.assertFalse=Only boolean [false] is allowed
Pattern.actionModel03.hhmmss=String format is hh:mm:ss
Past.actionModel03.dateInPast=Date must be before or equal to today's date
Future.actionModel03.dateInFuture=Date must be after today's date
Length.actionModel03.str4=String must be four characters long
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Only boolean [true] is allowed
Email.actionModel03.email=Invalid email
Size.actionModel03.strBetween4and6=String must be between four and six characters long
URL.actionModel03.url=Invalid URL

O modelo de ação [ActionModel03] é utilizado pela seguinte ação:


// ----------------------- externalização das mensagens de erro ------------------------
    @RequestMapping(value = "/m28", method = RequestMethod.POST)
    public Map<String, Object> m28(@Valid ActionModel03 data, BindingResult result, HttpServletRequest request) {
        Map<String, Object> map = new HashMap<String, Object>();
        // o contexto da aplicação Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // local
        Locale locale = RequestContextUtils.getLocale(request);
        // erros?
        if (result.hasErrors()) {
            for (FieldError error : result.getFieldErrors()) {
                // Pesquisa da mensagem de erro com base nos códigos de erro
                // a mensagem é procurada nos ficheiros de mensagens
                // os códigos de erro apresentados numa tabela
                String[] codes = error.getCodes();
                // na forma de cadeia de caracteres
                String listCodes = String.join(" - ", codes);
                // Pesquisa
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // foi encontrado?
                if (msg == null) {
                    msg = String.format("Indiquez un message pour l'un des codes [%s]", listCodes);
                }
                // foi encontrado - adiciona-se o erro ao dicionário
                map.put(error.getField(), msg);
            }
        } else {
            // sem erros
            map.put("data", data);
        }
        return map;
    }

Já comentámos este tipo de código. A única coisa realmente importante é a linha 23: a mensagem de erro obtida depende da configuração regional do pedido.

Eis um exemplo em francês:

e agora em inglês: