在线 OJ 项目(一) · 项目介绍 · 进程与线程 · 实现编译运行模块

一、项目介绍

项目实现一个在线 OJ 平台,核心功能:

  1. 能够管理题目(保存很多题目信息)。
  2. 题目列表页:能够展示题目列表。
  3. 题目详情页:能够展示某个题的详细信息 + 代码编辑框。
  4. 提交并运行题目:详情页中有一个 “提交” 按钮,点击按钮网页就会把当前的代码给提交到服务器上。服务器会执行代码,并且给出一些是否通过用例的结果。
  5. 查看运行结果:有另外一个结果页面,能展示提交是否通过,以及错误的用例信息。


二、导入依赖、创建基本项目结构

导入依赖

新建 maven 项目,在 pom.xml 中导入依赖

        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
            <scope>provided</scope>
        </dependency>

创建基本项目结构


三、进程、线程的基础知识回顾

进程简介:

进程也可以称为是 “任务”,操作系统想要执行一个具体的 “动作” 就要创建出一个对应的进程。

一个程序没有运行的时候,仅仅只是一个 “可执行文件”;一个程序跑起来的时候,就变成一个进程了。

为了实现 “并发编程”,同时执行多个任务,就引入了 “多进程编程”。
把一个很大的任务,拆分成若干个很小的任务,创建多个进程,每个进程分别负责其中的一部分任务。

也带来了一个问题:创建 / 销毁进程,比较低效。怎么办呢?就引入了线程。

线程简介:

每个线程都是一个独立的执行流,一个进程包含了一个或者多个线程。
创建 / 销毁线程 比 创建 / 销毁进程更加高效。
因此,在 Java 中大部分并发编程都是通过多线程的方式来实现的。

二者对比:

可是,进程相比于线程,有着 “独立性” 的优势。
操作系统上,同一时刻运行着很多个进程,如果某个进程挂了,不会影响到其它进程(类似于你微信崩溃了,不会影响到 QQ 的使用)。

相比之下,由于多个线程之间,共用着同一个进程的地址空间,某个线程挂了,就很有可能把整个进程带走。

回到 OJ 项目,分析多进程与多线程:

在线 OJ,有一个服务器进程,运行着 Servlet,接收用户的请求,返回响应…

用户提交的代码模块,也是一个独立的逻辑。这个逻辑要使用多线程执行好?还是多进程呢?

对于用户提交的代码,一定要通过 “多进程” 的方式来执行~
因为我们无法控制用户提交了什么代码,代码可能存在很多问题,很可能一运行就崩溃!
如果使用多线程,就会导致用户代码直接导致整个服务器崩溃的情况~

所以我们要使用多进程编程。

Java 中进行多进程编程

多进程编程主要做的事情:

站在操作系统的角度(以 Linux 为例),提供了很多和多进程编程相关的接口。
进程创建、进程终止、进程等待、进程程序替换、进程间通信…

而 Java 中对系统提供的这些操作进行了限制,最终给用户只提供了两个操作:进程创建、进程等待。

测试进程的代码

	public static void main(String[] args) throws IOException {
	    Runtime runtime = Runtime.getRuntime();
	    // 执行这个代码,相当于对着 cmd 中输入了一个 javac 命令
	    Process process = runtime.exec("javac");
	}

显然,操作系统不认识这个 javac 命令是啥。
如果把 javac 改为 notepad,操作系统就会帮我们打开一个记事本。

	public static void main(String[] args) throws IOException {
	    Runtime runtime = Runtime.getRuntime();
	    // 执行这个代码,相当于对着 cmd 中输入了一个 javac 命令
	    Process process = runtime.exec("notepad");
	}

咱们输入的命令,操作系统会去一些特定目录中找,看看是否存在与之对应的可执行文件。

解决前面 javac 问题,需要把 javac 所在的目录加入到 PATH 环境变量中,就一开始学 Java 要配置的环境变量

配置成功,输入 javac 就可以显示列表了。

javac 是一个控制台程序,它的输出是输出到 “标准输出” 和 “标准错误” 这两个特殊的文件中的。
想要看到这个程序的运行效果,就要获取到标准输出和标准错误的内容~

一个进程在启动的时候,就会自动打开三个文件:

  1. 标准输入 - 对应到键盘
  2. 标准输出 - 对应到显示器
  3. 标准错误 - 对应到显示器

虽然子进程启动后,同样也打开了这三个文件,可是由于子进程没有和 IDEA 的终端关联。因此在 IDEA 中是看不到这些文件的,我们需要手动在代码中获取。

通过文件的方式,获取进程中的标准输出和标准错误

public static void main(String[] args) throws IOException {
   Runtime runtime = Runtime.getRuntime();
    // process 表示 “进程”
    Process process = runtime.exec("javac");

    // 获取子进程的标准输出和标准错误,把这里的内容写入到两个文件中
    // 获取标准输出
    InputStream stdoutFrom = process.getInputStream();
    FileOutputStream stdoutTo = new FileOutputStream("stdout.txt");
    while (true) {  // 循环读取进程中的标准输出,写入到文件中
        int ch = stdoutFrom.read();
        if (ch == -1) {
            break;
        }
        stdoutTo.write(ch);
    }
    // 关闭流
    stdoutFrom.close();
    stdoutTo.close();

    // 获取标准错误, 从这个文件对象中读, 就能把子进程的标准错误给读出来!
    InputStream stderrFrom = process.getErrorStream();
    FileOutputStream stderrTo = new FileOutputStream("stderr.txt");
    while (true) {
        int ch = stderrFrom.read();
        if (ch == -1) {
            break;
        }
        stderrTo.write(ch);
    }
    stderrFrom.close();
    stderrTo.close();
}

此时目录会生成两个文件,把进程中的标准输出和标准错误读取到文件中了。

进程等待

通过这个代码,能创建出子进程,但是此时父子进程之间是 “并发执行” 的关系。另一方面,往往也需要让父进程知道子进程的执行状态。

在当前场景中,希望父进程等待子进程执行完毕之后,再执行后续代码。
像在线 OJ 系统,需要让用户提交代码,编译执行代码完毕后,再把响应返回给用户。

 // 通过 Process 类的 waitFor 方法来实现进程的等待.
 // 父进程执行到 waitFor 的时候, 就会阻塞. 一直阻塞到子进程执行完毕为止.
 // (和 Thread.join 是非常类似的)
 // 这个退出码 就表示子进程的执行结果是否 ok. 如果子进程是代码执行完了正常退出, 此时返回的退出码就是 0.
 // 如果子进程代码执行了一半异常退出(抛异常), 此时返回的退出码就非 0.
 int exitCode = process.waitFor();
 System.out.println(exitCode);

四、封装操作进程的工具类

复习完进程的相关知识,我们需要把系统关于进程的操作封装成一个工具类。

CommandUtil

public class CommandUtil {
    // 1. 通过 Runtime 类得到 Runtime 实例,执行 exec 方法
    // 2. 获取到标准输出,写入到指定文件中
    // 3. 获取到标准错误,写入到指定文件中
    // 4. 等待子进程结束,拿到子进程状态码,并返回
    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.getInputStream();
                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 e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return 1;
    }
}

对 CommandUtil 类进行测试
在 CommandUtil 类创建 main 方法,调用 run 方法,查看是否能生成文件。

public static void main(String[] args) {
    CommandUtil.run("javac", "stdout.txt", "stderr.txt");
}

报错了…

回到代码中发现,文件流都没有写错,标准输出和标准错误没有混淆。最后发现是标准错误中的进程 process 调用错了,把调用了上面的标准输出的数据。

修改过后,可以成功运行,也能查看到生成文件中的内容了。


五、实现 “编译运行” 模块 Task 类

接下来,基于准备好的 CommandUtil,实现一个完整的 “编译运行” 模块。

需要做的事情:

  1. 用户提交的代码(输入).
  2. 程序的编译结果和运行结果(输出).

创建一个承载需要编译代码的实体类 Question。

// 此类表示一个 task 的输入内容,包含需要编译的代码
public class Question {
    private String code;

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }
}

创建一个存储编译运行结果的实体类 Answer。

// 此类表示 Task 的执行结果
public class Answer {
    // 错误码。error 为 0 表示编译运行通过;为 1 表示编译出错;为 2 表示运行出错。
    private int error;
    // 出错提示信息,根据错误码,存储不同的错误信息。
    private String reason;
    // 运行程序得到的标准输出的结果。
    private String stdout;
    // 运行程序得到的标准错误的结果。
    private String stderr;

    public int getError() {
        return error;
    }

    public void setError(int error) {
        this.error = error;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public String getStdout() {
        return stdout;
    }

    public void setStdout(String stdout) {
        this.stdout = stdout;
    }

    public String getStderr() {
        return stderr;
    }

    public void setStderr(String stderr) {
        this.stderr = stderr;
    }

    @Override
    public String toString() {
        return "Answer{" +
                "error=" + error +
                ", reason='" + reason + '\'' +
                ", stdout='" + stdout + '\'' +
                ", stderr='" + stderr + '\'' +
                '}';
    }
}

由于 Java 中,类名要和文件名一致。
用户提交的类名字,就需要和写入的文件名一致,就可以约定,类名和文件名都叫做 Solution。
类似于 leetcode 刷题中提供好的代码一样:

约定临时文件名字

接下来,我们先通过一组常量来约定临时文件的名字。

// 表示所有临时文件所在的目录
private static final String WORK_DIR = "./tmp1/";
// 约定代码的类名
private static final String CLASS = "Solution";
// 约定要编译的代码文件名
private static final String CODE = WORK_DIR + "Solution.java";
// 约定存放编译错误信息的文件名
private static final String COMPILE_ERROR = WORK_DIR + "compileError.txt";
// 约定存放运行时标准输出的文件名
private static final String STDOUT = WORK_DIR + "stdout.txt";
// 存放运行时标准错误的文件名
private static final String STDERR = WORK_DIR + "stderr.txt";

为什么要搞这么多临时文件呢?

主要是为了 “进程间通信”。
进程与进程之间是存在独立性的,一个进程很难影响到其它进程。

这里我们采取一种简单粗暴的方式进行通信,就是通过文件~

管道、消息队列、信号、Socket、文件…
只要某个东西可以被多个进程同时访问到,就可以用来进行进程间通信~


六、封装读写文件的方法

对读写文件操作进一步封装。
提供两个方法,一个负责读取整个文件内容,返回一个字符串;
另一个方法负责写入整个字符串到文件中。

对于文本文件来说,字符流会比字节流省事很多,不需要手动处理编码格式,尤其是文件中包含中文的时候。

FileUtil

public class FileUtil {
    // 负责把 filePath 对应的文件内容读取出来,放到返回值中
    public static String readFile(String filePath) {
    	//StringBuiler 是线程不安全的,但是效率高
        StringBuilder result = new StringBuilder();
        // 此写法不需要关闭流
        try (FileReader fileReader = new FileReader(filePath)){
            while (true) {
                int ch = fileReader.read();
                if (ch == -1) {
                    break;
                }
                result.append((char)ch);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return result.toString();
    }

    // 负责把 content 写入到 filePath 对应的文件中
    public static void writeFile(String content, String filePath) {
        try(FileWriter fileWriter = new FileWriter(filePath)) {
            fileWriter.write(content);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        FileUtil.writeFile("hello world", "d:/test.txt");
        String content = FileUtil.readFile("d:/test.txt");
        System.out.println(content);
    }
}

能够在 D 盘看到 test.txt 文件,打开里面有 hello world 就🆗~

测试的时候,莫名其妙又挂了.

在这里插入图片描述

可能是将字符流代码放在 try 的括号里面导致的,需要修改 JDK 版本,保证以下三处 JDK 版本一致就不会报错了~

修改 JDK 版本

查看项目的 JDK 版本

查看工程的 JDK 版本

查看 IDEA 编辑器的 JDK 版本


七、Task 类的实现

之前的操作都是为了 Task 类准备的。

实现保存源代码文件,并测试该方法

// 此类的核心方法。参数:要编译运行的 Java 源代码;返回值:表示编译运行结果。
public Answer compileAndRun(Question question) {
    Answer answer = new Answer();
    // 0. 准备好用来存放临时文件的目录
    File workDir = new File(WORK_DIR);
    // 判断是否存在该目录
    if (!workDir.exists()) {
        // 不存在则创建多级目录.
        workDir.mkdirs();
    }

    // 1. 把 question 中的 code 写入到一个 Solution.java 文件中
    FileUtil.writeFile(question.getCode(), CODE);

    // 2. 创建子进程,调用 javac 进行编译。编译的时候,需要有一个 .java 文件
    //      如果编译出错,javac 就会把错误信息写入到 stderr 里,使用专门的文件来保存:compileError.txt
    
    // 3. 创建子进程,调用 java 命令执行
    //      运行程序的时候,也会把 java 子进程的标准输出和标准错误获取到. stdout.txt, stderr.txt
    
    // 4. 父进程获取到刚才的编译执行结果,并打包成 Answer 对象
    //      编译执行的结果,就通过刚才约定的文件来进行获取
    return null;
}

public static void main(String[] args) {
    Task task = new Task();
    // 待编译代码
    Question question = new Question();
    question.setCode("public class Solution {\n" +
            "    public static void main(String[] args) {\n" +
            "        System.out.println(\"hello world\");\n" +
            "    }\n" +
            "}\n");
    // 编译运行后的结果
    Answer answer = task.compileAndRun(question);
    System.out.println(answer);
}

ok~
经过单元测试发现并没有什么问题,继续~

创建子进程,调用 javac 进行编译。

我们先来看看 javac 进行编译的命令.

javac -encoding utf8 ./tmp/Solution.java -d ./tmp/

指定字符集:-encoding utf8
需要编译的文件:./tmp/Solution.java
指定生成的.class文件存放位置:./tmp/

如果不指定好位置,.class文件可能就跑到别的地方了,后面再进行运行就不方便。

// 2. 创建子进程,调用 javac 进行编译。编译的时候,需要有一个 .java 文件
//      如果编译出错,javac 就会把错误信息写入到 stderr 里,使用专门的文件来保存:compileError.txt
String compileCmd = String.format("javac -encoding utf8 %s -d %s", CODE, WORK_DIR);
System.out.println("编译命令:" + compileCmd);

对于 Java 进程来说,它的标准输出我们不必关注,而是关注标准错误。
一旦编译出错,内容就会通过标准错误来反馈。

CommandUtil.run(compileCmd, null, COMPILE_ERROR);

完成编译模块的代码

// 2. 创建子进程,调用 javac 进行编译。编译的时候,需要有一个 .java 文件
//      如果编译出错,javac 就会把错误信息写入到 stderr 里,使用专门的文件来保存:compileError.txt
String compileCmd = String.format("javac -encoding utf8 %s -d %s", CODE, WORK_DIR);
System.out.println(compileCmd);
CommandUtil.run(compileCmd, null, COMPILE_ERROR);
// 如果编译出错,错误信息就被记录到 COMPILE_ERROR 这个文件中。如果没有编译出错,该文件为空。
String compileError = FileUtil.readFile(COMPILE_ERROR);
if (!compileError.equals("")) {
    System.out.println("编译出错!");
    answer.setError(1);
    answer.setReason(compileError);
    return answer;
}

后续运行 java 命令的代码和编译时差不多,就一次性放出来。

完整的 Task 类

// 编译运行
public class Task {

    // 通过一组常量来约定临时文件的名字
    // 表示所有临时文件所在的目录
    private static final String WORK_DIR = "./tmp1/";
    // 约定代码的类名
    private static final String CLASS = "Solution";
    // 约定要编译的代码文件名
    private static final String CODE = WORK_DIR + "Solution.java";
    // 约定存放编译错误信息的文件名
    private static final String COMPILE_ERROR = WORK_DIR + "compileError.txt";
    // 约定存放运行时标准输出的文件名
    private static final String STDOUT = WORK_DIR + "stdout.txt";
    // 存放运行时标准错误的文件名
    private static final String STDERR = WORK_DIR + "stderr.txt";

    // 此类的核心方法。
    // 参数:要编译运行的 Java 源代码;
    // 返回值:表示编译运行结果。
    public Answer compileAndRun(Question question) {
        Answer answer = new Answer();
        // 0. 准备好用来存放临时文件的目录
        File workDir = new File(WORK_DIR);
        // 判断是否存在该目录
        if (!workDir.exists()) {
            // 不存在则创建多级目录.
            workDir.mkdirs();
        }

        // 1. 把 question 中的 code 写入到一个 Solution.java 文件中
        FileUtil.writeFile(question.getCode(), CODE);

        // 2. 创建子进程,调用 javac 进行编译。编译的时候,需要有一个 .java 文件
        //      如果编译出错,javac 就会把错误信息写入到 stderr 里,使用专门的文件来保存:compileError.txt
        String compileCmd = String.format("javac -encoding utf8 %s -d %s", CODE, WORK_DIR);
        System.out.println("编译时:" + compileCmd);
        CommandUtil.run(compileCmd, null, COMPILE_ERROR);
        // 如果编译出错,错误信息就被记录到 COMPILE_ERROR 这个文件中。如果没有编译出错,该文件为空。
        String compileError = FileUtil.readFile(COMPILE_ERROR);
        if (!compileError.equals("")) {
            System.out.println("编译出错!");
            answer.setError(1);
            answer.setReason(compileError);
            return answer;
        }

        // 3. 创建子进程,调用 java 命令执行
        //      运行程序的时候,也会把 java 子进程的标准输出和标准错误获取到. stdout.txt, stderr.txt
        String runCmd = String.format("java -classpath %s %s", WORK_DIR, CLASS);
        System.out.println("运行时:" + runCmd);
        CommandUtil.run(runCmd, STDOUT, STDERR);
        String runError = FileUtil.readFile(STDERR);
        if (!runError.equals("")) {
            System.out.println("运行时错误!");
            answer.setError(2);
            answer.setReason(runError);
            return answer;
        }

        // 4. 父进程获取到刚才的编译执行结果,并打包成 Answer 对象
        //      正常编译运行的结果,就通过刚才约定的文件来进行获取
        answer.setError(0);
        answer.setReason(FileUtil.readFile(STDOUT));

        return answer;
    }

    public static void main(String[] args) {
        Task task = new Task();
        // 待编译代码
        Question question = new Question();
        question.setCode("public class Solution {\n" +
                "    public static void main(String[] args) {\n" +
                "        System.out.println(\"hello world\");\n" +
                "    }\n" +
                "}\n");
        // 编译运行后的结果
        Answer answer = task.compileAndRun(question);
        System.out.println(answer);
    }
}

通过单元测试,方法没问题。


八、整理一下项目列表

修改一下项目列表
有点乱了…

api 用于前后端交互 Servlet.
common 存放工具类.
compile 存放编译运行模块.
dao 层存储实体类.

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值