耗时一个月开发的OJ在线判题系统,文末有项目地址,目前还在更新代码~
今天让我们来用JAVA原生实现代码沙箱
代码沙箱项目初始化
代码沙箱的定位:只负责接收代码和输入,返回编译运行的结果,不负责判题(可以独立作为独立的项目/服务,提供给其他需要执行代码的项目去使用)
只实现java的代码沙箱,重要的是学思想,学关键流程
todo 扩展实现c++语言的代码沙箱
由于代码沙箱是能够通过API调用的独立服务,所以新建一个Spring Boot Web项目,最终这个项目要提供一个能够执行代码,操作代码沙箱的接口。
1)新建代码沙箱项目
2)application.yml中编写配置
server:
port: 8090
3)编写测试接口,验证能否访问
package com.yupi.yojcodesandbox.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController("/")
public class MainController {
@GetMapping("/health")
public String healthCheck() {
return "ok";
}
}
4)将yoj-backend项目的JudgeInfo 类移到 model 目录下,然后复制 model 包和CodeSandbox 接口到该沙箱项目,便于字段的统一。
java原生实现代码沙箱
原生:尽可能不借助第三方库和依赖,用最干净,最原始的方式实现代码沙箱
命令行执行
JAVA程序执行流程:
接收代码 -> 编译代码(javac)-> 执行代码(java)
1)编写示例代码,放到resources目录下
public class SimpleCompute {
public static void main(String[] args) {
int a = Integer.parseInt(args[0]);
int b = Integer.parseInt(args[1]);
System.out.println("结果:" + (a + b));
}
}
用 javac 命令编译代码
javac {Java代码路径}
用 java 命令执行代码
java -cp {编译后的class文件所在路径} SimpleCompute 1 2
2)解决程序中文乱码问题
问什么编译后的 class 文件出现中文乱码?
原因:命令行终端的编码是GBK,和 java 代码文件本身的编码 UTF-8 不一致,导致乱码。
解决方案:通过 chcp 命令 查看命令行编码,GBK是936,UTF-8是65001
方案一(不建议):改变终端编码来解决编译乱码,不建议,因为其他运行你代码的人也要改变环境,兼容性很差
方案二:javac 编译命令上带上编码
javac -encoding utf-8 .\SimpleCompute.java
3)统一类名
把用户输入代码的类名限制为Main,可以减少编译时类名不一致的风险,而且不用从用户代码中提取类名,更方便。
Main.java
public class Main {
public static void main(String[] args) {
int a = Integer.parseInt(args[0]);
int b = Integer.parseInt(args[1]);
System.out.println("结果:" + (a + b));
}
}
执行命令时,使用统一的类名:
javac -encoding utf-8 .\Main.java
java -cp . Main 1 2
核心流程实现
核心实现思路:用程序代替人工,用程序来操作命令行,去编译执行代码
核心依赖:JAVA进程类 Process
核心流程:
- 把用户的代码保存为文件
- 编译代码,得到class文件
- 执行代码,得到输出结果
- 收集整理输出结果
- 文件清理,释放空间
- 错误处理,提升程序健壮性
1、把用户提交代码保存到文件中
引入 Hutool 工具类。提高操作文件效率
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.8</version>
</dependency>
在根目录下新建目录tempCode存放用户的原始代码和编译后的代码,注意tempCode下代码不提交到代码仓库(在.gitignore文件内新加个目录tempCode即可,这样代码提交时就会忽略tempCode了),将每个用户的代码都存放在独立目录下,通过UUID随机生成目录名,便于隔离和维护:
List<String> inputList = excodeCodeRequest.getInputList();
String code = excodeCodeRequest.getCode();
String language = excodeCodeRequest.getLanguage();
//1.把用户提交的代码保存到文件中
//获取当前用户工作目录
String userDir = System.getProperty("user.dir");
System.out.println(userDir);
String globalCodePathName = userDir + File.separator + "tempCode";
//没有全局代码目录就新建
if(!FileUtil.exist(globalCodePathName)){
FileUtil.mkdir(globalCodePathName);
}
//把和用户的代码隔离存放
//用户代码父目录
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
FileUtil.writeString(code,userCodeParentPath + File.separator + "Main.java", StandardCharsets.UTF_8);
测试:
public static void main(String[] args) {
ExecutecodeCodeRequest executecodeCodeRequest = new ExecutecodeCodeRequest();
JavaNativeCodeSandbox javaNativeCodeSandbox = new JavaNativeCodeSandbox();
executecodeCodeRequest.setInputList(Arrays.asList("1 2","3 4"));
//ResourceUtil可以读取resources目录下的文件
String code = ResourceUtil.readStr("Main.java", StandardCharsets.UTF_8);
executecodeCodeRequest.setCode(code);
executecodeCodeRequest.setLanguage("java");
ExecutecodeResponse executecodeResponse = javaNativeCodeSandbox.executeCode(executecodeCodeRequest);
System.out.println(executecodeResponse);
}
成功将resources目录下的Main.java文件中的代码写入tempCode/Main.java
执行两遍的结果:生成了两个目录
2、编译代码
使用 Process 类实现在终端执行命令
String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
Process process = Runtime.getRuntime().exec(compileCmd);
执行 process.waitFor 等待程序执行完成,并通过返回的 exitValue 判断程序是否正常放回,然后从 Process 的输入流 inputStream 和错误流 errorStream 获取控制台输出
完整代码:
//2.编译程序代码
String compileCmd = String.format("javac -encoding utf-8 %s",userCodeFile.getAbsoluteFile());
try {
Process process = Runtime.getRuntime().exec(compileCmd);
int exitValue = process.waitFor();
//正常退出,我现在想获取控制台输出
if(exitValue == 0){
System.out.println("编译成功");
//分批读取输入流(控制台输出)
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
//逐行读取,控制台输出信息
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null){
compileOutputStringBuilder.append(compileOutputLine);
}
System.out.println(compileOutputStringBuilder);
}else{
System.out.println("编译失败" + exitValue);
//分批读取正常输出流:有的程序员会在正常输出里写一些错误日志之类的
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
//逐行读取,控制台输出信息
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null){
compileOutputStringBuilder.append(compileOutputLine);
}
System.out.println(compileOutputStringBuilder);
//分批读取错误输出:
BufferedReader errorbufferedReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
//逐行读取,控制台输出信息
String errorcompileOutputLine;
while ((errorcompileOutputLine = errorbufferedReader.readLine()) != null){
errorCompileOutputStringBuilder.append(errorcompileOutputLine);
}
System.out.println(errorCompileOutputStringBuilder);
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
把上述代码提取为工具类 ProcessUtils ,执行进程并获取输出
/**
* 进程工具类
*/
public class ProcessUtils {
/**
* 执行进程并获取信息
*
* @param runProcess
* @param opName
* @return
*/
public static ExecuteMessage runProcessAndGetMessage(Process runProcess, String opName) {
ExecuteMessage executeMessage = new ExecuteMessage();
try {
// 等待程序执行,获取错误码
int exitValue = runProcess.waitFor();
executeMessage.setExitValue(exitValue);
// 正常退出
if (exitValue == 0) {
System.out.println(opName + "成功");
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
compileOutputStringBuilder.append(compileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
} else {
// 异常退出
System.out.println(opName + "失败,错误码: " + exitValue);
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
compileOutputStringBuilder.append(compileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
// 分批获取进程的错误输出
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
// 逐行读取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
errorCompileOutputStringBuilder.append(errorCompileOutputLine);
}
executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
}
} catch (Exception e) {
e.printStackTrace();
}
return executeMessage;
}
}
3、执行程序
同样是使用 Process 类运行 java 程序,命令中添加-Dfile.encoding=UTF-8
参数,解决中文乱码
String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
上面命令适用于执行从输入参数(args)中获取值的代码。
P42 集
todo:很多OJ是ACM模式,需要和用户交互,让用户不断输入内容并获取输出
比如:
import java.io.*;
import java.util.*;
public class Main
{
public static void main(String args[]) throws Exception
{
Scanner cin=new Scanner(System.in);
int a=cin.nextInt(),b=cin.nextInt();
System.out.println(a+b);
}
}
对于此类程序,我们需要使用 OutputStream 向程序终端发送参数,并即时获取结果,注意最后要关闭流释放资源
4、整理输出
1)通过for循环遍历执行结果,从中获取输出列表
//4.整理输出
ExecutecodeResponse executecodeResponse = new ExecutecodeResponse();
List<String> outputList = new ArrayList<>();
for(ExecuteMessage executeMessage : executeMessageList){
//有的执行用例执行时出现错误,响应信息直接设为错误信息,且响应状态设为错误,中断循环
if(StrUtil.isNotBlank(executeMessage.getErrorMessage())){
executecodeResponse.setMessage(executeMessage.getErrorMessage());
executecodeResponse.setStatus(3);
break;
}
//将输出用例添加到列表
outputList.add(executeMessage.getMessage());
}
executecodeResponse.setOutputList(outputList);
//每条都正常输出了,正常运行完成,状态设置为1
if(outputList.size() == executeMessageList.size()){
executecodeResponse.setStatus(1);
}
JudgeInfo judgeInfo = new JudgeInfo();
// judgeInfo.setMessage(); judgeInfo 的信息在判题过程中设置
judgeInfo.setTime(21:01);
// judgeInfo.setMemory();
executecodeResponse.setJudgeInfo();
2)获取程序执行时间
可以使用 Spring 的 StopWatch 获取一段程序的执行时间
在ProcessUtils 代码执行那里执行,先给 ExecuteMessage 加个程序消耗时间字段
@Data
public class ExecuteMessage {
//错误码
private Integer exitValue;
//控制台正确信息
private String message;
//控制台错误信息
private String errorMessage;
//程序消耗时间
private Long time;
}
StopWatch stopWatch = new StopWatch();
stopWatch.start();
... 程序执行
stopWatch.stop();
stopWatch.getLastTaskTimeMillis(); // 获取时间
这里我们用所有样例的执行时间的最大值来作为最终的代码执行时间,便于后续判题服务计算程序是否超时,只要有一个程序超时,就判断为超时
// 取用时最大值,便于判断是否超时
long maxTime = 0;
for (ExecuteMessage executeMessage : executeMessageList) {
...
Long time = executeMessage.getTime();
if (time != null) {
maxTime = Math.max(maxTime, time);
}
}
扩展:可以每个测试用例都有一个独立的内存,时间占用的统计
3)获取内存信息
实现过程比较复杂,因为无法从 Process 对象中获取到子进程号,也不推荐在 java 原生实现代码沙箱的过程中获取。
5、文件清理
防止服务器空间不足,执行结束后删除代码目录:
//5.文件清理
if(userCodeFile.getParentFile() != null){
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除" + (del ? "成功" : "失败"));
}
6、错误处理
还有可能编译就错误了,编译错误下面的代码就不用执行了
封装一个错误处理方法,当程序抛出异常时,直接返回错误响应
private ExecuteCodeResponse getErrorResponse(Throwable e) {
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
executeCodeResponse.setOutputList(new ArrayList<>());
executeCodeResponse.setMessage(e.getMessage());
// 表示代码沙箱错误
executeCodeResponse.setStatus(2);
executeCodeResponse.setJudgeInfo(new JudgeInfo());
return executeCodeResponse;
}
完整代码
/**
* java原生代码沙箱
*/
public class JavaNativeCodeSandbox implements CodeSandbox {
private static final String GLOBAL_CODE_DIR_NAME = "tempCode";
private static final String CLOBAL_JAVA_CLASS_NAME = "Main.java";
public static void main(String[] args) {
ExecutecodeCodeRequest executecodeCodeRequest = new ExecutecodeCodeRequest();
JavaNativeCodeSandbox javaNativeCodeSandbox = new JavaNativeCodeSandbox();
executecodeCodeRequest.setInputList(Arrays.asList("1 2", "3 4"));
//ResourceUtil可以读取resources目录下的文件
String code = ResourceUtil.readStr("Main.java", StandardCharsets.UTF_8);
executecodeCodeRequest.setCode(code);
executecodeCodeRequest.setLanguage("java");
ExecutecodeResponse executecodeResponse = javaNativeCodeSandbox.executeCode(executecodeCodeRequest);
System.out.println(executecodeResponse);
}
@Override
public ExecutecodeResponse executeCode(ExecutecodeCodeRequest excodeCodeRequest) {
List<String> inputList = excodeCodeRequest.getInputList();
inputList = Arrays.asList("1 2","3 4");
String code = excodeCodeRequest.getCode();
String language = excodeCodeRequest.getLanguage();
//1.把用户提交的代码保存到文件中
//获取当前用户工作目录 D:\project\yoj-code-sandbox
String userDir = System.getProperty("user.dir");
String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
//没有全局代码目录就新建
if (!FileUtil.exist(globalCodePathName)) {
FileUtil.mkdir(globalCodePathName);
}
//把和用户的代码隔离存放
//用户代码父目录
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
String userCodePath = userCodeParentPath + File.separator + CLOBAL_JAVA_CLASS_NAME;
File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);
//2.编译程序代码
String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsoluteFile());
try {
Process process = Runtime.getRuntime().exec(compileCmd);
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(process, "编译");
System.out.println(executeMessage);
} catch (IOException e) {
return getErrorResponse(e);
}
//3.执行程序
//输出信息列表
List<ExecuteMessage> executeMessageList = new ArrayList<>();
for(String inputArgs:inputList){
String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
try {
//执行命令
Process process = Runtime.getRuntime().exec(runCmd);
//获取控制台输出
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(process, "执行");
System.out.println(executeMessage);
executeMessageList.add(executeMessage);
} catch (IOException e) {
return getErrorResponse(e);
}
}
//4.整理输出
ExecutecodeResponse executecodeResponse = new ExecutecodeResponse();
List<String> outputList = new ArrayList<>();
Long maxTime = 0L;
for(ExecuteMessage executeMessage : executeMessageList){
//只要有一个程序超时,就判断为超时
Long time = executeMessage.getTime();
if(time != null){
maxTime = Math.max(time,maxTime);
}
//有的执行用例执行时出现错误,响应信息直接设为用户提交代码错误的信息,且响应状态设为错误,中断循环
if(StrUtil.isNotBlank(executeMessage.getErrorMessage())){
executecodeResponse.setMessage(executeMessage.getErrorMessage());
executecodeResponse.setStatus(3);
break;
}
//将输出用例添加到列表
outputList.add(executeMessage.getMessage());
}
executecodeResponse.setOutputList(outputList);
//每条都正常输出了,正常运行完成,状态设置为1
if(outputList.size() == executeMessageList.size()){
executecodeResponse.setStatus(1);
}
JudgeInfo judgeInfo = new JudgeInfo();
// judgeInfo.setMessage(); judgeInfo 的信息在判题过程中设置
judgeInfo.setTime(maxTime);
// judgeInfo.setMemory();
executecodeResponse.setJudgeInfo(judgeInfo);
//5.文件清理
if(userCodeFile.getParentFile() != null){
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除" + (del ? "成功" : "失败"));
}
//6.错误处理,提升程序健壮性
return executecodeResponse;
}
/**
* 返回异常信息对象
* @param e
* @return
*/
private ExecutecodeResponse getErrorResponse(Throwable e){
ExecutecodeResponse executecodeResponse = new ExecutecodeResponse();
executecodeResponse.setOutputList(new ArrayList<>());
executecodeResponse.setMessage(e.getMessage());
//表示代码沙箱错误
executecodeResponse.setStatus(2);
executecodeResponse.setJudgeInfo(new JudgeInfo());
return executecodeResponse;
}
}
项目地址
(求求大佬们赏个star~)
前端:https://github.com/IMZHEYA/yoj-frontend
后端:https://github.com/IMZHEYA/yoj-backend
代码沙箱:https://github.com/IMZHEYA/yoj-code-sandbox