サーバ側の重い処理の進捗をクライアント側で表示する

2022/09/04に公開

前提

やりたいこと

  • クライアント(ブラウザ)からサーバにファイルをアップロードする
  • サーバ側で何かファイルに処理を行い、進捗をクライアント側で表示する

使用する言語など

  • クライアント側: TypeScript (React)
  • サーバ側: Python (Flask), Gunicorn, Redis, Docker

方針

  • クライアントはファイルを1回だけPOSTし、進捗を聞くリクエストを定期的にPOSTする
  • サーバは最初のリクエストを受け取ったワーカーと別のワーカーがリクエストを受け取るので、redis(データベース)に進捗状況を格納しながら変換を進め、進捗リクエストが来たらその都度redisから進捗を取り出してレスポンスを返す
  • クライアントは帰ってきた進捗を使ってプログレスバーを描画

クライアント側

create-react-appでプロジェクトを作成、インストール

npx create-react-app my-app --template typescript
cd my-app
npm i axios uuidjs react-bootstrap bootstrap @mui/material @emotion/react @emotion/styled

package.jsonのreactのバージョンは"react": "^18.2.0",
tsconfig.jsonとsrc/App.tsxを以下のように変更する

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "downlevelIteration": true, // 追加
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],

進捗リクエスト時にタスクを特定するため、リクエストごとにidを生成してファイルアップロード時に一緒にサーバに送っている

App.tsx
import React, { useState } from "react";
import axios from "axios";
import UUID from 'uuidjs';
import ProgressBar from 'react-bootstrap/ProgressBar';
import Button from "@mui/material/Button";
import 'bootstrap/dist/css/bootstrap.min.css';


function App() {

  type ResponseUpload = {
    result: string;
  }
  type ResponseProgress = {
    progress: number;
  }

  const [files, setFiles] = useState<File[]>([]);
  const [progress, setProgress] = useState<number>(0);
  const [message, setMessage] = useState<string>("");
  const id: string = UUID.generate();
  const urlOrigin: string = "http://127.0.0.1/";
  const inputId: string = "input";


  // progressリクエスト
  const getProgress = () => {

    // 送信データ
    const url: string = urlOrigin + "progress";
    const data = new FormData();
    data.append("id", id);
    const header = {
      headers: {
        "Content-Type": "multipart/form-data",
      },
    }

    // intervalごとに繰り返し問い合わせ
    const interval: number = 1000;
    let progress: number = 0;
    let timer = setInterval(async () => {
      const response = await axios.post<ResponseProgress>(url, data, header);
      progress = response.data.progress;
      setProgress(progress);

      // 進捗100%になったら終了
      if (progress >= 100) {
        clearInterval(timer);
      }
    }, interval);
  };

  // uploadリクエスト
  const handleOnSubmit = async (e: React.SyntheticEvent): Promise<void> => {
    e.preventDefault();
    setMessage("送信した");

    // 送信データ
    const data = new FormData();
    files.forEach((file) => {
      data.append("file", file);
    });
    data.append("id", id);
    const header = {
      headers: {
        "Content-Type": "multipart/form-data",
      },
    }

    // ファイルを送信。すべてのリクエストがresolveされるまで次に進まない
    const url: string = urlOrigin + "upload";
    const responses = await Promise.allSettled([
      axios.post<ResponseUpload>(url, data, header),
      Promise.resolve(getProgress()),
    ])

    // 結果を表示    
    const response = responses[0];
    if (response.status === 'fulfilled') {
      const result: string = response.value.data.result;
      setMessage("処理結果: " + result);
    }


  };

  const handleOnAddFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;
    setFiles([...files, ...e.target.files]);
    setProgress(0);
    setMessage("ファイル選択した");
  };

  const styleDiv = {
    margin: "auto",
    width: "50%",
    height: "50%"

  }

  const styleButton = {
    margin: "20px",
  }

  const styleProgress = {
    margin: "20px",
    width: "300px"
  }

  const styleInput = {
    display: "none"
  }

  return (


    <div style={styleDiv}>
      <form
        action=""
        onSubmit={(e) => handleOnSubmit(e)}
      >
        <label htmlFor={inputId}>
          <Button
            variant="contained"
            component="span"
            style={styleButton}
          >
            ファイル選択
          </Button>
          <input
            id={inputId}
            type="file"
            multiple
            style={styleInput}
            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
              handleOnAddFile(e)
            }
            onClick={(event) => {
              (event.target as HTMLInputElement).value = "";
            }}
          />
        </label>

        {/* submitボタン */}
        <Button
          variant="contained"
          type="submit"
          disableElevation
          disabled={files.length === 0}
          style={styleButton}
        >
          送信
        </Button>
      </form>

      {/* プログレスバー */}
      <div
        style={styleProgress}
      >
        <ProgressBar
          now={progress}
          label={`${progress}%`}
        />
      </div>

      {/* メッセージ */}
      <p>{message}</p>
    </div>


  );
}

export default App;

サーバ側

docker-composeでFlaskのコンテナとredisのコンテナが同時に起動するようにする
Flaskはそのままではリクエストを並列で受け取れないのでgunicornを使う

ディレクトリ構成

my-app-server/
 ├ docker-compose.yml
 ├ Dockerfile
 ├ main.py
 └ requirements.txt
docker-compose.yml
version: '3'
services:
  redis:
    image: "redis:latest"
    ports:
      - "6379:6379"
    volumes:
      - "./data/redis:/data"
  myapp:
    build: .
    ports:
      - "80:5000"
    volumes:
      - .:/workspace
    command: gunicorn main:app -b 0.0.0.0:5000 -w 2 --timeout 300 # worker1個だとファイル処理と進捗表示が同時にできないので-w 2以上にする
    depends_on:
      - redis
Dockerfile
FROM python:3.9.13-bullseye

ENV DIR_WORK=/workspace
RUN mkdir ${DIR_WORK} 
WORKDIR ${DIR_WORK}
COPY . .

# python
RUN python3 -m pip install --upgrade pip \
    && python3 -m pip install -r requirements.txt
main.py
from typing import Dict
import json
import time

from flask import Flask, request
import flask
from icecream import ic
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
import redis

app: Flask = Flask(__name__)
# アップロードできるファイルサイズを1MBに制限
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024

# redisに接続する準備
redis_client: redis.client.Redis = redis.Redis(
    host='redis',
    port=6379,
    db=0,
    decode_responses=True,
)


# 進捗を返すためのエンドポイント
@app.route("/progress", methods=['POST'])
def get_progress():
    ic(request.values)

    try:
        id_request: int = request.values["id"]
        if redis_client.exists(id_request):
            # redisから進捗を取り出す
            progress: int = redis_client.get(id_request)
        else:
            progress: int = 100
    except:
        progress: int = 100

    data: Dict[str, int] = {
        "progress": progress,
    }
    response: flask.wrappers.Response = app.response_class(
        response=json.dumps(data),
        status=200,
        mimetype='application/json',
    )
    # CORS
    response.headers.add('Access-Control-Allow-Origin', '*')
    return response


# ファイルアップロード用のエンドポイント
@app.route("/upload", methods=['POST'])
def upload_file():

    ic(request.files)
    ic(request.values)
    id_request: int = request.values["id"]

    # ファイルを読み込む
    file_in: FileStorage = request.files['file']
    name_file: str = file_in.filename
    if name_file == "":
        data: Dict[str, str] = {"result": "filename must not empty"}
        response: flask.wrappers.Response = app.response_class(
            response=json.dumps(data),
            status=400,
            mimetype='application/json',
        )
        return response
    name_file: str = secure_filename(name_file)

    # ============
    # ファイル処理。今回は何もしない
    # ============
    # 進捗の初期化
    progress: int = 0
    redis_client.set(id_request, progress)

    n: int = 10
    for i in range(n):
        time.sleep(1)

        # 進捗の登録
        progress: int = int((i + 1) / n * 100)
        redis_client.set(id_request, progress)

    # ============
    # 送信
    # ============
    result: str = "success"
    data: Dict[str, str] = {
        "result": result,
    }
    response: flask.wrappers.Response = app.response_class(
        response=json.dumps(data),
        status=200,
        mimetype='application/json',
    )

    # CORS
    response.headers.add('Access-Control-Allow-Origin', '*')

    # 進捗の消去
    redis_client.delete(id_request)
    return response

requirements.txt
icecream==2.1.3
Flask==2.2.2
gunicorn==20.1.0
redis==4.3.4

動作確認

サーバ側
docker-compose upで起動

クライアント側
npm startで起動


起動時の状態


ファイル選択後、送信ボタンを押すとプログレスバーに進捗が表示される


処理が完了すると処理結果: にサーバから帰ってきた文字列が出る。進捗リクエストの送信間隔が長いと進捗100%になる前に処理結果が出る

他にもっと良い方法がありましたらお知らせいただけると幸いです。

ソースコード

クライアント
https://github.com/e4exp/example-progressbar
サーバ
https://github.com/e4exp/example-progressbar-server

参考

Discussion