【初心者OK】AWS S3を利用した画像アップロードアプリを開発してみた

# アプリの概要
![aws-s3-app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/66a7ed00-1176-1dfb-92bc-2f6d225db852.png)

このアプリは、クライアント端末からS3に画像のアップロードとS3内の画像の取得ができます。
ただそれだけのアプリですが、これを応用すれば動画投稿サイトやSNSなど、開発の幅が広がります。

使用した技術は以下の通りです。

- HTML
- Tailwind CSS
- JavaScript（React, TypeScript, Next.js）
- Node.js
- Express
- AWS IAM
- AWS S3

開発期間は1日ですので、手順通りにすれば数2〜3時間程度で完了します。

# フロントエンド編
create-next-appで開発環境を整えたら、以下のようなディレクトリになるようにしましょう。

![aws-s3-app2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/292aa6e1-a164-16a3-103a-21f72ac79bc0.png)

まずは、header.tsxを編集します。
``` header.tsx
import React from 'react';

const Header = () => {
    return (
        <header className='flex justify-center items-center h-14 bg-blue-400'>
            <div className='text-white text-lg font-semibold'>
                AWS-S3-Upload-App
            </div>
        </header>
    );
};

export default Header;
```
headerが表示されているかを確認するために、pagesのindex.tsxにコンポーネントを貼り付けます。

``` pages/index.tsx
import React from "react";

//components
import Header from "@/components/headerComponent/header";

export default function Home() {
  return (
    <>
    <Header />
    </>
  );
};
```
footer.tsxは以下の通りです。

``` footer.tsx
import React from 'react';

const Footer = () => {
    return (
        <footer className='flex justify-center items-center h-16 bg-blue-400'>
            <div className='text-white'>
                &copy; All rights reserved Takayuki
            </div>
        </footer>
    );
};

export default Footer;
```
headerと同様にpagesのindex.tsxにコンポーネントを貼り付けて、表示を確認します。
確認が完了したら、divタグとmainタグを追加してください。

``` pages/index.tsx
import React from "react";

//components
import Header from "@/components/headerComponent/header";
import Footer from "@/components/footerComponent/footer";

export default function Home() {
  return (
    <>
    <Header />
    <main className="bg-neutral-200 min-h-screen">
      <div className="max-w-5xl min-h-screen mx-auto">
      </div>
    </main>
    <Footer />
    </>
  );
};
```
これは背景色を変えたり、画像を中央に寄せるためにスタイルをあてています。

次に、画像をアップロードするform.tsxのデザインを作成します。
以下のようにしましょう。

``` form.tsx
import React, { useState } from 'react';

//axios
import { apiClientMulti } from '@/lib/apiClient';

const Form = () => {
    return (
    <section className="flex items-center justify-center pt-6 mb-8">
        <div className='flex items-center'>             
            <label className="cursor-pointer bg-blue-500 text-white p-2 rounded-md hover:bg-blue-400 transition-all">
                <span>ファイルを選択</span>
                <input id="inputImage" type="file" className="hidden" accept="image/*" onChange={handleImageChange} />
            </label>
        </div>
    </section>
    );
};

export default Form;
```
これで、画像をアップロードするボタンを作れました。
以下のように画像をアップロードする際に、画像の名前を表示する画面を作ります。

![aws-s3-app3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/edc2f0f9-8200-120e-01ac-f256522cc216.png)

「ファイルを選択」をクリックし、画像を選択すると上記画面になります。
これで、どの画像を対象としているかが確認可能です。

この画面を作るには、useStateで状態を管理する必要があります。

``` form.tsx
const Form = () => {
    const [selectImage, setSelectImage] = useState<File | null>(null);

    ...省略
}
```
対象のファイル名を表示するには、selectImageにデータを入れる必要がありますので、changeEventを実装します。

``` form.tsx
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const fileList = e.target.files;
    if (fileList !== null) {
        const file = fileList[0];
        setSelectImage(file);
    };
};
```
ファイルは配列で渡されるので、fileList[0]のように指定する必要があります。
この処理でselectImageにファイルがセットされたので、画面を先ほどの画像のように画像の名前とアップロードボタンを表示させます。

そのために、return以降を書き換えます。

``` form.tsx
...省略

 return (
    <section className="flex items-center justify-center pt-6 mb-8">
        {
            selectImage ? (
                <div className='flex items-center'>
                    <p className='mr-6'>
                        {selectImage.name}
                    </p>
                    <button 
                    className='cursor-pointer bg-blue-500 text-white p-2 rounded-md hover:bg-blue-400'
                    onClick={handleUpload}
                    >
                        アップロード
                    </button>
                </div>
            ) : (
                <>                
                <label className="cursor-pointer bg-blue-500 text-white p-2 rounded-md hover:bg-blue-400 transition-all">
                    <span>ファイルを選択</span>
                    <input id="inputImage" type="file" className="hidden" accept="image/*" onChange={handleImageChange} />
                </label>
                </>
            )
        }
    </section>
    );
```

上記のコードは、selectImageの有無で表示する内容を変えています。
次にアップロード機能を実装します。

``` form.tsx
const handleUpload = async () => {

    if (!selectImage) {
        console.error('No image selected');
        return;
    };

    const formData = new FormData();
    formData.append('image', selectImage);
    formData.append('userId', '1');

    try {
        const response = await apiClientMulti.post('/upload/upload', formData);
        console.log(response.data.message);

        setSelectImage(null);
        location.reload();

    } catch (err) {
        console.error(err);
    };
};
```
formData.appendは、画像データとユーザーIDを追加しています。
今回はユーザーIDに意味はないですが、このように追加するとログインしているユーザーを識別することが可能です。

apiClientMultiにエラーが発生しているので、こちらも実装します。

``` lib/apiClient.ts
import axios from "axios";

export const apiClientMulti = axios.create({
    baseURL: "http://localhost:8080/api/v1",
    headers: {
        'Content-Type': 'multipart/form-data'
    }
});
```

Axiosを利用してバックエンドにデータを送信しますので、インストールしておきましょう。

実装が完了したら、pages/index.tsxにコンポーネントを貼り付けます。

``` index.tsx
import React from "react";

//components
import Header from "@/components/headerComponent/header";
import Form from "@/components/formComponent/form";
import Footer from "@/components/footerComponent/footer";

export default function Home() {
  return (
    <>
    <Header />
    <main className="bg-neutral-200 min-h-screen">
      <div className="max-w-5xl min-h-screen mx-auto">
        <Form />
      </div>
    </main>
    <Footer />
    </>
  );
};

```

最後に、取得した画像を画面に表示するためのコンポーネントを開発します。

``` display.tsx
import React, { useState, useEffect } from 'react';

//axios
import { apiClientMulti } from '@/lib/apiClient';

//type
type ImageType = {
    key: string | undefined;
    url: string;
    lastModified: Date | undefined;
    size: number | undefined;
};

const Display = () => {
    return (
        <section>
            <ul className='flex flex-wrap'>
                <li key={image.key} className='p-2 w-64 cursor-pointer h-40 hover:scale-105 transition-all'>
                    <img 
                    className='m-auto w-full h-full rounded-md'
                    alt='画像'
                    src={'ダミー画像パス'}
                    />
            </ul>
        </section>
    );
};

export default Display;
```

あなたが持っている画像をpublic/imagesディレクトリに入れて、そのパスをダミー画像パスに入力してください。
うまく表示されたら、完了です。

次に、画像取得機能を作成します。

``` display.tsx
...省略

    const [images, setImages] = useState<ImageType[]>([]);

    const fetchImages = async () => {
        const response = await apiClientMulti.get("/download/list-images");

        try {
            if (response.data.images) {
                setImages(response.data.images);
            } else {
                console.log("データなし");
            };
        } catch (err) {
            console.error(err);
        };
    };

    useEffect(() => {
        //fetchImages();
    }, []);
    
...省略
```
useEffectにあるfetchImagesはこの後のバックエンド編とインフラ編が完了したら、コメント解除してください。

バックエンドからデータが配列で転送されるので、全ての画像を表示できるようにします。
return以降の文を編集します。

``` display.tsx
return (
    <section>
        <ul className='flex flex-wrap'>
            {
                images.map((image) => (
                    <li key={image.key} className='p-2 w-64 cursor-pointer h-40 hover:scale-105 transition-all'>
                        <img 
                        className='m-auto w-full h-full rounded-md'
                        alt='画像'
                        src={image.url}
                        />
                    </li>
                ))
            }
        </ul>
    </section>
);
```

これで、フロントエンドの実装が完了です。
ここまでのコードは以下のリンクで確認できます。

https://github.com/Takapon2512/image-upload-s3/tree/main/client

# インフラ編
次はAWSの設定をしていきます。

AWSにログインし、コンソール画面を表示しましょう。

## IAMの設定
AWS IAMにあるユーザーをクリックして以下の画面を表示します。

![aws-s3-app4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/1c2cfac5-01a8-64e7-4838-493c9aba1e98.png)

右上にある「ユーザーの作成」をクリックし、以下の画面を表示します。

![aws-s3-app5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/1e916e4d-ae4d-195f-9abd-0dead24f441b.png)

ユーザー名に任意の名前を入力し、「次へ」を押下してください（オプションのチェックは空欄で問題ありません）。

![aws-s3-app6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/2b37c49a-0790-ae92-0730-4cd943c591cd.png)

ポリシーを直接アタッチするにチェックを入れて、許可ポリシー「AmazonS3FullAccess」を検索し、チェックを入れます。
FullAccessは権限が強いので本番環境で動かすアプリには適切な許可設定を行いますが、今回はそれで問題ありません。

![aws-s3-app7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/c05b57aa-1d79-6eff-1dea-22159a2fa07d.png)

「ユーザーの作成」をクリックすると、ユーザーが新しく作成されます。

![aws-s3-app8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/872d6a95-a24e-4fa4-11f5-feaad87f43b5.png)

作成したユーザーの詳細画面を開き、セキュリティ認証情報のタブをクリックしてください。

![aws-s3-app9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/42c162e2-8ae5-d814-12bf-444d8308cbfe.png)

スクロールすると「アクセスキーを作成」というボタンがあるので、それをクリックします。

![aws-s3-app10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/6eb0d736-3448-f459-0287-bb174202b8c1.png)

「上記のレコメンデーションを理解し、アクセスキーを作成します。」にチェックを入れます。

AWSはアクセスキーを利用したアクセス方法は推奨してないので、このように別の方法でのアクセスを提案しています。
本番環境ではAWSの提案に従ってアクセスキー以外の方法を使うことをおすすめしますが、今回はアクセスキーを使用します。

![aws-s3-app11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/18ab2ce2-fcca-630e-b04e-eff12a298320.png)

説明タグの設定は入力してもしなくても良いです。
私は分かりやすいように入力しておきます。

「アクセスキーを作成」をクリックすると「Access key ID」と「Secret access key」が表示されます。
これはページを閉じると確認できなくなるので、csvファイルでダウンロードすることをおすすめします。

![aws-s3-app12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/e9da1571-ce37-9d90-8015-60521ed6aa67.png)

## S3の設定
IAMの設定が完了したので、S3のページを開きます。
「バケットを作成」をクリックし、以下の画面を表示します。

![aws-s3-app13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/ff4e812e-b522-d733-a079-1eba8a6c035c.png)

好きな名前をバケット名に入力します。
ただし、世界中で一意の名前にならないとエラーになるため、日付などで名前をかぶらないようにすると良いでしょう。

![aws-s3-app14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/6e115164-2caa-1e53-398b-40b6870447e5.png)

次に、ACLを有効化します。

![aws-s3-app15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/569be2aa-eea0-cea1-f4bc-c01c85ba8a1b.png)

デフォルトで「パブリックアクセスをすべてブロック」にチェックがついてますが、パブリックアクセスしないと画像を取得できないのでチェックを外します。

黄色のブロックにあるチェックボックスもチェックをつけてください。

以降の項目は、そのままにして「バケットを作成」をクリックします。

作成したバケットをクリックし、「アクセス許可」のタブをクリックしてください。
以下と見比べて問題なければ、オブジェクトにアクセスできるようになっています。

![aws-s3-app16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/31f17ec0-00fb-1a70-aaa5-6af4fa98054e.png)

![aws-s3-app17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/8b12c94a-9d92-6efc-3641-a2208efdf369.png)

最後に、以下を画像にある「編集」をクリックします。

![aws-s3-app20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/70c3f8ad-ea6a-119b-e9fa-28aff131e38d.png)

デフォルトでは空欄なので、以下のJSONを記入します。
``` AWS.json
{
  "Version": "2012-10-17",
  "Statement": [
      {
	  "Effect": "Allow",
	  "Principal": "*",
	  "Action": "*",
	  "Resource": "arn:aws:s3:::bucket-name/*"
      }
   ]
}
```

これでS3の設定は完了です。

# バックエンド編
以下のようにapiディレクトリを作成しましょう。

![aws-s3-app18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/10d699ee-ebd6-7d68-1f38-8557d1c8c4d5.png)

そのapiディレクトリの中にpackage.jsonやTypeScriptが起動する環境を作成してください。

![aws-s3-app19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/aaaedd01-8746-54da-e5ce-66123c18c6d3.png)

環境構築が難しい場合は、私が以前作成した記事を参照してください。

https://qiita.com/takapontype0924/items/85792419706da003ca2b

まずは、以下をインストールしてください。

``` bash.sh
npm install aws-sdk
npm install cors
npm install @types/cors -D
npm install multer
npm install @types/multer -D
npm install dotenv
```
次にserver.tsを以下のように書きます。

``` server.ts 
import express, { Express } from "express";
import * as AWS from "aws-sdk";
import cors from "cors";
import "dotenv/config"

//routers
import { uploadRouter } from "./routers/upload";
import { downloadRouter } from "./routers/download";

//AWS
AWS.config.update({
    accessKeyId: process.env.ACCESS_KEY_ID,
    secretAccessKey: process.env.SECRET_ACCESS_KEY_ID,
    region: process.env.AWS_REGION
});

const app: Express = express();
const PORT = 8080;

app.use(cors({ origin: "http://localhost:3000" }));
app.use(express.json());

app.use("/api/v1/upload", uploadRouter);
app.use("/api/v1/download", downloadRouter);

app.listen(PORT, () => console.log(`Server is running on ${PORT}`));
```
Routerは、ファイルを分割できるようにします。
aws-sdkは、AWSにあるサービスをプログラムで扱えるようにする開発キットです。

corsは異なるオリジンからのアクセスを許可することができるミドルウェアです。
許可していないとブラウザはCORSエラーを発生させるので、処理ができなくなります。

dotenvは、.envで記述した内容を利用できるようにします。

インフラ編でAWSにアクセスできるようにするためにアクセスキーを生成しました。
その際に「Access key ID」と「Secret access key」が発行されていますので、その内容を.envファイルにコピペします。

![aws-s3-app12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617758/e9da1571-ce37-9d90-8015-60521ed6aa67.png)

regionは、上記ページのAWSリージョンにありますので、それを記載してください。

次に、アップロードの処理を書いていきます。

``` upload.ts
import { Router } from "express";
import multer from "multer";
import * as AWS from "aws-sdk";

export const uploadRouter = Router();
```

Multerとは、Expressでファイルのアップロード処理を行うためのミドルウェアです。
これにより、multipart/form-data形式のデータが扱えます。

次に、S3を利用する準備としてS3のインスタンスの作成とバケット名を登録します。

``` upload.ts
//S3のインスタンス作成
const s3 = new AWS.S3();
const bucketName = process.env.AWS_BUCKET_NAME;
```
Multerでファイルを扱えるようにします。

``` upload.ts
//Multer
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
```

ここからは、アップロードの処理を書いていきます。

``` upload.ts
uploadRouter.post("/upload", upload.single('image'), async (req, res) => {
    const data: Express.Multer.File | undefined = req.file;

    try {
        if (data) {
            const params = {
                Bucket: bucketName || "",
                Key: `images/${Date.now()}_${data.originalname}`,
                Body: data.buffer,
                ContentType: data.mimetype,
            };
        
            await s3.upload(params).promise();
        
            return res.status(200).json({ message: "アップロード完了！" });
        } else {
            return res.status(500).json({ message: "アップロードに失敗しました。" });
        };
    } catch (err) {
        console.error(err);
        return res.status(500).json({ message: "アップロードできません。" });
    };
});
```
これでアップロードできるようになりました。

ダウンロードの処理は以下のように書きます。

``` download.ts
import { Router } from "express";
import * as AWS from "aws-sdk";

export const downloadRouter = Router();

const s3 = new AWS.S3();
const bucketName = process.env.AWS_BUCKET_NAME;
const folderName = "images/";

downloadRouter.get("/list-images", async (req, res) => {
    try {
        const params = {
            Bucket: bucketName || "",
            Prefix: folderName
        };

        const data = await s3.listObjectsV2(params).promise();

        const images = data.Contents?.map((obj) => {
            return {
                key: obj.Key,
                url: `${process.env.S3_URL}/${obj.Key}`,
                lastModified: obj.LastModified,
                size: obj.Size
            };
        });

        res.status(200).json({ images });
    } catch (err) {
        console.error(err);
        return res.status(500).json({ message: "画像一覧の取得に失敗しました" });
    };
});
```
S3から受け取ったデータは配列かつ情報量が多いので、mapで加工してからフロントエンド側に転送します。

最後にclientのdisplay.tsxを以下のようにすれば、画像が表示されます。
もし、取得できない場合はAWSの許可設定やcorsの設定を見直してください。

``` display.tsx
...省略

    useEffect(() => {
        fetchImages();
    }, []);
    
...省略
```

バックエンドのコードも貼りますので、動かない方は確認してみると良いでしょう。

https://github.com/Takapon2512/image-upload-s3/tree/main/api

# 最後に
S3に画像をアップロードして、ダウンロードしたものを画面に表示するというアプリですが、さまざまなものに応用できます。

このように簡単なアプリの開発にチャレンジしていくと、多機能なアプリを開発できるようになります。

## 筆者について

https://qiita.com/takapontype0924/items/23b3c6648f57c871c2c1