87
80

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 3 years have passed since last update.

Dashのつまづきやすいポイント解説

Last updated at Posted at 2019-10-03

Dashとは?

DashはPlotly社が開発しているPython用のWebアプリを作成するためのフレームワークです。同じPlotly社の製品であるグラフ用のライブラリPlotlyと合わせることで、Web上でインタラクティブなグラフ表示を手軽に作れる優れものです。サンプルギャラリーにあるようものが作れるわけです。Dash Bootstrap Componentsなんて外部ライブラリもあり、UIパーツを使ったWebサイト構築もそこそこいけます。

手軽、だけど癖もある

私個人は非常に手軽で便利なフレームワークであると感じています。Javascriptが分からなくてもWebアプリがちょちょいと出来てしまうのでWebアプリ入門としてもかなりおススメなフレームワークです。

しかし、使っていくと「あれ、ここは上手く書けないなぁ」「こういう用途には向いてないんだな」というところが見えてきたので、これからDashを使う方に対して知見を残しておきたいと思います。

コールバックの記述がちょっと難しい

Webのプログラムだと、InputやらButtonや、またはDivかもしれませんが、とにかくクリックすることでイベントが発生する場合が多いと思います。Javascriptだと、それぞれのタグにonclick属性をつけて

Hello.html
<body>
  <div id='output'></div>
  <button onclick="sayhello();">挨拶する</button>
  <script>
    function sayhello(){
      let el = document.getElementById('output');
      el.innerText = "Hello";
    }
  </script>
</body>

のように自由にコールバックの関数を記述することができます。特定の要素を書き換えてもいいし、書き換えなくてもいい。条件に応じて動作を変えても構いません。

出力と入力は必ず対で、しかも固定

一方Dashの場合はコールバックを記述する際、入力となる要素と出力となる要素は必ず対で登録しなければなりません。上のJavascriptとおよそ同等のコードは以下のようになります。

Hello.py
import dash
import dash_html_components as html
from dash.dependencies import Input, Output

app = dash.Dash(__name__)
app.layout = html.Div(children=[
    html.Div(id='output'),
    html.Button(children='挨拶する', id='greet'),
])
@app.callback(Output('output', 'children'),  # 出力!
              [Input('greet', 'n_clicks')])  # 入力!
def onclick(n_clicks):
    if n_clicks:
        return "Hello!"
    return ""

app.run_server(debug=True)

Dashではコールバック関数の引数が入力要素の指定プロパティ(ここではn_clicks)からインプットされ、コールバック関数の戻り値が出力要素の指定プロパティ(ここではchildren)に設定されるので、Javascriptの場合のように、やっぱり出力先変える、とか、別の入力も使うなんて書き方ができません。ただし、それはそういうルールになっているというだけの話なので、多少窮屈に感じるかもしれないですが、特に問題ではないでしょう。

出力要素はそれぞれ一度しか登録できない

callback_ng.png

悩ましいのはこちらの制限です。先ほどの例で、ボタンが3つに増えたとしたら、普通はどう記述するでしょうか。コードの綺麗さとかを特に考えなければ、そのまま3つ分コピペして書けばいいと考えると思いますが、Dashではエラーになります。コールバックのOutputとして同じIDを持つ要素を登録できるのは、ただ1回だけだからです。2回以上おなじ要素を登録しようとすると、DuplicateCallbackOutput例外が発生します。

ではどうするかというと、全部ひっくるめて1個のコールバックとして登録することになります。以下の図のようにです。

callback_ok.png

3つのボタンに対し、押したボタンによってそれぞれ異なるテキストを表示するプログラムのソースコードは以下のようになります。
example1.png

3button
import dash
import dash_html_components as html
from dash.dependencies import Input, Output

app = dash.Dash(__name__)

# ボタンを3つ用意し、押したボタンによって、それぞれテキストを切り替えるサンプル

# レイアウトを構成する
out_text = html.Div('', id='outtext')
button_1 = html.Button(children='button1', id='button1')
button_2 = html.Button(children='button2', id='button2')
button_3 = html.Button(children='button3', id='button3')
app.layout = html.Div(children=[
    out_text,
    button_1,
    button_2,
    button_3
])

def set_button_callback_all(out, b1, b2, b3):
    # 関連する入力と出力を一括で設定する
    @app.callback(Output(out.id, 'children'),
                  [Input(b1.id, 'n_clicks'),
                   Input(b2.id, 'n_clicks'),
                   Input(b3.id, 'n_clicks')])
    def set_text(n_clicks_1, n_clicks_2, n_clicks_3):
        # どの要素のイベントによって発火したのか調べるにはcallback_contextを使う
        ctx = dash.callback_context
        # また、なぜかボタンが押されてないのにロード時に呼び出されるので、無視するように処理する
        if not ctx.triggered or ctx.triggered[0]['value'] is None:
            return 'No clicks yet'
        else:
            clicked_id = ctx.triggered[0]['prop_id'].split('.')[0]  # なんていうかバッドノウハウの塊みたいなコード
            if clicked_id == b1.id:
                return 'button1 clicked'
            elif clicked_id == b2.id:
                return 'button2 clicked'
            elif clicked_id == b3.id:
                return 'button3 clicked'
            else:
                raise ValueError('undefined button clicked')


# つまりボタン3つと要素outtextとを結ぶコールバックを一回だけ定義する
set_button_callback_all(out_text, button_1, button_2, button_3)

app.run_server(debug=True)

ややコードが長めになってしまっています。その理由は、入力の3つのボタンのうち、どれが押されたのかを確認しなければならないためです。引数にはその情報は含まれていないため、グローバルな変数appのcallback_contextを覗かなければいけません。どうにもスマートなコードにはなりませんが、なんとか工夫して対応するしかありません。

Pattern-Matching Callbackによるコード短縮

[2020/07/16追記] Dash 1.11.0から「Pattern-Matching Callback」という機能が追加されています。これはコールバックの入力についてあいまいさを残した状態で記述できるようになるもので、上記のケースのような場合でも[Input(...), Input(...), Input(...), ...]のように、Inputを羅列しなくても良くなるというものです。

具体的には上のコードは以下のように書き換えることができます。

# レイアウトを構成する
out_text = html.Div('', id='outtext')
button_1 = html.Button(children='button1', id={'type': 'my-button', 'index': 1})  # IDが辞書形式になる
button_2 = html.Button(children='button2', id={'type': 'my-button', 'index': 2})  # typeを共通の値にする
button_3 = html.Button(children='button3', id={'type': 'my-button', 'index': 3})  # 個の識別はindexで行う
app.layout = html.Div(children=[
    out_text,
    button_1,
    button_2,
    button_3
])


@app.callback(Output('outtext', 'children'),  # 出力!
              [Input({'type': 'my-button', 'index': ALL}, 'n_clicks')])  # 入力!
def onclick(n_clicks):
    ctx = dash.callback_context
    if not ctx.triggered or ctx.triggered[0]['value'] is None:
        return 'No clicks yet'
    else:
        # IDに指定した文字列を受け取る
        clicked_id_text = ctx.triggered[0]['prop_id'].split('.')[0]  # '{"index":x,"type":"my-button"}'
        # 文字列を辞書に変換する
        clicked_id_dic = ast.literal_eval(clicked_id_text)  # evalは恐ろしいのでastを使おう
        # クリックした番号を取得
        clicked_index = clicked_id_dic['index'] # xを取り出す
        if clicked_index == 1:
            return 'button1 clicked'
        elif clicked_index == 2:
            return 'button2 clicked'
        elif clicked_index == 3:
            return 'button3 clicked'
        else:
            return 'undefined button clicked'

コンポーネントのIDの指定が、通常の場合文字列だったのが、Pattern-Match Callbackの場合は{'type':'my-button', 'index':x}の形式(xは識別番号)になります。また、Inputの指定の部分も特定のIDだったのが、{'type':'my-bytton', 'index':XXX}の形式に変わります。XXXの部分はALL, MATCH, ALLSMALLERから選べますが、今回はALLを用います。

こうすると、'my-button'をtypeとして持っているコンポーネントが自動的に入力対象となり、Inputをずらずら並べて書かなくても良くなります。一方、triggeredから目的のindex番号を取り出す部分が長くなってやだなー、という感じですが、ここはこのコードをコピーして(関数化をおすすめ)使ってください。総じて前よりも少しすっきりすると思います。

動的なコンテンツは難しい

Webサイトによっては動的にコンテンツの内容を差し替えるところもよくありますよね。ECサイトでは検索結果に合わせて商品のラインナップを表示したりですとか、ブログでも1ページあたりの記事の表示数を調整できたりします。

Dashでコンテンツを動的に差し替えるには、要素のchildrenプロパティを上書きすればOKです。内部的には要素のinnerHTMLを書き換えているっぽく、DOMの再構成が行われるようです。一番上のサンプルのように、テキストを指定してもいいですし、html.Divのような要素を渡してもいけます。

指定した要素が存在しないとコールバックが利かなくなる

ですが、悲しいのは、動的に差し替えた際、コールバックに登録した要素が1つでも欠けると、そのコールバックが機能しなくなってしまうことです。

次のようなカレンダーアプリを考えます。1月は31日まで、2月は28日(非うるう年)まで、それぞれ月の日数に合わせた数のボタンを並べる仕様とします。

example2.png

月のボタンを押すとその月に合わせた個数のボタンが表示される仕組みです。

calendar_NG
import dash
import dash_html_components as html
from dash.dependencies import Input, Output

app = dash.Dash(__name__)

# 月と日のボタンを用意し、簡単なカレンダー表示をするサンプル
out_text = html.Div('', id='outtext')
b_month_Jan = html.Button('1月', id='january')
b_month_Feb = html.Button('2月', id='february')


def make_day_buttons(day_num):
    buttons = []
    for day in range(1, day_num+1):
        buttons.append(html.Button(children=f'{day:02}', id=f'button_{day}'))
    return buttons


def set_day_callback(out, buttons):
    # 日のボタンを押したときの動作を規定する
    # ※1月のときは動作するが、2月のときは動作しない
    @app.callback(Output(out.id, 'children'),
                  [Input(button.id, 'n_clicks') for button in buttons])
    def set_text(*n_clicks):
        ctx = dash.callback_context
        if not ctx.triggered or ctx.triggered[0]['value'] is None:
            return 'No clicks yet'
        else:
            clicked_id = ctx.triggered[0]['prop_id'].split('.')[0]
            for button in buttons:
                if button.id == clicked_id:
                    return f'{clicked_id} clicked'
            else:
                raise ValueError('undefined day-button clicked')


def set_month_callback(out, buttons):
    # 月のボタンを押したときの動作を規定する
    @app.callback(Output(out.id, 'children'),
                  [Input(button.id, 'n_clicks') for button in buttons])
    def set_day_buttons(*n_clicks):
        ctx = dash.callback_context
        if not ctx.triggered or ctx.triggered[0]['value'] is None:
            # 初期状態は1月とする
            return make_day_buttons(31)
        else:
            clicked_id = ctx.triggered[0]['prop_id'].split('.')[0]
            if clicked_id == 'january':
                # 1月をクリックしたのでボタンを31個並べる
                return make_day_buttons(31)
            elif clicked_id == 'february':
                # 2月をクリックしたのでボタン28個並べる
                return make_day_buttons(28)
            else:
                raise ValueError('undefined month-button clicked')


# レイアウトを構成する
button_months = [b_month_Jan, b_month_Feb]
month_container = html.Div(children=button_months, id='months')
button_days = make_day_buttons(31)
day_container = html.Div(children=button_days, id='days')
app.layout = html.Div(children=[
    out_text,
    html.Span('月:'),
    month_container,
    html.Span('日:'),
    day_container
], style={'width': '280px'})  # 邪道だけど、見た目カレンダーぽくするために改行させる

# コールバックを有効にする
set_day_callback(out_text, button_days)
set_month_callback(day_container, button_months)

app.run_server(debug=True)

で、このアプリを動かしてみると、1月の時は問題なく動きますが、2月の表示に切り替えたとき、01から28までのボタンは全て動かなくなってしまいます。つまり、コールバックset_day_buttonsで指定した要素のうち、29~31のボタンが存在することが確認できなかったため、Dashが処理をキャンセルしてしまいます。ちなみに1月・2月のボタンは正常に動作します。こちらのコールバックは要素が欠けていないためです。

コールバックの上書き・リセットが出来ない

じゃあ、コールバックを設定しなおせばいいんじゃないの?と思う方もいるかと思います。私もそう考えたのですが、調べた限りその方法はないようです。無理やりDashの内部変数をいじれば何とかなるかもしれませんが、やめた方が吉でしょう。

要素を消すのは諦めて、非表示でごまかす

結局やり方を変えるしかありませんね。必要になる最大限の要素をあらかじめ確保してコールバックを登録しておき、状況に応じて、個別に表示・非表示を切り替えるようにしましょう。具体的には要素のstyleにdisplay:noneかvisibility:hiddenをつけてあげます。もっといい方法があるかもしれませんが、私が思いつけるのはその程度でした。

先ほどのプログラムの一部を次のように書き換えます。

calendar_OK
def make_day_buttons(day_num):
    buttons = []
    for day in range(1, 31+1): # ボタンは必ず31個作る
        if day > day_num:
            # 存在しない日の場合は、非表示にする
            style = {'display': 'none'}
        else:
            style = {}
        buttons.append(html.Button(children=f'{day:02}', id=f'button_{day}', style=style))
    return buttons

これで正常に動くようになります。消費メモリについてはこの際忘れましょう。

マルチページ対応

Dashでは入力されたURLに応じて生成する内容を変えるマルチページなアプリケーションを作成することができます(内部でFlaskを使っているのだし当たり前かもですけど)。ただ、その際これはどうすればいいの、ということが多いので、記しておきます。

ページタイトルの設定

DashのDocumentはページレイアウトの構築、つまりbodyタグの中身をどう変えるかについては丁寧に教えてくれるのですが、headタグの中身をどう変えればいいのかということはあまり触れていません。
とりわけ重要なのはページのタイトルを変える方法なのですが、書いてありません。

ページのタイトルを設定するには以下の方法で行います。

app.title = 'ページのタイトル'

こうやって設定しておくと、DOMを生成する際にこの情報を使ってhead内のtitleタグを付けてくれるわけです。

それではマルチページのアプリにおいて、それぞれ異なるページタイトルを付けるにはどうすればよいかというと、Dash.index()関数をオーバーライドします。

class MyDash(dash.Dash):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def index(self, *args, **kwargs):
        if 'path' in kwargs:
            path = kwargs['path']
            self.title = '/' + path  # ここを関数化するか、辞書でディスパッチする
        else:
            self.title = 'Root page'
        return super().index(*args, **kwargs)

app = MyDash(__name__)

index関数の中でapp.titleを見ているので、その前に差し替えてやればよいわけですね。

メタ情報の設定

じゃあメタタグについては?

dash.py
def index(self, *args, **kwargs):  # pylint: disable=unused-argument
        scripts = self._generate_scripts_html()
        css = self._generate_css_dist_html()
        config = self._generate_config_html()
        metas = self._generate_meta_html()
        renderer = self._generate_renderer()
        title = getattr(self, "title", "Dash")

先のsuper().index関数の先頭でmetasとして作られている変数がmetaタグのHTMLとなります。惜しいかな_generate_meta_html関数は引数を受け取っていないので、そのままオーバーライドしてもURL毎に分岐させられません。index関数丸ごと書き換えてしまうか、selfの属性としてパスを仕込んでなんとかする必要があります。

ページ固有のスクリプト

どのページでも使用するスクリプトについてはこちらを参照してassetsから読み込ませるか、external_scriptsとして指定するのがスマートでしょう。一方ページ固有のスクリプトについてはやはりメタタグと同様に処理する必要があります。

終わりに

Dashはとりあえず動くアプリを作る上の選択肢としては優れたフレームワークです。一方で凝ったことをやるにはあまり向かないのも確かです。動的なページを作ろうと思ったり、ページ毎に大きく内容を変えたいと思った際にはより汎用性の高いフレームワークを検討した方がよろしいと思います。

87
80
2

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
87
80

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?