实时(socket.io)

Frappe 内置了一个基于 socket.io 的实时事件 API。由于 socket.io 需要 Node 服务器来运行,我们在主 Web 服务器之外并行运行一个 Node 进程。

客户端 API (JavaScript)

frappe.realtime.on

要在客户端(浏览器)监听实时事件,可以使用 frappe.realtime.on 方法:

frappe.realtime.on('event_name', (data) => {
    console.log(data)
})

frappe.realtime.off

停止监听您已订阅的事件:

frappe.realtime.off('event_name')

服务端 API (Python)

frappe.publish_realtime

要从服务端发布实时事件,可以使用 frappe.publish_realtime 方法:

frappe.publish_realtime('event_name', data={'key': 'value'})

frappe.publish_progress

您可以使用此方法在对话框中显示进度条:

frappe.publish_progress(25, title='Some title', description='Some description')

自定义事件处理器 (Python)

注意:此功能仅在夜间版中可用。此功能被视为实验性功能。

您可以通过在自定义应用中创建 your_app/your_app/realtime/handlers.py 文件来实现自定义的实时事件处理器。其语法与 API 白名单非常相似。

from frappe.realtime import realtime

@realtime.on(
  "project_subscribe",
  frappe_context=False,   # open a Frappe context (DB + session) for the handler body
  allow_guest=False,      # if False, the event is dropped when socket.user == "Guest"
)

默认值:frappe_context=Falseallow_guest=False

示例

import frappe
from frappe.realtime import Socket, realtime

@realtime.on("project_subscribe")
def project_subscribe(socket: Socket, project: str) -> None:
    if socket.has_permission("Project", project):
        socket.join(f"project:{project}")

第一个参数始终是输入的 Socket。其余参数是客户端发送的负载,按位置传递。事件名称 "project_subscribe" 是浏览器通过 frappe.realtime.emit(...) 发出的。

使用 frappe_context=True 的工作示例

使用 frappe_context=True 时,完整的 ORM 在处理器主体中可用 — frappe.get_docfrappe.local.sitefrappe.session.user 都会被填充,并反映已认证的 socket 用户。

此示例处理器加载一个文档,检查权限,并将结果发送回客户端:

@realtime.on("test_get_doc", frappe_context=True)
def test_get_doc(socket: Socket, doctype: str, docname: str) -> None:
    import frappe
    try:
        doc = frappe.get_doc(doctype, docname)
        doc.check_permission()
        socket.emit(
            "test_get_doc_result",
            {
                "ok": True,
                "site": frappe.local.site,
                "user": frappe.session.user,
                "doctype": doc.doctype,
                "name": doc.name,
                "modified": str(doc.get("modified")),
            },
        )
    except Exception as e:
        socket.emit("test_get_doc_result", {"ok": False, "error": f"{type(e).__name__}: {e}"})

从浏览器端:

frappe.realtime.on("test_get_doc_result", (r) => console.log(r));
frappe.realtime.emit("test_get_doc", "User", "Administrator");

请参阅“权限检查”部分,了解其成本以及何时应优先使用基于 HTTP 的廉价权限检查。

仅当处理器所属的应用安装在连接站点上时,该处理器才会运行。所属应用会自动从导入中检测出来。

您可能需要重启 socketio 服务器才能看到代码更改生效。有关编写自定义事件处理器的更多信息,请参阅 Socket.IO 文档。

Socket 对象

只读身份信息,在连接时填充:

socket.site            # the site this socket is on
socket.user            # "Guest" for anonymous
socket.user_type       # e.g. "System User"
socket.installed_apps  # list of installed apps

房间、发送和瞬态状态:

socket.join(room)                        # add this socket to a room
socket.leave(room)                       # remove it
socket.emit(event, data=None, room=None) # emit to a room, or to this client if room is None
socket.get(key, default=None)            # read transient per-socket state
socket.set(key, value)                   # persist transient per-socket state (cleared on disconnect)

权限检查

有两种方法可以执行 doctype 权限检查。

A. HTTP(默认,推荐)socket.has_permission(doctype, name) 向 Web 进程发起请求,与核心处理器的行为完全一致。实时进程中不建立数据库连接。成本低。除非有特殊原因,否则请使用此方法。

if socket.has_permission("Project", project):
    socket.join(f"project:{project}")

B. 进程内(frappe_context=True:为处理器主体打开一个完整的 Frappe 上下文,以便您可以直接调用 frappe.has_permission(...)、查询数据库等。代价:每个此类事件都会执行 frappe.init -> connect -> set_user -> commit/rollback -> destroy,并强制在实时进程中建立数据库连接。请谨慎使用。

@realtime.on("project_subscribe", frappe_context=True)
def project_subscribe(socket: Socket, project: str) -> None:
    if frappe.has_permission("Project", doc=project, ptype="read"):
        socket.join(f"project:{project}")

完整参考:++https://github.com/frappe/frappe/blob/develop/frappe/realtime/README.md++

自定义事件处理器 (NodeJS)

从 v16 版本开始可用。 被视为实验性功能。这是之前的实现,将在新版本中逐步淘汰。

Socket.IO 服务器路径;新应用应优先使用上述 Python 处理器。

您可以通过在应用的 realtime 文件夹中创建 handlers.js 文件来实现自定义的实时事件处理器。

此文件需要有一个单一的导出 — 一个在 socket 实例上设置事件处理器的函数。例如,对于一个名为“chat”的应用:

// bench/apps/chat/realtime/handlers.js

function chat_app_handlers(socket) {
    socket.on("hello_chat", () => {
        console.log("hello world!");
    });
}

module.exports = chat_app_handlers;

使用 frappe.realtime.emit("hello_chat") 从客户端代码触发此事件。您可能需要重启 socketio 服务器才能看到代码更改生效。有关编写自定义事件处理器的更多信息,请参阅 Socket.IO 文档。

向客户端推送事件

您不能直接从 Web 进程发送事件 — 您需要通过 Redis 发布,然后实时服务器将其桥接到已连接的 socket。您发布到的房间字符串必须与您的处理器 join 到的房间匹配。

from frappe.realtime import publish_to_room
publish_to_room("project:PROJ-0001", "project_updated", {"status": "Open"})

其他命名的辅助函数,每个都是对 publish_realtime 的轻量封装。

publish_to_user(user, event, message=None)
publish_to_doc(doctype, docname, event, message=None)
publish_to_doctype(doctype, event, message=None)
publish_task_progress(task_id, message=None)
publish_to_website(event, message=None)
publish_to_all(event, message=None)
publish_to_room(room, event, message=None)

完整参考:++https://github.com/frappe/frappe/blob/develop/frappe/realtime/README.md++

自定义客户端

如果你正在开发一个不使用 Desk 界面的 SPA 或移动应用,你可以编写自定义客户端来连接 socket.io 服务器。请参考官方 Socket.IO 客户端文档。

自定义客户端示例:

  • gameplan/frontend/src/socket.js
  • frappe/socketio_client.js

自定义客户端中的授权

有两种方式可以对 socket.io 服务器的连接进行身份验证:

  • Cookies — 在类似浏览器的环境中,连接会自动发送 cookies,socketio 服务器会使用它们进行身份验证。
  • 授权头 — 如果 cookies 不可用(例如移动应用),请像 API 请求一样使用 Authorization 头。请参阅 REST API 身份验证文档和 Socket.IO extraHeaders。

实现说明

  • 实时服务器使用 socket.io 服务器,用 node.js 编写,位于 /realtime 目录中。
  • 实时客户端是 socket.io 客户端库的封装,位于 public/js/frappe/socketio_client.js 中。
  • Python 进程通过 Redis 发布-订阅通道将事件发布到 node 服务器。实时服务器订阅 Redis 通道,并重新发布给所有已订阅的客户端。
  • 实时服务器是多租户的:所有站点流量都按站点名称进行命名空间隔离。命名空间会动态创建为 /{sitename},其中 sitename 是站点在 sites 目录中的文件夹名称(或 frappe.local.site)。
  • 实时服务器使用主 Frappe Web 服务器来验证连接。SID cookie 或授权头会传递给客户端,并用于确保连接是有效用户,且可以根据权限订阅 DocTypes/文档。

可用房间

房间 访问权限
all 所有系统用户默认连接
website 任何用户(包括访客)均可访问
user:{username} 按用户分配的房间。无需权限检查即可加入
doctype:{doctype} 按 DocType 分配的房间。只有具有 DocType 权限的用户才能加入;打开列表/表单视图时自动订阅
doc:{doctype}/{name} 按文档分配的房间。只有具有文档权限的用户才能加入;打开表单视图时自动订阅