This section: Spring JPA Command Line Runner
A relatively simple example of a Spring Boot
command line runner that writes to a DB using JPA
. This application shows a CLI (command line interface) menu, letting the user enter "comments" into a database and view them.
When a @SpringBootApplication
sees any org.springframework.boot.CommandLineRunner
implementations, it doesn't start a server but runs those command line runner classes.
This app is in my GitHub: ce-comments
Resources:
This section: Creating the app
Instructions below.
-
Generate the base app using Spring Initializr
-
Link to base app, which uses
- Spring Data JPA.
- H2 Database for an in-memory test DB.
- MySQL Driver for a PROD DB.
- Lombok.
- Java 14.
-
Expanded to
C:\Users\Robert Bram\work\personal_projects\Coding-Exercises\ce-comments
. |-- ce-comments.iml |-- HELP.md |-- mvnw |-- mvnw.cmd |-- pom.xml `-- src |-- main | |-- java | | `-- com | | `-- rmb | | `-- cecomments | | `-- CeCommentsApplication.java | `-- resources | `-- application.properties `-- test `-- java `-- com `-- rmb `-- cecomments `-- CeCommentsApplicationTests.java 12 directories, 8 files
-
-
The
pom.xml
file: pom.xml -
Create the Domain Object - a
Comment
. -
Create the Repository for accessing comments in a data store.
-
Create the Service, which is the business layer of logic that
- Should be the only thing accessing the DAO layer (the repository).
- Should be the only thing implementing
@Transactional
methods. - Test the service
-
Create the Spring Boot Application.
-
Create a Command Line Runner.
-
Set up Logging configuration.
-
Set up Application properties.
-
Set up Test application properties.
This section: Create the database
I have an SQL file, tools\db\createDb.sql
, to create the database.
DROP DATABASE IF EXISTS comments;
DROP USER IF EXISTS 'comments-user'@'localhost';
CREATE DATABASE comments;
CREATE USER 'comments-user'@'localhost' IDENTIFIED WITH mysql_native_password BY '7#@aO*&W^u*8C8T29HyK7foOqd$euzi2jFc5SgP#';
GRANT ALL PRIVILEGES ON comments.* TO 'comments-user'@'localhost';
USE `comments`;
and run it in MySQL Workbench.
This section: pom.xml
- Added
org.apache.commons:commons-lang3
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.rmb</groupId>
<artifactId>ce-comments</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ce-comments</name>
<description>Example of a Spring Boot app Command Line Runner using JPA.</description>
<properties>
<java.version>14</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
This section: Domain Object
A comment.
package com.rmb.cecomments.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Comment.
*/
@Builder(toBuilder = true)
@Data
// We are saving instances of this object via JPA
@Entity
/* - JPA/JSON tools needs a no-args constructor.
- So does @Data.
- They instantiate an empty bean and use setters to init data.
*/
@NoArgsConstructor(force = true)
// @Builder needs an all-args constructor.
@AllArgsConstructor
public class Comment {
/**
* How to format dates when printing comments.
*/
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
/**
* The ID.
*/
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
/**
* Date at which the comment was created.
*/
@NonNull
@Builder.Default
private LocalDateTime created = LocalDateTime.now();
/**
* Comment text.
*/
@NonNull
private String text;
@Override
public String toString() {
return "Created at: " + DATE_TIME_FORMATTER.format(created) +
". Comment: " + text;
}
}
Notes.
@Builder.Default
enables a default value to be used when the object is created through lombok's builder interfaces and via thenew
keyword.
This section: Repository
package com.rmb.cecomments.repo;
import com.rmb.cecomments.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Comment repository.
*/
public interface CommentRepository extends JpaRepository<Comment, Long> {
}
Notes.
- This is a super-simple repository - I am only going to use the default
findAll()
andsave()
methods.
This section: Test the repository
package com.rmb.cecomments.repo;
import com.rmb.cecomments.model.Comment;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Test the repository.
*/
@DataJpaTest
@Slf4j
class CommentRepositoryTest {
/**
* The Comment repository.
*/
@Autowired
private CommentRepository commentRepository;
/**
* Test saving.
*/
@Test
void testSaving() {
final String text = "abc";
Comment comment = Comment.builder()
.text(text)
.build();
final Comment saved = commentRepository.save(comment);
assertNotNull(saved, "Saved object should not be null");
assertEquals(text, saved.getText());
assertNotNull(saved.getCreated());
assertNotNull(saved.getId());
}
/**
* Test find all.
*/
@Test
void testFindAll() {
final String text = "abc";
commentRepository.save(Comment.builder().text(text).build());
commentRepository.findAll();
commentRepository.save(Comment.builder().text(text).build());
commentRepository.findAll();
commentRepository.save(Comment.builder().text(text).build());
commentRepository.findAll();
commentRepository.save(Comment.builder().text(text).build());
final List<Comment> all = commentRepository.findAll();
log.info("Search results: {}", all);
final int expectedSize = 4;
assertEquals(expectedSize, all.size(), "Expected " + expectedSize + " results.");
}
}
Notes.
- This test depends on Test application properties to ensure that it uses an in-memory H2 database.
- The
@DataJpaTest
annotation in this test is very important.- In Testing in Spring Boot, you can see various annotations to test parts of a Spring Boot application, specifically:
@DataJpaTest
@SpringBootTest
- Normally I go straight ahead and use
@SpringBootTest
by default, which will set up the complete Spring Context. But in this example, something very bad goes wrong if I use@SpringBootTest
:- As Spring scans the class path for all the beans it needs to instantiate for the Spring Context, it picks up and then runs the Spring JPA Command Line Runner! This is bad - the command line runner asks displays a command line UI, asks for user input etc. We don't want to do that during this test - all we want is access to the repository and database part of Spring.
- In Testing in Spring Boot, you can see various annotations to test parts of a Spring Boot application, specifically:
- If the
@SpringBootApplication
alsoimplements CommandLineRunner
, the situation is still bad even if I use@DataJpaTest
:- The Spring Context still tries to pick up
@SpringBootApplication
object and because it is only looking for JPA related beans, doesn't instantiate@Service CommentService
, which generates an error because the@SpringBootApplication
class now has an unsatisfied dependency for the service object. - So it is better to have two separate classes for
@SpringBootApplication
and the one thatimplements CommandLineRunner
.- It's also worth nothing that you can have several
CommandLineRunner
s in a single app. Running a@SpringBootApplication
will look for allCommandLineRunner
s from the package tree in which it belongs, so if you need to do several jobs you can do it like that.
- It's also worth nothing that you can have several
- The Spring Context still tries to pick up
This section: Service
The service is simple enough, just offering a single query to find all comments and a method to save a comment.
package com.rmb.cecomments.service;
import com.rmb.cecomments.error.CommentException;
import com.rmb.cecomments.model.Comment;
import com.rmb.cecomments.repo.CommentRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.apache.commons.lang3.StringUtils.isBlank;
/**
* The type Comment service. No need to code against an interface here because the service methods are simple enough.
*/
@Service
@Slf4j
public class CommentService {
/**
* The Comment repository.
*/
private final CommentRepository commentRepository;
/**
* Instantiates a new Comment service.
*
* @param commentRepository the comment repository
*/
@Autowired
public CommentService(final CommentRepository commentRepository) {
this.commentRepository = commentRepository;
}
/**
* Find all comments as a list.
*
* @return the list of all comments
*/
public List<Comment> findAll() {
final List<Comment> all = commentRepository.findAll();
log.info("Retrieving list of all {} comments.", all.size());
return all;
}
/**
* Save a comment.
*
* @param commentText text of the comment
*
* @return the comment just saved.
*
* @throws CommentException if the <code>commentText</code> is invalid.
*/
@Transactional
public Comment save(final String commentText) throws CommentException {
log.debug("Request to save comment text: {}", commentText);
if (isBlank(commentText)) {
throw new CommentException("Comment text cannot be empty.");
}
if (commentText.length() > 200) {
throw new CommentException("Comment text too long (" +
commentText.length() + " characters long). It must be between 1 - 200 characters long.");
}
final Comment comment = Comment.builder()
.text(commentText)
.build();
final Comment saved = commentRepository.save(comment);
log.info("Saved comment: {}", saved);
return saved;
}
}
Notes.
- At this level of simplicity (both in service and application design), we don't need interfaces here. Interfaces don't give us any advantage here.
- Validation is handled in the
save()
method.- In a more complicated application, we could use a validation framework and annotate fields on the domain object with validation rules.
This section: Test the service
Test the service.
package com.rmb.cecomments.service;
import com.rmb.cecomments.error.CommentException;
import com.rmb.cecomments.model.Comment;
import com.rmb.cecomments.repo.CommentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.AdditionalAnswers;
import org.mockito.Mock;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
/**
* The type Comment service test.
*/
class CommentServiceTest {
/**
* The Comment repository that underlies the service.
*/
@Mock
CommentRepository commentRepository;
/**
* The Comment service being tested.
*/
CommentService commentService;
/**
* Sets up.
*/
@BeforeEach
void setUp() {
initMocks(this);
commentService = new CommentService(commentRepository);
}
/**
* Find all.
*/
@Test
void testFindAll() {
// Set up test data - we don't care about the result.. the service method is just a pass-through.
when(commentRepository.findAll()).thenReturn(Collections.emptyList());
// Call the method under test.
commentService.findAll();
// What we care about is that it called the underlying repository method once.
verify(commentRepository, times(1)).findAll();
}
/**
* Test save with valid data.
*/
@Test
void testSave() throws CommentException {
// Set up test data.
when(commentRepository.save(any(Comment.class))).then(AdditionalAnswers.returnsFirstArg());
// Save comment - just the max length.
final String commentText = "12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890";
final Comment saved = commentService.save(commentText);
assertNotNull(saved, "Saved comment should not be null.");
assertEquals(commentText, saved.getText());
}
/**
* Test save with null/blank text fails.
*/
@Test
void testSaveWithBlankText() {
// Save comment with bad data.
Exception exception = assertThrows(CommentException.class, () -> commentService.save(null));
assertEquals("Comment text cannot be empty.", exception.getMessage());
exception = assertThrows(CommentException.class, () -> commentService.save(""));
assertEquals("Comment text cannot be empty.", exception.getMessage());
exception = assertThrows(CommentException.class, () -> commentService.save(" "));
assertEquals("Comment text cannot be empty.", exception.getMessage());
}
/**
* Test save with long text fails.
*/
@Test
void testSaveWithLongText() {
// Save comment with bad data - one character too many.
Exception exception = assertThrows(CommentException.class, () -> commentService.save("12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890"
+ "12345678901234567890123456789012345678901234567890"
+ "1"));
assertEquals("Comment text too long (201 characters long). It must be between 1 - 200 characters long.",
exception.getMessage());
}
}
Notes.
- This is still a unit test. It is not relying on a Spring Context, database, external files etc and as such will be quick.
- The key here is that I am not testing the database i.e. I am mocking the repository object.
- This is useful for testing the
com.rmb.cecomments.service.CommentService#save
method, because it actually has logic in it.- It means I need to be careful to mock the repository so that it gives back results I would expect from the real repository.
- Testing the
com.rmb.cecomments.service.CommentService#findAll
is a bit different, because this service method is just a pass-through method: it has no logic of it's own and just calls the repository method, returning whatever it returns.- In this case, there is no logic that I am testing except for one thing - verify that the repository method actually gets called.
- This is useful for testing the
This section: Spring Boot Application
package com.rmb.cecomments;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@Slf4j
public class CeCommentsApplication {
/**
* The entry point of application.
*
* @param args the input arguments
*/
public static void main(String[] args) {
log.info("STARTING THE APPLICATION");
SpringApplication.run(CeCommentsApplication.class, args);
log.info("APPLICATION FINISHED");
}
}
Notes.
- There is no logic here, but a heck of a lot going on under the covers thanks to
Spring Boot
. - In this example, we are not creating a server - so Spring will look for
CommandLineRunner
s and run them.
This section: Command Line Runner
This is the guts of the application - it runs a simple command line UI to allow a use to enter "comments" into a database and see a list of them.
package com.rmb.cecomments;
import com.rmb.cecomments.error.CommentException;
import com.rmb.cecomments.model.Comment;
import com.rmb.cecomments.service.CommentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Scanner;
import static org.springframework.util.StringUtils.isEmpty;
@Component
@Slf4j
public class CommentsRunner implements CommandLineRunner {
/**
* The Comment service.
*/
private final CommentService commentService;
/**
* Instantiates a new CommentsRunner.
*
* @param commentService the comment service
*/
@Autowired
public CommentsRunner(final CommentService commentService) {
this.commentService = commentService;
}
@Override
public void run(String... args) {
log.info("EXECUTING : command line runner");
final Scanner scanner = new Scanner(System.in);
boolean userWantsToKeepGoing = true;
while (userWantsToKeepGoing) {
displayMenu();
final int userChoice = getUserChoice(scanner);
switch (userChoice) {
case 1 -> createNewComment(scanner);
case 2 -> showAllComments();
case 3 -> {
System.out.println("Exiting...");
userWantsToKeepGoing = false;
}
default -> System.out.println("Invalid option. Please try again.");
}
}
scanner.close();
}
/**
* Show all comments.
*/
private void showAllComments() {
System.out.printf("%n%nListing all comments.%n");
final List<Comment> comments = commentService.findAll();
for (int index = 0; index < comments.size(); index++) {
System.out.printf("%4d: %s%n", index, comments.get(index));
}
}
/**
* Display menu.
*/
private void displayMenu() {
System.out.printf("%n%n---%nMenu%n---%n%nEnter an option and press ENTER.%n");
System.out.printf("1. Enter new comment.%n");
System.out.printf("2. Display all comments.%n");
System.out.printf("3. Exit.%n%n");
}
/**
* Gets user choice: an integer.
*
* @param scanner the scanner we read user input from
*
* @return the user choice as in int or -1 if it was an invalid number.
*/
private int getUserChoice(final Scanner scanner) {
final String choiceString = scanner.nextLine();
if (isEmpty(choiceString)) {
return -1;
}
try {
return Integer.parseInt(choiceString);
} catch (NumberFormatException e) {
return -1;
}
}
/**
* Create new comment.
*
* @param scanner the scanner
*/
private void createNewComment(final Scanner scanner) {
System.out.printf("%n%nComment text cannot be empty and must be between 1 - 200 characters.%n");
System.out.println("Enter comment text and then press ENTER:");
final String comment = scanner.nextLine();
try {
final Comment saved = commentService.save(comment);
System.out.printf("Saved comment %d.", saved.getId());
} catch (CommentException e) {
System.out.printf("Invalid entry. %s", e.getMessage());
}
}
}
Notes.
- I am using
System.out
a lot here, as well as a logger. I have made a deliberate separation here between where output is seen.- Things I want the user to see on the console goes through
System.out
. - Logging I might use to debug the application etc goes to a log (and not the console), as per logging configuration.
- Things I want the user to see on the console goes through
- In this excellent resource: Spring Boot Console Application, it shows the
@SpringBootApplication
also being aCommandLineRunner
.- That makes the application much simpler, but has side effects on JPA tests, as seen in Test the repository.
This section: Logging configuration
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOGS" value="./logs"/>
<appender name="Console"
class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
%black(%d{ISO8601}) %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable
</pattern>
</encoder>
</appender>
<appender name="RollingFile"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOGS}/comments.log</file>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<Pattern>%d %p %C{1.} [%t] %m%n</Pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- rollover daily -->
<fileNamePattern>${LOGS}/comments-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!-- each file should be at most 10MB, keep 60 days worth of history, but at most 20GB -->
<maxFileSize>10MB</maxFileSize>
<maxHistory>60</maxHistory>
<totalSizeCap>100GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- LOG everything at INFO level -->
<root level="info">
<appender-ref ref="RollingFile"/>
</root>
<!-- LOG "com.rmb.cecomments*" at DEBUG level -->
<logger name="com.rmb.cecomments" level="debug" additivity="false">
<appender-ref ref="RollingFile"/>
</logger>
</configuration>
Notes.
-
In a normal web-app, I would have my loggers write to the
Console
, but since this is a command line app, I have loggers write to file only. -
I have a test logging configuration file as well,
src/test/resources/logback-spring.xml
, where I have all logging go to console and a log file, so I can see all logging in my IDE etc.<!-- LOG everything at INFO level --> <root level="info"> <appender-ref ref="RollingFile"/> <appender-ref ref="Console"/> </root> <!-- LOG "com.rmb.cecomments*" at DEBUG level --> <logger name="com.rmb.cecomments" level="debug" additivity="false"> <appender-ref ref="RollingFile"/> <appender-ref ref="Console"/> </logger>
This section: Application properties
debug=false
# Tells Spring that we really really don't want to run a web-app..
spring.main.web-application-type=none
spring.datasource.url=jdbc:mysql://localhost:3306/comments?useUnicode=true&characterEncoding=utf8&useSSL=false&useLegacyDatetimeCode=false&serverTimezone=UTC
spring.datasource.username=comments-user
# suppress inspection "SpellCheckingInspection"
spring.datasource.password=7#@aO*&W^u*8C8T29HyK7foOqd$euzi2jFc5SgP#
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
# Will save data across re-boots and attempt to update schema if changed - for DEV.
spring.jpa.hibernate.ddl-auto=update
# For PROD
# spring.jpa.hibernate.ddl-auto=none
# suppress inspection "SpellCheckingInspection"
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
Notes.
- I specify a MySQL database when running the app. Tests will use an in-memory H2 database.
- I have specified
spring.jpa.hibernate.ddl-auto=update
, which will let Spring/Hibernate update the database if it detects any changes in the domain objects. (@Entity
objects.)- This is generally ok when you are developing the application, but once it is in Production, it isn't such a good idea. You generally want database changes to be much more controlled so you can see when changes were made, who made them and be able to roll them back etc.
- Consider tools such as Liquibase or Flyway.
- Or at the very least, keep DDL and DML in the script that creates the database.
- DDL is Data Definition Language - SQL that creates the schema etc.
- DML is Data Manipulation Language - SQL that creates data.
- Or at the very least, keep DDL and DML in the script that creates the database.
- Turn off auto-update with this property instead:
spring.jpa.hibernate.ddl-auto=none
.
This section: Test application properties
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa
Notes.
- These properties ensure that tests will use an in-memory H2 database.
This section: Build and run the app
Compile the project.
./mvnw clean package
Run it with either of these.
./mvnw spring-boot:run # through maven
java -jar target/ce-comments-0.0.1-SNAPSHOT.jar # running the jar.