JavaWeb项目——基于Servlet实现的在线OJ平台 (项目问答+代码详解)

Posted RAIN 7

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaWeb项目——基于Servlet实现的在线OJ平台 (项目问答+代码详解)相关的知识,希望对你有一定的参考价值。

文章目录

项目演示

访问部署在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这个类封装了这个创建一个子进程,执行命令的过程

  1. 通过一个Runtime类 获得一个 Runtime 实例,执行exec方法
  1. 获取标准输出,并写入到指定文件
  1. 获取标准错误,并写入到指定文件
  1. (每个子进程在最后都要进程等待)等待子进程结束, 拿到子进程的状态码,并返回结果。

如果返回为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的在线考试系统-JavaWeb项目-有源码

基于javaweb jsp+servlet学生宿舍管理系统设计和实现

超级简单的javaweb适合初学者学习基于servlet客户关系管理系统设计与实现(源码)

计算机课程设计-基于javaweb的在线点餐系统-线上点餐系统代码java外卖点餐系统代码