2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NestJS で S3 互換ストレージに保存するファイルアップローダを作る

Posted at

今回の目的

NestJS を用いて、シンプルなファイルアップローダを作る。
ファイルの情報 (ファイル名・タイプ) は SQLite で管理し、ファイルのデータ本体は S3 互換ストレージに格納する。

構成図

ファイルのデータを SQLite に保存すると、データをストリーミングでクライアントに渡すのが難しく、一旦データ全体をメモリに置く実装になりやすそうである。
また、ファイルのデータをファイルシステム上のファイルとして保存すると、ファイルが多くなったときの管理が大変そうである。(同じディレクトリ内にあるファイルにアクセスする際、ディレクトリに格納されたファイルリストの線形探索が発生する?ディレクトリを掘るにしても、どのように?)

ファイルのデータを S3 互換ストレージに保存することで、このような懸念を避け、ファイルのデータの管理に偉大な先人の知恵を用いることを狙う。

S3 互換ストレージ

今回は、S3 互換ストレージとして Zenko CloudServer を用いる。
Zenko CloudServer は、Docker を用いて起動することができる。
設定のための環境変数は
Docker — scality-zenko-cloudserver 7.0.0 documentation
に載っている。

Compose を用いて、

  • 管理サービスと連携しない
  • データをボリュームに保存する
  • ポート 8000 でアクセスできるようにする

設定で起動する。

compose.yaml
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 には、stringUint8ArrayBufferReadable のオブジェクトを指定できる。

今回用いた 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 にアクセスできるようなデフォルト値を用意した。

DatabaseService
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 を操作する準備を行う。
データベースを開き、開く操作が完了したらその後の操作を行う関数を格納したオブジェクトを返す。

DatabaseService
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 互換ストレージのオブジェクトキーを格納する。

DatabaseService
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のリストを取得する。

DatabaseService
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 の残りの部分を前後につけて返す。

AppController
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
      return `<li><a href="/file/${entry.id}">${name}</a></li>\n`;
    }).join("") +
    PAGE_SUFFIX
  );
}

ファイルのアップロード

ファイル名・MIMEタイプ・ローカルの(一時)ファイルのパスを指定し、ファイルを SQLite と S3 互換ストレージに格納する。

まず、S3 互換ストレージ用のオブジェクトキーを生成し、これを含めたファイル情報を SQLite に格納する。
次に、生成したキーを用いて、ファイルのデータを S3 互換ストレージに格納する。
ファイルのデータの格納が成功した場合は、SQLite のトランザクションをコミットする。
ファイルのデータの格納に失敗した場合は、SQLite のトランザクションをロールバックし、ファイル情報の格納をキャンセルする。

DatabaseService
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 を返し、ファイル一覧ページに戻す。

AppController
@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 互換ストレージからファイルのデータを取り出すストリームを取得する。

DatabaseService
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
を参考にした。

AppController
@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 CloudServerDocker を用いて立ち上げる方法を確認した
  • @aws-sdk/client-s3 を用いて S3 互換ストレージのデータを読み書きする基本的な方法を確認した
  • NestJS を用いてシンプルなファイルアップローダを作成し、ストリームを用いて S3 互換ストレージのデータを読み書きする方法を確認した
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?