Spring Cloud FunctionでAzure Functionsアプリ

# はじめに

Spring Cloud Functionとは、関数(Function)によってビジネスロジックを実装し、実行環境に依存せず、同じコードでWebエンドポイント、ストリーム処理、タスクを実行できるようにするフレームワークです。  
AWS LambdaやAzure Functionsなどのサーバーレス環境で動作させることができます。
要はSpringの便利機能を使いながらFunction開発できますというシロモノです。

https://spring.io/projects/spring-cloud-function

この記事ではSpring Cloud Function × Azure Functionsを試してみます。  
Mavenを使っているサンプルが多かったので、Gradleでプロジェクト作成したいと思います。  

https://learn.microsoft.com/ja-jp/azure/developer/java/spring-framework/getting-started-with-spring-cloud-function-in-azure

# プロジェクトの作成

[Spring Initializr](https://start.spring.io/)でSpring Bootプロジェクトのひな型を作ります。

```tex
Project  : Gradle Project
Language : Java
Packaging: Jar
Java     : 11 or 8 (17ではデプロイできなかった: 2022/11/02 時点)
その他は任意
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/e769e7da-c030-4670-a78b-6a1caf26e636.png)

# 設定ファイル

Azure Functionsの機能を使えるようにするため、ビルド設定を修正します。

```diff_gradle:build.gradle
plugins {
	id 'org.springframework.boot' version '2.7.5'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
+	id "com.microsoft.azure.azurefunctions" version "1.11.0"
	id 'java'
}

group = 'azure.functions'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

+ compileJava.options.encoding = 'UTF-8'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
+	implementation 'org.springframework.cloud:spring-cloud-function-adapter-azure:3.2.7'
+	implementation 'com.microsoft.azure.functions:azure-functions-java-library:2.1.0'
    // ↓は任意
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
     useJUnitPlatform()
}


+ jar {
+	enabled = true
+    // jarファイル名を指定しないと、"${rootProject.name}-${version}-plan.jar"みたいなファイル名になって
+    // 実行時に「jarが見つからない」と怒られたので、明示的に指定
+	archiveFileName = "${rootProject.name}-${version}.jar"
+    manifest {
+        // メインクラスを指定
+        attributes 'Main-Class' : 'azure.functions.springcloudapp.SpringCloudApplication'
+    }
+}
+
+ // Azureへのデプロイ設定
+ azurefunctions {
+	resourceGroup = 'xxxxxxxxxxxxx'        // リソースグループ名
+	appName = 'springcloudapp'             // Functionアプリ名
+	region = 'eastus'                      // リージョン
+	appServicePlanName = 'xxxxxxxxxxxxx'   // App Service Plan名
+   // ↑他にも色々リソースの指定ができます
+	runtime {
+        os = 'windows'                    // 'linux'にしたらなぜかデプロイできなかった
+	}
+	appSettings {
+		WEBSITE_RUN_FROM_PACKAGE = '1'
+        FUNCTIONS_EXTENSION_VERSION = '~4'
+    	FUNCTIONS_WORKER_RUNTIME = 'java'
+    	MAIN_CLASS = 'azure.functions.springcloudapp.SpringCloudApplication'
+	}
+	auth {
+		type = 'azure_cli'
+	}
+   // ローカルでデバッグするときは必要
+	localDebug = 'transport=dt_socket,server=y,suspend=n,address=5005'
+	deployment {
+		type = 'run_from_blob'
+	}
+ }
```

以下のファイルをプロジェクトルートに追加

```json:host.json (Azure Functionsの拡張機能を使えるようにするための設定)
{
  "version": "2.0",
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[3.*, 4.0.0)"
  }
}
```

```json:local.settings.json  (ローカルデバッグ時に必要)
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "java",
    "MAIN_CLASS": "azure.functions.springcloudapp.SpringCloudApplication",
    "AzureWebJobsDashboard": ""
  }
}
```

# 関数作成

大きく分けて、各トリガをハンドリングするためのHandlerと実際のロジックであるFunctionの2つを作成する必要があります。
Handlerでリクエストを受ける → Functionを呼び出す という流れになります。

## Handler作成

トリガーをハンドリングするためにハンドラクラスを作成します。  
ここではHttpTriggerを使用しています。  

```java:CreateTodosHandler.java
// FunctionInvokerというクラスを継承する
public class CreateTodoHandler extends FunctionInvoker<TodoDto, TodoDto> {

  @FunctionName("saveTodo")  // デプロイ時の関数アプリ名
  public HttpResponseMessage saveTodo(
    // @HttpTriggerにHTTPの受付情報を設定する
    @HttpTrigger(
      name = "req",
      methods = { HttpMethod.POST },
      route = "todos",
      authLevel = AuthorizationLevel.FUNCTION
    ) HttpRequestMessage<TodoDto> request,
    ExecutionContext context
  ) {
    final TodoDto payload = request.getBody();

    if (payload == null) {
      return request.createResponseBuilder(HttpStatus.BAD_REQUEST).build();
    }

    // handleRequestメソッドを呼び出すと別で定義したFunctionが呼び出されて結果が返る
    // @FunctionNameで指定した名前でDIコンテナ内のFunctionを検索する
    final TodoDto todoCreated = handleRequest(payload, context);

    return request
      .createResponseBuilder(HttpStatus.CREATED)
      .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
      .header(
        HttpHeaders.LOCATION,
        UriComponentsBuilder
          .fromUri(request.getUri())
          .path("/{id}")
          .buildAndExpand(todoCreated.getId())
          .toString()
      )
      .body(todoCreated)
      .build();
  }
}
```

## Function作成

実際のロジック部分を実装します。
書き方は2通りあります。(個人的には1つ目が好み)

1. `@Bean` を付与した、Function型を返却するメソッドを定義する
2. `@Component` を付与した、Funtionインターフェースの実装クラスを作る
※Function以外に、Supplier、Consumerも可

```java:TodoFunction.java  (@Beanバージョン)
@Component
@RequiredArgsConstructor
public class TodoFunction {

  // DIも使える
  private final TodoService todoService;

  // Handlerの@FunctionNameで指定した名前と同じ名前でDIコンテナに登録する
  // デフォルトではメソッド名で登録されるので、メソッド名を合わせておけば@Beanで明示的に名前指定しなくてもOK
  @Bean("saveTodo")
  public Function<TodoDto, TodoDto> saveTodo() {
    return (payload) -> {
      return todoService.saveTodo(payload);
    };
  }

  // 複数Functionを定義する場合はメソッドを追加する
}
```

```java:TodoFunction.java  (Function実装クラスバージョン)
// Handlerの@FunctionNameで指定した名前と同じ名前でDIコンテナに登録する
// デフォルトではクラス名（先頭は小文字）で登録される
// ※この場合だとクラス名が「SaveTodo」だったら@Componentで明示的に名前指定しなくてもOK
@Component("saveTodo")
@RequiredArgsConstructor
public class TodoFunction implements Function<TodoDto, TodoDto> {

  private final TodoService todoService;

  @Override
  public TodoDto apply(TodoDto payload) {
      return todoService.saveTodo(payload);
  }
}

// 複数Functionを定義する場合はクラスを追加する
```

# ローカルデバッグ

プロジェクトのルートディレクトリで以下のコマンドを実行。

```bash
./gradlew azureFunctionsRun
```

# Azureへデプロイ

プロジェクトのルートディレクトリで以下のコマンドを実行。

```bash
./gradlew azureFunctionsDeploy
```

# おまけ

## ExecutionContextへのアクセス

Handlerのトリガーメソッドで`ExecutionContext`というのを受け取れます。  
こいつは主にロギングの際に使うことになると思います。

```java
context.getLogger().info("hogehoge");
```

このcontextをFunction側で使うには少し面倒ですが`Message`というのを使う必要があります。  
結構Function側でログ出すことありそうなのに、ちょっと不便。

```java:TodoFunction.java
// Functionの入力をMessage(org.springframework.messaging.Message)にする
@Bean
public Function<Message<TodoDto>, TodoDto> saveTodo() {
  return message -> {
    // MessageのHeadersに"executionContext"というキーでExecutionContextがセットされてくる
    ExecutionContext context = 
      ExecutionContext.class.cast(message.getHeaders().get("executionContext"));
    context.getLogger().info("Invoke saveTodo");
    return todoService.saveTodo(message.getPayload());
  };
} 
```

これをFunction毎に書くのは面倒なので、Functionインターフェースをラップしたインターフェースを作って共通化してみました。

```java:AzureFunction.java
public interface AzureFunction<I, O> extends Function<Message<I>, O> {
  public static final String CONTEXT_HEADER_KEY = "executionContext";

  @Override
  default O apply(Message<I> message) {
    ExecutionContext context =
      ExecutionContext.class.cast(
          message.getHeaders().get((CONTEXT_HEADER_KEY))
        );

    return apply(message.getPayload(), context);
  }

  /** handleRequestの引数とExecutionContextを受け取るメソッドを定義 */
  O apply(I payload, ExecutionContext context);
}
```

使い方

```java:TodoFunction.java
  @Bean
  public AzureFunction<TodoDto, TodoDto> saveTodo() {
    return (payload, context) -> {
      context.getLogger().info("Invoke saveTodo");
      return todoService.saveTodo(payload);
    };
  }
```

少しは使いやすくなった・・・（？）  
ただし、Funtionの引数がPublisher(Mono、Flux)とかだと上手く型変換できませんでした。  
もっとスマートなやり方があったら教えてください。

## Function呼び出し時の型変換

Handlerの`handleRequest`メソッドでFunctionを呼び出すと、Functionの引数に合わせて自動的に型変換してくれたりします。  
ざっくり確認してみたところ、以下のような感じでした。

|入力時の型<br>(handleRequestの引数)|変換後の型<br>(Functionの引数)|変換可否|備考|
|---|---|:---:|---|
|T|Collection\<T\>|○|要素数1のCollectionに変換される|
|T|Mono\<T\>|○||
|T|Flux\<T\>|○|要素数1のFluxに変換される|
|T|Message\<T\>|○|`Message::getPayload`で入力値が取れる|
|Collection\<T\>|T|○|Collectionの件数分Functionが実行される<br>`handleRequest`の戻り値はArrayList\<O[^1]\>になる<br>ただし、OがCollectionの場合はflatなリストになる|
|Flux\<T\>|T|○|Fluxの件数分Functionが実行される<br>`handleRequest`の戻り値は↑と同じ|
|Message\<T\>|Message\<T\>|×||
|Message\<T\>|T|○|`Message::getPayload`の値が渡される|
|HttpRequestMessage\<T\>|HttpRequestMessage\<T\>|×||
|HttpRequestMessage\<T\>|T|○|`HttpRequestMessage::getBody`の値が渡される<br>`handleRequest`の戻り値は`HttpResponseMessage`になる|

## HttpTrigger以外のトリガー

HttpTrigger以外のトリガーも普通に使えるようです。  
以下のトリガーが使えるみたいです。  
(Trigger自体はSpring Cloud Functionの機能ではなくAzure側が提供している)

- BlobTrigger
- TimerTrigger
- KafkaTrigger
- QueueTrigger
- WarmupTrigger
- EventHubTrigger
- EventGridTrigger
- ServiceBusTopicTrigger
- ServiceBusQueueTrigger

試しに`TimerTrigger`と`EventGridTrigger`を使ってみました。  
1. Function1がTimerTriggerでEventGridのイベントを発行
1. Function2がEventGridのイベントを受け取って、Function3(API)に対してリクエスト
1. Function3がHttpリクエストを受けてCosmos DBにデータ登録

というよくわからないものを作成。

```java:Function1  TimerTrigger
public class TimerHandler extends FunctionInvoker<String, EventGridEvent> {

  public static final String FUNCTION_NAME = "timer";

  @FunctionName(FUNCTION_NAME)
  public void timer(
    // 1分毎に発火
    @TimerTrigger(name = "timer", schedule = "* * * * *") String timerInfo,
    @EventGridOutput(
      name = "outputEvent",
      topicEndpointUri = "MyEventGridTopicUriSetting",
      topicKeySetting = "MyEventGridTopicKeySetting"
    ) OutputBinding<EventGridEvent> outputEvent,
    ExecutionContext context
  ) {
    // handleRequestの戻り値をoutputEventにセットしている
    handleOutput(timerInfo, outputEvent, context);
  }
}

@Component
@RequiredArgsConstructor
public class TimerFunction {

  @Bean
  public AzureFunction<String, EventGridEvent> timer() {
    return (payload, context) -> {
      context.getLogger().info("Invoke timer: " + payload);

      final EventGridEvent document = new EventGridEvent();
      document.setId(UUID.randomUUID().toString());
      document.setEventType("MyCustomEvent");
      document.setEventTime(DateTimeUtil.formatDateTime(LocalDateTime.now()));
      document.setDataVersion("1.0");
      document.setSubject("EventGridSample");
      document.setData(new TodoDto(null, "Content", false, null));

      return document;
    };
  }
}
```

```java:Function2  EventGridTrigger
public class EventGridHandler extends FunctionInvoker<EventSchema, TodoDto> {

  public static final String FUNCTION_NAME = "eventGrid";

  @FunctionName(FUNCTION_NAME)
  public void eventGrid(
    @EventGridTrigger(name = "event") EventSchema event,
    ExecutionContext context
  ) {
    final TodoDto output = handleRequest(event, context);
    context.getLogger().info(output.toString());
  }
}

@Component
@RequiredArgsConstructor
public class EventGridFunction {

  private final RestTemplate restTemplate;

  @Value("${FUNCTION_ENDPOINT}")
  private String baseEndpoint;

  @Value("${FUNCTION_KEY}")
  private String functionKey;

  @Bean
  public AzureFunction<EventSchema, TodoDto> eventGrid() {
    return (event, context) -> {
      context.getLogger().info("Event: " + event);

      // Function3 API 呼び出し
      RequestEntity<TodoDto> request = RequestEntity
        .post(baseEndpoint + "/todos?code={code}", functionKey)
        .contentType(MediaType.APPLICATION_JSON)
        .body(event.getData());
      ResponseEntity<TodoDto> response = restTemplate.exchange(
        request,
        TodoDto.class
      );

      return response.getBody();
    };
  }
}
```

Function3は[Handler作成](#handler作成)、[Function作成](#function作成) で作成したもの。

ソース

https://github.com/azure-learn-796/springcloudapp

# まとめ

Spring Cloud Functionを使うことで、Springの機能を活かしてFunctionの開発ができるようになります。  
個人的にはDIできるようになるのが嬉しいです。  
HttpTrigger以外のトリガーも使えるので、色々試してみたいと思います。  
(公式のライブラリもその辺りは開発中のようなので、今後デフォルトでDIできるようになるかもしれません)

# 参考文献

- [Spring Cloud Function](https://spring.io/projects/spring-cloud-function)
- [Azure での Spring Cloud Function の概要](https://learn.microsoft.com/ja-jp/azure/developer/java/spring-framework/getting-started-with-spring-cloud-function-in-azure)
- [Azure Functions の Azure Event Grid トリガー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-event-grid-trigger?tabs=in-process%2Cextensionv3&pivots=programming-language-java)
- [AzureEventGridSimulator(EventGridのローカルデバッグ環境)](https://github.com/pmcilreavy/AzureEventGridSimulator)

[^1]: `O` はFunctionの戻り値型