..

Code-Prettifyer project

前言

本篇內容主要是說明我如何用 Docker、traefik、nginx 軟體,以及 JavaScript、PHP、C⋯⋯等等程式語言,做出一個可以讓使用者透過網頁輸入自己的程式碼後,對其進行分析、檢查以及排版的網頁應用程式。

概念發想

因著我是大一才開始真正接觸程式,從編輯器開始接觸了 vim。因著 vim 的特性,可以自定義很多快捷鍵、以及安裝許多由其他人寫的 plugin。其中就有 plugin 可透過其他人寫的 linter 直接對程式碼進行分析,找出程式碼中可能存在的錯誤。這個 plugin 帶給我許多方便,可惜的是絕大部分的 linter 在 windows 環境安裝起來有點麻煩,為了能推廣 linter 給使用 windows 環境的朋友,我便有一個想法。就是做一個網頁使使用者只要複製貼上自己的程式碼,按一個鍵,就能輸出分析結果甚至格式化程式碼。

過程

伺服器

要能架設網頁首先就是要有自己的伺服器,因著我手邊剛好有一台 raspberry pi 我就直接在上面安裝 ubuntu 20.4LTS。

安裝完基本設定都搞定後,抱著好玩與嘗試的心態我沒有照原本的計畫直接在 raspberry 上安裝 web server 而是想要嘗試看看用 docker 來運行 web server,基本上我就照著官方給的 資料 安裝就成功了。接著我照著其他人給的建議安裝了 portainer 讓我可以透過網頁 UI 來管理 docker。

到目前為止,我只能透過內網連到我的伺服器,如果要從外面的網路連到伺服器的話就需要有一個固定 ip。因著我家裡是安裝中華電信的網路,所以我可以直接到他們的網站申請一組固定 ip。申請完後直接設定 raspberry pi 讓它能透夠 PPPoE 的方式上網。

這時候我已經可以直接在外網 ssh 到伺服器,或是直接在瀏覽器輸入 ip 位址打開 portianer 頁面了。但如果其他要連到我的網頁的話就要輸入一串很難記的數字。為了讓我的網頁更好記得,我就在 name.com 上申請了自己網域名。

my domain

申請完後在設定裡添加一個 A record 指向自己 server 的位址

截圖 2021-01-06 下午2.59.10

這樣就可以透過網域名連到我的伺服器了。

但這樣就會遇到一個問題—我有多個網域名都指向同一個 ip 位址,而我有不同的網頁,這樣伺服器則怎麼判斷使用者想要連到哪一個網頁?這時候,就要使用 反向代理 來處理這個問題。 透過反向代理伺服器解析 request 的 HTTP header 可以知道使用者要連到那一個網頁伺服器,再將使用者連到該伺服器。

在網路上查了些資料後我選擇 traefik 作為反向代理的軟體。

traefik revers proxy

在設定 traefik 時我基本就是照官網的建議設定,其中還有參考 Digitalocean 的 教學文章 修改 traefik.toml 以及 traefik_dynamic.toml 檔案,以下是我的 docker-compose.yml 的一部分

services:
  
  traefik:
    build:
      context: "./Dockerfiles/traefik/"
      dockerfile: "Dockerfile"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./Dockerfiles/traefik/traefik.toml:/traefik.toml:ro
      - ./Dockerfiles/traefik/traefik_dynamic.toml:/traefik_dynamic.toml

    networks: 
      - web
    container_name: traefik

以及 Dockerfile

FROM traefik:latest
RUN touch /acme.json #建立acme.json檔案
RUN chmod 600 /acme.json #更改檔案權限

其中必須注意 acme.json 這個檔案,因為 traefik 會自動向 Let’s Encrypt 申請 TLS,這個檔案是存放憑證的,所以要另外在伺服器中建立一個。透過 Let’s Encrypt 申請憑證後使用者能透過 https 協議連到網站。

設定完 traefik 後接著要安裝網頁伺服器,我選擇使用 nginx 搭配 php-fpm,以下是我的 docker-compose.yml 的一部分

  nginx:
    build:
      context: "./Dockerfiles/nginx/"
      dockerfile: "Dockerfile"
    labels:
      - traefik.http.routers.nginx.rule=Host(`blog.yungen.studio`,`app.yungen.studio`)
      - traefik.http.routers.nginx.tls=true
      - traefik.http.routers.nginx.tls.certresolver=lets-encrypt
      - traefik.http.services.nginx.loadbalancer.server.port=80
      - traefik.http.middlewares.gzip.compress=true
      - traefik.http.routers.nginx.middlewares=gzip
    networks:
      - internal  #確認跟php在同一個網路裡
      - web
    volumes:
      - ./blog.yungen.studio:/var/www/blog.yungen.studio  #確認路徑與php的一樣
      - ./app.yungen.studio:/var/www/app.yungen.studio
      - ./nginxlog/:/var/log/nginx/

    container_name: nginx
  php:
    build:
      context: "./Dockerfiles/php/"
      dockerfile: "Dockerfile"
    container_name: php
    networks:
      - internal  #確認跟nginx在同一個網路裡
    volumes:
      - ./blog.yungen.studio:/var/www/blog.yungen.studio
      - ./app.yungen.studio:/var/www/app.yungen.studio  #確認路徑與nginx的一樣
    labels:
      - traefik.enable=false

以及 Dockerfile

#Nginx的檔案
FROM nginx:alpine
COPY ./nginx.conf /etc/nginx/ #將設定檔複製到Container裡
COPY ./sites-enabled /etc/nginx/
COPY ./nginxconfig.io /etc/nginx/ 
#PHP的檔案
FROM php:fpm-alpine
RUN apk update  #安裝網頁需要的程式
RUN apk add cppcheck
RUN apk add build-base
RUN apk add shadow
RUN usermod -u 1000 www-data #更改www-data權限讓php能執行以上程式
RUN groupmod -g 1000 www-data

其中要注意的是 php 和 nginx 的 volume 設定要掛載相同的位址。我自己因為設定錯誤位址而花了不少時間除錯。

docker 的部分設定完後接著設定 nginx。我用 Digitalocean 所提供的 工具 設定,稍微更改了一些有關 php 的設定

# fastcgi settings
fastcgi_pass                  php:9000;  #"php"需要改成在docker-compose.yml中設定的名稱
fastcgi_index                 index.php;

設定完這部分後基本上就完成大部分的設定了,接著只要在伺服器中輸入:

docker network create web
docker-compose up -d

docker 就會把所有軟體架設好。

網頁

大略流程圖

flowchart (1)

前端

收先需要建立一個輸入介面讓使用者能輸入程式碼,我使用 Codejar 作為我的輸入介面,我照這 Github 上的指示建立一個 div 元素給予它一個名稱為 “editor” 的 id 屬性,並在 javascript 中加入以下

import { CodeJar } from 'https://medv.io/codejar/codejar.js';

const highlight = (editor) => {
  editor.textContent = editor.textContent;
  hljs.highlightBlock(editor);
};
const editor = document.querySelector('.editor');
const jar = new CodeJar(editor, highlight);

這樣就可以在網頁中產生一個程式碼編輯器。接著需要建立三個按鈕,一個執行檢查,一個執行上傳,一個執行格式化。

<button type="button" id="button1">format</button>
<button type="button" id="button2">check</button>
<input  type="file" name="filesubmit" id="filesubmit"/>

接著在 javascript 中用 querySelector 選擇按鈕並監聽按鈕的動作

let b1 = document.querySelector('#button1');
let b2 = document.querySelector('#button2');
let b3 = document.querySelector('#filesubmit');

b1.addEventListener('click', sendb1Req);
b2.addEventListener('click', sendb2Req);
b3.addEventListener('change', sendb3Req)

對每一個按鈕都會執行不同的函示,以 button1 為例

function sendb1Req() {
  let code = jar.toString();  //用codejar的API將使用者輸入的程式碼給code這個變數
  let codeObject = {
    codeKey: code,  //將code變數放在codeObject這個object裡面
    filenamekey: 'tmpfile.c',
  };
  axios
    .post('postData.php', codeObject)  //傳送POST request給'postData.php'其中包含codeObject
    .then((res) => {
      console.log(res.data);
      return axios.get('formatOrder.php', {  //接受到伺服器的response後再傳送GET request 給'formatOrder.php'
        params: { filenamekey: 'tmpfile.c' },
      });
    })
    .then((res) => {
      console.log(res.data);
      jar.updateCode(res.data);  //將回傳的資料(格式化過的程式碼)更新編輯器內的程式碼
    })
    .catch((err) => {
      console.log('ERR');  //假如在其中過程失敗的話在console中輸出'ERR'
      console.log(err);
    });
}

上述例子中,我使用 axios 來發送 AJAX request。

後端

從前端接受資料後需要經過後端處理再將資料回傳,我主要是用 php 來處理資料的接收與傳送以及 request,並根據 request 執行我寫的三個 c 程式 formatBracket、removeSpace 以及 parseError 還有我正在使用的 cppcheck 這個程式。

以下是我處理接受資料的兩個程式

//postData.php
<?php
if (isset($_POST)) {
  header('Content-Type: application/json');  //因為傳送資料的格式為json
  $body=file_get_contents("php://input");
  $object = json_decode($body, true);  //需要把json轉換為php看得懂的格式
  $input = $object["codeKey"];
  $filename=$object["filenamekey"];  //codescript.js在傳送GET request 時有包含一個filenamekey 以及codeKey值
  $fptr = fopen('./upload/'.$filename, "w");
  fwrite($fptr, $input);
  fclose($fptr);
  echo json_encode("SUCCESS");
}

?>
//uploadData.php
<?php
if ($_FILES['filesubmit']['error'] === UPLOAD_ERR_OK) {  //檢查有沒有上傳成功
  if (!is_dir("upload")) {  //若upload資料夾不存在的話就新增一個
    mkdir("upload", 0755, true);
  }

  if (file_exists('upload/' . $_FILES['filesubmit']['name'])) {
    unlink('upload/' . $_FILES['filesubmit']['name']);  //若檔案存在的話刪除
  } 
    $file = $_FILES['filesubmit']['tmp_name'];
    $dest = 'upload/' . $_FILES['filesubmit']['name'];
    move_uploaded_file($file, $dest);  //將檔案從暫存移動到upload/
    echo json_encode("SUCCESS!");
}

?>

成功接受到資料後,要對資料進行處理。首先是格式化程式碼的程式,我寫的程式主要功能是格式化大括號裡面的程式碼,先將所有的 tab 轉換成 space 後再將多餘的 space 移除,接著將 code 排版為以下格式

{
    /* ... */
    {
       /* ... */
    }
}

removeSpace 主要是將 tab 轉換為空白後移除多餘的空白跟換行

void replaceTab(char *string) {
    char *qtr;
    while ((qtr = strchr(string, '\t')) != NULL) {  //尋找到\t後替換為空白
        *qtr=' ';
    }
}
char *myrmspace(char *string) {
    char *ptr = string;  //ptr作為讀取資料的指標
    char *out = malloc(sizeof(char) * strlen(string) + 1);
    char *qtr = out;  //qtr作為寫入資料的指標
    while (*ptr == ' ') {
        ptr++;
    }
    while (*ptr) {
        while ((*ptr == ' ') && *(ptr + 1) == ' ') { //若重複找到空白ptr向前跳過
            ptr++;
        }
        *qtr++ = *ptr++;  //遇到正常字元則依序寫入
    }
    if (*(ptr - 1) == ' ') {  //因為我是一行一行讀取,所以當所有字都讀取完後,檢查有沒有trailing space
        qtr--;
    }
    *qtr = '\0';
    return out;
}

再來是格式化的程式 formatBracket,以下是簡化的流程圖

formatflow (2)

這是簡化過的版本,以下是 formatBracket 的一部分

void formatBracket(char **input, char **output, int(*indentlevelPtr)) {
    while (*(*input)) {
        if (*(*input) == '{') {
            /* 
             省略
             ...
            */
            addindent(output, indentlevelPtr); //根據indentlevel進行縮排處理
            *(*output)++ = *(*input)++;
            replaceNewline(input, output);
            (*indentlevelPtr)++; //indentlevel + 1
            formatBracket(input, output, indentlevelPtr);
        }

        if (*(*input) == '}') {
             /* 
             省略
             ...
            */
            (*indentlevelPtr)--; //indentlevel - 1
            addindent(output, indentlevelPtr);
            *(*output)++ = *(*input)++;
            replaceNewline(input, output);
            return;
        }
        if (*((*output) - 1) == '\n') {
            addindent(output, indentlevelPtr);
        }
        if (*(*input) == ';' && *(*input + 1) != '\n') {
            if (*(*input - 1) == ';' || *(*input) + 1 == ';') {
                *(*output)++ = *(*input)++;
                continue;
            }
            *(*output)++ = *(*input)++;
            *(*output)++ = '\n';
            continue;
        }

        *(*output)++ = *(*input)++;
    }
}

首先在 main 函示中建立兩個指標,一個負責讀取一個負責寫入,接著將兩個指標傳入 formatBracket 函示中近處理大括號,處理完後再將結果輸出。程式碼本身還包含許多例外處理,例如遇到 ‘{’ 要當作一般的字處理,還有很多判斷式判斷是否要換行⋯⋯等等。

再來是 cppcheck 這個程式,這個程式是開放原始碼是由很多人一起寫的,這邊主要解釋我使用它時會給予的 arugument

 cppcheck --enable=all --suppress="missingIncludeSystem" ./formatBracket.c 2>&1
  • –enable:cppcheck 可檢查很多方面的錯誤,選擇“all”表示要檢查所有方面的錯誤
  • –suppress:cppcheck 可以檢查自己寫的 header file,但目前不會用到,所以我選擇不顯示關於 header 方面的錯誤
  • 2>&1:將 stderr 導向 stdout,方便等等處理

接下來是處理 cppcheck 輸出的程式 parseError,由觀察可知 cppcheck 輸出的資訊是有一定格式的,以下舉例

test.c:11:1: error: Memory leak: tmp [memleak]
}
^
test.c:15:9: style: Unused variable: a [unusedVariable]
    int a;
        ^

第一個是檔案名,再來是行數、列數、錯誤類別(還有 warning、information 等等)、錯誤資訊、括號內的錯誤 ID 以及最後的錯誤程式碼。可以由這格式透過正規表示式將所需要的資訊擷取出來

[^ ]+:([0-9]+):([0-9]+):[\r\n\t\f\v ]+([^:]+):[\r\n\t\f\v ]+(.+)\[+(.+)\]
  1. “[^ ]+:” 匹配所有非空格的字元直到遇到“:”
  2. “([0-9]+)” 匹配所有數字直到“:”並將此數字放在一個 Capturing Group 中
  3. “[\r\n\t\f\v ]+([^:]+)” 匹配 whitespace 特殊字元之後將所有非“:”的字元放在一個 Capturing Group 中
  4. “(.+)\[” 匹配所有字元直到 “["(”[" 需加上\)並將匹配結果放在一個 Capturing Group 中
  5. “(.+)]” 匹配所有字元直到 “]” 並將匹配結果放在一個 Capturing Group 中

這樣我們就有五個 Capturing Group 分別代表行數、列數、錯誤類別、錯誤資訊、錯誤 ID。程式碼的部分參考了這篇 文章 將所需要的資料依據上述表示式提取出來。以下是 parseError 的一部分

#include <stdio.h>
#include <regex.h>
int main() {
    char input[1024];
    regex_t regexCompile;
    char* pattern = "[^ ]+:([0-9]+):([0-9]+):[\r\n\t\f\v ]+([^:]+):[\r\n\t\f\v ]+(.+)\\[+(.+)\\]";  // 定義表示式("\"要escape)
    int groupCnt = 6;
    regmatch_t groupArray[groupCnt];  //匹配後的結果存放在這裏
    int lineCnt = 0;
    int checkCnt = 0;
    if (regcomp(&regexCompile, pattern, REG_EXTENDED)) {  //將表示式編譯成特定的資料格式
        printf("Could not compile regular expression.\n");
        return -1;
    }

    while (fgets(input, sizeof(char) * 1024, stdin) != NULL) {
        if (removeline(input) == 1) {
            continue;
        }
        rmln(input);
        lineCnt++;
        if (regexec(&regexCompile, input, groupCnt, groupArray, 0) == 0) {  //對input進行匹配
            checkCnt++;
            unsigned int g = 1;
            for (g = 1; g < groupCnt; g++) {
                char sourceCopy[strlen(input) + 1];
                strcpy(sourceCopy, input);
                sourceCopy[groupArray[g].rm_eo] =
                    '\0';  //在sourcCopy為原始資料的複製,將groupArray[g].rm_eo(第g個Group的結束位址)(rm_so,rm_eo是regmatch_t
                           //struct的其中兩個參數,紀錄匹配結果開始與結束位址)位址賦予'\0'
                if (g == 3) {  //對第三個Group進行處理(錯誤類別)轉換為toastr模組能參數
                    replaceInfo(sourceCopy + groupArray[g].rm_so);
                }
                printf("%s#", sourceCopy + groupArray[g].rm_so);  // sourceCopy 位址加上groupArray[g].rm_so(開始位址)等於第g個Group的字串起始位址
            }
            printf("?");
        }
    }
    if ((checkCnt == 0) && (lineCnt == 0)) { //如果沒有錯誤的話輸出此行
        printf("success# #?");
    }
    regfree(&regexCompile); //清空 regexCompile的内容
}

接著用迴圈分別將 Capturing Group 裡的值依照以下格式輸出

行數#列數#錯誤類別#錯誤資訊#錯誤ID#?行數#列數 ....

“#”以及“?”特殊符號是為了方便等等回傳時能將資料轉換為 JSON 的格式。

輸出

format 的部分 php 在接受到一個 GET request 後將剛剛儲存的程式碼經過 removeSpace、 formatBracket 的處理後將結果回傳。當瀏覽器收到回傳值後,再透過 Codejar 提供的函示更新編輯器裡的程式碼。

//formatOrder.php
<?php
if (isset($_GET)){
    $filename = $_GET['filenamekey'];
    if((file_exists('./app/removeSpace')==false)&& file_exists('./app/removeSpace.c')){
        shell_exec('gcc ./app/removeSpace.c -o ./app/removeSpace');
    }
    if((file_exists('./app/formatBracket')==false)&& file_exists('./app/formatBracket.c')){
        shell_exec('gcc ./app/formatBracket.c -o ./app/formatBracket');
    }
    if ((file_exists('./app/formatBracket')==false)||(file_exists('./app/removeSpace')==false)) {  //檢查執行命令所需程式,若無則直接編譯,若失敗則回傳"Opps"
    echo "Opps...";
    }else{
    $cmd = "cat "."./upload/$filename "."| "."./app/removeSpace "."| "."./app/formatBracket";
    $output = shell_exec($cmd);
    echo $output;
    unlink("./upload/$filename");
    }
}

?>

cppcheck 的部分就相對複雜,cppcheck 的輸出結果再經過 parseError 的格式化後,將其儲存在一個 php 的變數中,接著使用 php 的 函示explode(“pattern”,variable) 將格式化結果依據 “pattern” 作為斷點分割,分割後將結果個別放到變數中。

//excuteOrder.php
<?php
if (isset($_GET))
    if((file_exists('./app/parseError')==false)&& file_exists('./app/parseError.c')){
        shell_exec('gcc ./app/parseError.c -o ./app/parseError');
    }
    if (file_exists('./app/parseError')==false) {  //檢查執行命令所需程式,若無則直接編譯,若失敗則回傳Opps...
    echo "Opps...";
    }else{
    $filename = $_GET['filenamekey'];
    $cmd = 'cppcheck '.'--enable=all '.'--suppress=missingIncludeSystem '."./upload/$filename".' 2>&1 '.'| ./app/parseError';
    $output = shell_exec($cmd);
    $arrayOfGroup=explode("#?",$output); //用"#?"作為分割依據
    $jsonGroup=[];
    for($i = 0;$i <count($arrayOfGroup)-1;$i++){
        $tmp=explode("#",$arrayOfGroup[$i]);  "#"作為分割依據
        array_push($jsonGroup,$tmp);
    }
    echo json_encode($jsonGroup);
    unlink("./upload/$filename");
    }

?>

首先用 “#?” 作為分割依據可以得到類似以下結果

array 
  0 => string '12#1#error#Memory leak: tmp #memleak' 
  1 => string '25#12#info#Local variable 'a' shadows outer variable #shadowVariable'
  2 => string '17#7#info#Unused variable: a #unusedVariable'

接著再對陣列中每一個元素使用一次 explode 函示,這次用 “#” 作為分割依據並將結果根據原本列位址放在一個陣列中的第 i 位址

[["12","1","error","Memory leak: tmp ","memleak"],["25","12","info","Local variable 'a' shadows outer variable ","shadowVariable"],["17","7","info","Unused variable: a ","unusedVariable"]]

接著使用 json_encode 函示將資料轉為 JSON 格式並回傳

當瀏覽器收到回傳值時,axios 會自動將 JSON 轉換為一個 Object 這時候將包含所需資料的 Object 傳入以下函示(Context[i][0,1,2…] 解讀為第 i 個錯誤,而其中 0,1,2…代表行、列、錯誤類別⋯⋯)

function toastFunc(Context) {
  for (let i = 0; i < Context.length; i++) {
    console.log(Context[i][2]);
    toastr[Context[i][2]](Context[i][3], 'Line: ' + Context[i][0]);
  }
}

這裡我使用一個叫做 toastr 的 plugin,可以在頁面上顯示 toast,例如像以下這樣

截圖 2021-01-08 上午10.28.29

toastr[“style”](“content”,“title”)“style” 是更改 toast 的風格,對應的是 cppcheck 輸出的錯誤類別,在 parseError 程式中已經先將 cppcheck 輸出的錯誤類別全部替換成 taostr 可以接受的參數。“content” 對應的是錯誤資訊,“title” 是更改 toast 的標題,對應的是 cppcheck 輸出的行數。toastr 的一些設置(大小、樣式、顯示位址等等)可以透過更改 toastr.options 物件裡的參數設定。最後的結果會像以下這樣

截圖 2021-01-08 上午10.33.10

結論與可改進之處

現在網站已經可以做到接受使用者的程式碼後分析並回傳錯誤資訊與格式化的結果,但其中還有許多可以改進的地方

  1. 如果使用著上傳一個毫無錯誤的程式碼,無法顯示“無法找到錯誤”之類的資訊。
  2. cppcheck 本身可以 lint C 以及 C++ 的程式碼,目前只能處理 C 的程式碼
  3. formatBracket 程式只能處理大括號裡的程式碼
  4. parseError 無法處理 cppcheck 輸出的”錯誤程式碼“
  5. 可以讓使用著選擇 Codejar 字體樣式以及 Syntax Highlighting 的風格等等

心得

我覺得做這個 project 讓我學習到很多新東西,像是 Javascript、php 這些跟 C 很不一樣的程式語言,還有 Docker、Markdown、Github 等等,最重要的是能藉著這個 project 將計算機概論教的網路概念實際操作一次,讓我對網路方面有更深的領悟。整體而言,做這個 project 是很快樂的。尤其是看到自己寫的功能如預期運作的時候,那種興奮感就好像第一次寫 Hello World 一樣。雖然說這只是老師出的一份作業而已,但我後續還會繼續更新我的網站,把我課堂上、網路上的學到的新東西加進去。

連結

參考連結