Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第七章:Bokeh Server进阶

前置知识:本章需要了解 ColumnDataSource(第二章 2.2)和 CustomJS 回调(第六章 6.7)。Bokeh 三层架构的概念见第二章 2.2.6

7.1 为什么需要Bokeh Server?

CustomJS的局限

  • 只能执行JavaScript,无法调用Python库
  • 无法访问数据库、文件系统
  • 无法进行复杂计算

Bokeh Server的优势

  • 回调函数用Python编写
  • 可以访问任何Python库
  • 支持实时数据更新
  • 支持多用户会话

运行方式提醒:本章中的 Python 回调示例必须通过 bokeh serve 运行。直接使用 python app.pyshow()save() 生成静态 HTML 时,Python 端回调不会执行;静态 HTML 交互应使用 CustomJS

交叉引用

  • 如果你还不熟悉 ColumnDataSourceDocumentcurdoc() 的关系,建议先回看第二章
  • 如果你只是想做浏览器端交互而不需要 Python 回调,建议先看第六章中的 CustomJS
  • 关于输出、嵌入和部署后的访问方式,可继续阅读第八章

7.2 第一个Bokeh Server应用

创建文件app.py

这是一个 Bokeh Server 应用,请保存为 app.py 后使用 bokeh serve app.py --show 运行。

# app.py
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.models import Slider, ColumnDataSource
from bokeh.layouts import column
import numpy as np

# 数据
x = np.linspace(0, 10, 100)
source = ColumnDataSource(data={'x': x, 'y': np.sin(x)})

# 图表
p = figure(title="正弦波", width=600, height=400)
p.line('x', 'y', source=source, line_width=2)

# 滑块
freq = Slider(title="频率", start=0.1, end=5, value=1, step=0.1)

# 回调
def update(attr, old, new):
    new_y = np.sin(freq.value * x)
    source.data = {'x': x, 'y': new_y}

freq.on_change('value', update)

# 布局
curdoc().add_root(column(freq, p))
curdoc().title = "正弦波演示"

运行

bokeh serve app.py --show

7.3 Widgets小部件详解

数值输入类

from bokeh.models import Slider, RangeSlider, Spinner

# 滑块
slider = Slider(title="数值", start=0, end=100, value=50, step=1)

# 范围滑块
range_slider = RangeSlider(title="范围", start=0, end=100, value=(20, 80))

# 数字输入框
spinner = Spinner(title="数量", low=0, high=1000, value=100, step=10)

选择类

from bokeh.models import Select, MultiSelect, CheckboxGroup, RadioGroup

# 下拉选择
select = Select(title="类别", options=["A", "B", "C"], value="A")

# 多选下拉
multi_select = MultiSelect(title="多选", options=["A", "B", "C"], value=["A"])

# 复选框
checkbox = CheckboxGroup(labels=["选项1", "选项2", "选项3"], active=[0])

# 单选按钮
radio = RadioGroup(labels=["选项1", "选项2", "选项3"], active=0)

按钮类

from bokeh.models import Button, Toggle, Dropdown

# 按钮
button = Button(label="点击")

# 开关按钮
toggle = Toggle(label="开关", active=False)

# 下拉按钮
dropdown = Dropdown(label="菜单", menu=[("选项1", "1"), ("选项2", "2")])

文本类

from bokeh.models import TextInput, TextAreaInput, Div

# 文本输入
text_input = TextInput(title="输入", value="")

# 多行文本
text_area = TextAreaInput(title="描述", value="")

# 文本显示
div = Div(text="<h2>标题</h2><p>段落内容</p>")

日期类

from bokeh.models import DatePicker, DateRangeSlider
from datetime import date

# 日期选择
date_picker = DatePicker(title="日期", value=date.today())

# 日期范围
date_range = DateRangeSlider(
    title="日期范围",
    start=date(2020, 1, 1),
    end=date.today(),
    value=(date(2023, 1, 1), date.today())
)

7.4 回调机制

on_change - 属性变化回调

def callback(attr, old, new):
    print(f"属性 {attr} 从 {old} 变为 {new}")

slider.on_change('value', callback)

on_click - 点击回调

def callback():
    print("按钮被点击")

button.on_click(callback)

on_event - 事件回调

from bokeh.events import DoubleTap, Press

def callback(event):
    print(f"双击位置: ({event.x}, {event.y})")

p.on_event(DoubleTap, callback)

选择回调

def callback(attr, old, new):
    indices = source.selected.indices
    print(f"选中索引: {indices}")

source.selected.on_change('indices', callback)

7.5 实时数据更新

periodic_callback - 定时更新

import random
from bokeh.io import curdoc

source = ColumnDataSource(data={'x': [], 'y': []})
p = figure()
p.line('x', 'y', source=source)

counter = [0]

def update():
    counter[0] += 1
    new_data = {'x': [counter[0]], 'y': [random.random()]}
    source.stream(new_data, rollover=100)  # 保留最近100个点

curdoc().add_root(p)
curdoc().add_periodic_callback(update, 100)  # 每100ms更新

stream - 流式添加

# 添加数据,保留最近N个
source.stream(new_data, rollover=100)

# 添加数据,无限制
source.stream(new_data)

patch - 局部更新

# 更新特定位置的值
source.patch({
    'y': [(0, new_value), (2, another_value)]  # 更新索引0和2
})

7.6 完整示例:交互式仪表板

# dashboard.py
from bokeh.plotting import figure, curdoc
from bokeh.models import (
    ColumnDataSource, Select, Slider, DataTable, 
    TableColumn, Div, HoverTool
)
from bokeh.layouts import column, row, gridplot
import pandas as pd
import numpy as np

# 模拟数据
np.random.seed(42)
n = 1000
df = pd.DataFrame({
    'date': pd.date_range('2023-01-01', periods=n, freq='D'),
    'category': np.random.choice(['A', 'B', 'C'], n),
    'value': np.random.randn(n).cumsum(),
    'volume': np.random.randint(100, 1000, n),
})
df['month'] = df['date'].dt.strftime('%Y-%m')

# 数据源
source = ColumnDataSource(df)

# 标题
title = Div(text="<h1>销售数据分析仪表板</h1>", width=800)

# 控件
category_select = Select(title="类别", options=['All'] + list(df['category'].unique()), value='All')
month_slider = Slider(title="显示天数", start=30, end=n, value=n, step=30)

# 主图表
p_main = figure(x_axis_type="datetime", width=800, height=400, title="趋势图")
p_main.line('date', 'value', source=source, line_width=2)
p_main.add_tools(HoverTool(tooltips=[("日期", "@date{%F}"), ("值", "@value{0.00}")],
    formatters={'@date': 'datetime'}))

# 分布图
p_dist = figure(width=400, height=400, title="分布")
hist, edges = np.histogram(df['value'], bins=30)
p_dist.quad(top=hist, bottom=0, left=edges[:-1], right=edges[1:],
    fill_color="skyblue", line_color="white")

# 数据表格
columns = [
    TableColumn(field="date", title="日期", formatter=DateFormatter()),
    TableColumn(field="category", title="类别"),
    TableColumn(field="value", title="值"),
    TableColumn(field="volume", title="销量"),
]
table = DataTable(source=source, columns=columns, width=800, height=200)

# 回调
def update_data(attr, old, new):
    # 过滤类别
    cat = category_select.value
    days = month_slider.value
    
    filtered = df.copy()
    if cat != 'All':
        filtered = filtered[filtered['category'] == cat]
    filtered = filtered.tail(days)
    
    source.data = ColumnDataSource.from_df(filtered)

category_select.on_change('value', update_data)
month_slider.on_change('value', update_data)

# 布局
controls = column(category_select, month_slider)
layout = column(
    title,
    row(controls, p_main),
    row(p_dist, table)
)

curdoc().add_root(layout)
curdoc().title = "销售仪表板"

运行

bokeh serve dashboard.py --show

7.7 应用模板结构

在继续看部署之前,建议先把这一节和第五章连起来理解:

  • 第五章解决的是“页面怎么排版、控件和图表怎么组织”
  • 本章解决的是“这些控件变化后,Python 代码如何真正响应”
  • 第九章会继续讨论“这些回调和实时更新在数据量变大时如何优化”

如果你发现自己在仪表板代码里同时处理布局、数据过滤、回调、部署,通常说明已经进入了 Bokeh Server 的核心使用场景。

推荐的项目结构

myapp/
├── main.py           # 主应用文件
├── templates/
│   └── index.html    # 自定义模板(可选)
├── static/
│   ├── css/
│   └── js/
├── data/
│   └── data.csv
└── requirements.txt

main.py 示例

from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource

# 初始化数据
source = ColumnDataSource(data=dict(x=[], y=[]))

# 创建图表
p = figure(title="实时数据")
p.line('x', 'y', source=source)

# 添加到文档
curdoc().add_root(p)
curdoc().title = "My App"

7.8 生产环境部署

方式1:使用 Nginx 反向代理

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://localhost:5006;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

方式2:使用 Docker

FROM python:3.11

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

EXPOSE 5006
CMD ["bokeh", "serve", "--allow-websocket-origin=*", "."]

启动命令

# 开发环境
bokeh serve myapp.py --show

# 生产环境
bokeh serve myapp.py \
    --allow-websocket-origin=example.com \
    --port=5006 \
    --num-procs=4

7.9 高级部署方案

使用 Gunicorn + Supervisor

# 安装 Gunicorn
pip install gunicorn

# 启动命令
gunicorn -w 4 -b 0.0.0.0:5006 \
    -k bokeh.server.gunicorn.bokeh_worker.BokehWorker \
    myapp:application

Supervisor 配置

[program:bokeh_app]
command=/path/to/venv/bin/gunicorn -w 4 -b 127.0.0.1:5006 -k bokeh.server.gunicorn.bokeh_worker.BokehWorker myapp:application
directory=/path/to/app
user=www-data
autostart=true
autorestart=true
stderr_logfile=/var/log/bokeh/bokeh.err.log
stdout_logfile=/var/log/bokeh/bokeh.out.log

使用 Systemd

[Unit]
Description=Bokeh Server Application
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/path/to/app
ExecStart=/path/to/venv/bin/bokeh serve --port=5006 --allow-websocket-origin=example.com
Restart=always

[Install]
WantedBy=multi-user.target

使用 Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bokeh-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: bokeh
  template:
    metadata:
      labels:
        app: bokeh
    spec:
      containers:
      - name: bokeh
        image: my-bokeh-app:latest
        ports:
        - containerPort: 5006
        env:
        - name: BOKEH_ALLOW_WS_ORIGIN
          value: "example.com"
---
apiVersion: v1
kind: Service
metadata:
  name: bokeh-service
spec:
  selector:
    app: bokeh
  ports:
  - port: 80
    targetPort: 5006
  type: LoadBalancer

7.10 安全最佳实践

WebSocket安全配置

# 限制WebSocket连接的来源
bokeh serve app.py \
    --allow-websocket-origin=example.com \
    --allow-websocket-origin=www.example.com

CORS设置

# 在应用中配置CORS
from bokeh.server.server import Server

server = Server({'/app': app}, 
    allow_websocket_origin=["example.com"],
    port=5006)

身份验证集成

# 使用Flask-Login进行身份验证
from flask_login import login_required

@app.route('/bokeh-app')
@login_required
def bokeh_app():
    script = server_document('http://localhost:5006/app')
    return render_template('app.html', script=script)

敏感数据保护

# 使用环境变量存储敏感信息
import os

DB_PASSWORD = os.environ.get('DB_PASSWORD')
API_KEY = os.environ.get('API_KEY')

# 不要在代码中硬编码敏感信息

7.11 生产环境部署清单

部署前检查

  • 设置正确的 --allow-websocket-origin
  • 配置HTTPS证书
  • 设置进程管理(systemd/supervisor)
  • 配置反向代理(Nginx/Apache)
  • 设置日志记录
  • 配置监控和告警

环境配置

# 生产环境启动命令
bokeh serve app.py \
    --port=5006 \
    --allow-websocket-origin=example.com \
    --num-procs=4 \
    --log-level=warning \
    --address=127.0.0.1

性能调优

# 使用数据流控制
source.stream(new_data, rollover=1000)

# 启用WebGL(大数据集)
p = figure(output_backend="webgl")

# 使用异步回调
curdoc().add_periodic_callback(update, 1000)

监控配置

# 使用systemd监控
sudo systemctl enable bokeh-app
sudo systemctl status bokeh-app

# 查看日志
journalctl -u bokeh-app -f

Docker部署示例

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5006
HEALTHCHECK CMD curl -f http://localhost:5006/ || exit 1

CMD ["bokeh", "serve", "app.py", \
     "--port=5006", \
     "--allow-websocket-origin=*", \
     "--num-procs=2"]

7.12 本章小结

这一章的重点不是“会写一个滑块回调”这么简单,而是理解 Bokeh Server 让浏览器里的交互继续连接到 Python

到这里,你应该已经建立了下面这条主线:

  • standalone HTML 适合浏览器端交互和 CustomJS
  • 一旦需要 Python 回调、周期更新、访问数据库或文件系统,就进入 Bokeh Server 场景
  • Bokeh Server 应用本质上是围绕 curdoc()、数据源、控件和回调组织起来的一个 Python Web 应用
  • 部署时不仅要考虑“能跑起来”,还要考虑 WebSocket、反向代理、安全、进程管理和资源限制

可以把这一章和前后章节连起来看:

  • 本章前半部分是在承接第六章:当 CustomJS 不够时,就进入 Bokeh Server
  • 本章中的 ColumnDataSource 更新、stream()patch() 又和第二章是连在一起的
  • 本章的部署、嵌入和访问方式,还要结合第八章理解
  • 本章里的实时回调、频率控制和数据规模问题,会在第九章进一步展开

7.13 常见坑

坑 1:把 Bokeh Server 当成“高级版 show()”

Bokeh Server 不是简单把 show() 换成 bokeh serve
一旦进入 Server 模式,你就同时进入了这些问题域:

  • 会话状态
  • WebSocket 同步
  • 回调执行频率
  • 多用户访问
  • 部署与安全

所以不要把它当成“只是让 Python 回调能跑”的小补丁,而要把它当成一个真正的应用运行环境。

坑 2:所有问题都用 Python 回调解决

很多交互其实根本不需要 Server,例如:

  • 改点大小
  • 简单过滤显示
  • 前端联动
  • tooltip 展示

这些场景通常直接用 CustomJS 更轻量,也更容易部署。
经验上可以这样判断:

  • 只改前端展示:优先 CustomJS
  • 需要 Python 参与计算或访问后端资源:使用 Bokeh Server

如果你不确定该怎么选,建议回看第六章

坑 3:回调能跑,但数据更新方式不对

很多 Server 应用变卡,不是因为 Server 本身“太慢”,而是因为:

  • 每次都整体替换 source.data
  • 定时器频率过高
  • 明明只改一个值,却重传整份数据
  • 保留了过长的历史数据窗口

更稳妥的思路通常是:

  • 追加数据用 stream()
  • 局部修改用 patch()
  • 控制 rollover
  • 把耗时计算和高频 UI 更新分开

这部分建议和第九章一起看。

坑 4:本地能跑,部署后交互失效

这通常不是图表代码本身的问题,而是部署链路的问题。最常见的原因包括:

  • --allow-websocket-origin 没配对
  • 反向代理没正确转发 WebSocket
  • 页面是 HTTPS,但 Bokeh Server 仍然走 HTTP
  • 多进程配置和会话状态设计不匹配

所以生产环境部署时,应该把“图表功能正确”和“访问链路正确”分开排查。

坑 5:应用代码越写越大,却没有及时拆结构

一个 Bokeh Server 文件一开始可能只有几十行,但很快就会同时包含:

  • 数据加载
  • 图表创建
  • 控件定义
  • 回调逻辑
  • 布局组织
  • 部署配置假设

如果不及时拆分,后面会很难维护。
建议尽早把代码拆成几类函数:

  • 数据准备函数
  • 图表创建函数
  • 控件创建函数
  • 回调绑定函数
  • 页面组装函数

这样在继续扩展到多页面、多个数据源或复杂部署时,成本会低很多。


延伸阅读:Bokeh Server 应用的部署和安全见本章 7.10-7.11 节。Server 应用的性能优化见第九章。输出选项见第八章;如果你还想回到前端交互和 CustomJS 的场景对比,请回看第六章