在线OJ
一、准备工作
这篇博客我们分三部分来讲解如何实现一个在线oj,可以拿牛客网的在线oj系统作为参考,我们这里是一个基础篇。
1.创建项目
使用 IDEA 创建一个 Maven 项目.
1 ) 菜单 -> 文件 -> 新建项目 -> Maven
2) 引入依赖在中央仓库 https://mvnrepository.com/中搜索 "servlet"和mysql, 一般第一个结果就是. (强调一下注意版本,mysql最好用5开头的);
3)将下面的这些代码复制到pom.xml中
如下图红色方框所示记得加在”<dependencis“中
4)然后点击main如图创建wed.xml
在该wed.xml界面复制如下代码
“http://java.sun.com/dtd/web-app_2_3.dtd” >会标红此刻我们不需要去搭理他,默认忽略
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>
<display-name>Archetype Created Web Application</display-name>
</web-app>
二、编辑模块设计
1.封装CommandUtil类
如图在java下面创建一个名为CommandUtil的类在这个类中我们放入如下代码
这里我们会用到文件io的知识和线程等待还有异常处理的知识。
简单提一下字节流和字符流(帮助大家理解)
如果数据所在的文件通过windows自带的记事本打开并能读懂里面的内容,就用字符流,其他用字节流。
如果你什么都不知道,就用字节流。
InputStream & FileInputStream
InputStream字节输入流,用来将文件中的数据读取到java程序
FileInputStream就是他的子类
OutputStream & FileOutputStream
字节输出流,将数据输出到指定文件中,
通过这套组合我们可以把文件A的内容读取出来写入文件B
多进程编程
进程 == “任务”. 是一个 "动作"就是我们打开任务管理器出来的内一堆玩意,多进程是实现并发编程的一种重要实现方式
为什么是进程不是线程?
如果一个进程挂了, 不会影响到其他进程. 如果一个线程挂了, 则整个进程都要异常终止.
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
public class CommandUtil {
//1.通过Runtime类得到实例,执行exec方法
//2.希望获取到标准输出,并写入到指定的文件中
//3.获取到标准的错误,并写入到指定文件中
//4.等待子进程结束,拿到状态码,并返回。
public static int run(String cmd,String stdoutFile,String stderrFile){
try {
//1.通过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();
}
//进程等待父进程执行到waitfor的时候就会阻塞,直到子进程完毕
int exitCode=process.waitFor();
System.out.println(exitCode);
}catch (IOException |InterruptedException e){
e.printStackTrace();
}
return 1;
}
}
理解 "标准输入", "标准输出", "标准错误" 这几个重要概念.
需要手动实现重定向的过程.
exec 执行过程是异步的. 可以使用 waitFor 方法阻塞等待命令执行结束.
接下来,基于刚刚准备好的CommandUtil,我们来实现一个完整的“编译运行”这样的模块。
要做的就是,用户输入,程序相应做错出反应,来判断这个oj结果是否正确。因此我们创建如下四类。
基于刚刚准备好的CommandUtil,实现一个完整的编译运行这样的模块。
2. 创建Question类
用这个类来表示要编译代码,一个task的编译代码。我们直接用 String然后直接用get和set方法。
public class Question {
private String code;
// 其实这个 stdin 没有用上
private String stdin;
public String getCode(){
return code;
}
public void setCode(String code){
this.code=code;
}
}
3.创建Answer类(编译的结果)
编译的结果总共有三种编译出错/运行出错/运行正确.
public class Answer {
//错误码如果error为0表示运行ok,1为编译错误,2为运行出错。
private int error;
//出错的提示信息,如果error为1,出错了放错误信息,如果为0,放运行出错的信息。
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;
}
}
4.创建Task类,表示一次编译的过程(最重要的一部)
每次的“编译”加“运行”,被称为Task。
这里需要理解
javac 是java语言编程编译器。全称java compiler。javac工具读由java语言编写的类和接口的定义,并将它们编译成字节代码的class文件。javac 可以隐式编译一些没有在命令行中提及的源文件。用 -verbose 选项可跟踪自动编译。当编译源文件时,编译器常常需要它还没有识别出的类型的有关信息。对于源文件中使用、扩展或实现的每个类或接口,编译器都需要其类型信息。这包括在源文件中没有明确提及、但通过继承提供信息的类和接口。
这里就不得不提我们需要打开cmd看看输入cmd有反应,如果没有我们需要在环境变量里面引入jdks的环境变量。
java中的文件名和类名是一样的
我们就把question的文件写入,java的solution中去。
public class Task {
// 通过这个方法封装编译命令, 并得到编译运行结果.
//compileAndRun的意思是编译加运行.
//返回值就是编译的结果
public Answer compileAndRun(Question question) {
// 0. 把question中的文件写入到.java文件中去。
// 1. 根据 Question 创建.java临时文件,创建子进程,用javac进行编译。需要先将Question中间的文件写入到一个。java的文件中去。
// 2. 创建子进程,调用javac把错误信息写入到stdout.txt文件stderr.txt文件
// 3. 创建子进程,调用java并执行,读stdout.txt文件stderr.txt文件
// 4. 父进程获取到刚刚的结果并包装到最终 Answer 对象中
//编辑结果通过刚刚的文件获取即可。
}
}
在编译运行过程中可能会生成一些临时文件. 这里统一用临时文件的方式表示. 并约定命名. 这些临时文件放到一个统一的目录中.
这些属性都是 Task 类的成员因此我们将他放入Task类中
为什么搞这么多临时文件,最主要目的是为了进程间通信
进程和进程之间,是独立存在的,一个进程很难影响到其它进程。
我们这里用的简单粗暴的方法,临时文件。
只要某个东西可以被多个进程同时访问到,就可以用来进行进程间通信。
// 存放临时文件的目录.(进程间通信)
private final String WORK_DIR = "./tmp/";
// 编译代码的类名
private final String CLASS = "Solution";
// 编译代码的文件名
private final String CODE = WORK_DIR + "Solution.java";
private final String STDIN = WORK_DIR + "stdin.txt";
//标准输出
private final String STDOUT = WORK_DIR + "stdout.txt";
//标准错误
private final String STDERR = WORK_DIR + "stderr.txt";
//错误信息
private final String COMPILE_ERROR = WORK_DIR + "compile_error.txt";
5.创建 FileUtil
对于文本来说字符流会很省事。
import java.io.*;
public class FileUtil {
//负责把文件读取出来
public static String readFile(String filePath) {
//多个线程修改同一个变量,才会触发线程安全问题
StringBuilder result = new StringBuilder();
try (FileReader fileReader = new FileReader(filePath)) {
while (true) {
int ch = fileReader.read();
// 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 filePath, String content) {
try (FileWriter fileWriter = new FileWriter(filePath)) {
fileWriter.write(content);
} catch (IOException e) {
e.printStackTrace();
}
}
}
有了以上模块我们就可以编写task代码了,就和在上面的task上提到一样。这就是类的方法。
分析一下步骤。先实例化一个Answer然后创建一个文件用来放入我们写入的代码,然后通过cmd进行编程,判断然后将不同情况返回到不同的文件中去。
public Answer compileAndRun(Question question){
Answer answer=new Answer();
File workDir=new File(WORK_DIR);
//如果不存在就创建一个
if(!workDir.exists()){
workDir.mkdir();
}
//1.把question中的code写入到一个Solution.java文件中。
FileUtil.writeFile(CODE,question.getCode());
//2.创建子进程,调用javac编译。
String compileCmd=String.format("javac -encoding uft8 %s -d %s",CODE,WORK_DIR);
System.out.println(compileCmd);
CommandUtil.run(compileCmd,null,COMPILE_ERROR);
//3.创建子进程,调用java命令并执行
String compileError=FileUtil.readFile(COMPILE_ERROR);
if (!compileError.equals("")){
answer.setError(1);
answer.setReason(compileError);
return answer;
}
//4.父进程获取到刚才的编译执行结果,并打包成Answer对象
return null;
}
public static void main(String[] args) {
Task task=new Task();
Question question=new Question();
question.setCode("public class Solution {
" +
" public static void main(String[] args) {
" +
" System.out.println("hello world");
" +
" }
" +
"}");
Answer answer=task.compileAndRun(question);
System.out.println(answer);
}
}
我们用这个代码测试一下然后去tmp找这俩文件会发现Solution文件写入成功,然后compileError.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;
}
剩下最后一种情况了。正确的情况我们接着写即可
answer.setError(0);
answer.setStdout(FileUtil.readFile(STDOUT));
return answer;
这下我们的task模块就做好了。这就是我们的后端不含数据库部分。