• 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创建进程,有两种办法,这里我们只介绍一种,有兴趣的伙伴,可自行查一下另一种:

    代码示例:

    1. public static void main(String[] args) {
    2. Runtime runtime = Runtime.getRuntime();
    3. Process process = runtime.exec();
    4. }

     说明:

            Runtime是Java中内置的一个类,runtime.exec()方法,其中有很多参数,多的就不介绍了,只介绍我们接下来要使用的这一种,传一个参数,该参数为字符串,表示一个可执行程序的路径;执行这个方法,就会把指定路径的可执行程序,创建出进程并执行。这个参数怎么理解,我们这样说,大家会更明白点:这个参数就是我们在cmd命令行中敲的一行代码,这一行代码我们存为一个字符串,把这个字符串传给exec()方法,他的执行效果,和我们在cmd命令行中执行的效果是一样,举例说明:

    cmd:

    java:

             小伙伴们可以看到,这里的输出怎么什么也没有?

            这里是因为,子进程没有和IDEA的终端进行关联,所以我们在IDEA中是看不到子进程的输出滴~ 想要看到,就需要我们进行手动获取。他的相关信息都是写在文件中的,所以我们想要获取,就需要我们掌握文件的读写操作,下面我们有一起了解一下,但这里呢,我们只是需要知道,runtime.exec()这个方法的使用。我们接下来先了解进程的另一个操作:进程等待


    1.2、进程等待

            为什么要进行进程的等待?

             首先,(父子)进程之间是并发执行的关系,其次,在这个项目的业务中,我们是需要子进程运行用户提交的代码,运行结束后,要告知给父进程,运行结果是什么,根据相应的结果,父进程才能进行后续的操作~

    代码示例:

    1. Process process = Runtime.getRuntime().exec("javac");
    2. process.waitFor();//等待process结束,再执行后续操作

    2、前置知识:解决1.1中遗留的关于文件操作

     2.1、了解进程启动时,自动打开的文件

            当一个进程启动时,会自动打开三个文件:标准输入,标准输出、标准错误。不管是父进程还是子进程,都是这样的~  不同的是,父进程会和IDEA终端进行关联,因此父进程的标准输入对应到键盘、标准输出对应到显示器、标准错误也对应到显示器~

    问题来了,那子进程如何获取标准错误和标准输出?

    代码示例:

    1. //获取标准输出
    2. InputStream stdout = process.getInputStream();
    3. //获取标准错误
    4. InputStream stderr = process.getErrorStream();

    2.2、回顾文件的读写

    读文件:

    1. public static String readFile(String fileName) {
    2. StringBuilder result = new StringBuilder();
    3. try(FileReader fileReader = new FileReader(fileName)) {
    4. while (true) {
    5. int ch = fileReader.read();
    6. if(ch == -1) {
    7. break;
    8. }
    9. result.append((char)ch);
    10. }
    11. } catch (FileNotFoundException e) {
    12. e.printStackTrace();
    13. } catch (IOException e) {
    14. e.printStackTrace();
    15. }
    16. return result.toString();
    17. }

    说明:

    • 方法参数说明:这个方法,我们是读文件操作,方法的参数是待读的文件【其实就是带读文件的路径加文件名】
    • 为什么使用StringBuilder,因为这里的读文件,是按照字符来读取的,所以就涉及到字符串的拼接,并且不涉及到多线程,所以采用StringBuilder
    • 文件操作时,我们需要打开文件,关闭文件,为了避免不必要的麻烦,我们就将文件的打开放在try中,就可以省去了关闭文件这个操作

    写文件:

    1. public static void writeFile(String fileName,String content) {
    2. try(FileWriter fileWriter = new FileWriter(fileName)) {
    3. fileWriter.write(content);
    4. } catch (IOException e) {
    5. e.printStackTrace();
    6. }
    7. }

    说明:

    • 方法参数说明:第一个参数为,待写入的文件【就是待写入文件的路径和文件名】;第二个参数时需要写入的内容~

    3、梳理核心业务线

     业务线:

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

    3.1、数据库准备

     数据库准备以下字段:       

    • 题目id

    • 题目名字

    • 代码模版

    • 待拼接代码

    说明:

    • 代码模版:刷题上,一般人家都会给你一个模版,用户在模版的基础上写代码即可。并且在代码模版中,会规定待运行类的类名,例:Solution  ;后续在运行用户提交的代码时,就直接运行Solution.java即可
    •  待拼接代码:这个代码,下面我会举个例子,具体可自行发挥~

    3.2、代码模版设计

             代码模版,仿照牛客上,一般就是会给你一个public类,类里面会给你一个函数,然后你来编写函数内容即可。也正是因为使我们给提供代码模版,因此我们可以确定给用户的可执行程序的类名,例:Solution

    举例:反转链表的代码模版:

    1. class ListNode {
    2. int val;
    3. ListNode next = null;
    4. ListNode(int val) {
    5. this.val = val;
    6. }
    7. }
    8. public class Solution {
    9. public ListNode ReverseList(ListNode head) {
    10. //在此处编写代码
    11. }
    12. }

             上述代码,就是模版代码,我们会看到和牛客不同的是,我们在public上方,有一个ListNode类,这个类牛客是没有的呀。首先呢,这些都是可以实现的,只是后续会稍微复杂而已,当前我们先以这个为例~

            然后我们把这个代码模版以字符串的形式来存储在数据库中~

    3.3、待拼接代码设计

             因为用户提交的类已经是public类了,所以我们的待拼接代码肯定是要加在这个public类的里面的,先看下面的举例:

    举例:反转链表的待拼接代码:

    1. public static void main(String[] args) {
    2. Solution solution = new Solution();
    3. int[][] arr ={{1,2,3,4,5},{2,3,4,5,6},{9,8,7,6,5}};
    4. int caseCount = arr.length;
    5. int passCount = 0;
    6. for(int i = 0;i<arr.length;i++) {
    7. ListNode head = solution.Construction(arr[i]);
    8. ListNode cur = solution.Construction(arr[i]);
    9. ListNode head1 = solution.ReverseList(head);
    10. ListNode head2 = solution.ReverseList1(cur);
    11. while (head1 != null && head2 != null) {
    12. if(head1.val != head2.val) {
    13. System.out.println("用例通过:"+ passCount + "/" + caseCount);
    14. return;
    15. }
    16. head1 = head1.next;
    17. head2 = head2.next;
    18. }
    19. if(head1 != null || head2 != null) {
    20. System.out.println("用例通过:"+ passCount + "/" + caseCount);
    21. return;
    22. }
    23. passCount = passCount + 1;
    24. }
    25. System.out.println("运行通过!");
    26. }
    27. //标准代码
    28. public ListNode ReverseList1(ListNode head) {
    29. ListNode pre = null;
    30. while(head != null) {
    31. ListNode cur = head.next;
    32. head.next = pre;
    33. pre = head;
    34. head = cur;
    35. }
    36. return pre;
    37. }
    38. //构建链表
    39. public ListNode Construction(int[] arr) {
    40. ListNode head = new ListNode(arr[0]);
    41. ListNode cur = head;
    42. for (int i = 1;i<arr.length;i++) {
    43. cur.next = new ListNode(arr[i]);
    44. cur = cur.next;
    45. }
    46. return head;
    47. }

      说明:

    • main函数:main函数的主要逻辑是,有一个测试用例集合,然后记录测试用例的总个数,然后分别运行用户提交的代码和标准代码,后续对比这个结果是否相同,相同则用例通过,不相同则用例不通过,会在标准输出中,说明,有几个测试用例,通过的用例个数~
    • 用户提交的代码 VS 标准代码 :这两个方法是实现相同的功能,以此检查用户提交的代码是否正确~
    • 构建链表:我们的测试用例是以数组的形式记录的,要进行测试前,要将其构建为链表~ 
    • 后续扩展也可以将测试用例单独存储在数据库中,在代码拼接时,将测试用集合以数组的形式拼接到完整代码中~

            然后我们把这个待拼接以字符串的形式来存储在数据库中~

    3.4、 整理拼接代码

            根据上述我们提供的代码模版和待拼接代码后,怎么拼接就是一目了然了:就是把待拼接代码拼接在模版代码里面,也就是拼接在用户提交的代码的最后一个 右花括号【  }  】的前面~

            例如:

    1. public static String mergeCode(String SubmitCode,String positiveSolution) {
    2. int pos = SubmitCode.lastIndexOf("}");
    3. //没有找到{
    4. if(pos == -1) {
    5. return null;
    6. }
    7. //找到后,截取前半段
    8. String subSubCode = SubmitCode.substring(0,pos);
    9. //返回拼接的
    10. return subSubCode + positiveSolution + "\n}";
    11. }

    说明:

    • 第一个参数是用户提交的代码;第二个参数是待拼接代码
    • 取出最后一个右花括号的之前的代码,拼接后,记得再添加上右花括号~ 

    3.5、运行拼接后的代码【最核心】

    先提出两个问题:

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

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

    业务逻辑梳理:

    1. 收到完整的代码后,他是一个字符串,我们需要把这个字符串放到一个可执行程序中,也就是放到一个文件中
    2. 调用javac编译命令,来编译这个可执行程序【命令构造 + 执行 + 后续判断。命令构造:javac + 可执行程序文件名 +可执行程序的路径  ; 执行:执行构造出的命令,然后再把子进程的标准错误读取出来  ;  后续判断:检查刚才读取出的标注错误是否有值,如果有值则表示有编译错误】
    3. 调用java运行命令,来运行这个可执行程序【命令构造 + 执行 + 后续判断。 命令构造:java + 文件路径 + public修改的类名 ; 执行:执行后,把子进程的标准错误和标准输出读取出来 ; 后续判断:判断标准错误中是否有值,有值则表示代码运行出异常了  , 再判断标准输出中的值,因为运行不报错,不代表运行就正确了,可能测试用例没有完全通过~】

    代码:

    1. package com.example.demo.compile;
    2. import com.example.demo.common.FileOperations;
    3. import com.sun.org.apache.bcel.internal.classfile.Code;
    4. import org.springframework.context.annotation.Configuration;
    5. import java.io.File;
    6. import java.util.ArrayList;
    7. import java.util.List;
    8. import java.util.UUID;
    9. /**
    10. * Created with IntelliJ IDEA.
    11. * Description:
    12. * User:龙宝
    13. * Date:2023-10-08
    14. * Time:15:39
    15. * 此类为用户提交的OJ代码的编译运行过程
    16. */
    17. @Configuration
    18. public class Task {
    19. //通过一组常量来约定零时文件的名字
    20. private String WOKE_DIR = null;//临时文件的所在目录
    21. private String CLASS = null;//约定代码的类名
    22. private String COMPILE_BE = null;//约定待编译的代码文件名
    23. private String COMPILE_ERROR = null;//存放编译错误信息的文件名
    24. private String STDOUT = null;//存放运行时的标准输出的文件名
    25. private String STDERR = null;//存放标准错误信息的文件名
    26. public Task() {
    27. //使用UUID这个类生成一个UUID,来区分不用的文件夹
    28. WOKE_DIR = "./tmp/" + UUID.randomUUID().toString() + "/";
    29. CLASS = "Solution";
    30. COMPILE_BE = WOKE_DIR + "Solution.java";
    31. COMPILE_ERROR = WOKE_DIR + "compileError.txt";
    32. STDOUT = WOKE_DIR + "stdout.txt";
    33. STDERR = WOKE_DIR + "stdeer.txt";
    34. }
    35. //执行用户代码进行编译和运行 传来的参数就是待编译运行的代码
    36. public Answer CompileAndRun(String question) {
    37. Answer answer = new Answer();
    38. //1、准备用来存放临时文件的目录
    39. File workDir = new File(WOKE_DIR);
    40. if(!workDir.exists()) {
    41. //创建多级目录
    42. workDir.mkdirs();
    43. }
    44. //2、安全性判定
    45. if(!checkCodeSafe(question)) {
    46. //不安全则不能继续进行下去
    47. answer.setCode(3);
    48. answer.setMsgReason("您提交的代码存在违规代码,禁止运行!");
    49. return answer;
    50. }
    51. //3、把一个question中的code代码写入到一个Solution.Java文件中
    52. FileOperations.writeFile(COMPILE_BE,question);
    53. //4、创建子进程,调用javac进行编译
    54. //4.1先把命令构造出来
    55. String compileCmd = String.format("javac -encoding utf8 %s -d %s",COMPILE_BE,WOKE_DIR);
    56. //4.2、执行
    57. CommandExecute.run(compileCmd,null ,COMPILE_ERROR);//编译期间不关心他的标准输出,只关心他编译有没有出错
    58. //4.3、如果编译出错,错误信息就被记录到了COMPILE_ERROR中,如果没有出错,该文件为空
    59. String compileError = FileOperations.readFile(COMPILE_ERROR);
    60. if(!compileError.equals("")) {
    61. //不为空,编译出错,返回
    62. answer.setCode(1);
    63. answer.setMsgReason("编译出错!" + compileError);
    64. return answer;
    65. }
    66. //5、编译正确后,开始运行代码
    67. String runCmd = String.format("java -classpath %s %s",WOKE_DIR,CLASS);
    68. CommandExecute.run(runCmd,STDOUT,STDERR);
    69. String runError = FileOperations.readFile(STDERR);
    70. if(!runError.equals("")) {
    71. answer.setCode(2);
    72. answer.setMsgReason("运行出错!" + runError);
    73. return answer;
    74. }
    75. //6、代码能走到这里,说明用户提交的代码是可以运行
    76. //父进程获取到刚才的运行后的结果,并且打包成compile.Answer对象
    77. //检查是否可以运行通过
    78. String runStdout = FileOperations.readFile(STDOUT);
    79. //用例没有完全通过
    80. if(!runStdout.equals("运行通过!")) {
    81. answer.setCode(4);
    82. answer.setMsgReason(runStdout);
    83. return answer;
    84. }
    85. answer.setCode(0);
    86. answer.setStdout(FileOperations.readFile(STDOUT));
    87. return answer;
    88. }
    89. private boolean checkCodeSafe(String code) {
    90. List<String> blackList = new ArrayList<>();
    91. //防止提交的代码恶意运行程序
    92. blackList.add("Runtime");
    93. blackList.add("exec");
    94. //禁止提交代码操作文件
    95. blackList.add("java.io");
    96. //禁止提交代码访问网络
    97. blackList.add("java.net");
    98. for(String target : blackList) {
    99. int pos = code.indexOf(target);
    100. if(pos >= 0) {
    101. return false;//找到了恶意代码,返回false1表示不安全
    102. }
    103. }
    104. return true;
    105. }
    106. }

    其中answer类说明:

    1. package com.example.demo.compile;
    2. import lombok.Data;
    3. /**
    4. * Created with IntelliJ IDEA.
    5. * Description:
    6. * User:龙宝
    7. * Date:2023-10-08
    8. * Time:15:27
    9. * 此类表示task的输出内容
    10. */
    11. @Data
    12. public class Answer {
    13. private Integer code;//状态码:0-》运行编译都ok,1表示编译出错,2表示运行出错(会抛异常滴,3表示代码中有违规代码,4表示可以运行,但是用例没有完全通过~
    14. private String msgReason;//出错的提示信息,不管是编译出错还是运行出错,都是放其对应的出错信息
    15. private String stdout;//标准输出结果
    16. private String stderr;//标准错误信息
    17. @Override
    18. public String toString() {
    19. return "Compile.Answer{" +
    20. "code=" + code +
    21. ",reason='" + msgReason +'\'' +
    22. ",stdout='" + stdout + '\'' +
    23. ",stderr='" + stderr + '\'' +
    24. "}";
    25. }
    26. }

    说明:

    • 代码中指定的Solution和Solution.java,我们是可以更换名字,我这里使用Solution,是因为我在前面给定的代码模版的public类的类名是Solution
    • 其中之所以要进行安全性判定,是因为用户提交的代码是要放在父进程下的文件中运行的,防止用户恶意破坏或恶意运行程序,因此,要对用户提交的代码做出一定的限制~
    • 我们在读写文件时,是直接调用writeFile方法和readFile方法的,这两个方法都是自己编写的,具体如何编写,上述回顾文件读写时,已经写了,就不赘述了
    • 其中的CommandExecute.run()方法就是封装了创建子进程、运行子进程的相关操作,代码如下:
    1. package com.example.demo.compile;
    2. import java.io.FileOutputStream;
    3. import java.io.IOException;
    4. import java.io.InputStream;
    5. /**
    6. * Created with IntelliJ IDEA.
    7. * Description:
    8. * User:龙宝
    9. * Date:2023-10-08
    10. * Time:16:50
    11. * 该类时对于执行编译和运行命令的封装
    12. */
    13. public class CommandExecute {
    14. /**
    15. *
    16. * @param cmd 命令
    17. * @param stdoutFile 标准结果存放的文件路径及文件名
    18. * @param stderrFile 标准错误存放的文件路径及文件名
    19. * @return
    20. */
    21. public static int run(String cmd,String stdoutFile,String stderrFile) {
    22. try {
    23. //1、通过Runtime类得到Runtime实例,执行exec方法
    24. Process process = Runtime.getRuntime().exec(cmd);
    25. //2、获取到标准输出,并写入到指定文件中
    26. if(stdoutFile != null) {
    27. InputStream stdoutFrom = process.getInputStream();
    28. FileOutputStream stdoutTO = new FileOutputStream(stdoutFile);
    29. while (true) {
    30. int ch = stdoutFrom.read();
    31. if(ch == -1) {
    32. break;
    33. }
    34. stdoutTO.write(ch);
    35. }
    36. stdoutFrom.close();
    37. stdoutTO.close();
    38. }
    39. //3、获取到标准错误,写入指定文件
    40. if(stderrFile != null) {
    41. InputStream stderrFrom = process.getErrorStream();
    42. FileOutputStream stderrTo = new FileOutputStream(stderrFile);
    43. while (true) {
    44. int ch = stderrFrom.read();
    45. if(ch == -1) {
    46. break;
    47. }
    48. stderrTo.write(ch);
    49. }
    50. stderrFrom.close();
    51. stderrTo.close();
    52. }
    53. //4、子进程结束,拿到子进程的状态码,并返回
    54. int exitCode = process.waitFor();
    55. return exitCode;
    56. } catch (IOException | InterruptedException e) {
    57. e.printStackTrace();
    58. }
    59. return 1;
    60. }
    61. }

            核心代码到这里就实现完成了~


    4、小结

    controller类中,如何组合刚才的逻辑,如下:

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

    效果展示

            

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

    看后端:

    会自动生成tmp的文件夹~

            另外的前端代码和后端代码中调用service层,service层调用mapper层,mapper如何和数据库交互就不展示了,相信大家都可以滴~

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

  • 相关阅读:
    前端ES6结构赋值详解大全
    python数据分析-matplotlib绘制折线图
    数据分析9
    iOS Flutter Engine源码调试和修改
    银行卡证识别易语言
    Docker Desktop安装以及MYSQL, GRAFANA安装
    首都博物京韵展,监测系统实现文物科技保护
    leetcode 45
    实现http流式输出的最小实践
    JDK1.8中HashMap的底层实现
  • 原文地址:https://blog.csdn.net/LYJbao/article/details/133798183