基於Docker容器和PHP技術的簡易多人在線編程平臺(雲IDE)的實現

前言: 

 這篇文章將講述如何利用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 重新啓動
最後將這個服務腳本放置到/etc/init.d文件夾下,去掉.sh後綴,就可以使用service命令調用了。Tip:如果調用後發現還會有控制檯輸出shell腳本運行信息,關掉終端重開就不會有了。

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、博客園還有百度百科,在此一一表示感謝!現在在等研究生出成績的這段時間內整文章,也是我現在想做也可以做的。眼看馬上就要畢業,真的很想能夠如願以償上第一志願,不論最後研究生能不能上,還是會繼續在這行倒騰,不論代碼是自己寫的還是部分引用網絡的,能夠大致讀懂,弄清架構還是重要的,更重要的是創意,東西就是我們創造出來的嘛,這是一個很有意思的過程,加油!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章