OJ项目——最核心业务->用户刷题,手把手教会你【SpringMVC+MyBatis】

目录

前言 

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项目中最核心的业务,那它最核心的业务是什么呢?当然就是支持用户刷题呀,也就是说要检查用户提交的代码是否正确~

        你能不能做到呢,我先来问你几个问题:

  1. 用户提交的代码,你打算怎么做来检查他的代码是否能编译通过、运行、所有测试用例都能跑过?
  2. 接着上述问题,如果你想到的是起一个线程来解决,请问:用户提交的代码编译或者运行抛异常了怎么办?要知道多线程中,一个线程挂了,很可能其他线程都会挂掉的,相当于你的服务器就挂掉了呀~

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、梳理核心业务线

 业务线:

  1. 准备数据库:先要思考,数据库中的表如何进行构建
  2. 后端业务开始:先是从前端接收到用户提交的代码
  3. 后端业务:拼接整理用户提交的代码
  4. 后端业务:运行拼接后的代码
  5. 返回响应给前端

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、运行拼接后的代码【最核心】

先提出两个问题:

  • 运行的命令,我们如何构造?如何确定可执行程序的文件路径?
  • 上面我们有说明,运行拼接后的代码,其实是起了一个子进程来运行的。这里我们要等待子进程运行结束后,父进程才能继续运行。后续父进程中,我们还要读取子进程的标准输出和标准错误,那我们去哪儿找这个标准输出和错误呢?

        这里就需要我们来整理子进程的文件管理,把子进程需要使用的相关文件放到统一的地方,来供我们使用,并且这个文件的路径需要是相对路径,不然你部署项目后,就找不到路径了。

业务逻辑梳理:

  1. 收到完整的代码后,他是一个字符串,我们需要把这个字符串放到一个可执行程序中,也就是放到一个文件中
  2. 调用javac编译命令,来编译这个可执行程序【命令构造 + 执行 + 后续判断。命令构造:javac + 可执行程序文件名 +可执行程序的路径  ; 执行:执行构造出的命令,然后再把子进程的标准错误读取出来  ;  后续判断:检查刚才读取出的标注错误是否有值,如果有值则表示有编译错误】
  3. 调用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如何和数据库交互就不展示了,相信大家都可以滴~

好啦,本期就到这里了,此项目后续会继续更新~ 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙洋静

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值