基于Docker容器和PHP技术的简易多人在线编程平台(云IDE)的实现
前言: 这篇文章将讲述如何利用Docker云计算框架和PHP技术在Linux环境下实现一个多人在线编程环境,同时保证服务器安全。我把它叫做云IDE。可能没有桌面级IDE的全部功能,只有简单的多语言编程,运行,下载代码功能,虽然现在这种平台在网络上还是不少的,不乏包括百度、华为这些大厂。不过当前有叙述这一实现思路的文献并不多(国内),中国知网上泛眼过去几乎看不到影子,我看到的一篇是《CodeR
前言:
这篇文章将讲述如何利用Docker云计算框架和PHP技术在Linux环境下实现一个多人在线编程环境,同时保证服务器安全。我把它叫做云IDE。可能没有桌面级IDE的全部功能,只有简单的多语言编程,运行,下载代码功能,虽然现在这种平台在网络上还是不少的,不乏包括百度、华为这些大厂。不过当前有叙述这一实现思路的文献并不多(国内),中国知网上泛眼过去几乎看不到影子,我看到的一篇是《CodeRun_浏览器里的云端编程开发IDE》,结果下载下来发现就是一段腾讯科技的新闻。或许这篇文章更适合作为一篇学术论文发表,但是嘛,我最近刚考完研,有一些时间,想做一些学术研究,又不是很想依照论文的格式死死地来,主要是没有啥参考文献吧,纯个人创造,还请大家多多包含。
主要用到的技术有:Dockerfile文件编写、PHP编程、JQuery Ajax异步交互、Linux Shell编程
一、背景
随着云计算和大数据时代的到来,越来越多的服务被集成至云端,这其中不乏编程领域。云计算的宗旨是PaaS,Platform as a Service,即平台即服务,用一个浏览器或手持设备就能完成所有的事情。在传统编程领域,我们都是在本地安装编译程序,而在云时代,这些都可以迁往云端,利用容器虚拟化技术加上现在流行的HTML5前端设计语言可以打造类似本地编译环境的效果。目前,一些平台已经做到了在线使用工程并且debug的功能。鉴于网上缺乏这类平台的实现思路,于是我就利用所学知识,打造了这个简易的云端IDE平台,希望能给各位的深入研究提供一个思路。
二、实现思路
整体的实现思路还是非常直接的。我们可以为每一个用户建立一个私有的文件夹,文件夹中存放用户代码,和用户指定的输入数据,以及程序的运行结果数据。然后使用PHP语言调用docker容器,可以在执行参数中对容器的运行环境进行限制,通过管道流进而在容器中运行用户代码。这一切的数据传输都将由Ajax异步刷新技术实现,用户不必刷新页面。在服务器上,有一个专门的守护进程,来保证用户写了死循环程序,又关闭浏览器导致服务器资源一直被消耗的情况。思路的大致框架如下:
三、实现过程
1、Docker镜像的获取
有两种方式,一种我们可以去DockerHub上去找,但是因为天朝,于是就有了另外的一种方式,就是从国内的镜像仓库,比如上图中的DaoCloud。有一个镜像是一定要的,就是Linux系统镜像,我用的是Ubuntu,你也可以拖Cent OS。剩下的就根据你想让系统实现什么样的语言编程。我这里只做了C/C++和Java,于是我需Java的镜像就好了,GCC的我是基于Ubuntu构建的,当然也可以使用官方。如果以后想实现现在比较火的Python编程的话,只需后面再拖Python的镜像就行了。注意认准offical标识哦,其他的可能是有做过某些方面的修改,我们希望用的比较纯净的官方镜像。下图以Ubuntu镜像为例。
2、镜像的编辑之一
有的镜像不是一拖下来就能用的,比如GCC,这时我们就要使对原始镜像进行改造。Docker提供了两种方式构建新的镜像,一种是使用commit命令,另外一种是DockerFile。下面对两种方式都做一个简单介绍。
2.1 先介绍一下DocketFile,以及我们要用到的一些指令,如果想更深入了解,建议参阅官方文档 。
关于DockerFile,官方的说明是:
Docker can build images automatically by reading the instructions from a Dockerfile. A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image. Using docker build users can create an automated build that executes several command-line instructions in succession.
译文:Docker能够通过从Dockerfile中读取指令来自动地构建镜像。一个Dockerfile是一个包括所有能够组成一个镜像的,用户能够调用的命令行文本文件。使用docker build命令,用户能够创建一个自动化的镜像,这个镜像通过成功执行一些命令行指令来构建。
2.1.2 DockerFile语法,这里就提我们要用的
2.1.2.1 escape指令:用于表示换行,如果不具体话的话,默认是【\】,在Linux下影响到不是很大,如果是Windows,要设置为【`】。
2.1.2.2 From指令:用于标识构建新镜像的基础镜像。
2.1.2.3 WORKDIR指令:对RUN、CMD、ENTRYPOINT,COPY和ADD指令设置工作目录,如果我们设置的工作目录不存在的话,即使在后面的指令中没有用到,它也会被创建。
2.1.2.4 CMD指令:官方文档显示这个指令有3种使用方法,我们使用
CMD command param1 param2 (shell form)
这种方式。
CMD指令的主要目的是在容器运行的时候给予其一个默认的操作。在一个Dockerfile中,CMD指令只能有一个,如果有多个,只有最后一个指令会被执行。
2.2 接下来是commit指令,这个命令用于从一个容器来构建新镜像。具体示例可参阅官方文档
2.2.1 语法
docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]]
2.2.2 简单例子(来自官方,我们用到的就是这种,官方还有其他的)这个例子是从ID为c3f279d17e0a的镜像构建新的
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c3f279d17e0a ubuntu:12.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
197387f1b436 ubuntu:12.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
$ docker commit c3f279d17e0a svendowideit/testimage:version3
f5283438590d
3、镜像的编辑之二
a、我们先进入拖下来的Ubuntu镜像,记得进入控制台交互模式,就是在docker run的时候加参数-t -i。t即terminal,i即interaction,我是这么理解的。
b、运行apt-get update更新源,然后运行apt-get install gcc,安装gcc编译器。
c、安装完gcc后退出容器,使用docker ps -a指令,找到刚才刚退出的那个容器(从Status栏中的时间可推知)
d、使用命令docker commit 06a8d7269e27 XXX指令,构建新镜像,06a8d7269e27为容器ID,XXX为新镜像名,注意要全部小写。
经过以上4步,接下来就是Dockerfile登场了,我的Dockerfile是这样的。
#escape\
FROM 265d2046fe63
WORKDIR /tmp
CMD ./myappp
做个说明:escape \ 指明分隔符
FROM 265d2046fe63 就是我们前面基于容器构建的新镜像的ID
WORKDIR /tmp 设置容器启动时的工作目录为/tmp
CMD ./myappp 容器启动时,执行./myapp指令,其实就是运行用户编写的程序,程序实体将通过映射的方式传入容器中
编写完Dockerfile后,使用docker build -t gccrun .命令构建tag为gccrun的镜像。不要忘记了后面的那个小【.】,这个句点表示的是上下文,如果为句点的话,就表示以当前目录为构建镜像的上下文,上下文中要包含构建新镜像所用到的所有文件,所以建议在一个新的空目录中执行指令,不要是用【/】,在Linux中,/代表根目录,如果这样的话,将会导致整个硬盘的内容作为上下文传输给docker守护进程。
这里创建的是运行时要用的镜像,编译的话使用将Dockerfile的CMD改为编译的命令就行,然后构建一个叫gcccom的镜像。
4、网站服务器用户权限的设置
要先查询网页服务器所对应用户的权限,适当提升,因为我们需要进行一些系统级操作,比如读写文件。我用的是Apache,进入apache2.conf中找到${APACHE_RUN_USER}以及${APACHE_RUN_GROUP},这显然是两个引入变量名,注意上面的蓝字,发现这些变量名定义在/etc/apache2/envvars中
跟踪,发现Apache执行的用户名为www-data,所在用户组为www-data,然后要将这个用户具有执行sudo的权限,具体可见这篇文章http://blog.csdn.net/mgsky1/article/details/79059400
5、PHP编程部分之一
第4步的作用是能够让PHP执行一些Shell命令,要调用system函数,还有操作文件的fopen函数,如果不设置的话,将会导致权限不足提示Permission Denied。system之类的函数属于比较“危险”的函数,默认PHP并不开放,记得去php.ini中进行修改。创建compiler.php文件
5.1 根据语言做好初始化操作,以Java为例,代码中的$random为随机数,下同,每一个用户都有自己的文件夹
if($lan == "java")
{
$myfile = fopen("$random/Main.java", "w") or die("Unable to open file!");//创建Main.java文件
fwrite($myfile, $code);//将用户代码写入,$code为提交表单后POST来的代码
fclose($myfile);//关闭文件
$type = "class";//设置类型,用于运行完毕后进行清理
$comCMD ="sudo docker run --rm -v '/var/www/html/cloud/$random':/usr/src/myapp -w /usr/src/myapp daocloud.io/java:7 javac Main.java 2>$random/com.txt";//编译指令,运用管道流进行输出重定向
$runCMD = "sudo docker run --rm --name $random -i -v '/var/www/html/cloud/$random':/usr/src/myapp -w /usr/src/myapp daocloud.io/java:7 java Main <$random/in.txt >$random/out.txt 2>&1";//运行指令,运用管道流进行输入输出重定向
}
5.2 在容器进行编译,若编译失败清理文件
system($comCMD,$status);//$status保存命令的返回值,在这里,就是执行是否成功
$myfile = fopen("$random/com.txt", "r") or die("Unable to open file!");//读取com.txt文件
$error = @fread($myfile,filesize("$random/com.txt"));//将文件内容读入至$error
if($error != "" )
{
if($lan == "gcc" && strstr($error,"error") == true)//判断是否编译错误
{
echo $error;//显示编译错误信息
fclose($myfile);
system("rm -r /var/www/html/cloud/$random");//删除文件夹
return;
}
else if($lan != "gcc")
{
echo $error;
fclose($myfile);
system("rm -r /var/www/html/cloud/$random");//删除文件夹
return;
}
}
5.3 如果编译成功则在容器中运行,运行完成后执行清理
$myfile = fopen("$random/in.txt", "w") or die("Unable to open file!");
fwrite($myfile, $input);
fclose($myfile);
system($runCMD,$status);
$myfile = fopen("$random/out.txt", "r") or die("Unable to open file!");
$info = @fread($myfile,filesize("$random/out.txt"));
if($info != "")
{
echo $info;//显示运行结果
fclose($myfile);
system("find . -name '*.".$type."' | xargs rm -rf");//根据$type变量,寻找杂项文件(比如java的.class文件)并清除
system("rm -r /var/www/html/cloud/$random");//删除文件夹
return;
}
system("rm -r /var/www/html/cloud/$random");//删除文件夹
6、PHP编程部分之二
第5步是正常的程序执行步骤,如果用户想中途停止呢?(比如是一个死循环程序),我们就需要一个用于停止容器的代码。killContainer.php。这部分的内容就很简单了,在第5步中,我的容器名称就是那个随机数$random,所以,在这里直接把这个容器杀死就好
$cmd = "sudo docker kill ".$_GET['num'];//num为提交表单后获取的用户随机数
system($cmd);
7、前端JQuery编程部分
云IDE在浏览器上主要通过JQuery脚本来进行控制,使用Ajax异步交互与后台进行数据传输。比较核心的两个函数就是tj(提交)和zz(终止)
7.1 tj(提交函数),分两个部分,第一个是计时,第二个是Ajax
7.1.1 计时部分
这一部分的功能就是在预定的时间(我设定是5秒)内将运行按钮变灰,即不可点击,5秒后恢复可点击状态,这样做的缘由是给予程序一定的运行时间,避免创建重复容器导致运行失败。核心就是setInterval 和 clearInterval 这两个函数。
var intvalID = setInterval(function()
{
count++;
$('#startRun').text("等待("+count+")");//告知用户等待秒数
if(count == 5)//count用于计时,初始值为0
{
$('#startRun').removeAttr("disabled");//使运行按钮可点击
$('#startRun').text("运行");//修改运行按钮的内容为"运行"
count = 0;
clearInterval(intvalID);
}
}
,1000);//每过1s执行function()
7.1.2 Ajax异步交互部分
这里就是与后台服务器交互的核心了,使用Ajax避免用户刷新页面,提升用户体验。Jquery Ajax语法点这里 。这里借助了setTimeout函数来延时执行zz(终止)函数,zz(终止)函数的具体内容见下节。
$flag = 0;//用于标记是否运行成功,如果5秒后依然是0,则有可能是死循环,执行强制终止
$.ajax({
cache: true,
type: 'POST',
url:'compiler.php',
data:$('#myForm').serialize(),// 你的formid
async: true,
error: function(request) { //如遇网络问题导致连接失败,弹出提醒
alert("Connection error");
},
success: function(data) { //如果执行成功,将回传数据显示在屏幕上
$flag = 1;
$("#output").html(data);
$("#running").html('');
$('#stopRun').attr("disabled","true");//将停止按钮设置为不可点击
}
});
setTimeout(function () {//若5秒后依然运行未完成,强制终止
if($flag == 0)
{
zz();
$('#stopRun').attr("disabled","true");
}
}, 5000);
7.2 zz(终止)函数,这里就直接一个Ajax异步
function zz() {
$("#running").html('<img src="img/loading.gif" style="width:20px"/>终止中');
var id = $('#random').val();
$.ajax({
cache: true,
type: 'GET',
url:'killContainer.php?num='+id,//所以上面提及的PHP脚本中使用GET方法,与提交用的POST不同,POST可以传输比较大的数据,GET限制1024字节,POST理论上没有限制
async: true,
error: function(request) {
alert("Connection error");
},
success: function(data) {//成功终止后,清除屏幕多余数据
$("#output").html('');
$("#running").html('');
$("#stop").html('已终止程序');
}
});
}
8、Linux Shell编程部分之一
第7步是保障服务器安全的第一步,在前端保障,但是有可能出现用户提交了一段死循环程序,然后关闭了浏览器,这时Jquery就不起作用了,而这个死循环程序依然在消耗服务器资源,所以,在服务器上也必须有措施做好“最后一道防线”。这里,我将把一个定时清理容器的.sh脚本注册为Linux系统服务,在后台静默运行。
先上代码吧:daemon.sh
#!/bin/bash
while echo "Begin New Round"
do
sleep 0.5m;
sudo docker ps | grep grun | awk '{print $1}' | xargs docker rm -f;
sudo docker ps | grep java | awk '{print $1}' | xargs docker rm -f;
echo "End This Round";
done
看上去似乎非常简单,思路和是很清晰的,一个死循环,每隔半分钟清理名为grun和java的容器(注:grun是C/C++运行的容器,编译期间应该是不会造成死循环的)。但是那两个sudo执行的命令就展现了Linux Shell命令的精简和强大文字处理能力。
第一个是【|】,这个是管道符号,也就是说,将符号左边命令的输出作为符号右边命令的输入
第二个是【grep】,这个是Linux的文本查询命令,全称是(global search regular expression(RE) and print out the line,全面搜索正则表达式并把行打印出来),支持正则,但是我们这边用到的是精确查找,因为我们是根据镜像名定位,镜像名又是我们定死的。例子:
$ sudo
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
956165ee5c24 ubuntu "/bin/bash" 9 hours ago Up 9 hours 0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp adoring_jang
d56f8e4b698f ubuntu "/bin/bash" 4 months ago Up 10 hours 0.0.0.0:8085->80/tcp silly_yalow
629978e3bdb9 registry "/entrypoint.sh /e..." 4 months ago Up 10 hours 0.0.0.0:5000->5000/tcp suspicious_jang
$ sudo docker ps | grep ubuntu 956165ee5c24
ubuntu
"/bin/bash" 10 hours ago Up 10 hours 0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp adoring_jang d56f8e4b698f
ubuntu
"/bin/bash" 4 months ago Up 11 hours 0.0.0.0:8085->80/tcp silly_yalow
可以发现返回的是匹配行,想详细了解这个命令,点这里。
第三个是【awk】,awk是一种编程语言,用于在linux/unix下对文本和数据进行处理。数据可以来自标准输入(stdin)、一个或多个文件,或其它命令的输出。脚本通常是攘括在单引号或者双引号中。用{ }包裹。它先读入有'\n'换行符分割的一条记录,然后将记录按指定的域分隔符划分域,填充域,$0则表示所有域,$1表示第一个域,$n表示第n个域。默认域分隔符是"空白键" 或 "[tab]键"。下面举一个简单的例子,就可以看到$0,$1有什么区别。
$ sudo
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
956165ee5c24 ubuntu "/bin/bash" 9 hours ago Up 9 hours 0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp adoring_jang
d56f8e4b698f ubuntu "/bin/bash" 4 months ago Up 10 hours 0.0.0.0:8085->80/tcp silly_yalow
629978e3bdb9 registry "/entrypoint.sh /e..." 4 months ago Up 10 hours 0.0.0.0:5000->5000/tcp suspicious_jang
然后我调用命令sudo docker ps | grep ubuntu | awk '{print $0}'
$ sudo docker ps | grep ubuntu | awk '{print $0}'
956165ee5c24 ubuntu "/bin/bash" 9 hours ago Up 9 hours 0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp adoring_jang
d56f8e4b698f ubuntu "/bin/bash" 4 months ago Up 10 hours 0.0.0.0:8085->80/tcp silly_yalow
现在我改成
sudo docker ps | grep ubuntu | awk '{print $1}'
$ sudo docker ps | grep ubuntu | awk '{print $1}'
956165ee5c24
d56f8e4b698f
所以,按照其工作原理,按照空格或者TAB键分割,$0显示的是所有,$1显示的是第一列,在这里,有点类似表格。
想详细了解,点这里。
第四个是【xargs】,它擅长将标准输入数据或管道传来的数据转换成命令行参数。下面再举个例子,就用最简单的echo字符串,echo命令正常的顺序是echo "string",使用了xargs后,变为some command | xargs echo
$ echo "abc" | xargs echo
abc
想详细了解,点
这里。
以sudo docker ps | grep grun | awk '{print $1}' | xargs docker rm -f;为例,意思就是,将docker ps 的输出作为grep的输入,寻找与grun镜像名相匹配的行,然后打印第一列,也就是容器ID,最后通过xargs命令,将这些ID转换为docker rm -f XXX中的XXX(参数),对容器进行销毁。
9、Linux Shell编程部分之二
有了8的铺垫,我们还需要将这个sh脚本注册为系统服务。先上代码,这段代码是网络上搜索来进行修改的,在代码中使用我使用注释简单解释一下意思,本人水平有限,服务这方面还是第一次涉猎,查了一些资料,还请多多包涵。
#!/bin/bash
#description: hello.sh
#chkconfig: 2345 20 81 #优先级
#From Internet
EXEC_PATH=/var/www/html/cloud/Shell/ #shell文件存放路径
EXEC=daemon.sh #shell文件名
DAEMON=/var/www/html/cloud/Shell/daemon.sh
PID_FILE=/var/run/daemon.sh.pid
. /etc/rc.d/init.d/functions
#判断Shell文件是否存在
if [ ! -x $EXEC_PATH/$EXEC ] ; then
echo "ERROR: $DAEMON not found"
exit 1
fi
#关闭服务
stop()
{
echo "Stoping $EXEC ..."
ps aux | grep "$DAEMON" | kill -9 `awk '{print $2}'` >/dev/null 2>&1 #与上文8步类似,找到服务pid,杀死,并把输出重定向至无底洞/dev/null
rm -f $PID_FILE #删除pid文件
usleep 100
echo "Shutting down $EXEC: [ OK ]"
}
#启动服务
start()
{
echo "Starting $EXEC ..."
$DAEMON > /dev/null & #将shell运行时的输出重定向至无底洞/dev/null,结果就是在控制台开不到输出
pidof $EXEC > $PID_FILE #写入pid文件,防止进程启动多个副本
usleep 100
echo "Starting $EXEC: [ OK ]"
}
#重启服务
restart()
{
stop
start
}
#case语句判断传入参数,根据此执行相应操作
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
status)
status -p $PID_FILE $DAEMON
;;
*)
echo "Usage: service $EXEC {start|stop|restart|status}"
exit 1
esac
exit $?
要说明的一个地方是chkconfig命令,这个用来控制服务启动的优先级,我这里是2345 20 81,2345表示系统运行级别为2、3、4、5时启动服务,20表示启动优先级,81表示关闭优先级。关于前4个数,这里有张表格,来自百度百科。
等级0 | 关机 |
等级1 | 单用户模式 |
等级2 | 无网络连接的多用户命令行模式 |
等级3 | 有网络连接的多用户命令行模式 |
等级4 | 不可用 |
等级5 | 带图形界面的多用户模式 |
等级6 | 重新启动 |
10、代码下载的实现
这一部分主要用到的是PHP的header函数,设置http报头。$code为用户代码,$filename为文件名。设置好报头之后,直接把用户代码打印出来就能实现下载功能了。
header("Content-type:application/octet-stream");//.*( 二进制流,不知道下载文件类型)
header("Accept-Ranges:bytes");
header("Accept-Length:".strlen($code));//代码长度
header("Content-Disposition:attachment; filename=".$filename);//以附件形式下载
最后来一张效果图吧
写在最后:为了理出这篇文章,查阅了不少po主的博客,包括CSDN、博客园还有百度百科,在此一一表示感谢!现在在等研究生出成绩的这段时间内整文章,也是我现在想做也可以做的。眼看马上就要毕业,真的很想能够如愿以偿上第一志愿,不论最后研究生能不能上,还是会继续在这行倒腾,不论代码是自己写的还是部分引用网络的,能够大致读懂,弄清架构还是重要的,更重要的是创意,东西就是我们创造出来的嘛,这是一个很有意思的过程,加油!
更多推荐
所有评论(0)