# Jupyter Notebook React 实现

Jupyter 是一个大型项目,涵盖了众多不同的软件产品和工具,包括广受欢迎的 Jupyter Notebook (opens new window)JupyterLab (opens new window)。Jupyter 项目及其子项目都是围绕计算笔记本(Computational Notebook)展开的 。

计算笔记本是一种可共享的文档,它结合了计算机代码、解释性文本、数据、丰富的可视化效果(例如 3D 模型、图表、图形与图像)以及交互式控件。笔记本与编辑器(例如 Jupyter Notebook、JupyterLab)配合使用,提供了一个快速的交互式环境,用于原型设计和解释代码、探索和可视化数据,以及与他人分享想法。

# 问题与目标

我们怎样在自己的系统里实现 Jupyter Notebook 功能呢?以前我们是直接在后台启动 Jupyter Notebook 或 JupyterLab 应用,然后通过 iframe 嵌入到我们系统 。但是这样存在一些问题:

  • 不便于用户共享,用户启动的 Jupyter Notebook 或 JupyterLab 应用都是独立的,无法预览其他用户创建的 notebook。虽然 JupyterHub (opens new window) 可以实现多用户共享,但是它有一套自己的认证机制,它更适合一个团队的成员共享。
  • 用户为了预览 notebook,也要启动 Jupyter Notebook 或 JupyterLab 应用,消耗资源。
  • Jupyter Notebook 或 JupyterLab 应用的 UI 与系统风格不一致。

因此本文将使用 React 实现 Jupyter Notebook,做一个 Jupyter Notebook-Like 的功能,实现以下功能:

  • 用户可以方便地创建 notebook,用户之间可以预览。
  • 自定义 notebook UI ,以符合系统自身的风格。
  • 在接入 Jupyter Server 后,能运行 notebook 里的代码。
  • 可以编辑、保存和导出 notebook。

# 实现效果

# Jupyter 生态

在学习 Jupyter 时,经常看到 Jupyter Notebook 与 JupyterLab。Jupyter Notebook 和 JupyterLab 都是创建、渲染以及编辑 notebook 的 Web 应用。

它们都支持:

  • 代码编辑,具备自动语法高亮、缩进和 Tab 键自动补全功能。
  • 能够执行代码,并将计算结果附加到代码上。
  • 使用富媒体(如 HTML、LaTeX、PNG、SVG 等)显示计算结果。
  • 使用 Markdown 标记语言编辑富文本。
  • 使用 LaTeX 在 Markdown 单元格中添加数学符号,并由 MathJax (opens new window) 进行原生渲染。

不同之处

Jupyter Notebook 是早期的经典应用,提供了一种简化的、轻量级的 notebook 创作体验。通常是一个页面只能打开一个 .ipynb 文件。

JupyterLab 是新一代的 Web 应用,提供功能丰富的标签式多 notebook 编辑环境、可以自定义界面布局,以及增加了系统控制台等附加工具。

主要功能对比

功能 Notebook JupyterLab
多文件同时打开
多标签
拖拽分屏
文件管理 简单 强大
编辑 .py/.md/.txt
终端
控制台(Console)

简单说一句结论:JupyterLab 是新一代平台,Notebook 是老的经典应用

所以 JupyterLab 是我们的研究对象。

# Jupyter Notebook

# 安装

安装 Jupyter Notebook 推荐使用 Anaconda (opens new window)

jupyter-anaconda

Anaconda 适合初学者,因为已经内置了 Jupyter Notebook 和 JupyterLab。

还可以通过 pip (opens new window) 安装

$ pip3 install jupyter
1

# 运行

安装之后,运行 Jupyter Notebook 很简单

$ jupyter notebook
1

jupyter-notebook-preview

从上面这张图可以看出,Jupyter Notebook 是一个单页应用,不支持文件管理,多标签功能。

# JupyterLab

# 安装

同样推荐使用 Anaconda (opens new window) 安装 JupyterLab。

另外也可以使用 condamambapippipenv 以及 docker 安装 JupyterLab。

# conda
$ conda install -c conda-forge jupyterlab
# mamba
$ mamba install -c conda-forge jupyterlab
# pip
$ pip install jupyterlab
1
2
3
4
5
6

更多安装方式请参考 Jupyter - Installation (opens new window)

# 运行

运行 JupyterLab 也很简单

$ jupyter lab
1

jupyter-lab-preview

从上面这张图可以看出,JupyterLab 支持多文件管理,支持多标签等功能。

# 其它

# JupyterHub

JupyterHub (opens new window) 是一个多用户 Jupyter Notebook 服务平台,可用于学生班级、企业或科研团队。

# Jupyter Desktop

Jupyter Desktop (opens new window) 是一款基于 Electron 的桌面应用程序。

# Voilà

Voilà (opens new window) 将 Jupyter Notebook 转换为一个独立的静态 Web 应用程序。它删除了 notebook 中的代码,是一个不能编辑、执行的 Jupyter Notebook。

# JupyterLite

JupyterLite (opens new window) 是一种完全基于浏览器运行的 JupyterLab,它使用 WebAssembly (opens new window)(Wasm)运行 code,因此它不需要 Jupyter Server。

# Voici

Voici (opens new window)Voilà (opens new window)JupyterLite (opens new window) 的组合,它和 JupyterLite (opens new window) 一样使用 WebAssembly (opens new window)(Wasm)在浏览器中渲染 notebook。它可以作为 Voilà (opens new window) 的替代品。

# Notebook 文件格式

Jupyter notebook 文件(.ipynb)其实就是一个特殊的 JSON 文件 (opens new window)。下面是它的基本格式:

{
  "metadata": { // Notebook 级别的元数据
    "kernel_info": {
      # if kernel_info is defined, its name field is required.
      "name": "the name of the kernel"
    },
    "language_info": {
      # if language_info is defined, its name field is required.
      "name": "the programming language of the kernel",
      "version": "the version of the language",
      "codemirror_mode": "The name of the codemirror mode to use [optional]",
    },
  },
  "nbformat": 4, // 主版本
  "nbformat_minor": 5, // 小版本
  "cells": [
    # list of cell dictionaries, see below
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

其中最重要的是 cells,有三种类型:

  • markdown
  • code
  • raw

# Markdown Cell

Markdown Cell 主要用于解释性内容,支持 GitHub-flavored (opens new window) 风格,JuputerLab 使用 marked (opens new window) 进行渲染。其定义如下:

{
  "cell_type": "markdown",
  "metadata": {}, // Cell 级别的元数据
  "source": "[multi-line *markdown*]",
}
1
2
3
4
5

# 附件

Markdown Cell 和 Raw Cell 可以包含附件,通常是内联图片,这些图片可以在 Markdown 中被引用。

{
  "cell_type": "markdown",
  "metadata": {},
  "source": ["Here is an *inline* image ![inline image](attachment:test.png)"],
  "attachments": {"test.png": {"image/png": "base64-encoded-png-data"}},
}
1
2
3
4
5
6

# Code Cell

Code Cell 是 Jupyter notebook 的主要内容,包含代码、代码结果输出和执行次数。

{
  "cell_type": "code",
  "execution_count": 1,  # integer or null
  "metadata": {
    "collapsed": True,  # whether the output of the cell is collapsed
    "scrolled": False,  # any of true, false or "auto"
  },
  "source": "[some multi-line code]",
  "outputs": [
    {
      # list of output dicts (described below)
      "output_type": "stream",
      # ...
    }
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Code Cell 的输出又有下面四种类型:

  • stream

  • display_data

  • execute_result

  • error

# Stream

stdout 或者 stderr 的输出

{
  "output_type": "stream",
  "name": "stdout",  # or stderr
  "text": "[multiline stream text]",
}
1
2
3
4
5

# display_data

富文本输出,支持各种 mime-type,比如 "text/plain""image/png" 等。

一个 output data 可以同时有多个 mime-type,前端会选择最合适的一个进行展示,JupyterLab 使用 Rendermime 处理这些 mime-type 输出的渲染。

{
  "output_type": "display_data",
  "data": {
    "text/plain": "[multiline text data]",
    "image/png": "[base64-encoded-multiline-png-data]",
    "application/json": {
      # JSON data is included as-is
      "key1": "data",
      "key2": ["some", "values"],
      "key3": {"more": "data"},
    },
    "application/vnd.exampleorg.type+json": {
      # JSON data, included as-is, when the mime-type key ends in +json
      "key1": "data",
      "key2": ["some", "values"],
      "key3": {"more": "data"},
    },
  },
  "metadata": { // Output 级别的元数据
    "image/png": {
      "width": 640,
      "height": 480,
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# execute_result

执行 Code Cell 的结果,与 display_data 类型差不多,多了一个表示执行次数的 execution_count

{
  "output_type": "execute_result",
  "execution_count": 42,
  "data": {
    "text/plain": "[multiline text data]",
    "image/png": "[base64-encoded-multiline-png-data]",
    "application/json": {
      # JSON data is included as-is
      "json": "data",
    },
  },
  "metadata": {
    "image/png": {
      "width": 640,
      "height": 480,
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# error

执行失败,显示一个错误信息

{
  'output_type': 'error',
  'ename' : str,   # Exception name, as a string
  'evalue' : str,  # Exception value, as a string

  # The traceback will contain a list of frames,
  # represented each as a string.
  'traceback' : list,
}
1
2
3
4
5
6
7
8
9

# Raw Cell

Raw Cell 表示在 nbconvert 输出中应保持不变的内容。Raw Cell 唯一有用的是 metadataformat 字段,它指定 nbconvert 使用什么格式转换 Raw Cell 的内容。

{
  "cell_type": "raw",
  "metadata": {
      # the mime-type of the target nbconvert format.
      # nbconvert to formats other than this will exclude this cell.
      "format": "mime/type"
  },
  "source": ["some nbformat output text"],
}
1
2
3
4
5
6
7
8
9

这种类型的 Cell 一般很少使用。

# JupyterLab 架构

下面是 JupyterLab 整体架构,主要包括有两大部分组成:

# JupyterLab UI

JupyterLab 通过一个前端组件库 (opens new window)来实现 notebook 编辑器的界面。这些组件库都是用 Typescript 编写的,比如

这些组件都不是 React 组件,它们依赖 jupyterlab/lumino (opens new window) 进行渲染。

# Lumino

Lumino (opens new window) 是 JupyterLab 前端渲染库,类似于 JQuery、React、Vue。

Lumino 提供了一套丰富的组件、布局、事件和数据结构工具包。我们可以使用这些工具包构建可扩展的、高性能的 Web 应用程序。

@lumino/
├─ application    应用
├─ widgets        UI / 布局 / 生命周期
├─ coreutils      工具箱(插件系统、UUID、JSON、Promise)
├─ commands       命令系统(菜单、快捷键、palette)
├─ messaging      Widget 消息系统(底层)
├─ signaling      状态变化、解耦通信
├─ disposable     资源释放、生命周期管理
├─ collections    数据结构(LinkedList、Heap)
├─ algorithm      算法工具(迭代器)
├─ polling        定时轮询
├─ dragdrop       拖拽系统
├─ keyboard       键盘映射
├─ datagrid       高性能表格
├─ domutils       DOM 工具栏(剪贴板、Selector、BoxSizing)
├─ properties     响应式属性(很少用)
└─ virtualdom     虚拟 DOM(⚠️ 基本弃用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

其中最重要的是@lumino/widgets@lumino/coreutils 工具包。

# @lumino/widgets

这是 Lumino 最核心的包,负责:

  • Widget,可以理解为一个组件
  • DOM 生命周期
  • 布局系统,比如 BoxLayoutAccordionLayoutPanelLayout
  • Attach / Detach、Resize 管理

Widget 的定义(简化版)

export class Widget implements IMessageHandler, IObservableDisposable {
  constructor(options: Widget.IOptions = {}) {
    this.node = Private.createNode(options);
    this.addClass('lm-Widget');
  }
}

namespace Private {
  export function createNode(options: Widget.IOptions): HTMLElement {
    return options.node || document.createElement(options.tag || 'div');
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

怎么使用 Widget 创建一个 HTML 块呢?

<div>
  <h1>标题</h1>
  <p>这是段落</p>
  <span>这是正文</span>
  <img src="http://localhost:8000/image/example.jpg" />
<div>
1
2
3
4
5
6
  1. 首先创建一个 Widget
import { Widget } from '@lumino/widgets';

function createNode(): HTMLElement {
  const root = document.createElement('div');

  const h1 = document.createElement('h1');
  h1.textContent = '标题';

  const p = document.createElement('p');
  p.textContent = '这是段落';

  const span = document.createElement('span');
  span.textContent = '这是正文';

  const img = document.createElement('img');
  img.src = 'http://localhost:8000/image/example.jpg';

  root.append(h1, p, span, img);
  return root;
}

class ArticleWidget extends Widget {
  constructor() {
    super({ node: createNode() });
  }
}

export default ArticleWidget;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  1. 然后挂载到 Document
const widget = new ArticleWidget();
Widget.attach(widget, document.body);
1
2

attach 其实就是使用了 insertBefore (opens new window) 方法。

export function attach(
    widget: Widget,
    host: HTMLElement,
    ref: HTMLElement | null = null
  ): void {
    if (widget.parent) {
      throw new Error('Cannot attach a child widget.');
    }
    if (widget.isAttached || widget.node.isConnected) {
      throw new Error('Widget is already attached.');
    }
    if (!host.isConnected) {
      throw new Error('Host is not attached.');
    }
    MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
    host.insertBefore(widget.node, ref);
    MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

同理 detach 使用了 removeChild (opens new window) 方法。

  export function detach(widget: Widget): void {
    if (widget.parent) {
      throw new Error('Cannot detach a child widget.');
    }
    if (!widget.isAttached || !widget.node.isConnected) {
      throw new Error('Widget is not attached.');
    }
    MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
    widget.node.parentNode!.removeChild(widget.node);
    MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
# @lumino/coreutils

顾名思义,@lumino/coreutils 提供了核心工具,比如 Random、UUID、Promise 代理、JSON 等,其中最重要的是插件系统。这也是 JupyterLab 的工作方式。

interface IPlugin<T, U> {
  id: string; // 插件 ID
  description?: string; // 插件描述
  autoStart?: boolean | "defer"; //是否自动启用插件
  provides?: null | Token<U>; // 该插件提供的服务,Token 作为插件的唯一标识
  requires?: Token<any>[]; // 插件依赖服务类型(必需)
  optional?: Token<any>[]; // 插件依赖服务类型(可选)
  activate: ((app, ...args) => U | Promise<U>); // 激活插件,函数的返回值作为插件提供服务的具体实例。
  deactivate?: null | ((app, ...args) => void | Promise<void>); // 停用插件
}
1
2
3
4
5
6
7
8
9
10

JupyterLab 大量使用了插件,比如 @jupyterlab/markedparser-extension (opens new window) 提供了渲染 Markdown 内容的服务。

const plugin: JupyterFrontEndPlugin<IMarkdownParser> = {
  id: '@jupyterlab/markedparser-extension:plugin',
  description: 'Provides the Markdown parser.',
  autoStart: true,
  provides: IMarkdownParser,
  requires: [IEditorLanguageRegistry],
  optional: [IMermaidMarkdown],
  activate: (
    app: JupyterFrontEnd,
    languages: IEditorLanguageRegistry,
    mermaidMarkdown: IMermaidMarkdown | null
  ) => {
    return createMarkdownParser(languages, {
      blocks: mermaidMarkdown ? [mermaidMarkdown] : []
    });
  }
};

export const IMarkdownParser = new Token<IRenderMime.IMarkdownParser>(
  '@jupyterlab/rendermime:IMarkdownParser',
  'A service for rendering markdown syntax as HTML content.'
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

createMarkdownParser 创建 Markdown 解析器。JupyterLab 使用这个插件提供的 Markdown 解析器,渲染 "text/markdown" 类型的输出。

同时 @lumino/coreutils 提供了注册插件的类 PluginRegistry

Class PluginRegistry<T> {
  registerPlugins(plugins): void; // 注册插件
  deregisterPlugin(id, force?): void; // 注销插件
  activatePlugin(id): Promise<void>; // 激活插件
  deactivatePlugin(id): Promise<string[]> // 停用插件
}
1
2
3
4
5
6

@lumino/application 以及它的子类 @jupyterlab/application (opens new window) 使用 PluginRegistry 注册插件。

# 插件系统

JupyterLab UI 是由一个 shell widget(root 容器) + 一堆插件构成。

比如上面提到的 @jupyterlab/markedparser-extension 只是其中一个插件,提供渲染 Markdown 内容的服务。

# 注册插件

JupyterLab 怎么注册插件?JupyterLab 没有提供相关的文档,我们只能从代码层面寻找答案。

  1. 首先在 dev_mode/package.json 里列出了需要的插件,JupyterLab 把插件分为两类:
    • 标准插件(extensions
    • MIME 插件(mimeExtensions
"jupyterlab": {
  "extensions": {
    "@jupyterlab/markedparser-extension": "",
    "@jupyterlab/rendermime-extension": "",
    ...
  },
  "mimeExtensions": {
    "@jupyterlab/javascript-extension": "",
    "@jupyterlab/json-extension": "",
    "@jupyterlab/mermaid-extension": "lib/mime.js",
    "@jupyterlab/pdf-extension": "",
    "@jupyterlab/vega5-extension": ""
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. Webpack(dev_mode/webpack.config.js) 读取 dev_mode/package.jsonextensionsmimeExtensions,并使用 Handlebars.js 模版 (opens new window) 填充 dev_mode/index.js 里的模版内容,输出到 index.out.js

dev_mode/webpack.config.js (简化版)

// 1. 读取 `dev_mode/package.json` 里 `extensions` 与 `mimeExtensions` 内容
const packageData = require('./package.json');
const jlab = packageData.jupyterlab;
const { extensions, mimeExtensions } = jlab;

// 2. 使用 `Handlebars.js` 模版填充 `dev_mode/index.js` 里的模版内容
const source = fs.readFileSync('index.js').toString();
const template = Handlebars.compile(source);
const extData = {
  jupyterlab_extensions: extensions,
  jupyterlab_mime_extensions: mimeExtensions
};
// 3. 输出到 `index.out.js`
fs.writeFileSync(path.join(buildDir, 'index.out.js'), template(extData));
1
2
3
4
5
6
7
8
9
10
11
12
13
14

dev_mode/index.js (简化版)

/**
 * The main entry point for the application.
 */
export async function main() {
  var pluginRegistry = new PluginRegistry();
  var JupyterLab = require('@jupyterlab/application').JupyterLab;
  var register = [];

  // 1. 处理注册的 MIME 扩展
  const mimeExtensions = [];
  {{#each jupyterlab_mime_extensions}}
  if (!queuedFederated.includes('{{@key}}')) {
    try {
      let ext = require('{{@key}}{{#if this}}/{{this}}{{/if}}');
      ext.__scope__ = '{{@key}}';
      for (let plugin of activePlugins(ext)) {
        mimeExtensions.push(plugin);
      }
    } catch (e) {
      console.error(e);
    }
  }
  {{/each}}

  // 2. 处理已注册的标准扩展。
  {{#each jupyterlab_extensions}}
  if (!queuedFederated.includes('{{@key}}')) {
    try {
      let ext = require('{{@key}}{{#if this}}/{{this}}{{/if}}');
      ext.__scope__ = '{{@key}}';
      for (let plugin of activePlugins(ext)) {
        register.push(plugin);
      }
    } catch (e) {
      console.error(e);
    }
  }
  {{/each}}

  // 3. 注册标准插件
  pluginRegistry.registerPlugins(register);

  // 4. 实例化 Jupyter Application
  const lab = new JupyterLab({
    pluginRegistry,
    mimeExtensions,
    ...
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
  1. dev_mode/bootstrap.js 加载 index.out.js 完成标准插件的注册
void (async function bootstrap() {
  let main = (await import('./index.out.js')).main;
  window.addEventListener('load', main);
})();
1
2
3
4
  1. JupyterLab 实例化时,注册 MIME 插件
export class JupyterLab extends JupyterFrontEnd<ILabShell> {
  constructor(options: JupyterLab.IOptions = { shell: new LabShell() }) {
    if (options.mimeExtensions) {
      for (const plugin of createRendermimePlugins(options.mimeExtensions)) {
        this.registerPlugin(plugin);
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
# 使用插件

JupyterLab 又是怎么使用插件呢?我们以 @jupyterlab/markedparser-extension 渲染 text/markdown 类型的 output 为例进行说明。

  1. 首先 @jupyterlab/markedparser-extension (opens new window) 并不是单独使用,而是作为 @jupyterlab/rendermime-extension (opens new window) 的依赖, @jupyterlab/rendermime-extension (opens new window) 创建了 RenderMimeRegistry (opens new window) 实例,将 @jupyterlab/markedparser-extension (opens new window) 提供的服务作为 markdownParser 参数。
const plugin: JupyterFrontEndPlugin<IRenderMimeRegistry> = {
  id: '@jupyterlab/rendermime-extension:plugin',
  description: 'Provides the render mime registry.',
  optional: [
    IDocumentManager,
    ILatexTypesetter,
    ISanitizer,
    IMarkdownParser,
    ITranslator
  ],
  provides: IRenderMimeRegistry,
  activate: activate,
  autoStart: true
};

/**
 * Activate the rendermine plugin.
 */
function activate(
  app: JupyterFrontEnd,
  docManager: IDocumentManager | null,
  latexTypesetter: ILatexTypesetter | null,
  sanitizer: IRenderMime.ISanitizer | null,
  markdownParser: IMarkdownParser | null,
  translator: ITranslator | null
): RenderMimeRegistry {
  return new RenderMimeRegistry({
    initialFactories: standardRendererFactories,
    markdownParser: markdownParser ?? undefined,
    // ...
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  1. RenderMimeRegistry (opens new window) 添加 MIME 渲染映射
export class RenderMimeRegistry implements IRenderMimeRegistry {
  constructor(options: RenderMimeRegistry.IOptions = {}) {
    // Parse the options.
    this.translator = options.translator ?? nullTranslator;
    this.resolver = options.resolver ?? null;
    this.linkHandler = options.linkHandler ?? null;
    this.latexTypesetter = options.latexTypesetter ?? null;
    this.markdownParser = options.markdownParser ?? null;
    this.sanitizer = options.sanitizer ?? new Sanitizer();

    // Add the initial factories.
    if (options.initialFactories) {
      for (const factory of options.initialFactories) {
        this.addFactory(factory);
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

其中 standardRendererFactories 包含 text/markdown 类型的渲染工厂

export const markdownRendererFactory: IRenderMime.IRendererFactory = {
  safe: true,
  mimeTypes: ['text/markdown'],
  defaultRank: 60,
  createRenderer: options => new widgets.RenderedMarkdown(options)
};
1
2
3
4
5
6

最终得到一个映射:'text/markdown' => options => new widgets.RenderedMarkdown(options)

widgets.RenderedMarkdown(options) 对应 RenderedMarkdown

  1. 为 MIME 类型创建一个渲染器
function createRenderer(mimeType: string): IRenderMime.IRenderer {
    return this._factories[mimeType].createRenderer({
      mimeType,
      resolver: this.resolver,
      sanitizer: this.sanitizer,
      linkHandler: this.linkHandler,
      latexTypesetter: this.latexTypesetter,
      markdownParser: this.markdownParser,
      translator: this.translator
    });
}
1
2
3
4
5
6
7
8
9
10
11

它将创建 RenderedMarkdown 实例

export class RenderedMarkdown extends RenderedHTMLCommon {
  constructor(options: IRenderMime.IRendererOptions) {
    super(options);
    this.addClass('jp-RenderedMarkdown');
  }
  
  render(model: IRenderMime.IMimeModel): Promise<void> {
    return (this._rendered = renderers.renderMarkdown({
      host: this.node,
      source: String(model.data[this.mimeType]),
      trusted: model.trusted,
      resolver: this.resolver,
      sanitizer: this.sanitizer,
      linkHandler: this.linkHandler,
      shouldTypeset: this.isAttached,
      latexTypesetter: this.latexTypesetter,
      markdownParser: this.markdownParser,
      translator: this.translator
    }));
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 渲染代码输出的组件 OutputArea (opens new window) 通过 RenderMimeRegistry (opens new window) 创建 RenderedMarkdown 实例,然后通过 output.renderModel(model) 调用 RenderedMarkdownrender 方法
function createRenderedMimetype(model: IOutputModel): Widget | null {
  let output = this.rendermime.createRenderer(mimeType);
	output.renderModel(model)
  return output
} 
1
2
3
4
5
  1. RenderedMarkdownrender 方法调用 renderers.renderMarkdown,最终使用 @jupyterlab/markedparser-extension (opens new window) 提供的服务 markdownParser 渲染 markdown 内容。
export async function renderMarkdown(
  options: renderMarkdown.IRenderOptions
): Promise<void> {
  const { host, source, markdownParser, ...others } = options;

  let html = '';
  if (markdownParser) {
    // Separate math from normal markdown text.
    const parts = removeMath(source);

    // Convert the markdown to HTML.
    html = await markdownParser.render(parts['text']);

    // Replace math.
    html = replaceMath(html, parts['math']);
  } else {
    // Fallback if the application does not have any markdown parser.
    html = `<pre>${source}</pre>`;
  }

  // Render HTML.
  await renderHTML({
    host,
    source: html,
    ...others
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

至于 JupyterLab 什么时候将 @jupyterlab/rendermime-extension (opens new window) 创建了 RenderMimeRegistry (opens new window) 实例给 OutputArea (opens new window),那又是另外一个故事了。

# Jupyter Server

Jupyter Server (opens new window) 为 Jupyter Web 应用程序提供核心服务和 REST API (opens new window)。可以使用 Jupyter Server 提供的框架和服务来创建应用程序(比如 JupyterLab)和服务。

Jupyter 服务器采用基于 ZeroMQ (opens new window)双进程内核架构 (opens new window),并使用 Tornado (opens new window) 来处理 HTTP 请求。

# 安装

$ pip install jupyter_server
1

如果安装了 Anaconda (opens new window),那么已经安装了 jupyter_server

# 运行

$ jupyter server
1

然后就能看到服务地址,比如 http://localhost:8888/?token=xxx

# 配置文件

默认情况下,Jupyter Server 会在 Jupyter 路径下的 jupyter_server_config 文件中查找服务器特定的配置。

列出 Jupyter Server 将要查找的路径:

$ jupyter --paths

config:
    /Users/username/.jupyter
    /usr/local/etc/jupyter
    /etc/jupyter
data:
    /Users/username/Library/Jupyter
    /usr/local/share/jupyter
    /usr/share/jupyter
runtime:
    /Users/username/Library/Jupyter/runtime
1
2
3
4
5
6
7
8
9
10
11
12

config 目录下的路径按优先级顺序排列。如果同一个 trait 在多个位置列出,则会将其值设置为优先级最高的文件中的值。

Jupyter Server 使用 IPython 的 traitlets 系统进行配置。您可以快速在 .jupyter 目录下创建一个 jupyter_server_config.py 文件中,它将所有默认值注释掉:

$ jupyter server --generate-config
1

在 Python 文件中,这些特性将以 c.ServerApp 为前缀。例如,您的配置文件可能如下所示:

# inside a jupyter_server_config.py file.

c.ServerApp.port = 9999
1
2
3

相同的配置用 JSON 表示如下:

{
  "ServerApp": {
    "port": 9999
  }
}
1
2
3
4
5
# 命令行参数

您也可以使用 CLI 参数进行配置。每个参数前添加 --ServerApp 前缀,如下所示:

$ jupyter server --ServerApp.port=9999
1

# 架构

下面是 Jupyter Server 系统架构

  • ServerApp 是基于 Tornado (opens new window) 的应用程序,它将所有组件连接在一起。
  • Config Manager 初始化 ServerApp 的配置。
  • Custom Extensions 允许您创建自定义服务器的 REST API 端点。
  • Gateway Server 是一种网络服务器,一旦进行配置,它就能为运行在其他主机上的 Jupyter 核心提供访问权限。
  • Contents Manager and File Contents Manager 负责在文件系统上提供 Notebook 服务。
  • Session Manager 处理用户的会话。当用户启动新的内核时,Session Manager 会启动一个进程为该用户配置内核,并生成一个新的 Session ID。每个打开的 Notebook 都有一个独立的 Session,但不同的 Notebook 内核可以使用相同的 Session。如果用户希望在多个打开的 Notebook 之间共享数据,这一点非常有用。会话管理器使用 SQLite3 数据库来存储会话信息。默认情况下,该数据库存储在内存中,但也可以配置为保存到磁盘上。
  • Mapping Kernel Manager 负责管理服务器应用程序内运行的内核的生命周期。它为用户的 Session 启动新的内核,并支持对该内核的中断、重启和关闭操作。
  • Jupyter Client 被 Jupyter Server 用来与 Notebook 内核一起工作。
    • Kernel Manager 管理 Notebook 的单个内核。
    • Kernel Spec Manager 解析符合内核 JSON 规范的文件,并提供可用内核配置的列表。

更多详情请参考 Jupyter Server Architecture (opens new window)

# 创建 Session 工作流

当用户启动一个新内核时:

  1. Notebook Clients 向 Jupyter 服务器发送 POST /api/sessions (opens new window) 请求。此请求包含所有必要的数据,例如 Notebook 名称、类型、路径和内核名称等。
  2. Session Manager 根据输入数据向 Contents Manager 请求内核文件系统路径。
  3. Session Manager 将内核路径发送给 Mapping Kernel Manager
  4. Mapping Kernel Manager 使用 Multi Kernel ManagerKernel Manager 启动内核创建进程。
  5. Kernel Manager 使用 Provisioner Layer 启用一个新内核。
  6. Kernel Provisioner 负责根据内核规范启动内核。
  7. Kernel Spec Manager 从 JSON 文件中获取内核规范。该规范位于 kernel.json 文件中。
  8. 一旦 Kernel Provisioner 启动内核, Kernel ManagerSession Manager 生成新的内核 ID。
  9. Session Manager 将新的会话数据保存到 SQLite3 数据库(会话 ID、notebook 路径、notebook 名称、notebook 类型、内核 ID 等)。
  10. Notebook Clients 接收已创建的会话数据。

更多详情请参考 Create Session Workflow (opens new window)

# @jupyterlab/services

我们可以通过 Jupyter Server REST API (opens new window) 和 WebSocket 与 Jupyter Server 进行连接,执行代码,但是 JupyterLab 提供了一个 @jupyterlab/services (opens new window) 库,它封装了 Jupyter Server REST API (opens new window)、WebSocket、Jupyter Messaging Protocol (opens new window) 等实现细节,方便我们快速集成。

简便流程如下:

  1. 创建 ServerSettings
import { ServerConnection } from '@jupyterlab/services';

// 假设 jupyter server 地址是 http://localhost:8888/?token=xxx
// baseUrl 必须以 / 结尾
const serverSettings = ServerConnection.makeSettings({
  baseUrl: 'http://localhost:8888/',
  token: 'xxx',
  wsUrl: 'ws://localhost:8888/',
  appendToken: true,
});
1
2
3
4
5
6
7
8
9
10
  1. 启动 / 连接 Kernel
export async function createKernelSession(
  settings: ServerConnection.ISettings,
  kernelName: string = 'python3',
): Promise<Session.ISessionConnection> {
  // 创建 KernelManager
  const kernelManager = new KernelManager({ serverSettings: settings });

  // 创建 SessionManager
  const sessionManager = new SessionManager({
    serverSettings: settings,
    kernelManager,
  });

  // 创建新会话
  const session = await sessionManager.startNew({
    path: `notebook-${Date.now()}.ipynb`,
    type: 'notebook',
    name: '',
    kernel: {
      name: kernelName,
    },
  });

  return session;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  1. 执行代码
const future = kernel.requestExecute({ code });

// 实时接收输出
future.onIOPub = handleMessage;

// 执行完成
future.done.then(() => {
  console.log('execution finished');
});
1
2
3
4
5
6
7
8
9

# 实现

至此,我们分析了

  • Notebook 的文件结构
  • JupyterLab 的架构
  • JupyterLab 的 UI 框架、插件系统
  • Jupyter Server 的架构
  • 通过 @jupyterlab/services 连接 Jupyter Server,执行代码

因此,我们可以仿照 JupyterLab,使用 React 实现 Jupyter Notebook。

一个简单版的 JupyterLab 就完成了。

我们也可以进一步研究 JupyterLab 的源代码,实现更多功能,比如和 JupyterLab 一样使用插件系统。

# 完整代码

cp3hnu/jupyter-react (opens new window)

# References