共享状态
Dash原则之一 是Dash Callbacks绝不能修改其范围之外的变量。修改任何global变量都是不安全的。
共享状态的需求
在某些应用程序中,您可能有多个回调依赖于昂贵的数据处理任务,例如进行SQL查询,运行模拟或下载数据。
您可以让一个回调运行任务,然后将结果共享给其余的回调,而不是让每个回调运行相同的昂贵任务。
由于您可以为一次回调设置多个输出,因此这种需求有所改善 。这样,这项昂贵的任务可以完成一次,并立即用于所有输出。但在某些情况下,这仍然不太理想,例如,如果有简单的后续任务可以修改结果,例如单位转换。我们不需要重复大型数据库查询只是为了将结果从华氏温度更改为摄氏温度!
global变量会破坏您的应用
Dash旨在在多用户环境中工作,多个人可以同时查看应用程序并具有独立会话。
如果您的应用使用修改后的global变量,那么一个用户的会话可以将变量设置为一个值,这将影响下一个用户的会话。
Dash还被设计为能够与多个python worker一起运行,以便可以并行执行回调。这通常gunicorn使用语法来完成
$ gunicorn --workers 4 app:server
(app指命名的文件app.py和server指命名的文件的变量server:server = app.server)。
当Dash应用程序跨多个工作程序运行时,它们的内存不会被共享。这意味着如果您在一个回调中修改全局变量,则该修改将不会应用于其余的工作程序。
在回调之间共享数据
为了在多个python进程之间安全地共享数据,我们需要将数据存储在每个进程可访问的位置。
存储此数据有三个主要位置:
- 在用户的浏览器会话中
- 在磁盘上(例如在文件或新数据库上)
- 与Redis一样的共享内存空间
示例1 - 使用隐藏Div在浏览器中存储数据
要在用户的浏览器会话中保存数据:
-
通过在https://community.plot.ly/t/sharing-a-dataframe-between-plots/6173中解释的方法将数据保存为Dash前端存储的一部分来实现
-
必须将数据转换为类似JSON的字符串才能进行存储和传输
-
以这种方式缓存的数据仅在用户的当前会话中可用。
- 如果您打开一个新浏览器,应用程序的回调将始终计算数据。数据仅在会话内的回调之间进行缓存和传输。
- 因此,与缓存不同,此方法不会增加应用程序的内存占用量。
- 网络传输可能会有成本。如果您在回调之间共享10MB数据,那么这些数据将在每次回调之间通过网络传输。
- 如果网络成本太高,则预先计算聚合并传输它们。您的应用可能不会显示10MB的数据,它只会显示其子集或聚合。
此示例概述了如何在一个回调中执行昂贵的数据处理步骤,在JSON上序列化输出,并将其作为其他回调的输入提供。其使用标准Dash回调并将JSON-ified数据存储在应用程序中的隐藏div中。
global_df = pd.read_csv('...')
app.layout = html.Div([
dcc.Graph(id='graph'),
html.Table(id='table'),
dcc.Dropdown(id='dropdown'),
# Hidden div inside the app that stores the intermediate value
html.Div(id='intermediate-value', style={'display': 'none'})
])
@app.callback(Output('intermediate-value', 'children'), [Input('dropdown', 'value')])
def clean_data(value):
# some expensive clean data step
cleaned_df = your_expensive_clean_or_compute_step(value)
# more generally, this line would be
# json.dumps(cleaned_df)
return cleaned_df.to_json(date_format='iso', orient='split')
@app.callback(Output('graph', 'figure'), [Input('intermediate-value', 'children')])
def update_graph(jsonified_cleaned_data):
# more generally, this line would be
# json.loads(jsonified_cleaned_data)
dff = pd.read_json(jsonified_cleaned_data, orient='split')
figure = create_figure(dff)
return figure
@app.callback(Output('table', 'children'), [Input('intermediate-value', 'children')])
def update_table(jsonified_cleaned_data):
dff = pd.read_json(jsonified_cleaned_data, orient='split')
table = create_table(dff)
return table
示例2 - 预先计算聚合
如果数据很大,则通过网络发送计算数据可能很昂贵。在某些情况下,序列化此数据和JSON也可能很昂贵。
在许多情况下,您的应用只会显示计算的或过滤的数据的子集或聚合。在这些情况下,您可以在数据处理回调中预先计算聚合,并将这些聚合传输到剩余的回调。
这是一个简单的示例,说明如何将过滤或聚合数据传输到多个回调。
@app.callback(
Output('intermediate-value', 'children'),
[Input('dropdown', 'value')])
def clean_data(value):
# an expensive query step
cleaned_df = your_expensive_clean_or_compute_step(value)
# a few filter steps that compute the data
# as it's needed in the future callbacks
df_1 = cleaned_df[cleaned_df['fruit'] == 'apples']
df_2 = cleaned_df[cleaned_df['fruit'] == 'oranges']
df_3 = cleaned_df[cleaned_df['fruit'] == 'figs']
datasets = {
'df_1': df_1.to_json(orient='split', date_format='iso'),
'df_2': df_2.to_json(orient='split', date_format='iso'),
'df_3': df_3.to_json(orient='split', date_format='iso'),
}
return json.dumps(datasets)
@app.callback(
Output('graph', 'figure'),
[Input('intermediate-value', 'children')])
def update_graph_1(jsonified_cleaned_data):
datasets = json.loads(jsonified_cleaned_data)
dff = pd.read_json(datasets['df_1'], orient='split')
figure = create_figure_1(dff)
return figure
@app.callback(
Output('graph', 'figure'),
[Input('intermediate-value', 'children')])
def update_graph_2(jsonified_cleaned_data):
datasets = json.loads(jsonified_cleaned_data)
dff = pd.read_json(datasets['df_2'], orient='split')
figure = create_figure_2(dff)
return figure
@app.callback(
Output('graph', 'figure'),
[Input('intermediate-value', 'children')])
def update_graph_3(jsonified_cleaned_data):
datasets = json.loads(jsonified_cleaned_data)
dff = pd.read_json(datasets['df_3'], orient='split')
figure = create_figure_3(dff)
return figure
示例 3 - 缓存和信号
这个例子:
- 使用Redis通过Flask-Cache存储“全局变量”。该数据通过一个函数访问,该函数的输出由其输入参数缓存和键入。
- 当昂贵的计算完成时,使用隐藏的div解决方案向其他回调发送信号。
- 注意:用户也可以将其保存到文件系统,而不是Redis。有关 详细信息,请参阅https://flask-caching.readthedocs.io/en/latest/。
- 这种“信号”很酷,因为它允许昂贵的计算只占用一个过程。如果没有这种类型的信令,每个回调可能最终并行计算昂贵的计算,锁定四个进程而不是一个进程。
该方法也是有利的,因为将来的会话可以使用预先计算的值。这适用于具有少量输入的应用程序。
这个例子就是这个样子,有些事情需要注意:
- 使用time.sleep(5)模拟了一个昂贵的过程。
- 当应用加载时,渲染所有四个图形需要五秒钟。
- 初始计算仅阻止一个进程。
- 一旦计算完成,就发送信号并且并行执行四个回调以呈现图形。这些回调中的每一个都从“全局存储”中检索数据:Redis或文件系统缓存。
- 在app.run_server中设置了processes = 6,以便可以并行执行多个回调。在制作中,这是通过类似来完成的
$ gunicorn --workers 6 --threads 2 app:server
- 如果已经在过去选择了下拉列表中的值将花费不到五秒钟。这是因为正从缓存中提取值。
- 类似地,重新加载页面或在新窗口中打开应用程序也很快,因为已经计算了初始状态和初始昂贵的计算。
示例4 - 服务器上基于用户的会话数据
前面的示例缓存了对文件系统的计算,并且所有用户都可以访问这些计算。
在某些情况下,您希望将数据与用户会话隔离:一个用户的派生数据不应更新下一个用户的派生数据。一种方法是将数据保存在隐藏中Div,如第一个示例所示。
另一种方法是使用会话ID将数据保存在文件系统缓存中,然后使用该会话ID引用数据。由于数据保存在服务器上而不是通过网络传输,因此该方法通常比“隐藏div”方法更快。
这个例子:
- 使用flask_caching文件系统缓存缓存数据。您还可以保存到Redis等内存数据库。
- 将数据序列化为JSON。
- 如果您使用的是Pandas,请考虑使用Apache Arrow进行序列化。社区线程
- 将会话数据保存到预期的并发用户数。这可以防止缓存过满数据。
- 通过将隐藏的随机字符串嵌入到应用程序的布局中并在每个页面加载时提供唯一的布局来创建唯一的会话ID。
注意: 与向客户端发送数据的所有示例一样,请注意这些会话不一定是安全的或加密的。这些会话ID可能容易受到会话固定样式攻击。
import dash
from dash.dependencies import Input, Output
import dash_core_components as dcc
import dash_html_components as html
import datetime
from flask_caching import Cache
import os
import pandas as pd
import time
import uuid
external_stylesheets = [
# Dash CSS
'https://codepen.io/chriddyp/pen/bWLwgP.css',
# Loading screen CSS
'https://codepen.io/chriddyp/pen/brPBPO.css']
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
cache = Cache(app.server, config={
'CACHE_TYPE': 'redis',
# Note that filesystem cache doesn't work on systems with ephemeral
# filesystems like Heroku.
'CACHE_TYPE': 'filesystem',
'CACHE_DIR': 'cache-directory',
# should be equal to maximum number of users on the app at a single time
# higher numbers will store more data in the filesystem / redis cache
'CACHE_THRESHOLD': 200
})
def get_dataframe(session_id):
@cache.memoize()
def query_and_serialize_data(session_id):
# expensive or user/session-unique data processing step goes here
# simulate a user/session-unique data processing step by generating
# data that is dependent on time
now = datetime.datetime.now()
# simulate an expensive data processing task by sleeping
time.sleep(5)
df = pd.DataFrame({
'time': [
str(now - datetime.timedelta(seconds=15)),
str(now - datetime.timedelta(seconds=10)),
str(now - datetime.timedelta(seconds=5)),
str(now)
],
'values': ['a', 'b', 'a', 'c']
})
return df.to_json()
return pd.read_json(query_and_serialize_data(session_id))
def serve_layout():
session_id = str(uuid.uuid4())
return html.Div([
html.Div(session_id, id='session-id', style={'display': 'none'}),
html.Button('Get data', id='button'),
html.Div(id='output-1'),
html.Div(id='output-2')
])
app.layout = serve_layout
@app.callback(Output('output-1', 'children'),
[Input('button', 'n_clicks'),
Input('session-id', 'children')])
def display_value_1(value, session_id):
df = get_dataframe(session_id)
return html.Div([
'Output 1 - Button has been clicked {} times'.format(value),
html.Pre(df.to_csv())
])
@app.callback(Output('output-2', 'children'),
[Input('button', 'n_clicks'),
Input('session-id', 'children')])
def display_value_2(value, session_id):
df = get_dataframe(session_id)
return html.Div([
'Output 2 - Button has been clicked {} times'.format(value),
html.Pre(df.to_csv())
])
if __name__ == '__main__':
app.run_server(debug=True)
在这个例子中有三件事需要注意:
- 检索数据时,数据帧的时间戳不会更新。此数据作为用户会话的一部分进行缓存。
- 最初检索数据需要五秒钟,但连续查询是即时的,因为数据已被缓存。
- 第二个会话显示的数据与第一个会话不同:回调之间共享的数据与单个用户会话隔离。