środa, 20 maja 2015

Wstęp do jOOQ

Wstęp

W dobie powszechności bibliotek służących do mapowania klas na bazę lub bazę na klasy (tak zwanych ORMappingów) powstało wiele rozwiązań implementujących jedno lub drugie rozwiązanie. Jednym z takich rozwiązań jest biblioteka jOOQ, która realizuje drugi z tych typów, tzn. na podstawie stworzonej bazy danych tworzy klasy javowe za pomocą których w łatwy sposób możemy zarządzać danymi. Warto wspomnieć o polityce licencyjnej przyjętej przez twórców biblioteki, mianowicie w przypadku korzystania z darmowych baz danych używanie owej jest darmowe. Natomiast w przypadku baz komercyjnych licencja jest już płatna (jest to całkiem logiczne, skoro stać nas na baze to zapewne też stać nas na bibliotekę). Każdy kto myśli o migracji na baze komercyjną, a chce używać jOOQ powinien wzięć to pod uwagę przy jej wyborze.

Co tak właściwie będzie prezentował artykuł?

Celem artykułu jest pokazanie podstawowych możliwości jOOQa (i oczywiście zachęcenie do wypróbowania) na przykładzie akademickim jakim jest realizacja książki telefonicznej umożliwiającej dodawanie/usuwanie kontaktów wraz z telefonami. Książka ta będzie dostępna z poziomu przeglądarki internetowej. Do realizacji tego zadania będę używał frameworka SpringBoot (zakładam czytelniku, że znasz owy framework lub jemu podobny) który spina cała warstwę aplikacyjną. Za warstwę danych będzie odpowiedzialna dobra darmowa baza jaką jest PostgreSQL (w przykładam korzystam z wersji 9.3). Do budowania projektu będę używać oprogramowania Maven. A w końcu do "zaprojektowania" bazy wraz z wygenerowaniem zapytań SQL będę używać świetnej aplikacji chmurowej jaką jest Vertabelo.

Instalacja środowiska

Przestawię poniżej przykładową konfigurację dla dystrybucji Ubuntu, myślę, że w przypadku innych dystrybucji oraz systemu Windows realizacja będzie podobna.
Instalacja PostgreSql:

sudo apt-get install postgresql postgresql-contrib
Konfiguracja PostgreSql:
W pliku
/etc/postgresql/<numer_wersji>/main/pg_hba.conf
zamienic wszystkie metody na trust.
Instalacja Maven:
sudo apt-get install maven
Konfiguracja środowiska niezbędną do realizacji projektu:
W katalogu gdzie chcemy przechowywać źródłą naszego projektu tworzymy następujący plik
pom.xml

    4.0.0

    pl.mostua
    jooq-tutorial
    0.0.1

    
        org.springframework.boot
        spring-boot-starter-parent
        1.2.3.RELEASE
    

    
        
            org.springframework.boot
            spring-boot-starter-web
        
    

    
        1.8
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    

służący programowi maven do budowania środowiska.
Należy też utworzyć odpowiednie pliki java:
src/main/java/jooq_example/TelephoneBookController.java
package jooq_example;

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

@RestController
public class TelephoneBookController {

    @RequestMapping("/")
    public String myPhones() {
        return "Ksiazka kontaktowa";
    }

}

src/main/java/jooq_example/Application.java
package jooq_example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;

import java.sql.Connection;
import java.sql.DriverManager;

@SpringBootApplication
@Configuration
public class Application {

    public static void main(String[] args) {
        ApplicationContext ctx = SpringApplication.run(Application.class, args);
    }
}

Aby uruchimić to co udało nam się stworzyć należy w katalogu głównym projektu wykonąć polecenie
mvn package && java -jar target/jooq-tutorial-0.0.1.jar 
W przeglądarce pod adresem
localhost:8000
powinniśmy ujrzeć naszą stronę internetową.
Teraz możemy przejść do meritum artykułu.

Warstwa danych

Projekt bazy danych zamodelowany w aplikacji Vertabelo wraz z sekwencjami zaczynającymi się od 500, aby można było dodać dane testowe:
Oraz skrypty wygenerowane przez nań.
sql/jooq_tutorial_create.sql:
-- Created by Vertabelo (http://vertabelo.com)
-- Last modification date: 2015-05-17 18:24:18.697

-- tables
-- Table: friend
CREATE TABLE friend (
    id int  NOT NULL,
    name varchar(255)  NOT NULL,
    surname varchar(255)  NOT NULL,
    CONSTRAINT friend_pk PRIMARY KEY (id)
);

-- Table: phone_number
CREATE TABLE phone_number (
    id int  NOT NULL,
    phone varchar(13)  NOT NULL,
    friend_Id int  NOT NULL,
    CONSTRAINT phone_number_pk PRIMARY KEY (id)
);

-- foreign keys
-- Reference:  phone_number_friend (table: phone_number)

ALTER TABLE phone_number ADD CONSTRAINT phone_number_friend 
    FOREIGN KEY (friend_Id)
    REFERENCES friend (id)
    NOT DEFERRABLE 
    INITIALLY IMMEDIATE 
;

-- sequences
-- Sequence: friend_id_seq
CREATE SEQUENCE friend_id_seq
      INCREMENT BY 1
      NO MINVALUE
      NO MAXVALUE
      START WITH 500 
      
      NO CYCLE
      
;

-- Sequence: phone_number_id_seq
CREATE SEQUENCE phone_number_id_seq
      INCREMENT BY 1
      NO MINVALUE
      NO MAXVALUE
      START WITH 500 
      
      NO CYCLE
      
;
-- End of file.
Ponadto warto dodać kilka danych testowych.
sql/jooq_tutorial_create.sql:
INSERT INTO friend VALUES (1, 'Adam', 'Kowalski');
INSERT INTO friend VALUES (2, 'Michał', 'Nowak');

INSERT INTO phone_number VALUES(1, '333222111', 1);
INSERT INTO phone_number VALUES(2, '222333111', 1);
Aby stworzyć bazę która będzie przechowywać nasze dane oraz wykonać powyższe skrypty należy wykonać poniższe polecenie w katalogu głównym projektu
createdb jooq_tutorial -U postgres
psql jooq_tutorial postgres -f sql/jooq_tutorial_create.sql
psql jooq_tutorial postgres -f  sql/jooq_tutorial_add.sql
Żeby sprawdzić czy wszystko się udało możemy się zalogować do bazy:
psql jooq_tutorial postgres
oraz wpisać
SELECT * FROM friends; 
Co pokaże czy z sukcesem udało nam się dodać dane testowe.

Konfiguracja jOOQa

Należy zmodyfkować plik pom.xml w następujący spsób:
<?xml version="1.0" encoding="UTF-8"?>


    4.0.0

    pl.mostua
    jooq-tutorial
    0.0.1

    
        org.springframework.boot
        spring-boot-starter-parent
        1.2.3.RELEASE
    

    
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.jooq
            jooq
            3.4.5
        

        
            org.jooq
            jooq-meta
            3.4.5
        

        
            org.jooq
            jooq-codegen
            3.4.5
        

        
            org.postgresql
            postgresql
            9.3-1102-jdbc41
        
    

    
        1.8
    


    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
            

              
              org.jooq
              jooq-codegen-maven
              3.4.5

              
              
                
                  
                    generate
                  
                
              

              
              
                
                  org.postgresql
                  postgresql
                  9.3-1102-jdbc41
                
              

              
              

                
                
                  org.postgresql.Driver
                  jdbc:postgresql://localhost:5432/jooq_tutorial
                  postgres
                  test
                

                
                
                  org.jooq.util.DefaultGenerator
                  
                    org.jooq.util.postgres.PostgresDatabase
                    .*
                    
                    public
                  
                  
                    jooq_sources
                    src/main/java
                  
                  
                    true
                  
                
              
               
        
    

Wyjaśnienie: Do zależności dodajemy wszystkie zależności komponentów jOOQa oraz sterownik bazy (sekcja <dependencies>). Natomiast w sekcji <plugins> definiujemy plugin jooq-codegen-maven, który wygeneruje nam klasy na podstawie bazy. Zleży on od sterownika bazy (sekcja <dependency>), tę należy zmienić gdy korzystamy z innej. W sekcji <configuration> określamy parametry połączenia z których będzie korzystał plugin (jeżeli ustawimy metodę uwierzytelnienia na trust to hasło nie ma znaczenia, warto o tym pamiętać). W sekcji <generator> określamy typ bazy, to gdzie kod ma być wygenerowany (<target>) oraz, że chcemy wygenerować obiekty DAO (o owych jeszcze wspomnę).
Teraz po wykonaniu polecenia
mvn package
w katalogu głównym projektu powinien pojawić się folder
src/main/java/jooq_sources
w którym plugin jooqa wygenerował odpowiednie klasy.

Implementacja

Zacznijmy od modyfikacji pliku Application tak, żeby dostarczał on odpowiednich beanów.
package jooq_example;

import org.jooq.SQLDialect;
import org.jooq.impl.DefaultConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.sql.Connection;
import java.sql.DriverManager;

@SpringBootApplication
@Configuration
public class Application {

    public static void main(String[] args) {
        ApplicationContext ctx = SpringApplication.run(Application.class, args);
    }

    @Autowired
    private org.jooq.Configuration configuration;

    @Bean(name = "dbConfiguration")
    public org.jooq.Configuration getConfiguration() {
        return new DefaultConfiguration().set(connection).set(SQLDialect.POSTGRES);
    }

    @Bean
    public TelephoneBookManager getTelephoneBookManager() {
        return new TelephoneBookManagerImpl(configuration);
    }

    @Bean
    public Connection getConnection() {
        String userName = "postgres";
        String password = "test";
        String url = "jdbc:postgresql://localhost:5432/jooq_tutorial";
        try {
            return DriverManager.getConnection(url, userName, password);
        } catch (Exception e) {
            throw new RuntimeException("Cannot connect to database");
        }
    }

}
Powyższe beany to
  • Configuration wymagany przez jOOQ, jest to informacja o bazie jak i połączeniu.
  • Connection slużący do połaczenia się z bazą.
  • Implementacje interfejsu TelephoneBookManager realizującego logikę biznesową aplikacji.
Teraz możemy przystąpić do zdefinjowaniu interfejsu TelephoneBookManager realizującego logikę biznesową:
package jooq_example;

import jooq_sources.tables.pojos.Friend;
import jooq_sources.tables.pojos.PhoneNumber;

import java.util.List;

/**
 * Realizuje logike biznesową związana z dodawniem telefonów
 *
 * @author Adam Mościcki
 */


public interface TelephoneBookManager {
    /**
     * Pobiera listę znajomych
     * @return
     */
    List&ltFriend> getFriends();

    /**
     * Dodanie znajmego
     * @return
     */
    void addFriend(Friend friend);

    /**
     * Dodanie znajmego w raz z numerem telefonu
     * @return
     */
    void addFriendWithPhoneNumber(Friend friend, String phoneNumber);

    /**
     * Pobiera listę znajomych
     * @return
     */
    void removeFriend(Integer friendId);

    /**
     * Zwraca listę telefonów danego użytkownika
     * @param friendId
     * @return lista telefonów
     */
    List<PhoneNumber> getFriendPhones(Integer friendId);

    /**
     * Dodanie numeru telefonu dla danego użytkownika
     */
    void addPhoneToFriend(Integer friendId, String phoneNumber);

    /**
     * Usuniecie numeru telefonu
     */
    void removeNumber(Integer phoneId);
}

Oraz implementacje TelephoneBookManagerImpl (tutaj właśnie jOOQ lśnić będzie):
package jooq_example;

import jooq_sources.Sequences;
import jooq_sources.Tables;
import jooq_sources.tables.daos.FriendDao;
import jooq_sources.tables.daos.PhoneNumberDao;
import jooq_sources.tables.pojos.Friend;
import jooq_sources.tables.pojos.PhoneNumber;
import org.jooq.Configuration;
import org.jooq.DSLContext;
import org.jooq.TransactionalRunnable;
import org.jooq.impl.DSL;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

/**
 * @author Adam Mościcki
 */
public class TelephoneBookManagerImpl implements TelephoneBookManager{

    private final Configuration configuration;
    private final FriendDao friendDao;
    private final PhoneNumberDao phoneNumberDao;

    @Autowired
    public TelephoneBookManagerImpl(Configuration configuration) {
        this.configuration = configuration;
        this.friendDao = new FriendDao(configuration);
        this.phoneNumberDao = new PhoneNumberDao(configuration);
    }

    @Override
    public List<Friend> getFriends() {
        return friendDao.findAll();
    }

    @Override
    public void addFriend(Friend friend) {
        //addFriendByDirectlySet(friend);
        //addFriendByDao(friend);
        addFriendDirectlySimple(friend);
    }

    private void addFriendByDao(Friend friend) {
        DSLContext create = DSL.using(configuration);
        Long nextId = create.nextval(Sequences.FRIEND_ID_SEQ);
        friend.setId(nextId.intValue());
        friendDao.insert(friend);
    }

    private void addFriendByDirectlySet(Friend friend) {
        DSLContext create = DSL.using(configuration);
        create.insertInto(Tables.FRIEND)
                .set(Tables.FRIEND.ID, Sequences.FRIEND_ID_SEQ.nextval().cast(Tables.FRIEND.ID))
                .set(Tables.FRIEND.NAME, friend.getName())
                .set(Tables.FRIEND.SURNAME, friend.getSurname())
                .execute();
    }

    private void addFriendDirectlySimple(Friend friend) {
        DSLContext create = DSL.using(configuration);
        create.insertInto(Tables.FRIEND)
                .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                .execute();
    }

    @Override
    public void addFriendWithPhoneNumber(Friend friend, String phoneNumber) {
        addFriendWithPhoneNumberClassic(friend, phoneNumber);
        //addFriendWithPhoneNumberLambda(friend, phoneNumber);
    }

    private void addFriendWithPhoneNumberLambda(Friend friend, String phoneNumber) {
        DSLContext create = DSL.using(configuration);
        create.transaction(configuration -> {
                DSLContext createInner = DSL.using(configuration);
                Integer id = createInner.insertInto(Tables.FRIEND)
                        .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                        .returning(Tables.FRIEND.ID).fetchOne().getId();
                createInner.insertInto(Tables.PHONE_NUMBER)
                        .values(Sequences.PHONE_NUMBER_ID_SEQ.nextval(), phoneNumber).execute();
        });
    }

    private void addFriendWithPhoneNumberClassic(final Friend friend, final String phoneNumber) {
        DSLContext create = DSL.using(configuration);
        create.transaction(new TransactionalRunnable() {
            @Override
            public void run(Configuration configuration) throws Exception {
                DSLContext create = DSL.using(configuration);
                Integer id = create.insertInto(Tables.FRIEND)
                        .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                        .returning(Tables.FRIEND.ID).fetchOne().getId();
                create.insertInto(Tables.PHONE_NUMBER)
                        .values(Sequences.PHONE_NUMBER_ID_SEQ.nextval(), phoneNumber, id).execute();
            }
        });
    }

    @Override
    public void removeFriend(Integer friendId) {
        DSLContext create = DSL.using(configuration);
        create.transaction(configuration -> {
            DSLContext createInner = DSL.using(configuration);
            createInner.delete(Tables.PHONE_NUMBER)
                .where(Tables.PHONE_NUMBER.FRIEND_ID.equal(friendId))
                .execute();
            friendDao.deleteById(friendId);
        });
    }

    @Override
    public List<PhoneNumber> getFriendPhones(Integer friendId) {
        return phoneNumberDao.fetchByFriendId(friendId);
    }

    @Override
    public void addPhoneToFriend(Integer friendId, String phoneNumber) {
        phoneNumberDao.insert(new PhoneNumber(DSL.using(configuration).nextval(Sequences.PHONE_NUMBER_ID_SEQ).intValue(),
                                                phoneNumber, friendId));
    }

    @Override
    public void removeNumber(Integer phoneId) {
        phoneNumberDao.deleteById(phoneId);
    }
}

Wyjaśnienia

Powyższy kod będę stopniowo analizował zaczynając od rzeczy bardzo podstawowych skończywszy na tych bardzo przydatnych.

Interfejsy DSLContext, Configuration oraz obiekt DSL

Obiekt implementujący interfejs Configuration przechowuje w sobie informacje o połączeniu z bazą oraz dialekcie - typie bazy. Obiekt implementujący interfejs DSLContext przechowuje dowiązany kontekst z konkretną konfiguracją, za pomocą tego obiektu możemy już tworzyć konkretne zapytania. Obiekt DSL m.in. zwraca implementacje poszczególnych interfejsów np. DSLContext na podstawie Configuration z czego będziemy często korzystać. Mam nadzieje, że na poniższych przykładach trochę to się rozjaśni.

Dodanie danych

 @Override
    public void addFriend(Friend friend) {
        //addFriendByDirectlySet(friend);
        //addFriendByDao(friend);
        addFriendDirectlySimple(friend);
    }

    private void addFriendByDao(Friend friend) {
        DSLContext create = DSL.using(configuration);
        Long nextId = create.nextval(Sequences.FRIEND_ID_SEQ);
        friend.setId(nextId.intValue());
        friendDao.insert(friend);
    }

    private void addFriendByDirectlySet(Friend friend) {
        DSLContext create = DSL.using(configuration);
        create.insertInto(Tables.FRIEND)
                .set(Tables.FRIEND.ID, Sequences.FRIEND_ID_SEQ.nextval().cast(Tables.FRIEND.ID))
                .set(Tables.FRIEND.NAME, friend.getName())
                .set(Tables.FRIEND.SURNAME, friend.getSurname())
                .execute();
    }

    private void addFriendDirectlySimple(Friend friend) {
        DSLContext create = DSL.using(configuration);
        create.insertInto(Tables.FRIEND)
                .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                .execute();
    }
Zaimplementowałem metodę
addFriend
na 3 równoważne sposóby:
  • addFriendDirectlySet
  • Za pomocą metody insertInto na obiekcie kontekstowym przyjmującej "identyfikator" tabeli wygenerowany przez jOOQa mówimy, że do tabeli friend dla atrybutu 'id' ustawiamy id pobierane z sekwencji, imię użytkownika na te z obiektu friend, nazwisko na te z obiektu friend, po czym dla tych ustawień wykonujemy zbudowane zapytanie. Jest to równoważne z zapytaniem:
    INSERT INTO friend (id,name, surname) VALUES (friend_id_seq.NEXTVAL,'imie','nazwisko');
    Pobranie z sekwencji następnej wartości realizowane jest poprzez odniesienie do obiektu Sequences.FRIEND_ID_SEQ i jego metody nextval(), co dalej jest to rzutowane to typu atrybutu id. Ponadto jeżeli chcielibyśmy pobrać id najpierw, a później je ustawić to wycięcie tej części na żywca nie zadziała, bo obiekt Sequences.FRIEND_ID_SEQ nie wiedziałby o kontekście w jakim jest realizowany, jak to zrobić pokaże poniżej.
  • addFriendDirectlySimple
  • To rozwiązanie robi dokładnie to samo tylko odpowiada sqlowi następującemu:
    INSERT INTO friend VALUES (friend_id_seq.NEXTVAL,'imie','nazwisko');
    Co przy wielu atrybutach staje sie mało czytelne
  • addFriendByDao
  • Ostatnia metoda ustawia zmiennej friend id i poprzez obiekt DAO (Data Access Object), wstawia go do bazy. Obiekty DAO udostępniają podstawowe metody typu wstawianie, usuwanie, wyszukiwanie przez co upraszczają cała logikę.
    Wyciągniecie następnej wartości z sekwencji realizujemy poprzez wywołanie metody nextval oczkującej obiektu sekwencji na obiekcie kontekstowym.

Tranzakcja, returning

    @Override
    public void addFriendWithPhoneNumber(Friend friend, String phoneNumber) {
        addFriendWithPhoneNumberClassic(friend, phoneNumber);
        //addFriendWithPhoneNumberLambda(friend, phoneNumber);
    }

    private void addFriendWithPhoneNumberLambda(Friend friend, String phoneNumber) {
        DSLContext create = DSL.using(configuration);
        create.transaction(configuration -> {
                DSLContext createInner = DSL.using(configuration);
                Integer id = createInner.insertInto(Tables.FRIEND)
                        .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                        .returning(Tables.FRIEND.ID).fetchOne().getId();
                createInner.insertInto(Tables.PHONE_NUMBER)
                        .values(Sequences.PHONE_NUMBER_ID_SEQ.nextval(), phoneNumber).execute();
        });
    }

    private void addFriendWithPhoneNumberClassic(final Friend friend, final String phoneNumber) {
        DSLContext create = DSL.using(configuration);
        create.transaction(new TransactionalRunnable() {
            @Override
            public void run(Configuration configuration) throws Exception {
                DSLContext create = DSL.using(configuration);
                Integer id = create.insertInto(Tables.FRIEND)
                        .values(Sequences.FRIEND_ID_SEQ.nextval(), friend.getName(), friend.getSurname())
                        .returning(Tables.FRIEND.ID).fetchOne().getId();
                create.insertInto(Tables.PHONE_NUMBER)
                        .values(Sequences.PHONE_NUMBER_ID_SEQ.nextval(), phoneNumber, id).execute();
            }
        });
    }
Powyższa metoda addFriendWithPhoneNumber realizuje dodanie użytkownika wraz z numerem telefonów co jest równoważne operacji na dwóch tabelach, stąd uzasadnione jest robienie obu wstawień w obrębie transakcji, ponadto to wymaganie umożliwia pokazanie funkcjonalności Postgresa (oraz jej obsługi przez jOOQ) jaką jest komenda returning umożliwiająca zwrócenie wartości (zbioru wartości) wstawianej (w naszym przypadku id użytkownika, które jest kluczem obcym dla numeru telefenu). Powyższa metoda addFriendWithPhoneNumber , posiada 2 równoważne implementacje, jedna wykorzystująca nowość w javie 8, tzn. wyrażenia lambda (addFriendWithPhoneNumberLambda) operującego na obiekcie implementującym interfejs Configuration, druga wykorzystująca stary mechanizm tzn. anonimową implementacje interfejsu(addFriendWithPhoneNumberClassic) przeciązająca metodę run(Configuration). W jednym jak i drugim przypadku w obrębie metody transaction wykonujemy naszą transakcje. Aby skorzystać z komendy RETURNING, wystarczy że wykonamy metodę .returning(Tables.FRIEND.ID).fetchOne().getId(); tzn. określimy atrybut, a następnie go pobierzemy, co w prosty sposób załatwia nam pobranie danego atrybutu (w tym przypadku id).

Pełna implementacja

Aby nie zaciemniać tematu nie będę wstawiał tutaj implementacji Springowej, która po prostu wywołuje odpowiednie metody menadżera, natomiast drogi Czytelniku jeżeli chcesz to zachęcam do pobrania gotowego kodu oraz jego uruchamianie (ostrzegam, że jest on kodem przykładowym, a więc nie waliduje mnóstwa rzeczy, które powinny być sprawdzona w kodzie produkcyjnym). Implementacja

Podsumowanie

Starałem się pokazać jaka moc drzemie w ORMapingu jakim jest jOOQ jednocześnie prezentując prosty poradnik dla kogoś kto chciałby jej użyć "na już". Warto jednak pamiętać, że jego możliwości daleko wykraczają poza ten wpis, a więc wymagają pewnego wkładu aby je poznać (do czego gorąco zachęcam). A na koniec dla wszystkich zainteresowanych bazami danych polecam odwiedzenie twitera twórcy biblioteki https://twitter.com/lukaseder.

Bibliografia

http://www.jooq.org/
http:/vertabelo.com/
http://spring.io/guides/gs/spring-boot/

Brak komentarzy:

Prześlij komentarz