Enabling REPLs and conversations with Spring Boot using Spring Shell and JShell

by
Tags: , , , , ,
Category:

Earlier this year at PhillyETE, we had a great talk by Avdi Grimm and Jessica Kerr, REPLs All The Way Up: A Rubric For Virtuous Feedback Loops. In this talk, one of the key theses was to find ways to make exploring your code easier, via REPLs, scenario setups and other means.

Many years ago I used to mount the BeanShell servlet and export my Spring Context so I could write little scripts in a web page in a Spring MVC application. Experimenting by conversing with my application let me find bugs I’d not find without more laborious test writing, and I could take the conversations and turn them into tests or updates to the API later. I always felt scripting was my secret weapon.

Of course, scripting languages and REPLs ate the world since then, and Groovy, Ruby, Python, Clojure, Scala and many other languages sported powerful REPLs. Meanwhile, Java even got a REPL with JShell, and now Spring has come up with a command-line interface builder, Spring Shell. Now everybody has the REPL fever.

You might be wondering how can we can set ourselves up with a modern Spring Boot application to interrogate it with our own questions.

Objective: Having conversations with your application

In this article we’ll look at two ways to get yourself to expose parts of your Spring Boot application to command-line REPL-style exploration. First, we’ll use Spring Shell to create simple command-line interfaces to your application. We’ll see how to set up Spring Shell, create commands, and call our Spring Beans. Next, we’ll use Java’s own REPL, JShell, to boot the same application and provide that real conversational interface.

The sample program is an evolving demo of newer Java and Spring features, using Spring Boot 3.1, Java 20, and Maven. This repo currently has a very rudimentary demo integrating with Redis through Lettuce (via the ReactiveRedisTemplate) and Spring Data Redis. I wanted to play with Redis a bit using containers from Spring (somehow I never used it myself) so I could get the hang of the APIs. This was a perfect excuse to set up that REPL nature in my application.

Let’s start by setting up Spring Shell to expose some commands for interacting with our Redis-based service and repository.

Creating command-line interfaces with Spring Shell

Spring Shell is a tool that helps developers create command-line interfaces to Spring applications. Since a Spring Shell command is simply a Spring Bean, you can wire that command with any Spring-based beans you’d like and interact with them in a simple CLI.

The goal: Interact with Spring Boot APIs for Redis from a CLI

Let’s use Spring Shell and Spring Boot’s Docker support to fire up Redis and interact with the APIs.

Setting up Spring Shell

To use the Spring Shell, first install it in Maven’s dependencies:

<project>
  ...
    <properties>
        ...
        <spring-shell.version>3.1.1</spring-shell.version>
    </properties>

   ...


    <dependencies>
        <dependency>
            <groupId>org.springframework.shell</groupId>
            <artifactId>spring-shell-starter</artifactId>
            <version>${spring-shell.version}</version>
        </dependency>
    </dependencies>
</project>

The spring-shell-starter configures Spring to provide Spring Shell by default in a configuration bean. Any class annotated in your application package path with @ShellComponent will be exposed as a CLI command.

Here’s an example that injects a Spring Data Redis repository that adds and fetches a customer.

@ShellComponent
public class RedisCommands {

    private CustomerCacheRepository repository;

    @Autowired
    public void setRepository(CustomerCacheRepository repository) {
        this.repository = repository;
    }

    @ShellMethod(key = "add-customer-crud")
    public String addCustomerSpringDataCrud(@ShellOption String name ) {
        var insertedCustomer = repository.save(new CustomerCacheEntry(name));
        return insertedCustomer.toString();
    }

    @ShellMethod(key = "get-customer-crud")
    public String getCustomerSpringDataCrud(@ShellOption String uuid ) {
        var fetchedCustomer = repository.findById(UUID.fromString(uuid));
        return fetchedCustomer.toString();
    }
}

Try it out!

Before we dive into the backing code, let’s try it out. Just fire up Spring boot as usual:

$ mvn spring-boot:run

[lots of output, ending in:]
2023-06-21T11:03:29.713-04:00  INFO 21064 --- 
   [  restartedMain] WebfluxAndReactiveSpringDemosApplication : 
      Started WebfluxAndReactiveSpringDemosApplication in 2.063 seconds (process running for 2.262)
shell:>

So, let’s interact! Hitting TAB shows you tab completion options:

shell:>[TAB]
Available commands
add-customer-crud ()   get-customer-crud ()
... and other build-in commands ... 

Let’s create a new Redis cached Customer and fetch it back again:

shell:>add-customer-crud Ken Rimple
CustomerCacheEntry{
  id=a053915e-ab6b-4b24-b9c7-a8b4147b854e,
  name='Ken,Rimple'}

shell:>get-customer-crud a053915e-ab6b-4b24-b9c7-a8b4147b854e
Optional[CustomerCacheEntry{
  id=a053915e-ab6b-4b24-b9c7-a8b4147b854e,
name='Ken,Rimple'}]

YAY! So we were able to inject a Spring bean into our shell command class and call them with a simple command line.

Limitations of Spring Shell

The biggest, most frustrating limitation of Spring Shell is the fact that it will stop your build process waiting on input once it boots the application. For Spring integration tests, that means it will just pause forever, until your CI kills it. In this repo I’ve gone out of my way to come up with ways to fix this situation, and what I’ve arrived at is this solution.

Disable Spring Shell by default

I edited src/main/resources/application.yaml and added these entries to skip the Spring Shell by default:

spring:
  shell:
    interactive:
      enabled: false
    noninteractive:
      enabled: false
    scripting:
      enabled: false

Then I wrote a tiny shell script (well, fragment of one anyway) to send the property in to enable the interactive shell. This script is called run-with-spring-shell.sh and is marked executable (chmod u+x run-with-spring-shell.sh):

mvn spring-boot:run \
  -Dspring-boot.run.arguments=--spring.shell.interactive.enabled=true

(the backslash is a way to make a UNIX command line continue on the next physical line and works well for our blog here)

A little bit of background – what are we calling here?

This example exercises a Spring Data Redis CRUD repository that looks like this:

import org.springframework.data.repository.CrudRepository;

import java.util.UUID;

public interface CustomerCacheRepository 
         extends CrudRepository<CustomerCacheEntry, UUID> {
}

A customer cache entry in Redis would be persisted with a name, keyed by an UUID. Here is the type itself:

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import java.io.Serializable;
import java.util.UUID;

@RedisHash("Customer")
public class CustomerCacheEntry implements Serializable {
  @Id
  private UUID id;
  private String name;

  public CustomerCacheEntry(UUID id, String name) {
    this.id = id;
    this.name = name;
  }

  public CustomerCacheEntry(String name) {
    this.id = UUID.randomUUID();
    this.name = name;
  }

  public CustomerCacheEntry() {

  }

  public void setId(UUID id) {
    this.id = id;
  }

  public UUID getId() {
    return id;
  }

  public String getName() {
    return name;
  }

  @Override
  public String toString() {
    return "CustomerCacheEntry{" +
        "id=" + id +
        ", name='" + name + '\'' +
        '}';
  }
}

This is a simple JavaBean, annotated with @RedisHash, which tells Redis what name to use to store the cache entry. The @Id annotation defines the primary key as a java.util.UUID type. Any other properties become fields in the Redis store.

The Redis Spring Boot configuration is defined below. We are using a LettuceConnectionFactory, which accesses Redis data asychronously using a Java Reactive API. We also define a serializer that can handle the UUID to Redis serialization using a GenericToStringSerializer.

@Slf4j
@EnableCaching
@Configuration
@RequiredArgsConstructor
public class RedisConfig {

  @Bean
  public LettuceConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory();
  }

  @Bean("reactiveRedisTemplate")
  public ReactiveRedisTemplate<UUID, 
         CustomerCacheEntry> reactiveJsonCustomerCacheEntryRedisTemplate() {
    var serializer = new Jackson2JsonRedisSerializer<>(CustomerCacheEntry.class);

    RedisSerializationContext.RedisSerializationContextBuilder<UUID, CustomerCacheEntry> builder
        = RedisSerializationContext.newSerializationContext(
            new GenericToStringSerializer<>(UUID.class));

    var serializationContext = builder.value(serializer).build();
    return new ReactiveRedisTemplate<>(redisConnectionFactory(), serializationContext);
  }
}

Although we are using Redis here, the key point is to mount the Spring Shell starter, annotate a class with @ShellComponent, expose methods with @ShellMethod and inject the shell bean with whatever classes you need to get your work done.

How Spring Shell Helps

As you can see, we can start experimenting with Spring services right away by injecting them into a Spring Shell bean. The full sample experiments with both the high level Spring Data Redis API and the lower-level Spring ReactiveRedisTemplate. This gets us closer to easy experimentation, although the Spring Shell is NOT a REPL and is not as fully featured as, say, JShell.

Limitations of Spring Shell

The Spring Shell is not a Java language REPL; it can’t create variables or run arbitrary code snippets. It’s a simple command interface, great for providing developer utilities. Don’t have a user management UI yet but have to make the calls? Make commands for add-user, update-password, add-user-to-group, etc. It’s a great way to give simple command line tools to your developers and to test out APIs by calling them from a simple command line user interface.

What if we want more than just a set of commands, and we really want to use a REPL? Can we leverage the Java JShell to do this too? Yes, we can. Let’s take a look.

Using JShell with Spring Boot as a full Java REPL

The JShell REPL comes with every version of Java having been released in JDK 9. Just type JShell from a command line and you are suddenly working in Java.

$ jshell

jshell> 1 + 345
$1 ==> 346

jshell> new HashMap<String, Integer>();
$2 ==> {}

jshell> $2.put("One", 1);
$3 ==> null

jshell> $2.put("Two", 2);
$4 ==> null

jshell> var map = $2;
map ==> {One=1, Two=2}

jshell> map.get("One")
$6 ==> 1

jshell> 

This is a full Java runtime. You can create interfaces, records, classes and anything else you can think of on the fly, run scripts from a file, save variables, and much more. I won’t make this a tutorial on JShell itself, for that check out the Introduction to JShell post on Oracle’s site.

Integrating JShell with our build

We do have one problem with using JShell, and that’s the dreaded Java class path. Since our application can get pretty sophisticated, we have to ask our build tool to build up a class path for us and add it to JShell when we launch.

Fortunately, there is a simple script (for OS X, Linux and users of Windows running Java from WSL 2.0) that you can call in the root directory of your project that does this for you. I called mine jshell.sh:

#!/bin/bash

mvn package

CLASSPATH=`mvn dependency:build-classpath \
    -DincludeTypes=jar \-Dmdep.outputFile=/dev/stderr \
    2>&1 >/dev/null`:target/classes \
    jshell -start DEFAULT -start PRINTING -start ./bootstrap.jsh

You’ll see some options after the JShell command for startup settings. There is a reason for this.

Avoid using the SET command to set JShell startup scripts

JShell doesn’t have the concept of directory-level settings files. If you set the default scripts using a /SET START command, it will set it for every session, anywhere your JDK is installed. So hence I use the shell script to launch JShell, and feed the startup arguments through the invocation, which does not change the defaults.

Mark the script as executable and run it

Once you create this file, mark it as executable with chmod u+x jshell.sh, and run it, it will kick off a Maven build and then use Maven’s dependency plugin to fetch the classpath, and use it to set the CLASSPATH environment variable to launch JShell.

Now just fire it off.

./jshell.sh

You should see your build run (you can’t run a REPL against your app if you can’t build the classes, right? We ARE still in Java!), and even watch the Spring context fire up. Let’s see how we did that with those startup scripts…

The startup scripts

I’ve also included three startup scripts – the standard DEFAULT script, which imports some common packages:

---- DEFAULT ----
|  import java.io.*;
|  import java.math.*;
|  import java.net.*;
|  import java.nio.file.*;
|  import java.util.*;
|  import java.util.concurrent.*;
|  import java.util.function.*;
|  import java.util.prefs.*;
|  import java.util.regex.*;
|  import java.util.stream.*;

Also the PRINTING script, which simplifies debug printing to the shell:

|  ---- PRINTING ----
|  void print(boolean b) { System.out.print(b); }
|  void print(char c) { System.out.print(c); }
|  void print(int i) { System.out.print(i); }
|  void print(long l) { System.out.print(l); }
|  void print(float f) { System.out.print(f); }
|  void print(double d) { System.out.print(d); }
|  void print(char s[]) { System.out.print(s); }
|  void print(String s) { System.out.print(s); }
|  void print(Object obj) { System.out.print(obj); }
|  void println() { System.out.println(); }
|  void println(boolean b) { System.out.println(b); }
|  void println(char c) { System.out.println(c); }
|  void println(int i) { System.out.println(i); }
|  void println(long l) { System.out.println(l); }
|  void println(float f) { System.out.println(f); }
|  void println(double d) { System.out.println(d); }
|  void println(char s[]) { System.out.println(s); }
|  void println(String s) { System.out.println(s); }
|  void println(Object obj) { System.out.println(obj); }
|  void printf(java.util.Locale l, String format, Object... args) { System.out.printf(l, format, args); }
|  void printf(String format, Object... args) { System.out.printf(format, args); }

And finally, I’ve imported our application packages, and even boostrapped Spring Boot, exposing our Spring Context for hacking purposes (this one is bootstrap.jsh in our root project directory).

|  ---- ./bootstrap.jsh @ Jun 21, 2023, 10:36:43 AM ----
|  import com.chariot.webfluxandreactivespringdemos.caching.*;
|  import com.chariot.webfluxandreactivespringdemos.caching.entities.*;
|  import com.chariot.webfluxandreactivespringdemos.WebfluxAndReactiveSpringDemosApplication;
|  var context = WebfluxAndReactiveSpringDemosApplication.bootstrap(new String[] {});

Playing with the JShell REPL

let’s fire it up!

./jshell.sh

[lots of output culminating in:]
|  Welcome to JShell -- Version 20.0.1
|  For an introduction type: /help intro
jshell>

OK, so now I’ve got my shell up and running. Let’s use the Spring Data Redis repository to create and fetch a Customer:

jshell> var repository = context.getBean(CustomerCacheRepository.class)
repository ==> org.springframework.data.keyvalue.repository.supp ... eyValueRepository@3578a45b

jshell> repository.save(new Customer("Ken Rimple"))
$6 ==> CustomerCacheEntry{
  id=f2b555a8-2306-428c-ba87-ca3f6697e10c,
  name='Ken Rimple'}

jshell> repository.save(new Customer("Mark Rimple"))
$7 ==> CustomerCacheEntry{
  id=24497942-7b52-4e72-a80e-dfe1af44c2de,
  name='Mark Rimple'}

jshell> repository.findAll()
$8 ==> [
  CustomerCacheEntry{
    id=f2b555a8-2306-428c-ba87-ca3f6697e10c,
    name='Ken Rimple'}, 
  CustomerCacheEntry{
    id=24497942-7b52-4e72-a80e-dfe1af44c2de,
    name='Mark Rimple'}]

jshell> repository.findById(UUID.fromString("f2b555a8-2306-428c-ba87-ca3f6697e10c"))
$9 ==> Optional[
   CustomerCacheEntry{
     id=f2b555a8-2306-428c-ba87-ca3f6697e10c,
     name='Ken Rimple'}]

We can do anything we want in JShell, including creating our own classes, writing functions and calling Lambdas, etc. JShell is really a powerful REPL. Although it takes a little more setup to get it going, it’s more powerful AND doesn’t force you to compile classes just to try things out.

Summary

I hope Spring Shell and JShell gave you a few new options for interrogating and conversing with your Spring-based applications. Although my configuration is given for Maven and on Unix-based operating systems, the same configuration can be set up for Gradle, and you can either run Java from the wonderful WSL 2.0 linux environment in Windows 10+, or manually generate your classpath from Windows.

Sidebar: My money is on getting WSL 2.0 working in Windows. You can even set up Docker, run XWindows apps if you need to, and much more. It’s the best way to work on a cross-OS team if you’re the sole windows user.

References

The video from Avdi Grimm’s talk at Philly ETE is available here.

And here’s another talk with VMWare Tanzu’s DaShaun Carter, talking about Spring Boot 3.1.1 and Java 17+. Check out our interview with him on all things Spring and Java 17 here. Other resources: