IBM Cloud Object Storageを使ったWebアプリ開発

## はじめに

この記事では、IBM Cloud Object Storageを活用したファイルのアップロード、ダウンロード、削除機能を備えたWebアプリの開発方法について、デモアプリを通して紹介します。

IBM Cloud Object Storageは、高い可用性と耐久性を持ち、画像やドキュメントなどの非構造化データを効率的に管理できるクラウド・ストレージ・サービスで、複雑なインフラ構築をせずに手軽に利用できます。

この記事を通じて、以下の技術に関する知識を得ることができます。

- IBM Cloud Object Storageのリソース作成と接続方法
- Webアプリにおけるファイルのアップロード、ダウンロード、削除の実装方法

## デモアプリの概要

デモアプリのソースコードは[GitHub](https://github.com/mountln/icos-demo)にて公開しています。

このデモアプリには、次の機能があります。
- IBM Cloud Object Storageへのファイルアップロード
- ファイルの一覧表示
- ファイルのダウンロード
- ファイルの削除

以下は、アプリの動作を示すデモGIF画像です。

![icos-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/5f86ab98-7bbc-4afd-6565-dbf55ab3d58e.gif)


### 全体構成図

全体構成図は以下の通りです。

![全体構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/a049fa0d-4fb7-0f22-ad1e-8830454c6b10.png)
Object Storageへのアクセスキーなどの機密情報はWebサーバー側で管理されています。
ユーザーは、直接ブラウザーからObject Storageにアクセスするのではなく、Webサーバーにリクエストを送り、WebサーバーがIBM COS SDKを使ってObject Storage内のファイル情報を取得、操作を実行し、その結果をレスポンスします。

## デモアプリの実行

このセクションでは、お使いのPCでデモアプリの環境準備から実行までの手順を説明します。

### 環境準備

#### 前提条件

デモアプリを実行するために、以下の準備が必要です。この記事では、これらの前提条件が整っていることを前提としています。

1. IBM Cloudのアカウント
1. Git
1. Node.js（v20.6.0以降）
    - v20.6.0未満の場合、以下の記事を参考に、Node.jsのアップデートまたは`dotenv`関連コードの修正が必要です
        - [nvm で複数の Node.js バージョンを切り替えて使用する (Node Version Manager)](https://maku77.github.io/p/3x95seb/)
        - [Node.jsで.envファイルを使って環境変数を設定する方法](https://moewe-net.com/nodejs/nodejs-use-env)
    - 筆者の環境: v20.16.0

#### ソースコードの取得

以下のコマンドを実行して、リポジトリをローカルにクローンします。

```bash
git clone https://github.com/mountln/icos-demo
cd icos-demo/
```

ソースコードの主なファイルは以下の通りです。

|ファイル|中身|
|---|----|
|`.env`|接続情報などの環境変数を定義するファイル|
|`public/index.html`|フロントエンドのコード|
|`app.js`|バックエンドのコード|
|`package.json`|プロジェクトで使用するパッケージやスクリプトなどを定義するファイル|

#### パッケージのインストール

デモアプリで使用する必要なNode.jsパッケージは、`package.json`に記載されています。プロジェクトのルートディレクトリ（`package.json`が存在するディレクトリ）で、以下のコマンドを実行します。

```bash
npm install
```

このコマンドを実行すると、`package.json`に記載されているパッケージや、それらのパッケージが依存する他のパッケージが自動的にインストールされます。
npmやpackage.jsonについて詳しく知りたい方は、こちらの[記事](https://qiita.com/righteous/items/e5448cb2e7e11ab7d477)を参考にしてください。

### IBM Cloud Object Storageでのリソース作成と接続設定

#### インスタンスの作成

IBM Cloud Object Storageの[ページ](https://cloud.ibm.com/objectstorage/)にアクセスし、「Create an instance」（または「インスタンスの作成」）ボタンをクリックします。

![create-instance.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/d3f8891e-820a-0b51-3600-3260d101e0a0.jpeg)

必要事項を入力し、インスタンスを作成します。

![create-instance-detail.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/477211ea-8635-db3e-a389-c4aaaa472285.jpeg)

無料アカウントで作成できるインスタンスの数は1つで、すでに作成している場合は、既存のインスタンスを利用しても構いません。

#### バケットの作成

インスタンスが作成されたら、その詳細ページに移動し、「バケットの作成」ボタンをクリックします。

![create-bucket-button.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/12e2fe29-07fe-88d7-ebce-f424b931edbe.jpeg)

画面の指示に従い、バケット名を設定してバケットを作成します。無料枠として5GBが提供されています。

![create-bucket.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/803c0a03-b196-3914-9711-cdea5ab7af92.jpeg)

![create-bucket-detail.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/46e53060-fccb-5cd3-e76b-ab3fc97cf190.jpeg)

作成されたバケットのページで、バケット名とエンドポイントのパブリックURLをコピーし、ソースコードの`.env`ファイル内で`<bucket-name>`および`<endpoint>`を対応する値に書き換えます。

![endpoint.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/d70cdd7b-04ba-e4c5-d4ae-3c842d82a881.jpeg)

#### サービス資格情報の作成

次に、サービス資格情報を作成します。インスタンスの詳細ページで「サービス資格情報」タブを開き、「新規資格情報」ボタンをクリックします。

![create-api-key.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/fa717beb-c978-304a-b17b-a3cc34aee459.jpeg)

HMAC資格情報を含めた設定で新規資格情報を作成し、`access_key_id`と`secret_access_key`をコピーして、`.env`ファイル内の`<hmac-access-key-id>`および`<hmac-secret-access-key>`をそれぞれ書き換えます。

![create-api-key-detail.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/0a8c2694-a851-16d7-e28c-d8fa186b081a.jpeg)

![api-key-detail.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/d1a25627-c9b3-8ab5-9d64-9d8aed7fd7ad.jpeg)

### デモアプリの実行

以下のコマンドでWebアプリのサーバーを起動します。

```bash
npm start
```

![npm-start.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/155d09cc-cdca-f017-3402-6ae567102836.jpeg)

`Listening on port 3000`と表示されたら、ブラウザーで`http://localhost:3000`にアクセスし、ファイルのアップロードなどを行えます。

![demo-ui.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915379/00e316ae-2eb5-8c5b-f4af-20b5a481253f.jpeg)

## 実装詳細・ソースコードの説明

このセクションでは、デモアプリの実装について解説します。

### ファイルアップロードの実装

#### フロントエンド (`public/index.html`)

ファイルのアップロードフォームは以下の通りです。`POST /`リクエストを通じて、ファイルをアップロードします。（参考: [multer - Usage](https://www.npmjs.com/package/multer#usage)）

```html
<form action="/" method="post" enctype="multipart/form-data">
  <input
    type="file"
    class="form-control mt-4 mb-3"
    id="input-file"
    name="file"
  />
  <button class="btn btn-primary px-4" type="submit">アップロード</button>
</form>
```

#### バックエンド (`app.js`)

バックエンドでは、`ibm-cos-sdk`を使用してS3クライアントを作成します。（参考: [S3 API Reference](https://ibm.github.io/ibm-cos-sdk-js/AWS/S3.html#constructor-property)）
```javascript
const cos = require("ibm-cos-sdk");
const s3 = new cos.S3({ endpoint: process.env.COS_ENDPOINT });
```

次に、`multipart/form-data`を処理するためのミドルウェア[`multer`](https://www.npmjs.com/package/multer#api)を使って、アップロードされたファイルを処理するミドルウェアを定義します。[`multer-s3`](https://www.npmjs.com/package/multer-s3#usage)を使うことで、アップロードされたファイルを直接IBM Cloud Object Storageに保存されます。

```javascript
const multer = require("multer");
const multerS3 = require("multer-s3");

const bucketName = process.env.COS_BUCKET_NAME;
const upload = multer({
  storage: multerS3({
    s3: s3,
    bucket: bucketName,
    acl: "public-read",
    metadata: (_req, file, callback) => {
      callback(null, { fieldName: file.fieldname });
    },
    key: (_req, file, callback) => {
      // ファイル名をUTF-8に変換（日本語文字化けの対応）
      const key = Buffer.from(file.originalname, "latin1").toString("utf8");
      callback(null, key);
    },
  }),
});
```

`POST /`のルーティングを次のように定義します。

```javascript
app.post("/", upload.single("file"), (req, res) => {
  if (res.statusCode === 200) {
    console.log("Uploaded file successfully");
    console.log(req.file);
  } else {
    console.log("Upload failed");
  }
  res.redirect("/");
});
```

### ファイルのリスト・ダウンロード・削除

#### フロントエンド (`public/index.html`)

ファイルリストは、ページのロード時に取得され、次の`<ul>`要素内にリストとして表示されます。

```html
<div class="my-4 p-4 pb-3 rounded border bg-body-tertiary shadow-sm">
  <h3>ファイルリスト</h3>
  <ul id="file-list" class="list-group mt-4 mb-2"></ul>
</div>
```

JavaScriptの[`fetch`](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)APIを使用して、`GET /api/file`リクエストをサーバーに送信し、バケット内に保存されているファイルのリストを取得します。
取得したファイルを最新順にソートし、`<ul>`要素内に追加します。

```javascript
const renderFileList = async () => {
  const fileList = document.getElementById("file-list");
  const res = await fetch("/api/file");
  if (res.ok) {
    const contents = await res.json();
    if (contents.length > 0) {
      // 最新順にソートする
      contents.sort((a, b) => {
        return new Date(a.LastModified) < new Date(b.LastModified);
      });
      for (const content of contents) {
        // リスト要素
        const itemElement = document.createElement("li");
        itemElement.classList.add(
          "list-group-item",
          "d-flex",
          "align-items-center",
          "gap-1"
        );
        // ファイル名
        const filenameNode = document.createElement("span");
        filenameNode.innerText = content.Key;
        itemElement.appendChild(filenameNode);
        // サイズ
        const sizeNode = document.createElement("span");
        sizeNode.classList.add("ms-auto", "me-3", "text-secondary");
        sizeNode.innerText = `${content.Size}バイト`;
        itemElement.appendChild(sizeNode);
        // ダウンロードボタン
        const downloadButton = document.createElement("a");
        downloadButton.classList.add("btn", "btn-primary");
        downloadButton.href = content.Url;
        downloadButton.innerText = "ダウンロード";
        itemElement.appendChild(downloadButton);
        // 削除ボタン
        const deleteButton = document.createElement("button");
        deleteButton.classList.add("btn", "btn-danger");
        deleteButton.onclick = async () => {
          const res = await fetch(`/api/file/${content.Key}`, {
            method: "delete",
          });
          if (res.status === 200) {
            // ファイルを削除した
            location.reload();
          } else {
            console.error(`Cannot delete ${content.Key}: ${res.status}`);
          }
        };
        deleteButton.innerText = "削除";
        itemElement.appendChild(deleteButton);
        fileList.appendChild(itemElement);
      }
    }
  }
};
```

削除ボタンをクリックすると、`DELETE /api/file/<ファイル名>`リクエストがサーバーに送信されます。

#### バックエンド (`app.js`)

バックエンド側では、まず、ファイルのリストを取得するための`GET /api/file`リクエストにレスポンスする必要があります。
ルーティングの定義は次の通りです。

```javascript
app.get("/api/file", async (_req, res) => {
  try {
    const data = await s3.listObjects({ Bucket: bucketName }).promise();
    const contents = [];
    if (data) {
      for (const content of data.Contents) {
        // オブジェクトをダウンロードするためのリンクを取得する
        const contentUrl = await s3.getSignedUrlPromise("getObject", {
          Bucket: bucketName,
          Key: content.Key,
        });
        contents.push({ ...content, Url: contentUrl });
      }
    }
    res.json(contents);
  } catch (error) {
    console.error(`ERROR: ${error.code} - ${error.message}`);
  }
});
```

s3 の[`listObjects`](https://ibm.github.io/ibm-cos-sdk-js/AWS/S3.html#listObjects-property)APIを使用して、バケット内にあるオブジェクトのリストを取得します。さらに、インターネットから資格情報を必要とせずにダウンロードするための事前署名URLを[`getSignedUrlPromise`](https://ibm.github.io/ibm-cos-sdk-js/AWS/S3.html#getSignedUrlPromise-property)APIで取得します。このURLをコンテンツ情報追加し、レスポンスとして返します。

リンクの有効期限を指定したい場合は、次のように指定できます。

```javascript
const contentUrl = await s3.getSignedUrlPromise("getObject", {
  Bucket: bucketName,
  Key: content.Key,
  Expires: 60 * 10, // リンクの有効期限を10分に指定
});
```

続いて、ファイルを削除ための`DELETE /api/file/<ファイル名>`ルーティングを定義します。
ファイルの削除には、s3の[`deleteObject`](https://ibm.github.io/ibm-cos-sdk-js/AWS/S3.html#deleteObject-property)APIを使用します。

```javascript
app.delete("/api/file/:filename", async (req, res) => {
  try {
    await s3
      .deleteObject({ Bucket: bucketName, Key: req.params.filename })
      .promise();
    res.end();
  } catch (error) {
    console.error(`ERROR: ${error.code} - ${error.message}`);
    res.status(500).end;
  }
});
```


## まとめ

この記事では、デモアプリを通じて、IBM Cloud Object Storageを利用したファイルのアップロード、ダウンロード、削除機能を持つWebアプリケーションの実装方法を解説しました。

さらに詳しい情報を得たい方は、以下のリンクも参考にしてください。

- [IBM Cloud Object Storage](https://www.ibm.com/jp-ja/products/cloud-object-storage)
- [IBM Cloud Object Storage製品ガイド](https://cloud.ibm.com/docs/cloud-object-storage)
    - [Node.jsの使用](https://cloud.ibm.com/docs/cloud-object-storage?topic=cloud-object-storage-node)
    - [Webアプリケーションの開発](https://cloud.ibm.com/docs/cloud-object-storage?topic=cloud-object-storage-web-application)
- S3 API
    - （一部未記載のAPIがあります）[IBM Cloud Object Storage S3 API](https://cloud.ibm.com/apidocs/cos/cos-compatibility?code=node)
    - [IBM Cloud Object Storage - Node.js SDK](https://ibm.github.io/ibm-cos-sdk-js/)
        - S3関連は[こちら](https://ibm.github.io/ibm-cos-sdk-js/AWS/S3.html)