- 一、项目介绍
- 二、导入依赖、创建基本项目结构
- 导入依赖
- 创建基本项目结构
- 三、进程、线程的基础知识回顾
- 四、封装操作进程的工具类
- 五、实现 “编译运行” 模块 Task 类
- 六、封装读写文件的方法
- 修改 JDK 版本
- 七、Task 类的实现
- 八、整理一下项目列表
一、项目介绍
项目实现一个在线 OJ 平台,核心功能:
- 能够管理题目(保存很多题目信息)。
- 题目列表页:能够展示题目列表。
- 题目详情页:能够展示某个题的详细信息 + 代码编辑框。
- 提交并运行题目:详情页中有一个 “提交” 按钮,点击按钮网页就会把当前的代码给提交到服务器上。服务器会执行代码,并且给出一些是否通过用例的结果。
- 查看运行结果:有另外一个结果页面,能展示提交是否通过,以及错误的用例信息。
二、导入依赖、创建基本项目结构
导入依赖
新建 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 是一个控制台程序,它的输出是输出到 “标准输出” 和 “标准错误” 这两个特殊的文件中的。
想要看到这个程序的运行效果,就要获取到标准输出和标准错误的内容~
一个进程在启动的时候,就会自动打开三个文件:
- 标准输入 - 对应到键盘
- 标准输出 - 对应到显示器
- 标准错误 - 对应到显示器
虽然子进程启动后,同样也打开了这三个文件,可是由于子进程没有和 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,实现一个完整的 “编译运行” 模块。
需要做的事情:
- 用户提交的代码(输入).
- 程序的编译结果和运行结果(输出).
创建一个承载需要编译代码的实体类 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 层存储实体类.