Django+Javascriptでプログレスバーを実装する

f:id:monozukuri-bu:20200106131540j:plain

hashiです。
外部ツールを使いにくい環境でプログレスバーを実装する必要に駆られてしまいました。
そこで、Django+Javascriptプログレスバーを実装しましたので、その方法をまとめてみました。

全体の処理の流れ

処理の流れは以下のようになっています。
進捗状況を管理するテーブルをDB上に作成し、随時その値を確認することで進捗状況をリアルタイムで取得することができます。

f:id:monozukuri-bu:20200106132313p:plain

環境構築

では、これから実際に実装していくための、開発環境を構築しましょう。

前提

以下の環境であることを前提とします。

プロジェクト用ディレクトリ作成

以下のコマンドを実行して適当な場所にDjangoプロジェクトを格納するためのディレクトリを作成し、その中に入ります。

mkdir progress_bar  # ディレクトリ作って
cd progress_bar     # その中に入る
仮想環境の作成

既にインストールされているパッケージの影響を受けることを避けるため、新しく作った仮想環境内で1から環境構築していきます。
今回はパッケージ管理にpipenvを使用します。入っていない場合は、

pip install pipenv  # pipでインストール

を実行してください。その後、

export PIPENV_VENV_IN_PROJECT=true  # 仮想環境をプロジェクトディレクトリ配下に作るように設定
pipenv shell                        # 仮想環境作成!

これでまっさらな環境が作成できました。
これ以降のコマンドはpipenv shellを実行して仮想環境に入った状態で実行することを前提とします。

Djangoのインストール

以下のコマンドを実行してDjangoをインストールします。

pipenv install django

これで、基本的な開発環境は整いました!

Djangoの動作確認

プロジェクトを作成して、開発のベースとなるガワを作っていきます。

プロジェクトの作成

以下のコマンドを実行してprojectを作成します。

django-admin startproject config .  #今のディレクトリにprojectを作成

現在のファイル構成は以下のようになります。

progress_bar
|--.venv
|--config
| |--__init__.py
| |--settings.py
| |--urls.py
| |--wsgi.py
|--manage.py
|--Pipfile
|--Pipfile.lock
アプリの作成

manage.pyと同階層で以下のコマンドを実行します。

mkdir apps                                  #アプリ用ディレクトリ作成
cd apps                                     #その中に…
python ../manage.py startapp example_app    #アプリ作成

作成したアプリを利用できるようにするためにconfig/settings.pyに以下の内容を追記します。

# config/settings.py

INSTALLED_APPS = +["apps.example_app.apps.ExampleAppConfig"]

続けてapps/example_app/apps.pyを書き換えます。

# apps/example_app/apps.py
from django.apps import AppConfig


class ExampleAppConfig(AppConfig):
    # name = 'example_app'
    name = "apps.example_app"
トップページのviewの作成

アプリのビューを以下のように記述します。

# apps/example_app/views.py
from django.shortcuts import render

def index(request):
    """基本となるページ"""
    return render(request, "example_app/index.html")
テンプレートの作成

テンプレートにプログレスバーを埋め込む部分を用意しておきます。
Bootstrapはプログレスバーの装飾に使用します。

<!-- apps\example_app\templates\example_app\index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

    <!-- jQuery,Popper.js,Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"
        integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
        crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
        crossorigin="anonymous"></script>

    <title>プログレスバーサンプル</title>
</head>
<body>
    <div class="container">
        <div class="card">
            <div class="card-header">
                プログレスバーサンプル
            </div>
            <div class="card-body">
                <div id="progress">プログレスバー表示部分</div>
                <div id="result">処理結果表示部分</div>
                <div class="row">
                    <button class="btn btn-primary col-12" id="start_button">
                        処理の実行
                    </button>
                </div>
            </div>
        </div>
    </div>
</body>
</html>
urlの設定

まずアプリのurls.pyを作成します。

# apps/example_app/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("", views.index, name="index"),
]

続けてプロジェクト本体のurls.pyにアプリのurlパターンを読み込ませます。

# config/urls.py
from django.contrib import admin
from django.urls import path, include

from apps.example_app import urls as example_app_url

urlpatterns = [
    path("", include(example_app_url)),
    path("admin/", admin.site.urls),
]
表示確認

表示の確認のためmanage.pyと同階層で以下のコマンドを実行したのち、ブラウザからhttp://127.0.0.1:8000/にアクセスして動作確認します。

python ../manage.py runserver   #サーバ起動

f:id:monozukuri-bu:20200106133349p:plain

これが表示されていればOKです。

これで、ガワは完成しました。
ここから、プログレスバーのロジックを実装していきます。

サーバサイド側

PythonDjango側のロジックを実装していきます。

時間のかかる処理

サンプルとして時間のかかる処理を行う関数を作成します。
0.1秒ごとに1ステップ進み、10秒後に処理が終了する関数です。
10ループごとに実行されるmake_progress_func()が行う処理は後ほど説明します。

# apps\another_app\do_something.py
import time


def slow_function(make_progress_func):
    """裏側で動いている時間のかかる処理"""

    for i in range(100):
        time.sleep(0.1)
        if i % 10 == 0:
            make_progress_func()
    return "処理完了"

早速この関数を、views.pyから呼び出せるようにしておきます。

# apps/example_app/views.pyに以下の内容を追記
from ..another_app.do_something import slow_function
進捗管理モデルの作成(Django)

進捗状況を管理するためのモデルをアプリ内のmodels.pyで定義します。

# apps/example_app/models.py
from django.db import models


class Progress(models.Model):
    """進捗管理モデル"""

    now = models.IntegerField("現在の進捗", default=0)
    total = models.IntegerField("全ステップ数", default=100)

早速、このモデルをviews.pyにインポートしておきます。

# apps/example_app/views.pyに以下の内容を追記
from .models import Progress
進捗管理インスタンス作成部分(Django)

時間のかかる処理を実行する前に呼び出される関数を定義します。
この関数はmodels.pyで定義したProgressのインスタンスを作成し、そのプライマリーキーを返します。
この関数が返すプライマリーキーをもとに様々な処理を行うことになります。

# apps/example_app/views.pyに以下の内容を追記
from django.shortcuts import HttpResponse, render, get_object_or_404

def setup(request):
    """進捗管理インスタンスを作成する"""
    progress = Progress.objects.create()
    return HttpResponse(progress.pk)
プログレスバー表示部分(Django)

プログレスバーの表示にかかわる関数を定義します。
この関数はGETパラメータのprogress_pkに紐づく進捗管理インスタンスを取得し、その進捗度合いをパーセント換算してプログレスバーのテンプレートに渡します。

# apps/example_app/views.pyに以下の内容を追記
def show_progress(request):
    """時間のかかる関数を実行する"""
    if "progress_pk" in request.GET:
        # progress_pkが指定されている場合の処理
        progress_pk = request.GET.get("progress_pk")
        progress = get_object_or_404(Progress, pk=progress_pk)
        persent = str(int(progress.now / progress.total * 100)) + "%"
        return render(request, "example_app/progress_bar.html", {"persent": persent})
    else:
        # progress_pkが指定されていない場合の処理
        return HttpResponse("エラー")
進捗を進める部分(Django)

プログレスバーの進捗度合いを進めるための関数を定義します。 この関数は引数に紐づく進捗管理インスタンスを取得し、progress.nowを10増やして更新します。

# apps/example_app/views.pyに以下の内容を追記
def make_progress(pk):
    """引数のプライマリーキーに紐づく進捗を進める"""
    progress = get_object_or_404(Progress, pk=pk)
    progress.now += 10
    progress.save()
重い処理を呼び出す部分(Django)

時間のかかる処理を呼び出す部分を定義します。
functoolsを用いて引数を固定したmake_progress(pk)を引数としてslow_function()を呼び出します。

# apps/example_app/views.pyに以下の内容を追記
import functools


def set_hikisuu(pk):
    """引数を固定する"""
    return functools.partial(make_progress, pk=pk)


def do_something(request):
    """時間のかかる関数を実行する"""
    if "progress_pk" in request.GET:
        # progress_pkが指定されている場合の処理
        progress_pk = request.GET.get("progress_pk")
        result = slow_function(set_hikisuu(progress_pk))
        return render(request, "example_app/result.html", {"result": result})
    else:
        # progress_pkが指定されていない場合の処理
        return HttpResponse("エラー")
追加分のurl設定

ここまでで定義した関数に紐づくURLを設定します。

# apps/example_app/urls.pyに以下の内容を追記
urlpatterns += [
    path("setup/", views.setup, name="setup"),
    path("show_progress/", views.show_progress, name="show_progress"),
    path("do_something/", views.do_something, name="do_something"),
]

フロントエンド側

ブラウザの表示部分をJavascriptで実装していきます。

Djangoと表示の橋渡し

JavaScript部分を追記します。処理のフローは以下の通りです。

・画面上の処理開始ボタンが押される。
/setupにリクエストし進捗管理インスタンスのプライマリーキーを取得する。
/show_progressに定期的にリクエストしプログレスバーを取得し、画面上に反映しつづける。
/do_somethingにリクエストし処理結果を取得し、画面上に反映する。

<!-- apps\example_app\templates\example_app\index.htmlに追記 -->
<script>
    //プログレスバー表示部分の初期状態
    const progresshtml = '<div id="progress">プログレスバー表示部分</div>';

    //処理開始ボタンが押された時の処理
    $("#start_button").on("click", function (event) {
        console.log("start")
        let timer_id = 0;
        let url = "{% url 'setup' %}"
        $("#start_button").attr({ "disabled": true })
        //進捗管理インスタンス作成部分
        $.get(url, {},
            function (data) {
                console.log("get")
                let pk = data
                console.log("Data Loaded: " + data);

                //プログレスバーを3秒ごとに取得開始
                timer_id = setInterval(function () { ShowProgressBar(pk) }, 3000)
                //時間のかかる処理を開始
                GetResult(pk)
            }
        );
        //プログレスバーの取得
        function ShowProgressBar(progress_pk) {
            $.get("{% url 'show_progress' %}", { progress_pk: progress_pk },
                function (data) {
                    console.log("Data Loaded: " + data);
                    $("#progress").replaceWith(data)
                }
            );
        }
        //時間のかかる処理
        function GetResult(progress_pk) {
            $.get("{% url 'do_something' %}", { progress_pk: progress_pk },
                function (data) {
                    console.log("Data Loaded: " + data);
                    //プログレスバー更新をやめる
                    clearInterval(timer_id);
                    //プログレスバー部分を元の状態に戻す
                    $("#progress").replaceWith(progresshtml)
                    //処理結果表示
                    $("#result").replaceWith(data)
                    $("#start_button").attr({ "disabled": false })
                    alert("処理完了!")
                }
            );
        }
    });
</script>
プログレスバー表示部分(テンプレート)

Bootstrap4のドキュメントを参考に、プログレスバー部分を構築します。

getbootstrap.com

<!-- apps\example_app\templates\example_app\progress_bar.html -->
<div id="progress">
    <div class="progress">
        <div class="progress-bar" style="width:{{persent}}"></div>
    </div>
</div>
重い処理の処理結果部分(テンプレート)

この記事のアプリでは単にresultの値を出力するだけのものにしておきます。

<!-- apps\example_app\templates\example_app\result.html -->
<div id="result">
    {{result}}
</div>

動作確認

現在のファイル構成は以下のようになっているはずです。

|--apps
| |--__init__.py
| |--__pycache__
| |--another_app
| | |--__init__.py
| | |--__pycache__
| | |--do_something.py
| |--example_app
| | |--__init__.py
| | |--__pycache__
| | |--admin.py
| | |--apps.py
| | |--migrations
| | |--models.py
| | |--templates
| | | |--example_app
| | | | |--index.html
| | | | |--progress_bar.html
| | | | |--result.html
| | |--tests.py
| | |--urls.py
| | |--views.py
|--config
| |--__init__.py
| |--__pycache__
| |--settings.py
| |--urls.py
| |--wsgi.py
|--db.sqlite3
|--manage.py
|--Pipfile
|--Pipfile.lock

manage.pyと同階層で以下のコマンドを実行します。

python manage.py migrate    #DBのマイグレーション
python manage.py runserver  #サーバ起動

ブラウザからhttp://127.0.0.1:8000/にアクセスして「処理の実行」ボタンを押すと少しずつプログレスバーが進み、右端に到達するあたりで処理結果が表示されるはずです。

f:id:monozukuri-bu:20200106133349p:plain f:id:monozukuri-bu:20200106135757p:plain f:id:monozukuri-bu:20200106135805p:plain f:id:monozukuri-bu:20200106135814p:plain

終わりに

時間のかかる処理を扱う関数と進捗を進める関数の結合を弱めるため、進捗を進める関数の中身を意識せずに済むように意識しました。
これによってテスト時に考慮すべき必要なケース数が少なくて済むはずです。