はじめに
みなさん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をあわせたページです。
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から呼び出す仕組みですね。
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}'
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が被ります。
コード差分はこちら(これで動かすとエラーがでます)
dcc.Dropdown(
{f'Page 1 - {i}': f'{i}' for i in ['New York City', 'Montreal', 'Los Angeles']},
id='dropdown'
),
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}_
ととりあえずつけるスタイル。たぶんこれで大きな問題はないはず。これもそんなことやってた。
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}'
SPAにしない方法
- flaskのwith app.app_context()でDashのページを登録。
- それぞれ別Dashとして登録しているので、ロードが非常に遅い。
- それぞれ別Dashとして登録しているのでIDかぶりが問題ない。
- navbarでそれぞれのページはiframeとして埋め込む。
という感じの特徴になっています。↓みてもロードに結構時間がかかってるのがわかります。でもらくちんで便利。
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を返します。
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 = 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での振り分けより、一個深いアドレスに指定するようにしています。
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を渡してあげてます。もっといいやりかたあるかも。
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
など