今回の目的
NestJS を用いて、シンプルなファイルアップローダを作る。
ファイルの情報 (ファイル名・タイプ) は SQLite で管理し、ファイルのデータ本体は S3 互換ストレージに格納する。
ファイルのデータを SQLite に保存すると、データをストリーミングでクライアントに渡すのが難しく、一旦データ全体をメモリに置く実装になりやすそうである。
また、ファイルのデータをファイルシステム上のファイルとして保存すると、ファイルが多くなったときの管理が大変そうである。(同じディレクトリ内にあるファイルにアクセスする際、ディレクトリに格納されたファイルリストの線形探索が発生する?ディレクトリを掘るにしても、どのように?)
ファイルのデータを S3 互換ストレージに保存することで、このような懸念を避け、ファイルのデータの管理に偉大な先人の知恵を用いることを狙う。
S3 互換ストレージ
今回は、S3 互換ストレージとして Zenko CloudServer を用いる。
Zenko CloudServer は、Docker を用いて起動することができる。
設定のための環境変数は
Docker — scality-zenko-cloudserver 7.0.0 documentation
に載っている。
Compose を用いて、
- 管理サービスと連携しない
- データをボリュームに保存する
- ポート 8000 でアクセスできるようにする
設定で起動する。
services:
storage:
image: zenko/cloudserver
ports:
- "8000:8000"
environment:
REMOTE_MANAGEMENT_DISABLE: "1"
S3BACKEND: "file"
S3DATAPATH: "/storage/data"
S3METADATAPATH: "/storage/metadata"
volumes:
- "storage-data:/storage"
storage-init:
image: alpine
profiles: ["init"]
command: ["mkdir", "/storage/data", "/storage/metadata"]
volumes:
- "storage-data:/storage"
volumes:
storage-data:
最初に、データを保存する用のディレクトリをボリュームに作成する。
docker compose run --rm storage-init
その後、Zenko CloudServer を起動する。
docker compose up -d
HTTP でアクセスしてみるとレスポンスが返ってくるので、サーバーが起動していることがわかる。
ストレージへのアクセス
@aws-sdk/client-s3 を用いて、ストレージへのアクセスを行う。
Node.js の REPL (node
コマンド) を用いて、ライブラリの使い方を試してみる。
クライアントの用意
ライブラリを読み込む。
s3 = require("@aws-sdk/client-s3")
実際のプログラムでは、require
ではなく import
を使用して読み込んでもよい。
import {
// 使うクラスを列挙する
S3Client,
} from "@aws-sdk/client-s3";
それぞれのコマンドをサーバーに送り、レスポンスを受け取るクライアントを用意する。
S3ClientConfig
のオブジェクトを渡して S3Client
オブジェクトを生成する。
client = new s3.S3Client({
region:"us-west-2", // 操作対象のリージョン
endpoint: "http://localhost:8000", // 操作に用いるエンドポイント
forcePathStyle: true, // エンドポイントへのアクセスにサブドメインを用いない
credentials: {
accessKeyId: "accessKey1", // 認証用のID
secretAccessKey:"verySecretKey1", // 認証用のシークレット
},
})
リージョンは locationConfig.json
で定義されている。
認証用のIDとシークレットは、authdata.json
で定義されている。
本来は、デフォルトで用意されているアカウントをそのまま用いるのではなく、独自にアカウント (ID・シークレット) を設定するべきである。
今回の記事では、簡単のためデフォルトのアカウントを用いる。
バケットの列挙
ListBucketsCommand
を用いると、自分が所有しているバケットを列挙できる。
await client.send(new s3.ListBucketsCommand({
MaxBuckets: 1000, // このリクエストで列挙するバケットの最大数
}))
たとえば、以下のような結果が得られる。
{
'$metadata': {
httpStatusCode: 200,
requestId: 'f0b8d4d67c4ca1cb73f5',
extendedRequestId: 'f0b8d4d67c4ca1cb73f5',
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
Buckets: [ { Name: 'test-bucket-1', CreationDate: 2025-01-10T15:19:45.223Z } ],
Owner: {
DisplayName: 'Bart',
ID: '79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be'
}
}
結果オブジェクトのメンバ Buckets
に、バケットのリストを表す配列が格納されている。
バケットの作成
CreateBucketCommand
を用いると、バケットを作成できる。
await client.send(new s3.CreateBucketCommand({
Bucket: "test-bucket-1", // 作成するバケットの名前
}))
たとえば、以下のような結果が得られる。
{
'$metadata': {
httpStatusCode: 200,
requestId: '66ad8f67c94488e125a8',
extendedRequestId: '66ad8f67c94488e125a8',
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
Location: '/test-bucket-1'
}
結果オブジェクトのメンバ Location
に、作成したバケットの名前の前に /
をつけた文字列が格納されている。
データのアップロード
PutObjectCommand
を用いると、データをサーバーに保存できる。
await client.send(new s3.PutObjectCommand({
Bucket: "test-bucket-1", // 保存先のバケットの名前
Key: "hello", // 保存するデータの名前
Body: "hello, world", // 保存するデータ
ContentType: "text/plain", // 保存するデータの MIME タイプ
IfNoneMatch: "*", // 同名のデータが既に存在するとき、上書きせずエラーとする
}))
保存するデータ Body
には、string
・Uint8Array
・Buffer
・Readable
のオブジェクトを指定できる。
今回用いた Zenko CloudServer では、IfNoneMatch
の指定は効かず、指定しても同じ名前のデータがあれば上書きするようである。
たとえば、以下のような結果が得られる。
{
'$metadata': {
httpStatusCode: 200,
requestId: '2c3e3e40c7428ed9a387',
extendedRequestId: '2c3e3e40c7428ed9a387',
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
ETag: '"e4d7f1b4ed2e42d15898f4b27b019da4"'
}
データのダウンロード
GetObjectCommand
を用いると、サーバーに保存したデータを取得することができる。
res = await client.send(new s3.GetObjectCommand({
Bucket: "test-bucket-1", // 取得するデータを保存したバケットの名前
Key: "hello", // 取得するデータの名前
}))
たとえば、以下のような結果が得られる。(長いので折りたたんでいる)
結果例
{
'$metadata': {
httpStatusCode: 200,
requestId: '524677e76348884fe1a7',
extendedRequestId: '524677e76348884fe1a7',
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
AcceptRanges: 'bytes',
LastModified: 2025-01-10T15:56:15.000Z,
ContentLength: 12,
ETag: '"e4d7f1b4ed2e42d15898f4b27b019da4"',
ContentType: 'application/octet-stream',
Metadata: {},
Body: <ref *1> IncomingMessage {
_events: {
close: undefined,
error: undefined,
data: undefined,
end: [Function: responseOnEnd],
readable: undefined
},
_readableState: ReadableState {
highWaterMark: 16384,
buffer: [Array],
bufferIndex: 0,
length: 12,
pipes: [],
awaitDrainWriters: null,
[Symbol(kState)]: 1315596
},
_maxListeners: undefined,
socket: Socket {
connecting: false,
_hadError: false,
_parent: null,
_host: 'localhost',
_closeAfterHandlingError: false,
_events: [Object],
_readableState: [ReadableState],
_writableState: [WritableState],
allowHalfOpen: false,
_maxListeners: undefined,
_eventsCount: 6,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: null,
_server: null,
parser: null,
_httpMessage: [ClientRequest],
autoSelectFamilyAttemptedAddresses: [Array],
[Symbol(async_id_symbol)]: 1484,
[Symbol(kHandle)]: [TCP],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: null,
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: true,
[Symbol(kSetKeepAlive)]: true,
[Symbol(kSetKeepAliveInitialDelay)]: 1,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0
},
httpVersionMajor: 1,
httpVersionMinor: 1,
httpVersion: '1.1',
complete: true,
rawHeaders: [
'Accept-Ranges',
'bytes',
'Content-Length',
'12',
'ETag',
'"e4d7f1b4ed2e42d15898f4b27b019da4"',
'Last-Modified',
'Fri, 10 Jan 2025 15:56:15 GMT',
'Content-Type',
'application/octet-stream',
'server',
'S3 Server',
'x-amz-id-2',
'524677e76348884fe1a7',
'x-amz-request-id',
'524677e76348884fe1a7',
'Date',
'Fri, 10 Jan 2025 15:59:56 GMT',
'Connection',
'keep-alive',
'Keep-Alive',
'timeout=5'
],
rawTrailers: [],
joinDuplicateHeaders: undefined,
aborted: false,
upgrade: false,
url: '',
method: null,
statusCode: 200,
statusMessage: 'OK',
client: Socket {
connecting: false,
_hadError: false,
_parent: null,
_host: 'localhost',
_closeAfterHandlingError: false,
_events: [Object],
_readableState: [ReadableState],
_writableState: [WritableState],
allowHalfOpen: false,
_maxListeners: undefined,
_eventsCount: 6,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: null,
_server: null,
parser: null,
_httpMessage: [ClientRequest],
autoSelectFamilyAttemptedAddresses: [Array],
[Symbol(async_id_symbol)]: 1484,
[Symbol(kHandle)]: [TCP],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: null,
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: true,
[Symbol(kSetKeepAlive)]: true,
[Symbol(kSetKeepAliveInitialDelay)]: 1,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0
},
_consuming: false,
_dumped: false,
req: ClientRequest {
_events: [Object: null prototype],
_eventsCount: 2,
_maxListeners: undefined,
outputData: [],
outputSize: 0,
writable: true,
destroyed: false,
_last: true,
chunkedEncoding: false,
shouldKeepAlive: true,
maxRequestsOnConnectionReached: false,
_defaultKeepAlive: true,
useChunkedEncodingByDefault: false,
sendDate: false,
_removedConnection: false,
_removedContLen: false,
_removedTE: false,
strictContentLength: false,
_contentLength: 0,
_hasBody: true,
_trailer: '',
finished: true,
_headerSent: true,
_closed: false,
socket: [Socket],
_header: 'GET /test-bucket-1/hello?x-id=GetObject HTTP/1.1\r\n' +
'x-amz-user-agent: aws-sdk-js/3.726.0\r\n' +
'user-agent: aws-sdk-js/3.726.0 ua/2.1 os/win32#10.0.22631 lang/js md/nodejs#20.12.2 api/s3#3.726.0 m/N,E,e\r\n' +
'host: localhost:8000\r\n' +
'amz-sdk-invocation-id: 05bd851a-208d-4d33-a68a-b8d23390b8df\r\n' +
'amz-sdk-request: attempt=1; max=3\r\n' +
'x-amz-date: 20250110T155955Z\r\n' +
'x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\n' +
'authorization: AWS4-HMAC-SHA256 Credential=accessKey1/20250110/us-west-2/s3/aws4_request, SignedHeaders=amz-sdk-invocation-id;amz-sdk-request;host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, Signature=0ac09d1079cb039e4f7bbef6fabe32d35af27051ddcd64384b275ea6e0c23e86\r\n' +
'Connection: keep-alive\r\n' +
'\r\n',
_keepAliveTimeout: 0,
_onPendingData: [Function: nop],
agent: [Agent],
socketPath: undefined,
method: 'GET',
maxHeaderSize: undefined,
insecureHTTPParser: undefined,
joinDuplicateHeaders: undefined,
path: '/test-bucket-1/hello?x-id=GetObject',
_ended: false,
res: [Circular *1],
aborted: false,
timeoutCb: null,
upgradeOrConnect: false,
parser: null,
maxHeadersCount: null,
reusedSocket: false,
host: 'localhost',
protocol: 'http:',
[Symbol(shapeMode)]: false,
[Symbol(kCapture)]: false,
[Symbol(kBytesWritten)]: 0,
[Symbol(kNeedDrain)]: false,
[Symbol(corked)]: 0,
[Symbol(kOutHeaders)]: [Object: null prototype],
[Symbol(errored)]: null,
[Symbol(kHighWaterMark)]: 16384,
[Symbol(kRejectNonStandardBodyWrites)]: false,
[Symbol(kUniqueHeaders)]: null
},
_eventsCount: 1,
transformToByteArray: [AsyncFunction: transformToByteArray],
transformToString: [AsyncFunction: transformToString],
transformToWebStream: [Function: transformToWebStream],
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false,
[Symbol(kHeaders)]: {
'accept-ranges': 'bytes',
'content-length': '12',
etag: '"e4d7f1b4ed2e42d15898f4b27b019da4"',
'last-modified': 'Fri, 10 Jan 2025 15:56:15 GMT',
'content-type': 'application/octet-stream',
server: 'S3 Server',
'x-amz-id-2': '524677e76348884fe1a7',
'x-amz-request-id': '524677e76348884fe1a7',
date: 'Fri, 10 Jan 2025 15:59:56 GMT',
connection: 'keep-alive',
'keep-alive': 'timeout=5'
},
[Symbol(kHeadersCount)]: 22,
[Symbol(kTrailers)]: null,
[Symbol(kTrailersCount)]: 0
}
}
結果オブジェクトのメンバ Body
に、データを取得できるストリームオブジェクトが格納されている。
たとえば、read()
により取得したデータを読むことができる。
res.Body.read()
たとえば、以下のような結果が得られる。
<Buffer 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64>
ファイルアップローダの作成
以下では、コードの重要な部分を抜き出して掲載する。
コード全体は、以下のリポジトリを参照してほしい。
mikecat/nest-simple-uploader: File uploader demo using NestJS and S3-compatible storage
構成
今回は、クライアントからのリクエストを受け取ってレスポンスを返す AppController
と、AppController
から参照されてデータを管理する DatabaseService
を用いる。
準備
環境変数から設定を読み取る。
設定されていない場合にはローカルの Zenko CloudServer にアクセスできるようなデフォルト値を用意した。
private readonly dbFile = process.env.DB_FILE || "files.db";
private readonly bucketName = process.env.BUCKET_NAME || "files";
private readonly s3client = new S3Client({
region: process.env.STORAGE_REGION || "us-west-2",
endpoint: process.env.STORAGE_ENDPOINT || "http://localhost:8000",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.STORAGE_KEY || "accessKey1",
secretAccessKey: process.env.STORAGE_SECRET || "verySecretKey1",
},
});
ここで指定するバケットは、あらかじめ作っておかなければならない。
node-sqlite3 を用いて SQLite3 を操作する準備を行う。
データベースを開き、開く操作が完了したらその後の操作を行う関数を格納したオブジェクトを返す。
interface DBObject {
close: () => Promise<void>;
runSQL: (sql: string, params?: any) => Promise<any[]>;
}
private async openDB(): Promise<DBObject> {
return new Promise((resolve, reject) => {
const db = new Database(
this.dbFile,
OPEN_READWRITE | OPEN_CREATE | OPEN_FULLMUTEX,
(err) => {
if (err === null) resolve({
close: async () => {
return new Promise<void>((resolve, reject) => {
db.close((err) => {
if (err === null) resolve();
else reject(err);
});
});
},
runSQL: async (sql: string, params?: any) => {
return new Promise((resolve, reject) => {
db.all(sql, params || [], (err, rows) => {
if (err === null) resolve(rows);
else reject(err);
});
});
},
});
else reject(err);
},
);
});
}
起動時、ファイル情報を格納するテーブルが無ければ作成する。
今回は、最低限の情報として、ファイル名・MIMEタイプ・S3 互換ストレージのオブジェクトキーを格納する。
constructor() {
this.openDB().then(async (db) => {
try {
await db.runSQL(`
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
storageId TEXT NOT NULL
) STRICT
`);
} finally {
await db.close();
}
});
}
ファイルの列挙
DatabaseService
では、SQLite からファイル一覧に表示するためのファイル名とファイルのIDのリストを取得する。
interface FileEntry {
name: string;
id: number;
}
async listFile(): Promise<FileEntry[]> {
return await this.openDB().then(async (db) => {
try {
return await db.runSQL(
"SELECT name, id FROM files ORDER BY id ASC",
) as FileEntry[];
} finally {
await db.close();
}
});
}
今回は、簡単のためページネーションには対応しない。
AppController
では、DatabaseService
から受け取ったリストを HTML に変換する。
そして、アップロードフォームを含む HTML の残りの部分を前後につけて返す。
const PAGE_PREFIX = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>アップローダ</title>
</head>
<body>
<h1>アップローダ</h1>
<form method="POST" action="/upload" enctype="multipart/form-data">
<p>
<input type="file" name="file" required>
<input type="submit" value="アップロード" style="margin-left: 1em;">
</p>
</form>
<hr>
<ul>
`;
const PAGE_SUFFIX=`</ul>
</body>
</html>
`;
@Get()
async getIndex(): Promise<string> {
return (
PAGE_PREFIX +
(await this.databaseService.listFile()).map((entry) => {
const name = entry.name.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return `<li><a href="/file/${entry.id}">${name}</a></li>\n`;
}).join("") +
PAGE_SUFFIX
);
}
ファイルのアップロード
ファイル名・MIMEタイプ・ローカルの(一時)ファイルのパスを指定し、ファイルを SQLite と S3 互換ストレージに格納する。
まず、S3 互換ストレージ用のオブジェクトキーを生成し、これを含めたファイル情報を SQLite に格納する。
次に、生成したキーを用いて、ファイルのデータを S3 互換ストレージに格納する。
ファイルのデータの格納が成功した場合は、SQLite のトランザクションをコミットする。
ファイルのデータの格納に失敗した場合は、SQLite のトランザクションをロールバックし、ファイル情報の格納をキャンセルする。
async putFile(name: string, type: string, path: string) {
return await this.openDB().then(async (db) => {
try {
await db.runSQL("BEGIN TRANSACTION");
const s3key = randomUUID();
await db.runSQL(
"INSERT INTO files (name, type, storageId) VALUES (?, ?, ?)",
[name, type, s3key]
);
await this.s3client.send(new PutObjectCommand({
Bucket: this.bucketName,
Key: s3key,
Body: createReadStream(path),
ContentType: type,
IfNoneMatch: "*",
}));
await db.runSQL("COMMIT TRANSACTION");
} catch (e: any) {
try {
await db.runSQL("ROLLBACK TRANSACTION");
} catch {}
throw e;
} finally {
await db.close();
}
});
}
File upload | NestJS - A progressive Node.js framework
を参考に AppController
を実装する。
FileInterceptor
のオプションとして、空のオプションを指定した DiskStorage
を渡す。
これにより、受信したファイルをランダムなファイル名で一時フォルダに保存してくれる。
このファイルは自動では削除されないので、保存処理を行った後 unlink()
関数で削除する。
FileInterceptor
にオプションを渡さないと、受信したファイルはディスクではなくメモリ上に保持され、大容量のファイルの受信に支障が出やすくなる可能性がある。
ファイル名がマルチバイト文字を含む場合も、エンコードを無視して1バイトずつの値を文字コードとした文字列に変換されるようなので、file.originalname
をそのまま用いず、デコードを行う処理を挟んだ。
保存処理に成功したら、Redirect
の指定により 303 See Other を返し、ファイル一覧ページに戻す。
@Post("upload")
@Redirect("/", 303)
@UseInterceptors(FileInterceptor("file", { storage: diskStorage({}) }))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException("no file sent");
const fileName = new TextDecoder().decode(
new Uint8Array(Array.from(file.originalname).map((c) => c.charCodeAt(0)))
);
try {
await this.databaseService.putFile(fileName, file.mimetype, file.path);
} finally {
await unlink(file.path);
}
}
ファイルのダウンロード
まず、指定されたIDをもとに、SQLite からファイルの情報を取得する。
ファイルの情報を取得できたら、それに含まれるオブジェクトキーを用いて S3 互換ストレージからファイルのデータを取り出すストリームを取得する。
interface FileData {
name: string;
type: string;
dataStream: any;
}
async getFile(id: number): Promise<FileData | null> {
return await this.openDB().then(async (db) => {
try {
const [file] = await db.runSQL(
"SELECT name, type, storageId FROM files WHERE id=?",
[id]
);
if (!file) return null;
const s3object = await this.s3client.send(new GetObjectCommand({
Bucket: this.bucketName,
Key: file.storageId,
}));
if (!s3object.Body) return null;
return {
name: file.name as string,
type: file.type as string,
dataStream: s3object.Body,
};
} finally {
await db.close();
}
});
}
AppController
では、
Streaming Files | NestJS - A progressive Node.js framework
を参考に、ファイルのデータを StreamableFile
を用いて返す。
返すべきファイルのIDは Param
により受け取る。
ファイル名の表現方法は
PHPでダウンロードさせるファイル名がIEで文字化けする件 #HTTP - Qiita
を参考にした。
@Get("file/:id")
async getFile(@Param("id") id: string) {
const idNumber = parseInt(id, 10);
if (isNaN(idNumber)) throw new BadRequestException("invalid id");
const file = await this.databaseService.getFile(idNumber);
if (!file) throw new NotFoundException("nonexistent id");
return new StreamableFile(file.dataStream, {
type: file.type,
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
});
}
まとめ
- S3 互換ストレージの Zenko CloudServer を Docker を用いて立ち上げる方法を確認した
- @aws-sdk/client-s3 を用いて S3 互換ストレージのデータを読み書きする基本的な方法を確認した
- NestJS を用いてシンプルなファイルアップローダを作成し、ストリームを用いて S3 互換ストレージのデータを読み書きする方法を確認した