Phần kết: Xây dựng hệ thống thi lập trình trực tuyến
Mở đầu
Sau hai phần trước em, chưa hài lòng về hệ thống hiện tại em tiếp tục đi tìm các hướng đi mới. Thật sự có các open source của mảng này làm rất ổn áp rồi, chủ yếu là mục đích học tập nên em vẫn muốn làm lại cái "bánh xe" 😂😂.
Open source: RemoteCodeCompiler
An online code compiler supporting 11 programming languages (Java, Kotlin, Scala, C, C++, C#, Golang, Python, Ruby, Rust and Haskell) for competitive programming and coding interviews.
Đây là đoạn giới thiệu của nó trên github. Nôm na nó là 1 open source được viết bằng Java dùng để compiler code online.
Sau một hồi tìm kiếm em thấy thằng này có vẻ phù hợp tiêu chí không code giới hạn tài nguyên bằng C, và hoạt động cũng đơn giản.
Cách hoạt động
- Khi gửi file code lên nó sẽ khởi tạo 1 container để compiler (ở thời điểm em đọc mấy tháng trước nó không có cái này, không biết mục đích nó bổ sung thêm thằng này làm gì?)
- Sau khi compiler xong nó sẽ khởi tạo 1 container dùng để chạy file vừa được compiler và phản hồi lại kết quả.
Chi tiết cách họ compiler và giới hạn tài nguyên
Thay vì dùng C thì họ dùng bash script
để làm điều đó. Cụ thể khi ta upload file input
, output
và các thông số giới hạn thời gian
, bộ nhớ
họ sẽ tạo 1 file entrypoint.sh
với nội dung như sau:
#!/usr/bin/env bash
rename=${compiler.rename}
compile=${compiler.compile}
if [ "$rename" = true ];
then
mv ${compiler.defaultName} ${compiler.fileName}
fi
if [ "$compile" = true ];
then
${compiler.compilationCommand} 1> /dev/null
ret=$?
if [ $ret -ne 0 ];
then
exit ${compiler.compilationErrorStatusCode}
fi
fi
ulimit -s ${compiler.memoryLimit}
timeout -s SIGTERM ${compiler.timeLimit} ${compiler.executionCommand}
exit $?
- Giải thích:
- Các ${} là template khi họ generate các chỗ này sẽ được truyền giá trị vào.
rename = true
thì sẽ đổi tên bằng commandmv
compile = true
thì sẽ chạy command compiler tương ứng của ngôn ngữ đó (tại vì một số ngôn ngữ không cần compiler)- Sử dụng command
ulimit
để giới hạn bộ nhớ, commandtimeout
để giới hạn thời gian - Dựa vào
exit code
để phân biệt các lỗi runtime, compiler, vượt quá thời gian hay bộ nhớ.
Vậy công việc còn lại là chạy file entrypoint.sh
này bằng các ngôn ngữ lập trình bắt exit code nếu không lỗi thì so sánh với đáp án đúng.
Triển khai ý tưởng
1. Viết code generate file entrypoint
Với template string của js thì việc tạo ra file entrypoint ez =))
const generateEntryPointFile = async(compiler, path) => {
const content =
`#!/usr/bin/env bash
rename=${compiler.rename}
compile=${compiler.compile}
if [ "$rename" = true ];
then
mv ${compiler.defaultName} ${compiler.fileName}
fi
if [ "$compile" = true ];
then
${compiler.compilationCommand} 1> /dev/null
ret=$?
if [ $ret -ne 0 ];
then
exit ${compiler.compilationErrorStatusCode}
fi
fi
ulimit -s ${compiler.memoryLimit}
timeout -s SIGTERM ${compiler.timeLimit} ${compiler.executionCommand}
exit $?`
await fs.writeFile(`${path}/entrypoint.sh`, content);
}
2. Định nghĩa các command compiler
ENUM_COMPILATION_CMD
: Các comand compiler
ENUM_EXCECUTION_CMD
: Các command để chạy code
ENUM_MESSAGE
: exit code tương ứng với ý nghĩa.
Ví dụ sau này muốn update thì chỉ việc bổ sung command vào 😂
const ENUM_COMPILATION_CMD = {
"c": "gcc main.c -o exec",
"cpp": "g++ main.cpp -o exec",
"java": "javac main.java",
"python": "",
"js": ""
}
const ENUM_EXCECUTION_CMD = {
"c": "./exec",
"cpp": "./exec",
"java": "java Main",
"python" : "python3 main.py",
"js": "node main.js"
}
const ENUM_MESSAGE = {
"0": "Accepted",
"1": "Wrong Answer",
"139" : "Out Of Memory",
"124": "Time Limit Exceeded",
"96": "Compilation Error"
}
3. Tạo các object compiler truyền vào hàm generate entrypoint
const compiler = (input, memoryLimit, timeLimit, lang) => {
let compile = true;
let executionCommand = ENUM_EXCECUTION_CMD[lang];
if(ENUM_COMPILATION_CMD[lang] == "") compile = false;
let compiler = {
rename: false,
compile,
compilationCommand: ENUM_COMPILATION_CMD[lang],
compilationErrorStatusCode: 96,
executionCommand,
memoryLimit,
timeLimit,
}
return compiler;
}
4. Chạy container và lấy kết quả
--cpus=1
: giới hạn cpu của container
--rm
: Sau khi chạy xong thì cho bay màu
const runContainer = (name, folder) => {
return new Promise(async (resolve, reject) => {
const cmdBuildImage = `docker build -t ${name} ${folder}`;
const cmdRunContainer = `docker run --cpus=1 --rm ${name}`;
const { stdout, stderr } = await exec(cmdBuildImage);
if (stdout.includes("Successfully")) {
const ls = spawn("docker", ["run", "--cpus=1", "--rm", name]);
let result = {};
ls.stdout.on("data", (data) => {
result.output = data.toString();
});
ls.stderr.on("data", (data) => {
result.stack = data.toString();
});
ls.on("close", (code) => {
console.log(`child process close all stdio with code ${code}`);
});
ls.on("exit", (code) => {
result.code = code;
resolve(result);
});
}
});
};
Một số vấn đề khi triển khai
1. Việc so sánh kết quả
Ta đọc ouput của container và dùng hàm đọc file để so sánh kết quả nó không good cho lắm, nên sau em lưu đáp án đúng vào redis giúp tốc độ đọc nhanh hơn.
SET output:id
{
"01": "1 2 3"
"02": "1 2 3 4 5",
"03": "1"
}
Sau đó em quyết định so sánh luôn trong file entrypoint.sh
raw=$(ulimit -s ${compiler.memoryLimit} && timeout -s SIGTERM ${compiler.timeLimit} ${compiler.executionCommand} < "${inputFileName}")
ans=$(cat "out1.txt")
if [ "$raw" == "$ans" ]
2. Việc chạy nhiều test đồng thời
Thay vì phải khởi mỗi container cho 1 test thì em chuyển hết file input
và output
vào 1 container rồi dùng for
của bash để chạy
count=$(find . -name "in*.txt" |wc -l)`;
for i in \`seq 1 $count\`
do
run_code $i $1
done
sau đó viết hàm run_code
đối số truyền vào số thứ tự của file input
run_code(){
start=$(date +%s.%N)
raw=$(ulimit -s ${compiler.memoryLimit} && timeout -s SIGTERM ${compiler.timeLimit} ${compiler.executionCommand} < "${inputFileName}")
code=$?
if [ $code -eq 0 ]
then
ans=$(cat "out\${1}.txt")
...
if [ "$raw" == "$ans" ]
then
...
return 1
else
...
return 1
fi
else
...
fi
printf '%s|' "$JSON"
}
Vấn đề: Nếu chạy vòng lặp thì nó sẽ bị chạy "tuần tự" (mỗi test để giới hạn 2s thì 10 cái test nếu xui mất 20s) vậy ta cần chạy nó "đồng thời"
Google thì solution là thêm &
vào để nó chạy "đồng thời"
count=$(find . -name "in*.txt" |wc -l)`;
for i in \`seq 1 $count\`
do
run_code $i $1 &
done
3. Nhập từ bàn phím với javascript
Việc nhập vào từ bàn phím với ngôn ngữ js thì ta có thể sử dụng readline
nhưng em để ý một số thằng ví dụ như leetcode nó không cần người dùng có thằng này mà nó vẫn hiểu ảo ma vch =))
//Leetcode
function solution(a, b){
// code here
}
Vấn đề: Như trên ta chỉ cần viết code vào trong function, việc truyền biến vào thì leetcode sẽ lo. Làm sao để có thể truyền động các đối số như họ ?
Ban đầu em nghĩ đến việc for các đối số rồi generate các hàm readline, nhưng biết sao được các đối số là kiểu dữ liệu nào để parse cho đúng (vì input ghi trong file text thì nó sẽ auto kiểu string). Rồi còn kiểu mảng, kiểu object truyền kiểu gì ?
Trường hợp này file text không chơi được với js vậy mình dùng json. Vậy nó sẽ có dạng
{
"in": [2]
}
Khi dùng json thì ta có thể dùng bất kì kiểu dữ liệu nào ta thích number, string, array, object.
Vậy truyền nó vào hàm solution kiểu gì ? Đáp dòng này vào cuối file code js của người dùng là được 😂
process.stdin.resume();
process.stdin.setEncoding("utf8");
process.stdin.on("data", function (input) {
let data = JSON.parse(input);
solution.apply(null, data.in);
});
Kết
- Hệ thống có vẻ hoạt động đúng yêu cầu đề ra.
- Nó chưa hoàn hảo như em kỳ vọng vì em muốn giới hạn thêm một số tài nguyên nữa nhưng không thể. Chắc có lẽ đấy là lý do người ta dùng C để giới hạn tài nguyên.
- Hành trình theo đổi cái hệ thống này em cũng học được nhiều cái hay 😂😂
À còn thằng domjudge cũng sịn 😄
All rights reserved