Bokeh Server 的 HTTP 视图层:bokeh.server.views 七类请求处理器深度解析
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
本文基于 Bokeh 仓库的 API 参考页 views.rst 展开,系统讲解bokeh.server.views包中 7 个请求处理器模块的职责、路由映射与核心实现机制。读完本文,你将理解bokeh serve启动的 Tornado 应用如何把根路径、文档页、/ws、/metadata、/autoload.js与静态资源等请求分发到对应 Handler,并能结合源码定位鉴权、会话创建、WebSocket 令牌校验等关键链路。
一、视图层总览:路由如何注册到 Bokeh Server
bokeh.server.views包为 Bokeh Server(Tornado 应用)提供全部 HTTP/WebSocket 请求处理器。官方参考页 views.rst 以automodule指令逐模块列出了 7 个成员:
| 参考页小节 | 源码模块 | 导出类 |
|---|---|---|
| auth_request_handler | auth_request_handler.py | AuthRequestHandler |
| autoload_js_handler | autoload_js_handler.py | AutoloadJsHandler |
| doc_handler | doc_handler.py | DocHandler |
| autoload_metadata_handler | metadata_handler.py | MetadataHandler |
| root_handler | root_handler.py | RootHandler |
| session_handler | session_handler.py | SessionHandler |
| static_handler | static_handler.py | AsyncStaticFileHandler、StaticHandler |
| ws | ws.py | WSHandler |
注意参考页中第四节标题写作 “autoload_metadata_handler”,但其automodule指向的是bokeh.server.views.metadata_handler,即实际的 metadata_handler.py 模块——阅读文档时可按后者定位源码。
这些 Handler 与 URL 的绑定集中在 urls.py 中定义,并说明由bokeh.server.tornado中的BokehTornado应用负责注册。路由分两层:
顶层路由toplevel_patterns(与应用无关,统一加上配置的prefix前缀):
toplevel_patterns: URLRoutes = [ (r'/?', RootHandler), # <prefix>/ (r'/static/extensions/(.*)', MultiRootStaticHandler, dict(root=extension_dirs)), (r'/static/(.*)', StaticHandler), # <prefix>/static/ ]每应用路由per_app_patterns(叠加应用路径与prefix前缀):
per_app_patterns: URLRoutes = [ (r'/?', DocHandler), # <prefix>/<app>/ (r'/ws', WSHandler), # <prefix>/<app>/ws (r'/metadata', MetadataHandler), # <prefix>/<app>/metadata (r'/autoload.js', AutoloadJsHandler), # <prefix>/<app>/autoload.js ]由此得到一张完整的端点对照表:
| 请求路径 | 处理器 | 作用 |
|---|---|---|
<prefix>/ | RootHandler | 列出所有应用,或在单应用时重定向 |
<prefix>/static/(.*) | StaticHandler | 提供 BokehJS 的 JS/CSS 静态资源 |
<prefix>/static/extensions/(.*) | MultiRootStaticHandler | 从多个扩展目录提供静态文件 |
<prefix>/<app>/ | DocHandler | 渲染文档展示页(HTML) |
<prefix>/<app>/ws | WSHandler | 服务端 WebSocket 通道 |
<prefix>/<app>/metadata | MetadataHandler | 返回应用元数据 JSON |
<prefix>/<app>/autoload.js | AutoloadJsHandler | 返回服务端 autoload 的 JS 片段 |
除参考页列出的 7 个模块外,views包中还有 multi_root_static_handler.py(多根目录静态文件)等辅助模块,它们与上述 Handler 共同构成完整的视图层。
二、AuthRequestHandler:统一授权钩子的基类
AuthRequestHandler 继承自 Tornado 的RequestHandler,把 Tornado 的三个标准授权钩子统一委托给配置在 Bokeh Tornado 应用上的AuthProvider:
get_login_url()(L65-L79):按优先级取登录地址——若已缓存self._bokeh_login_url则直接返回;否则调用auth_provider.get_login_url(self);再否则把auth_provider.login_url与prefix + "/"做urljoin拼接(源码注释特别强调第二个参数必须lstrip("/"),否则urljoin会把带前导斜杠的第二参数当作绝对路径而丢弃前缀);两者都没有时抛出RuntimeError('login_url or get_login_url() must be supplied when authentication hooks are enabled')。get_current_user()(L81-L89):同步钩子,委托给auth_provider.get_user(self);未配置时回退为字符串"default_user",这解释了为何默认(无鉴权)部署下每个请求都有“当前用户”。prepare()(L91-L108):异步版本。优先使用auth_provider.get_user_async;若只有同步get_user,则通过_run_in_executor放入线程池执行,并且对OPTIONS预检请求直接跳过;若最终current_user为空、请求方法是GET/HEAD且配置了get_login_url,则解析出登录 URL 缓存到_bokeh_login_url,供后续get_login_url()返回 302 重定向。
从源码结构看,所有需要鉴权的业务 Handler(下文逐一介绍)都通过继承AuthRequestHandler并在处理方法上叠加 Tornado 的@authenticated装饰器来获得“未登录即跳转登录页”的行为。
三、SessionHandler:会话解析的抽象基类
SessionHandler 是文档页与 autoload.js 等“需要会话”的端点的公共父类。它在__init__中接收两个关键关键字参数:
application_context:该应用对应的ApplicationContext,持有应用实例与会话集合;bokeh_websocket_path:用于告知前端 WebSocket 的绝对路径。
核心方法是带@authenticated装饰的get_session()(L78-L84):
@authenticated async def get_session(self) -> ServerSession | None: try: request = cast("RequestLike", self.request) return await self.application.create_session(self.application_context, request) except SessionError as error: raise HTTPError(status_code=error.status, reason=error.reason)它把“根据请求中的 token/session ID 解析(必要时创建)会话”的职责下沉到BokehTornado.create_session;当出现SessionError时转换为对应状态码的HTTPError。从源码结构看,create_session返回None表示 token 非法或会话不存在,子类 Handler 会进一步把它翻译成 403。
四、DocHandler:文档展示页 / /
DocHandler 继承SessionHandler,处理每个应用的根路径请求,负责渲染出浏览器里看到的文档页面:
@authenticated async def get(self, *args: Any, **kwargs: Any) -> None: session = await self.get_session() if session is None: raise HTTPError(status_code=403, reason="Invalid token or session ID") page = server_html_page_for_session( session, resources=self.application.resources(), title=session.document.title, template=session.document.template, template_variables=session.document.template_variables) self.set_header("Content-Type", 'text/html') self.write(page)实现要点:
- 会话解析失败(
get_session()返回None)直接返回403 “Invalid token or session ID”; - 页面 HTML 由
bokeh.embed.server.server_html_page_for_session生成(导入见 doc_handler.py L33),标题、Jinja 模板与模板变量均取自session.document,即服务端Document对象——这意味着在应用代码中修改doc.title、doc.template会直接反映到下次请求的页面; - 资源(BokehJS 的 JS/CSS)通过
self.application.resources()由 Tornado 应用注入,与 urls.py 中StaticHandler提供/static/的静态文件相呼应。
五、AutoloadJsHandler:把应用“嵌入”外部页面的 /autoload.js
AutoloadJsHandler 处理<prefix>/<app>/autoload.js,配合server_document等嵌入 API,把服务端应用加载进第三方页面。它是视图层中 CORS 逻辑最复杂的 Handler:
默认 CORS 头(L67-L70):
def set_default_headers(self) -> None: self.set_header("Access-Control-Allow-Origin", "*") self.set_header("Access-Control-Allow-Headers", "*") self.set_header("Access-Control-Allow-Credentials", "true")可信源镜像(_allow_websocket_origin,L72-L87):读取请求头Origin,取其 host 与允许列表比对(允许列表优先取settings.allowed_ws_origin(),即--allow-websocket-origin/BOKEH_ALLOW_WS_ORIGIN;否则用应用级websocket_origins)。命中后把具体 origin 回写到Access-Control-Allow-Origin并加Vary: Origin——源码注释解释了原因:带凭据的 CORS 请求不能依赖通配符 origin,只能镜像已被信任打开 Bokeh WebSocket 的源。
GET 主流程(L89-L121),可归纳为 6 步:
- 调用
_allow_websocket_origin()处理 CORS; get_session()解析会话,失败返回 403;- 必填查询参数
bokeh-autoload-element(目标 DOM 元素 ID),缺失时send_error(400, reason='No bokeh-autoload-element query parameter'); - 可选参数
bokeh-app-path(默认"/")与bokeh-absolute-url(用于推断server_url); - 可选参数
resources(默认"default",值为"none"时不注入资源);随后由应用生成 bundle 并附加一段Script,其内容由script_for_render_items基于RenderItem(token=session.token, elementid=element_id, ...)渲染; - 用
bokeh.core.templates的AUTOLOAD_JS模板(导入见 L34)输出 JS,Content-Type为application/javascript。
OPTIONS 预检(L123-L126):浏览器在跨域 GET 前会先发 OPTIONS,这里设置Access-Control-Allow-Methods: PUT, GET, OPTIONS并复用 origin 镜像逻辑。
六、MetadataHandler:/metadata 返回应用元数据
MetadataHandler 处理<prefix>/<app>/metadata,返回一个极简的 JSON:
@authenticated async def get(self, *args: Any, **kwargs: Any) -> None: url = self.application_context.url userdata = self.application_context.application.metadata if callable(userdata): userdata = userdata() if userdata is None: userdata = {} metadata = dict(url=url, data=userdata) self.set_header("Content-Type", 'application/json') self.write(json.dumps(metadata))规则很直接:data字段来自应用对象的metadata属性(可为dict或返回dict的可调用对象),为None时回退为空 dict;url字段来自application_context.url,即该应用被注册的路径。这个端点的典型用途是让客户端脚本在不打开文档页的情况下探测应用身份或携带的自定义元数据。
七、RootHandler:应用索引页 /
RootHandler 是顶层路由r'/?'的处理器,initialize从 Tornado 的路由上下文(URLRoutes允许携带第三个dict参数,见 urls.py L90-L108)取出四个关键字:applications、prefix、index、use_redirect。其get逻辑:
@authenticated async def get(self, *args: Any, **kwargs: Any) -> None: prefix = "" if self.prefix is None else self.prefix if self.use_redirect and len(self.applications) == 1: redirect_to = prefix + list(self.applications.keys())[0] self.redirect(redirect_to) else: index = "app_index.html" if self.index is None else self.index self.render(index, prefix=prefix, items=sorted(self.applications.keys()))- 单应用 + 开启重定向:302 跳转到
<prefix>/<app_name>,这正是bokeh serve单目录启动后浏览器直接看到应用的原因; - 多应用或关闭重定向:渲染索引页模板(默认包内 app_index.html,也可用
index参数指定自定义模板),把排序后的应用名列表items传入模板。
八、StaticHandler 与异步静态文件服务
static_handler.py 提供了两个类,服务于<prefix>/static/(.*)路由。
8.1 AsyncStaticFileHandler:不在事件循环里做文件 I/O
AsyncStaticFileHandler 重写自 TornadoStaticFileHandler,模块文档字符串一句话点明目的:“Serve static files without performing filesystem I/O on the event loop.”具体做法:
get_modified_time、compute_etag、get_content_size、os.stat等阻塞调用全部通过_run_in_executor(IOLoop.current().run_in_executor+asyncio.shield,L133-L142)放入线程池;- 支持
Range请求:解析Range头、越界返回416,部分满足返回206并设置Content-Range(L76-L104);ETag 未变化则返回304; - 内容以分块方式流式写出(
_stream_content,L179-L198),并处理iostream.StreamClosedError应对客户端提前断开; - 路径安全:
_validate_absolute_path拒绝跳出根目录的路径(403),目录 URL 未带尾斜杠时 301 重定向补斜杠,同时显式禁止以//开头的双斜杠重定向(防开放重定向,L144-L167)。
8.2 StaticHandler:指向 BokehJS 资源目录
StaticHandler 进一步把path固定为settings.bokehjs_path(),即安装包内 BokehJS 产物目录。另一个值得注意的方法是append_version(L217-L229):
if settings.dev: return path else: version = TornadoStaticFileHandler.get_version(dict(static_path=settings.bokehjs_path()), path) return f"{path}?v={version}"即非 dev 模式下静态 URL 会追加?v=<版本戳>以便浏览器缓存失效;dev 模式则依赖浏览器开发者工具管理缓存(源码注释原话)。
同包的 MultiRootStaticHandler 支持“按路径首段映射到不同根目录”(root为dict[str, Path]),urls.py用它为/static/extensions/(.*)从多个 Bokeh 扩展的产物目录提供静态文件(dict(root=extension_dirs),urls.py L99)。
九、WSHandler: / /ws 的完整握手与消息链路
WSHandler 是AuthRequestHandler与 TornadoWebSocketHandler的混合体,也是 Bokeh 前后端实时通信的唯一通道。
9.1 子协议与令牌传递:select_subprotocol
def select_subprotocol(self, subprotocols: list[str]) -> str | None: if not len(subprotocols) == 2: return None self._token = subprotocols[1] return subprotocols[0]握手时客户端必须提供恰好两个子协议项:第一个是bokeh子协议本身,第二个是会话 token。open()(L130-L171)随即做三重校验:子协议不是bokeh、缺少 token、token 签名无效(check_token_signature,签名模式取决于application.sign_sessions,密钥为application.secret_key)任一命中都会close()并抛ProtocolError。此外还检查 payload 中的session_expiry:缺失或已过期都会断开,过期时的错误信息直接提示用更大的--session-token-expiration值调整(L157-L163)。
9.2 Origin 校验
check_origin(L99-L128)把Origin头解析出 host,与允许列表比对:列表优先取settings.allowed_ws_origin()(对应命令行--allow-websocket-origin或环境变量BOKEH_ALLOW_WS_ORIGIN),否则取应用级websocket_origins(通常由--allow-websocket-origin与服务器自身地址组成)。拒绝时会记录一条包含修复建议的 ERROR 日志:“use --allow-websocket-origin=%s or set BOKEH_ALLOW_WS_ORIGIN=%s to permit this”。
9.3 异步建链:_async_open
open()通过后,通过io_loop.add_callback转入异步流程_async_open(L189-L227):
- 从 token 中取
session_id,调用application.create_session_if_needed(application_context, session_id, request, token)——不存在则创建新会话; - 从
application_context取出ServerSession; - 创建协议
Receiver(用于重组分片消息)与ServerConnection(application.new_connection(self, session)); - 向客户端发送协议层
ack()消息,表示连接建立完成。
发送侧用write_lock保证一次连接只按序写消息(send_message,L271-L287),并在WebSocketClosedError时仅告警不崩溃。
9.4 消息处理与关闭
on_message(L229-L269):每个 WebSocket 帧先经Receiver.consume(fragment)组装成完整Message;再交给connection.handle(parsed_message),若产生 reply 则回写。协议错误走_protocol_error(关闭码1002),其它内部错误走_internal_error(关闭码1011)。源码注释强调不能从on_message抛异常,因为调用方只是 Tornado,它无法处理未处理的 Future;on_close(L289-L296):记录关闭码与原因,通知会话notify_connection_lost()并调用application.client_lost(connection),由上层决定是否销毁会话;- 压缩:
get_compression_options(L181-L187)把构造期传入的compression_level/mem_level透传给 Tornado,未配置则禁用压缩; - 模块尾部还定义了一个仅供测试收集原始收发报文的
MessageTestPort(L323-L328),注释明确它是“undocumented API purely for harvesting low level messages for testing”。
十、处理器继承体系与阅读建议
把上面各模块串起来,bokeh.server.views的继承关系可以概括为:
tornado.web.RequestHandler └── AuthRequestHandler # 授权钩子委托给 AuthProvider ├── RootHandler # <prefix>/ ├── MetadataHandler # <prefix>/<app>/metadata └── SessionHandler # get_session() 解析会话 ├── DocHandler # <prefix>/<app>/ └── AutoloadJsHandler # <prefix>/<app>/autoload.js tornado.web.RequestHandler + WebSocketHandler └── WSHandler # <prefix>/<app>/ws tornado.web.StaticFileHandler └── AsyncStaticFileHandler # 线程池 I/O + Range/ETag ├── StaticHandler # <prefix>/static/(.*) └── MultiRootStaticHandler # <prefix>/static/extensions/(.*)实践中的阅读与验证路径:
- 从路由入手:先看 urls.py 确定某个 URL 由哪个 Handler 处理,注意顶层路由与每应用路由都受
prefix影响; - 鉴权问题追 auth_request_handler.py 与应用的
auth_provider,重点看prepare()中_bokeh_login_url的缓存时机; - 403/会话问题看
SessionHandler.get_session()与BokehTornado.create_session(session_handler.py L78-L84); - 跨域/嵌入问题对照
AutoloadJsHandler的 CORS 头与WSHandler.check_origin的允许列表来源(--allow-websocket-origin/BOKEH_ALLOW_WS_ORIGIN); - 连接闪断优先查 WebSocket 日志中的子协议、token 签名与
session_expiry三类ProtocolError,它们对应open()中三个显式关闭分支。
最后需要说明一点适用边界:本文所有端点、参数与行为描述均基于当前仓库中 src/bokeh/server/views/ 的源码;prefix、use_redirect、index等路由上下文字段由 tornado.py 中的BokehTornado在装配路由时注入,若你定制部署(如自定义 Tornado/ASGI 装配),这些默认值可能随之变化,请以当前仓库版本实际代码为准。
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考