De fleste java udviklere har hørt om Spring. Spring blev, ligesom Hibernate, bygget fordi udviklere mente der manglede noget i Java EE, og begge frameworks har derfor været med til at forme teknologier som CDI, og JPA som vi i dag nyder godt af i Java EE. I dag er Spring en paraply, som dækker over mere end 20 projekter, et af de nyeste er Spring Boot.
Spring Boot er som navnet antyder, en Bootstrapping process til Spring. Traditionelt har man brugt Spring til at bygge Web applikationer, der blev deployet på en Servlet/Java EE Container, men det er faktisk en anelse bøvlet for udvikleren, som skal kende et hav af forskellige containere, og hvordan de konfigureres. Med Spring Boot embedder man servlet containeren i sin applikation, og build plugins til Maven og Gradle gør det nemt at bygge én Jar fil som indeholder hele applikationen, og kan startes direkte fra en command line.
Hello World
Man kan ikke snakke om kode uden det obligatoriske Hello World eksempel, heldigvis flyder det ikke så meget i Spring Boot. Vi kan klarer os med en enkelt gradle fil til vores dependencies, og tre Java klasser til vores applikation. Du kan klone koden med git på https://bitbucket.org/Klaus_Groenbaek/helloworld.git
build.gradle
apply plugin: 'java' sourceCompatibility = 1.8 repositories { mavenCentral() } dependencies { compile 'org.springframework.boot:spring-boot-starter-web:1.5.4.RELEASE' compileOnly 'org.projectlombok:lombok:1.16.10' }
3 Java klasses
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } @RestController public class MyController { @GetMapping("/") public MyResponse sayHello() { return new MyResponse().setMessage("Hello World"); } } @Data @Accessors(chain = true) class MyResponse { private String message; }
Hvis du kører Main metoden burde det se sådan ud i en browser
Masser af magi med Spring Boot
Med et simpelt bygge script og mindre end 30 linjer kode har vi en REST service. Applikationen kan startes direkte fra vores IDE, uden at vi først skal downloade og konfigurere en applikationsserver – det er næsten magisk. For den gennemsnitlige internet bruger, kan computer godt virke magiske, men folk som koder ved at magi blot er kode skrevet af andre, så lad og kigge på hvad der faktisk sker.
Spring Starter biblioteker
Spring boot har lavet en masser starter libraries som ikke indeholder nogen kode, men i stedet indeholder en mængde transitive afhængigheder, som definere de jar filer man har brug for til at lave et projekt af en bestemt type, her er det spring-boot-starter-web. Lad os kigge på hvad hvordan dependency træet ser ud. I Gradle gøres det ved at køre kommandoen ‘gradlew dependencies’ i roden af projektet, og det producerer:
compile - Dependencies for source set 'main'. --- org.springframework.boot:spring-boot-starter-web:1.5.4.RELEASE +--- org.springframework.boot:spring-boot-starter:1.5.4.RELEASE | +--- org.springframework.boot:spring-boot:1.5.4.RELEASE | | +--- org.springframework:spring-core:4.3.9.RELEASE .... osv
I alt får vi 32 dependencies, 7 af dem er Spring Boot dependencies, 7 er Spring dependencies, desuden får vi en embedded Tomcat, SLF4J med Logback implementation, og Jackson’s JSON serialiserings framework.
Spring Autoconfiguration
En af de centrale features i Spring Boot er AutoConfiguration, vores kode indeholder ingen konfiguration eller kode der starter den embedded Tomcat, men når Tomcat findes i vores classpath, vil Spring Boot automatisk starter den med en default konfiguration. Der findes i øjeblikket automatisk konfiguration til mere end 100 applikations komponenter, bla. ActiveMQ, Redis, FlyWay, LiquiBase, MongoDB, JDBC, JPA. Standard konfiguration af komponenter foregår i Java properties filer, og mere avanceret konfiguration kan laves i kode – mere om det senere.
Default implementations
En af de ting som gør at man kommer hurtigt i gang med Spring Boot er velvalgte defaults og default implementationer. Hvis vi f.eks tilføjer
org.springframework.boot:spring-boot-starter-security
vil vi se følgende hvis vi prøver at tilgå vores endpoint i en browser.
Kigger vi i outputtet i vores IDE, kan vi se at Spring har skrevet et password i loggen:
Using default security password: 61d37cc6-9292-4d3d-8a99-ccd886606840
Med brugernavnet ‘user’ og det autogenerede password kan man få adgang til vores endpoint. Man kan selvfølgeligt ikke have en applikation der generere et nyt password hver gang man starter den, så for at sætte et password skal vi bruge en konfigurationsfil. Filen skal hedde ‘application.properties’ og ligge i roden af vores classpath, hvilket vil sige at den skal placeres i ‘/src/main/resources’. I filen skriver vi det password vi gerne vil have, f.eks.
security.user.password=LetMeIn
Så med en ekstra dependency og en linie konfiguration har vi fået beskyttet vores service med basic authentication. Du kan finde koden på branch ‘simple-auth’
Security og Test
Det er yderst sjældent at authentication er så simple at den kan klares med auto configuration. Applikations rettigheder er ofte baseret på roller, og disse roller kommer typisk fra et centralt sted i organisationen, som f.eks Active Directory. Desuden er sikkerhed en af de områder af applikationer som bør testes grundigt, og testability er en af mine helt store kæpheste, og et af de steder hvor jeg synes Java EE, er temmelig langt bagud i forhold til Spring. Here er et lidt større eksempel på en Spring security configuration der bruger ‘forumsys.com’ gratis LDAP service.
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } @Data @Accessors(chain = true) class Book { @NotEmpty private String isbn; @NotEmpty private String name; } @Repository public class BookRepository { private final ConcurrentMap<String, Book> books = new ConcurrentHashMap<>(); Collection<Book> allBooks() { return books.values(); } void put(String isbn, Book book) { books.put(isbn, book); } Optional<Book> get(String isbn) { return Optional.ofNullable(books.get(isbn)); } void remove(String isbn) { books.remove(isbn); } } @RestController @RequestMapping("books") public class MyController { @Autowired private BookRepository bookRepository; @GetMapping public Collection<Book> getBooks() { return bookRepository.allBooks(); } @PostMapping @PreAuthorize("hasRole('ROLE_MATHEMATICIANS')") public void addBook(@RequestBody @Valid Book book) { bookRepository.put(book.getIsbn(), book); } @PutMapping("/{isbn}/name/{name}") @PreAuthorize("hasRole('ROLE_SCIENTISTS')") public void update(@PathVariable("isbn") String isbn, @PathVariable("name") String name) { Optional<Book> optional = bookRepository.get(isbn); Book book = optional.orElseThrow(() -> new IllegalStateException( String.format("No book with isbn '%s'", isbn))); book.setName(name); } @DeleteMapping("/{isbn}") @PreAuthorize("hasRole('ROLE_CHEMISTS') or hasRole('ROLE_SCIENTISTS')") public void delete(@PathVariable("isbn") String isbn) { bookRepository.remove(isbn); } } /** * http://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/ * Free ldap with users in 3 groups, mathematicians, scientists, chemists */ @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) @Configuration public class LDAPSecurity extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http. httpBasic() .and() .authorizeRequests().anyRequest().fullyAuthenticated() .and() .csrf().disable(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .contextSource(contextSource()) .userSearchBase("dc=example,dc=com") .userSearchFilter("(uid={0})") .groupSearchBase("dc=example,dc=com") .groupSearchFilter("(uniqueMember={0})"); } @Bean public LdapContextSource contextSource() { DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource( "ldap://ldap.forumsys.com:389"); contextSource.setUserDn("cn=read-only-admin,dc=example,dc=com"); contextSource.setPassword("password"); return contextSource; } }
Med en ekstra klasse til security konfiguration, samt @PreAuthorize har vi lavet en applikation der kan authenticate brugere fra en LDAP server. Koden samt tilhørende tests findes på branchen ‘LDAP’.
Applikationen udstiller et meget simpelt bogkatalog, med nogle underlige permissions. LDAP serveren har 3 grupper af brugere, mathematicians, scientists, chemists. Alle (authenticated users) har lov til at læse bogkataloget, men det er kun mathematicians det kan tilføje bøger, og kun scientists der kan ændre bøger, og man skal være enten chemists eller scientists for at slette en bog.
Standard Spring tests (uden Spring boot) giver mulighed for at teste vores kode på Controller niveau, altså ved at kalde controlleren direkte fra vores test. Hvis vi skal teste sikkerhed kan vi tilføje annotationen @WithMockUser(roles = “MATHEMATICIANS”). Dette gør det nemt bygge en unit test, så man ikke ødelægger koden i en senere refaktorering. Test klassen ser således ud:
@RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.MOCK) public class ControllerTest { @Autowired private MyController controller; @Test @WithMockUser(roles = "MATHEMATICIANS") public void testAddAllowed() { assertTrue("There should be no books", controller.getBooks().isEmpty()); controller.addBook(new Book().setIsbn("1").setName("Moby dick")); assertEquals("There should be 1 book", 1, controller.getBooks().size()); } @Test(expected = AccessDeniedException.class) @WithMockUser(roles = "SCIENTISTS") public void testAddNotAllowed() { assertTrue("There should be no books", controller.getBooks().isEmpty()); controller.addBook(new Book().setIsbn("1").setName("Moby dick")); } }
Der er dog problemer ved kun at teste koden på controller niveau. Hvis en udvikler piller ved sikkerheds konfigurationen, og ved en fejl får slået basic authentication fra, så vil controller test ikke finde fejlen. Spring boot har selvfølgelig en elegant løsning, for da vi allerede har en embedded tomcat server kan vi jo bare få den til at køre controlleren, på samme måde som i produktion. Det betyder at vi kan sende HTTP request, med basic authentication header, ligesom en rigtig klient. Det kunne f.eks se således ud:
@RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HttpControllerTest { @Autowired private MyController controller; @LocalServerPort private int port; @Test public void testAddAllowed() { int bookCount = controller.getBooks().size(); Book book = new Book().setIsbn("1").setName("Moby dick"); HttpHeaders headers = createHeaders("euler", "password"); ResponseEntity<Book[]> exchange = new RestTemplate().exchange( "http://localhost:" + port + "/books", HttpMethod.POST, new HttpEntity<Object>(book, headers), Book[].class); assertEquals("There should be 1 book", bookCount + 1, controller.getBooks().size()); } @Test public void testInvalidCredentials() { Book book = new Book().setIsbn("2").setName("Moby dick"); HttpHeaders headers = createHeaders("euler", "wrong_password"); try { new RestTemplate().exchange( "http://localhost:" + port + "/books", HttpMethod.POST, new HttpEntity<Object>(book, headers), Book[].class); fail("401 is expected"); } catch (RestClientException e) { HttpClientErrorException exception = (HttpClientErrorException) e; assertEquals("Wrong password, 401 forbidden is expected", HttpStatus.UNAUTHORIZED, exception.getStatusCode()); } } @Test public void testAddNotAllowed() { Book book = new Book().setIsbn("2").setName("Moby dick"); HttpHeaders headers = createHeaders("einstein", "password"); try { new RestTemplate().exchange( "http://localhost:" + port + "/books", HttpMethod.POST, new HttpEntity<Object>(book, headers), Book[].class); fail("403 is expected"); } catch (RestClientException e) { HttpClientErrorException exception = (HttpClientErrorException) e; assertEquals("Einstein is a scientists, only mathematicians can add, 403 forbidden is expected", HttpStatus.FORBIDDEN, exception.getStatusCode()); } } private HttpHeaders createHeaders(String username, String password) { HttpHeaders headers = new HttpHeaders(); headers.setAccept(Lists.newArrayList(MediaType.APPLICATION_JSON)); headers.setContentType(MediaType.APPLICATION_JSON); String auth = username + ":" + password; byte[] encodedAuth = Base64.encodeBase64( auth.getBytes(StandardCharsets.US_ASCII)); String authHeader = "Basic " + new String(encodedAuth); headers.set("Authorization", authHeader); return headers; } }
I testen har vi brugt Springs RestTemplate, som er en nem måde at kalde en Rest service på (den har samme funktion som en JAX-RS client). RestTemplate bruger Jacksons JSON serialiserings bibliotek til at converter JSON til Java objekter, ligesom Spring MVC bruger Jackson til at konverter fra Java til JSON når vores RestController returnere et java objekt.
Når jeg har arbejdet med Java EE, har jeg virkelig savnet Springs test framework. For mig er det vigtig at jeg kan starte alle unit test direkte fra mit IDE, og at jeg kan debugge både klient og server koden (på samme tid). De fleste applikationsserver har deres eget test framework, jeg kender også flere der er glade for Arquillian, men jeg har endnu ikke set noget i Java EE verdenen der kommer tæt på flexibiliteten i Springs test framework.
Configuration done right
Et andet sted hvor Spring boot virkelig har skudt papegøjen er deres konfigurations framework. Alle it systemer kræver konfiguration, og de fleste it systemer har minimum 2 environments, udvikling og produktion. Jeg har set adskillige forsøg på at løse konfigurationsproblemet, men ingen kommer tæt på den elegance som Spring Boots konfigurationssystem levere.
Vi har allerede set at Spring Boot properties kan konfigureres i application.properties, og der er masser af komponenter der kan konfigureres. Hvis vi f.eks vil have vores embedded tomcat til lytte på port 80 i stedet for 8080, så tilføjer vi linien
server.port=80
Vil vi ændre log level for Spring web MVC til debug, tilføjer vi
logging.level.org.springframework.web=DEBUG
Bruger du Intellij, er der selvfølgelig completion på alle kendte Spring properties.
Spring Boot benytter sig desuden af Springs profil system, så hvis man har flere environments laver man bare en profil til hver, og vælger hvilken profil man vil køre med når man starter applikationen. Man kunne f.eks. have følgende setup
application.properties spring.thymeleaf.mode=HTML5 spring.resources.chain.cache=true logging.level.org.springframework=INFO
application-dev-properties spring.thymeleaf.cache=false spring.resources.chain.cache=false logging.level.org.springframework.web=DEBUG
Hvis man ikke angiver nogen profil, får man konfigurationen fra application.properties, angiver man ‘dev’ profilen får en et merge af de to konfigurationsfiler hvor dev konfiguration overskriver default konfigurationen, præcis som man forventer.
Den erfarne udvikler vil selvfølgelig straks bemærke at det sjældent giver mening at have alt konfigurationen som en del af classpath, altså inde i sin jar/war fil. Derfor kan man selvfølgelig angive andre stier hvor konfigurationen skal loades fra (f.eks. user.home). Faktisk findes der intet mindre end 17 forskellig property sources, f.eks. System Propeties, Environment variables, ServletContext etc. Starter man f.eks. en Spring Boot application inden i en Docker container kan man definere/overskrive individuelle properties med environment variable i sin docker konfiguration; på den måde deployer man præcis den samme applikation i alle environments, mens hver environment leverer credentials til datasources og andet konfiguration.
Man kan desuden selv definere properties som kan injectes ind i Spring beans, det betyder at det er super nemt at lave ting konfigurerbare. Her er et simpelt eksempel, på en komponent som skal bruge en afsenderadresse når der skal sendes emails.
@Component public class MailSender { @Value("${email.from}") private String fromAddress; public void sendEmail() { ... do stuff } }
Og i application.properties
email.from=[email protected]
Deployment i Spring Boot
Spring boot applikationer deployes typisk som en enkelt jar fil. Traditionelt har single jar deployments været bygget som über-jar, hvor man unzipper alle dependencies og pakker dem sammen med de kompilerede klasser fra ens eget projekt, hvilket kan give en del problemer. Spring Boot har derimod lavet et sand genistreg, ved at udnytte zip formatet så det er muligt at loaded nestede jar filer. Check
https://docs.spring.io/spring-boot/docs/current/reference/html/executable-jar.html for flere detaljer.
For at pakke en spring applikation som en enkelt jar fil, skal man bruge deres build tool. I gradle kræves der blot at man inkluderer:
apply plugin: 'org.springframework.boot' buildscript { repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:1.5.4.RELEASE") } }
Spring Boot’s gradle plugin har desuden den fordel at man ikke behøver at angive versionen på de Spring biblioteker man anvender. Der findes selvfølgelig også et maven plugin – for dem der stadig bruger den slags 😉
Skulle man få lyst til at deploye sin spring boot application på en application server, skal man blot extende SpringBootServletInitializer og overskrive configure metoden. Det betyder at følgende klasse kan både køres som en main fra dit IDE, fra en command line via den embedded Tomcat server, eller deployes direkte på en servlet container.
@SpringBootApplication public class Application extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(Application.class); } }
Lombok
Lombok har egentlig ikke noget med Spring at gøre, men det er bestemt et library som alle Java udviklere bør kende, og det er altid den første dependency jeg tilføjer til et nyt projekt. Lombok er en annotationsprocessor, som efter kompilering bl.a. kan autogenerer getter, setter, toString, equals og hashCode baseret på annotationer, hvilket har følgende fordele:
- Reducere mængden af boilerplate.
- Du skal ikke huske at genere en ny equals og hashCode, når du tilføjer nye felter.
Særlig punkt 2 er vigtigt, – jeg har flere gang opdaget fejl, som opstod fordi man havde generet equals og hashCode i sit IDE, men glemt at regenerere dem, når man tilføjede et nyt felt – man kan bruge lang tid på at debugge den slags fejl.
Den absolut eneste grund til ikke at bruge Lombok er, hvis man skal generere JavaDoc, da man har brug for en metode at skriver dokumentationen på. Du kan læse mere om Lombok på:
Du skal installere et plugin, for at dit IDE kan regne ud, hvilke metoder som bliver generet af kompileren. Du skal også sørge for at dit IDE ved, at den skal kører annotationsprocessoren når den kompilere klasserne.