【実践記録】Azure DevOps REST APIで自動化！バックログ起票の手作業を完全解放（Java版）

## はじめに
こんにちは！AgVenture Labの荒井です。

Azure DevOpsでのチケット管理、まだ手作業でやっていますか？

本記事では、Azure DevOps REST APIを使い、Javaでバックログ起票を自動化する方法を徹底解説します。
実践的なコード例や、CSV生成、Blob Storage連携など、チーム開発の効率化に直結するノウハウを余すところなく紹介。

Azure DevOpsを活用する開発者の必見記事です！

:::note info
本記事のポイント
・Azure DevOps REST APIを活用するメリット
・自動化の具体的な流れ
・実際に使ってみた感想
:::

## 目次
1. [Azure DevOps Service REST APIとは](#azure-devops-service-rest-apiとは) 
2. [目的](#目的) 
3. [概要図](#概要図) 
4. [①CSV作成](#csv作成) 
5. [②CSV取得・読み込み](#csv取得読み込み) 
6. [③PBI起票処理](#pbi起票処理) 
7. [④task起票処理](#task起票処理) 
8. [完成（コード全量）](#完成コード全量)
9. [振り返り](#振り返り) 
10. [最後に ： Azure DevOps Service REST APIを活用してみて](#最後に--azure-devops-service-rest-apiを活用してみて)

## Azure DevOps Service REST APIとは
まず、Azure DevOps REST APIが何かを簡単におさらいします。

Azure DevOps REST APIは、Azure DevOps内のさまざまなリソース（Work Items、Pipelines、Repositories など）をプログラムから操作できる非常に強力なAPIです。

これを活用すれば、

:::note info
・バックログやタスクの自動登録
・ステータス更新の自動化
・作業の可視化の効率化
:::

などが可能になります！


## 目的
今回の目的は、Azure DevOps REST APIを活用して「手作業でのバックログ登録を自動化する」ことです。

具体的には、毎スプリント手作業で起票していた定型PBI（Product Backlog Item）やtaskを自動で登録できるようにします。

## 概要図
全体の流れを示した図はこちらです。
次から順を追って解説していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3884142/cf5d8d85-0c90-44fd-86aa-f953bdf30747.png)

図の番号それぞれについて説明していきます。

### ①CSV作成
まず、起票するPBI・taskの情報をCSVにまとめ、Azure Blob Storageに格納します。

このCSVが後続の処理に使われます。

＜手順＞
・Azure DevOpsでCSV作成
DevOpsの「Queries」を使ってCSVにする情報を作成します。
1. 自動起票対象となるPBIのIDを下図の通りに設定
2. 「Run query」を押下
3. 右上の3点リーダ→「Export to CSV」を押下
4. blobストレージに格納

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3884142/5897afae-93f1-4972-99a2-a2421ba814c1.png)

### ②CSV取得・読み込み
次に、BlobストレージからCSVファイルをダウンロードし、内容をプログラムで読み込みます。

ここでは、指定されたパスからCSVデータを読み取り、リストに変換します。

＜downloadBlobClient＞
```diff_java
        /**
         * 引数のファイルをBLOBストレージからダウンロードする
         *
         * @param blobPath ファイルパス
         * @param tmpFilePath 一時ファイル格納パス
         */
        public String downloadBlobClient(String blobPath, String tmpFilePath) {
                try {
                        ByteArrayOutputStream baos = new ByteArrayOutputStream();
                        // BLOBコンテナに接続
                        BlobContainerClient blobContainerClient = getAutoTicketBlobContainerClient();
                        BlobClient blobClient = blobContainerClient.getBlobClient(blobPath);

                        // blobからCSVをダウンロード
                        blobClient.download(baos);
                        String csvContent = baos.toString(StandardCharsets.UTF_8);

                        return csvContent;
                } catch (Exception e) {
                        throw e;
                }
        }
```

そして、CSV形式の文字列を「DevOpsWorkItem」というオブジェクトのリストに変換します。

"DevOpsWorkItem"の中身は「完成（コード全量）」の項目で記載しています。

＜parseCsvToWorkItemList＞
```diff_java
    /**
     * CSVのデータをList<DevOpsWorkItem>に格納するメソッド。
     *
     * @param csvData CSV形式の文字列データ
     * @return List<DevOpsWorkItem> CSVデータから生成されたDevOpsWorkItemオブジェクトのリスト
     * @throws IOException
     */
    public List<DevOpsWorkItem> parseCsvToWorkItemList(String csvData) throws IOException {
        // 文字列からReaderを作成
        try (Reader reader = new StringReader(csvData)) {
            // CsvToBeanBuilderを使用してCSVデータをTargetTicketオブジェクトにマッピング
            CsvToBean<DevOpsWorkItem> csvToBean = new CsvToBeanBuilder<DevOpsWorkItem>(reader)
                    .withType(DevOpsWorkItem.class)
                    .withIgnoreLeadingWhiteSpace(true)
                    .build();
            // パースしてDevOpsWorkItemのリストを取得
            List<DevOpsWorkItem> devOpsWorkItemList = csvToBean.parse();

            return devOpsWorkItemList;
        } catch (Exception e) {
            e.printStackTrace();
            throw e;
        }
    }
```

これでCSVデータの読み込みが完了しました！

### ③PBI起票処理
読み込んだデータを基に、PBIを作成します。

createPBI メソッドで、必要な情報をリクエストボディとして構築し、Azure DevOpsに送信します。

＜createPBI＞
```diff_java
    /**
     * PBIを作成する
     *
     * @param wi            ワークアイテム
     * @param iterationPath イテレーションパス
     */
    public HttpResponse<String> createPBI(DevOpsWorkItem wi, String iterationPath)
            throws Exception {

        // リクエストボディを作成
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_DESCRIPTION, wi.getDescription()));
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
        sb.append("]");
        String body = sb.toString();

        return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreatePBI(), body);
    }
```

＜sendHttpRequest＞
```diff_java
        // PBIを作成する
        public static HttpResponse<String> sendHttpRequest(String url, String body)
                        throws Exception {

                // PATをエンコード
                String encodedPAT = Base64.getEncoder().encodeToString((":" +
                                {PAT}).getBytes());

                // リクエスト作成
                HttpClient client = HttpClient.newHttpClient();
                HttpRequest request = HttpRequest.newBuilder()
                                .uri(URI.create(url))
                                .header("Content-Type", "application/json-patch+json")
                                .header("Authorization", "Basic " + encodedPAT)
                                .POST(HttpRequest.BodyPublishers.ofString(body))
                                .build();
                // リクエスト送信
                return client.send(request, HttpResponse.BodyHandlers.ofString());
        }
```

＜呼び出し元＞
```diff_java
            String iterationPath = devOpsForm.getIterationPath();

            // workItemListの数だけ繰り返す
            for (DevOpsWorkItem workItemPBI : workItemList) {
                // PBIの場合
                if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
                    // PBIを起票する
                    HttpResponse<String> res = createPBI(workItemPBI, iterationPath);
                }
            }
```

### ④task起票処理
次に、PBIに関連するtaskを作成します。

新たに作成したPBIのIDを使って、関連するtaskを登録します。

＜createTask＞
```diff_java
    /**
     * タスクを作成する
     *
     * @param parentID      親USのID
     * @param wi            ワークアイテム
     * @param iterationPath イテレーションパス
     */
    public HttpResponse<String> createTask(int parentID, DevOpsWorkItem wi, String iterationPath)
            throws Exception {
        // リクエストボディを作成
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_REMAININGWORK, wi.getRemainingWork()));
        sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
        sb.append(createWorkItemRelationJson(parentID));
        sb.append("]");
        String body = sb.toString();

        return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreateTask(), body);
    }
```

＜呼び出し元＞
```diff_java
            // workItemListの数だけ繰り返す
            for (DevOpsWorkItem workItemPBI : workItemList) {
                // PBIの場合
                if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
                    // PBIを起票する
                    HttpResponse<String> res = createPBI(workItemPBI, iterationPath);

                    // 新規起票したPBIのIdを取得
                    JSONObject bodyJson = new JSONObject(res.body());
                    int newPBIId = bodyJson.getInt("id");

                    // "Parent"がPBIのIDと一致するもののみtask起票する
                    String workItemPBIId = workItemPBI.getId();
                    for (DevOpsWorkItem workItemTask : workItemList) {
                        if (workItemTask.getParent().equals(workItemPBIId)) {
                            HttpResponse<String> res2 = createTask(newPBIId, workItemTask, iterationPath);
                        }
                    }
                }
            }
```

## 完成（コード全量）
最終的なコードを全てまとめたものがこちらです。
Postmanを使う場合の設定例もありますので、ぜひ参考にしてみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3884142/07012af2-5391-4ea2-bd27-41299e0b65cb.png)


＜DevOpsRestController＞
```diff_java
    /**
     *
     * DevOpsRestControllerクラス.
     *
     */
    
    @RestController
    @CrossOrigin
    public class DevOpsRestController {
        /** DevOps管理インスタンス */
        @Autowired
        DevOpsService devOpsService;
    
        /**
         * blobに格納されているCSVデータをもとにAzureDevOpsのPBIとtaskを起票する<br>
         * MappingURI: /devops/workitems<br>
         *
         * @return
         */
        @PostMapping("/devops/workitems")
        public void postScrumEvent(DevOpsForm devOpsForm) {
            try {
                devOpsService.postDevOpsWorkItem(devOpsForm);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
```


＜DevOpsService＞
```diff_java
    /**
     * Azureに関する処理を行うServiceクラス.
     *
     */
    @Service
    @Transactional
    public class DevOpsService {
        @Autowired
        ExternalServiceConnection externalServiceConnection;
    
        /** 一時ディレクトリパス. */
        private static final String TMP_DIR_PATH = "任意のパス";
    
        /** Blobコンテナのフォルダパス */
        private static final String PATH_WORK_ITEM_TEMPLATES = "任意のパス";
    
        /** csv拡張子 .csv */
        private static final String EXTENSION_CSV = ".csv";
    
        /** Work item option - add */
        private static final String WORK_ITEM_OPTION_ADD = "add";
    
        /** Work item path - IterationPath */
        private static final String WORK_ITEM_PATH_ITERATIONPATH = "/fields/System.IterationPath";
    
        /** Work item path - Title */
        private static final String WORK_ITEM_PATH_TITLE = "/fields/System.Title";
    
        /** Work item path - Description */
        private static final String WORK_ITEM_PATH_DESCRIPTION = "/fields/System.Description";
    
        /** Work item path - RemainingWork */
        private static final String WORK_ITEM_PATH_REMAININGWORK = "/fields/Microsoft.VSTS.Scheduling.RemainingWork";
    
        /**
         * DevOpsのUSを追加する.
         *
         * @param devOpsForm
         * @throws Exception
         */
        public void postDevOpsWorkItem(DevOpsForm devOpsForm) throws Exception {
    
            try {
                // 一時ファイル格納フォルダを指定
                String uuid = java.util.UUID.randomUUID().toString();
                String tmpFilePath = TMP_DIR_PATH + uuid + EXTENSION_CSV;
    
                // 引数のTargetWorkItemNameをもとに取得するCSVを指定
                String blobPath = PATH_WORK_ITEM_TEMPLATES + devOpsForm.getTargetWorkItemName() + EXTENSION_CSV;
    
                // blobからCSV取得
                String csvContent = externalServiceConnection.downloadBlobClient(blobPath, tmpFilePath);
    
                // CSVデータをtargetTicketListに格納
                List<DevOpsWorkItem> workItemList = parseCsvToWorkItemList(csvContent);
    
                String iterationPath = devOpsForm.getIterationPath();
    
                // targetTicketListの数だけ繰り返す
                for (DevOpsWorkItem workItemPBI : workItemList) {
                    // PBIの場合
                    if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
                        // PBIを起票する
                        HttpResponse<String> res = createPBI(workItemPBI, iterationPath);
    
                        // 新規起票したPBIのIdを取得
                        JSONObject bodyJson = new JSONObject(res.body());
                        int newPBIId = bodyJson.getInt("id");
    
                        // "Parent"がPBIのIDと一致するもののみtask起票する
                        String workItemPBIId = workItemPBI.getId();
                        for (DevOpsWorkItem workItemTask : workItemList) {
                            if (workItemTask.getParent().equals(workItemPBIId)) {
                                HttpResponse<String> res2 = createTask(newPBIId, workItemTask, iterationPath);
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        /**
         * CSVのデータをList<DevOpsWorkItem>に格納するメソッド。
         *
         * @param csvData CSV形式の文字列データ
         * @return List<DevOpsWorkItem> CSVデータから生成されたDevOpsWorkItemオブジェクトのリスト
         * @throws IOException
         */
        public List<DevOpsWorkItem> parseCsvToWorkItemList(String csvData) throws IOException {
            // 文字列からReaderを作成
            try (Reader reader = new StringReader(csvData)) {
                // CsvToBeanBuilderを使用してCSVデータをTargetTicketオブジェクトにマッピング
                CsvToBean<DevOpsWorkItem> csvToBean = new CsvToBeanBuilder<DevOpsWorkItem>(reader)
                        .withType(DevOpsWorkItem.class)
                        .withIgnoreLeadingWhiteSpace(true)
                        .build();
                // パースしてDevOpsWorkItemのリストを取得
                List<DevOpsWorkItem> devOpsWorkItemList = csvToBean.parse();
    
                return devOpsWorkItemList;
            } catch (Exception e) {
                e.printStackTrace();
                throw e;
            }
        }
    
        /**
         * PBIを作成する
         *
         * @param wi            ワークアイテム
         * @param iterationPath イテレーションパス
         */
        public HttpResponse<String> createPBI(DevOpsWorkItem wi, String iterationPath)
                throws Exception {
    
            // リクエストボディを作成
            StringBuilder sb = new StringBuilder();
            sb.append("[");
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_DESCRIPTION, wi.getDescription()));
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
            sb.append("]");
            String body = sb.toString();
    
            return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreatePBI(), body);
        }
    
        /**
         * タスクを作成する
         *
         * @param parentID      親USのID
         * @param wi            ワークアイテム
         * @param iterationPath イテレーションパス
         */
        public HttpResponse<String> createTask(int parentID, DevOpsWorkItem wi, String iterationPath)
                throws Exception {
            // リクエストボディを作成
            StringBuilder sb = new StringBuilder();
            sb.append("[");
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_REMAININGWORK, wi.getRemainingWork()));
            sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
            sb.append(createWorkItemRelationJson(parentID));
            sb.append("]");
            String body = sb.toString();
    
            return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreateTask(), body);
        }
    
        /**
         * WorkItem JSONオブジェクトを作成するための補助メソッド
         *
         * @param op    オプション
         * @param path  パス
         * @param value 値
         */
        private String createWorkItemJson(String op, String path, String value) {
    
            if (value == null || value.isEmpty()) {
                return "";
            }
            return String.format("{\"op\": \"%s\", \"path\": \"%s\", \"value\": \"%s\"},", op, path, escapeJson(value));
        }
    
        /**
         * WorkItem 関連付けJSONオブジェクトを作成するための補助メソッド
         *
         * @param parentID ID
         */
        private String createWorkItemRelationJson(int parentID) {
            String relationValue = String.format(
                    "{ \"rel\": \"System.LinkTypes.Hierarchy-Reverse\", \"url\": \"https://dev.azure.com/{プロジェクト名}/_apis/wit/workItems/%s\", \"attributes\": { \"comment\": \"Making a new link for the purpose of the bulk add\" } }",
                    parentID);
            return String.format("{\"op\": \"%s\", \"path\": \"%s\", \"value\": %s}", "add", "/relations/-",
                    relationValue);
        }
    
        /**
         * AzureDevOpsAPIのURLを返却する.
         * PBI新規作成URL
         * 
         * @param parentID
         * @return
         */
        private String getAzureAPIUrlCreatePBI() {
            return "https://dev.azure.com/"
                    + {組織名}
                    + "/"
                    + {プロジェクト名}
                    + "/_apis/wit/workitems/$Product%20Backlog%20Item?api-version="
                    + {APIバージョン};
        }
    
        /**
         * AzureDevOpsAPIのURLを返却する.
         * Task新規作成URL
         * 
         * @param parentID
         * @return
         */
        private String getAzureAPIUrlCreateTask() {
            return "https://dev.azure.com/"
                    + {組織名}
                    + "/"
                    + {プロジェクト名}
                    + "/_apis/wit/workitems/$Task?api-version="
                    + {APIバージョン};
        }
    
        /**
         * Json文字列のエスケープを行う
         * 
         * @param str
         * @return エスケープ後の文字列
         */
        public static String escapeJson(String str) {
            return str.replace("\\", "\\\\")
                    .replace("\"", "\\\"")
                    .replace("\n", "\\n")
                    .replace("\r", "\\r")
                    .replace("\t", "\\t")
                    .replace("\b", "\\b")
                    .replace("\f", "\\f");
        }
    }
```

＜ExternalServiceConnection＞
```diff_java
    @Component
    public class ExternalServiceConnection {
            /** Environment(プロパティ値保持クラス). */
            @Autowired
            Environment environment;
    
            /**
             * 引数の素材ファイルをBLOBストレージからダウンロードする
             *
             * @param materialUrl 素材ファイルパス
             * @param tmpFilePath 一時ファイル格納パス
             */
            public String downloadBlobClient(String blobPath, String tmpFilePath) {
                    try {
                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
                            // BLOBコンテナに接続
                            BlobContainerClient blobContainerClient = getAutoTicketBlobContainerClient();
                            BlobClient blobClient = blobContainerClient.getBlobClient(blobPath);
    
                            // blobからCSVをダウンロード
                            blobClient.download(baos);
                            String csvContent = baos.toString(StandardCharsets.UTF_8);
    
                            return csvContent;
                    } catch (Exception e) {
                            throw e;
                    }
            }
    
            /**
             * プロパティ値をもとにBlobContainerClientオブジェクトを生成する.
             *
             * @return BlobContainerClientオブジェクト
             */
            private BlobContainerClient getAutoTicketBlobContainerClient() {
                    return new BlobContainerClientBuilder()
                                    .connectionString({接続文字列}))
                                    .containerName({blobストレージコンテナ名}))
                                    .buildClient();
            }
    
            // PBIを作成する
            public static HttpResponse<String> sendHttpRequest(String url, String body)
                            throws Exception {
    
                    // PATをエンコード
                    String encodedPAT = Base64.getEncoder().encodeToString((":" +
                                    {PAT}).getBytes());
    
                    // リクエスト作成
                    HttpClient client = HttpClient.newHttpClient();
                    HttpRequest request = HttpRequest.newBuilder()
                                    .uri(URI.create(url))
                                    .header("Content-Type", "application/json-patch+json")
                                    .header("Authorization", "Basic " + encodedPAT)
                                    .POST(HttpRequest.BodyPublishers.ofString(body))
                                    .build();
                    // リクエスト送信
                    return client.send(request, HttpResponse.BodyHandlers.ofString());
            }
    
    }
```

＜DevOpsForm＞
```diff_java
    /**
     * DevOpsformクラス.
     */
    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    
    public class DevOpsForm implements Serializable {
        /** serialVersionUID. */
        private static final long serialVersionUID = 1L;
    
        /** 自動起票対象の名前. */
        @NonNull
        private String targetWorkItemName;
    
        /** イテレーションパス. */
        private String iterationPath;
    }
```

＜DevOpsWorkItem＞    
```diff_java
    /**
     * AzureコストCSV行クラス.
     *
     */
    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public class DevOpsWorkItem implements Serializable {
        /** ステータス */
        @CsvBindByName(column = "State")
        private String state;
    
        /** ID */
        @CsvBindByName(column = "ID")
        private String Id;
    
        /** ワークアイテムタイプ */
        @CsvBindByName(column = "Work Item Type")
        private String workItemType;
    
        /** タグ */
        @CsvBindByName(column = "Tags")
        private String tag;
    
        /** タイトル */
        @CsvBindByName(column = "Title")
        private String title;
    
        /** AC */
        @CsvBindByName(column = "Acceptance Criteria")
        private String acceptanceCriteria;
    
        /** ディスクリプション */
        @CsvBindByName(column = "Description")
        private String description;
    
        /** リマイニング */
        @CsvBindByName(column = "Remaining Work")
        private String remainingWork;
    
        /** 見積時間 */
        @CsvBindByName(column = "見積時間")
        private String estimatedTime;
    
        /** イテレーションパス */
        @CsvBindByName(column = "Iteration Path")
        private String iterationPath;
    
        /** 親PBIのID */
        @CsvBindByName(column = "Parent")
        private String Parent;
    }
```

## 振り返り
今回の取り組みを通じて、Azure DevOps REST APIを活用したバックログ起票の自動化には次のような学びがありました。

:::note info
### 良かった点
- 作業の効率化  
    - 手作業に比べ、大幅な時間削減  
    - 定型的なPBIやタスク作成のスムーズ化  
    - ミスの削減
- Azure DevOps REST APIの柔軟性  
    - 多くの操作が可能で、自動化の幅が広がる  
    - このスクリプトを元に、さらに自動化を進めることができる  
:::

:::note warn
### 課題・苦労した点
- Work Itemのフィールド定義の把握  
    - フィールドが多く、適切な設定に手間がかかる  
- エラーハンドリングの重要性  
    - APIレスポンスの適切な処理が必要  
    - 例外処理が不足すると、意図しないデータが登録されるリスクがある  
:::

## 最後に ： Azure DevOps Service REST APIを活用してみて
Azure DevOps REST APIを活用することで、チケット管理の手間を大きく減らせることが実感できました。

最初はAPIの仕様に慣れるのに時間がかかりましたが、一度仕組みを作ってしまえば、毎スプリントのバックログ起票を完全に自動化でき、開発チームの生産性が大幅に向上しました。

今後は、さらに以下のような改善や拡張も検討できそうです。

:::note info
- Power AutomateやAzure Functionsとの連携で、より柔軟な自動化が可能に
- Webhookを使ったリアルタイムのワークフロー自動化
:::

この記事が、Azure DevOpsの自動化に挑戦する皆さんの参考になれば嬉しいです。

それでは、良い開発ライフを！