Step 1: Run server-side simulation asynchronously
Add WebFlux dependency
The web application backend we are building now should be able to give back the current state of the ongoing server-side simulation. We will use Spring WebFlux to stream data to frontend in real time.
Find the spring-boot-starter-web
dependency in the <dependencies>
list of your pom.xml
file.
Replace it with spring-boot-starter-webflux
.
The updated dependency should look like this:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Make sure the code still compiles and runs:
-
To start the application, use your IDE to run the web service using its
BackendApplication
class, or execute themvn spring-boot:run
command from the console. -
Open the
http://localhost:8080/hello
URL in your browser - it should display the 'Hello, World' message.
Implement the API
We will implement API endpoints mentioned in the introduction page of this tutorial.
First, create a new ExperimentService
class
in the web.tutorial.backend.service
package:
@Service
public class ExperimentService {
private static final int UPDATE_ANIMATION_INTERVAL_MS = 100;
@Autowired
private ExperimentService experimentService;
@Autowired
private ScenarioMapper scenarioMapper;
@Autowired
private EmbeddedScenarioProvider embeddedScenarioProvider;
}
Then, create a new ExperimentController
class
in the web.tutorial.backend.controller
package:
@RestController
@RequestMapping("/")
@CrossOrigin
public class ExperimentController {
}
Add appropriate import
statements to both files.
Embedded scenarios API
Add a new method to the ExperimentController
class:
GET /scenarios
@GetMapping(value = "/scenarios")
public List<String> getScenarios() {
return embeddedScenarioProvider.readAllDTO().stream().map(s -> s.name()).toList();
}
Simulation initialization API
ExperimentService
In the ExperimentService
class, add a new method that
will initialize the active simulation experiment associated with the ExperimentService
instance:
public void initSimulation(Scenario scenario) {
simulationId.incrementAndGet();
simulationResetHandlers.forEach(Runnable::run);
simulationResetHandlers.clear();
try {
this.scenario = scenario;
if (engine != null) {
engine.stop();
while (engine.isRunning()) {
Thread.sleep(1000);
}
engine.reset();
}
engine = new Engine();
engine.setFastMode(false);
experimentRun = new ExperimentRun(scenario, engine);
model = experimentRun.getModel();
} catch (Exception e) {
stopSimulation();
throw new ScenarioErrorException(scenario.getName(), e.getMessage());
}
}
ExperimentController
Add these new methods to the ExperimentController
class:
@PostMapping(value = "/init/embedded/{name}")
public ResponseEntity<ScenarioDTO> initSimulation(@PathVariable String name) {
List<ScenarioDTO> scenarios = embeddedScenarioProvider.readAllDTO();
ScenarioDTO scenarioDTO = scenarios.stream().filter(s -> s.name().equals(name)).findAny().orElseThrow();
experimentService.initSimulation(scenarioMapper.readFromDTO(scenarioDTO));
return new ResponseEntity<>(scenarioDTO, HttpStatus.OK);
}
@PostMapping(value = "/init/custom")
public ScenarioDTO initSimulationByUploadedScenario(@RequestPart("file") FilePart file) throws IOException {
Path tempFile = Files.createTempFile(null, ".xlsx");
file.transferTo(tempFile).subscribe();
EMFExcelLoader<Scenario> loader = createExcelTransform().createLoader(tempFile.toString());
loader.load();
Scenario scenario = loader.getRootObject();
experimentService.initSimulation(scenario);
return scenarioMapper.convertToDTO(scenario);
}
private EMFExcelTransform<Scenario> createExcelTransform() {
EMFExcelTransform<Scenario> emfExcelTransform =
new EMFExcelTransform<Scenario>()
.setRootClass(DatamodelPackage.eINSTANCE.getScenario())
.addPackage(EcoreutilsPackage.eINSTANCE)
.addPackage(CalendardatamodelPackage.eINSTANCE)
.addPackage(RandomdatamodelPackage.eINSTANCE)
.addPackage(TimeseriesdatamodelPackage.eINSTANCE)
;
return emfExcelTransform;
}
Simulation flow API
Here we are adding methods to start (resume), stop, and reset the simulation and to change the simulation speed.
ExperimentService
Add these methods to the ExperimentService
class:
public void startSimulation() {
if (engine != null && engine.time() < engine.dateToTime(scenario.getEndDate())) {
engine.run(false);
}
}
public void stopSimulation() {
if (engine == null) {
return;
}
engine.stop();
while (engine.isRunning()) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void resetSimulation() {
initSimulation(this.scenario);
}
public SimulationSpeedDTO runFaster() {
if (engine == null) {
return new SimulationSpeedDTO(1.0);
}
double newTimeScale = Math.min(MAX_SIMULATION_SPEED, engine.timeScale() * 2);
engine.setTimeScale(newTimeScale);
return new SimulationSpeedDTO(newTimeScale);
}
public SimulationSpeedDTO runSlower() {
if (engine == null) {
return new SimulationSpeedDTO(1.0);
}
double newTimeScale = Math.max(MIN_SIMULATION_SPEED, engine.timeScale() / 2);
engine.setTimeScale(newTimeScale);
return new SimulationSpeedDTO(newTimeScale);
}
ExperimentController
Add new methods to the ExperimentController
class:
@PostMapping(value = "/start")
public void startSimulation() {
experimentService.startSimulation();
}
@PostMapping(value = "/stop")
public void stopSimulation() {
experimentService.stopSimulation();
}
@PostMapping(value = "/resume")
public void resumeSimulation() {
experimentService.startSimulation();
}
@PostMapping(value = "/reset")
public void reset() {
experimentService.resetSimulation();
}
@PostMapping(value = "/faster")
public SimulationSpeedDTO faster() {
return experimentService.runFaster();
}
@PostMapping(value = "/slower")
public SimulationSpeedDTO slower() {
return experimentService.runSlower();
}
Real time simulation status API
To see what is going in in the model during simulation, we will add new methods that supply real time information.
This will be the first time that reactive approach is applied in our application. Namely, these new API methods will not return just a single value, but rather a stream of values that is populated in real time as simulation goes on.
DTO
Let’s start with new state transfer objects (DTOs).
Create a new package: web.tutorial.backend.dto.state
.
Add the TruckDTO
class:
package web.tutorial.backend.dto.state;
public record TruckDTO(
String id,
String name,
double currentPositionX,
double currentPositionY,
double currentHeading,
boolean withCargo,
double expenses,
double distanceTraveled) {}
A TruckDTO
object contains the overall statistics for a truck.
Sounds familiar?
Yes, we’ve seen it before in the
'Trucks' table in the desktop application from Part 3 of this tutorial.
Besides, TruckDTO
contains current coordinates of a Truck that
will let us show the road graph animation,
much like in the map animation in the desktop application.
Add the TaskDTO
class:
package web.tutorial.backend.dto.state;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
public record TaskDTO(
String id,
String truck,
String source,
String destination,
@JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime created,
@JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime started,
@JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime deadline,
@JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime completed,
String status) {}
Remember the Gantt Chart from Part 3?
A TaskDTO
object, when it denotes a completed task, contains information for one Gantt Chart item.
We will also display all tasks (not only completed ones) in a tab similar to the Tasks part in the desktop application.
Add the StatsDTO
class:
package web.tutorial.backend.dto.state;
public record StatsDTO(
int serviceLevelPct,
double expenses) {}
Objects of this class contain the data that we can see in the Summary statistics part in the desktop application.
Add the SimulationStateUpdateDTO
class:
package web.tutorial.backend.dto.state;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.List;
public record SimulationStateUpdateDTO (
Integer simulationProgressPct,
@JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime modelDateTime,
StatsDTO stats,
List<TruckDTO> truckStats
){}
Instances of this class hold all information that will be sent to frontend regularly.
ExperimentService
Let’s switch to the ExperimentService
class.
Add the following code to it:
(1)
public int getSimulationId() {
return simulationId.get();
}
(2)
public SimulationStateUpdateDTO getSimulationState() {
if (engine == null) {
return null;
}
Long start = System.currentTimeMillis();
SimulationStateUpdateDTO result;
if (engine.isRunning()) {
SynchronousQueue<SimulationStateUpdateDTO> stateUpdateObject = new SynchronousQueue<>();
engine.visualize(start, () -> {
try {
var stateUpdateDTO = new SimulationStateUpdateDTO(
getSimulationProgressPct(),
getModelDateTime(),
getStats(),
getTrucksStats());
stateUpdateObject.put(stateUpdateDTO);
} catch (Exception e) { }
});
try {
result = stateUpdateObject.take();
} catch (Exception e) {
return null;
}
} else {
result = new SimulationStateUpdateDTO(
getSimulationProgressPct(),
getModelDateTime(),
getStats(),
getTrucksStats());
}
return result;
}
public double getModelTime() {
return model == null ? 0.0 : model.engine().time();
}
private int getSimulationProgressPct() {
if (model == null) {
return 0;
}
return (int) Math.ceil(Math.min(100, 100 * engine.time() / engine.dateToTime(scenario.getEndDate())));
}
private LocalDateTime getModelDateTime() {
if (engine == null) {
return null;
}
return engine.timeToDate(engine.time());
}
private StatsDTO getStats() {
if (engine == null) {
return null;
}
return new StatsDTO(
(int)Math.round(model.getStatistics().getServiceLevel() * 100.0),
Math.round(model.getStatistics().getExpenses() * 100.0) / 100.0);
}
private List<TruckDTO> getTrucksStats() {
if (engine == null) {
return List.of();
}
return model.getTrucks()
.stream()
.map(truck -> new TruckDTO( truck.getId(),
truck.getName(),
truck.getCurrentAnimationPoint().getX(),
truck.getCurrentAnimationPoint().getY(),
truck.getCurrentAnimationHeading(),
truck.getCurrentTask() != null && truck.getCurrentTask().isMovingWithCargo(),
Math.round(truck.getExpenses() * 100.0) / 100.0,
Math.round(truck.getDistanceTraveled() * 100.0) / 100.0))
.toList();
}
(3)
public void addSimulationResetHandler(Runnable handler) {
simulationResetHandlers.add(handler);
}
public void addTaskStateChangeHandler(Consumer<TaskDTO> handler) {
if (model != null) {
model.addTaskStateChangeHandler(task -> handler.accept(convertToDto(task)));
}
}
public void clearTaskStateChangeHandlers() {
if (model != null) {
model.clearTaskStateChangeHandlers();
}
}
(4)
public List<TaskDTO> getAllTasks() {
if (model == null) {
return List.of();
}
return model.getTransportationTasks().stream().map(this::convertToDto).toList();
}
private TaskDTO convertToDto(TransportationTask task) {
var request = task.getRequest();
var truck = task.getTruck();
return new TaskDTO(task.getId(),
truck == null ? "" : truck.getName(),
request.getSourceAsset().getName(),
request.getDestAsset().getName(),
engine.timeToDate(request.getCreatedTime()),
engine.timeToDate(task.getBeginTime()),
engine.timeToDate(request.getDeadlineTime()),
request.isCompleted() ? engine.timeToDate(request.getCompletedTime()) : null,
task.getStatus().toString());
}
1 | The getSimulationId() method denotes the current integer-valued ID
of the active simulation experiment.
Each time a simulation is reset, this ID is incremented.
We will use this ID in the controller to detect an outdated simulation
so as to stop streaming its real-time information (statistics, etc.). |
2 | The getSimulationState() method returns current simulation statistics,
including truck positions, model time, etc. This method is called many times per second
to update the frontend animation and tabular data in real time. |
3 | The addSimulationResetHandler() method adds a callback for simulation resetting.
The addTaskStateChangeHandler() and clearTaskStateChangeHandlers methods
add/remove callbacks for any task-related events happening inside the simulation model.
All three methods will help us create a reactive data stream for the frontend
and populate it with tasks in real time. |
4 | The getAllTasks() method will be used once in the
/taskstream endpoint
to fetch all tasks that were created since the user first called the /start endpoint.
That way, users won’t miss any task data they might not have seen
if they called /start before /taskstream .
Note that there will be exactly one getAllTasks() call per /taskstream execution;
all new task information that appears thereafter will be supplied by the simulation model
sequentially (we added the addTaskStateChangeHandler() method to subscribe to task events). |
ExperimentController
We will now publish this information via the ExperimentController
class.
Add the following code to this class:
(1)
@GetMapping(value = "/updatestream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<SimulationStateUpdateDTO> getSimulationStateUpdates() {
final int simulationId = experimentService.getSimulationId();
return Flux .interval(Duration.ofMillis(UPDATE_ANIMATION_INTERVAL_MS))
.limitRate(1)
.onBackpressureDrop()
.takeWhile(t -> simulationId == experimentService.getSimulationId())
.distinctUntilChanged(p -> experimentService.getModelTime())
.flatMap(this::mapLongCounterToMonoWithSimulationState);
}
private Mono<SimulationStateUpdateDTO> mapLongCounterToMonoWithSimulationState(Long t) {
return Mono .just(t)
.publishOn(Schedulers.boundedElastic())
.mapNotNull(v -> experimentService.getSimulationState())
.timeout(Duration.ofMillis(UPDATE_ANIMATION_INTERVAL_MS * 2), Mono.empty());
}
(2)
@GetMapping(value = "/taskstream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<TaskDTO> getTaskUpdates() {
Flux<TaskDTO> existingTasksFlux = Flux.fromIterable(experimentService.getAllTasks());
Flux<TaskDTO> taskUpdatesFlux = Flux .<TaskDTO>create(sink -> {
experimentService.addTaskStateChangeHandler(taskDto -> sendTaskMessage(taskDto, sink));
experimentService.addSimulationResetHandler(() -> sink.complete());
})
.doFinally(signalType -> experimentService.clearTaskStateChangeHandlers());
return existingTasksFlux.concatWith(taskUpdatesFlux);
}
private void sendTaskMessage(TaskDTO taskDto, FluxSink<TaskDTO> sink) {
sink.next(taskDto);
}
1 | The getSimulationStateUpdates() pushes new data to the stream regularly, no matter if there were any changes. |
2 | The getTaskUpdates() , on the other hand, only posts an event to the stream when some
transportation task is created, finished, or otherwise changed. |
Exception handling
Add these two methods to the ExperimentController
class:
@ExceptionHandler(ScenarioErrorException.class)
public ResponseEntity<?> handleScenarioErrorException(ScenarioErrorException ex) {
return new ResponseEntity<>(
new ExceptionDTO(ex.getScenarioName(), ex.getMessage()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public <X extends Exception> ResponseEntity<?> handleAnyException(X ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
}
Generating API documentation
To make backend debugging easier, add the following dependency to your pom.xml
file:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.1.0</version>
</dependency>
After restarting, the following URLs will be available:
-
http://localhost:8080/v3/api-docs - lists backend API as a JSON.
-
http://localhost:8080/webjars/swagger-ui/index.html - shows Swagger UI for the backend API.
Swagger UI will let you see the API your backend is publishing and also connect to them directly from your browser.
Check the result
-
Start the backend application.
-
Use an API tool (such as Postman or Curl) to send the simulation initialization request:
POST http://localhost:8080/init/embedded/scenario
-
Send a request to get real-time tasks information. Note that this request receives a data stream, so do not expect it to complete instantly. Keep the window/tab with this request open in your API tool and do not terminate the request.
GET http://localhost:8080/taskstream
-
Send a request to get real-time simulation updates. As with task information, keep the window/tab with this request open in your API tool and do not terminate the request.
GET http://localhost:8080/updatestream
-
Send another request to start the simulation:
POST http://localhost:8080/start
You will start receiving messages about tasks and simulation events.
Here is what /taskstream
output should look like:

And this is the /updatestream
expected data:
