0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

フロントエンドから画像データをバックエンドを経由してストレージにアップロードする

Last updated at Posted at 2023-09-10

構成

フロントエンドでユーザーから画像ファイルの入力を受け取り、バックエンドサーバーに渡して、そこから S3 にアップロードします。

フロントエンド

React を使って画像の入力を受付け、 バックエンドへと投げるフロントエンドアプリケーションを作成します。

開発環境

  • node: v18.14.0
"react": "^18.2.0",
"vite": "^4.4.5",
"typescript": "^5.0.2"

ファイルを選択ボタンのクリックにより画像ファイルを受けて、プレビュー表示した後に、アップロードボタンをクリックすることで画像をアップロードすることとします。

画像の入力コンポーネント

画像の入力フォームを作成します。
DOM API によりファイル選択イベントの発火を input タグでなく button タグに持たせたいので、以下の記事を参考に input タグを装飾しました。

App.tsx
function App() {
  const inputFileRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <h2>画像をアップロード</h2>
      <div>
        <img alt="画像が選択されていません。" />
        <input
          type="file"
          ref={inputFileRef}
          accept="image/*"
          style={{ display: "none" }}
        />
        <button onClick={() => inputFileRef.current?.click()}>
          ファイルを選択
        </button>
      </div>
    </>
  );
}

画像のプレビュー表示

上で作成したコンポーネントに画像のプレビュー機能を実装していきます。

App.tsx
function App() {
  const [previewImage, setPreviewImage] = useState("");
  const inputFileRef = useRef<HTMLInputElement>(null);

  const handleChangePreview = (ev: React.ChangeEvent<HTMLInputElement>) => {
    if (!ev.target.files) {
      return;
    }
    const file = ev.target.files[0];

    setPreviewImage(URL.createObjectURL(file));
  };

  return (
    <>
      <h2>画像をアップロード</h2>
      <div>
        <img
           src={previewImage}
          alt="画像が選択されていません。"
        />
        <input
          type="file"
           onChange={handleChangePreview}
          ref={inputFileRef}
          accept="image/*"
          style={{ display: "none" }}
        />
        <button onClick={() => inputFileRef.current?.click()}>
          ファイルを選択
        </button>
      </div>
    </>
  );
}

ファイルを選択すると以下のように画像を選択してブラウザ上に表示します。

 2023-09-08 17.53.37.png

 2023-09-08 17.54.53.png

フロントエンドからのHTTPリクエスト

バックエンドへ画像データを投げる関数を作成します。
追加した画像を FromData としてサーバーに送信します。

import axios from "axios";

export async function createUploadImage(file: File) {
  const formData = new FormData();

  Object.entries({ file }).forEach(([key]) => {
    formData.append(key, file);
  });

  const res = await axios.post(url, formData, {
    headers: {
      "Content-Type": "multipart/form-data",
    },
  });

  return res.data
}

また、アップロードボタンクリック時に inputFileRef から画像ファイルを取得します。

<button onClick={() => {
  const fileList = inputFileRef.current?.files;
  console.log(fileList);
}}>アップロードする</button>

これらの関数をコンポーネントに加えます。

App.tsx
function App() {

  const inputFileRef = useRef<HTMLInputElement>(null);

+  const handleClickUpload = async () => {
+    const fileList = inputFileRef.current?.files;
+      if (!fileList) {
+        return;
+      }
+    const file = fileList[0];
+    const res = await createUploadImage(file);
+    console.log(res);
+  };

  return (
    <>
      <h2>画像をアップロード</h2>
      <div>
        <img
          src={previewImage}
          alt="画像が選択されていません。"
        />
        <input
          type="file"
          onChange={handleChangePreview}
          ref={inputFileRef}
          accept="image/*"
          style={{ display: "none" }}
        />
      </div>
      <button onClick={() => inputFileRef.current?.click()}>
        ファイルを選択
      </button>
+       <button onClick={handleClickUpload}>アップロードする</button>
    </>
  );
}

バックエンドがないので動作を見ることはまだできませんが、ひとまずフロントはこれで OK です。

バックエンド

NestJS を使ってバックエンドを作ります。

開発環境

  • node: v18.14.0
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"rxjs": "^7.2.0",
"typescript": "^4.3.5"

HTTP POST リクエストによるファイルのアップロードにおいて、multipart/form-data 形式で投稿されたデータを処理するために、以下の型定義ファイルをインストールします。

yarn add -D @types/multer

Controller

HTTP リクエストの処理を行う Controller を作成します。

作成した NestJS プロジェクトに src/images/images.controller.tssrc/images/images.module.ts を作成します。

images.controller.ts
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Express } from "express";

Controller("images")
export class ImagesController {
  @UseInterceptors(FileInterceptor("file"))
  @Post("upload")
  uploadFile(@UploadedFile() file: Express.Multer.File) {

    return { filename: file.originalname };
  }
}
images.module.ts
import { Module } from "@nestjs/common";
import { ImagesController } from "./images.controller";

@Module({
  controllers: [ImagesController],
})
export class ImagesModule {}

app.module.ts につなぎこみます。

app.module.ts

@Module({
-   imports: [],
+   imports: [ImagesModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

cors の設定をし、フロントエンドから API を叩けるようにします。

main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // すべてのオリジンからのアクセスを許容する設定
+  app.enableCors();
  await app.listen(3000);
}
bootstrap();

開発サーバーをそれぞれ立て、フロントエンドからバックエンドへデータの受け渡しができているか確認してみます。

フロントエンドから選択した画像をバックエンドに送信します。

image-upload.gif

成功してブラウザの開発者コンソールにレスポンス(ファイル名)が表示されます。

ストレージ

続いて、バックエンドからストレージへの画像アップロード処理を作っていきます。ストレージサービスには AWS S3 を使います。

AWSへの接続

App.module.ts から ConfigModule を使って、 AWS への接続に使う環境変数を NestJS アプリで読み込みます。

yarn add -D @nestjs/config

App.module..ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { ImagesModule } from "./images/images.module";

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [
        () => ({
          aws: {
            region: process.env.AWS_REGION,
            accessKey: process.env.AWS_ACCESS_KEY_ID,
            secretKey: process.env.AWS_SECRET_ACCESS_KEY,
            s3BucketName: process.env.AWS_BUCKET_NAME,
          },
        }),
      ],
      isGlobal: true,
    }),
    ImagesModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

次に main.ts で AWS の設定をします。

yarn add -D aws-sdk

main.ts
+ import { ConfigService } from "@nestjs/config";
+ import { config } from "aws-sdk";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();

+  const configService = app.get(ConfigService);
+  config.update({
+    accessKeyId: configService.get("aws.accessKey"),
+    secretAccessKey: configService.get("aws.secretKey"),
+    region: configService.get("aws.region"),
+  });

  await app.listen(3000);
}
bootstrap();

images.module.ts で ConfigModule を使う設定をします。

images.module.ts
+ import { ConfigModule } from "@nestjs/config";

@Module({
+  imports: [ConfigModule],
  controllers: [ImagesController],
  providers: [ImagesService],
})
export class ImagesModule {}

サービスクラスを作成する

AWS S3 へファイルアップロードを実際に行う images.service.ts を作成します。

images.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { S3 } from "aws-sdk";
import { v4 as uuid } from "uuid";

@Injectable()
export class ImagesService {
  constructor(private readonly configService: ConfigService) {}

  async upload(file: Express.Multer.File) {
    const s3 = new S3();

    const uploadParams = {
      Bucket: this.configService.get("aws.s3BucketName"),
      Body: file,
      Key: uuid(),
    };

    await s3.upload(uploadParams).promise()
  }
}

ImageServiceupload 関数を作成し、 ImagesController から受け取った画像ファイルを S3 にアップロードします。

最後に ImageModuleImageController から ImageService を使えるように設定します。

// images.controller.ts
+ import { ImagesService } from "./images.service";

@Controller("images")
export class ImagesController {
+  constructor(private readonly imagesService: ImagesService) {}

  @UseInterceptors(FileInterceptor("file"))
  @Post("upload")
  async uploadFile(@UploadedFile() file: Express.Multer.File) {

-    return { filename: file.originalname };
+    await this.imagesService.upload(file);
  }
}

// images.module.ts
+ import { ImagesService } from "./images.service";

@Module({
  imports: [ConfigModule],
  controllers: [ImagesController],
+  providers: [ImagesService],
})
export class ImagesModule {}

おまけ

フロントエンドでの単体テストを行います。
ここでの単体テストは、ユーザーがファイルを選択することで アップロードする ボタンが表示されることをテストしていきます。

App.spec.tsx
test("画像が選択されると`アップロードする`ボタンを表示する");

環境

"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"jsdom": "^22.1.0",
"vitest": "^0.34.3"

テストコード

まず最初に Arrange, Act, AssertAssert の部分から書いていきます。

最終的に アップロードする ボタンを表示することが確認できればいいので、以下のようにします。

App.spec.tsx
test("画像が選択されると`アップロードする`ボタンを表示する", () => {

  expect(screen.getByRole("button", { name: "アップロードする" })).toBeInTheDocument();
});

次に Act を書いていきます。

App.spec.tsx
test("画像が選択されると`アップロードする`ボタンを表示する", async () => {

  const user = userEvent.setup();

  // user-event を使って擬似的にDOMを操作する
  // https://testing-library.com/docs/user-event/utility/#upload
  await user.upload(input, file);

  expect(screen.getByRole("button", { name: "アップロードする" })).toBeInTheDocument();
});

imageInputfile の変数定義がされていませんが一旦おいておきます。

最後に Arrange 部分を書いていきます。

App.spec.tsx
test("画像が選択されると`アップロードする`ボタンを表示する", async () => {
  const user = userEvent.setup();

  render(<App />);

  const input = screen.getByLabelText("file");

  const file = new File(["hello"], "hello.png", { type: "image/png" });

  await user.upload(input, file);

  expect(screen.getByRole("button", { name: "アップロードする" })).toBeInTheDocument();
});

ここで、テストコードの screen.getByRavelText("file") に合わせてプロダクトコードの input タグに aria-label を設定します。

App.tsx
function App() {
  // 省略
  return (
   // 省略
    <input
      type="file"
      onChange={handleChangePreview}
      ref={inputFileRef}
      accept="image/*"
      style={{ display: "none" }}
+      aria-label="file"
    />
  // 省略
  );
}

ここまでで aria-labelfile となっている要素を取得し、user-event を使ってファイルの選択を擬似的に行う流れになっています。

この状態でテストを実行すると以下のようなエラーが吐かれます。

TypeError: The "obj" argument must be an instance of Blob. Received an instance of File
 ❯ new NodeError node:internal/errors:399:5
 ❯ Function.createObjectURL node:internal/url:1027:13
 ❯ handleChangePreview src/App.tsx:15:25
     13|     const file = ev.target.files[0];
     14| 
     15|     setPreviewImage(URL.createObjectURL(file));
       |                         ^
     16|   };
     17| 

App.tsxhandleChangePreview 関数で URL.createObjectURL() 関数を実行していますが、 js-dom では URL.createObjectURL を実装していないのでモックする必要があります。

App.spec.tsx
import { expect, test, vi } from "vitest";

beforeEach(() => {
  // URL.createObjectURL が "hello.png" を返すように設定する
  URL.createObjectURL = vi.fn(() => "hello.png");
});

test("画像が選択されると`アップロードする`ボタンを表示する", async () => {
  const user = userEvent.setup();

  render(<App />);

  const input = screen.getByLabelText("file");

  const file = new File(["hello"], "hello.png", { type: "image/png" });

  await user.upload(input, file);

  expect(screen.getByRole("button", { name: "アップロードする" })).toBeInTheDocument();
});

jest を使う場合は beforeEach 内の、vi.fn() を jest.fn() に置き換えれば OK です。

0
3
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
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?