设计思路:

1、保存代码文件
✅ 目的:
将用户提交的源码以字符串形式写入磁盘,生成 .java 文件。📌 原因:
Java 是静态语言,必须先编译成 .class 文件才能运行。
需要物理文件路径来调用 javac 或使用 JavaCompiler API 编译。
可以通过隔离目录结构(如 UUID 子目录)实现用户代码之间的隔离。
实现步骤:
获取当前工作目录路径:
- 使用
System.getProperty("user.dir")
获取当前运行程序的工作目录路径。例如:/home/user/project
。构建全局代码存储目录的完整路径:
- 将工作目录路径与全局代码存储目录名称(假设是一个预定义的常量
GLOBAL_CODE_DIR_NAME
)拼接起来,形成全局代码存储目录的完整路径。例如:/home/user/project/codeTemp
。检查并创建全局代码存储目录:
- 使用工具类
FileUtil.exist(globalCodePathName)
判断全局代码存储目录是否存在。如果不存在,则调用FileUtil.mkdir(globalCodePathName)
创建该目录。生成用户专属目录:
- 通过
UUID.randomUUID()
生成一个唯一标识符,并将其作为当前用户的代码存放目录名,目的是为了避免多个用户提交代码时发生文件覆盖的问题。例如:/home/user/project/codeTemp/uuid-xxxxxx
。构造具体的Java文件路径:
- 在用户专属目录下创建一个固定名称的 Java 文件(假设使用的是
GLOBAL_JAVA_CLASS_NAME
作为文件名),例如:/home/user/project/codeTemp/uuid-xxxxxx/Main.java
。将用户代码写入到指定路径的文件中:
- 使用
FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8)
方法将用户传入的代码字符串以 UTF-8 编码格式写入到之前构造好的文件路径中。这个方法还会自动创建文件及其父目录(如果它们还不存在的话)。返回创建好的文件对象:
- 最后,返回刚刚创建好的
File
对象,供调用者用于后续的编译或执行操作。
/**
* 1. 把用户的代码保存为文件
*
* 这个方法的主要目的是将用户传入的字符串形式的 Java 源代码保存为一个临时文件,
* 并返回该文件对象。为了隔离不同用户的代码,每次都会创建一个独立的目录来存放。
*
* @param code 用户输入的 Java 源代码字符串
* @return 返回保存后的 Java 文件对象(File),可用于后续编译或执行
*/
public File saveCodeToFile(String code) {
// 获取当前运行程序的工作目录路径(例如:/home/user/project)
String userDir = System.getProperty("user.dir");
// 构建全局代码存储目录的完整路径(userDir + 文件分隔符 + 全局目录名)
// 例如:/home/user/project/codeTemp
String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
// 判断这个全局代码目录是否存在
// 如果不存在,则使用工具类 FileUtil 创建该目录
if (!FileUtil.exist(globalCodePathName)) {
FileUtil.mkdir(globalCodePathName); // 创建目录
}
// 生成一个唯一标识符作为当前用户的代码目录名(UUID.randomUUID())
// 目的是为了避免多个用户提交代码时发生文件覆盖的问题
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
// 构造具体的 Java 文件路径(在用户专属目录下创建一个固定名称的 Java 文件)
// 例如:/home/user/project/codeTemp/uuid-xxxxxx/Main.java
String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
// 使用 FileUtil 工具类将用户传入的代码字符串写入到指定路径的文件中
// 编码方式为 UTF-8,确保中文等字符不会乱码
// writeString 方法会自动创建文件及其父目录(如果不存在)
File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);
// 返回创建好的文件对象,供调用者使用(如用于后续编译、执行操作)
return userCodeFile;
}
2. 编译代码,得到 class 文件
✅ 目的:
将
.java
源文件编译为.class
字节码文件,供 JVM 执行。📌 原因:
- Java 程序不能直接执行
.java
文件,必须先经过编译。- 使用标准工具(如
javac
或 Java Compiler API)进行编译。- 如果编译失败,需要捕获错误信息并反馈给用户。
实现步骤:
构造编译命令:
- 使用
String.format()
方法构建用于编译 Java 源代码文件的命令字符串。这里使用了javac
命令,并通过-encoding utf-8
参数指定源文件的字符编码格式为 UTF-8,以确保支持中文等非ASCII字符集。- 获取用户代码文件的绝对路径(
userCodeFile.getAbsolutePath()
),将其拼接到命令字符串中。执行编译命令:
- 使用
Runtime.getRuntime().exec(compileCmd)
方法执行上述构造好的编译命令。这会启动一个子进程运行javac
编译器来编译用户的 Java 源代码文件。- 返回的
Process
对象表示正在运行的编译进程,可以通过它获取编译过程中的输入输出流以及控制进程的行为。处理编译过程和结果:
- 调用自定义工具类
ProcessUtils.runProcessAndGetMessage(compileProcess, "编译")
来处理编译进程。这个方法通常负责:
- 读取并收集编译过程中的标准输出和错误输出信息。
- 等待编译进程结束并获取其退出状态码。
- 将上述信息封装到一个
ExecuteMessage
对象中返回。检查编译是否成功:
- 根据
ExecuteMessage
对象中的退出值(exitValue
)判断编译是否成功。如果退出值不为0(即executeMessage.getExitValue() != 0
),则认为编译失败,并抛出一个RuntimeException
异常,附带消息“编译错误”。异常处理:
- 如果在执行编译命令或处理编译过程中发生任何异常(如 IO 异常、编译命令执行失败等),则捕获这些异常并在当前实现中直接抛出一个新的
RuntimeException
,将原始异常作为其原因。注意,这里的异常处理策略可以根据实际需要调整,例如可以返回一个包含错误信息的响应对象而不是直接抛出异常。返回编译结果:
- 如果编译成功(即退出值为0),则返回封装了编译过程信息的
ExecuteMessage
对象给调用者。该对象包含了编译的标准输出、错误输出及退出状态码,供后续逻辑使用。
/**
* 进程执行信息
*/
@Data
public class ExecuteMessage {
private Integer exitValue;
private String message;
private String errorMessage;
private Long time;
private Long memory;
}
/**
* 2. 编译代码
*
* 此方法用于将用户保存的 Java 源代码文件(.java)进行编译,
* 生成对应的字节码文件(.class)。如果编译失败,会记录错误信息。
*
* @param userCodeFile 用户的 Java 源代码文件对象(已保存到磁盘)
* @return 返回一个 ExecuteMessage 对象,包含编译过程的标准输出、错误输出和退出码
*/
public ExecuteMessage compileFile(File userCodeFile) {
// 构造编译命令:javac -encoding utf-8 [源文件路径]
// -encoding utf-8 确保支持中文等字符集
String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
try {
// 使用 Runtime.getRuntime().exec() 执行系统命令 javac 进行编译
// 返回一个 Process 对象,表示正在运行的编译进程
Process compileProcess = Runtime.getRuntime().exec(compileCmd);
// 调用工具类 ProcessUtils.runProcessAndGetMessage() 来运行并监听编译过程
// 该方法会:
// - 读取标准输出流(System.out)
// - 读取错误输出流(System.err)
// - 获取进程退出码(exit code)
// 最终封装成一个 ExecuteMessage 对象返回
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(compileProcess, "编译");
// 判断编译是否成功:
// - 如果 exitValue == 0,说明编译通过
// - 如果 exitValue != 0,说明有语法错误或其他问题
if (executeMessage.getExitValue() != 0) {
// 抛出运行时异常,并提示“编译错误”
// 实际项目中可以改为更具体的异常类型或封装错误信息返回
throw new RuntimeException("编译错误");
}
// 如果编译成功,返回编译结果的信息对象
return executeMessage;
} catch (Exception e) {
// 捕获所有异常,包括 IO 异常、执行命令失败、中断等
// 可以选择记录日志或返回错误响应对象(如 getErrorResponse(e))
// 当前直接抛出运行时异常
// 注意:这里可以选择不抛出异常,而是返回 ExecuteMessage 错误对象
// 示例:return getErrorResponse(e);
throw new RuntimeException(e);
}
}
3. 执行代码,得到输出结果
✅ 目的:
在受控环境下运行用户代码,并捕获其输出(包括标准输出和错误输出)。
📌 原因:
- 用户代码可能包含无限循环、异常抛出、资源占用等风险行为。
- 需要限制执行时间、内存使用,防止系统崩溃或被攻击。
- 需要重定向
System.out
和System.err
来获取输出内容。
实现步骤:
获取用户代码文件的父目录路径:
- 使用
userCodeFile.getParentFile().getAbsolutePath()
获取用户代码文件所在目录的绝对路径。因为编译后的.class
文件与.java
文件位于同一目录下,所以这个路径可以用来指定 Java 运行时的类路径(-cp
参数)。初始化执行结果列表:
- 创建一个
ArrayList<ExecuteMessage>
类型的列表executeMessageList
用于存储每次运行的结果信息。每个ExecuteMessage
对象包含一次执行的标准输出、错误输出以及退出状态码。遍历输入参数列表:
- 使用
for (String inputArgs : inputList)
循环遍历传入的inputList
,其中每个元素代表一组测试用例的输入参数。构造运行命令:
- 使用
String.format()
方法构建用于运行 Java 程序的命令字符串。该命令包括以下部分:
-Xmx256m
:设置 JVM 最大堆内存为 256MB,以防止程序占用过多内存。-Dfile.encoding=UTF-8
:设置文件编码格式为 UTF-8,确保支持多语言字符集。-cp %s
:指定类路径为当前用户的代码目录。Main
:要执行的主类名(假设是Main.class
)。%s
:本次循环的输入参数,作为main
方法的参数传入。执行命令并启动超时监控:
- 使用
Runtime.getRuntime().exec(runCmd)
执行上述构造好的命令,启动一个子进程来运行 Java 程序。- 启动一个新的线程,使用
Thread.sleep(TIME_OUT)
来实现超时控制。如果超过设定的时间限制,则调用runProcess.destroy()
强制终止该进程,避免长时间占用资源或死循环等情况。处理运行过程和结果:
- 调用
ProcessUtils.runProcessAndGetMessage(runProcess, "运行")
方法处理运行进程。此方法通常会读取并收集标准输出和错误输出信息,并等待进程结束获取其退出状态码。- 将收集到的信息封装成一个
ExecuteMessage
对象,并将其添加到executeMessageList
中。异常处理:
- 如果在执行命令或处理过程中发生任何异常(例如 IO 异常、命令执行失败等),则捕获这些异常并抛出一个新的
RuntimeException
,附带原始异常作为原因。返回执行结果列表:
- 当所有输入参数都被处理完毕后,返回包含所有执行结果的
executeMessageList
列表。
/**
* 3. 执行文件,获得执行结果列表
*
* 此方法用于执行用户编译后的 Java 字节码文件(.class),并传入多组输入参数,
* 每次运行一个测试用例,并收集输出结果。
*
* @param userCodeFile 编译生成的 .class 文件所在目录中的源代码文件(用来获取父路径)
* @param inputList 用户提供的多个输入参数列表,代表多个测试用例
* @return 返回一个 ExecuteMessage 列表,每个元素对应一次运行的标准输出、错误输出和退出码
*/
public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
// 获取用户代码文件所在的父目录绝对路径(即存放 .class 文件的目录)
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
// 创建一个列表,用于存储每次执行的结果信息对象
List<ExecuteMessage> executeMessageList = new ArrayList<>();
// 遍历输入参数列表,依次对每一组输入执行程序
for (String inputArgs : inputList) {
// 构造 Java 运行命令:
// -Xmx256m: 设置 JVM 最大堆内存为 256MB,防止内存溢出
// -Dfile.encoding=UTF-8: 强制使用 UTF-8 编码,避免中文乱码
// -cp %s: 指定类路径为当前目录(即 userCodeParentPath)
// Main: 要执行的主类名(假设是 Main.class)
// %s: 本次循环的输入参数,作为 main 方法的 args 参数传入
String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
try {
// 使用 Runtime.getRuntime().exec() 执行构建好的 java 命令
Process runProcess = Runtime.getRuntime().exec(runCmd);
// 启动一个守护线程来监控执行时间,实现超时控制
new Thread(() -> {
try {
// 等待预设的超时时间(TIME_OUT,单位毫秒)
Thread.sleep(TIME_OUT);
// 如果还未执行完成,则强制终止进程
System.out.println("超时了,中断");
runProcess.destroy();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
// 使用工具类 ProcessUtils 来运行并监听进程的执行过程
// 该方法会读取标准输出流和错误输出流,并返回封装好的 ExecuteMessage 对象
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
// 将单次执行结果添加到结果列表中
executeMessageList.add(executeMessage);
} catch (Exception e) {
// 如果执行过程中出现异常(如 IO 错误、命令执行失败等),
// 则抛出运行时异常,并附带原始异常作为原因
throw new RuntimeException("执行错误", e);
}
}
// 返回所有测试用例执行后的结果列表
return executeMessageList;
}
4. 收集整理输出结果
✅ 目的:
将程序执行的标准输出、错误输出、退出码等信息整合后返回给调用者。
📌 原因:
- 提供给用户清晰的运行结果反馈。
- 包括成功输出、异常堆栈、超时提示、内存溢出警告等。
- 用于后续判断是否通过测试用例。
实现步骤:
初始化响应对象:
- 创建一个
ExecuteCodeResponse
对象executeCodeResponse
,用于封装最终返回给用户的响应信息。- 初始化一个
List<String>
类型的列表outputList
,用于存储所有成功执行的结果输出。初始化最大执行时间:
- 定义一个
long
类型变量maxTime
并初始化为 0,用于记录所有测试用例中最大的执行时间(毫秒),以便于后续判断是否超时或评估性能。遍历执行消息列表:
- 使用
for (ExecuteMessage executeMessage : executeMessageList)
遍历传入的executeMessageList
,每个executeMessage
包含一次执行的标准输出、错误输出、退出码及执行时间等信息。检查并处理错误信息:
- 获取当前执行结果中的错误信息
errorMessage
。- 如果
errorMessage
不为空且非空白字符串(使用StrUtil.isNotBlank(errorMessage)
检查),则表示该次执行出现了错误。
- 将错误信息设置到
executeCodeResponse
的message
字段中。- 设置
executeCodeResponse
的状态码为 3(代表代码在运行过程中出现错误)。- 立即跳出循环,停止进一步处理其他执行结果,因为一旦有错误发生,通常意味着整个过程失败。
收集标准输出和更新最大执行时间:
- 如果没有错误信息,则将当前执行结果的标准输出内容添加到
outputList
中。- 获取当前执行结果的执行时间
time
(单位可能是毫秒),如果该值不为空,则更新maxTime
为当前已知的最大值。检查全部成功执行情况:
- 在循环结束后,比较
outputList.size()
和executeMessageList.size()
。如果两者相等,说明所有输入参数对应的执行均成功完成,此时设置executeCodeResponse
的状态码为 1(表示全部运行成功,无任何错误)。设置输出结果列表:
- 将收集到的所有标准输出内容
outputList
设置到executeCodeResponse
的outputList
字段中。构建判题信息:
- 创建一个新的
JudgeInfo
对象judgeInfo
,用于封装判题所需的信息(如执行时间和内存占用)。- 将之前计算得到的最大执行时间
maxTime
设置到judgeInfo
的time
字段中。- (注:关于内存占用的获取较为复杂,通常需要借助 JVM 工具或者操作系统命令行工具,在 Java 进程中精确获取用户代码使用的内存非常困难,因此此处不做具体实现)
设置判题信息:
- 将
judgeInfo
设置到executeCodeResponse
的judgeInfo
字段中。返回响应对象:
- 返回填充完毕的
executeCodeResponse
对象,供调用者使用。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeResponse {
private List<String> outputList;
/**
* 接口信息
*/
private String message;
/**
* 执行状态
*/
private Integer status;
/**
* 判题信息
*/
private JudgeInfo judgeInfo;
}
/**
* 4. 获取输出结果
*
* 此方法根据代码执行过程中的多个执行结果(ExecuteMessage 列表),
* 构建最终返回给用户的响应对象 ExecuteCodeResponse。
* 主要功能包括:
* - 提取所有运行成功的结果
* - 检查是否有错误信息,并设置对应状态码
* - 收集最大执行时间等判题信息
*
* @param executeMessageList 执行过程中收集到的多个 ExecuteMessage 对象列表
* @return 返回封装好的 ExecuteCodeResponse 对象,包含输出、状态、判题信息等
*/
public ExecuteCodeResponse getOutputResponse(List<ExecuteMessage> executeMessageList) {
// 创建一个最终要返回的响应对象
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
// 用于存储所有成功的标准输出内容
List<String> outputList = new ArrayList<>();
// 用于记录所有测试用例中最大的执行时间(毫秒),便于判断是否超时
long maxTime = 0;
// 遍历每个 ExecuteMessage(即每次输入参数对应的执行结果)
for (ExecuteMessage executeMessage : executeMessageList) {
// 获取当前执行结果的错误信息(如果有)
String errorMessage = executeMessage.getErrorMessage();
// 如果错误信息不为空或非空白字符串,说明该次执行出错
if (StrUtil.isNotBlank(errorMessage)) {
// 将错误信息设置到响应对象中
executeCodeResponse.setMessage(errorMessage);
// 设置状态码为 3:代表代码在运行过程中出现错误(如异常、编译未通过等)
executeCodeResponse.setStatus(3);
// 出现错误后直接跳出循环,不再处理后续结果
break;
}
// 如果没有错误,则将本次运行的标准输出加入结果列表
outputList.add(executeMessage.getMessage());
// 获取本次运行的时间(单位可能是毫秒)
Long time = executeMessage.getTime();
// 如果时间有效,则更新最大时间
if (time != null) {
maxTime = Math.max(maxTime, time);
}
}
// 如果所有测试用例都成功运行完毕(输出数量等于执行次数)
if (outputList.size() == executeMessageList.size()) {
// 设置状态码为 1:表示全部运行成功,无任何错误
executeCodeResponse.setStatus(1);
}
// 将收集到的标准输出结果设置到响应对象中
executeCodeResponse.setOutputList(outputList);
// 创建并填充 JudgeInfo 判题信息对象(如时间和内存)
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setTime(maxTime); // 设置最大运行时间
// 内存占用获取较为复杂,通常需要借助 JVM 工具或者操作系统命令行工具(如 ps、top 等)
// 在 Java 进程中精确获取用户代码使用的内存非常困难,因此此处不做具体实现
// judgeInfo.setMemory();
// 将判题信息设置到响应对象中
executeCodeResponse.setJudgeInfo(judgeInfo);
// 返回完整的响应对象
return executeCodeResponse;
}
5. 文件清理,释放空间
✅ 目的:
删除临时生成的
.java
、.class
文件及目录,防止磁盘爆满。📌 原因:
- 沙箱频繁运行会导致大量临时文件堆积。
- 不及时清理会影响服务器性能与稳定性。
- 使用完即删是良好资源管理习惯。
实现步骤:
检查用户代码文件的父目录是否存在:
- 使用
if (userCodeFile.getParentFile() != null)
判断用户代码文件是否有父目录。如果该文件有父目录,说明它位于某个目录中,需要删除该目录及其所有内容;如果没有父目录,可能是根目录下的文件或其他特殊情况,直接认为删除成功。获取用户代码文件所在父目录的绝对路径:
- 使用
userCodeFile.getParentFile().getAbsolutePath()
获取用户代码文件所在父目录的绝对路径。这个路径指向包含.java
文件及其编译后生成的.class
文件的目录。递归删除整个目录及其内容:
- 调用
FileUtil.del(userCodeParentPath)
方法递归删除指定路径下的整个目录及其所有子目录和文件。FileUtil.del()
是一个工具方法,通常用于简化文件删除操作,并且支持递归删除。- 返回值
del
表示删除操作是否成功执行。打印删除操作的结果:
- 使用
System.out.println()
打印删除操作的结果,便于调试或记录日志。如果删除成功,输出“删除成功”;如果删除失败,输出“删除失败”。返回删除操作的结果:
- 根据删除操作的结果
del
,返回相应的布尔值。如果删除成功,返回true
;如果删除失败,返回false
。处理无父目录的情况:
- 如果用户代码文件没有父目录(即
userCodeFile.getParentFile()
返回null
),则默认认为删除成功,返回true
。这种情况较为罕见,但为了确保方法的健壮性,仍然需要处理。
/**
* 5. 删除文件
*
* 此方法用于删除用户代码文件及其所在的整个目录(包括编译生成的 .class 文件等),
* 以释放磁盘空间并保持环境清洁。
*
* @param userCodeFile 用户代码文件对象(通常为 .java 文件)
* @return 如果成功删除则返回 true,否则返回 false
*/
public boolean deleteFile(File userCodeFile) {
// 检查用户代码文件的父目录是否存在
if (userCodeFile.getParentFile() != null) {
// 获取用户代码文件所在父目录的绝对路径
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
// 使用 FileUtil.del() 方法递归删除整个目录及其内容
// 返回值表示操作是否成功
boolean del = FileUtil.del(userCodeParentPath);
// 打印删除操作的结果(仅用于调试或日志记录)
System.out.println("删除" + (del ? "成功" : "失败"));
// 返回删除操作的结果
return del;
}
// 如果用户代码文件没有父目录,则默认认为删除成功(这种情况很少见)
return true;
}
6. 错误处理,提升程序健壮性
✅ 目的:
对所有可能出现的异常进行捕获和处理,避免沙箱自身崩溃。
📌 原因:
- 用户代码可能存在语法错误、死循环、异常抛出等问题。
- 外部命令执行(如
Runtime.exec()
)可能失败。- IO 操作、路径访问、权限控制等都可能引发异常。
- 需要统一的日志记录、错误码返回机制。
实现步骤:
- 创建响应对象:初始化一个新的
ExecuteCodeResponse
对象。- 设置输出列表:将输出列表设置为空列表,表示没有正常输出。
- 设置错误消息:从传入的异常对象中获取错误消息,并将其设置到响应对象中。
- 设置状态码:将状态码设置为2,表示发生了代码沙箱错误。
- 初始化判题信息:初始化一个
JudgeInfo
对象并设置到响应对象中。- 返回响应对象:返回构建好的
ExecuteCodeResponse
对象。
/**
* 获取错误响应的方法
*
* @param e 异常对象,包含错误信息
* @return 包含错误信息的ExecuteCodeResponse对象
*/
private ExecuteCodeResponse getErrorResponse(Throwable e) {
// 创建一个新的ExecuteCodeResponse对象
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
// 设置输出列表为空列表
executeCodeResponse.setOutputList(new ArrayList<>());
// 设置错误消息为异常对象的消息
executeCodeResponse.setMessage(e.getMessage());
// 设置状态码为2,表示代码沙箱错误
executeCodeResponse.setStatus(2);
// 初始化并设置JudgeInfo对象
executeCodeResponse.setJudgeInfo(new JudgeInfo());
// 返回构建好的ExecuteCodeResponse对象
return executeCodeResponse;
}
至此,第一期的内容结束,不过我们可以发现一个问题,如果想要上线的话,安全么? 用户提交恶意代码,怎么办?
那针对这种情况,我们可以来提高程序安全性
这部分我放到下一期来讲!