6
9

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.

Dash/Plotlyでマルチページを作りたい

Posted at

はじめに

みなさんDash使ってますか?私はめっちゃ使ってます。PythonのFlaskベースで現代的なぬるぬる動くフロントのWebページが簡単にできるので非常に重宝しています。

DashがVer2になって割と破壊的変更が多く、ちょっと前の記事だとそのままでは動かないことが多く、日本語記事増えてほしい一心で自分の知見を記載していこうシリーズ(たぶん3回くらい書くつもり)の第一弾です(dash-bootstrap-componentsもver1になって破壊的変更が多い)。おおよそこちらの英語記事を参考に自分でアレンジしたような内容になる予定。第2弾はdash_authを改造する話でそこまでは書きたい。

基本的には公式ページが充実してるので英語ですが公式ページみてね、になるんですが、たまにそれはエンタープライズ版にしてねって書かれててたり、nginxとuwsgiかませた環境だと動かないとか(これもエンタープライズ版にしてね問題)があってどこにも知見がなかったりします。

そんななかで自分で適当になんとかやった知見書いていきますというものです。実はもっといいやり方あるよってのとかあればコメントよろしくお願いします。

なお、基本的な使い方とかはこちらの記事がダイジェストでよいです…がDash2.0対応してない気配あるんだよなぁ…。dash_core_componentはdashからimportするようになってるのです…。

from dash import Dash, dcc, html, Input, Output

前提環境

python==3.9
dash==2.2.0
dash-bootstrap-components==1.1.0

マルチページの話

ある程度規模が小さいうちはSPAで作ってて何ら問題ないんですが、一定以上大きくなってくると色々大変。主にidが被りまくるのとコールバックがどれだってなる。書き方や構成の問題で解決するのかもしれないけど自分はnginxとuwsgiかませてflaskとして起動していると何故か他のPythonファイルに書いたコールバックが全く効かなくなる問題があって非常に書きにくい状態でした(記事書くのに合わせて最新の?手法で書いたらそんなことなかったorz。公式multipage一番下に書いてある、The multi-page app examples here use the decorator @ callback, which is new with Dash 2.0. It simplifies creating multi-page apps. のおかげの模様…)。

公式ページはDiv要素作っておいて、URLによってその中身をURLによって振り分けるよーというやり方です。

ファイルの分割も2種類紹介されていて、ページごとに分ける方法と、機能ごとに分ける方法です。

ページごとに分ける方法でコールバックがちゃんと動けば個人的には全く問題なくできると思ってます。

idかぶり問題は、ページごとにid割り振るのがよさげ。

実際のところ

基本形

とりあえずコードはこちら

docker-composeでuwsgiかませて動かすようになっています。appの中のrun.pyをpythonで動かせばデバッグモードでDashが起動します。

とりあえず何の変哲もないdbcのnavbarとDashのmultipageをあわせたページです。

1.gif

serverにFlaskを入れてdashのappにserverを指定してるとか

server = Flask(__name__)
app = Dash(
    __name__,
    server=server,
    suppress_callback_exceptions=True,
    external_stylesheets=[dbc.themes.CERULEAN]
)

pythonで起動する時にflaskで起動したりflaskで起動できたりすることはちょっと補足いるかも。

if __name__ == "__main__":
    app.run_server(
        host = '0.0.0.0', debug=True, port='3031'
    )
    # flaskで起動する場合
    # server.run(host='0.0.0.0', port=3031, debug=True)

これはuwsgiで起動するためのおまじない?でdashで直接立てるとuwsgiうまく動かないのでflaskをかませています。VScodeのデバッグモードとかはflaskで起動する場合にしとけば動く用になるはず(やっとことない

で、


navbar = dbc.NavbarSimple(
                children=[
                    dbc.NavItem(dbc.NavLink("page1", href="/page1")),
                    dbc.NavItem(dbc.NavLink("page2", href="/page2")),
                    dbc.NavItem(dbc.NavLink("page3", href="/page3"))
                ],
                brand="テストページだよ",
                id = 'navibar'
            )


content = html.Div(id="page-content", 
                    style={"padding": "1rem 1.5rem","width":"100%","height":"100%"})

app.layout = html.Div([
                        dcc.Location(id='url', refresh=False),
                        navbar,
                        content
                    ])

navbar部分と本体部分(空っぽのdiv)とが入ったdiv要素をlayoutに指定して、layoutにdcc.Locationでurl指定とnavbarと本体部分をいれています。URLに応じて本体部分(id: page-content)を書き換えるということです。はい。

@app.callback(
    [
        Output('page-content', 'children')
    ],
    [
        Input('url', 'pathname')
    ]
)
def display_page(pathname):

    if (pathname == '/page1')|(pathname == '/'): 
        return_content = html.Div('ページ1だよ')
    elif (pathname == '/page2'):
        return_content = html.Div('ページ2だよ')
       
    else:
        return_content = '404 not found'


    return [return_content]

ページをファイルでわける

公式ページmultipageの中段One Page Per Fileをコピったpage1, page2を付けます。
コードはこちら

この記事書きながら初めて知ったんですが、dash2.0で追加されたcallbackを使うことでめっちゃすっきりかけるようです。
page1.layoutをrun.pyから呼び出す仕組みですね。

page1.py
from dash import dcc, html, Input, Output, callback

layout = html.Div([
    html.H3('Page 1'),
    dcc.Dropdown(
        {f'Page 1 - {i}': f'{i}' for i in ['New York City', 'Montreal', 'Los Angeles']},
        id='page-1-dropdown'
    ),
    html.Div(id='page-1-display-value'),
    dcc.Link('Go to Page 2', href='/page2')
])


@callback(
    Output('page-1-display-value', 'children'),
    Input('page-1-dropdown', 'value'))
def display_value(value):
    return f'You have selected {value}'
run.py
def display_page(pathname):
    if (pathname == '/page1')|(pathname == '/'): 
        return_content = page1.layout
    elif (pathname == '/page2'):
        return_content = page2.layout      
    else:
        return_content = '404 not found'

ページをファイルで分ける失敗例

小規模ならいいんですが、適当に作って大規模になってくると、稀によくidが被ります。
コード差分はこちら(これで動かすとエラーがでます)

page1
    dcc.Dropdown(
        {f'Page 1 - {i}': f'{i}' for i in ['New York City', 'Montreal', 'Los Angeles']},
        id='dropdown'
    ),
page2
    dcc.Dropdown(
        {f'Page 2 - {i}': f'{i}' for i in ['London', 'Berlin', 'Paris']},
        id='dropdown'
    ),

そうすると、動かすと以下のエラーがでます。id被っちゃだめなんですねー。
これはflask起動だと表示されなかったりするのでちょっと厄介です。

Duplicate callback outputs

In the callback for output(s):
  display-value.children
Output 0 (display-value.children) is already in use.
Any given output can only have one callback that sets it.
To resolve this situation, try combining these into
one callback function, distinguishing the trigger
by using `dash.callback_context` if necessary.

ページIDつけてみたらいいのでは?

コード差分はこちら
idが被るとだめなので、ページごとにidを振って、idつけるときにf'{PAGE_ID}_ととりあえずつけるスタイル。たぶんこれで大きな問題はないはず。これもそんなことやってた。

page1
PAGE_ID = 'page1'
#########中略#########
    dcc.Dropdown(
        {f'Page 1 - {i}': f'{i}' for i in ['New York City', 'Montreal', 'Los Angeles']},
        id= f'{PAGE_ID}_dropdown'
    ),
#######中略##########
@callback(
    Output(f'{PAGE_ID}_display-value', 'children'),
    Input(f'{PAGE_ID}_dropdown', 'value'))
def display_value(value):
    return f'You have selected {value}'

ちなみにページはこんな感じ。
2.gif

SPAにしない方法

これを参考にSPAにしない方法の紹介。
コードはこちら

  1. flaskのwith app.app_context()でDashのページを登録。
  2. それぞれ別Dashとして登録しているので、ロードが非常に遅い。
  3. それぞれ別Dashとして登録しているのでIDかぶりが問題ない。
  4. navbarでそれぞれのページはiframeとして埋め込む。
    という感じの特徴になっています。↓みてもロードに結構時間がかかってるのがわかります。でもらくちんで便利。
    3.gif
run.py
from flask import Flask
app = Flask(__name__)

with app.app_context():
    from pages.navbar import add_dash as ad_navbar
    app = ad_navbar(app)

    from pages.page1 import add_dash as ad_page1
    app = ad_page1(app)

    from pages.page2 import add_dash as ad_page2
    app = ad_page2(app)

if __name__ == "__main__":
    # flaskで起動する場合
    app.run(host='0.0.0.0', port=3031, debug=True)

てな感じで、基本的には全てflaskで動いています。で、それぞれのページは関数化してあってserver(flask)を受け取ってdashにして、serverを返します。

navbar.py
from dash import Dash, dcc, html, Input, Output
import dash_bootstrap_components as dbc

def add_dash(server):
    app = Dash(
        __name__,
        server=server,
        suppress_callback_exceptions=True,
        external_stylesheets=[dbc.themes.CERULEAN]
    )

    app.title = 'テストページだよ'

    ######中略#####

    return server

で、中は普通のdashアプリ。html.Iframe(src='./pages/page1', width='100%', height='900px')と、iframeでそれぞれのページを完全に別ページとして表示しています。

navbar.py
######略した部分#######
    navbar = dbc.NavbarSimple(
                    children=[
                        dbc.NavItem(dbc.NavLink("page1", href="/page1")),
                        dbc.NavItem(dbc.NavLink("page2", href="/page2")),
                        dbc.NavItem(dbc.NavLink("page3", href="/page3"))
                    ],
                    brand="テストページだよ",
                    id = 'navibar'
                )

    content = html.Div(id="page-content", 
                        style={"padding": "1rem 1.5rem","width":"100%","height":"100%"})

    app.layout = html.Div([
                            dcc.Location(id='url', refresh=False),
                            navbar,
                            content
                        ])


    @app.callback(
        [
            Output('page-content', 'children')
        ],
        [
            Input('url', 'pathname')
        ]
    )
    def display_page(pathname):

        if (pathname == '/page1')|(pathname == '/'): 
            return_content = html.Div(
                html.Iframe(src='./pages/page1', width='100%', height='900px')
            )
        elif (pathname == '/page2'):
            return_content = html.Div(
                html.Iframe(src='./pages/page2', width='100%', height='900px')
            )
        
        else:
            return_content = '404 not found'

        return [return_content]

page1,2もほぼ同じノリなのですが、url_base_pathnameを指定してあげることでflaskに別々のURLを登録しています。ここが被るとエラーになります。navbarでの振り分けより、一個深いアドレスに指定するようにしています。

page1.py
def add_dash(server):
    UBPATH = '/pages/page1/'
    app = Dash(
        __name__,
        server=server,
        url_base_pathname=UBPATH,
        suppress_callback_exceptions=True,
        external_stylesheets=[dbc.themes.CERULEAN]
    )

他のページへのリンクは、親ページへのURLを渡してあげてます。もっといいやりかたあるかも。

page1.py
    dcc.Link('Go to Page 2', href='../../page2', target='parent')

終わりに

というわけで、DashでMultipage作ってみる場合の大枠2パターンの紹介でした。どっちもどっちでめんどくさいけどiframe作戦の方が管理はしやすそうな気がする(くそおもだけど

参考

https://towardsdatascience.com/embed-multiple-dash-apps-in-flask-with-microsoft-authenticatio-44b734f74532
https://dash.plotly.com/urls
https://dash-bootstrap-components.opensource.faculty.ai

https://qiita.com/Yusuke_Pipipi/items/b74f269d112f180d2131
https://zenn.dev/shota_imazeki/books/7a0f8e2f4cccd846fb16/viewer/717e178159909f8c12e5
https://dafukui.hatenablog.com/entry/2018/12/19/172901

https://qiita.com/shimopino/items/ddc46adcbd6332511b92
https://qiita.com/OgawaHideyuki/items/0bd9e38de0055a5da046

など

6
9
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
6
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?