JAVA - OJ沙箱(JAVA默认模板沙箱,JAVA操作dokcer沙箱)
一 沙箱
沙箱(Sandbox):一种安全机制,用于隔离运行环境,限制程序或代码的权限,防止其对系统或其他程序造成损害。沙箱通常用于运行不受信任的代码(如用户提交的代码、第三方插件等),以确保系统的安全性和稳定性。
核心目标:
隔离运行环境:为代码提供一个独立的运行环境,与主系统和其他程序隔离。从而确保,即使用户提供的代码有错误,或者崩溃,也不会影响到核心系统的正常运行。
限制权限:沙箱通过权限控制机制,从而限制代码对系统资源的调用以及影响。
安全性:通过权限限制以及对于运行环境的隔离,从而确保沙箱在可控的运行环境下运行,避免了沙箱对外部造成危害(例如删除资源,文件等)。
沙箱的关键特点
-
独立性:沙箱中的代码运行在一个独立的环境中,与外部系统隔离。
-
权限控制:沙箱限制了代码的权限,确保其只能访问允许的资源。
-
安全性:防止恶意代码对系统造成破坏。
-
可恢复性:如果代码运行失败,沙箱可以快速清理环境并重新启动
二 JAVA默认沙箱
这里我们先以JAVA默认沙箱为例,主要是依靠本地的JAVA环境运行,测试代码。
实际上对于这两种代码沙箱的方式,大致都可以分为5个步骤:
1. 保存用户代码,创建为文件
2. 对用户代码文件进行编译
3. 运行用户代码,并获取结果
4. 收集整理输出结果
5. 清理用户文件
2.1 保存用户代码
先梳理一下思路:
既然要保存文件,就必须确定文件保存的路径,一种是直接保存在当前目录,一种是自定义固定文件保存路径。这里我选择直接保存在当前工作目录,也就是程序运行的目录。
通过System.getProperty("user.dir")获取,如下。
同时,单独创建一个保存文件的文件夹,用以保存所有代码。
GLOBAL_CODE_DIR_NAME自定义即可。
//返还当前用户工作目录的绝对路径 其实也就是我们代码存放的位置
String userDir = System.getProperty("user.dir");
String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
// 判断全局代码目录是否存在,没有则新建
//将对应用户的所有代码都存放在一个目录当中 没有就创建
if (!FileUtil.exist(globalCodePathName)) {
FileUtil.mkdir(globalCodePathName);
}
再保存用户代码到对应文件当中,同时使用UUID拼接,最后加上Main.java,确保程序正确·命名,从而将用户代码隔离存放,并且返还保存的文件对象:
// 把用户的代码隔离存放
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
//用户提交代码相关信息的文件名
String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
//写入 有三个参数:写入的字符串,保存的路径,对应保存文件的字符编码
//代替保存的文件对象
File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);
return userCodeFile;
2.2代码文件编译
文件保存后,使用命令进行编译,并且指定编译的字符集跟文件名,这里的userCodeFile就是上文保存的文件信息,getAbsolutePath()以获取绝对路径。
String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
指令已经设定成功,那么如何使用JAVA执行外部操作?
这里提供有两种方式,一种是ProcessBuilder
另外一种是Runtime.getRuntime()
Runtime.getRuntime()
先说一下 Runtime.getRuntime(),这是一种比较老的方法了,将上面已经设置好的命令放入其中,即可执行对应的cmd命令。
优点:操作比较简单,exec
方法接受一个字符串(String
)作为参数。
缺点:如果命令中包含空格或特殊字符,可能会导致参数解析错误。同时,无法支持链式调用,无法配置运行环境,代码发生异常时,提示的异常信息也比较少。
Process compileProcess = Runtime.getRuntime().exec(compileCmd);
ProcessBuilder
是一种比较现代的方法,整体比较全面,基本上可以满足各种用户的需求
输入参数时,其采用列表的形式,避免了输入特殊字符造成的错误;同时还支持链式输入,配置文件目录,运行环境等。但是ProcessBuilder给出的方法比较多,学习成本相对比较高,并且对于我们当前的简单命令,也不需要配置什么环境,所以使用前者即可,有兴趣的可以了解一下ProcessBuilder
Process compileProcess = new ProcessBuilder("javac","-encoding","utf-8", userCodeFile.getAbsolutePath()).start();
线程启动之后,我们获取到了Process,那么我们一起简单了解一下:
Process类是 Java 中用于表示操作系统进程的一个类。它允许 Java 程序启动和管理外部进程(如执行系统命令、运行其他程序等),并与这些进程进行交互(如获取输入、输出、错误流,以及等待进程结束等)。
获取到对应进程之后,我们就可以开始对此进程进行一个“监控”,例如编译是否正常,不正常的话编译错误信息是什么,编译时长是多少等。
这里我们使用一个全新的概念(对我来说),StopWatch,作为一个计时器,相对于其他的计时器,其调用更加简单,高精度计时,甚至还能对多个程序同时计时,最后汇总操作时间等,有兴趣的搜索一下。
计时开始之后,我们开始等待对应线程执行完毕,要判断线程是否执行完毕以及线程的执行状态,可以用对应Process的方法,waitFor,使用这个方法的时候,程序会暂时阻塞起来,直到对应的runProcess完全执行完之后,之后返回对应操作状态,才继续之后的操作。
根据对应的状态码,我们就能判断是否正确执行了,返还0,正常执行;1有错误信息。
剩下的比较简单,不再赘述:
//收集数据
ExecuteMessage executeMessage = new ExecuteMessage();
try {
//相当于计时器,用来计算对应代码编译的时间长度
StopWatch stopWatch = new StopWatch();
//开始计时
stopWatch.start();
// waitFor会暂时阻塞起来,直到对应的runProcess完全执行完之后,之后返回对应操作状态,才继续之后的操作
int exitValue = runProcess.waitFor();
executeMessage.setExitValue(exitValue);
// 正常退出
if (exitValue == 0) {
System.out.println(opName + "成功");
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
List<String> outputStrList = new ArrayList<>();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
outputStrList.add(compileOutputLine);
}
executeMessage.setMessage(StringUtils.join(outputStrList, "\n"));
} else {
// 异常退出
System.out.println(opName + "失败,错误码: " + exitValue);
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
List<String> outputStrList = new ArrayList<>();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
outputStrList.add(compileOutputLine);
}
executeMessage.setMessage(StringUtils.join(outputStrList, "\n"));
// 分批获取进程的错误输出
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
// 逐行读取
List<String> errorOutputStrList = new ArrayList<>();
// 逐行读取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
errorOutputStrList.add(errorCompileOutputLine);
}
executeMessage.setErrorMessage(StringUtils.join(errorOutputStrList, "\n"));
}
stopWatch.stop();
executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
return executeMessage;
2.3文件执行
文件编译之后,执行的操作跟编译大差不差,设置指令,填写用户输入,执行线程。
我是将对应的输入放在List集合当中了,多个输入的话一个一个遍历,这里的输入对应的其实就是测试案例的输入了。
使用executeMessageList 将其全部保存起来,进行返还,以便之后使用。
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
List<ExecuteMessage> executeMessageList = new ArrayList<>();
for (String inputArgs : inputList) {
//当前默认的判题,其实就是在本地跑的,而非是基于docker上装配跑的
// String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
try {
Process runProcess = Runtime.getRuntime().exec(runCmd);
// 超时控制
new Thread(() -> {
try {
Thread.sleep(TIME_OUT);
System.out.println("超时了,中断");
runProcess.destroy();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
System.out.println(executeMessage);
executeMessageList.add(executeMessage);
} catch (Exception e) {
throw new RuntimeException("执行错误", e);
}
}
return executeMessageList;
这里需要解释的应该就是对应的超时控制了,对应的线程启动之后,我们再创建一个新的线程,并且启动,这个新的线程是为了监控runProcess的,为了放置其运行时间过长而占用大量系统资源。如果在TIME_OUT,也就是我们规定的时间没有完成任务,那么就会销毁线程,释放资源。
ProcessUtils.runProcessAndGetMessage(runProcess, "运行")跟编译时,设置对应编译时间,编译情况用的方法一样,这里用的是对运行时程序的信息填充。
2.4 获取执行结果
因为对应的算法题目一般都设置的有对应的时间限制,内存限制,所以我们需要将程序运行的结果再与题目要求的比对一下,设置运行程序的最大运行时间,以及占用内存等(获取占用内存比较繁琐)。不过多阐述。
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
List<String> outputList = new ArrayList<>();
// 取用时最大值,便于判断是否超时
long maxTime = 0;
for (ExecuteMessage executeMessage : executeMessageList) {
String errorMessage = executeMessage.getErrorMessage();
if (StrUtil.isNotBlank(errorMessage)) {
executeCodeResponse.setMessage(errorMessage);
// 用户提交的代码执行中存在错误
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()) {
executeCodeResponse.setStatus(1);
}
executeCodeResponse.setOutputList(outputList);
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setTime(maxTime);
// 要借助第三方库来获取内存占用,非常麻烦,此处不做实现 第三方技术获取内存占用情况
//judgeInfo.setMemory();
executeCodeResponse.setJudgeInfo(judgeInfo);
return executeCodeResponse;
2.5删除用户代码
程序既然已经运行完毕,那么自然是要将对应的文件进行删除,如下:
public boolean deleteFile(File userCodeFile) {
if (userCodeFile.getParentFile() != null) {
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除" + (del ? "成功" : "失败"));
return del;
}
return true;
}
docker沙箱放到明天再更新,马上整理完毕 ^-^ ,希望对大家有所帮助。
这里对应用户,题目提交的服务是与沙箱代码分开的,这里着重介绍了沙箱。需要的话我也会更新,希望对大家有所帮助。