Azure FunctionsでAPIを作る～Java編～

最近Azureの勉強を始めて、実際に何か動かしてみたくなったので、手始めにAzure Functionsで遊んでみました。

# この記事でやっていること

* Azure Functionsのローカル開発環境構築(Java)
* コマンドによるAzureへのデプロイ

[Microsoftのドキュメント](https://learn.microsoft.com/ja-jp/azure/azure-functions/create-first-function-cli-java?tabs=bash%2Cazure-cli%2Cbrowser)に沿って実際に構築していきます。
この手の記事は他に沢山ありますが、自分なりに躓いたポイントなども残しておこうと思います。

※Javaの開発経験がある方向けの内容となっています

# 目次

* [Azure Core Tools のインストール](#azure-core-tools-のインストール)
* [Azure CLI のインストール](#azure-cli-のインストール)
* [Maven のインストール](#maven-のインストール)
* [Mavenプロジェクトの作成](#mavenプロジェクトの作成)
* [ローカルサーバー起動](#ローカルサーバー起動)
* [関数呼び出し](#関数呼び出し)
* [関数本体のコード](#関数本体のコード)
* [コードの変更](#コードの変更)
* [Azure へのデプロイ](#azure-へのデプロイ)

# 動作環境

作業時の環境は以下の通りです。

- OS
    - Windows 11 Pro (21H2)
    - Ubuntu 20.04 (WLS)
      ※最新版が動かなかったので古いバージョンを使用
- JDK
    - [OpenJDK 11](https://learn.microsoft.com/ja-jp/java/openjdk/download)
      ※JDKのインストールについては手順に含まれていません

# Azure Core Tools のインストール

* Windows

   [インストーラー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-run-local?tabs=v4%2Cwindows%2Ccsharp%2Cportal%2Cbash#v2)でインストール。

* Linux

   1. パッケージの整合性検証。

      ```Bash
      curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
      sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
      ```

   2. apt ソースリストを設定。

      * Ubuntu の場合

         ```bash
         sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list'
         ```

      * Debian の場合

         ```bash
         sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/debian/$(lsb_release -rs | cut -d'.' -f 1)/prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list'
         ```
  
   3. インストール

      ```bash
      sudo apt-get update
      sudo apt-get install azure-functions-core-tools-4
      ```

# Azure CLI のインストール

* Windows

  [インストーラー](https://learn.microsoft.com/ja-jp/cli/azure/install-azure-cli-windows?view=azure-cli-latest&tabs=azure-cli)でインストール。  

* Linux

   ```Bash
   sudo apt install azure-cli
   ```

   私の作業環境(Ubuntu20.04)ではインストールされるバージョンが古いようなので、[ここ](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt)を参考に、インストールしました。

   ```bash
   curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
   ```

以下のコマンドを実行してバージョンが表示されればインストール完了。

```bash
az --version
```

# Maven のインストール

* Windows

   [この記事](https://qiita.com/Junichi_M_/items/20daee936cd0c03c3115)を参考にインストール。  

* Linux

   ```bash
   sudo apt install maven
   ```


# Mavenプロジェクトの作成

1. プロジェクト作成

   ```bash
   mvn archetype:generate -DarchetypeGroupId=com.microsoft.azure -DarchetypeArtifactId=azure-functions-archetype -DjavaVersion=11
   ```

   `-DjavaVersion=11` の部分で java のバージョンを指定できます。  

   :::note
   ※2022/10/12 時点では `8` と `11` が選択可能 (`17`はプレビュー機能）
   :::

2. 以下の入力を求められるので入力する。

   | 設定       | 値                   | 説明                                    |
   | ---------- | -------------------- | --------------------------------------- |
   | groupId    | com.azure_functions     | プロジェクトを一意に識別する値。        |
   | artifactId | azure-functions      | バージョン番号のない、jar の名前。      |
   | version    | 1.0-SNAPSHOT         | jarのバージョン。規定値を選択。                          |
   | package    | com.azure_functions | パッケージ名。規定値は groupId と同じ。 |

3. 「Y」を入力し、「BUILD SUCCESS」と表示されれば OK。

   ```text:作成されるもの
   azure-functions/
   ├── host.json
   ├── local.settings.json
   ├── pom.xml
   └── src
       ├── main
       │   └── java
       │       └── com
       │           └── azure_functions
       │               └── Function.java
       └── test
           └── java
               └── com
                   └── azure_functions
                       ├── FunctionTest.java
                       └── HttpResponseMessageMock.java
   ```

4. 作成されたフォルダに移動し、以下のコマンドでビルド。

   ```bash
   mvn clean package
   ```


# ローカルサーバー起動

```Bash
mvn azure-functions:run
```

正常に起動すると以下のようにエンドポイントが表示される。

```text
・・・
Functions:

        HttpExample: [GET,POST] http://localhost:7071/api/HttpExample
・・・
```


# 関数呼び出し

表示されたエンドポイントにアクセスすると関数を呼び出すことができます。

## コマンドで確認

```bash
curl http://localhost:7071/api/HttpExample
```

```text:結果
Please pass a name on the query string or in the request body
```

## パラメータを指定して呼び出し

```bash
curl http://localhost:7071/api/HttpExample?name=AzureLearn
```

```text:結果
Hello, AzureLearn
```


# 関数本体のコード

```java:Function.java
public class Function {
    /**
     * This function listens at endpoint "/api/HttpExample". Two ways to invoke it
     * using "curl" command in bash:
     * 1. curl -d "HTTP Body" {your host}/api/HttpExample
     * 2. curl "{your host}/api/HttpExample?name=HTTP%20Query"
     */
    @FunctionName("HttpExample")
    public HttpResponseMessage run(
            @HttpTrigger(name = "req", methods = { HttpMethod.GET,
                    HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage<Optional<String>> request,
            final ExecutionContext context) {
        context.getLogger().info("Java HTTP trigger processed a request.");

        // Parse query parameter
        final String query = request.getQueryParameters().get("name");
        final String name = request.getBody().orElse(query);

        if (name == null) {
            return request.createResponseBuilder(HttpStatus.BAD_REQUEST)
                    .body("Please pass a name on the query string or in the request body").build();
        } else {
            return request.createResponseBuilder(HttpStatus.OK).body("Hello, " + name).build();
        }
    }
}
```

## やってること

1. リクエストのクエリパラメータ、またはボディから`name`というパラメータを取得。
2. name が取得できなかった場合は、レスポンスとして`Please pass a name on the query string or in the request body`を返す。
3. name パラメータが取得できた場合は、レスポンスとして`Hello, <nameの値>`を返す。

`@FunctionName`アノテーションで関数名を指定する。(Azure にデプロイされる名前)  
次に説明する`@HttpTrigger`アノテーションで`route`を指定しない場合は、`/api/<関数名>`がエンドポイントとなる。  
`route`を指定することでエンドポイントをカスタマイズすることができます。  

`@HttpTrigger`アノテーションでトリガーを設定する。

サンプルでは、以下のようになっている。

| 設定      | 設定内容  | 説明                                                                                                                                                                                       |
| --------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| name      | req       | リクエストまたはリクエスト本文の関数コードで使用される変数名。　(・・・？？)                                                                                                               |
| methods   | GET, POST | リクエストを受け付ける HTTP メソッド。<br>配列で複数指定可能。                                                                                                                                 |
| authLevel | ANONYMOUS | 関数を呼び出す際の承認レベルを指定する。<br><ul><li>ANONYMOUS - API キーなしでアクセス可能。</li><li>FUNCTION - 関数固有の API キーが必要。</li><li>ADMIN - マスターキーが必要。</li></ul> |

`@HttpTrigger`の詳細については
[Azure Functions の HTTP トリガー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=in-process%2Cfunctionsv2&pivots=programming-language-java) を参照。


# コードの変更

そのままではつまらないので、適当なJSONデータを返すようにコードを変更してみます。

```text
azure-functions/
├── host.json
├── local.settings.json
├── pom.xml ------------------------------------ 変更
└── src
    ├── main
    │   └── java
    │       └── com
    │           └── azure_functions
    │               ├── Function.java ---------- 変更
    │               ├── models
    │               │   └── TodoItem.java ------ 追加
    │               ├── services
    │               │   └── TodoService.java --- 追加
    │               └── utils
    │                   └── JsonUtil.java ------ 追加
    └── test
        └── java
            └── com
                └── azure_functions
                    ├── FunctionTest.java
                    └── HttpResponseMessageMock.java
```

```xml:pom.xml
    <dependencies>
        <dependency>
            <groupId>com.microsoft.azure.functions</groupId>
            <artifactId>azure-functions-java-library</artifactId>
            <version>${azure.functions.java.library.version}</version>
        </dependency>

        <!-- ↓追加↓(Lombok使いたかった) -->
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
            <scope>provided</scope>
        </dependency>

        <!-- JSONシリアライズのカスタマイズ用 -->
        <dependency>
            <groupId>com.azure</groupId>
            <artifactId>azure-core-serializer-json-jackson</artifactId>
            <version>1.2.22</version>
        </dependency>
        <!-- ↑追加↑ -->
```

```Java:Function.java (関数本体)
public class Function {

   /**
    * TODO一覧を取得
    * 
    * @param request リクエスト
    * @param context 実行コンテキスト
    * @return レスポンス
    */
   @FunctionName("fetchTodoItems")
   public HttpResponseMessage fetchTodoList(
               @HttpTrigger(name = "req", methods = { HttpMethod.GET,
                               HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS,
                               // エンドポイントを「/api/todo/list」に設定
                               route = "todo/list") HttpRequestMessage<Optional<String>> request,
               final ExecutionContext context) {
      context.getLogger().info("Java HTTP trigger processed a request.");

      // TODO一覧取得
      var todoService = new TodoService(context);
      List<TodoItem> todoItems = todoService.fetchTodoItems();

      // TODO一覧をJSONに変換
      var jsonUtil = new JsonUtil(context);
      String json = jsonUtil.serialize(todoItems);

      return request.createResponseBuilder(HttpStatus.OK)
                      .header("Content-Type", "application/json;")
                      .body(json).build();

   }

}
```

* TODO一覧を返すように変更
* エンドポイントをカスタマイズ

## 躓いたポイント①

最後のレスポンスを生成する部分で、`body()`には`todoItems`をそのまま渡しても勝手にシリアライズしてくれますが、`LocalDateTime`型が綺麗にフォーマットされないので、後述の`JsonUtil.java`で事前にシリアライズしたものを渡すようにしました。

```Java:TodoService.java (TODO一覧を取ってくるクラス)
/**
 * TODOサービス
 */
@RequiredArgsConstructor
public class TodoService {

   /** 実行コンテキスト */
   private final ExecutionContext context;

   /**
    * TODO一覧を取得
    * 
    * @return TODO一覧
    */
   public List<TodoItem> fetchTodoItems() {
      context.getLogger().info("fetchTodoItems");
      // ダミーデータ生成
      List<TodoItem> todoItems = List.of(
                      TodoItem.builder()
                              .content("Azureの勉強")
                              .done(false)
                              .createdAt(LocalDateTime.now())
                              .build(),
                      TodoItem.builder()
                              .content("Javaの勉強")
                              .done(true)
                              .createdAt(LocalDateTime.now())
                              .build(),
                      TodoItem.builder()
                              .content("TypeScriptの勉強")
                              .done(false)
                              .createdAt(LocalDateTime.now())
                              .build());

      return todoItems;
   }

}
```

```Java:TodoItem.java (TODOの入れ物)
@Data
@Builder
public class TodoItem {

    /** 内容 */
    private String content;

    /** 完了フラグ */
    private boolean done;

    /** 作成日時 */
    private LocalDateTime createdAt;

}
```

```Java:JsonUtil.java (JSON変換用ユーティリティ)
/**
 * JSON関連のユーティリティ
 */
@RequiredArgsConstructor
public class JsonUtil {

    /** JSONシリアライズ用のマッパー */
    private static final ObjectMapper MAPPER = objectMapper();

    /** 実行コンテキスト */
    private final ExecutionContext context;

    private static ObjectMapper objectMapper() {
        var mapper = new ObjectMapper();

        // LocalDateTime用のシリアライザを追加
        var javaTimeModule = new JavaTimeModule();
        javaTimeModule.addSerializer(LocalDateTime.class,
                new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")));
        mapper.registerModule(javaTimeModule);

        return mapper;
    }

    /**
     * オブジェクトをJSONに変換
     * 
     * @param <T> オブジェクトの型
     * @param obj オブジェクト
     * @return JSON文字列
     */
    public <T> String serialize(T obj) {
        try {
            return MAPPER.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            context.getLogger().severe("オブジェクト→JSONの変換に失敗しました。エラーメッセージ：" + e.getMessage());
            throw new IllegalArgumentException(e);
        }
    }

}
```


# Azure へのデプロイ

## Azure にログイン

```bash
az login
```

コマンド実行すると、ブラウザが起動してログインしろと言われるので従います。  
※ログインは初回のみでOKです

## デプロイ

```bash
mvn azure-functions:deploy
```

デプロイが成功すると、エンドポイントが表示されます。  
`/api/todo/list`になっていますね。

```text:結果
[INFO] HTTP Trigger Urls:
[INFO]   fetchTodoItems : https://azure-learn-functions796.azurewebsites.net/api/todo/list
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
```

## デプロイ結果確認

### 作成されるリソース

デプロイ時、以下のリソースが自動的に作成されます。

* リソースグループ
  * 関数アプリ
  * ストレージアカウント
  * App Service プラン
  * Application Insights

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/68fbc3b4-5dd2-21e7-c6b3-ace22423db63.png)

pom.xml の `build.plugins.plugin.configuration` で、作成されるリソースの設定をカスタマイズできます。

|設定名|既定値|説明|
|---|---|---|
|appName|プロジェクト作成時に指定したartifactId + プロジェクト作成日時|関数アプリ名|
|resourceGroup|java-functions-group|関数アプリが属するリソースグループ名。|
|appServicePlanName|java-functions-app-service-plan|App Service プラン名|
|region|westus|作成されるリソースが属するリージョン|

### コマンドで確認

```bash
curl https://azure-learn-functions796.azurewebsites.net/api/todo/list
```

```JSON:結果 (見やすいように整形済)
[
  {
    "content": "Azureの勉強",
    "done": false,
    "createdAt": "2022-10-10 18:38:26"
  },
  {
    "content": "Javaの勉強",
    "done": true,
    "createdAt": "2022-10-10 18:38:26"
  },
  {
    "content": "TypeScriptの勉強",
    "done": false,
    "createdAt": "2022-10-10 18:38:26"
  }
]
```

レスポンスとして、`body()`に設定したJSONが返されました。

### Azure Portal上で確認

Azure上にデプロイした関数は、[Azure Portal](https://portal.azure.com/) 上からも実行できます。  
`関数アプリ` → `<関数アプリ名>` → `関数` → `<関数名>` → `コードとテスト` → `テストと実行`  
パラメータやヘッダ、APIキーも指定できます。  
実行すると、画面上のコンソールに標準・エラー出力がリアルタイムで表示されます。  
うまく動かないときはここで確認してみると良いと思います。  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/68cff551-7929-7a47-8d2c-bed124f32214.png)

:::note warn
~~私の環境だけかもしれませんが、関数自体が成功してもレスポンス：500が返ってきてしまいます。  
`https://functions.azure.com/api/passthrough`というURLにリクエストを投げて、その先で実際の関数を呼んでいるようで、そこが500を返していました。~~
[2022/11/07] 再度確認したら正常に返ってきました。以前試したときは何かが足りなかったのかもしれません。
:::

## 躓いたポイント②

Windows(PowerShell)では問題ありませんでしたが、私の環境ではWSL上でデプロイコマンドを実行すると、エラーとなりプロイできませんでした。  

```text:エラー
Failed to execute goal com.microsoft.azure:azure-functions-maven-plugin:1.21.0:deploy (default-cli) on project azure-functions: login with (AUTO): device code consumer is not configured.
```

`login with (AUTO)`となっており、[認証方式](https://github.com/microsoft/azure-maven-plugins/wiki/Authentication)が自動で選択されるみたいですが、実際にはデバイスコード認証が使われているようです。(エラー内容から推測)  
「デバイス認証になってるけど、デバイスコードが設定されてないよ」って感じでしょうか。  
そこで、認証方式を「Azure CLI」に変更するため、pom.xmlを以下のように変更。

```xml:pom.xml
   <build>
      <plugins>
         ・・・
         <plugin>
            <groupId>com.microsoft.azure</groupId>
            <artifactId>azure-functions-maven-plugin</artifactId>
            <version>${azure.functions.maven.plugin.version}</version>
            <configuration>
               ・・・
               <!-- ↓追加↓ -->
               <auth>
                  <type>azure_cli</type>
               </auth>
               <!-- ↑追加↑ -->
            </configuration>
            ・・・
         </plugin>
         ・・・
      </plugins>
   </build>
```

デプロイすると、今度は以下のエラーが発生...

```text:エラー
Failed to execute goal com.microsoft.azure:azure-functions-maven-plugin:1.21.0:deploy (default-cli) on project azure-functions: login with (AZURE_CLI): execute Azure Cli command 'az account list --output json' failed due to error: Process exited with an error: 2 (Exit value: 2).
```

`az account list --output json`コマンドが失敗しているらしいので、単体で実行してみましたが、何の問題もなく動きました。  

結局、原因追いきれず、諦めて認証方式を「デバイスコード」に変更。

```xml:pom.xml
<auth>
   <type>device_code</type>
</auth>
```

デプロイコマンドを実行すると、URLとデバイスコードが表示されるので、ブラウザで指定されたURLを開き、表示されたダイアログにデバイスコードを入力します。  
今度はちゃんとデバイスコード入力しているので、デバイスコード認証でも問題なく通りました。

```text
[INFO] Auth type: DEVICE_CODE
To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code H6HNEDQCQ to authenticate.
```

# あとがき

WSL上でのデプロイで躓きましたが、意外と簡単に関数アプリの作成＆デプロイができました。  
今回はAPIキーは使用していないため、誰でもアクセスできるようになっていますが、今後はその辺りも考慮していきたいと思います。

次は今回作った関数からAzureのマネージドデータベース(MySQL)に接続してみたいと思います。

最後まで読んでいただき、ありがとうございました！

**2022/10/17 続きの記事を投稿しました**

https://qiita.com/fsdg-nagumo/items/4c069fe09676ac1b3326

# 参考文献

* [コマンド ラインから Azure に Java 関数を作成する](https://learn.microsoft.com/ja-jp/azure/azure-functions/create-first-function-cli-java?tabs=bash%2Cazure-cli%2Cbrowser)
* [Azure Functions Core Tools](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-run-local?tabs=v4%2Cwindows%2Ccsharp%2Cportal%2Cbash#v2)
* [Azure CLI](https://learn.microsoft.com/ja-jp/cli/azure/install-azure-cli-windows?view=azure-cli-latest&tabs=azure-cli)
* [Linux に Azure CLI をインストールする](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt)
* [WindowsへのMavenインストール](https://qiita.com/Junichi_M_/items/20daee936cd0c03c3115)
* [Azure Functions の HTTP トリガー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=in-process%2Cfunctionsv2&pivots=programming-language-java)
* [Azure Mavenプラグインの認証](https://github.com/microsoft/azure-maven-plugins/wiki/Authentication)