最近研究了一些在线运行代码应用,感觉颇为有趣,在此稍作总结,并尝试实现一种在浏览器运行可交互Python代码的方案。
所谓“可交互Python代码”,指的是python中input等接受标准输入数据的API
下面列举了一些在线编辑器,可以体验一番
1. 将Python转换成JavaScript代码
由于Python也是解释型代码,因此可以通过解析AST的方式,通过JavaScript运行Python代码,常见的库有
brython,是一个Python在浏览器中运行的实现
相关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.stdin和process.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)的几种方法
脚本自动登录
假设一个命令行工具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 = `
>>> ${msg}:`;
list.appendChild(li);
}
function createResponse(msg){
let li = document.createElement("li");
li.innerHTML = `
>>> ${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构建运行沙盒来解决上述问题,后会有期。