コーディングテストなどを作る際に、Node.jsコードを実行したい、それも
すべてのユーザーが同じ環境上で実行できるように
実装したいというときのために、作ったコードを載せようと思います。
#フロントエンド
今回はMonaco Editorをつかって、ユーザーからのコードを編集してもらえるようにします。
バックエンドからテスト用のソースの入ったディレクトリがランダムに降ってきてブラウザ上に、ディレクトリの中身を展開しているものとします。
const Editor = () => {
//展開しているディレクトリのファイル名の配列
const [fileNames, setFileNames] = useState([]);
//展開しているディレクトリのファイルの中身を配列として順番に格納したもの
const [fileContents, setFileContents] = useState([]);
const [answerOfUser, setAnswerOfUser] = useState([]);
const [executionCompleted, setExecutionCompleted] = useState(false);
const [clickCount, setClickCount] = useState(0);
const handleExecuteCode = useCallback(() => {
// 実行ボタンを押す前にMonaco Editorの内容を保存
const answer = {
files: fileNames.map((item) => item),
content: fileContents.map((item) => item.content),
};
setAnswerOfUser(answer);
setClickCount(clickCount + 1);
}, [fileContents]);
const handleTabSelect = (selectedIndex) => {
const fileName = fileNames[selectedIndex];
setSelectedFileName(fileName);
};
return (
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<button className="execute" type="submit" onClick={handleExecuteCode}>
実行する
</button>
<Tabs onSelect={handleTabSelect}>
<TabList>
{fileNames.map((fileName, index) => (
<Tab key={fileName}>{fileName}</Tab>
))}
</TabList>
{fileContents.map((item, index) => (
<TabPanel key={item.fileName} value={selectedFileName}>
<div className="editor-space">
<MonacoEditor
language="javascript"
theme="vs"
value={item.content}
onChange={(newValue) => handleOnChange(newValue, index)}
/>
</div>
</TabPanel>
))}
</Tabs>
</div>
<div style={{ flex: 1 }}>
<ResultOfCode answerOfUser={answerOfUser} clickCountOfButton={clickCount} updateState={updateState}/>
</div>
</div>
);
};
export default Editor;
useStateフックで定義されている、answerOfUserには、ユーザーが提示されたテスト問題のファイル名と、中身を格納しています。
それらをJSON形式でバックエンド側に送信します。
バックエンド(Express)
Express上では、フロントエンドから渡されたコードをバックエンド側で実行します。
このようにすることで、すべてのブラウザで、すべてのユーザーが同じ環境でコードを動かすことが出来るようになるのが魅力的です。
その分、セキュリティをしっかりしないと、この機能を踏み台にされて乗っ取られてしまいます。
Dockerコンテナ上に展開するのであれば、OSイメージの上にNode.js環境を構築して、iptablesにてコンテナ単位で制限するもしくは、Dockerネットワークを用いてコンテナごとフロントエンドのサーバーないしコンテナ意外との通信を制限するというやり方もあります。
const crypto = require("crypto");
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
exports.catchObject = function(fileAndCode) {
return new Promise((resolve, reject) => {
//JSON形式でファイル名が配列で送られる
const fileNames = fileAndCode.files;
//JSON形式でファイルの内容が送られる
const contents = fileAndCode.content;
//ユーザーがPOSTしたファイルをランダムなディレクトリに格納する
const directoryName = crypto.randomBytes(20).toString('hex');
//usersCodeというディレクトリに格納されるようにする
const directory = path.join('./usersCode/', directoryName);
//ディレクトリの作成
fs.mkdir(directory, (err) => {
if (err) {
console.error(err);
reject(err);
return;
}
//ファイル名とファイルの内容を結合する関数に指定したディレクトリ(directory)とファイル名(fileNames)とファイルの内容(contents)を格納する
jointFilesAndContents(directory, fileNames, contents);
//格納されたファイルのコードを実行
executeCode(directoryName, fileNames)
.then(result => {
//実行に成功した場合、
removeDirectory(directory) // ディレクトリを削除する
.then(() => {
resolve(result);
})
.catch(error => {
//失敗した場合はrejectされる。
reject(error);
});
})
.catch(error => {
removeDirectory(directory)
.then(() => {
reject(error);
})
.catch(error => {
reject(error);
});
});
});
});
};
function jointFilesAndContents(directory, fileNames, contents) {
const answer = {
directory: directory,
files: []
};
for (let i = 0; i < fileNames.length; i++) {
//ディレクトリ内にファイルを生成
const filePath = path.join(directory, fileNames[i]);
//そのファイルの中身をfor文で書き込む
fs.writeFileSync(filePath, contents[i]);
//生成されたファイルをディレクトリ内にPushする
answer.files.push(fileNames[i]);
}
return answer;
}
//ユーザーがPOSTしたコードを実行する関数
async function executeCode(directoryName, fileNames) {
return new Promise((resolve, reject) => {
//ユーザーのPOSTしてきたファイルの中からJSファイルを探す
const jsFile = fileNames.find((fileName) => fileName.match('.js'));
if (!jsFile) {
console.error('JavaScript file not found.');
resolve('JavaScript file not found.');
return;
}
//生成されたディレクトリ内のJSファイルのパスを格納
const jsFilePath = path.join(directoryName, jsFile);
// node JSファイルを実行する場所がホームディレクトリになってしまうので、ユーザーがコードをPOSTしてきた際に生成されたファイルに変更
process.chdir('./usersCode/', directoryName, '/');
//JSファイルを実行
exec(`node ${jsFilePath}`, (error, stdout, stderr) => {
const result = [
//コードの実行結果を格納
stdout ? stdout.trim() : '',
//コードがエラーになってしまっている場合は、エラー分を格納
stderr ? stderr.trim() : ''
];
//ユーザーのコードの実行結果をJSON形式で返す関数に、ディレクトリの名前と、コードの実行結果が格納された配列resultを渡す。
returnUsersAnswer(directoryName, result)
.then(result => {
resolve(result);
})
.catch(error => {
reject(error);
});
});
});
}
function returnUsersAnswer(directoryName, result) {
return new Promise((resolve, reject) => {
//ファイル名をutf-8で読み込む(でないとバグが生まれてしまう)。
const fileNames = fs.readdirSync(directoryName, { encoding: 'utf-8' });
let usersAnswer = []; // 配列として宣言
for (let i = 0; i < fileNames.length; i++) {
//ファイルの中身をfs.readFileSyncで読み込む。
const contents = fs.readFileSync(path.join( directoryName, fileNames[i]), { encoding: 'utf-8' });
//ファイル名とファイルの中身が関連付けされた配列を配列usersAnswerにPushする。
usersAnswer.push({ files: fileNames[i], contents: contents });
}
//カレントディレクトリを、ワークディレクトリに戻す。
process.chdir('/app');
//JSONの中に格納するものを文字列に変換
const jsonUsersAnswer = JSON.stringify(usersAnswer);
//JSONの中身をパースして、文字列として成立させる。
const parsedUsersAnswer = JSON.parse(jsonUsersAnswer);
//このファイルの実行結果とファイルの内容と名前を格納したJSON文字列をresolveとして返す。
resultOfAPI(result, parsedUsersAnswer)
.then(result => {
resolve(result);
})
.catch(error => {
console.error(error);
reject(error);
});
});
}
async function resultOfAPI(result, parsedUsersAnswer) {
const arrayResult = [result, parsedUsersAnswer];
return JSON.stringify(arrayResult);
}
function removeDirectory(directory) {
return new Promise((resolve, reject) => {
//ディレクトリを削除する
fs.rm(directory, { recursive: true }, (err) => {
if (err) {
console.error(err);
reject(err);
return;
}
resolve(resolve);
});
});
}
まとめ
実際にNode.jsのコードをバックエンド側で実装するとなると、かなり最新の注意を払わないといけないと思います。
自分はリリースしようとして挫折しましたw
先程例に上げたネットワークの制限も、まだ挫折する前に考えていたことです。
何かしらの参考になれば幸いです。
指摘等あれば、コメントなどで指摘お願いします。
ボッコボコにしてください!!!!