翻译自:Messaging in Jupyter

这篇文档解释了jupyter前端和内核通信的基本的设计和消息规范。ZeroMQ库提供了低层级的发送这些消息的传输层协议。

介绍

基本的设计可以用下图来解释:

一个kernel可能同时连接一个或者多个前端。kernel有专注于以下功能的sockets:

  1. Shell: 这个单路由的socket允许从前端来的多个连接,这也是实现通过前端来对kernel来做代码运行请求,对象信息,提示符等功能的套接字。在这个套接字上的通信是一系列从每个前端到kernel的请求/回应的动作。
  2. IOPub: 这个套接字是broadcast channel广播频道,kernel用来发布所有的副作用比如(stdout, stderr等),同时也会发布从任何客户端使用shell socket来的请求和它自己使用stdin socket的请求。在Python中有许多方式可以产生副作用,比如向sys.stdout写入的print()函数,错误产生的tracebacks等等。另外,在多客户端的情景下,我们希望所有的前端能够知道彼此发送给kernel的内容,这样在合作场景中非常有用。这个套接字可以使得一个客户端通信中发生的副作用和通信信息可以被所有的客户端以一种标准规范的格式获取到。
  3. stdin: 这个路由socket会连接到所有的前端,它允许kernel在raw_inpunt()被调用的时候去动态前端请求输入。执行代码的前端会有一个DEALER socket会在通信发生的时候表现为虚拟键盘(图中黑色的外框的键盘)。在实践中,前端可能会使用一个特别的输入控件来展现kernel的输入请求或者是指示用户去输入而不是前端中普通的命令。所有的消息都会被附加足够的信息,这样客户端就知道哪个消息是自己与kernel的交互,那些是其他客户端的,因此它们可以合适的展现输入。
  4. Control: 这个频道和Shell是等同的,但是在单独的socket运作,用来允许重要的消息避免在请求执行队列排队。比如shutdown或者abort
  5. Heartbeat: 这个socket允许单字节字符串信息在前端和kernel中传递用来保证它们仍然在连接中。

这些频道实际允许的消息格式在下面指定了。消息是字典字典,字符串作为键,值是采用JSON支持的格式。

General Message Format

一个消息是由下面4个字典结构组成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
# 消息头为起源session和实际的消息id包含了一对独一无二的标识符,另外还有生成消息的程序使用的username。这在合作设置中很有用,因为可能有多个用户来和同一个kernel交互,这样前端可以以一种有意义的方式标识不同的消息。
'header' : {
'msg_id' : str, # 通常是uuid,每个消息有不同的msg_id
'username' : str,
'session' : str, # 通常是uuid,每个session都不同
'date': str, # 每个消息生成时候的ISO 8601 时间戳
'msg_type' : str, # 所有可以识别的消息类型字符串将会在下面罗列
'version' : '5.0', # 消息协议的版本
},
# 在一系列的消息中,从parent来的消息将会被复制到这里,这样客户端就能知道消息是从哪里来的
'parent_header' : dict,
# 绑定到消息上的元信息
'metadata' : dict,
# 消息实际内容是一个字典,其实际结构将取决于消息类型
'content' : dict,
# 可选的,是一个二进制数据buffers列表,为了支持二进制扩展的协议实现
'buffers': list,
}

Compatibility

内核必须实现executekernel info消息,这样内核才是可用的。其他的消息类型都是可选的,尽管我们建议如果可能的话实现completion。内核不需要对它们不处理的消息做出任何回应。前端应该对没有回应到达的情况提供合理的行为,除了excutekernel info消息。

stdin消息是在从kernel来的请求中独一无二的,回应是从前端发出。前端没有被强制去支持,但是如果前端不支持的话,需要在execute请求中设置'allow_stdin': False,这样kernel就不会发送stdin请求了。如果那个字段是True,kernel就会发送stdin请求,然后阻塞等待回应,因此前端必须去回应。

kernel和前端应该允许不期望的消息类型和在已知的消息类型中有额外的字段。这样额外添加到协议的字段不会让现在的代码崩溃。

The Wire Protocol

消息格式存在于高层次,但是没有描述实际在zeromq中的低层次的实现。权威的消息规范实现是我们的Session

这一节内容只与协议的非Python消费者有关。Python消费者只需要简单的导入和使用jupyter_client.session.Session就可以了。

每一个消息将会被序列化至下面至少6个二进制对象列表

1
2
3
4
5
6
7
8
9
10
11
[
b'u-u-i-d', # zmq identity(ies)
b'<IDS|MSG>', # 定界符
b'baddad42', # HMAC signature
b'{header}', # serialized header dict
b'{parent_header}', # serialized parent header dict
b'{metadata}', # serialized metadata dict
b'{content}', # serialized content dict
b'\xf0\x9f\x90\xb1' # extra raw data buffer(s)
...
]

消息开头是ZeroMQ的路由前缀,可以是0或者是更多的socket标识符。这是在定界符前面的消息的每一部分。在IOPub的情况下,只应该有一个前缀组件,就是IOPub订阅者的主题,比如是execute_result, display_data

在大多数情况下,IOPub主题都是不相关的而且是完全忽略的,因为前端订阅了所有主题。在IPython内核中使用msg_type作为主题,可能是关于消息的额外的信息,kernel.{u-u-i-d}.execute_result或者是stream.stdout

在定界符之后是消息的HMAC(Hash-based message authentication code)签名,用来做数据鉴定。如果数据鉴定禁用,这个应该是空字符串。默认的,计算这些签名的哈希函数是sha256。

在签名之后是实际的消息,这四个字典构成了一个消息,它们是单独序列化的,以header, parent header, metadata, content的顺序进行。可以被任意一种序列化函数将字典转换成bytes。默认并且通用的序列化方法是JSON,msgpack和pickle是通用的替代方案。

在序列化字典之后是0到多个的原始数据buffers,可以在支持二进制数据的消息类型中使用,可以在自定义的消息中使用,比如comms和协议的扩展。

Python API

因为消息是字典形式,因此自然的映射到了func(**kw)调用形式。我们应该在几个关键点上开发以这种方式接收参数的函数,然后将其转换成必要的字典,然后发送之。

另外,消息规范的Python实现在反序列化扩展为下面形式的消息提供了方便。

1
2
3
4
5
6
7
8
{
'header' : dict,
'msg_id' : str, # 消息的标识符和类型是在header中存储的,但是Python实现将其拿到了顶层
'msg_type' : str,
'parent_header' : dict,
'content' : dict,
'metadata' : dict,
}

所有的从任何IPython进程中发送或者接收都应该使用这种扩展形式。

Messages on the shell (ROUTER/DEALER) channel

Request-Reply

一般的,ROUTER/DEALER 套接字遵循下面的请求-相应模式:

客户端发送一个<action>_request消息,比如execute_request在它的shell(DEALER)套接字。内核收到这个请求然后很快的发布一个status: busy消息在IOPub上。内核然后处理这个请求,然后发送一个合适的<action>_reply消息,比如execute_reply。在处理完请求和发布相应的IOPub消息之后,内核会发布一个status: idle消息。这个idle状态表明对于一个给定请求相应的IOPub消息已经被接收到了。

所有的回应消息都有一个status字段,可能会有以下值:

  • status='ok' 请求被成功的处理,回应剩余内容在下面合适的部分指定。
  • status='error' 请求因为一个错误而失败

    当状态是error的时候,通常成功回应的内容应该被忽略,下面的内容字段应该被呈现

    1
    2
    3
    4
    5
    6
    {
    'status' : 'error',
    'ename' : str, # Exception name, as a string
    'evalue' : str, # Exception value, as a string
    'traceback' : list(str), # traceback frames as strings
    }
  • status='abort'status='error'相同,但是没有错误的相关信息。除了status不应该显示其他字段。

Execute

这个信息类型用来前端请求内核去代表用户执行代码,在保留用户变量的命名空间中(因此和内核自己的内部代码和变量相互独立)

消息类型:execute_request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
content = {
# 被内核执行的源代码,一行或者多行字符串
'code' : str,
# 布尔类型,如果是True,内核会尽可能安静的执行代码,而且会使得store_history变成False
# 不会在IOPub上广播输出,不会有execute_result回应
# 默认是False
'silent' : bool,
# bool类型,如果是True,会使得内核来记录history
# 默认如果silent是False就是True,如果silent是True,该选项强制为False
'store_history' : bool,
# 在用户字典中执行的名字到表达式的映射,在执行之后会对每个富显示数据表示进行求值
'user_expressions' : dict,
# 一些前端不支持stdin请求,
'allow_stdin' : True,
# 如果是True,如果异常发生,则不会退出执行队列,这允许多个进入队列`execute_requests`的执行,即使发生了异常。
'stop_on_error' : False,
}

user_expressions字段需要详细的解释。在过去IPython有提示符概念来允许任意的代码来执行,这被很多在创建提示符来展示系统状态,路径信息甚至是更加难以使用比如远程设备状态等良好的使用。现在IPython在内核和客户端之间有清晰的界限,kernel没有提示符,提示符是前端特性。甚至对于不同的前端应该有不同的提示符,即使连接的是一个内核。user_expressions可以用来恢复这一信息。

任何在user_expression中发生的错误会导致包含这个key的标准错误信息:

1
2
3
4
5
6
{
'status' : 'error',
'ename' : 'NameError',
'evalue' : 'foo',
'traceback' : ...
}

执行请求完成之后,内核总会发送一个响应,其中有一个状态码来表明什么发生了,并且包含有关结果的额外数据,下面是可能的返回状态码和相关数据。

Execution counter(prompt number)

内核应该有一个单独的,单调增长的计数器对于所有的执行请求,当store_history=True的时候。这个计数器是用来填充In[n]Out[n]提示符的。这个计数器的值将会被以execution_count字段返回,在所有的execute_replyexecute_input信息。

Execution results

消息类型:execute_reply

1
2
3
4
5
6
content = {
# One of: 'ok' OR 'error' OR 'abort'
'status' : str,
'execution_count' : int,
}

当状态为ok,会有以下额外的字段

1
2
3
4
5
6
7
{
# 每个payload字典应该有一个source键,用来区分每个payload字典
'payload' : list(dict),
# Results for the user_expressions.
'user_expressions' : dict,
}

Payloads (DEPRECATED)

payloads是一种从内核来触发前端动作。目前的payloads:

  • page: 以分页的形式展现数据

    分页输出用来解析,或者其他不被认为是输出的显示信息。分页载荷一般被在单独的面板中展示,可以在代码旁边看到,而且不包括在notebook文档里。

    1
    2
    3
    4
    5
    6
    {
    "source": "page",
    # 必须包括text/plain.
    "data": mimebundle,
    "start": int,
    }
  • set_next_input: 创建一个新的输出

    用来在notebook中创建一个新的单元,或者在控制台界面中设置下一个输入。最主要的例子是%load

    1
    2
    3
    4
    5
    6
    {
    "source": "set_next_input",
    "text": "some cell content",
    # 如果是True,替换当前单元的内容,而不是创建一个新的
    "replace": bool,
    }
  • edit: 打开一个文件用来编辑

    通过%edit出发,只有QtConsole支持edit载荷。

    1
    2
    3
    4
    5
    {
    "source": "edit",
    "filename": "/path/to/file.py", # the file to edit
    "line_number": int, # the line number to start with
    }
  • ask_exit 用来指示前端提示用户退出

    允许内核去请求退出,在IPython里通过%exit来进行。只能在控制前端中使用。

    1
    2
    3
    4
    5
    {
    "source": "ask_exit",
    # 是否保留内核继续运行,只关闭客户端
    "keepkernel": bool,
    }

Introspection

代码可以被检查用来给用户展示有用的信息。由内核决定哪些信息应该被展示,而且还有它的格式

信息类型:inspect_request

1
2
3
4
5
6
7
8
content = {
# 解析被请求的代码上下文
'code' : str,
'cursor_pos' : int,
'detail_level' : 0 or 1,
}

Connect

当一个客户端连接一个内核的request/reply套接字时候,它可以发起一个连接请求来获取内核的基本信息,比如其他ZeroMQ监听的端口。这样允许客户端仅仅需要知道一个连接到kernel的端口(sehll channel)就行了。其他的内核监听的channels都应该包含了响应中。如果有被忽略的端口,表明这个channel没有运行。

消息类型:connect_request

1
content = {}

一个例子,运行所有频道的内核:

消息类型:connect_reply

1
2
3
4
5
6
7
content = {
'shell_port' : int, # The port the shell ROUTER socket is listening on.
'iopub_port' : int, # The port the PUB socket is listening on.
'stdin_port' : int, # The port the stdin ROUTER socket is listening on.
'hb_port' : int, # The port the heartbeat socket is listening on.
'control_port' : int, # The port the control ROUTER socket is listening on.
}

Comm info

如果一个客户端需要现在在内核上打开通信,它可以发起一个现在打开通信的请求。当可选参数target_name指定的时候,相应只包含现在为目标打开的通信。

消息类型:comm_info_request

1
2
3
4
content = {
# Optional, the target name
'target_name': str,
}

消息类型:comm_info_reply

1
2
3
4
5
6
7
8
content = {
# A dictionary of the comms, indexed by uuids.
'comms': {
comm_id: {
'target_name': str,
},
},
}

Messages on the IOPub (PUB/SUB) channel

Streams (stdout, stderr, etc)

消息类型:stream

1
2
3
4
5
6
7
content = {
# stdout或者stderr之一
'name' : str,
# 任意写入该流的字符串
'text' : str,
}

Display Data

这条消息是用来将要在前端展示的数据数据带回来,比如text, html。这个数据是发布给所有前端的,每一个消息可以有不同的数据表现。它视前端决定如何使用来定的。单个消息应该包含对同样信息的所有可能的展示。每一个展示都应该是JSON数据结构,也应该是一个有效的MIME类型。

消息类型:display_data

1
2
3
4
5
6
7
8
9
10
11
content = {
# 数据字典,key是MIME类型,值是以这种类型展现的原始数据
'data' : dict,
# 描述这个数据的所有元信息
'metadata' : dict,
# 临时数据
'transient': dict,
}

后记

还有很多消息和消息类型,不过也都大同小异,等到真正要用到的时候再去看看吧。基本上前端和内核的通信原理和消息规范就是这样。