目录
前言
1、前置知识:用户提交的代码,能不能采用多进程的方式来运行
1.1、Java中如何进行多进程的操作
1.2、进程创建
1.2、进程等待
2、前置知识:解决1.1中遗留的关于文件操作
2.1、了解进程启动时,自动打开的文件
2.2、回顾文件的读写
3、梳理核心业务线
3.1、数据库准备
3.2、代码模版设计
3.3、待拼接代码设计
3.4、 整理拼接代码
3.5、运行拼接后的代码【最核心】
4、小结
效果展示
看到OJ,大家都会想到牛客呀~LeetCode呀~等等这些刷题网站,那如果我们自己来设计一个这样的网站,你可以吗?现在自己心里画个问号哈~
像标题所说,今天我们讨论的是如何实现OJ项目中最核心的业务,那它最核心的业务是什么呢?当然就是支持用户刷题呀,也就是说要检查用户提交的代码是否正确~
你能不能做到呢,我先来问你几个问题:
回答:能。上述已经提到了,一个多线程挂了,会引起这个进程挂了,因此,我们采用多进程来解决这个问题。
Java中对系统提供的多进程编程做出了很多限制,因为Java更多的是多线程嘛,所以说,这样的限制对我们一般的需求来说,是没有什么问题的~
Java在进行限制后,最终只给我们提供了两个操作
咱们新创建的进程,其实就叫做“子进程”,而我们创建子进程的这个进程就是“父进程”。
而在我们这个项目中,我们自己的这个服务器就是父进程;父进程内部新创建的进程是子进程,父进程在创建子进程时,会给其发送用户提交的代码,而这个子进程就专门来处理用户提交的代码~
Java创建进程,有两种办法,这里我们只介绍一种,有兴趣的伙伴,可自行查一下另一种:
代码示例:
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
Process process = runtime.exec();
}
说明:
Runtime是Java中内置的一个类,runtime.exec()方法,其中有很多参数,多的就不介绍了,只介绍我们接下来要使用的这一种,传一个参数,该参数为字符串,表示一个可执行程序的路径;执行这个方法,就会把指定路径的可执行程序,创建出进程并执行。这个参数怎么理解,我们这样说,大家会更明白点:这个参数就是我们在cmd命令行中敲的一行代码,这一行代码我们存为一个字符串,把这个字符串传给exec()方法,他的执行效果,和我们在cmd命令行中执行的效果是一样,举例说明:
cmd:
java:
这里是因为,子进程没有和IDEA的终端进行关联,所以我们在IDEA中是看不到子进程的输出滴~ 想要看到,就需要我们进行手动获取。他的相关信息都是写在文件中的,所以我们想要获取,就需要我们掌握文件的读写操作,下面我们有一起了解一下,但这里呢,我们只是需要知道,runtime.exec()这个方法的使用。我们接下来先了解进程的另一个操作:进程等待
为什么要进行进程的等待?
首先,(父子)进程之间是并发执行的关系,其次,在这个项目的业务中,我们是需要子进程运行用户提交的代码,运行结束后,要告知给父进程,运行结果是什么,根据相应的结果,父进程才能进行后续的操作~
代码示例:
Process process = Runtime.getRuntime().exec("javac");
process.waitFor();//等待process结束,再执行后续操作
当一个进程启动时,会自动打开三个文件:标准输入,标准输出、标准错误。不管是父进程还是子进程,都是这样的~ 不同的是,父进程会和IDEA终端进行关联,因此父进程的标准输入对应到键盘、标准输出对应到显示器、标准错误也对应到显示器~
问题来了,那子进程如何获取标准错误和标准输出?
代码示例:
//获取标准输出
InputStream stdout = process.getInputStream();
//获取标准错误
InputStream stderr = process.getErrorStream();
读文件:
public static String readFile(String fileName) {
StringBuilder result = new StringBuilder();
try(FileReader fileReader = new FileReader(fileName)) {
while (true) {
int ch = fileReader.read();
if(ch == -1) {
break;
}
result.append((char)ch);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return result.toString();
}
说明:
写文件:
public static void writeFile(String fileName,String content) {
try(FileWriter fileWriter = new FileWriter(fileName)) {
fileWriter.write(content);
} catch (IOException e) {
e.printStackTrace();
}
}
说明:
业务线:
数据库准备以下字段:
题目id
题目名字
代码模版
待拼接代码
说明:
代码模版,仿照牛客上,一般就是会给你一个public类,类里面会给你一个函数,然后你来编写函数内容即可。也正是因为使我们给提供代码模版,因此我们可以确定给用户的可执行程序的类名,例:Solution
举例:反转链表的代码模版:
class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
public class Solution {
public ListNode ReverseList(ListNode head) {
//在此处编写代码
}
}
上述代码,就是模版代码,我们会看到和牛客不同的是,我们在public上方,有一个ListNode类,这个类牛客是没有的呀。首先呢,这些都是可以实现的,只是后续会稍微复杂而已,当前我们先以这个为例~
然后我们把这个代码模版以字符串的形式来存储在数据库中~
因为用户提交的类已经是public类了,所以我们的待拼接代码肯定是要加在这个public类的里面的,先看下面的举例:
举例:反转链表的待拼接代码:
public static void main(String[] args) {
Solution solution = new Solution();
int[][] arr ={{1,2,3,4,5},{2,3,4,5,6},{9,8,7,6,5}};
int caseCount = arr.length;
int passCount = 0;
for(int i = 0;i
说明:
然后我们把这个待拼接以字符串的形式来存储在数据库中~
根据上述我们提供的代码模版和待拼接代码后,怎么拼接就是一目了然了:就是把待拼接代码拼接在模版代码里面,也就是拼接在用户提交的代码的最后一个 右花括号【 } 】的前面~
例如:
public static String mergeCode(String SubmitCode,String positiveSolution) {
int pos = SubmitCode.lastIndexOf("}");
//没有找到{
if(pos == -1) {
return null;
}
//找到后,截取前半段
String subSubCode = SubmitCode.substring(0,pos);
//返回拼接的
return subSubCode + positiveSolution + "\n}";
}
说明:
先提出两个问题:
这里就需要我们来整理子进程的文件管理,把子进程需要使用的相关文件放到统一的地方,来供我们使用,并且这个文件的路径需要是相对路径,不然你部署项目后,就找不到路径了。
业务逻辑梳理:
代码:
package com.example.demo.compile;
import com.example.demo.common.FileOperations;
import com.sun.org.apache.bcel.internal.classfile.Code;
import org.springframework.context.annotation.Configuration;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Created with IntelliJ IDEA.
* Description:
* User:龙宝
* Date:2023-10-08
* Time:15:39
* 此类为用户提交的OJ代码的编译运行过程
*/
@Configuration
public class Task {
//通过一组常量来约定零时文件的名字
private String WOKE_DIR = null;//临时文件的所在目录
private String CLASS = null;//约定代码的类名
private String COMPILE_BE = null;//约定待编译的代码文件名
private String COMPILE_ERROR = null;//存放编译错误信息的文件名
private String STDOUT = null;//存放运行时的标准输出的文件名
private String STDERR = null;//存放标准错误信息的文件名
public Task() {
//使用UUID这个类生成一个UUID,来区分不用的文件夹
WOKE_DIR = "./tmp/" + UUID.randomUUID().toString() + "/";
CLASS = "Solution";
COMPILE_BE = WOKE_DIR + "Solution.java";
COMPILE_ERROR = WOKE_DIR + "compileError.txt";
STDOUT = WOKE_DIR + "stdout.txt";
STDERR = WOKE_DIR + "stdeer.txt";
}
//执行用户代码进行编译和运行 传来的参数就是待编译运行的代码
public Answer CompileAndRun(String question) {
Answer answer = new Answer();
//1、准备用来存放临时文件的目录
File workDir = new File(WOKE_DIR);
if(!workDir.exists()) {
//创建多级目录
workDir.mkdirs();
}
//2、安全性判定
if(!checkCodeSafe(question)) {
//不安全则不能继续进行下去
answer.setCode(3);
answer.setMsgReason("您提交的代码存在违规代码,禁止运行!");
return answer;
}
//3、把一个question中的code代码写入到一个Solution.Java文件中
FileOperations.writeFile(COMPILE_BE,question);
//4、创建子进程,调用javac进行编译
//4.1先把命令构造出来
String compileCmd = String.format("javac -encoding utf8 %s -d %s",COMPILE_BE,WOKE_DIR);
//4.2、执行
CommandExecute.run(compileCmd,null ,COMPILE_ERROR);//编译期间不关心他的标准输出,只关心他编译有没有出错
//4.3、如果编译出错,错误信息就被记录到了COMPILE_ERROR中,如果没有出错,该文件为空
String compileError = FileOperations.readFile(COMPILE_ERROR);
if(!compileError.equals("")) {
//不为空,编译出错,返回
answer.setCode(1);
answer.setMsgReason("编译出错!" + compileError);
return answer;
}
//5、编译正确后,开始运行代码
String runCmd = String.format("java -classpath %s %s",WOKE_DIR,CLASS);
CommandExecute.run(runCmd,STDOUT,STDERR);
String runError = FileOperations.readFile(STDERR);
if(!runError.equals("")) {
answer.setCode(2);
answer.setMsgReason("运行出错!" + runError);
return answer;
}
//6、代码能走到这里,说明用户提交的代码是可以运行
//父进程获取到刚才的运行后的结果,并且打包成compile.Answer对象
//检查是否可以运行通过
String runStdout = FileOperations.readFile(STDOUT);
//用例没有完全通过
if(!runStdout.equals("运行通过!")) {
answer.setCode(4);
answer.setMsgReason(runStdout);
return answer;
}
answer.setCode(0);
answer.setStdout(FileOperations.readFile(STDOUT));
return answer;
}
private boolean checkCodeSafe(String code) {
List blackList = new ArrayList<>();
//防止提交的代码恶意运行程序
blackList.add("Runtime");
blackList.add("exec");
//禁止提交代码操作文件
blackList.add("java.io");
//禁止提交代码访问网络
blackList.add("java.net");
for(String target : blackList) {
int pos = code.indexOf(target);
if(pos >= 0) {
return false;//找到了恶意代码,返回false1表示不安全
}
}
return true;
}
}
其中answer类说明:
package com.example.demo.compile;
import lombok.Data;
/**
* Created with IntelliJ IDEA.
* Description:
* User:龙宝
* Date:2023-10-08
* Time:15:27
* 此类表示task的输出内容
*/
@Data
public class Answer {
private Integer code;//状态码:0-》运行编译都ok,1表示编译出错,2表示运行出错(会抛异常滴,3表示代码中有违规代码,4表示可以运行,但是用例没有完全通过~
private String msgReason;//出错的提示信息,不管是编译出错还是运行出错,都是放其对应的出错信息
private String stdout;//标准输出结果
private String stderr;//标准错误信息
@Override
public String toString() {
return "Compile.Answer{" +
"code=" + code +
",reason='" + msgReason +'\'' +
",stdout='" + stdout + '\'' +
",stderr='" + stderr + '\'' +
"}";
}
}
说明:
package com.example.demo.compile;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* Created with IntelliJ IDEA.
* Description:
* User:龙宝
* Date:2023-10-08
* Time:16:50
* 该类时对于执行编译和运行命令的封装
*/
public class CommandExecute {
/**
*
* @param cmd 命令
* @param stdoutFile 标准结果存放的文件路径及文件名
* @param stderrFile 标准错误存放的文件路径及文件名
* @return
*/
public static int run(String cmd,String stdoutFile,String stderrFile) {
try {
//1、通过Runtime类得到Runtime实例,执行exec方法
Process process = Runtime.getRuntime().exec(cmd);
int exitCode = process.waitFor();
//2、获取到标准输出,并写入到指定文件中
if(stdoutFile != null) {
InputStream stdoutFrom = process.getInputStream();
FileOutputStream stdoutTO = new FileOutputStream(stdoutFile);
while (true) {
int ch = stdoutFrom.read();
if(ch == -1) {
break;
}
stdoutTO.write(ch);
}
stdoutFrom.close();
stdoutTO.close();
}
//3、获取到标准错误,写入指定文件
if(stderrFile != null) {
InputStream stderrFrom = process.getErrorStream();
FileOutputStream stderrTo = new FileOutputStream(stderrFile);
while (true) {
int ch = stderrFrom.read();
if(ch == -1) {
break;
}
stderrTo.write(ch);
}
stderrFrom.close();
stderrTo.close();
}
//4、子进程结束,拿到子进程的状态码,并返回
return exitCode;
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
return 1;
}
}
核心代码到这里就实现完成了~
controller类中,如何组合刚才的逻辑,如下:
这里只是给大家展示一下大致的逻辑调用,相信大家自己梳理一下,也可以写出来的。这张图中,AjaxResult是我将返回值进行了统一处理,大家是实现时,按照自己的设计调整即可~
效果展示
上面弹窗中是提示运行成功!
这里出现的乱码,当项目部署到运行服务器上,就会自动解决了~
看后端:
会自动生成tmp的文件夹~
另外的前端代码和后端代码中调用service层,service层调用mapper层,mapper如何和数据库交互就不展示了,相信大家都可以滴~
好啦,本期就到这里了,此项目后续会继续更新~