Skip to content

4. Działania: model

Wróćmy do architektury aplikacji Spring MVC:

W poprzednim rozdziale przyjrzeliśmy się procesowi, w ramach którego żądanie [1] trafia do kontrolera i akcji [2a], które je przetworzą – mechanizm ten nazywamy routingiem. Przedstawiliśmy również różne odpowiedzi, jakie akcja może zwrócić do przeglądarki. Jak dotąd omawialiśmy akcje, które nie wykorzystywały przekazanego im żądania. Żądanie [1] zawiera różne informacje, które Spring MVC przekazuje akcji w postaci modelu. Nie należy mylić tego terminu z modelem M widoku V [2c], który jest generowany przez akcję:

  • żądanie klienta HTTP trafia do [1];
  • w [2] informacje zawarte w żądaniu zostaną przekształcone w szablon akcji [3] – często, choć niekoniecznie, jest to klasa – który posłuży jako dane wejściowe dla akcji [4];
  • w [4] akcja, na podstawie tego modelu, wygeneruje odpowiedź. Będzie ona składać się z dwóch elementów: widoku V [6] oraz modelu M tego widoku [5];
  • widok V [6] wykorzysta swój szablon M [5] do wygenerowania odpowiedzi HTTP przeznaczonej dla klienta.

W modelu MVC akcja [4] stanowi część C (kontroler), model widoku [5] to M, a widok [6] to V.

W niniejszym rozdziale omówiono mechanizmy powiązania między informacjami przekazywanymi w żądaniu, które z natury są ciągami znaków, a modelem akcji, który może być klasą posiadającą właściwości różnych typów.

Uwaga: termin „[Modèle d'action]” nie jest rozpoznawany.

Tworzymy nowy kontroler dla tych nowych akcji:

  

Kod kontrolera [ActionModelController] będzie na razie wyglądał następująco:


package istia.st.springmvc.controllers;

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

@RestController
public class ActionModelController {

}
  • wiersz 5: przypominamy, że adnotacja [@RestController] powoduje, że odpowiedź wysyłana do klienta jest serializacją w postaci ciągu znaków wyniku działań kontrolera;

4.1. [/m01]: parametry kontrolera GET

Dodajemy następującą akcję [/m01]:



    // ----------------------- pobieranie parametrów za pomocą 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);
}
  • wiersz 4: akcja przyjmuje dwa parametry o nazwach [nom] i [age]. Zostaną one zainicjowane parametrami o tych samych nazwach w żądaniu HTTP GET;

W przeglądarce Chrome wyniki dla [1-3] są następujące:

  • w [1] – zapytanie GET z parametrami [nom] i [age];
  • w [3] widać, że akcja [/m01] prawidłowo pobrała te parametry;

4.2. [/m02]: parametry akcji POST

Dodajemy następującą akcję [/m02]:



    // ----------------------- pobieranie parametrów za pomocą 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);
}
  • wiersz 4: akcja przyjmuje dwa parametry o nazwach [nom] i [age]. Zostaną one zainicjowane parametrami o tych samych nazwach w zapytaniu HTTP POST;

Wyniki dla [Advanced rest Client] są następujące:

  • w [1-3] zapytanie POST z parametrami [nom] i [age];
  • w [4-5] ustala się nagłówek HTTP [Content-Type] zapytania POST. Powinien on brzmieć [Content-Type: application/x-www-form-urlencoded];
  • w [6], [Form Data] zawiera listę parametrów operacji POST. Tutaj widoczne są parametry [nom] i [age];
  • w [7] znajduje się odpowiedź serwera, która pokazuje, że operacja [/m02] pomyślnie pobrała parametry [nom] i [age]; ;

4.3. [/m03]: parametry o tych samych nazwach

W punkcie 2.5.2.8 widzieliśmy, że lista z możliwością wielokrotnego wyboru może wysyłać do serwera parametry o tych samych nazwach. Zobaczmy, w jaki sposób akcja może je pobrać. Dodajemy następującą akcję [/m03]:


    // ----------------------- pobieranie parametrów o tych samych nazwach-----------------
    @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));
}
  • wiersz 2: akcja przyjmuje parametr o nazwie [nazwa[]]. Zostanie on tutaj zainicjowany wszystkimi parametrami o tej nazwie, niezależnie od tego, czy pochodzą one z GET, czy z POST, ponieważ w tym przypadku typ żądania nie został określony;

Wyniki są następujące:

  • poprzez POST [1] wysyłane są parametry [2];
  • parametry umieszcza się również w URL i [3];
  • w pliku [4] znajdują się cztery parametry o tej samej nazwie, co w pliku [nom]: [Query String parameters] są parametrami dla URL, a [Form Data] to przesłane parametry;
  • w [5] widać, że akcja [/m03] pobrała cztery parametry o nazwach [nom];

4.4. [/m04]: mapowanie parametrów akcji na obiekt Java

Niech nowa akcja [/m04] będzie wyglądała następująco:


    // ------ mapowanie parametrów na obiekt (Command Object) ---------------
    @RequestMapping(value = "/m04", method = RequestMethod.POST)
    public Personne m04(Personne personne) {
        return person;
}
  • wiersz 3: akcja ma jako parametr osobę o następującym typie:

public class Personne {

    // identyfikator
    private Integer id;
    // nazwa
    private String nom;
    // wiek
    private int age;
....
    // metody pobierające i ustawiające
...
}
  • aby utworzyć parametr [Personne personne], Spring MVC tworzy obiekt [new Personne()];
  • następnie, jeśli istnieją parametry o nazwach odpowiadających polom [id, nom, age] utworzonego obiektu, instancjonuje go z tymi polami za pomocą ich metod setterów;
  • wiersz 4: akcja zwraca typ [Personne], który zostanie zatem zserializowany do postaci ciągu znaków przed wysłaniem do klienta. Widzieliśmy, że domyślnie przeprowadzana jest serializacja typu jSON. Klient powinien zatem otrzymać ciąg znaków jSON dotyczący danej osoby;

Oto przykład:

  • w [1], parametry [id, nom, age] do utworzenia obiektu [Personne];
  • w [2], ciąg znaków jSON tej osoby;

Co się stanie, jeśli nie wyślemy wszystkich pól dotyczących danej osoby? Spróbujmy:

  • na [2], zainicjowano tylko parametr [id];

4.5. [/m05]: pobranie elementów z URL

Niech nowa akcja [/m05] będzie następująca:


    // ----------------------- pobieranie elementów z obiektu 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;
}
  • wiersz 2: przetwarzana akcja URL ma postać [/m05/{a}/x/{b}], gdzie {param} jest elementem parametrów akcji URL;
  • wiersz 3: elementy parametrów z pliku URL są pobierane wraz z adnotacją [@PathVariable];
  • wiersze 4–6: pobrane elementy [a] i [b] są umieszczane w słowniku;
  • wiersz 7: odpowiedzią będzie ciąg znaków jSON z tego słownika;

Wyniki są następujące:

 

4.6. [/m06]: pobranie elementów z URL oraz parametrów

Oto nowa akcja [/m06]:


    // -------- pobieranie elementów z URL oraz parametrów---------------
    @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;
}
  • wiersz 3: pobierane są zarówno elementy z URL i [Integer a, Double b], jak i parametr (GET lub POST) z [Double c];
  • wiersze 4–7: elementy te są umieszczane w słowniku;
  • wiersz 8: tworzy to odpowiedź klienta, który otrzyma zatem ciąg znaków jSON z tego słownika;

Oto wyniki:

 

Należy zwrócić uwagę na znak / na końcu ścieżki [http://localhost:8080/m06/100/x/200.43/]. Bez niego otrzymujemy następujący nieprawidłowy wynik:

 

4.7. [/m07]: uzyskanie dostępu do całego zapytania

Rozważmy następującą nową akcję [/m07]:


    // ------ dostęp do zapytania HttpServletRequest ------------------------
    @RequestMapping(value = "/m07", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m07(HttpServletRequest request) {
        // nagłówki 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();
}
  • wiersz 3: prosimy Spring MVC o wstrzyknięcie obiektu [HttpServletRequest request], który zawiera wszystkie informacje, jakie można uzyskać na temat zapytania;
  • wiersze 5–10: pobieramy wszystkie nagłówki HTTP z żądania, aby połączyć je w ciąg znaków, który wysyłamy do klienta (wiersz 11);

Wyniki są następujące:

  • w [1] – nagłówki HTTP z zapytania;
  • na [2], odpowiedź. Znajdują się w niej wszystkie nagłówki HTTP z żądania.

4.8. [/m08]: dostęp do obiektu [Writer]

Rozważmy następującą akcję:


    // ----------------------- wstrzyknięcie writer ------------------------
    @RequestMapping(value = "/m08", method = RequestMethod.GET)
    public void m08(Writer writer) throws IOException {
        writer.write("Bonjour le monde !");
}
  • wiersz 3: Spring MVC wstrzykuje obiekt [Writer writer], który umożliwia zapis w strumieniu odpowiedzi dla klienta;
  • wiersz 3: akcja zwraca typ [void], co oznacza, że musi samodzielnie skonstruować odpowiedź dla klienta;
  • wiersz 4: dodanie tekstu do strumienia odpowiedzi dla klienta;

Wyniki są następujące:

  • w [2] widać, że nagłówek HTTP [Content-Type] nie został wysłany;
  • w przypadku [3] – odpowiedź;

4.9. [/m09]: dostęp do nagłówka HTTP

Rozważmy następującą akcję:


    // ----------------------- wstrzyknięcie kodu RequestHeader ------------------------
    @RequestMapping(value = "/m09", method = RequestMethod.GET)
    public String m09(@RequestHeader("User-Agent") String userAgent) {
        return userAgent;
}
  • wiersz 3: adnotacja [@RequestHeader("User-Agent")] pozwala pobrać nagłówek HTTP [User-Agent];
  • wiersz 4: zwracamy tekst tego nagłówka;

Wyniki są następujące:

  • w [2] nagłówki HTTP i [User-Agent];
  • na [3], akcja [/m08] poprawnie pobrała ten nagłówek;

Plik cookie to zazwyczaj nagłówek HTTP, który:

  • serwer wysyła po raz pierwszy do klienta;
  • klient następnie systematycznie odsyła do serwera;

Najpierw utwórzmy akcję, która tworzy plik cookie:


    // ----------------------- tworzenie pliku cookie ------------------------
    @RequestMapping(value = "/m10", method = RequestMethod.GET)
    public void m10(HttpServletResponse response) {
        response.addCookie(new Cookie("cookie1", "remember me"));
}
  • wiersz 3: wstawiamy obiekt [HttpServletResponse response], aby uzyskać pełną kontrolę nad odpowiedzią;
  • wiersz 4: tworzymy plik cookie z kluczem [cookie1] i wartością [remember me] (Uwaga: znaki z akcentami w wartości pliku cookie powodują błędy);
  • wiersz 3: akcja nie zwraca żadnego wyniku. Ponadto nie zapisuje niczego w treści odpowiedzi. Klient otrzyma zatem pusty dokument. Odpowiedź służy wyłącznie do dodania do niej nagłówka pliku cookie o nazwie HTTP;

Spójrzmy na wyniki:

  • w [1]: żądanie;
  • w [2]: odpowiedź jest pusta;
  • w [3]: plik cookie utworzony przez akcję;

Teraz utwórzmy akcję, która pobierze ten plik cookie, który przeglądarka będzie odtąd wysyłać przy każdym żądaniu:


    // ----------------------- wstrzyknięcie pliku cookie ------------------------
    @RequestMapping(value = "/m11", method = RequestMethod.GET)
    public String m10(@CookieValue("cookie1") String cookie1) {
        return cookie1;
}
  • wiersz 3: adnotacja [@CookieValue("cookie1")] pozwala pobrać plik cookie o kluczu [cookie1];
  • wiersz 4: ta wartość będzie odpowiedzią wysłaną do klienta;

Sprawdźmy wyniki:

  • w [2] widać, że przeglądarka zwraca plik cookie;
  • w [3] widać, że akcja pomyślnie odzyskała plik cookie;

4.11. [/m12]: dostęp do treści POST

Parametry wysyłane metodą POST są zazwyczaj opatrzone nagłówkiem HTTP [Content-Type: application/x-www-form-urlencoded]. Można uzyskać dostęp do całego wysłanego ciągu znaków. Tworzymy następującą akcję:


    // ----------- pobranie treści obiektu typu String o nazwie POST------------------------
    @RequestMapping(value = "/m12", method = RequestMethod.POST)
    public String m12(@RequestBody String requestBody) {
        return requestBody;
}
  • wiersz 3: adnotacja [@RequestBody] pozwala pobrać treść z POST. Zakładamy tutaj, że jest to typ [String];
  • wiersz 4: treść ta jest zwracana do klienta;

Oto pierwszy przykład:

  • w [2] – wartości przesłane;
  • w [3] – nagłówek HTTP [Content-Type] żądania;
  • w [4] – odpowiedź serwera;

Parametry wysyłane metodą POST nie zawsze mają prostą postać [p1=v1&p2=v2], z której często korzystaliśmy do tej pory. Rozważmy bardziej złożony przypadek:

  • w [2-3]: wartości wysyłane wprowadzamy w postaci [clé:value];
  • w przypadku [5] – ciąg znaków, który został przesłany;

W przypadku typu [Content-Type: application/x-www-form-urlencoded] wysyłany ciąg znaków musi mieć postać [p1=v1&p2=v2]. Jeśli chcemy wysłać dowolną treść, wybieramy typ [Content-Type: text/plain]. Oto przykład:

  • w przypadku [2-3] tworzymy nagłówek HTTP [Content-Type]. Domyślnie [5] – to właśnie ten zostanie użyty zamiast tego zdefiniowanego w [6]. Atrybut [charset=utf-8] jest ważny. Bez niego tracimy znaki akcentowane z wysłanego ciągu znaków;
  • w [4] łańcuch przesłany jest poprawnie odzyskiwany w [7];

4.12. [/m13, /m14]: pobieranie wartości wysłanych w formacie jSON

Możliwe jest wysyłanie parametrów z nagłówkiem HTTP [Content-Type: application/json]. Tworzymy następującą akcję:


    // ----------------------- pobieranie treści jSON z obiektu POST
    @RequestMapping(value = "/m13", method = RequestMethod.POST, consumes = "application/json")
    public String m13(@RequestBody Personne personne) {
        return personne.toString();
}
  • wiersz 2: [consumes = "application/json"] określa, że akcja oczekuje treści o nazwie jSON;
  • wiersz 3: [@RequestBody] reprezentuje tę treść. Ta adnotacja została powiązana z obiektem typu [Personne]. Treść jSON zostanie automatycznie deserializowana w tym obiekcie;
  • wiersz 4: stosuje się metodę [Personne].toString(), aby zwrócić wartość inną niż wysłany ciąg znaków jSON;

Oto przykład:

  • w [2], wysłany ciąg znaków jSON;
  • na [3], czyli [Content-Type] z żądania;
  • na [4] – odpowiedź serwera;

Można to zrobić inaczej:


    // ----------------------- pobranie treści jSON z obiektu typu 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();
}
  • wiersz 2: wskazano, że metoda oczekuje strumienia typu [text/plain]. Spring MVC przetworzy wówczas treść żądania jako typ [String] (wiersz 3);
  • wiersz 4: deserializujemy ciąg znaków jSON do obiektu typu [Personne] (patrz rozdział 9.7, strona 542);

Wyniki są następujące:

  • na [3], należy wpisać [text/plain];

4.13. [/m15]: odzyskaj sesję

Wróćmy do architektury wykonywania akcji:

Klasa kontrolera jest instancjonowana na początku żądania klienta i niszczona po jego zakończeniu. Nie może więc służyć do przechowywania danych między dwoma żądaniami, nawet jeśli jest wywoływana wielokrotnie. Można chcieć przechowywać dwa rodzaje danych:

  • dane wspólne dla wszystkich użytkowników aplikacji internetowej. Są to zazwyczaj dane tylko do odczytu;
  • dane współdzielone przez żądania tego samego klienta. Dane te są przechowywane w obiekcie zwanym sesją. Mówimy wówczas o sesji klienta, aby określić pamięć klienta. Wszystkie żądania danego klienta mają dostęp do tej sesji. Mogą w niej przechowywać i odczytywać informacje.

Powyżej przedstawiono rodzaje pamięci, do których ma dostęp akcja:

  • pamięć aplikacji, która w większości przypadków zawiera dane tylko do odczytu i jest dostępna dla wszystkich użytkowników;
  • pamięć konkretnego użytkownika, czyli sesja, która zawiera dane do odczytu i zapisu i jest dostępna dla kolejnych żądań tego samego użytkownika;
  • nie przedstawiono powyżej, istnieje pamięć żądania lub kontekst żądania. Żądanie użytkownika może być przetwarzane przez kilka kolejnych akcji. Kontekst żądania pozwala akcji 1 przekazać informacje do akcji 2.

Przyjrzyjmy się pierwszemu przykładowi ilustrującemu te różne rodzaje pamięci:


    // ----------------------- pobranie sesji ------------------------
    @RequestMapping(value = "/m15", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m15(HttpSession session) {
        // pobieramy obiekt klucza [compteur] z sesji
        Object objCompteur = session.getAttribute("compteur");
        // konwertujemy go na liczbę całkowitą w celu zwiększenia jego wartości
        int iCompteur = objCompteur == null ? 0 : (Integer) objCompteur;
        iCompteur++;
        // umieszczamy go ponownie w sesji
        session.setAttribute("compteur", iCompteur);
        // zwracamy go jako wynik akcji
        return String.valueOf(iCompteur);
}

Spring MVC utrzymuje sesję użytkownika w obiekcie typu [HttpSession].

  • wiersz 3: prosimy Spring MVC o wstrzyknięcie obiektu [HttpSession] do parametrów akcji;
  • wiersz 5: pobieramy z nich atrybut o nazwie [compteur]. Sesja zachowuje się jak słownik, czyli zbiór par [clé, valeur]. Jeśli klucz [compteur] nie istnieje w sesji, pobierany jest wskaźnik null;
  • wiersz 7: wartość powiązana z kluczem [compteur] będzie miała typ [Integer];
  • wiersz 8: zwiększenie wartości licznika;
  • wiersz 10: aktualizacja licznika w sesji;
  • wiersz 12: wartość licznika jest wysyłana do klienta;

Gdy [/m15] zostanie wykonane po raz:

  • pierwszy raz, w wierszu 12 licznik będzie miał wartość 1;
  • za drugim razem, w wierszu 5, wartość ta zostanie pobrana i zmieniona na 2;
  • ...

Oto przykład wykonania:

  • w [1] otrzymujemy rzeczywiście pierwszą wartość licznika;
  • w [2] serwer wysłał plik cookie sesji. Posiada on klucz [JSESSIONID], a jako wartość unikalny ciąg znaków dla każdego użytkownika. Pamiętajmy, że przeglądarka zawsze odsyła otrzymane pliki cookie. Gdy więc po raz drugi wyślemy żądanie o akcję [/m15], klient odeśle ten plik cookie, co pozwoli serwerowi rozpoznać go i powiązać z sesją użytkownika. W ten sposób utrzymywana jest pamięć użytkownika;

Przyjrzyjmy się drugiemu żądaniu:

  • w [3] widać, że klient odsyła plik cookie sesji. Można zauważyć, że w odpowiedzi serwera nie ma już tego pliku cookie sesji. Teraz to klient go wysyła, aby zostać rozpoznanym;
  • w [4], druga wartość licznika. Została ona prawidłowo zwiększona;

4.14. [/m16]: pobranie obiektu o zakresie [session]

Można chcieć umieścić wszystkie dane sesji użytkownika w jednym obiekcie i umieścić w sesji wyłącznie ten obiekt. Obieramy tę ścieżkę. Umieszczamy licznik w następującym obiekcie [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;
    }

}
  • wiersz 7: adnotacja [@Component] jest adnotacją Springa (wiersz 5), która sprawia, że klasa [SessionModel] staje się komponentem, którego cykl życia jest zarządzany przez Spring;
  • wiersz 8: adnotacja [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] jest również adnotacją Springa (wiersze 3–4). Gdy Spring MVC napotka tę adnotację, tworzona jest odpowiednia klasa i umieszczana w sesji użytkownika. Atrybut [proxyMode = ScopedProxyMode.TARGET_CLASS] jest istotny. To właśnie dzięki niemu Spring MVC tworzy instancję dla każdego użytkownika z osobna, a nie jedną instancję dla wszystkich użytkowników (singleton);
  • wiersz 11: licznik;

Aby ten nowy komponent Spring został rozpoznany, należy sprawdzić konfigurację aplikacji w klasie [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);
    }
}
  • wiersz 9: komponenty Spring są wyszukiwane w pakiecie [istia.st.springmvc.controllers]. To już nie wystarcza. Modyfikujemy ten wiersz w następujący sposób:

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

Dodaliśmy pakiet, w którym znajduje się klasa [SessionModel].

Teraz dodajemy następującą akcję:


    @Autowired
    private SessionModel session;
    
    // ------ zarządzanie obiektem o zasięgu sesji [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());
}
  • wiersze 1–2: komponent Spring [SessionModel] jest wstrzykiwany jako [@Autowired] do kontrolera. Warto tutaj przypomnieć, że kontroler Springa jest singletonem. Paradoksalne jest zatem wstrzykiwanie do niego komponentu o mniejszym zakresie, w tym przypadku o zakresie [Session]. W tym miejscu do akcji wkracza adnotacja [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] komponentu [SessionModel]. Za każdym razem, gdy kod kontrolera uzyskuje dostęp do pola [session] w wierszu 2, wykonywana jest metoda proxy w celu przywrócenia sesji żądania aktualnie przetwarzanego przez kontroler;
  • wiersz 6: obiekt [HttpSession] nie jest już potrzebny w parametrach akcji;
  • wiersz 7: pobieramy / zwiększamy licznik;
  • wiersz 8: zwracana jest jego wartość;

Oto przykład wykonania:

Za pierwszym razem

Za drugim razem

Teraz weźmy inną przeglądarkę, która będzie reprezentować drugiego użytkownika. W tym przypadku wybieramy przeglądarkę Opera:

Jak widać powyżej w [1], ten drugi użytkownik otrzymuje wartość licznika równą 1. Świadczy to o tym, że jego sesja różni się od sesji pierwszego użytkownika. Jeśli przyjrzymy się wymianie danych między klientem a serwerem (w przeglądarce Opera również klawisze Ctrl-Shift-I), widzimy w [2], że ten drugi użytkownik ma plik cookie sesji inny niż ten pierwszego użytkownika. To właśnie zapewnia niezależność sesji.

4.15. [/m17]: pobranie obiektu o zasięgu [application]

Wróćmy do architektury wykonywania akcji:

Wiemy już, jak utworzyć sesję użytkownika. Teraz utworzymy obiekt zakresu [application], którego zawartość będzie tylko do odczytu i dostępna dla wszystkich użytkowników. Wprowadzamy klasę [ApplicationModel], która będzie obiektem zakresu [application]:

 

package istia.st.springmvc.models;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Component;

@Component
public class ApplicationModel {

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

    // metody pobierające i ustawiające
    public AtomicLong getCompteur() {
        return compteur;
    }

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

}
  • wiersz 5: adnotacja [@Component] sprawia, że klasa [ApplicationModel] będzie komponentem zarządzanym przez Spring. Domyślnym typem komponentów Springa jest typ [singleton]: komponent ten jest tworzony jako pojedynczy egzemplarz podczas instancjonowania kontenera Springa, czyli zazwyczaj podczas uruchamiania aplikacji. Możemy wykorzystać ten cykl życia do przechowywania w singletonie informacji konfiguracyjnych, które będą dostępne dla wszystkich użytkowników;
  • wiersz 11: licznik typu [AtomicLong]. Ten typ posiada metodę [incrementAndGet], zwaną atomową. Oznacza to, że wątek wykonujący tę metodę ma gwarancję, iż żaden inny wątek nie odczyta wartości licznika (Get) w okresie między jego odczytem (Get) a zwiększeniem (increment) przez pierwszy wątek, co spowodowałoby błędy, ponieważ dwa wątki odczytywałyby tę samą wartość licznika, a ten zamiast zostać zwiększony o dwa, zostałby zwiększony o jeden;

Tworzymy następującą nową akcję [/m17]:


@Autowired
    private ApplicationModel application;

    // ----- zarządzanie obiektem o zasięgu aplikacji [Autowired] ------------------------
    @RequestMapping(value = "/m17", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m17() {
        return String.valueOf(application.getCompteur().incrementAndGet());
    }
  • wiersze 1–2: wstawiamy komponent [ApplicationModel] do kontrolera. Jest to singleton. Oznacza to, że każdy użytkownik będzie miał odwołanie do tego samego obiektu;
  • wiersz 7: zwracamy licznik zakresu [application] po jego zwiększeniu;

Oto dwa przykłady, jeden z przeglądarką Chrome, drugi z przeglądarką Opera:

Powyżej widać, że obie przeglądarki korzystały z tego samego licznika, co nie miało miejsca w przypadku sesji. Te dwie przeglądarki symbolizują dwóch różnych użytkowników, którzy mają dostęp do danych z zakresu [application]. Ogólnie rzecz biorąc, należy unikać umieszczania w obiektach zakresu [application] informacji przeznaczonych do odczytu i zapisu, tak jak miało to miejsce powyżej w przypadku licznika. W rzeczywistości wątki wykonawcze różnych użytkowników uzyskują jednocześnie dostęp do danych zakresu [application]. Jeśli występują dane do zapisu, należy zsynchronizować operacje zapisu, tak jak zrobiono to powyżej w przypadku typu [AtomicLong]. Równoległy dostęp jest źródłem błędów programistycznych. Dlatego zaleca się umieszczanie w obiektach o zakresie [application] wyłącznie danych tylko do odczytu.

4.16. [/m18]: pobieranie obiektu o zakresie [session] za pomocą [@SessionAttributes]

Istnieje inny sposób na pobranie informacji z zakresu [session]. Umieścimy w sesji następujący obiekt:


package istia.st.springmvc.models;

public class Container {
    // licznik
    public int compteur=10;

    // metody pobierające i ustawiające
    public int getCompteur() {
        return compteur;
    }

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

Wykorzystamy ten obiekt w dwóch poniższych akcjach:


    // użycie [@SessionAttribute] ----------------------
    @RequestMapping(value = "/m18", method = RequestMethod.GET)
    public void m18(HttpSession session) {
        // tutaj umieszczamy klucz [container] w sesji
        session.setAttribute("container", new Container());
    }

    // użycie klucza [@ModelAttribute] ----------------------
    // klucz sesji [container] zostanie tutaj wstawiony
    @RequestMapping(value = "/m19", method = RequestMethod.GET)
    public String m19(@ModelAttribute("container") Container container) {
        container.setCompteur(1 + container.getCompteur());
        return String.valueOf(container.getCompteur());
    }
  • wiersze 3–6: akcja [/m18] nie zwraca żadnego wyniku. Służy ona wyłącznie do utworzenia obiektu w sesji o kluczu [container];
  • wiersz 11: w akcji [/m19] wykorzystuje się adnotację [@ModelAttribute]. Działanie tej adnotacji jest dość złożone. Parametr [container] tej adnotacji może odnosić się do różnych elementów, a w szczególności do obiektu sesji. Wymaga to jednak, aby obiekt ten został zadeklarowany za pomocą adnotacji [@SessionAttributes] na samej klasie:

@RestController
@SessionAttributes({"container"})
public class ActionModelController {
  • wiersz 2 powyżej wskazuje, że klucz [container] stanowi część atrybutów sesji;

Podsumowując:

  • w [/m18] klucz [container] jest umieszczany w sesji;
  • adnotacja [@SessionAttributes({"container"})] sprawia, że klucz ten może zostać wstawiony do parametru opatrzonego adnotacją [@ModelAttribute("container")];
  • nie widać tego w poniższym przykładzie wykonania, ale informacja opatrzona adnotacją [@ModelAttribute] automatycznie staje się częścią modelu M przekazanego do widoku V;

Oto przykład wykonania. Najpierw umieszczamy klucz [container] w sesji za pomocą akcji [/m18] [1]. Następnie dwukrotnie wywołujemy akcję [/m19], aby zobaczyć, jak licznik zwiększa swoją wartość.

4.17. [/m20-/m23]: wstawianie informacji za pomocą [@ModelAttribute]

Rozważmy następującą nową akcję:


    // atrybut p będzie częścią wszystkich szablonów widoku [Model] ----------------
    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
    }

    // ---------------instancja @ModelAttribute --------------------------
    // zostanie wstrzyknięty, jeśli znajduje się w sesji
    // zostanie wstawiony, jeśli kontroler zdefiniował metodę dla tego atrybutu
    // może pochodzić z pól obiektu URL, jeśli istnieje konwerter typu String na typ atrybutu
    // w przeciwnym razie jest tworzony za pomocą domyślnego konstruktora
    // następnie atrybuty modelu są inicjowane parametrami z GET lub POST
    // wynik końcowy stanie się częścią modelu wygenerowanego przez akcję
    
    // atrybut p jest wstawiany do argumentów------------------------
    @RequestMapping(value = "/m20", method = RequestMethod.GET)
    public Personne m20(@ModelAttribute("p") Personne personne) {
        return personne;
}
  • wiersze 2–5: definiują atrybut szablonu o nazwie [p]. Jest to szablon M widoku V, reprezentowany przez typ [Model] w Spring MVC. Model zachowuje się jak słownik par [clé, valeur]. W tym przypadku klucz [p] jest powiązany z obiektem [Personne] utworzonym przez metodę [getPersonne]. Nazwa metody może być dowolna;
  • wiersz 17: atrybut wzorca klucza [p] jest wstrzykiwany do parametrów akcji. Wstrzyknięcie to odbywa się zgodnie z zasadami opisanymi w wierszach 8–12. W tym przypadku mamy do czynienia z sytuacją określoną w wierszu 9. Zatem w wierszu 17 parametr [Personne personne] będzie obiektem [Personne(7,'abcd',14)];
  • w wierszu 18 zwracamy obiekt [personne] w celu weryfikacji. Zostanie on zserializowany do postaci jSON przed wysłaniem do klienta.

Oto przykład:

 

Teraz przyjrzyjmy się następującej akcji:


    // --------- atrybut p automatycznie staje się częścią szablonu M widoku V
    @RequestMapping(value = "/m21", method = RequestMethod.GET)
    public String m21(Model model) {
        return model.toString();
}

Akcja, która ma wyświetlić widok V, musi zbudować jego model M. Spring MVC obsługuje ten model za pomocą typu [Model], który może zostać wstrzyknięty do parametrów akcji. Początkowo model ten jest pusty lub zawiera informacje oznaczone adnotacją [@ModelAttribute]. Akcja może wzbogacić ten model lub pozostawić go bez zmian przed przekazaniem go do widoku.

  • wiersz 3: wstawienie modelu M;
  • wiersz 4: chcemy sprawdzić, co się w nim znajduje. Serializujemy go do postaci ciągu znaków, aby wysłać go do klienta. W tym przypadku zostanie użyta metoda [Personne.toString]. Musi ona więc istnieć;

Oto przykładowe wykonanie:

 

Powyżej widać, że instrukcje:


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

utworzyły wpis [p, Personne(7,'abcd',14)] w modelu. Tak jest zawsze.

Rozważmy teraz następujący przypadek:


    // w przeciwnym razie jest tworzony za pomocą domyślnego konstruktora
    // następnie atrybuty szablonu są inicjowane za pomocą parametrów GET lub POST

z następującą akcją:


    // --------- atrybut modelu [param1] jest częścią modelu, ale nie jest zainicjowany
    @RequestMapping(value = "/m22", method = RequestMethod.GET)
    public String m22(@ModelAttribute("param1") String p1, Model model) {
        return model.toString();
}
  • wiersz 3: atrybut szablonu klucza [param1] nie istnieje. W takim przypadku powiązany typ musi posiadać konstruktor domyślny. Tak jest w przypadku typu [String], jednak nie można zapisać [@ModelAttribute("param1") Integer p1], ponieważ klasa [Integer] nie posiada konstruktora domyślnego;
  • wiersz 4: zwracamy model, aby sprawdzić, czy atrybut klucza [param1] jest jego częścią;

Oto przykładowe wykonanie:

 

Atrybut szablonu [param1] rzeczywiście występuje w szablonie, ale metoda [toString] powiązanej wartości nie dostarcza żadnych informacji na temat tej wartości.

Rozważmy teraz następującą akcję, w której jawnie umieszczamy informację w modelu:


    // --------- atrybut szablonu [param2] jest jawnie umieszczony w szablonie
    @RequestMapping(value = "/m23", method = RequestMethod.GET)
    public String m23(String p2, Model model) {
        model.addAttribute("param2",p2);
        return model.toString();
}
  • wiersz 4: wartość [p2] pobrana w wierszu 3 jest umieszczana w modelu w powiązaniu z kluczem [param2]:

Oto przykład wykonania:

 

Zasady ulegają zmianie, jeśli parametrem akcji jest obiekt. Oto pierwszy przykład:


    // ------ atrybut szablonu [unePersonne] jest automatycznie umieszczany w szablonie
    @RequestMapping(value = "/m23b", method = RequestMethod.GET)
    public String m23b(@ModelAttribute("unePersonne") Personne p1, Model model) {
        return model.toString();
}

Akcja nie modyfikuje podanego jej szablonu. Wynik jest następujący:

Widać, że adnotacja [@ModelAttribute("unePersonne") Personne p1] umieściła osobę [p1] w modelu, powiązaną z kluczem [unePersonne].

Rozważmy teraz następującą akcję:


    // --------- osoba p1 jest automatycznie umieszczana w szablonie
    // -------- z kluczem będącym nazwą jej klasy, której pierwsza litera jest mała
    @RequestMapping(value = "/m23c", method = RequestMethod.GET)
    public String m23c(Personne p1, Model model) {
        return model.toString();
}
  • wiersz 4: nie dodano adnotacji [@ModelAttribute];

Wynik jest następujący:

Zauważamy, że obecność parametru [Personne p1] spowodowała umieszczenie osoby [p1] w modelu, powiązaną z kluczem [personne], który jest nazwą klasy [Personne] z pierwszą literą pisana małą literą.

4.18. [/m24]: walidacja modelu akcji

Rozważmy następujący model akcji [ActionModel01]:

 

package istia.st.springmvc.models;

import javax.validation.constraints.NotNull;

public class ActionModel01 {

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

    // metody pobierające i ustawiające
...
    }
  • wiersze 8 i 9: adnotacja [@NotNull] stanowi ograniczenie walidacyjne, które wskazuje, że adnotowane dane nie mogą przyjmować wartości null;

Przyjrzyjmy się teraz następującej akcji:


    // ----------------------- walidacja modelu ------------------------
    @RequestMapping(value = "/m24", method = RequestMethod.GET)
    public Map<String, Object> m24(@Valid ActionModel01 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // czy są błędy?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            // przeglądanie listy błędów
            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 {
            // brak błędów
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
}
  • wiersz 3: zostanie utworzony obiekt [ActionModel01], a jego pola [a, b] zostaną zainicjowane parametrami o tych samych nazwach. Adnotacja [@Valid] wskazuje, że należy sprawdzić ograniczenia poprawności. Wyniki tej weryfikacji zostaną umieszczone w parametrze typu [BindingResult] (drugi parametr). Przeprowadzone zostaną następujące weryfikacje:
    • z powodu adnotacji [@NotNull] muszą występować parametry [a] i [b];
    • ze względu na typ [Integer a] parametr [a], który z natury jest typu [String], musi być konwertowalny na typ [Integer];
    • ze względu na typ [Double b] parametr [b], który z natury jest typu [String], musi być konwertowalny na typ [Double];

Wraz z adnotacją [@Valid] błędy walidacji zostaną przeniesione do parametru [BindingResult result]. Bez adnotacji [@Valid] błędy walidacji powodują awarię akcji, a serwer wysyła do klienta odpowiedź HTTP ze statusem 500 (Internal server error).

  • wiersz 3: wynik akcji ma typ [Map]. Do klienta zostanie wysłany ciąg znaków jSON z tego wyniku. Tworzy się dwa rodzaje słowników:
    • w przypadku niepowodzenia – słownik z wpisem ['errors', value], gdzie [value] to ciąg znaków opisujący wszystkie błędy (wiersz 13);
    • w przypadku powodzenia – słownik z jednym wpisem ['data',value], gdzie [value] jest sam w sobie słownikiem zawierającym dwa wpisy: ['a', value], ['b', value] (wiersz 19);
  • wiersze 9–12: dla każdego wykrytego błędu [error] tworzy się ciąg [error.getField(), error.getRejectedValue(), error.Codes, error.getDefaultMessage()]:
    • pierwszym elementem jest błędne pole, np. [a] lub [b],
    • drugim elementem jest odrzucona wartość, na przykład [x],
    • trzecim elementem jest lista kodów błędów. Wkrótce omówimy ich znaczenie;
    • czwartym elementem jest kod błędu. Należy on do poprzedniej listy;
    • ostatnim elementem jest domyślny komunikat o błędzie. W rzeczywistości może występować kilka komunikatów o błędach;

Oto kilka przykładów wykonania:

Powyżej widać, że:

  • przypisanie wartości „x” do pola [ActionModel01.a] nie powiodło się, a komunikat o błędzie wyjaśnia, dlaczego;
  • przypisanie wartości „y” do pola [ActionModel01.b] nie powiodło się, a komunikat o błędzie wyjaśnia, dlaczego;

Zwróćmy uwagę na kody błędów w polu [a]: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Powrócimy do tych kodów błędów, gdy zajdzie potrzeba dostosowania komunikatu o błędzie. Należy zauważyć, że kod błędu to [typeMismatch].

Kolejny przykład:

W tym przypadku nie przekazano parametrów [a] i [b]. Wówczas zadziałały walidatory [@NotNull] z modelu akcji [ActionModel01];

W końcu poprawne wartości:

4.19. [m/24]: dostosowywanie komunikatów o błędach

Wróćmy do zrzutu ekranu z poprzedniego przykładu:

Powyżej widzimy domyślne komunikaty o błędach. Oczywiste jest, że nie możemy ich pozostawić w rzeczywistej aplikacji. Istnieje możliwość zdefiniowania tych komunikatów o błędach. W tym celu skorzystamy z kodów błędów. Powyżej widzimy, że błąd dotyczący pola [a] ma następujące kody: [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]. Kody błędów są uporządkowane od najbardziej szczegółowego do najmniej szczegółowego:

  • [typeMismatch.actionModel01.a]: błąd typu w polu [a] typu [ActionModel01];
  • [typeMismatch.a]: błąd typu w polu o nazwie [a];
  • [typeMismatch.java.lang.Integer]: błąd typu w typie Integer;
  • [typeMismatch]: błąd typu;

Zauważamy również, że kod błędu w polu [a], uzyskanym przez [error.getCode()], to [typeMismatch] (patrz zrzut ekranu powyżej).

Umieścimy komunikaty o błędach w pliku właściwości:

  

Powyższy plik [messages.properties] będzie wyglądał następująco:


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

Każdy wiersz ma następującą postać:

    clé=message

W tym przypadku kluczem będzie kod błędu, a komunikatem – komunikat o błędzie powiązany z tym kodem.

Przypomnijmy kody błędów dla obu pól:

  • [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch], gdy parametr [a] jest nieprawidłowy;
  • [typeMismatch.actionModel01.b - typeMismatch.b - typeMismatch.java.lang.Double - typeMismatch:typeMismatch ], gdy parametr [b] jest nieprawidłowy;
  • [NotNull.actionModel01.a - NotNull.a - NotNull.java.lang.Integer - NotNull], gdy brakuje parametru [a];
  • [NotNull.actionModel01.b - NotNull.b - NotNull.java.lang.Double - NotNull], gdy brakuje parametru [b];

Plik [messages.properties] musi zawierać komunikat o błędzie dla wszystkich możliwych przypadków błędów. W przypadku:

  • brak parametrów [a] i [b], zostanie użyty kod [NotNull];
  • w przypadku nieprawidłowego parametru [a] wprowadziliśmy komunikaty dla dwóch kodów [typeMismatch.actionModel01.a, typeMismatch]. Zobaczymy, który z nich zostanie użyty;
  • w przypadku nieprawidłowego parametru [b] zostanie użyty kod [typeMismatch];

Aby plik [messages.properties] był używany, należy skonfigurować Spring:

  

Usuwamy adnotacje konfiguracyjne z klasy [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);
    }
}
  • wiersz 8: uruchamia się aplikacja Spring Boot. Pierwszym parametrem metody statycznej [SpringApplication.run] jest klasa, która od tej pory konfiguruje aplikację;

Klasa [Config] ma następującą postać:


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;
    }
}
  • wiersze 11–13: znajdują się tu adnotacje konfiguracyjne, które wcześniej znajdowały się w klasie [Application];
  • wiersz 14: aby skonfigurować aplikację Spring MVC, należy rozszerzyć klasę [WebMvcConfigurerAdapter];
  • wiersz 15: adnotacja [@Bean] wprowadza komponent Springa, singleton;
  • wiersz 16: definiujemy bean o nazwie [messageSource] (nazwa metody). Bean ten służy do definiowania plików komunikatów aplikacji i musi nosić właśnie tę nazwę;
  • wiersze 17–19: informują Spring, że plik komunikatów:
    • znajduje się w folderze [i18n] w ścieżce Classpath projektu (wiersz 18),
    • nazywa się [messages.properties] (wiersz 18). W rzeczywistości termin [messages] stanowi podstawę nazw plików komunikatów, a nie samą nazwę. Zobaczymy, że w ramach internacjonalizacji może istnieć kilka plików komunikatów, po jednym dla każdej obsługiwanej kultury. Możemy więc mieć plik [messages_fr.properties] dla języka francuskiego i [messages_en.properties] dla języka angielskiego. Rozszerzenia dodawane do rdzenia [messages] są znormalizowane. Nie można wpisać byle czego;

W projekcie STS należy umieścić folder [i18n] w folderze zasobów, ponieważ jest on umieszczony w ścieżce Classpath projektu:

  

Aby wykorzystać ten plik, tworzymy następującą nową akcję:


// walidacja modelu, obsługa komunikatów o błędach ------------------------
    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
        // słownik wyników
        Map<String, Object> map = new HashMap<String, Object>();
        // kontekst aplikacji Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // ustawienia regionalne
        Locale locale = RequestContextUtils.getLocale(request);
        // błędów?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            for (FieldError error : result.getFieldErrors()) {
                // wyszukiwanie komunikatu o błędzie na podstawie kodów błędów
                // komunikat jest wyszukiwany w plikach komunikatów
                // kody błędów w formie tabeli
                String[] codes = error.getCodes();
                // w postaci ciągu znaków
                String listCodes = String.join(" - ", codes);
                // wyszukiwanie
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // czy znaleziono?
                if (msg == null) {
                    throw new Exception(String.format("Indiquez un message pour l'un des codes [%s]", listCodes));
                }
                // znaleziono – dodajemy komunikat o błędzie do listy komunikatów o błędach
                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;
    }

Kod ten jest analogiczny do kodu akcji [/m24]. Wyjaśniamy różnice:

  • wiersz 3: wstrzykujemy żądanie [HttpServletRequest request] do parametrów akcji. Będzie nam to potrzebne;
  • wiersze 7–8: pobieramy kontekst Springa. Kontekst ten zawiera wszystkie fasole Springa w aplikacji. Umożliwia on również dostęp do plików komunikatów;
  • wiersz 10: pobieramy ustawienia regionalne aplikacji. Termin ten zostanie wyjaśniony nieco dalej;
  • wiersze 15–31: dla każdego błędu szukamy komunikatu odpowiadającego jednemu z tych kodów błędów. Są one wyszukiwane w kolejności zgodnej z kodami znalezionymi w pliku [error.getCodes()]. Gdy tylko zostanie znaleziony komunikat, zatrzymujemy się;
  • wiersz 26: sposób na odzyskanie wiadomości w [messages.properties]:
    • pierwszy parametr to kod wyszukiwany w [messages.properties],
    • drugi to tablica parametrów, ponieważ czasami komunikaty zawierają parametry. W tym przypadku tak nie jest,
    • trzeci to używana lokalizacja (uzyskana w wierszu 10). Lokalizacja określa używany język: [fr_FR] dla francuskiego (Francja), [en_US] dla angielskiego z USA. Komunikat jest wyszukiwany w pliku messages_[locale].properties, a więc na przykład w [messages_fr_FR.properties]. Jeśli ten plik nie istnieje, komunikat jest wyszukiwany w pliku [messages_fr.properties]. Jeśli ten plik nie istnieje, komunikat jest wyszukiwany w pliku [messages.properties]. To właśnie ten ostatni przypadek będzie dla nas odpowiedni;
  • wiersze 25–29: co może być nieco zaskakujące, podczas wyszukiwania nieistniejącego kodu w pliku komunikatów pojawia się wyjątek zamiast wskaźnika null;
  • wiersze 33–35: obsługujemy przypadek braku komunikatu o błędzie;
  • wiersze 37–38: tworzymy ciąg błędu. Zawiera on ustawienia regionalne oraz znaleziony komunikat o błędzie;

Oto przykłady działania:

 

Widać, że:

  • ustawienie regionalne aplikacji to [fr_FR]. Jest to wartość domyślna, ponieważ nie podjęliśmy żadnych działań w celu jej zainicjowania;
  • komunikat używany dla obu pól brzmi następująco:

NotNull=Le champ ne peut être vide

Kolejny przykład:

 

Widać, że:

  • komunikat o błędzie używany dla parametru [a] brzmi następująco:

typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
  • komunikat o błędzie używany dla parametru [b] brzmi następująco:

typeMismatch=Format invalide

Dlaczego pojawiają się dwa różne komunikaty? W przypadku parametru [a] możliwe były dwa komunikaty:


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

Kody błędów zostały przeanalizowane zgodnie z kolejnością w tabeli [error.getCodes()]. Okazuje się, że kolejność ta przebiega od kodu najbardziej szczegółowego do kodu najbardziej ogólnego. Dlatego kod [typeMismatch.model01.a] został znaleziony jako pierwszy.

4.20. [/m25]: internacjonalizacja aplikacji Spring MVC

Skoro już wiemy, jak dostosować komunikaty o błędach do języka francuskiego, chcielibyśmy mieć je również w języku angielskim, co prowadzi nas do internacjonalizacji aplikacji Spring MVC. Aby to zrealizować, rozszerzymy klasę konfiguracyjną [Config], która przyjmie następujący kształt:


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;
    }
}
  • wiersze 28–32: tworzymy interceptor żądania. Interceptor żądania rozszerza interfejs [HandlerInterceptor]. Taka klasa sprawdza przychodzące żądanie, zanim zostanie ono przetworzone przez akcję. W tym przypadku interceptor [localeChangeInterceptor] wyszuka w przychodzącym żądaniu parametr o nazwie [lang], GET lub POST i zmieni ustawienia regionalne aplikacji zgodnie z tym parametrem. Jeśli więc parametrem jest [lang=en_US], ustawienia regionalne aplikacji zostaną zmienione na angielskie z USA;
  • wiersze 34–37: redefiniujemy metodę [WebMvcConfigurerAdapter.addInterceptors], aby dodać poprzedni interceptor;
  • wiersze 39–45: służą do skonfigurowania sposobu, w jaki ustawienie regionalne zostanie zapisane w pliku cookie. Wiadomo, że plik cookie może służyć jako pamięć użytkownika, ponieważ przeglądarka klienta systematycznie odsyła go do serwera. Poprzedni interceptor o nazwie [localeChangeInterceptor] tworzy plik cookie zawierający ustawienie regionalne. W wierszu 42 nadano temu plikowi cookie nazwę [lang]. Plik cookie służy również do zmiany ustawień regionalnych;
  • wiersz 43: wskazuje, że w przypadku braku pliku cookie o nazwie [lang] ustawieniem regionalnym będzie [fr];

Podsumowując, ustawienie regionalne żądania można ustalić na dwa sposoby:

  • przez przekazanie parametru o nazwie [lang];
  • poprzez wysłanie pliku cookie o nazwie [lang]. Ten plik cookie jest tworzony automatycznie po zastosowaniu poprzedniej metody;

Aby wykorzystać tę lokalizację, utworzymy pliki komunikatów dla lokalizacji [fr] i [en]:

 

Plik [messages_fr.properties] ma następującą treść:


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

Plik [messages_en.properties] ma następującą treść:


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

Plik [messages.properties] jest kopią pliku [messages_en.properties]. Przypominamy, że plik [messages.properties] jest używany, gdy nie znaleziono żadnego pliku odpowiadającego ustawieniom regionalnym zapytania. W naszym przypadku, jeśli użytkownik wyśle parametr [lang=en], a plik [messages_en.properties] nie istnieje, zostanie użyty plik [messages.properties]. Użytkownik otrzyma zatem komunikaty w języku angielskim.

Spróbujmy. Najpierw w środowisku deweloperskim przeglądarki Chrome (Ctrl-Shift-I) sprawdź pliki cookie:

 

Jeśli masz plik cookie o nazwie [lang], usuń go. Następnie w przeglądarce Chrome wywołaj pliki URL i [http://localhost:8080/m25]:

 

Przeglądarka wysłała następujące nagłówki: 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

Widać, że w tych nagłówkach nie ma pliku cookie o nazwie [lang]. W tym przypadku nasz kod wykorzystuje ustawienie regionalne [fr]. Pokazuje to zrzut ekranu. Sprawdźmy inny przypadek:

  • w przypadku [1] przekazaliśmy parametr [lang=en], aby zmienić ustawienie regionalne na [en];
  • w przypadku [2] widoczne jest nowe ustawienie regionalne;
  • w [3] komunikat został wyświetlony w języku angielskim;

Przyjrzyjmy się teraz wymianie danych w pliku HTTP:

 

Jak widać powyżej, serwer odesłał plik cookie o nazwie [lang]. Ma to istotną konsekwencję: ustawienie regionalne następnego żądania ponownie wyniesie [en] z powodu pliku cookie o nazwie [lang], który zostanie odesłany przez przeglądarkę. Powinniśmy więc zachować komunikaty w języku angielskim. Sprawdźmy to:

 

Powyżej widać, że ustawienie regionalne pozostało na [en]. Ze względu na plik cookie, który przeglądarka wysyła automatycznie, pozostanie ono niezmienione, dopóki użytkownik nie zmieni go, wysyłając parametr [lang] w następujący sposób:

 

4.21. [/m26]: wstawienie ustawienia regionalnego do szablonu akcji

W poprzednim przykładzie pokazaliśmy sposób na pobranie ustawień regionalnych z żądania:


    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
...
        // lokalne
        Locale locale = RequestContextUtils.getLocale(request);
// czy są jakieś błędy?

Ustawienie regionalne można bezpośrednio wstawić do parametrów akcji. Oto przykład:


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

Jak widać powyżej, nie ma weryfikacji poprawności żądanej lokalizacji. Niemniej jednak następujące żądanie przeglądarki powoduje wyjątek po stronie serwera, ponieważ otrzymany plik cookie lokalizacji jest nieprawidłowy.

4.22. [/m27]: sprawdzanie poprawności modelu za pomocą Hibernate Validator

Rozważmy następującą nową akcję:


    //walidacja modelu za pomocą 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>();
        // czy są jakieś błędy?
        if (result.hasErrors()) {
            // przeglądanie listy błędów
            for (FieldError error : result.getFieldErrors()) {
                map.put(error.getField(),
                        String.format("[message=%s, codes=%s]", error.getDefaultMessage(), String.join("|", error.getCodes())));
            }
        } else {
            // brak błędów
            map.put("data", data);
        }
        return map;
}

Mamy tu kod, który widzieliśmy już kilkakrotnie:

  • wiersz 3: akcja [/m27] jest wywoływana za pośrednictwem POST;
  • wiersze 8–11: każdy błąd będzie oznaczony jako [champ, message] z:
    • pole: błędne pole,
    • komunikat: powiązany komunikat o błędzie oraz lista kodów błędów;
  • wiersz 14: jeśli nie ma błędów, zwracany jest ciąg jSON zawierający przesłane wartości;

W wierszu 3 stosuje się następujący szablon akcji [ActionModel02]:

  

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;

    // metody getter i setter

...
}

Klasa wykorzystuje ograniczenia walidacyjne pochodzące z dwóch pakietów:

  • [javax.validation.constraints] w wierszach 5–13;
  • [org.hibernate.validator.constraints] w wierszach 15–19;

Zależności Maven tych dwóch pakietów znajdują się w projekcie:

  

W tym przypadku nie będziemy używać komunikatów zinternacjonalizowanych, lecz komunikaty zdefiniowane wewnątrz reguły za pomocą atrybutu [message]. Aby przetestować tę akcję, użyjemy [Advanced Rest Client]:

  • w [1-2] – zapytanie POST;
  • w [3] nagłówek HTTP [Content-Type], który należy zastosować;
  • w [4] link [Add new value] umożliwia dodanie pary [paramètre, value];
  • w [5] należy umieścić pole z [ActionModel02], w tym przypadku pole [assertFalse]:

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
private Boolean assertFalse;
  • w polu [6] wprowadź błędną wartość, aby wyświetlić komunikat o błędzie. Powyżej ograniczenie [@AssertFalse] wymaga, aby pole [assertFalse] miało wartość [false];
  • w [7] – odpowiedź serwera: uruchomiono ograniczenie [@NotNull] dotyczące pustych pól i wyświetlono powiązany komunikat o błędzie;
  • w [8] wyświetlono komunikat dotyczący pola [assertFalse], dla którego nie sprawdzono ograniczenia [@AssertFalse], wraz z kodami tego błędu. Przypominamy, że kody te mogą być powiązane z komunikatami w językach międzynarodowych;

Oto kolejny przykład:

 

Image

Zachęcamy czytelnika do przetestowania różnych przypadków błędów aż do kodu POST, w którym wszystkie dane są prawidłowe:

Uwaga: format daty jest zgodny z formatem anglosaskim: mm/dd/rrrr.

4.23. [/m28]: przeniesienie komunikatów o błędach do plików zewnętrznych

W klasie [ActionModel02] umieściliśmy komunikaty na stałe. Lepiej jest przenieść je do plików komunikatów. Kierujemy się przykładem akcji [/m25]. Tworzymy następujący nowy szablon akcji [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;

    // metody pobierające i ustawiające
        ...
}

Komunikaty o błędach są przenoszone do plików [messages.properties]:

  

Plik [messages_fr.properties] ma następującą treść:


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

Komunikaty o błędach zostały dodane w wierszach 4–16. Mają one następującą postać:

code=message

Kody nie mogą być dowolne. Są to kody wyświetlone w poprzedniej akcji [/m27]. Na przykład:

Image

W plikach komunikatów w polu [int1014] należy użyć jednego z czterech powyższych kodów.

Plik [messages_en.properties] ma następującą postać:


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

Szablon akcji [ActionModel03] jest wykorzystywany przez następującą akcję:


// ----------------------- eksternalizacja komunikatów o błędach ------------------------
    @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>();
        // kontekst aplikacji Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // ustawienia regionalne
        Locale locale = RequestContextUtils.getLocale(request);
        // błędów?
        if (result.hasErrors()) {
            for (FieldError error : result.getFieldErrors()) {
                // wyszukiwanie komunikatu o błędzie na podstawie kodów błędów
                // komunikat jest wyszukiwany w plikach komunikatów
                // kody błędów w formie tabeli
                String[] codes = error.getCodes();
                // w postaci ciągu znaków
                String listCodes = String.join(" - ", codes);
                // wyszukiwanie
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // czy znaleziono?
                if (msg == null) {
                    msg = String.format("Indiquez un message pour l'un des codes [%s]", listCodes);
                }
                // znaleziono – dodajemy błąd do słownika
                map.put(error.getField(), msg);
            }
        } else {
            // brak błędów
            map.put("data", data);
        }
        return map;
    }

Ten typ kodu został już omówiony. Jedyną naprawdę istotną rzeczą jest wiersz 23: wyświetlany komunikat o błędzie zależy od ustawień regionalnych żądania.

Oto przykład w języku francuskim:

a teraz w języku angielskim: