在浏览器运行可交互Python代码

最近研究了一些在线运行代码应用,感觉颇为有趣,在此稍作总结,并尝试实现一种在浏览器运行可交互Python代码的方案。

<!--more-->

所谓“可交互Python代码”,指的是python中input等接受标准输入数据的API

下面列举了一些在线编辑器,可以体验一番

1. 将Python转换成JavaScript代码

由于Python也是解释型代码,因此可以通过解析AST的方式,通过JavaScript运行Python代码,常见的库有

  • brython,是一个Python在浏览器中运行的实现
  • skulpt,同上

相关API使用在文档中均有说明,本文不再赘述。

由于浏览器的限制,上面的这些库会缺少一些功能如文件操作等;此外如input方法,会通过window.prompt进行mock。

因此,直接在浏览器运行python存在一些问题

  • 需要引入额外的py to js库文件,且这些库或多或少缺少部分API的支持,不能100%还原python代码运行
  • 由于最后是运行JavaScript代码,代码运行错误需要转换才行

2. 服务端沙盒运行python

另外的一种方案是:在服务端启动一个代码执行环境,通过网络提交python代码,然后将结果返回给前端。

2.1. 基础方案

通过shelljs,我们可以在NodeJS中运行脚本命令

let shell = require('shelljs')
// 如果code是通过http传输的,就可以直接在服务端环境运行python代码
let code = `print('hello world')`
let res = shell.exec(`python3 -c "${code}"`)
// 通过res.stdout将输出返回给浏览器

这种方式看起来比较简单,甚至不需要引入额外的库文件,只需一个提供python运行环境的服务器即可。在实现中遇见的一个问题是:如何解决python中input的问题?

为了解决这个问题,我们先来了解一下标准输入和标准输出的知识

2.2. 标准输入

下面是nodejs标准输入示例代码,需要了解process.stdinprocess.stdout模块

process.stdin.resume();
process.stdin.setEncoding('utf-8');

var arr = [];
process.stdin.on('data', function (data) {
    var number = data.slice(0, -1);
    if (number == 'end') {
        process.stdin.emit('end');
    } else {
        arr.push(number);
    }
});
process.stdin.on('end', function () {
    console.log(arr);
});

// process.stdin.emit('data', '1 ') // 向标准输入写入
// process.stdin.emit('data', 'end ')

2.3. 操作标准输入的例子

读取外界输入

在nodejs中可以直接使用process.argv获取命令行参数。在线刷题时,需要从控制台读取输入,可以使用readline模块,参考Nodejs 按行读取控制台输入(stdin)的几种方法

脚本自动登录

参考:nodejs中如何知道子进程正在等待输入,并向其输入数据

假设一个命令行工具login需要通过交互的方式依次输入username、password,如何编写一个自动化脚本auto-login将参数直接传递给login呢?

原本的登录工具login

// login.js
const readline = require('readline')

function createInput(msg) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  })
  return new Promise(function(resolve, reject) {
    rl.question(`请输入${msg}: `, data => {
      rl.close()
      resolve(data)
    })
  })
}

Promise.resolve()
  .then(() => {
    return createInput('用户名').then(username => ({ username }))
  })
  .then(userInfo => {
    return createInput('密码').then(password => ({
      ...userInfo,
      password,
    }))
  })
  .then(userInfo => {
    return createInput('邮箱').then(email => ({
      ...userInfo,
      email,
    }))
  })
  .then(userInfo => {
    console.log(userInfo)
    process.exit(0)
  })

自动登录auto-login,主要实现是获取子进程subProcess的实例,然后通过subProcess.stdin.write的方式传回数据

const fs = require('fs');

const { spawn } = require('child_process');

var subProcess = spawn('node', ['login.js'], { cmd: __dirname });
subProcess.on('error', function() {
    console.log('error');
    console.log(arguments);
});
subProcess.on('close', code => {
    if (code != 0) {
        console.log(`子进程退出码:${code}`);
    } else {
        console.log('登录成功');
    }
    process.stdin.end();
});
subProcess.stdin.on('end', () => {
    process.stdout.write('end');
});

let getNextInput = (()=>{
    let cursor = 0
    var config = {username:'txm',password:'123',email:'xx@123.com'}
    let keys = Object.keys(config)

    return ()=>{
        return config[keys[cursor++]]
    }
})()

subProcess.stdout.on('data', onData);
subProcess.stderr.on('data', onData);
function onData(data) {
    process.stdout.write('# ' + data);
    let answer = getNextInput()
    subProcess.stdin.write(answer + '\n');

    // 如果需要手动输入,则可以将父进程的输入重定向到子进程
    // process.stdin.on('data', input => {
    //     input = input.toString().trim();
    //     subProcess.stdin.write(input + '\n');
    // });
}

2.4. 解决input的问题

上面login的node脚本可以使用python编写,大致如下

# 一个展示命令行交互的代码
username = input('input username:')
password = input('input password:')
email = input('input email:')

print('username:%s, password: %s, email:%s' % (username, password, email))

# exit(0)
# 突然想起了“人生苦短,我用python”这句话

片头的问题可以修改为:如何通过其他程序,向一个等待标准输入的python程序写入数据?

想象一下整个流程

  • 在浏览器编写python代码
  • 通过http协议发送到服务端,服务端运行python脚本,执行到input时,等到输入
  • 服务端通知浏览器,提示用户输入,并将输入传回服务端
  • 服务端将用户输入透传给给正在等待输入的python程序,程序继续执行,如此往复
  • python程序执行完毕,服务端将输出响应返回浏览器,用户看见执行结果,运行完毕

可以对于整个过程,存在浏览器和服务端的多次通信,可以想到使用websocket来进行实现,在父子进程通信的各个时机进行socket消息的发送。

服务端代码实现

// server
socket.on("disconnect", function() {
    console.log("user disconnected");
});

// 运行传回的代码
let subProcess;
socket.on("run code", function(msg) {
    subProcess = runPython(socket, msg);
});

socket.on("code input", function(msg) {
    subProcess.stdin.write(msg + "\n");
});

function runPython(socket, code) {
    let fileName = "tmp.py"; // 可以换成随机文件名避免重复
    fs.writeFileSync(fileName, code, "utf8");

    let subProcess = spawn("python3", [fileName], { cmd: __dirname });

    let isClose = false;
    // 监听子进程是否运行完毕
    subProcess.on("close", code => {
        isClose = true;
        console.log(code === 0 ? "登录成功" : `子进程退出码:${code}`);
        subProcess.stdout.off("data", onData);
        subProcess.stderr.off("data", onData);
    });

    subProcess.stdout.on("data", onData);
    subProcess.stderr.on("data", onData);

    process.stdin.on("data", input => {
        input = input.toString().trim();
        if (!isClose) {
            subProcess.stdin.write(input + "\n");
        }
    });

    function onData(data) {
        setTimeout(() => {
            if (isClose) {
                socket.emit("code response", data.toString());
            } else {
                socket.emit("stdout", data.toString());
            }
        }, 20);
    }
    return subProcess;
}

客户端代码实现

let socket = io();

function createStdout(msg) {
    let li = document.createElement("li");
    li.innerHTML = `<li> >>> ${msg}:<input class="stdin"/></li>`;
    list.appendChild(li);
}
function createResponse(msg) {
    let li = document.createElement("li");
    li.innerHTML = `<li> >>> ${msg}`;
    list.appendChild(li);
}
// 注册响应
socket.on("stdout", function(msg) {
    createStdout(msg);
});
socket.on("code response", function(msg) {
    createResponse(msg);
});

// 注册事件
btn.onclick = function send() {
    let code = content.value;
    socket.emit("run code", code);
};

function codeInput(e) {
    let target = e.target;
    let val = target.value;
    val && socket.emit("code input", val);
}

list.onclick = function(e) {
    let target = e.target;
    if (target.classList.contains("stdin")) {
        target.removeEventListener("blur", codeInput);
        target.addEventListener("blur", codeInput);
    }
};

至此,就实现了一个可交互的在线python运行工具,完整代码已放在github上了。

3. 小结

本文主要实现了一种在浏览器运行可交互python代码的方案,主要原理是借助服务器环境运行代码,并通过websocket传递标准输入与标准输入。

此外还存在一些未解决的问题

  • 代码注入带来的安全问题,由于代码实际是在真实服务环境下运行,我们必须考虑相关的权限和安全问题
  • 进程新建、切换带来的性能问题,以及用户长时间不输入时导致进程一直无法退出等场景

接下来会研究使用Docker构建运行沙盒来解决上述问题,后会有期。