Budowanie aplikacji Angular CLI + Spring Boot
Każda nietrywialna aplikacja potrzebuje backendu. O ile obecnie to nie jest prawda, to na potrzeby tego artykułu przyjmijmy, że tak jest. A jak backend współpracujący z aplikacją webową to REST, a jak REST to Spring i Spring Boot. W kilku kolejnych akapitach stworzymy i z sukcesem przygotujemy gotowy do wdrożenia artefakt składający się z aplikacji webowej w Angular 2 i backendu usługowego wykorzystującego Spring Boot.
Artykuł zakłada podstawową znajomość Angular CLI, Spring Boot i Maven.
Wszystkie przedstawione kroki są commitami do testowego repozytorium, dzięki temu sam możesz prześledzić proces tworzenia aplikacji oraz rozwiać wszelkie wątpliwości. Link do repozytorium: demo@github.
Stworzenie minimalnego projektu
Nowy projekt najłatwiej stworzymy wykorzystując Spring Initializer, możemy to zrobić wchodząc http://start.spring.io/ i wyklikując konfigurację projektu, lub możemy to zrobić w stylu prawdziwego geeka - curlem.
$ cd demo
$ curl https://start.spring.io/starter.tgz \
-d groupId=net.lipecki.demo \
-d packageName=net.lipecki.demo \
-d artifactId=demo \
-d dependencies=web \
| tar -xzvf -
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 50190 100 50104 100 86 54925 94 --:--:-- --:--:-- --:--:-- 54878
x mvnw
x .mvn/
x .mvn/wrapper/
x src/
x src/main/
x src/main/java/
x src/main/java/net/
x src/main/java/net/lipecki/
x src/main/java/net/lipecki/demo/
x src/main/resources/
x src/main/resources/static/
x src/main/resources/templates/
x src/test/
x src/test/java/
x src/test/java/net/
x src/test/java/net/lipecki/
x src/test/java/net/lipecki/demo/
x .gitignore
x .mvn/wrapper/maven-wrapper.jar
x .mvn/wrapper/maven-wrapper.properties
x mvnw.cmd
x pom.xml
x src/main/java/net/lipecki/demo/DemoApplication.java
x src/main/resources/application.properties
x src/test/java/net/lipecki/demo/DemoApplicationTests.java
W efekcie dostajemy minimalną aplikację Spring Boot, którą możemy zbudować i uruchomić. Do testów dorzućmy prosty kontroler.
$ curl -L https://goo.gl/MbWM8s -o src/main/java/net/lipecki/demo/GreetingRestController.java
$ cat src/main/java/net/lipecki/demo/GreetingRestController.java
package net.lipecki.demo;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingRestController {
@RequestMapping("/greeting")
public String greeting() {
return "Welcome!";
}
}
Całość możemy zbudować wykorzystując Maven. W zależności od preferencji możemy zbudować aplikację wykorzystując globalnie zainstalowaną w systemie instancję lub skorzystać z dostarczanego ze szkieletem Maven Wrappera. Stosowanie Wrappera pozwala pracować z aplikacją nie zmieniając zainstalowanych w systemie pakietów oraz zapewnia, że możemy różne projekty budować różnymi wersjami Mavena. Wrapper w razie potrzeby ściągnie odpowiednią wersję bibliotek przy pierwszym uruchomieniu.
./mvnw clean package
.
.
.
[INFO] --- spring-boot-maven-plugin:1.4.2.RELEASE:repackage (default) @ demo ---
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.929 s
[INFO] Finished at: 2016-12-07T20:43:32+01:00
[INFO] Final Memory: 28M/325M
[INFO] ------------------------------------------------------------------------
uruchomić
$ java -jar target/demo-0.0.1-SNAPSHOT.jar
.
.
.
2016-12-07 20:44:13.392 INFO 70743 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2016-12-07 20:44:13.397 INFO 70743 --- [ main] net.lipecki.demo.DemoApplication : Started DemoApplication in 2.271 seconds (JVM running for 2.643)
i przetestować
$ curl http://localhost:8080/greeting && echo
Welcome!
Docelowa struktura projektu
W przypadku najprostszego podejścia wystarczające byłoby dorzucenie tylko kodów części webowej do aktualnego szkieletu i zadbania o jego poprawne budowanie. Jednak dla nas wystarczające to za mało, od razu przygotujemy strukturę która będzie miała szansę wytrzymać próbę czasu.
Minimalny sensowny podział to przygotowanie dwóch modułów:
- artefaktu wdrożeniowego z usługami REST,
- aplikacji webowej.
Taki podział aplikacja pozwala nam na dodatkową separację części backend i frontend (w pogoni za ideałem możemy przygotować jeszcze ciekawszą strukturę, szczegóły w jednym z ostatnich akapitów wpisu).
W tym celu:
- dodajemy do projektu dwa moduły,
- demo-app,
- demo-web,
- przenosimy dotychczasową konfigurację budowania i kody do demo-app.
Całość zmian możemy zweryfikować w commicie do testowego repozytorium GitHub: commit.
Po tych zmianach, nadal możemy budować i uruchamiać aplikację, jednak tym razem z poziomu modułu demo-app.
$ ./mvnw clean package
.
.
.
[INFO] Reactor Summary:
[INFO]
[INFO] demo ............................................... SUCCESS [ 0.141 s]
[INFO] demo-app ........................................... SUCCESS [ 4.909 s]
[INFO] demo-web ........................................... SUCCESS [ 0.002 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 5.329 s
[INFO] Finished at: 2016-12-07T21:15:51+01:00
[INFO] Final Memory: 30M/309M
[INFO] ------------------------------------------------------------------------
$ java -jar demo-app/target/demo-app-0.0.1-SNAPSHOT.jar
.
.
.
2016-12-07 21:16:30.569 INFO 71306 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2016-12-07 21:16:30.574 INFO 71306 --- [ main] net.lipecki.demo.DemoApplication : Started DemoApplication in 2.732 seconds (JVM running for 3.117)
Dodanie i budownie części web
Projekt części webowej najłatwiej stworzyć z wykorzystaniem Angular CLI, w tym celu wykonujemy:
$ pwd
/tmp/demo/demo-web/
$ rm -rf src
$ ng init --style=scss
.
.
.
Installing packages for tooling via npm.
Installed packages for tooling via npm.
Standardowy Angular CLI będzie budował aplikację do folderu dist. Jednak konwencja budowania przez Maven zakłada, że wszystkie zasoby, czy to pośrednie, czy docelowe, wygenerowane w trakcie procesu budowania trafią do odpowiednich podfolderów katalogu target. Preferowanie konwencji ponad konfigurację to zawsze dobry pomysł. W tym celu zmieniamy standardową konfigurację folderu budowania z dist na target/webapp w angular-cli.json: commit.
W tym momencie możemy już swobodnie pracować z aplikacją uruchamiając ją za pomocą ng serve. Kolejnym krokiem będzie zintegrowanie procesu budowania ng build z budowaniem modułu Maven. W tym celu wykorzystamy plugin frontend-maven-plugin.
Plugin frontend-maven-plugin pozwala:
- zainstalować niezależną od systemowej wersję node i npm,
- uruchomić instalację zależności npm,
- uruchomić budowanie aplikacji za pomocą ng.
Całość konfiguracji wprowadzamy definiując dodatkowe elementy execution konfiguracji pluginu.
Przed dodaniem konfiguracji poszczególnych kroków dodajemy do sekcji build.plugins definicję samego pluginu:
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.3</version>
<configuration>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<!-- tutaj będą konfiguracje kroków budowaia -->
</executions>
</plugin>
W pierwszym kroku instalujemy wskazaną wersję runtime node i menadżera pakietów npm. Całość jest instalowana lokalnie w folderze target. Dzięki lokalnej instalacji minimalizujemy listę wymagań wstępnych do pracy z naszym projektem, co jest szczególnie ważne przy wykorzystaniu systemów CI/CD.
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v6.9.1</nodeVersion>
</configuration>
</execution>
W drugim kroku plugin za pomocą npm instaluje wszystkie zdefiniowane w package.json zależności naszego projektu. Jest to odpowiednik wykonania npm install w katalogu głównym projektu.
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
</execution>
Ostatni krok to wykonanie właściwego procesu budowania aplikacji z wykorzystaniem Angular CLI.
W celu uproszczenia konfiguracji dodajemy nowy task o nazwie build w pliku package.json. Ręczne zdefiniowanie taska jest o tyle ważne, że w ten sposób będziemy się mogli uniezależnić od systemowej instancji Angular CLI i stosować lokalną wersję zainstalowaną na podstawie definicji w package.json.
"scripts": {
"build": "node node_modules/angular-cli/bin/ng build"
}
Oraz dodajemy wykonanie nowo utworzonego tasku przez plugin.
<execution>
<id>node build app</id>
<phase>prepare-package</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run-script build</arguments>
</configuration>
</execution>
Nazwa tasku build jest czysto umowna, jedyne wymaganie to używanie tej samej w package.json i pom.xml. Jednak trzymanie się konkretnej konwencji, np. build, ułatwi pracę pomiędzy różnymi projektami.
Tak przygotowana konfiguracja pozwala zintegrować budowanie aplikacji web z fazami cyklu życia Maven. Dodatkowo dostajemy uspójniony sposób uruchomienia za pomocą polecenia npm build. Dzięki wykorzystaniu frontend-maven-plugin uniezależniamy proces budowania od środowiska, wszystkie wymagane biblioteki (node, npm, angular-cli) są instalowane i wykonywane lokalnie w folderze projektu.
Całość zmian z tego kroku możemy obejrzeć w commicie GitHub: commit.
Składanie artefaktu z częścią web
Kolejnym krokiem jest umożliwienie spakowania modułu odpowiedzialnego za część web do pojedynczego artefaktu, gotowego do wykorzystania jako zależność lub przesłania do repozytorium artefaktów.
Standardowo Maven obsługuje najpopularniejsze typy artefaktów, np. jar, war, ear. Dla tych typów znany jest sposób ich budowania, struktura archiwów jest odgórnie ustalona i niezmienna pomiędzy projektami. Jednak my chcemy przygotować archiwum w postaci pliku zip, więc wykorzystując maven-assembly-plugin będziemy mogli sami określić jakie pliki i w jaki sposób zbierać budując wynikowy artefakt.
Do pom.xml modułu demo-web dodajemy definicję maven-assembly-plugin zawierającą docelową nazwę artefaktu oraz plik assembly opisujący sposób jego składania.
W sekcji build.plugins dopisujemy:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptor>assembly.xml</descriptor>
<finalName>demo-web-${project.version}</finalName>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
Następnie dodajemy plik assembly.xml (obok pliku pom.xml), w którym określamy docelowy format (zip) oraz które pliki, z którego katalogu spakować do artefaktu (wszystkie z folder target/webapp).
<assembly>
<id>zip</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>target/webapp</directory>
<outputDirectory></outputDirectory>
<includes>
<include>**</include>
</includes>
</fileSet>
</fileSets>
</assembly>
Całość możemy przetestować wykonując:
$ pwd
/tmp/demo
$ ./mvnw clean package
.
.
.
$ ls demo-web/target/demo-web-0.0.1-SNAPSHOT.zip
demo-web/target/demo-web-0.0.1-SNAPSHOT.zip
Commit zawierający zmiany: commit.
Składanie artefaktu wdrożeniowego
Ostatnie co musimy zrobić żeby nasza aplikacja składała się w pojedynczy wykonywalny artefakt to skonfigurować moduł demo-web jako zależność w projekcie demo-app oraz skonfigurowanie pluginu maven-dependency-plugin, który będzie odpowiadał za odpowiednie rozpakowanie zasobów.
Definiujemy zależność na moduł demo-web w pom.xml w sekcji dependencies:
<dependency>
<groupId>net.lipecki.demo</groupId>
<artifactId>demo-web</artifactId>
<version>${project.version}</version>
<type>zip</type>
</dependency>
Standardowo Maven szuka zależności typu jar, jednak nasz moduł web jest typu zip, co możemy jawnie wskazać definiując zależność.
Aplikacja Spring Boot poza serwowaniem zdefiniowany servletów i usług REST hostuje również wszystkie zasoby, które znajdują się na zdefiniowanych ścieżkach zasobów statycznych. W standardowej konfiguracji, jedną z takich ścieżek są zasoby wewnątrz samego jara aplikacji. Korzystając z tej wiedzy skonfigurujemy plugin maven-dependency-plugin w taki sposób, żeby rozpakowywał archiwum modułu web do odpowiedniego katalogu budowania.
W sekcji build.plugins dodajemy:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack</id>
<phase>generate-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>net.lipecki.demo</groupId>
<artifactId>demo-web</artifactId>
<version>${project.version}</version>
<type>zip</type>
</artifactItem>
</artifactItems>
<outputDirectory>${project.build.directory}/classes/resources</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
W tym momencie mamy już kompletny proces budowania aplikacji. Po jego wykonaniu i uruchomieniu aplikacji możemy zarówno wywołać testową usługę REST, jak i obejrzeć szkielet Angular 2.
$ ./mvnw clean package
.
.
.
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary:
[INFO]
[INFO] demo ............................................... SUCCESS [ 0.120 s]
[INFO] demo-web ........................................... SUCCESS [ 18.459 s]
[INFO] demo-app ........................................... SUCCESS [ 5.797 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 24.644 s
[INFO] Finished at: 2016-12-07T22:06:14+01:00
[INFO] Final Memory: 37M/346M
[INFO] ------------------------------------------------------------------------
$ java -jar demo-app/target/demo-app-0.0.1-SNAPSHOT.jar
.
.
.
2016-12-07 22:08:02.937 INFO 72316 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2016-12-07 22:08:02.942 INFO 72316 --- [ main] net.lipecki.demo.DemoApplication : Started DemoApplication in 2.681 seconds (JVM running for 3.077)
$ curl http://localhost:8080/greeting && echo
Welcome!
$ curl http://localhost:8080/
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>DemoWeb</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root>Loading...</app-root>
<script type="text/javascript" src="inline.bundle.js"></script><script type="text/javascript" src="styles.bundle.js"></script><script type="text/javascript" src="vendor.bundle.js"></script><script type="text/javascript" src="main.bundle.js"></script></body>
</html>
Komplet dotychczasowych zmian możemy podsumować w repozytorium GitHub: repozytorium.
Obsługa routingu z wykorzystaniem history.pushState (html5 url style)
Poza samym budowaniem i uruchamianiem aplikacji, warto jeszcze zadbać o wsparcie dla nowych sposobów nawigacji. Całość można łatwo zrealizować wykorzystując mechanizmy generowania stron błędów w Springu. Zanim przejdziemy do kodu, kilka słów wprowadzenia teoretycznego.
Dotychczas aplikacje web można było łatwo rozpoznać po routingu opartym o #… w url. Taki sposób nawigacji nie narzuca żadnych ograniczeń na stronę serwerową aplikacji, jednak tworzy kilka niemożliwych do rozwiązania problemów, np. renderowanie po stronie serwerowej, czy wsparcie dla SEO.
Obecnie, większość nowoczesnych przeglądarek dostarcza nowe API history.pushState pozwalające zrealizować nawigację z pominięciem znaku #. Po szczegóły odsyłam do oficjalnej dokumentacji Angular, natomiast w kolejnych akapitach zajmiemy się konfiguracją Spring Boot wspierającą tę strategię.
Całość jest o tyle ważna, że nawigacja bez # jest zalecaną przez zespół Angular 2 konfiguracją. Przez to jest stosowana zarówna w dokumentacji, oficjalnym guide oraz wszystkich szablonach projektów, w tym również Angular CLI. To jednak oznacza, że do serwera usług będą generowane żądania oparte o ścieżki, które fizycznie nie są nigdzie zdefiniowane, co zakończy się błędami 404. W tej sytuacji, bez dostosowania naszego projektu, nie będziemy w stanie w ogóle uruchomić aplikacji oferującej nawigację opartą o routing.
W założeniu przedstawiony problem możemy uprościć do zwracania treści index.html zawsze wtedy, kiedy standardowo zwrócilibyśmy błąd 404. Rozwiązanie powinno uwzględniać zarówno istnienie zdefiniowanych w aplikacji mapowań, jak i pobieranie zasobów dostępnych w lokalizacjach zasobów statycznych.
Najprostszym rozwiązaniem jest zdefiniowanie własnego ErrorViewResolver, który dla błędów 404 wykona przekierowanie na zasób /index.html.
W tym celu dodajemy do kontekstu beana customErrorViewResolver, który wszystkie żądania standardowo zwracając HttpStatus.NOT_FOUND przekieruje na index.html.
@Bean
public ErrorViewResolver customErrorViewResolver() {
final ModelAndView redirectToIndexHtml = new ModelAndView("forward:/index.html", Collections.emptyMap(), HttpStatus.OK);
return (request, status, model) -> status == HttpStatus.NOT_FOUND ? redirectToIndexHtml : null;
}
Przy takim podejściu warto zadbać o to, żeby zawsze jakiś index.html mógł się rozwiązać!
Sposób wprowadzenia zmiany można prześledzić w commicie GitHub: 57149a4.
Wykorzystanie własnego ErrorViewResolver dodatkowo zapewnia nam wsparcie dla rozróżniania żądań na podstawie nagłówka HTTP produces. To znaczy, że żądania z przeglądarek (zawierające produces = “text/html”) zostaną obsłużone zawartością zasobu /index.html, natomiast pozostałe (np. curl) odpowiedzą standardowym błędem 404.
Możliwe rozszerzenia
Wartym rozważenia rozszerzeniem projektu może być wydzielenie trzeciego modułu i dodatkowe podzielenie aplikacji:
demo
\-- demo-rest
\-- demo-app
\-- demo-web
Gdzie moduły:
- demo-web - zawiera zasoby aplikacji web,
- demo-rest - zawiera samodzielnie uruchamialną aplikację dostarczającą komplet usług REST,
- demo-app - jest złączeniem modułów web i rest w jeden wykonywalny artefakt.
Przy takim podziale uzyskujemy dużą separację pomiędzy modułami. Część backend odpowiedzialna ze udostępnienie usług REST i jest całkowicie niezależna od modułu demo-web. Moduł demo-web także nie ma żadnej zależności. To oznacza, że możemy je rozwijać, wersjonować oraz osadzać rozdzielnie. Dodatkowo wprowadzenie modułu app pozwala pisać usługi REST w oderwaniu od produkcyjnego osadzania, np. możliwe jest lokalne uruchamianie modułu demo-rest jako fat jar z Jetty, podczas gdy produkcyjnie moduł demo-app będzie osadzany jako war na Tomcat.
Codzienna praca z aplikacją
Pełnię możliwości duetu Spring Boot i Angular CLI poczujemy dopiero odpowiednio przygotowując środowisko codziennej pracy.
Część kliencką uruchamiamy przez ng serve, dzięki temu dostajemy kompilację i budowanie aplikacji po każdej zmianie kodów źródłowych oraz dodatkowo powiadomienia live reload i odświeżanie aplikacji w przeglądarce. Część serwerową uruchamiamy w IDE wspierającym hot swap kodów.
Przy takiej konfiguracji aplikacja webowa jest dostępna na porcie 4200, a backend REST na porcie 8080. Musimy jeszcze umożliwić dostęp do usług REST w sposób identyczny z docelowym, w tym celu na porcie 4200 skonfigurujemy proxy do usług.
Dla wygody konfiguracji przenosimy wystawione usługi pod prefix /api i tworzymy plik mapowań proxy w demo-web/proxy.conf.json:
{
"/api": {
"target": "http://localhost:8080",
"secure": false
}
}
We wszystkich zdefiniowanych adnotacjach @RequestMapping dopisałem prefix /api w mapowanym url.
Część serwerową uruchamiamy w IDE (lub dowolny inny sposobów), natomiast część web uruchamiamy przez Angular CLI:
$ ng serve --proxy-config proxy.conf.json
.
.
.
webpack: bundle is now VALID.
$ curl http://localhost:8080/api/greeting && echo
Welcome!
$ curl http://localhost:4200/api/greeting && echo
Welcome!
Dodakowo konfigurację uruchomienia wspierającą proxy usług warto zdefiniować w pliku package.json, w sekcji scripts modyfikujemy polecenie skrypt start:
"scripts": {
"start": "ng serve --proxy-config proxy.conf.json",
}
Dzięki temu nie musimy pamiętać przełączików i parametrów, a całość możemy uruchamiać jednym poleceniem:
$ npm start
.
.
.
webpack: bundle is now VALID.
W ten sposób pracujemy z aplikacją wystawioną pod adresem http://localhost:4200/, a wszystkie zmiany w części serwerowej i webowej możemy mieć odświeżane na bieżąco, zaraz po ich wprowadzeniu.
Commit opisujący wprowadzone zmiany.
Podsumowanie
Jeżeli na co dzień pracujesz z projektami opartymi o Spring Boot i Maven ich integracja z aplikacjami pisanymi w Angular CLI nie będzie stanowić dużego wyzwania. W podstawowej realizacji pomoże Ci plugin maven-frontend-plugin, natomiast wykorzystując dodatkowo maven-assembly-plugin i maven-dependency-plugin możliwe jest przygotowanie dużo bardziej złożonych procesów budowania aplikacji.
Ostateczną wersję aplikacji możemy obejrzeć na GitHub: demo@github.
Na sam koniec chciałbym podziękować Krysi, Jackowi i Marcinowi. Bez Was nie byłoby tego wpisu, dzięki!
Materiały
-
SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
B2B 19 680 - 27 220 PLN netto -
REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
B2B 14 950 - 21 000 PLN netto -
ZOBACZ WSZYSTKIE OGŁOSZENIA
newsletter
techniczny
Podobne wpisy
Czy wiesz, że w Angular 17 została wprowadzona alternatywa dla *ngFor?
-
SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
B2B 19 680 - 27 220 PLN netto -
REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
B2B 14 950 - 21 000 PLN netto -
ZOBACZ WSZYSTKIE OGŁOSZENIA