目录
看到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<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;
- }
说明:
然后我们把这个待拼接以字符串的形式来存储在数据库中~
根据上述我们提供的代码模版和待拼接代码后,怎么拼接就是一目了然了:就是把待拼接代码拼接在模版代码里面,也就是拼接在用户提交的代码的最后一个 右花括号【 } 】的前面~
例如:
- 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<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 + '\'' +
- "}";
- }
- }
说明:
- 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;
- }
- }
核心代码到这里就实现完成了~
controller类中,如何组合刚才的逻辑,如下:

这里只是给大家展示一下大致的逻辑调用,相信大家自己梳理一下,也可以写出来的。这张图中,AjaxResult是我将返回值进行了统一处理,大家是实现时,按照自己的设计调整即可~
效果展示

上面弹窗中是提示运行成功!
这里出现的乱码,当项目部署到运行服务器上,就会自动解决了~
看后端:

会自动生成tmp的文件夹~
另外的前端代码和后端代码中调用service层,service层调用mapper层,mapper如何和数据库交互就不展示了,相信大家都可以滴~
好啦,本期就到这里了,此项目后续会继续更新~