これは、なにをしたくて書いたもの?
ローカルで、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側なんでしょうね。
AzureでのSpringに関するソースコードは、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/samples
AzureでのSpring Bootに関するソースコードは、こちらを見るとよいでしょう。
※Starterは、同じディレクトリ階層に並んでいます
AutoConfigureはこちら。
Spring Bootのサンプル。
あと、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リファレンスは、こちら。
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リファレンス側(英語)を見た方が良さそうです。
このページは、StarterのREADME.md
が元になっているようです。
Azure Storageに関するAutoConfigureはこちら。
あと、操作するのは結局Azure StorageのJava SDK APIなので、こちらも見ることになるでしょう。
Azure Storage libraries for Java | Microsoft Docs
クイックスタート: Azure Blob Storage ライブラリ v12 - Java | Microsoft Docs
サンプルについては、Azure Storage向けのSpring Boot Starterに関するものはなさそうなので、Azure Java SDKで
Azure Storageを直接扱うサンプルを見ることになるでしょう。
では、情報をざっと眺めたところで使っていってみましょう。
環境
今回の環境は、こちらです。
$ 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にしておきました。
そして、生成された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の設定を行います。
こちらを参考に。
今回は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用のものがあるくらいでしょう。
わかりにくいですが、アップロードするコードのみ、コンテナが未存在の場合は作成するようにしています。
@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)); } }