JavaWeb项目——基于Servlet实现的在线OJ平台 (项目问答+代码详解)
Posted RAIN 7
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaWeb项目——基于Servlet实现的在线OJ平台 (项目问答+代码详解)相关的知识,希望对你有一定的参考价值。
文章目录
- 项目演示
- 预先知识
- 编译运行模块
- 题目管理模块
- API 模块
项目演示
访问部署在Linux云服务器上的项目地址
主页显示项目相关信息,向下翻显示题目列表
点击题目标题,展示题目详细信息。
点击提交按钮,把用户编辑的代码上传到服务器上进行编译和运行,把返回的结果显示到结果展示栏。
访问部署在云服务器上的项目
在这个页面里面,首先我们可以看到映入眼帘的主页,在这个主页我们就可以看到一些有关项目的基本信息
这个项目是一个基于Java servlet实现的在线oj平台,然后再项目名称下面加了一个 项目源代码的gittee链接,
然后呢我们再往下翻,我们就能看到的是一个题目的列表了,作为一个在线oj的一个系统呢,里面肯定得有很多的编程题目,我们这里呢就通过一个题目列表把题目的基本信息给展示出来,每一行列表都会显示题目的id、标题、难度。
我们点击具体的题目标题以后呢,一点之后呢会跳转到另外一个界面,直接显示的就是题目的具体信息,显示展示的是题目的具体描述,当然了这里也包括题目的id、标题、难度、以及具体描述及要求
然后在下面呢有一个代码编辑框,在这个编辑框中我们就可以编译自己写的代码,就演示一下在代码框中写一下代码,在编辑代码的时候还会有代码高亮补全的功能,
点击提交,然后这个程序的执行结果就会显示在下面的结果展示框中,里面就提示我们通过了哪个测试用例,
如果这个代码中出现一些问题,比如说代码中少写了一个分号,再次点击提交,就会在结果框展示出错的具体提示。通过这个错误提示就可以提示用户代码中哪一行出现错误。
这就是关于当前的一个在线OJ项目的最基本的最核心流程。
预先知识
请问 在处理用户同时提交代码时是 多进程处理还是 多线程处理?
创建线程/销毁线程 都比 创建销毁进程更高效,所以很多Java的并发编程都是通用多线程的方式来实现的,但是这个项目 应用的是 多进程编程
多进程相比于多线程也有自己的优势
进程之间具有独立性
操作系统上同一时刻运行着很多个进程,如果某个进程挂了,不会影响到其他进程(因为每个进程都有各自的地址空间)
相比之下,多个线程之间共用同一个进程的内存空间,如果某个线程挂了,就很可能把整个服务器进程都弄崩溃了。
所以子进程来处理用户的请求虽然没有多线程处理那么高效,但是会更加的安全,更加的稳定,在我们当前的项目中稳定就非常的重要。
我们的在线OJ
有一个服务器进程(运行着 servlet,接收用户请求,返回响应)
用户提交的代码,其实也是一个独立的逻辑,处理用户的代码我们就得使用多进程的方式来处理。
因为我们无法控制用户提交的代码到底是什么,这个代码很可能是存在问题的,很可能是一运行就程序崩溃了,如果是多线程就会导致把整个服务器进程都给带走了的情况。而且在现实中一个服务器处理的用户量是很大的,我们也无法保证用户提交的代码都是没有问题的。
因此在我们 项目中为了让程序顺利执行,为了让服务器更加稳定,为了让用户提交的代码不影响服务器的运行,此处势必要使用多进程编程。
你是如何创建多进程的逻辑的
先创建 Runtime 的实例
Runtime runtime = Runtime.getRuntime();
Process process = runtime.ecex(“javac”)
方法里面填入要执行的程序命令字符串 javac,返回的结果是一个Process进程
当我们执行这个代码就相当于在cmd中输入了具体的指令
这样我们就成功创建了一个子进程,并让子进程具体去执行任务了。
如果我们的操作系统不认识我们执行的命令的话,那么把 javac所在的目录给加入到 PATH环境变量当中。
如何获取到编译与运行后的结果?
一个进程在启动的时候,就会自动打开三个文件:
1、标准输入 对应到键盘
2、标准输出 对应到显示器
3、标准错误 对应到显示器
javac是一个控制台程序,他的输出 ,是输出到 标准输出 和标准错误的文件当中的,如果我们要看到程序运行的效果,就得获取到这两个文件的内容
process.getInputStream,读入文件数据流。写入到对应文件中。
process.getErrorStream,读入标准错误数据流,写入到对应文件。
虽然子进程启动后也打开了这三个文件,但是子进程没有和IDEA终端连接,,所以我们要获取到子进程的标准输出和标准错误,把这里的内容写入到两个文件中。
编译运行模块
子进程之间如何并发?
在当前的场景中,希望子进程执行完毕后,在执行后续代码,需要让用户提交代码,编译执行代码,肯定是要在编译已执行完毕了,再把相应返回给用户。
一方面时安排子进程的执行顺序,
一方面需要让父进程知道子进程的执行状态。
通过 Process 类的waitFor 方法来实现进程的等待。
父进程执行到waitFor方法就会阻塞,一直阻塞到子进程执行完毕。,同时会返回一个int整数退出码,这个退出码就表示子进程的执行结果是否ok,如果子进程时代码执行完了正常退出,此时返回的是0,如果子进程代码执行了一半异常退出(抛出异常),此时返回的退出码就非0
编译运行我们用一个 CommandUtile这个类封装了这个创建一个子进程,执行命令的过程
- 通过一个Runtime类 获得一个 Runtime 实例,执行exec方法
- 获取标准输出,并写入到指定文件
- 获取标准错误,并写入到指定文件
- (每个子进程在最后都要进程等待)等待子进程结束, 拿到子进程的状态码,并返回结果。
如果返回为0 说明执行没有问题,如果返回1,说明执行有异常。
文件读写操作为什么用 int 来接收每次读取的 byte字节流呢?
read方法一次返回的是一个字节(byte),但是实际上却使用的是int来接受的!
这样做的理由如下:
1、Java中不存在无符号类型,byte这样的类型是有符号的(有正负),byte的表示范围 -128 ~ 127
但实际上我们在按照字节读取数据的时候,并不需要这样的数据来
进行算术运算,此时这里的正负就没有意义了
因此期望读到的结果是一个无符号的数字 0->255
因为我们就需要一个更大的范围来表示接收的数据
如果返回到 byte类型那么不能返回到255
所以呢我们使用int是比较合适的,
第二方面:
read读取完毕(读取到末尾),就会返回EOF,用-1来表示。
正是因为我们把读到的字节用 0-255 来接收,接下来我们才能使用-1 负数来表示EOF状态。
我们把创建子进程并执行命令的操作封装成为了一个CommandUtil类,所以呢 ,我们把 编译加运行这一个过程 封装成 Task类
compileAndRun 方法是 编译加运行,参数是要编译运行的java源代码,返回值是 编译运行的结果
编译出错/运行出错/运行正常
为了方便表示 参数和返回值,我们就创建几个类来表示具体的信息。
参数类–Question
这个类来表示 一个 Task 的输入内容
包含 要编译的代码
private String code;
生成各个属性的 getter 和 setter 方法
public class Question
private String code;
public String getCode()
return code;
public void setCode(String code)
this.code = code;
返回值-- Answer
错误码
private int error;
约定如果 error为0 表示编译运行都 ok
error为1 表示编译出错
error为2 表示运行出错
错误信息
private String reason;
表示出错的提示信息
如果error为1 编译出错了,reason就放编译的错误信息
如果error为2 运行出错了,reason就放运行的错误信息
运行程序得到的标准输出的结果
private String stdout
运行程序得到的标准错误的结果
private String stderr
生成各个属性的 getter 和 setter 方法
public class Answer
private int error ; // 0表示没问题 1表示编译出错 2表示运行出错
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;
Task 编译运行过程的流程是什么?
0.把question 中的code 写入到 Solution.java 文件中
1.创建子进程,调用javac进行编译,注意:编译的时候要有一个.java 文件
如果编译出错,javac就会把错误信息写入到stderr里,就用一个专门的文件 compileError来保存。
2.创建子进程,调用java命令并执行,执行刚才的 .calss文件
运行程序的时候,也会把Java子进程的标准输出和标准错误获取到,stdout.txt ,stderr.txt
3.父进程获取到刚才的编译执行的结果,并打包成Answer对象
父进程怎么获取到刚才的结果,读取上面存放信息的文件即可。
约定临时文件名
创建了一个临时文件目录
private static final String WORKDIR ="./tmp/";
创建代码类名
private static final String CLASS= “Solution”;
约定编译的代码文件名
private static final String CODE = WORKDIR+“Solution.java”;
约定存放编译错误信息的文件名
private static final String COMPILE_ERROR = WORKDIR+“compileError.txt”;
约定存放运行时标准输出的文件名
private static final String STDOUT = WORKDIR+“stdout.txt”;
约定存放运行时标准错误的文件名
private static final String STDERR = WORKDIR+“stderr.txt”;
为什么要约定这么多临时文件呢?
最主要的目的就是为了进行"进程间通信“
进程和进程之间是存在独立性的
一个进程是很难影响到其他进程的。
Linux系统 提供的进程间通信有很多手段
但是在这里我们使用过文件的方式来进行进程间通信
服务器进程写到 code所在的文件,javac 所在的进程读取 code文件的代码,java所在的进程又读取 javac进程写的文件 .class ,服务器进程最后拿到 java进程写的标准输出标准错误文件。
整体的流向大概就是这样
总之呢,我们使用很多临时文件主要是 为了让这些进程之间能够相互配合,让这些进程能够通信起来
文件读写操作
因为很多进程之间通信我们使用了 创建临时文件的方式,所以要涉及到很多的文件的一些操作,最后为了方便我们在代码中的 快速读写操作,我们可以对读写文件的操作封装成一个工具类,来帮助我们实现文件的读写操作。
FileUtil
这个类里面提供两个方法
一个负责读取整个文件的内容(字符串)读取完放到返回值中
一个负责写入整个字符串到文件中。这是我们当前要完成的两个任务。
基于我们学习过的 字节流字符流操作,来写一个具体的文件操作的方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TGrMpX9s-1648565227266)(DDEF5961D33A4389A4F1725BD9C59C4D)]
后续要读写的文件,就是这几个,这几个文件都是文本文件,因此使用字节流更合适一些~
对于文本文件来说,字节流和字符流都可以读写,字符流会省事一些,字节流可能麻烦一些(手动的处理编码格式)
文件读操作
public static String readFile(String filePath)
FileReader fileReader = null;
StringBuilder sb = new StringBuilder();
try
fileReader = new FileReader(filePath);
while (true)
int ch = fileReader.read();
if(ch==-1)
break;
sb.append(ch+"");
catch (FileNotFoundException e)
e.printStackTrace();
catch (IOException e)
e.printStackTrace();
return sb.toString();
为啥不用String,而用StringBuilder 呢?
String是一个不可变对象!
里面持有的字符串内容是不可修改的,如果真要修改,必须要创建一个新的String对象,把旧的内容拷贝过去
StringBuilder 和 StringBuffer
基本用法都是一致的,StringBuffer是线程不安全的,StringBuilder是线程安全的
在这里 多个线程之间修改同一变量,会触发线程安全问题
但是在这里函数内部的局部变量,由于局部变量是在栈上的,你每个线程都有一个自己的栈,所以线程1栈上的数据,线程2 就很难访问到。
所以呢,我们当前就认为 这里的操作是修改局部变量,不涉及线程安全问题的。使用StringBuilder即可。
文件写操作
public static void writeFile(String filPath,String content) throws IOException
FileWriter fileWriter = null;
try
fileWriter = new FileWriter(filPath);
fileWriter.write(content);
catch (IOException e)
e.printStackTrace();
fileWriter.close();
文件写的操作就很简单了,直接把content 的内容直接写入到文件中。
读写操作完成,大大方便了我们在之后的 读取文件内容,写入文件内容等操作。
实现保存源代码
因为我们都把文件放到 当前目录 的tmp目录下,如果我们没有事先创建好这个目录就需要 新建一个目录。
0、准备好用来存放南方临时文件的目录
File workDir = new File(WORKDIR);
if(!workDir.mkdirs())
// 如果目录不存在的话,就创建多级目录
workDir.mkdirs();
1、根据提供的queution 对象中的 code 写入到一个 Solution.java 文件中
FileUtil.writeFile(CODE,question.getCode());
// CODE 是之前就定义的 存放Solution.java文件的目录,向这个目录里面写文件
实现编译功能
2. 创建编译的子进程 ,执行javac命令编译 Soulution.java 文件
String compileCmd = String.format("javac -encoding utf8 %s -d %s",CODE,WORKDIR);
ComandUtil.run(compileCmd,null,COMPILE_ERROR);
// 如果编译出错,这里的COMPILE_ERROR 就有内容,如果为空那么没有错误
String compileError = FileUtil.readFile(COMPILE_ERROR)
if(!"".equals(compileError))
// 如果COMPILE_ERROR不为空,那么编译出错
// 如果出错,直接包装成一个Answer 对象,然后返回
answer.setError(1); // 错误码如果是1 ,那么就表明编译出错
answer.setReason(compileError);
return answer;
// 编译正确,继续执行运行的逻辑
-d 选项主要是指定生成的class文件在哪里,这里如果不指定好,生成的 .class 文件可能就跑到其他位置,此时后面进行运行的时候,可能就找不到了。
对于 javac 这个进程来说,他的标准输出,我们不关注!而是关注他的标准错误~
一旦编译错误,内容就会通过标准错误来反馈出来
我们不要把 javac 的标准输出和标准错误 和java进程的搞混
编译是否正确,我们通过读取 javac进程的 标准错误文件,
如果为空,那么就编译正常
如果不为空那么就编译错误,我们就将标准错误信息还有退出码返回给Answer 对象,返回。
如果编译正确就会得到 .class 文件
如果编译不正确,那么就会包装一个Answer对象,然后直接返回
实现运行功能
3.创建运行的子进程,执行java命令运行刚才生成的 .class 文件
String runCmd = String.format("java -classpath %s ",WORKDIR,CLASS);
ComandUtil.run(runCmd,STDOUT,STDERR);
String runError = FileUtil.readFile(STDERR);
if(!"".equals(runError))
answer.setError(2);
answer.setReason(runError);
return answer;
这里的 -classpath 选项也很重要,因为我们当前的.class 文件到底在哪我们的java命令是不知道的,如果找不到这个.class文件,那么就会出现类加载不了的情况,所以为了处理这种情况,我们就需要显式的告诉java命令 .class 文件的路径是什么.
后面判断运行是否正确 与前面的 判断编译是否正确的过程是一样的,都是判断 读取标准错误的文件,如果为空那么没有问题,如果不为空那么打包成一个Answer对象,返回answer.
编译运行正常
4. 父类获取到运行的结果 ,并且打包成Answer对象
answer.setError(0);
answer.setStdout(FileUtil.readFile(STDOUT));
return answer;
完整编译运行模块代码逻辑
ComandUtil类–处理创建子进程,执行命令
import java.io.*;
public class ComandUtil
public static int run(String cmd,String stdout,String stderr) throws IOException, InterruptedException
//1. 先创建Runtime实例,创建子进程
Process process = Runtime.getRuntime().exec(cmd);
//2. 获取到标准输出
if (stdout!=null)
InputStream stdoutFrom = process.getInputStream();
OutputStream stdoutTo = new FileOutputStream(stdout);
while(true)
int ch = stdoutFrom.read();
if(ch==-1)
break;
stdoutTo.write(ch);
stdoutFrom.close();
stdoutTo.close();
// 3.获取到标准错误
if (stderr!=null)
InputStream stderrFrom = process.getErrorStream();
OutputStream stderrTo = new FileOutputStream(stderr);
while(true)
int ch = stderrFrom.read();
if(ch==-1)
break;
stderrTo.write(ch);
stderrFrom.close();
stderrTo.close();
// 4.进程等待,获取到进程状态码,然会结果
// 如果返回0 那么执行正常 如果返回非0,有异常
int exitCode = process.waitFor();
return exitCode;
FileUtil类–文件读写操作
import javafx.scene.transform.Scale;
import java.io.*;
public class FileUtil
public static String readFile(String filePath) throws IOException
FileReader fileReader = null;
StringBuilder sb = new StringBuilder();
try
fileReader = new FileReader(filePath);
while (true)
int ch = fileReader.read();
if(ch==-1)
break;
sb.append(ch+"");
catch (FileNotFoundException e)
e.printStackTrace();
catch (IOException e)
e.printStackTrace();
fileReader.close();
return sb.toString();
public static void writeFile(String filPath,String content) throws IOException
FileWriter fileWriter = null;
try
fileWriter = new FileWriter(filPath);
fileWriter.write(content);
catch (IOException e)
e.printStackTrace();
fileWriter.close();
public static void main(String[] args) throws IOException
FileUtil.writeFile("d:/test.txt","hello world");
String content = FileUtil.readFile("d:/test.txt");
System.out.println(content);
Task类–完整的编译运行过程
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
public class Task
// 创建了一个临时文件目录
private static final String WORKDIR ="./tmp/";
// 创建代码类名
private static final String CLASS= "Solution";
// 约定编译的代码文件名
private static final String CODE = WORKDIR+"Solution.java";
// 约定存放编译错误信息的文件名
private static final String COMPILE_ERROR = WORKDIR+"compileError.txt";
// 约定存放运行时标准输出的文件名
private static final String STDOUT = WORKDIR+"stdout.txt";
// 约定存放运行时标准错误的文件名
private static final String STDERR = WORKDIR以上是关于JavaWeb项目——基于Servlet实现的在线OJ平台 (项目问答+代码详解)的主要内容,如果未能解决你的问题,请参考以下文章
javaweb基于servlet天气预报查询系统设计与实现(项目源码)
基于javaweb jsp+servlet学生宿舍管理系统设计和实现