Skip to content

Latest commit

 

History

History
545 lines (413 loc) · 19.3 KB

File metadata and controls

545 lines (413 loc) · 19.3 KB

GraalPy Spring Boot Guide

1. Getting Started

In this guide, we will use the Python library vaderSentiment from a Spring Boot application written in Java. Screenshot of the app

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • A supported JDK1, preferably the latest GraalVM JDK

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the application

You can use Spring Initializr project generator to generate a basic Spring Boot project skeleton. To generate a project:

  1. Navigate to https://start.spring.io/
  2. GraalPy currently provides plugins for Maven and Gradle. You can select either one as the build system. Note: in the Gradle case, this guide uses Groovy.
  3. Click on Dependencies and select Spring Web
  4. Click on Dependencies and select Thymeleaf
  5. Click on Generate. Download and extract the generated ZIP file

4.1. Application

The generated Spring Boot application will contain the file DemoApplication.java, which is used when running the application via Maven or via deployment. You can also run the main class directly within your IDE if it is configured correctly.

src/main/java/com/example/demo/DemoApplication.java

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

4.2. Dependency configuration

Add the required dependencies for GraalPy in the dependency section of the POM build script. For Gradle, the GraalPy Gradle plugin that we will add in the next section will inject these dependencies automatically.

pom.xml

<dependency>
  <groupId>org.graalvm.python</groupId>
  <artifactId>python</artifactId> <!---->
  <version>25.0.2</version>
  <type>pom</type> <!---->
</dependency>
<dependency>
  <groupId>org.graalvm.python</groupId>
  <artifactId>python-embedding</artifactId> <!---->
  <version>25.0.2</version>
</dependency>

❶ The python dependency is a meta-package that transitively depends on all resources and libraries to run GraalPy.

❷ Note that the python package is not a JAR - it is simply a pom that declares more dependencies.

❸ The python-embedding dependency provides the APIs to manage and use GraalPy from Java.

4.3. Adding packages - GraalPy build plugin configuration

Most Python packages are hosted on PyPI and can be installed via the pip tool. The Python ecosystem has conventions about the filesystem layout of installed packages that need to be kept in mind when embedding into Java. You can use the GraalPy plugins for Gradle and Maven to manage Python packages for you.

Add the graalpy-maven-plugin configuration into the plugins section of the POM or the org.graalvm.python plugin dependency and a graalPy block to your Gradle build:

pom.xml

<plugin>
    <groupId>org.graalvm.python</groupId>
    <artifactId>graalpy-maven-plugin</artifactId>
    <version>25.0.2</version>
    <configuration>
        <packages> <!---->
            <package>vader-sentiment==3.2.1.1</package> <!---->
            <package>requests</package> <!---->
        </packages>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>process-graalpy-resources</goal>
            </goals>
        </execution>
    </executions>
</plugin>

build.gradle

plugins {
  id 'org.graalvm.python' version '25.0.2'
  // ...

build.gradle

graalPy {
    packages = [ // ①
        'vader-sentiment==3.2.1.1', // ②
        'requests' // ③
    ]
}

❶ The packages section lists all Python packages optionally with requirement specifiers.

❷ Python packages and their versions can be specified as if used with pip. Install and pin the vader-sentiment package to version 3.2.1.1.

❸ The vader_sentiment package does not declare requests as a dependency so it has to done so manually at this place.

4.4. Creating a Python context

GraalPy provides APIs to make setting up a context to load Python packages from Java as easy as possible.

Create a Java class which will serve as a wrapper bean for the GraalPy context:

src/main/java/com/example/demo/GraalPyContext.java

package com.example.demo;

import jakarta.annotation.PreDestroy;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
import org.graalvm.python.embedding.GraalPyResources;
import org.springframework.stereotype.Component;

@Component // ①
public class GraalPyContext {
    static final String PYTHON = "python";

    private final Context context;

    public GraalPyContext() {
        context = GraalPyResources.contextBuilder().build(); // ②
        context.initialize(PYTHON); // ③
    }

    public Value eval(String source) {
        return context.eval(PYTHON, source); // ④
    }

    @PreDestroy
    public void close() {
        context.close(true); // ⑤
    }
}

❶ Eagerly initialize as a singleton component.

❷ The created GraalPy context will serve as a single access point to GraalPy for the whole application.

❸ Initializing a GraalPy context isn't cheap, so we do so already at creation time to avoid delayed response time.

❹ Evaluate Python code in the GraalPy context.

❺ Close the GraalPy context at application shutdown.

4.5. Using a Python library from Java

After reading the vaderSentiment docs, you can now write the Java interface that matches the Python type and function you want to call.

GraalPy makes it easy to access Python objects via these interfaces. Java method names are mapped directly to Python method names. Return values and arguments are mapped according to a set of generic rules as well as the Target type mapping. The names of the interfaces can be chosen freely, but it makes sense to base them on the Python types, as we do below.

Your application will call the Python function polarity_scores(text) from the vader_sentiment.SentimentIntensityAnalyzer Python class.

In oder to do so, create a Java interface with a method matching that function:

src/main/java/com/example/demo/SentimentIntensityAnalyzer.java

package com.example.demo;

import java.util.Map;

public interface SentimentIntensityAnalyzer {
    Map<String, Double> polarity_scores(String text); // ①
}

❶ The Java method to call into SentimentIntensityAnalyzer.polarity_scores(text). The Python return value is a dict and can be directly converted to a Java Map on the fly. The same applies to the argument, which is a Python String and therefore also a String on Java side.

Using this Java interface and the GraalPy context, you can now construct a bean which calls the SentimentIntensityAnalyzer.polarity_scores(text) Python function:

src/main/java/com/example/demo/SentimentAnalysisService.java

package com.example.demo;

import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.stereotype.Service;

import java.util.Map;

@Service
@ImportRuntimeHints(SentimentAnalysisService.SentimentIntensityAnalyzerRuntimeHints.class)
public class SentimentAnalysisService {
    private final SentimentIntensityAnalyzer sentimentIntensityAnalyzer;

    public SentimentAnalysisService(GraalPyContext context) {
        var value = context.eval("""
                from vader_sentiment.vader_sentiment import SentimentIntensityAnalyzer
                SentimentIntensityAnalyzer() # ①
                """);
        sentimentIntensityAnalyzer = value.as(SentimentIntensityAnalyzer.class); // ②
    }

    public Map<String, Double> getSentimentScore(String text) {
        return sentimentIntensityAnalyzer.polarity_scores(text); // ③
    }

    static class SentimentIntensityAnalyzerRuntimeHints implements RuntimeHintsRegistrar { // ④
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            hints.proxies().registerJdkProxy(SentimentIntensityAnalyzer.class);
        }
    }
}

❶ The executed Python snippet imports the vader_sentiment.SentimentIntensityAnalyzer Python class into the GraalPy context and returns a new instance of it. Note that the GraalPy context preserves its state and an eventual subsequent eval call accessing SentimentIntensityAnalyzer would not require an import anymore.

❷ Map the obtained org.graalvm.polyglot.Value to the SentimentIntensityAnalyzer type.

❸ Return the sentimentIntensityAnalyzer object.

❹ Register interface as proxy for Value.as() (see 7.1. for more details).

4.6. Index page

The application will have a simple chat-like view, which takes text as input and return its sentiment value in form of an emoticon.

Create a html file, which uses jQuery to query an API endpoint for the sentiment value.

src/main/resources/templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Demo App</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
            crossorigin="anonymous"></script>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <script language="JavaScript">
        $(document).ready(() => {
            $("#form").on("submit", (event) => {
                event.preventDefault();
                let messageInput = $("#message");
                let text = messageInput.val();
                messageInput.val("");
                $("#responses").append(`<div class="row py-3"><div class="col-12"><div class="message-query">${text}</div></div></div>`);
                $.getJSON(
                    "/analyze",
                    {text: text},
                    data => {
                        let reactions = [
                            [0.5, "\u{1F604}"],
                            [0.05, "\u{1F642}"],
                            [-0.05, "\u{1F610}"],
                            [-0.5, "\u{1F641}"],
                        ];
                        let selectedReaction = "\u{1F620}";
                        for (let [threshold, reaction] of reactions) {
                            if (data["compound"] > threshold) {
                                selectedReaction = reaction;
                                break;
                            }
                        }
                        $("#responses").append(`<div class="row py-3"><div class="col-12"><div class="message-reaction display-6">${selectedReaction}</div></div></div>`);
                    }
                );
            });
        });
    </script>
    <style>
        @media (min-width: 768px) {
            .main-container {
                width: 600px;
            }
        }

        .message-query, .message-reaction {
            width: fit-content;
            padding: 2%;
            border-radius: 1.2em;
            color: white;
        }

        .message-query {
            background-color: #5dade2;
        }

        .message-reaction {
            margin-left: auto;
            background-color: #d5dbdb;
        }
    </style>
</head>
<body>
<div class="container py-5 main-container">
    <div class="row align-items-center rounded-3 border shadow-lg">
        <div class="row p-5">
            <h2>Sentiment analysis demo</h2>
            <p class="lead">Input messages and the application will visualize the sentiment.</p>
            <div id="responses"></div>
            <form id="form">
                <div class="row py-3">
                    <div class="col-12">
                        <div class="input-group">
                            <input type="text" id="message" class="form-control" placeholder="Message" required>
                            <button type="submit" class="btn btn-primary btn-lg px-4 me-md-2 fw-bold">Send</button>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
</div>
</body>
</html>

4.7. Controller

To create a microservice that provides a simple sentiment analysis, you also need a controller:

src/main/java/com/example/demo/DemoController.java

package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Controller
public class DemoController {
    private final SentimentAnalysisService sentimentAnalysisService; // ①

    public DemoController(SentimentAnalysisService sentimentAnalysisService) {
        this.sentimentAnalysisService = sentimentAnalysisService;
    }

    @GetMapping("/")
    public String answer() { // ②
        return "index";
    }

    @GetMapping(value = "/analyze", produces = "application/json")
    @ResponseBody
    public Map<String, Double> answer(@RequestParam String text) { // ③
        return sentimentAnalysisService.getSentimentScore(text); // ④
    }
}

❶ Inject our service commponent

❷ Serve the main page

❸ Serve the sentiment analysis endpoint

❹ Use the SentimentAnalysisService component to call the SentimentIntensityAnalyzer.polarity_scores(text) Python function.

4.8. Test

Create a test to verify that when you make a GET request to /analyze you get the expected sentiment score response:

src/test/java/com/example/demo/DemoApplicationTests.java

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.lessThan;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext // Necessary to shut down GraalPy context
class DemoApplicationTests {

    @Autowired
    private MockMvc mockMvc; // ①

    @Test
    void testIndex() throws Exception { // ②
        mockMvc.perform(get("/")).andExpect(status().isOk());
    }

    @Test
    void testSentimentAnalysis() throws Exception { // ③
        mockMvc.perform(get("/analyze").param("text", "I'm happy"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.compound", greaterThan(0.1)));
        mockMvc.perform(get("/analyze").param("text", "This sucks"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.compound", lessThan(-0.1)));
    }
}

❶ Use Spring MVC Test framework to make requests to the controller.

❷ Use MVC mock to make request to the index page and verify it returned an Ok response.

❸ Use MVC mock to make requests to the /analyze endpoint and verify the compound sentiment values are within expected ranges.

5. Testing the Application

To run the tests:

./mvnw test

or

./gradlew test

6. Running the Application

To run the application:

./mvnw spring-boot:run

or

./gradlew bootRun

This will start the application on port 8080.

7. GraalVM Native Executable

7.1. Native Executable metadata

The GraalVM Native Image compilation requires metadata to properly run code that uses dynamic proxies.

To compile the application into a native executable, SentimentAnalysisService is annotated with @ImportRuntimeHints (see 4.5.) and the corresponding SentimentIntensityAnalyzerRuntimeHints register SentimentIntensityAnalyzer for proxy access.

7.2. Generate a Native Executable with GraalVM

We will use GraalVM, the polyglot embeddable virtual machine, to generate a native executable of our Spring Boot application.

Compiling native executables ahead of time with GraalVM improves startup time and reduces the memory footprint of JVM-based applications.

First, add the org.graalvm.buildtools plugin for your build system.

For Maven:

pom.xml

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

For Gradle, add this to the plugins block:

build.gradle

  id 'org.graalvm.buildtools.native' version '0.11.4'

Make sure that the JAVA_HOME environmental variable is set to the location of a GraalVM installation. We recommend using a GraalVM 24.1.

To generate and run a native executable using Maven, run:

./mvnw -Pnative native:compile
./target/graalpy-springboot

To generate a native executable using Gradle, run:

./gradlew nativeCompile
./build/native/nativeCompile/demo

8. Next steps

Footnotes

  1. Oracle JDK 17 and OpenJDK 17 are supported with interpreter only. GraalVM JDK 21, Oracle JDK 21, OpenJDK 21 and newer with JIT compilation. Note: GraalVM for JDK 17 is not supported.