CLOVER🍀

That was when it all began.

Azure Storage向けのSpring Boot Starterを試してみる

これは、なにをしたくて書いたもの?

ローカルで、Azure StorageエミュレーターのAzuriteの使い方を調べたので、今度はJavaから使ってみます。

Azure Storageエミュレーター、Azuriteを試す - CLOVER🍀

Azureで使えるSpringライブラリがあるようなので、こちらを利用してみましょう。

Azure 上の Spring 統合のドキュメント | Microsoft Docs

AzureでのSpring、Spring Boot

AzureでのSpringに関する情報は、こちらのドキュメントを参照することになります。

Azure 上の Spring 統合のドキュメント | Microsoft Docs

Spring Bootについては、こちら。

Azure 向けの Spring Boot Starter | Microsoft Docs

ただ、このページの情報は古いようで、APIリファレンスのページを見ていくのが現時点では正解なようです。

https://docs.microsoft.com/en-us/java/api/overview/azure/spring-boot-readme?view=azure-java-stable

ガイド本体の方は、Spring Initializrを使ってAzureに関する依存関係を含めることになっていますが、Spring Boot 2.4以降では
Spring Initializrでは指定できず、pom.xmlなどに直接依存関係を書くことになります。

Spring Cloud Azureのページを見てもAzure側のドキュメントに行くことになっていますし、主体はAzure側なんでしょうね。

Spring Cloud Azure

AzureでのSpringに関するソースコードは、Azure SDK for Javaリポジトリに含まれています。

GitHub - Azure/azure-sdk-for-java: This repository is for active development of the Azure SDK for Java. For consumers of the SDK we recommend visiting our public developer docs at https://docs.microsoft.com/en-us/java/azure/ or our versioned developer docs at https://azure.github.io/azure-sdk-for-java.

現時点でのAzureでのSpring Boot Starterのバージョンが3.3.0なので、このバージョンで参照すると…、こちらですね。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring

タグがバージョンで細かくたくさんあるので、どれを見るのが正解かイマイチわかっていませんが…。

ここを見ていると、実際に使うStarterのバージョンごとにタグを指定するのが、厳密な気はしますね。

For each package we release there will be a unique git tag created that contains the name and the version of the package to mark the commit of the code that produced the package. This tag will be used for servicing via hotfix branches as well as debugging the code for a particular preview or stable release version. Format of the release tags are <package-name>_<package-version>.

Azure SDK for Java / Release branches (Release tagging)

サンプルコードは、こちらにあります。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot-samples

ですが、プロジェクト単位のサンプルコードは(存在する場合は)以下のルールに沿ったディレクトリを見るのが
正解のようです。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/samples

AzureでのSpring Bootに関するソースコードは、こちらを見るとよいでしょう。
※Starterは、同じディレクトリ階層に並んでいます

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot

AutoConfigureはこちら。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot/src/main/java/com/azure/spring/autoconfigure

Spring Bootのサンプル。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot/src/samples

あと、AzureのJava SDK本体の方も見ることになるでしょう。

Azure SDK for Java を使用する作業の開始 | Microsoft Docs

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk

APIリファレンスは、こちら。

Reference | Microsoft Docs

APIリファレンスページは、英語版を見た方が良さそうです。

ここまでが、AzureでのSpring Bootに関する情報でした。

Azure Storage向けのSpring Boot Starter

AzureでのSpring Bootでは、いくつかのリソースに対してのStarterが提供されています。

Azure Storage、Azure Key Vault、Azure Service Bus、Azure Cosmos DB、Azure Active Directory…などなど。

今回はAzure Storageを使うSpring Boot Starterを見ていきます。

Azure Storage 用の Spring Boot Starter の使用方法 | Microsoft Docs

ただ、こちらも実際の設定方法はAPIリファレンス側(英語)を見た方が良さそうです。

https://docs.microsoft.com/en-us/java/api/overview/azure/spring-boot-starter-storage-readme?view=azure-java-stable

このページは、StarterのREADME.mdが元になっているようです。

https://github.com/Azure/azure-sdk-for-java/blob/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot-starter-storage/README.md

Azure Storageに関するAutoConfigureはこちら。

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot/src/main/java/com/azure/spring/autoconfigure/storage

あと、操作するのは結局Azure StorageのJava SDK APIなので、こちらも見ることになるでしょう。

Reference | Microsoft Docs

Azure Storage libraries for Java | Microsoft Docs

クイックスタート: Azure Blob Storage ライブラリ v12 - Java | Microsoft Docs

サンプルについては、Azure Storage向けのSpring Boot Starterに関するものはなさそうなので、Azure Java SDK
Azure Storageを直接扱うサンプルを見ることになるでしょう。

azure-sdk-for-java/sdk/storage/azure-storage-blob at azure-spring-boot_3.3.0 · Azure/azure-sdk-for-java · GitHub

https://github.com/Azure/azure-sdk-for-java/tree/azure-spring-boot_3.3.0/sdk/storage/azure-storage-blob/src/samples

では、情報をざっと眺めたところで使っていってみましょう。

環境

今回の環境は、こちらです。

$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment (build 11.0.10+9-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.10+9-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.10, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-70-generic", arch: "amd64", family: "unix"

使用するAzuriteのバージョン。

$ npx azurite --version
3.11.0

プロジェクトの作成

Spring Initializrで、Spring Bootプロジェクトを作成します。依存関係は、Spring WebFluxにしました。
Azure Storageは、非同期のAPIを選択するとReactorを使うことになります。

$ curl -s https://start.spring.io/starter.tgz \
  -d dependencies=webflux,devtools \
  -d groupId=org.littlewings \
  -d artifactId=azure-spring-storege-example \
  -d packageName=org.littlewings.spring.azure \
  -d bootVersion=2.4.3 \
  -d javaVersion=11 \
  -d type=maven-project \
  -d baseDir=azure-spring-storege-example | tar zxvf -
$ cd azure-spring-storege-example

Azure向けのSpring Bootが依存しているSpring Bootのバージョンは2.4.3ですが、外部から異なるバージョンを指定されることも
想定しているようなので、現時点でのSpring Bootの最新版である2.4.4にしておきました。

https://github.com/Azure/azure-sdk-for-java/blob/azure-spring-boot_3.3.0/sdk/spring/azure-spring-boot/pom.xml#L31

そして、生成されたpom.xmlに以下の依存関係を追加します。

     <dependency>
            <groupId>com.azure.spring</groupId>
            <artifactId>azure-spring-boot-starter-storage</artifactId>
            <version>3.3.0</version>
        </dependency>

これで、Azure Storage向けのSpring Boot Starterが使えるようになります。

自動生成されたソースコードは、今回は不要なので削除しておきしょう。

$ rm src/main/java/org/littlewings/spring/azure/DemoApplication.java src/test/java/org/littlewings/spring/azure/DemoApplicationTests.java

とりあえず、mainクラスのみ再作成。

src/main/java/org/littlewings/spring/azure/App.java

package org.littlewings.spring.azure;

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

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

Azure Storage向けのSpring Boot Starterの設定を行う

まずは、Azure Storage向けのSpring Boot Starterの設定を行います。

こちらを参考に。

Azure Spring Boot Starter Storage client library for Java / Auto-configuration for Azure Blob storage

今回はBlob Storageのみを使うことにします。なので、Azuriteの情報を使ってこのように設定。

src/main/resources/application.properties

azure.storage.accountName=devstoreaccount1
azure.storage.accountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
azure.storage.blob-endpoint=http://127.0.0.1:10000/devstoreaccount1

これで、BlobServiceClientBuilderをDIできるようになります。

Azure Spring Boot Starter Storage client library for Java / Autowire the BlobServiceClientBuilder

存在するリソースであれば、Resouce@ValueでDIできそうではありますが。

Azure Spring Boot Starter Storage client library for Java / Autowire a resource

今回はBlobServiceClientBuilderを使います。

Azure StorageにアクセスするRestControllerを書く

あとは、BlobServiceClientBuilderを使ったソースコードを書いていくだけです。

RestControllerから、BlobServiceClientBuilderを使うようにしましょう。また、APIは非同期の方を使います。

こんな感じで雛形を用意。

src/main/java/org/littlewings/spring/azure/BlobController.java

package org.littlewings.spring.azure;

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;

import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobServiceAsyncClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.storage.blob.models.ParallelTransferOptions;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
public class BlobController {
    BlobServiceClientBuilder blobServiceClientBuilder;

    BlobServiceAsyncClient serviceClient;

    public BlobController(BlobServiceClientBuilder blobServiceClientBuilder) {
        this.blobServiceClientBuilder = blobServiceClientBuilder;

        serviceClient = this.blobServiceClientBuilder.buildAsyncClient();
    }

    // ここに、メソッドを書く
}

BlobServiceClientBuilderをインジェクションして、BlobServiceAsyncClientを作成します。

続いて、Blobにアクセスするコード。URLは、{コンテナ名/Blob名`でアクセスするようにしています。といっても、コンテナの
階層的なパスを扱えうようにはしていませんけど。
それぞれ、Blobを取得、アップロード、削除するメソッドです。

    // blobs
    @GetMapping("{containerName}/{blobName}")
    public Mono<String> getBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .getBlobAsyncClient(blobName)
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMapMany(blobClient -> blobClient
                        .download()
                        .map(bytes -> {
                            byte[] byteArray = new byte[bytes.remaining()];
                            bytes.get(byteArray);
                            return new String(byteArray, StandardCharsets.UTF_8);
                        })
                )
                .next();
    }

    @PostMapping("{containerName}/{blobName}")
    public Mono<String> uploadBlob(@PathVariable String containerName, @PathVariable String blobName, @RequestBody String body) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        ParallelTransferOptions options = new ParallelTransferOptions();

        return containerClient
                .exists()
                .flatMap(exists -> !exists ? serviceClient.createBlobContainer(containerName) : Mono.just(containerClient))
                .map(c -> c.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.upload(Flux.just(ByteBuffer.wrap(body.getBytes(StandardCharsets.UTF_8))), options, true))
                .map(v -> String.format("blob uploaded, %s/%s", containerName, blobName));
    }

    @DeleteMapping("{containerName}/{blobName}")
    public Mono<String> deleteBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.delete())
                .map(v -> String.format("blob deleted, %s/%s", containerName, blobName));
    }

Azure Storageを使うサンプルコードは、同期のものばかりだったのと、Springで扱うものはなかったのでちょっと頑張って
書いてみました。

クイックスタート: Azure Blob Storage ライブラリ v12 - Java | Microsoft Docs

非同期の方を使うコードは、Javadoc用のものがあるくらいでしょう。

https://github.com/Azure/azure-sdk-for-java/blob/azure-spring-boot_3.3.0/sdk/storage/azure-storage-blob/src/samples/java/com/azure/storage/blob/BlobAsyncClientJavaDocCodeSnippets.java

わかりにくいですが、アップロードするコードのみ、コンテナが未存在の場合は作成するようにしています。

    @PostMapping("{containerName}/{blobName}")
    public Mono<String> uploadBlob(@PathVariable String containerName, @PathVariable String blobName, @RequestBody String body) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        ParallelTransferOptions options = new ParallelTransferOptions();

        return containerClient
                .exists()
                .flatMap(exists -> !exists ? serviceClient.createBlobContainer(containerName) : Mono.just(containerClient))
                .map(c -> c.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.upload(Flux.just(ByteBuffer.wrap(body.getBytes(StandardCharsets.UTF_8))), options, true))
                .map(v -> String.format("blob uploaded, %s/%s", containerName, blobName));
    }

この部分ですね。

                .flatMap(exists -> !exists ? serviceClient.createBlobContainer(containerName) : Mono.just(containerClient))

BlobContainerAsyncClient#createというメソッドもあるのですが、だいぶハマったのでこちらで…。

確認。

$ mvn spring-boot:run

アップロード。

$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/mycontainer/message.txt -d 'Hello World!!'
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 38

blob uploaded, mycontainer/message.txt


$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/mycontainer/message2.txt -d 'Hello Storage!!'
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 39

blob uploaded, mycontainer/message2.txt


$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/mycontainer2/foo.txt -d 'Foo'
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 35

blob uploaded, mycontainer2/foo.txt


$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/mycontainer2/hoge.txt -d 'Hoge'
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 36

blob uploaded, mycontainer2/hoge.txt


$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/mycontainer3/sample.txt -d 'Sample'
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 38

blob uploaded, mycontainer3/sample.txt

取得。

    @GetMapping("{containerName}/{blobName}")
    public Mono<String> getBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .getBlobAsyncClient(blobName)
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMapMany(blobClient -> blobClient
                        .download()
                        .map(bytes -> {
                            byte[] byteArray = new byte[bytes.remaining()];
                            bytes.get(byteArray);
                            return new String(byteArray, StandardCharsets.UTF_8);
                        })
                )
                .next();
    }
$ curl -i localhost:8080/mycontainer/message.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 13

Hello World!!


$ curl -i localhost:8080/mycontainer/message2.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 15

Hello Storage!!


$ curl -i localhost:8080/mycontainer2/foo.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 3

Foo


$ curl -i localhost:8080/mycontainer2/hoge.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 4

Hoge


$ curl -i localhost:8080/mycontainer3/sample.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 6

Sample

削除。

    @DeleteMapping("{containerName}/{blobName}")
    public Mono<String> deleteBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.delete())
                .map(v -> String.format("blob deleted, %s/%s", containerName, blobName));
    }
$ curl -i -XDELETE localhost:8080/mycontainer2/hoge.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 0

Blobがない場合に、404にはしませんでした…。

$ curl -i localhost:8080/mycontainer2/hoge.txt
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 0

続いて、コンテナを扱うコードも書いてみましょう。こちらも、特に階層的なパスは意識していませんが。

    // containers
    @GetMapping
    public Mono<List<String>> containers() {
        return serviceClient
                .listBlobContainers()
                .map(blobContainerItem -> blobContainerItem.getName())
                .collectList();
    }

    @GetMapping("{containerName}")
    public Mono<List<String>> blobs(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .listBlobs()
                .map(blobItem -> blobItem.getName())
                .collectList();
    }

    @DeleteMapping("{containerName}")
    public Mono<String> deleteContainer(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .flatMap(v -> containerClient.delete())
                .map(v -> String.format("container deleted, %s", containerName));
    }

現在のコンテナの一覧を取得。

    @GetMapping
    public Mono<List<String>> containers() {
        return serviceClient
                .listBlobContainers()
                .map(blobContainerItem -> blobContainerItem.getName())
                .collectList();
    }
$ curl -i localhost:8080
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 45

["mycontainer","mycontainer2","mycontainer3"]

コンテナ内に保持しているBlobの一覧を取得。

    @GetMapping("{containerName}")
    public Mono<List<String>> blobs(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .listBlobs()
                .map(blobItem -> blobItem.getName())
                .collectList();
    }
$ curl -i localhost:8080/mycontainer
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 30

["message.txt","message2.txt"]

コンテナを削除。

    @DeleteMapping("{containerName}")
    public Mono<String> deleteContainer(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .flatMap(v -> containerClient.delete())
                .map(v -> String.format("container deleted, %s", containerName));
    }
$ curl -i -XDELETE localhost:8080/mycontainer3
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 0

指定したコンテナが削除されました、と。

$ curl -i localhost:8080
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 30

["mycontainer","mycontainer2"]

まとめ

Azure向けのSpring Boot Starterを使って、Azure Storageにアクセスしてみました。

けっこうてこずったのですが、AzureのSpringに関する情報はどこを見たらいいのか?というところと、Reactorに不慣れな
ところで苦労した感じですね…。

やりたいところまではできたので、良しとしましょう。

最後に、今回作成したAzure StorageにアクセスするRestControllerソースコード全体を載せておきます。

src/main/java/org/littlewings/spring/azure/BlobController.java

package org.littlewings.spring.azure;

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;

import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobServiceAsyncClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.storage.blob.models.ParallelTransferOptions;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
public class BlobController {
    BlobServiceClientBuilder blobServiceClientBuilder;

    BlobServiceAsyncClient serviceClient;

    public BlobController(BlobServiceClientBuilder blobServiceClientBuilder) {
        this.blobServiceClientBuilder = blobServiceClientBuilder;

        serviceClient = this.blobServiceClientBuilder.buildAsyncClient();
    }

    // containers
    @GetMapping
    public Mono<List<String>> containers() {
        return serviceClient
                .listBlobContainers()
                .map(blobContainerItem -> blobContainerItem.getName())
                .collectList();
    }

    @GetMapping("{containerName}")
    public Mono<List<String>> blobs(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .listBlobs()
                .map(blobItem -> blobItem.getName())
                .collectList();
    }

    @DeleteMapping("{containerName}")
    public Mono<String> deleteContainer(@PathVariable String containerName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .flatMap(v -> containerClient.delete())
                .map(v -> String.format("container deleted, %s", containerName));
    }

    // blobs
    @GetMapping("{containerName}/{blobName}")
    public Mono<String> getBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .getBlobAsyncClient(blobName)
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMapMany(blobClient -> blobClient
                        .download()
                        .map(bytes -> {
                            byte[] byteArray = new byte[bytes.remaining()];
                            bytes.get(byteArray);
                            return new String(byteArray, StandardCharsets.UTF_8);
                        })
                )
                .next();
    }

    @PostMapping("{containerName}/{blobName}")
    public Mono<String> uploadBlob(@PathVariable String containerName, @PathVariable String blobName, @RequestBody String body) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        ParallelTransferOptions options = new ParallelTransferOptions();

        return containerClient
                .exists()
                .flatMap(exists -> !exists ? serviceClient.createBlobContainer(containerName) : Mono.just(containerClient))
                .map(c -> c.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.upload(Flux.just(ByteBuffer.wrap(body.getBytes(StandardCharsets.UTF_8))), options, true))
                .map(v -> String.format("blob uploaded, %s/%s", containerName, blobName));
    }

    @DeleteMapping("{containerName}/{blobName}")
    public Mono<String> deleteBlob(@PathVariable String containerName, @PathVariable String blobName) {
        BlobContainerAsyncClient containerClient = serviceClient.getBlobContainerAsyncClient(containerName);

        return containerClient
                .exists()
                .filter(Boolean::booleanValue)
                .map(v -> containerClient.getBlobAsyncClient(blobName))
                .flatMap(blobClient -> blobClient.delete())
                .map(v -> String.format("blob deleted, %s/%s", containerName, blobName));
    }
}