SvelteKit + Azure Blob Storage でファイルアップロード

# はじめに

Webアプリケーション開発で厄介なものの１つにファイルアップロードがあります。

なぜ厄介かというと、アップロード可能なファイルサイズの上限を数GBまで上げてしまうと、Webサーバーの負担が大きくなるからです。ファイルアップロードにリソース(ネットワーク帯域、CPU時間、メモリ、等)を割かれてしまい、肝心の画面表示系の処理が重くなってしまいます。

この課題を解決するにはWebサーバーを経由せずにファイルをアップロードする方法があります。

その方法の一つとして、Web ブラウザーから直接 Azure Blob Storage にファイルをアップロードする方法があります。ここでは SvelteKit で実装する方法を紹介します。

# 概念

Azure Blob Storage からファイルアップロード用の一時トークン(SAS:Shared Access Signatures)を生成してもらい、そのトークンと一緒にファイルをアップロードすることでセキュアなファイルアップロードを実現できます。

https://learn.microsoft.com/ja-jp/azure/storage/common/storage-sas-overview?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json

```mermaid
flowchart
browser[ブラウザー]
server[Webサーバー]
blob[Azure Blob Storage]

blob --> |3.token| server
browser --> |1.request form & token| server
browser--> |5.file & token| blob
server--> |2.requet token| blob
server --> |4.form & token| browser
```

# 事前準備

## アプリケーションの基盤

https://qiita.com/fsd-tetsu/items/8017b66d24dddc53b052#%E6%9C%AC%E6%A0%BC%E7%9A%84%E3%81%AB%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E4%BD%9C%E6%88%90%E3%81%97%E3%81%9F%E3%81%8F%E3%81%AA%E3%81%A3%E3%81%9F%E3%82%89

を参考に GitHub Codespaces 上で SvelteKit のスケルトンを作成してください。

## Azure Blob Storage 用のライブラリを導入

```bash:ターミナル
npm i @azure/storage-blob
```

## Azure Storage 接続文字列の取得

Azure Portal の Storage account の Access Key から 取得できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/4249b5d6-c657-d189-8f04-69e5695cb82a.png)

取得した文字列を `.env` ファイルに記載します。
```:.env
AZURE_STORAGE_CONNECTION_STRING=[接続文字列]
```
## Azure Blob Storage にコンテナを追加する

`fileupload` という名前のコンテナを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/51be7218-4e7b-4151-7dee-7cc341f3f297.png)

## Azure Blob Storage の CORS 設定

以下の様に設定して `Save` ボタンをクリックして保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/4855b8e9-eec4-a541-de61-62941b5fdcc9.png)

# 実装

<details><summary>+page.server.ts</summary>

```ts:src/routes/+page.server.ts
import type { Actions } from './$types';
import { BlobSASPermissions, BlobServiceClient } from '@azure/storage-blob';
import crypto from 'crypto';
import { env } from '$env/dynamic/private';

export const actions = {
	/**
	 * SAS URL を取得するアクション
	 */
	getSasUrl: async ({ request }) => {
		try {
			const fileName = (await request.formData()).get('fileName') as string;
			const sasUrl = await getSasUrl("dummyuser_id", "fileupload", fileName);
			return createData({ sasUrl: sasUrl });
		} catch (error: any) {
			console.error(error);
			return createData({ sasMessage: 'error:' + error.message });
		}
	}
} satisfies Actions;

type Args = {
	message?: string;
	sasUrl?: string;
	sasMessage?: string;
};

const createData = (args: Args) => {
	return {
		message: args.message,
		sasUrl: args.sasUrl,
		sasMessage: args.sasMessage
	};
};

async function getSasUrl(userId: string, containerName: string, fileName: string) {
	const containerClient = getContainerClient(containerName);

	// 特定のパスを指定
	const blockBlobClient = containerClient.getBlockBlobClient(
		userId + '/' + crypto.randomUUID() + '_' + fileName
	);

	return blockBlobClient.generateSasUrl({

		// 書き込みを許可
		permissions: BlobSASPermissions.from({
			write: true
		}),

		// 有効期間は1時間
		expiresOn: new Date(new Date().setHours(new Date().getHours() + 1))
	});
}

function getContainerClient(containerName: string) {
	const blobServiceClient = getBlobServiceClient();
	const containerClient = blobServiceClient.getContainerClient(containerName);
	return containerClient;
}

function getBlobServiceClient() {
	const AZURE_STORAGE_CONNECTION_STRING = env.AZURE_STORAGE_CONNECTION_STRING;
	if (!AZURE_STORAGE_CONNECTION_STRING) {
		throw Error('Azure Storage Connection string not found');
	}
	const blobServiceClient = BlobServiceClient.fromConnectionString(AZURE_STORAGE_CONNECTION_STRING);
	return blobServiceClient;
}
```
</details>

<details><summary>+page.svelte</summary>

```html:src/routes/+page.svelte
<script lang="ts">
	import type { ActionData } from './$types';
	import { enhance } from '$app/forms';
	import { BlockBlobClient } from '@azure/storage-blob';
	let fileInput: HTMLInputElement;
	let fileName: HTMLInputElement;
	let success = false;

	const uploadFile = async (sasUrl) => {
		if (!fileInput) {
			return;
		}
		const files = fileInput.files;
		if (!files || files.length === 0) {
			return;
		}
		const blockBlobClient = new BlockBlobClient(sasUrl);
		const promises = [];
		promises.push(blockBlobClient.uploadData(files[0]));
		await Promise.all(promises);
	};
</script>

<h1>Upload to Blob Storage by SAS</h1>
<input
	type="file"
	name="file"
	bind:this={fileInput}
	on:change={() => {
		const files = fileInput.files;
		if (!files || files.length === 0) return;
		fileName.value = files[0].name;
	}}
/>
<form
	method="POST"
	action="?/getSasUrl"
	use:enhance={() => {
		return async ({ result, update }) => {
			success = false;
			const sasUrl = result.data.sasUrl;
			// SAS URL を取得できたらファイルをアップロードする
			uploadFile(sasUrl).then(() => {
				success = true;
			});
		};
	}}
>
	<input type="hidden" name="fileName" bind:this={fileName} />
	<button type="submit" disabled={!fileName?.value}>upload to blob storage</button>
</form>
{#if success}
	<div>success!</div>
{/if}
```
</details>

# 動作確認

```:ターミナル
npm run dev
```
表示されたURLにアクセスしてください。

:::note warn
たまにポートフォワードがうまくいかず 502 エラーが発生します。
この場合、コマンド末尾に `-- --host 127.0.0.1` を追加してみてください。
:::

こんな画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/4c6cedd9-7027-daf7-e7b3-95051cd5cb7d.png)

ファイルを選択して `upload to blob storage` ボタンをクリックすると、ファイルアップロードが実行され、以下の様に `success` が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/7caf577d-bfd9-7aea-dff7-33dead265fc0.png)

Azure Blob Storage にファイルがアップロードされているか確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/ea6ec3ff-1169-dc95-bc77-ef38e3fd9c11.png)
アップロードされてますね！

# まとめ

[BlockBlobClient](https://learn.microsoft.com/ja-jp/javascript/api/@azure/storage-blob/blockblobclient?view=azure-node-latest) は `uploadData(..)` 以外のメソッドもあるので、いろんなケースに対応できると思います。ファイルアップロードの進捗表示もできそうです。

これでファイルアップロードに悩まされることはなくなりそうです！:smiley: