目录
前言
看到OJ,大家都会想到牛客呀~LeetCode呀~等等这些刷题网站,那如果我们自己来设计一个这样的网站,你可以吗?现在自己心里画个问号哈~
像标题所说,今天我们讨论的是如何实现OJ项目中最核心的业务,那它最核心的业务是什么呢?当然就是支持用户刷题呀,也就是说要检查用户提交的代码是否正确~
你能不能做到呢,我先来问你几个问题:
- 用户提交的代码,你打算怎么做来检查他的代码是否能编译通过、运行、所有测试用例都能跑过?
- 接着上述问题,如果你想到的是起一个线程来解决,请问:用户提交的代码编译或者运行抛异常了怎么办?要知道多线程中,一个线程挂了,很可能其他线程都会挂掉的,相当于你的服务器就挂掉了呀~
1、前置知识:用户提交的代码,能不能采用多进程的方式来运行
回答:能。上述已经提到了,一个多线程挂了,会引起这个进程挂了,因此,我们采用多进程来解决这个问题。
1.1、Java中如何进行多进程的操作
Java中对系统提供的多进程编程做出了很多限制,因为Java更多的是多线程嘛,所以说,这样的限制对我们一般的需求来说,是没有什么问题的~
Java在进行限制后,最终只给我们提供了两个操作
- 进程创建
- 进程等待
1.2、进程创建
咱们新创建的进程,其实就叫做“子进程”,而我们创建子进程的这个进程就是“父进程”。
而在我们这个项目中,我们自己的这个服务器就是父进程;父进程内部新创建的进程是子进程,父进程在创建子进程时,会给其发送用户提交的代码,而这个子进程就专门来处理用户提交的代码~
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()这个方法的使用。我们接下来先了解进程的另一个操作:进程等待
1.2、进程等待
为什么要进行进程的等待?
首先,(父子)进程之间是并发执行的关系,其次,在这个项目的业务中,我们是需要子进程运行用户提交的代码,运行结束后,要告知给父进程,运行结果是什么,根据相应的结果,父进程才能进行后续的操作~
代码示例:
Process process = Runtime.getRuntime().exec("javac");
process.waitFor();//等待process结束,再执行后续操作
2、前置知识:解决1.1中遗留的关于文件操作
2.1、了解进程启动时,自动打开的文件
当一个进程启动时,会自动打开三个文件:标准输入,标准输出、标准错误。不管是父进程还是子进程,都是这样的~ 不同的是,父进程会和IDEA终端进行关联,因此父进程的标准输入对应到键盘、标准输出对应到显示器、标准错误也对应到显示器~
问题来了,那子进程如何获取标准错误和标准输出?
代码示例:
//获取标准输出
InputStream stdout = process.getInputStream();
//获取标准错误
InputStream stderr = process.getErrorStream();
2.2、回顾文件的读写
读文件:
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();
}
说明:
- 方法参数说明:这个方法,我们是读文件操作,方法的参数是待读的文件【其实就是带读文件的路径加文件名】
- 为什么使用StringBuilder,因为这里的读文件,是按照字符来读取的,所以就涉及到字符串的拼接,并且不涉及到多线程,所以采用StringBuilder
- 文件操作时,我们需要打开文件,关闭文件,为了避免不必要的麻烦,我们就将文件的打开放在try中,就可以省去了关闭文件这个操作
写文件:
public static void writeFile(String fileName,String content) {
try(FileWriter fileWriter = new FileWriter(fileName)) {
fileWriter.write(content);
} catch (IOException e) {
e.printStackTrace();
}
}
说明:
- 方法参数说明:第一个参数为,待写入的文件【就是待写入文件的路径和文件名】;第二个参数时需要写入的内容~
3、梳理核心业务线
业务线:
- 准备数据库:先要思考,数据库中的表如何进行构建
- 后端业务开始:先是从前端接收到用户提交的代码
- 后端业务:拼接整理用户提交的代码
- 后端业务:运行拼接后的代码
- 返回响应给前端
3.1、数据库准备
数据库准备以下字段:
-
题目id
-
题目名字
-
代码模版
-
待拼接代码
说明:
- 代码模版:刷题上,一般人家都会给你一个模版,用户在模版的基础上写代码即可。并且在代码模版中,会规定待运行类的类名,例:Solution ;后续在运行用户提交的代码时,就直接运行Solution.java即可
- 待拼接代码:这个代码,下面我会举个例子,具体可自行发挥~
3.2、代码模版设计
代码模版,仿照牛客上,一般就是会给你一个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类,这个类牛客是没有的呀。首先呢,这些都是可以实现的,只是后续会稍微复杂而已,当前我们先以这个为例~
然后我们把这个代码模版以字符串的形式来存储在数据库中~
3.3、待拼接代码设计
因为用户提交的类已经是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<arr.length;i++) {
ListNode head = solution.Construction(arr[i]);
ListNode cur = solution.Construction(arr[i]);
ListNode head1 = solution.ReverseList(head);
ListNode head2 = solution.ReverseList1(cur);
while (head1 != null && head2 != null) {
if(head1.val != head2.val) {
System.out.println("用例通过:"+ passCount + "/" + caseCount);
return;
}
head1 = head1.next;
head2 = head2.next;
}
if(head1 != null || head2 != null) {
System.out.println("用例通过:"+ passCount + "/" + caseCount);
return;
}
passCount = passCount + 1;
}
System.out.println("运行通过!");
}
//标准代码
public ListNode ReverseList1(ListNode head) {
ListNode pre = null;
while(head != null) {
ListNode cur = head.next;
head.next = pre;
pre = head;
head = cur;
}
return pre;
}
//构建链表
public ListNode Construction(int[] arr) {
ListNode head = new ListNode(arr[0]);
ListNode cur = head;
for (int i = 1;i<arr.length;i++) {
cur.next = new ListNode(arr[i]);
cur = cur.next;
}
return head;
}
说明:
- main函数:main函数的主要逻辑是,有一个测试用例集合,然后记录测试用例的总个数,然后分别运行用户提交的代码和标准代码,后续对比这个结果是否相同,相同则用例通过,不相同则用例不通过,会在标准输出中,说明,有几个测试用例,通过的用例个数~
- 用户提交的代码 VS 标准代码 :这两个方法是实现相同的功能,以此检查用户提交的代码是否正确~
- 构建链表:我们的测试用例是以数组的形式记录的,要进行测试前,要将其构建为链表~
- 后续扩展也可以将测试用例单独存储在数据库中,在代码拼接时,将测试用集合以数组的形式拼接到完整代码中~
然后我们把这个待拼接以字符串的形式来存储在数据库中~
3.4、 整理拼接代码
根据上述我们提供的代码模版和待拼接代码后,怎么拼接就是一目了然了:就是把待拼接代码拼接在模版代码里面,也就是拼接在用户提交的代码的最后一个 右花括号【 } 】的前面~
例如:
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}";
}
说明:
- 第一个参数是用户提交的代码;第二个参数是待拼接代码
- 取出最后一个右花括号的之前的代码,拼接后,记得再添加上右花括号~
3.5、运行拼接后的代码【最核心】
先提出两个问题:
- 运行的命令,我们如何构造?如何确定可执行程序的文件路径?
- 上面我们有说明,运行拼接后的代码,其实是起了一个子进程来运行的。这里我们要等待子进程运行结束后,父进程才能继续运行。后续父进程中,我们还要读取子进程的标准输出和标准错误,那我们去哪儿找这个标准输出和错误呢?
这里就需要我们来整理子进程的文件管理,把子进程需要使用的相关文件放到统一的地方,来供我们使用,并且这个文件的路径需要是相对路径,不然你部署项目后,就找不到路径了。
业务逻辑梳理:
- 收到完整的代码后,他是一个字符串,我们需要把这个字符串放到一个可执行程序中,也就是放到一个文件中
- 调用javac编译命令,来编译这个可执行程序【命令构造 + 执行 + 后续判断。命令构造:javac + 可执行程序文件名 +可执行程序的路径 ; 执行:执行构造出的命令,然后再把子进程的标准错误读取出来 ; 后续判断:检查刚才读取出的标注错误是否有值,如果有值则表示有编译错误】
- 调用java运行命令,来运行这个可执行程序【命令构造 + 执行 + 后续判断。 命令构造:java + 文件路径 + public修改的类名 ; 执行:执行后,把子进程的标准错误和标准输出读取出来 ; 后续判断:判断标准错误中是否有值,有值则表示代码运行出异常了 , 再判断标准输出中的值,因为运行不报错,不代表运行就正确了,可能测试用例没有完全通过~】
代码:
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<String> 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 + '\'' +
"}";
}
}
说明:
- 代码中指定的Solution和Solution.java,我们是可以更换名字,我这里使用Solution,是因为我在前面给定的代码模版的public类的类名是Solution
- 其中之所以要进行安全性判定,是因为用户提交的代码是要放在父进程下的文件中运行的,防止用户恶意破坏或恶意运行程序,因此,要对用户提交的代码做出一定的限制~
- 我们在读写文件时,是直接调用writeFile方法和readFile方法的,这两个方法都是自己编写的,具体如何编写,上述回顾文件读写时,已经写了,就不赘述了
- 其中的CommandExecute.run()方法就是封装了创建子进程、运行子进程的相关操作,代码如下:
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);
//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、子进程结束,拿到子进程的状态码,并返回
int exitCode = process.waitFor();
return exitCode;
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
return 1;
}
}
核心代码到这里就实现完成了~
4、小结
controller类中,如何组合刚才的逻辑,如下:
这里只是给大家展示一下大致的逻辑调用,相信大家自己梳理一下,也可以写出来的。这张图中,AjaxResult是我将返回值进行了统一处理,大家是实现时,按照自己的设计调整即可~
效果展示
上面弹窗中是提示运行成功!
这里出现的乱码,当项目部署到运行服务器上,就会自动解决了~
看后端:
会自动生成tmp的文件夹~
另外的前端代码和后端代码中调用service层,service层调用mapper层,mapper如何和数据库交互就不展示了,相信大家都可以滴~
好啦,本期就到这里了,此项目后续会继续更新~