Spring-Boot-3-Anwendung auf AWS Lambda - Teil 3

Spring Cloud Function Framework

Spring Cloud Function Framework

Spring-Boot-3-Anwendung auf AWS Lambda - Teil 3

Spring Cloud Function Framework


Bisher haben wir die Konzepte hinter dem AWS Serverless Java Container Framework und dem AWS Lambda Web Adapter Tool kennengelernt, eine Spring-Boot-3-Anwendung auf AWS Lambda damit entwickelt und die Performanz der Lambda-Funktion gemessen und optimiert [1], [2]. Im dritten Teil dieser Serie geht es um eine weitere Alternative, das Spring Cloud Function Framework bzw. den dazugehörigen AWS Adapter.

Beginnen wir mit einer Einführung in das Spring Cloud Function Framework. Es handelt sich dabei um ein Projekt mit den folgenden übergeordneten Zielen:

  • Förderung der Implementierung der Geschäftslogik durch Funktionen.

  • Entkopplung des Entwicklungslebenszyklus der Geschäftslogik von einem bestimmten Laufzeitziel, sodass derselbe Code als Webendpunkt, Streamprozessor oder als Job ausgeführt werden kann. Eines dieser spezifischen Laufzeitziele kann AWS Lambda sein, um das es in diesem Artikel geht.

  • Unterstützung eines einheitlichen Programmiermodells über Cloud-Anbieter bzw. deren serverlose Dienste hinweg sowie die Möglichkeit der eigenständigen Ausführung (lokal oder in einem PaaS).

  • Aktivierung von Spring-Boot-Funktionen (Autokonfiguration, Dependency Injection, Metriken) für verschiede Cloud-Anbieter bzw. deren serverlose Dienste.

  • Es abstrahiert alle Transportdetails und die Infrastruktur, sodass der Entwickler alle vertrauten Tools und Prozesse beibehalten und sich ganz auf die Geschäftslogik konzentrieren kann.

  • Eine einfache Funktionsanwendung im Kontext des Spring (Boot) Frameworks ist eine Anwendung, die Beans vom Typ Supplier oder dem Java 8 Function Interface enthält [3].

Listing 1 zeigt eine sehr einfache Spring-Cloud-Function-Anwendung, die den entgegengenommenen String in Großbuchstaben zurückgibt.

Listing 1

@SpringBootApplication

public class FunctionConfiguration {

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

  @Bean
  public Function<String, String> uppercase() {
    return value -> value.toUpperCase();
  }
}

AWS Adapter für das Spring Cloud Function Framework

Mit dem AWS (Lambda) Adapter wird die Applikation, die Spring Cloud Function verwendet, so umgewandelt, dass sie auf AWS Lambda ausgeführt werden kann [4]. Bei AWS bedeutet das, dass Spring Function Beans erkannt und in der AWS-Lambda-Umgebung ausgeführt werden. Das passt sehr gut in das AWS-Lambda-Modell mit dem Amazon API Gateway, das ähnlich wie die Java-8-Funktion die (HTTP-)Anfrage empfängt, Geschäftslogik ausführt und dann die (HTTP-)Antwort an den Aufrufer sendet.

Würde man die Anwendung aus Listing 1 in AWS auf AWS Lambda ausführen und das Amazon API Gateway verwenden, um die REST-Anfragen entgegenzunehmen, würden die Schritte aussehen, wie es Abbildung 1 zeigt.

kazulkin_spring_lambda_3_1

Abb. 1: Anfrage- und Rückgabefluss des Spring Cloud Function Framework, ausgeführt in der AWS-Cloud (API Gateway und Lambda)

Der AWS Request Adapter konvertiert das von der Lambda-Funktion kommende JSON in das HttpServletRequest-Objekt, das dann das Spring Dispatcher Servlet aufruft, das wiederum mit unserer Spring-Boot-Anwendung auf API-Ebene interagiert, ohne den Webserver (z. B. Tomcat) zu starten. Dann fließt die Antwort zurück und der AWS Response Adapter konvertiert das HttpServletResponse-Objekt in JSON, das die Lambda-Funktion zurück an das API Gateway sendet [5], [6], [7].

Spring Cloud Function Framework in AWS

Es gibt mehrere Möglichkeiten, AWS Lambda mit Spring Cloud Function in AWS unter Verwendung von Spring Boot 3 zu entwickeln:

  • Im Großen und Ganzen kann die Anwendung, die wir mit dem AWS Serverless Java Container im ersten Teil der Artikelserie vorgestellt haben, aufgrund der im Spring Boot 3 Serverless Java Container genutzten Abhängigkeit zu spring-cloud-function-serverless-web auch als Spring-Cloud-Function-AWS-Anwendung betrachtet werden [8]. Das ist das Ergebnis der Zusammenarbeit zwischen Spring- und AWS-Entwicklern.

  • Wir können Spring Beans (mit @Bean annotiert) auch direkt in der Hauptklasse der Spring-Boot-Anwendung (mit @SpringBootApplication annotiert) definieren und den mit @Bean annotierten Methodennamen so zuordnen, dass er genau dem Namen der Lambda-Funktion in der Infrastructure as a Code (d. h. der AWS-SAM-Vorlage) entspricht. Ein Beispiel für einen solchen Ansatz haben wir in Listing 1 gesehen. Dieser Ansatz ist besonders dann geeignet, wenn es mehrere eher kleine zusammengehörende Funktionen gibt, die man in einer Klasse unterbringen möchte.

  • Ein anderer Ansatz ist die Verwendung des Lambda Handler org.springframework.cloud.function.adapter.aws.web.WebProxyInvoker::handleRequest und des klassischen Spring-Boot-REST-Controllers (annotiert mit @RestController). Ein Beispiel für einen solchen Ansatz findet ihr unter [9].

In diesem Artikel möchte ich einen Ansatz zeigen, wie man eine ganze Klasse, die das Java 8 Function Interface implementiert, mit Hilfe des Spring Cloud Function Frameworks in eine AWS-Lambda-Funktion umwandelt. Zur Erläuterung verwenden wir eine Spring-Boot-3-Beispielanwendung, basierend auf Spring Cloud Function und AWS Lambda Adapter, und nutzen Spring Boot 3.2. sowie die Java 21 Runtime für unsere Lambda-Funktionen (Abb. 2) [10].

kazulkin_spring_lambda_3_2

Abb. 2: Architektur der Beispielanwendung

In dieser Anwendung erstellen wir Produkte und rufen sie nach ihrer ID ab, wobei wir Amazon DynamoDB als NoSQL-Datenbank für die Persistenzschicht verwenden. Wir nutzen das Amazon API Gateway, das das Erstellen, Veröffentlichen, Warten, Überwachen und Sichern von APIs für Entwickler vereinfacht. Außerdem setzen wir AWS SAM ein, das eine Kurzsyntax anbietet, die für die Definition von Infrastruktur als Code (nachfolgend IaC) für serverlose Anwendungen optimiert ist. Den vollständigen Code, inklusive IaC, basierend auf AWS SAM (template.yaml), findet ihr in meinem GitHub-Repository [10]. Jetzt schauen wir uns relevante Quellcodefragmente an.

In der pom.xml müssen wir einige Abhängigkeiten definieren, u. a. den zuvor beschriebenen Spring Cloud Function AWS Adapter, die Spring Cloud Function in AWS Lambda konvertieren und als Webanwendung bereitstellen (Listing 2). Unsere Spring-Boot-3-Hauptklasse zeigt Listing 3.

Listing 2

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-function-adapter-aws</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-function-web</artifactId>
</dependency>

Listing 3

...

@SpringBootApplication

public class Application {

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

Diese Hauptklasse müssen wir auch im AWS SAM template.yaml in der globalen Umgebungsvariablen MAIN_CLASS der Lambda-Funktion deklarieren (Listing 4).

Listing 4

...
Globals:
  Function:
    ...
    Environment:
      Variables:
        MAIN_CLASS: software.amazonaws.Application
...

Als Nächstes zeige ich, wie man die Spring Cloud Function GetProductByIdHandler implementieren kann (die wir später in eine AWS-Lambda-Funktion umwandeln). Diese Funktion liefert Produkte anhand deren ID (Listing 5).

Listing 5

...

@Component
public class GetProductByIdHandler implements Function<APIGatewayProxyRequestEvent, Product> {

  @Autowired
  private DynamoProductDao productDao;
  ...

  public Product apply(APIGatewayProxyRequestEvent requestEvent) {
    String id = requestEvent.getPathParameters().get("id");
    return productDao.getProduct(id);
  }
}

Die Annotation @Component ist hier sehr wichtig, da sie es dem Spring Framework ermöglicht, unsere benutzerdefinierten Beans automatisch zu erkennen. Standardmäßig nimmt Spring den Namen des Typs (Java-Klasse), der die Bean deklariert, ändert den ersten Buchstaben in einen Kleinbuchstaben und verwendet den resultierenden Wert, um die Bean zu benennen. Ansonsten implementiert die Klasse GetProductByIdHandler das Java 8 Function Interface mit APIGatewayProxyRequestEvent als Eingangstyp und Product als Ausgangstyp. Wir „autowiren“ ein Objekt der Klasse DynamoProductDao, die die Persistierungslogik für die Amazon-DynamoDB-Datenbank enthält.

In der Methode apply lesen wir den Wert der Produkt-ID aus dem APIGatewayProxyRequestEvent, fragen die DynamoDB-Persistenzschicht nach dem Produkt mit dieser ID und geben dieses Produkt zurück. Leider bietet Spring Cloud Function derzeit keine Möglichkeit, anstelle von APIGatewayProxyRequestEvent direkt den Wert des speziellen URL-Path-Parameters zu „autowiren“, um die Klasse GetProductByIdHandler ohne direkte Abhängigkeit zu Amazon APIs (in unserem Fall aws-lambda-java-events) umzusetzen.

Als Nächstes müssen wir die Spring Cloud Function GetProductByIdHandler als AWS SAM template.yaml (als Lambda-Funktion) deklarieren (Listing 6).

Listing 6

...

GetProductByIdFunction:
  Type: AWS::Serverless::Function
  Properties:
    Environment:
      Variables:
        SPRING_CLOUD_FUNCTION_DEFINITION: getProductByIdHandler
        FunctionName: GetProductByIdWithSpringBoot32SCF
        ...
        Events:
          GetRequestById:
            Type: Api
            Properties:
              RestApiId: !Ref MyApi
              Path: /products/{id}
              Method: get

...

Der relevante Teil ist hier der zugewiesene Wert der Umgebungsvariablen SPRING_CLOUD_FUNCTION_DEFINITION, die der Spring-Bean-Name ist, der in unserem Fall dem Klassennamen der Lambda-Funktion entspricht, wobei der erste Buchstabe in einen Kleinbuchstaben geändert wurde. In unserem Beispiel wird die Lambda-Funktion mit der Umgebungsvariablen SPRING_CLOUD_FUNCTION_DEFINITION = **getProductByIdHandler** auf die Klasse **GetProductByIdHandler** aufgelöst.

Da AWS keine Punkte (.) und/oder Bindestriche (-) im Namen der Umgebungsvariablen zulässt, können wir von der Spring-Boot-Unterstützung profitieren und einfach Punkte durch Unterstriche und Bindestriche durch Großbuchstaben ersetzen. So wird zum Beispiel spring.cloud....