5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Spring Cloud FunctionでAzure Functionsアプリ

Last updated at Posted at 2022-11-02

はじめに

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

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

プロジェクトの作成

Spring InitializrでSpring Bootプロジェクトのひな型を作ります。

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

image.png

設定ファイル

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

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'
+	}
+ }

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

host.json (Azure Functionsの拡張機能を使えるようにするための設定)
{
  "version": "2.0",
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[3.*, 4.0.0)"
  }
}
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を使用しています。

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も可
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を定義する場合はメソッドを追加する
}
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を定義する場合はクラスを追加する

ローカルデバッグ

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

./gradlew azureFunctionsRun

Azureへデプロイ

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

./gradlew azureFunctionsDeploy

おまけ

ExecutionContextへのアクセス

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

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

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

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インターフェースをラップしたインターフェースを作って共通化してみました。

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);
}

使い方

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の引数に合わせて自動的に型変換してくれたりします。
ざっくり確認してみたところ、以下のような感じでした。

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

HttpTrigger以外のトリガー

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

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

試しにTimerTriggerEventGridTriggerを使ってみました。

  1. Function1がTimerTriggerでEventGridのイベントを発行
  2. Function2がEventGridのイベントを受け取って、Function3(API)に対してリクエスト
  3. Function3がHttpリクエストを受けてCosmos DBにデータ登録

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

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;
    };
  }
}
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作成Function作成 で作成したもの。

ソース

まとめ

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

参考文献

  1. O はFunctionの戻り値型

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?