目标:仿照牛客/leetcode这类在线OJ网站,实现在线做题/判题功能
核心功能
- 题目列表页,展示当前系统中所有的题目。题目详情页,显示题目的具体要求和代码模板,提供一个编辑框供用户来编辑代码。代码的编译/运行/测试功能,能够针对用户提交的代码,进行编译运行,并自动执行测试用例,返回测试结果。
编译模块:给定一个java代码文件(只考虑单个文件的情况,不考虑多文件复杂工程)能够通过代码来控制jdk进行编译运行。通过借助Runtime这个对象,可以创建出一个子进程,并且让子进程来执行一个相关的命令。编译:javac,运行:java
CommandUtil进程与线程
进程是资源分配的基本单位。
线程是CPU调度和执行的基本单位
线程相比于进程的优势:
1.线程更轻量,创建一个线程开销比创建进程低很多;销毁一个线程的开销也比销毁一个进程开销低很多。
2.同一个进程中的所有线程共享着一些数据。
线程相比于进程的劣势:
1.线程代码编写更困难,涉及线程安全。
2.线程代码调试也更加困难。
3.对于程序的稳定性也就提出了更高要求。
如何利用子进程来执行命令?接下来就是对CommandUtil的讲解,其本质就是利用了jdk的Runtime对象。
exec方法干了两件事:1.创建子进程。2.进程程序替换
这里的运行结果实际上是父进程的输出结果,那么如果我们想要获取子进程的输出结果就需要使用到“重定向”功能,把进程输出的内容写到指定的文件中。具体实现:实现重定向,需要先获取到子进程对象,借助exec返回的Process对象,通过标准输入输出来重定向保存子进程输出结果。
执行结果发现:文件是有了,但是内容没有,为什么?因为一个命令的输出内容也可能是通过标准错误来打印的。操作系统中的任何一个进程,启动的时候都会自动打开三个文件:标准输入、标准输出、标准错误。并通过文件管理的方式,将其组织起来。我们只需要将上图第三步复制,修改为标准错误重定向就好了(process.getErrStream())。
执行结果:
首先,生成了两个对应的文件。
第二,在stderrFile.txt文件中可以看到内容,也就是我们在cmd中直接输入javac的内容。
当然,我们还需要考虑一件事,本身预期目标是用父进程中的run方法来控制子进程执行功能。在执行run的过程中,子进程也在执行。当run执行结束之后,也必须确保子进程也已经执行完了。但是此时这个代码中,子进程和父进程之间是并发关系,谁先执行完时无法确定的,为了能够明确让子进程先执行完,就需要让父进程进行等待。
CommandUtil代码
import java.io.*;
// 借助这个类,让java代码能够去执行一个具体的指令
// 例如:javac Test.java
public class CommandUtil {
//cmd 表示要执行的命令
//stdoutFile 表示标准输出结果重定向到哪个文件中,如果为null表示不需要重定向
//stderrFile 表示标准错误结果重定向到哪个文件中。
public static int run(String cmd,String stdoutFile,String stderrFile) throws IOException, InterruptedException {
//1.获取Runtime对象 ,Runtime对象是一个单例的
Runtime runtime = Runtime.getRuntime();
//2.通过runtime对象中的exec方法来执行一个指令。
// 相当于在命令行中输入了命令
// 获取exec返回的Process对象
Process process = runtime.exec(cmd);
//3.通过标准输入输出对输出结果进行重定向保存
if(stdoutFile != null){
//进程的标准输出中的结果就可以通过这个InputStream来获取
InputStream stdoutFrom = process.getInputStream();
OutputStream stdoutTo = new FileOutputStream(stdoutFile);
int ch = -1;
while((ch = stdoutFrom.read()) != -1){
stdoutTo.write(ch);
}
stdoutFrom.close();
stdoutTo.close();
}
//4.针对标准错误也进行重定向
if(stderrFile != null){
InputStream stderrFrom = process.getErrorStream();
OutputStream stderrTo = new FileOutputStream(stderrFile);
int ch = -1;
while((ch = stderrFrom.read()) != -1){
stderrTo.write(ch);
}
stderrFrom.close();
stderrTo.close();
}
// 5.为了确保 子进程先执行完,需要加上进程等待。
// 父进程会在waitFor阻塞等待,直到子进程执行结束,再继续往下执行。
int exitCode = process.waitFor();
return exitCode;
}
public static void main(String[] args) throws IOException, InterruptedException {
run("javac","f:/stdoutFile.txt","f:/stderrFile.txt");
}
}
到目前为止,已经实现了一个类,可以帮助我们完成执行某个指定的命令。下一步操作,就需要借助刚才这个类,把整个java程序的编译和运行过程都组合到一起。
CompileAndRun需要对代码进行编译和运行,那么就需要先将代码(code)和输入(stdin)进行封装。
//要编译执行的代码
public class Question {
//要编译和执行的代码内容
private String code;
//执行时标准输入要输入的内容
private String stdin;//实际上后面没有用到。
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getStdin() {
return stdin;
}
public void setStdin(String stdin) {
this.stdin = stdin;
}
}
编译运行之后的结果也要进行封装,例如:错误码,出错原因,标准输出,标准错误。
public class Answer {
//通过 error 表示当前的错误类型
//约定 error 为0表示没有错误,error 为1表示编译出错,error为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 + ''' +
'}';
}
}
读写文件是比较频繁的操作,所以将其封装为一个工具类,方便使用。
import java.io.*;
//这是一个简单的工具类,帮助我们更方便的读写文件
public class FileUtil {
//读文件:把整个文件内容都读到String中
public static String readFile(String filePath){
//当前涉及到的编译错误,标准输出结果等文件内容都是文本文件,此处使用字符流的方式来实现
try(FileReader fileReader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(fileReader)){
StringBuilder stringBuilder = new StringBuilder();
//按行读取文件内容了
String line = "";
while((line = bufferedReader.readLine()) != null){
stringBuilder.append(line);
}
return stringBuilder.toString();
}catch (IOException e) {
e.printStackTrace();
}
return null;
}
//写文件:把整个String的内容都写到指定文件中。
//filePath 表示要把数据写到哪个文件中
//content 表示要写的文件内容
public static void writeFile(String filePath,String content){
try(FileWriter fileWriter = new FileWriter(filePath)){
fileWriter.write(content);
} catch (IOException e) {
e.printStackTrace();
}
}
}
在编译运行过程中依赖了一些临时文件,需要约定一下这些临时文件的名字,这些临时文件就是为了把执行过程中涉及到的各种中间结果都记录下来,方便调试。例如保存在哪个目录下(WORK_DIR)、代码类名(CLASS)、文件名(CODE)、标准输入、标准输出、标准错误、编译错误。
编译运行(comileAndRun)的具体过程:
1.需要先创建好临时文件的目录。
2.根据Question对象,构造需要的临时文件(文件名,标准输入)。
3.构造编译命令并执行,再判断是否编译出错。
4.构造运行命令并执行,再判断是否运行出错。
5.将最终结果封装到Answer并返回。
import java.io.File;
import java.io.IOException;
//借助这个类来描述一次编译运行的过程
public class Task {
//存放的目录
private static final String WORK_DIR = "./tmp/";
//要编译的代码的类名
private static final String CLASS = "Solution";
//要编译的代码对应的文件名,需要和类名一致
private static final String CODE = "Solution.java";
//标准输入对应的文件(实际上也没有用到)
private static final String STDIN = WORK_DIR + "stdin.txt";
//标准输出对应的文件(编译执行的代码的结果保存到这个文件中)
private static final String STDOUT = WORK_DIR + "stdout.txt";
//标准错误对应的文件(编译执行的代码的结果保存到这个文件中)
private static final String STDERR = WORK_DIR + "stderr.txt";
//编译错误对应的文件(编译出错时的具体原因)
private static final String COMPILE_ERROR = WORK_DIR + "compile_error.txt";
public Answer compileAndRun(Question question) throws IOException, InterruptedException {
Answer answer = new Answer();
//1. 先创建好存放临时文件的目录
File worDir = new File(WORK_DIR);
if(!worDir.exists()){
worDir.mkdirs();
}
//2. 根据Question对象,构造需要的一些临时文件
FileUtil.writeFile(CODE,question.getCode());
FileUtil.writeFile(STDIN,question.getStdin());
//3. 构造编译命令,并执行
// 编译命令例如:javac -encoding utf8 ./tmp/Solution.java -d ./tmp/
// 直接通过字符串拼接,有的时候如果太复杂,容易拼错,尤其是命令选项多的时候,很容易少空格之类的东西。
// String cmd = "javac -encoding utf8 " + CODE + " -d " + WORK_DIR;
String cmd = String.format(
"javac -encoding utf8 %s -d %s",CODE,WORK_DIR
);
System.out.println("编译命令:"+cmd);
CommandUtil.run(cmd,null,COMPILE_ERROR);
// 判断编译是否出错,如果编译出错,则不需要继续向下运行了
// 如果COMPILE_ERROR 文件为空,就表示编译顺利,如果非空就表示编译出错。
String compileError = FileUtil.readFile(COMPILE_ERROR);
if(!"".equals(compileError)){
//编译出错
System.out.println("编译出错");
answer.setError(1);
answer.setReason(compileError);
return answer;
}
//4. 构造运行命令,并执行
// 运行命令例如:java -classpath ./tmp/ Solution
// 为了能让java 命令正确找到类对应的 .class 文件,需要指定加载路径,-classpath 选项来指定
cmd = String.format(
"java -classpath %s %s",WORK_DIR,CLASS
);
System.out.println("运行命令:" + cmd);
CommandUtil.run(cmd,STDOUT,STDERR);
//判定运行是否出错(是否存在异常),查看 STDERR 是否为空
String stdError = FileUtil.readFile(STDERR);
if(!"".equals(stdError)){
System.out.println("运行出错");
answer.setError(2);
answer.setReason(stdError);
answer.setStderr(stdError);
return answer;
}
//5. 将最终的运行结果包装到Answer中。
answer.setError(0);
answer.setStdout(FileUtil.readFile(STDOUT));
return answer;
}
public static void main(String[] args) throws IOException, InterruptedException {
//验证 Task 是否能正确运行
Question question = new Question();
question.setCode(
"public class Solution {n" +
" public static void main(String[] args) {n" +
" System.out.println("hello");n" +
" }n" +
"}n"
);
question.setStdin("");
Task task = new Task();
Answer answer = task.compileAndRun(question);
System.out.println(answer);
}
}
输出结果
小总结:到目前为止,我们已经可以将代码进行编译运行,并返回执行结果。其实主要难点是写CommandUtil,得理解对Runtime对象的使用,并了解进程的相关知识。后面再整合编译和运行过程,其实就是对我们再熟悉不过的java运行过程的实现,先生成.java文件(也就是代码)、将其编译成.class文件、最后运行该文件并返回。
运行之后的目录结构



