⏳
サーバ側の重い処理の進捗をクライアント側で表示する
前提
やりたいこと
- クライアント(ブラウザ)からサーバにファイルをアップロードする
- サーバ側で何かファイルに処理を行い、進捗をクライアント側で表示する
使用する言語など
- クライアント側: 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%になる前に処理結果が出る
他にもっと良い方法がありましたらお知らせいただけると幸いです。
ソースコード
クライアント
サーバ参考
-
Django+Javascriptでプログレスバーを実装する
2020- https://www.sw-mono.blog/entry/2020/01/06/140153
- サーバ側はDjango、クライアントはjavascriptで進捗を取得
-
ブラウザでサーバー側処理進捗のリアルタイム表示 (プログレスバーの実装)
2016- https://qiita.com/donaldchi/items/24c5f269e9ec76053bd6
- サーバでpython, shell, phpが動いており、pythonからメインの処理と進捗監視を起動し、phpで進捗状況を取得してプログレスバーを表示
-
Flaskで簡易版プログレスバー実装して処理の進捗見れるようにしてやんよ!!!
2020- https://tokidoki-web.com/2020/02/flaskで簡易版プログレスバー実装して処理の進捗見/
- flaskで進捗をqueueに格納してyieldしながら、jsのEventSourceで進捗を更新
-
reactで複数の画像をアップロードする
2022- https://omkz.net/react-upload-images/
- フロント側ファイルアップロード処理
-
docker-composeでredis環境をつくる
2019- https://qiita.com/uggds/items/5e4f8fee180d77c06ee1
- docker-compose.ymlの設定
Discussion