前言

对「浏览器中的编程环境」感兴趣已久, 兴趣的由来与以下几个话题有关:

  • 英荔教育在探索「对初学者友好的 Python 编程环境」
  • Lively 让我相信「浏览器是新的操作系统」(我近期准备就这个话题写一篇文章)
  • David Smith 在浏览器中重新实施 Open Croquet(浏览器中的新操作系统,包含 smalltalk78)
  • John Maloney 的 GPblocks 和 microblocks 编译成 WebAssembly 之后,运行在浏览器里

本文与第一个话题关系最大: 「对初学者友好的 Python 编程环境」。后几个话题,我之后争取写一篇「浏览器是新的操作系统」来讨论它们。

可以将 「浏览器中的 Python」 看做 「浏览器中的编程环境」的一个特例。我关注在 javascript 中实现的编程语言,如何榨取浏览器的能力。

Python 编程环境

谈到对初学者友好,我首先想到的 Python 编程环境是 mu-editor

此外,jupyterlab也相当出色(我日常主要使用它)。由于 notebook 文件包含代码之外的丰富信息(代码运行结果、markdown 记录的想法…),所以它相当适合教育。我使用 CodeLab Adapter 内置的 jupyterlab 在实施了一个学期的 《Python 基础入门》 课程,体验基本符合预期。

mu-editor 和 CodeLab Adapter(jupyterlab) 都是本地软件。 但在教育中,有许多场景会期待在线编程环境。

我使用「在线编程环境」指那些 无需本地下载/安装软件,打开浏览器立即可用的编程环境。

Python 在线编程环境

我特别喜欢的 Python 在线编程环境有:

这几个项目都做到了

无需本地下载/安装软件,打开浏览器立即可用的编程环境。

但它们的做法各有不同,这种差异非常具有代表性。

这些项目可以分成 2 个阵营:

Google CoLab 、replit 一个阵营。jupyterlite、brython 一个阵营

阵营 1: Google CoLab 、replit

阵营 1 是这样工作的: 浏览器中运行着代码编辑器(也处理输入/输出),Python 解释器(实际运行 Python 代码)则运行在云端服务器上。用户在浏览器里编程,每次运行代码,代码被实时传送到云端的服务器,然后把运行后的结果返回到浏览器里显示出来。

在阵营 1 里,Python 解释器并不运行在浏览器中,通常是靠运行在服务器上的 CPython 来实际运行代码(我们通常所说的 Python),mu-editor 和 CodeLab Adapter 中的 Python 也是这种。

值得注意的是,使用阵营 1 的服务,每个用户在编程时,都会占用一定的服务器资源。由于用户的代码运行在服务器里,可能涉及资源的分配和安全等问题,这类系统一般比较复杂(通常会用到服务器集群)

阵营 2: jupyterlite、brython

阵营 2 和 阵营 1 的最大不同在于,Python 也运行在浏览器里 ,这意味着没有后端服务,不需要数据库之类的东西,部署 jupyterlite、brython 就跟部署一个静态网页一样。

对项目实施者而言,这带来许多好处:

  • 项目易于维护、部署和管理(因为没有数据库之类的东西)
  • 随着用户增长,没有扩展压力(稳定可靠、低成本)
  • 方便嵌入到任何地方(诸如 lms)
  • 可以轻松定制

阵营 2 的大多数好处都与它是 纯前端项目 有关。

jupyterlite 与 brython 的差异

本文接下来会重点关注 brython,因为它包含了我当前感兴趣的东西。

在深入讨论 brython 之前,我们先简单讨论下 jupyterlite 与 brython 的区别。

由于 jupyterlite 基于 Pyodide 实现,所以我们实质是在讨论 Pyodide 与 brython 的区别。

Pyodide 基于 WebAssembly。 通过把 CPython 编译到 WebAssembly,进而允许 CPython 运行在浏览器里。

brython 是在 javascript 中实现 Python。所有的 Python 对象实际都是 javascript 对象。这带来非常有趣的特性,理论上我们在 Python 里可以使用 javascript 的所有能力。(SqueakJS 有一部分想法与之类似)

brython

关于 brython 的细节,在此不复述,它的文档写得很好,有丰富的实例代码。

由于 brython 可以使用 javascript 的所有能力,所以你可以使用 brython 来写前端页面!

由于我最初的兴趣来自:

英荔教育在探索「对初学者友好的 Python 编程环境」

所以我打算从教育视角来探索 brython。

以下是英荔教育关注的编程场景:

  • turtle
  • 可编程空间
  • intelino 小火车/可编程书包

以下是我在 Brython 中对这些事物进行编程的例子

例子

以下例子可以在官方 editorCodeLab editor中运行(除了 turtle).

我基本在 CodeLab editor 里编程。

turtle

只能运行在 CodeLab editor 里。运行的结果显示在页面下方(你可能要往下拖动页面)

 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
import turtle

t = turtle.Turtle()

t.width(5)

for c in ['red', '#00ff00', '#fa0', 'rgb(0,0,200)']:
    t.color(c)
    t.forward(100)
    t.left(90)

# dot() and write() do not require the pen to be down
t.penup()
t.goto(-30, -100)
t.dot(40, 'rgba(255, 0, 0, 0.5')
t.goto(30, -100)
t.dot(40, 'rgba(0, 255, 0, 0.5')
t.goto(0, -70)
t.dot(40, 'rgba(0, 0, 255, 0.5')

t.goto(0, 125)
t.color('purple')
t.write("I love Brython!", font=("Arial", 20, "normal"))

turtle.done()

值得注意的是目前云端的 Python 环境,大多数都无法支持 turtle(因为 CPython 中的 turtle 是 tkinter/tcl 写的)

可编程空间

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from browser import document, ajax
import json

def set_properties(thing_id, prop, data):
    THINGTALK_HOST = 'xxx'
    TOKEN = 'xxx'  # 可通过观察管理平台的请求数据获得
    url = f"https://THINGTALK_HOST/things/{thing_id}/properties/{prop}"
    req = ajax.Ajax()
    req.bind('complete', lambda x: print(x.text))
    req.open('PUT', url, True)
    req.set_header('Authorization', f"Bearer {token}")
    req.send(json.dumps(data))

# device_id
device_id = 'xxx'
set_properties(device_id,'state', data={"state":"ON"})

可编程书包

目前正在量产中

仅支持 Chrome/Edge 浏览器

 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
import random
import browser
from javascript import Uint8Array

COMMAND_CHARACTERISTIC = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
the_service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"

global writeCharacteristic


def handle_characteristic(characteristic):
    global writeCharacteristic
    writeCharacteristic = characteristic
    return writeCharacteristic

def test(*args):
    global writeCharacteristic
    # encoder = TextEncoder.new('utf-8');
    # cmd = encoder.encode('$1 0;');
    # https://www.rapidtables.com/convert/number/ascii-to-hex.html
    # $1 0; -> hex 24 31 20 30 3B
    # $1 1; -> hex 24 31 20 31 3B
    # cmd = Uint8Array.new([0x24, 0x31, 0x20, 0x30, 0x3b])
    # data = '$1 0;'
    data = '$1 1;'
    data_hex = []
    for i in data:
        data_hex.append(hex(ord(i)))
    cmd = Uint8Array.new(data_hex)
    writeCharacteristic.writeValue(cmd)

# 参考 https://web.dev/bluetooth/
# 推荐使用 `chrome://bluetooth-internals` 来调试和探索
browser.window.navigator.bluetooth.requestDevice({
                'filters': [{
                  # 'namePrefix': 'Wifi'
                  'services':[the_service]
                }],
                'optionalServices':[the_service]

              }).then(
                  lambda device: device.gatt.connect()
                  ).then(
                  lambda server: server.getPrimaryService(the_service)
                  ).then(
                  lambda service: service.getCharacteristic(COMMAND_CHARACTERISTIC)
                  ).then(handle_characteristic).then(test)

intelino 小火车

仅支持 Chrome/Edge 浏览器

 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
import random
import browser
from javascript import Uint8Array

COMMAND_CHARACTERISTIC = "40c540d0-344c-4d0d-a1da-9cc260b82d43"
the_service = "43dfd9e9-17e5-4860-803d-9df8999b0d7a"

global writeCharacteristic


def handle_characteristic(characteristic):
    global writeCharacteristic
    writeCharacteristic = characteristic
    return writeCharacteristic

def random_change_color(*args):
    global writeCharacteristic
    color = [random.randint(0, 1) * 0xFF for i in range(3)]
    cmd = Uint8Array.new([0xB4, 0x07, 0x06, *color, *color])
    # 前进
    # cmd = Uint8Array.new([0xB8, 0x03, 0x01, 0x02, 0x01])
    writeCharacteristic.writeValue(cmd)

# 参考 https://web.dev/bluetooth/
# 推荐使用 `chrome://bluetooth-internals` 来调试和探索
browser.window.navigator.bluetooth.requestDevice({
                'filters': [{
                  'namePrefix': 'intelino'
                }],
                'optionalServices':[the_service]

              }).then(
                  lambda device: device.gatt.connect()
                  ).then(
                  lambda server: server.getPrimaryService(the_service)
                  ).then(
                  lambda service: service.getCharacteristic(COMMAND_CHARACTERISTIC)
                  ).then(handle_characteristic).then(random_change_color)

更多蓝牙相关 api 参考intelino-trainlib

browser.window.navigator.bluetooth.requestDevice 相关的代码看起来有点冗长,它的功能是连上设备。考虑到对初学者友好, 我们可能期望,连接设备的功能并不是由用户主动编写的,而是当点击连接按钮时,后台发生的。也就是说我们期待这部分代码不需要显示写出来,而是编程环境提供的功能(可能是个按钮)。 这完全没有问题,更有趣的是,要做到这些使用 Python 本身即可,因为在 Brython 里我们可以使用 Python 写页面逻辑!


如果想实现官方 demo 里的循环变色,需要处理同步问题(sleep),我使用 aio 来做(Python 中的异步编程, 参考文末注意事项)

 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
import random
import browser
from browser import aio
from javascript import Uint8Array

COMMAND_CHARACTERISTIC = "40c540d0-344c-4d0d-a1da-9cc260b82d43"
the_service = "43dfd9e9-17e5-4860-803d-9df8999b0d7a"

global writeCharacteristic

def handle_characteristic(characteristic):
    global writeCharacteristic
    writeCharacteristic = characteristic
    return writeCharacteristic

async def go(*args):
    global writeCharacteristic
    for i in range(10):
      print(i)
      await aio.sleep(1)
      color = [random.randint(0, 1) * 0xFF for i in range(3)]
      cmd = Uint8Array.new([0xB4, 0x07, 0x06, *color, *color])
      # cmd = Uint8Array.new([0xB8, 0x03, 0x01, 0x02, 0x01])
      writeCharacteristic.writeValue(cmd)

def main(*args):
    aio.run(go())

browser.window.navigator.bluetooth.requestDevice({
                'filters': [{
                  'namePrefix': 'intelino'
                }],
                'optionalServices':[the_service]
              }).then(
                  lambda device: device.gatt.connect()
                  ).then(
                  lambda server: server.getPrimaryService(the_service)
                  ).then(
                  lambda service: service.getCharacteristic(COMMAND_CHARACTERISTIC)
                  ).then(handle_characteristic).then(main)

注意事项

time.sleep

Brython 不支持 time.sleep 这种阻塞式的代码。 背后的原因与 javascript 的 I/O 编程模型有关: 异步非阻塞。

由于 Brython 基于 javascript 实现,所以也受到影响,你有两种方式做time.sleep类似的事情:

需要注意的是,在 Brython 写阻塞式程序是困境的,诸如 aio.run 也是非阻塞的!

关于 Pyodide

Pyodide 的用例目前主要是数据分析和科学计算。这个项目的前景非常诱人,但目前还比较早期,处于研究状态,一些问题比较突出,诸如启动和加载需要 10 秒左右时间。

如果时机合适,我们未来可能会从 brython 切换到 Pyodide。