“ 作业帮 “ (Servlet)

引言

本次项目用到的技术

协议:HTTP.
前端:HTML, CSS, JavaScript, JS-WebAPI, jQuery, ace.js
后端:Servlet, Jackson, JDBC, 流对象, 多进程编程, " javac ", " java "
数据库:MySQL.
测试:IDEA, Chrome, Fiddler.

本次项目的业务流程

  1. 设计编译运行方案
  2. 设计数据库
  3. 根据数据库设计实体类
  4. 封装数据库
  5. 准备好纯前端页面
  6. 实现题目列表页
  7. 实现题目详情页
  8. 优化项目

一、理解 OJ 系统的核心思想并设计编译运行方案

1. 在线提交代码的后端处理方案

我们日常使用 leetcode 刷题的时候,都是在代码区域写好代码,然后提交。然而,我们在编辑代码的时候,编辑区并不像 IDEA 那样,为我们提示语法是否正确。所以,我们就需要考虑到,用户提交代码后可能发生的情况,也许是编译时期出现了错误,此时就生成不了 " .class " 文件了,也许编译无误,但是运行时期出现了错误,那么我们就应该抛出一个异常。

回顾多线程与多进程

多线程和多进程都可以进行并发编程,然而多线程更加轻量,多进程更加独立。

我的项目有一个服务器进程,它运行着 Servlet,用来接收用户的请求,返回响应…

而用户提交的代码,我认为也应该是一个独立的运行逻辑。很多情况下,我们无法控制用户到底提交了什么样的代码,也许用户提交的代码会正常通过用例,也许会抛出异常、也许会损害整个服务器端。这些都是可能发生的情况,有的人说 " 损害服务器 " 比较夸张。那就举个例子吧:如果有用户通过代码对服务器端的文件进行操作,那么是不是直接就接触到服务器端的本地数据了呢?虽然这种概率很低,但我们仍然要考虑进去。

综上所述,像 leetcode 这样的网站,同一时刻可能就有几万次提交代码的用户。那么,先抛开危险性、优化好坏不说,如果使用多线程编程,其中有一个用户代码出现了异常,可能就会导致整个服务器端进程崩溃,从而导致网站崩溃。

所以说,让 " 用户提交代码这个运行逻辑 " 使用多进程的方式,就是得益于它的独立性,使得每个用户提交的代码互不影响,这是一个很关键的思想。

Java 多进程编程

我们期望,由服务器端进程作为父进程,用来接收请求,返回响应。由 Runtime 类 和 Process 类创建一个子进程,来执行用户提交的代码,用这个子进程去处理 " 编译 + 运行 " 的整个过程,也就是 " javac " 和 " java " 命令。让这些命令传入 " exec " 方法,最后,将结果写入文件中。

Runtime runtime = Runtime.getRuntime();
Process process = runtime.exec(command);

进程与文件

计算机中一个进程在启动的时候,会自动打开三个文件:

  1. 标准输入对应到键盘
  2. 标准输出对应到显示器
  3. 标准错误对应到显示器

必须明确,JDK 为我们提供的 " javac " 命令是一个控制台程序,它的输出是输出到 " 标准输出 " 和 " 标准错误 " 这两个特殊的文件中的,要想看到这个程序的运行效果,就得获取到标准输出和标准错误的内容。

" javac " 这样的命令并不像我们在控制台输入一个 " notepad " 命令,直接打开了记事本,因为记事本程序是以图形化界面为我们呈现出来的。

此外,虽然子进程启动后同样也打开了这三个文件,但是由于子进程没有和 IDEA 的终端关联,因此在 IDEA 中是看不到子进程的输出的,要想获取到输出,就需要在代码中手动获取到。

在下面的 CommandUtil 类中,我就将 子进程的 " 标准输出 " 和 " 标准错误 " 写入文件中,以来观察 " 编译期 " 和 " 运行期 " 的代码是否有误。

2. 设计 " 编译 + 运行 " 方案

再通过上面的理论思想介绍后,其实总结下来就是一个方案。

① 让用户提交的代码经过 JVM 进行 " 编译+ 运行 ",让 JVM 为我们自动判断提交的代码到底是正确,还是哪里出现了错误。所以我们就可以通过 JDK 提供的 " javac " 和 " java " 命令来分别执行 " 编译+ 运行 " 。

② 此外,因为这两个命令是在控制台上输入的,那么命令的执行结果,我们只能在控制台观察,如果我们想要直观地观察,可以将上面两个命令执行的结果写入本地文件中,再通过 Java 流对象 将文件数据读出来,返回给用户观察即可。

3

封装 CommandUtil 类

综上所述,我们可以封装一个 CommandUtil 类来完成 【 创建子进程、让子进程执行编译或运行命令、读取子进程的标准输出、读取子进程的标准错误 】这四个主要的逻辑。

// 1. 通过 Runtime 类 得到 Runtime 实例,执行 exec 方法
// 2. 获取 “子进程” 的标准输出,并写入文件中
// 3. 获取 “子进程” 的标准错误,并写入文件中
// 4. 等待子进程结束,拿到子进程的状态码,并返回

// 1. 通过 Runtime 类 得到 Runtime 实例,执行 exec 方法
// 2. 获取到标准输出,并写入文件中
// 3. 获取到标准错误,并写入文件中
// 4. 等待子进程结束,拿到子进程的状态码,并返回

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class CommandUtil {
    public static int run(String command, String stdoutFile, String stderrFile) throws IOException {
        // 1. 通过 Runtime 类 得到 Runtime 实例,执行 exec 方法
        Runtime runtime = Runtime.getRuntime();
        Process process = runtime.exec(command);

        // 2. 获取 "子进程 " 的标准错误,并写入文件中
        if (stderrFile != null) {

            InputStream stderrFrom = process.getErrorStream();
            FileOutputStream stderrTo = new FileOutputStream(stderrFile);

            while (true) {
                // (1) 读 " 子进程的的错误数据
                int ch = stderrFrom.read();
                if (ch == -1) {
                    break;
                }
                // (2) 将刚刚读到的数据写入到 "stderr" 文件中
                stderrTo.write(ch);
            }

            stderrFrom.close();
            stderrTo.close();
        }

        // 3. 获取 "子进程 " 的标准输出,并写入文件中
        if (stdoutFile != null) {

            InputStream stdoutFrom = process.getInputStream();
            FileOutputStream stdoutTo = new FileOutputStream(stdoutFile);

            while (true) {
                // (1) 读 " 子进程的的输出数据
                int ch = stdoutFrom.read();
                if (ch == -1) {
                    break;
                }
                // (2) 将刚刚读到的数据写入到 "stdout" 文件中
                stdoutTo.write(ch);
            }

            stdoutFrom.close();
            stdoutTo.close();
        }

        // 4. 等待子进程结束,拿到子进程的状态码,并返回
        // 如果 exitCode 返回的是 0,就说明进程运行是一个无误的状态
        int exitCode = 0;
        try {
            exitCode =  process.waitFor();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return exitCode;
    }

    /**
     * 测试用例
     */
    public static void main(String[] args) throws IOException {
        int exitCode = CommandUtil.run("javac", "./stdout.txt", "./stderr.txt");
        System.out.println(exitCode); // 2
    }
}

与此同时,我们可以创建同级目录下的两个 " .txt " 文件,以此来验证。因为 " javac " 命令需要结合 ". java " 文件才能正常编译,所以,这里就会在 " stderr.txt " 文件中,生成一些错误的信息,而在 " stdout.txt " 文件中,什么也没有写入。而返回的状态码为 2,即表示子进程并不是正常执行了编译过程。

1

创建一个 Task 类

通过 Task 类与 " CommandUtil.run " 两者的结合,就能够让一个 " .java 文件 " 经过编译到运行完成的过程。而 Task 类最终返回的 Answer 对象,就是我们最终放到 HTTP 响应正文中的数据,它用作判定在线 OJ 代码的语法规范。

平时我们在 leetcode 上写的代码,都会进行提交,而提交是不是少了一个括号,或者变量名未定义等等问题…只要出现了问题,代码就不会通过,那么就会出现报错提示,所以说,这里的 Answer 对象就是为了这个报错提示所诞生的。

public class Task {
    // 将一些 "编译+运行" 的临时文件放在此目录下
    private static final String WORK_DIR = "./temp/";

    // 表示 ".java 文件",里面放着待编译的代码,等待 "javac" 命令编译
    private static final String PREPARED_CODE = WORK_DIR + "Solution.java";

    // 表示 ".class 文件",里面放着二进制字节码,等待 "java" 命令运行
    private static final String CLASS_FILE = "Solution";

    // 将 "编译出错" 的信息放在此文件中
    private static final String COMPILE_ERROR = WORK_DIR +"compile_error.txt";

    // 将 "运行无误" 的信息放在此文件中
    private static final String STDOUT = WORK_DIR +"stdout.txt";

    // 将 "运行出错" 的信息放在此文件中
    private static final String STDERR = WORK_DIR +"stderr.txt";

    /**
     * 此方法就是用来 " 编译 + 运行 " 的,
     * 传入的参数是 " 一段待编译的代码 ";
     * 返回的参数是 " 一个 Answer " 对象,里面放着用例是否通过的信息
     */
    public Answer compileRun(String preparedCode) throws IOException {
        // 创建一个 Answer 对象,用作返回值,即 HTTP 响应的正文内容
        Answer answer = new Answer();

        // 创建一个工作目录,用来存放临时文件
        File workDir = new File(WORK_DIR);
        if (!workDir.exists()) {
            // 不存在就创建
            workDir.mkdir();
        }

        // 1. 在编译之前,我们得需要一个 ".java " 文件才行
        //    我们将传入进来的代码,放到 " Solution.java " 文件中
        FileUtil.writeFile(preparedCode, PREPARED_CODE);

        // 2. 编译期
        // 指定 ".java" 文件,以及它所在的目录
        String compileCommand = String.format("javac -encoding utf8 %s -d %s", PREPARED_CODE, WORK_DIR);
        CommandUtil.run(compileCommand, null, COMPILE_ERROR);

        // 从刚刚的 COMPILE_ERROR 文件中读数据,如果数据为空,那么就是编译没有问题;反之,有问题
        String compileError = FileUtil.readFile(COMPILE_ERROR);
        if ( ! "".equals(compileError) ) {
            // 代码走到这里,说明编译出错了
            System.out.println(" 编译出错!");
            answer.setStatus(1);
            answer.setReason(compileError);
            return answer;
        }

        // 代码走到这里,说明编译无误
        System.out.println("编译无误!");

        // 3. 运行期
        String runCommand = String.format("java -classpath %s %s", WORK_DIR, CLASS_FILE );
        CommandUtil.run(runCommand, STDOUT, STDERR);

        // 从刚刚的 STDERR 文件中读数据,如果数据为空,那么就是运行没有问题;反之有问题
        String stderr = FileUtil.readFile(STDERR);
        if ( ! "".equals(stderr)) {
            // 代码走到这里,说明运行出错了
            System.out.println("运行出错!抛出异常!");
            answer.setStatus(2);
            answer.setStderr(stderr);
            return answer;
        }

        // 代码走到这里,说明 "编译和运行" 都无误
        System.out.println("运行无误!");

        // 其实一般来说,所有结果都无误,STDOUT 文件中 也没数据
        answer.setStatus(0);
        String stdout = FileUtil.readFile(STDOUT);
        answer.setStdout(stdout);
        return answer;
    }

    /**
     * 测试用例
     */
    public static void main(String[] args) throws IOException {
        Task task = new Task();
        String preparedCode = "public class Solution {\n" +
                "    public static void main(String[] args) {\n" +
                "        int[] arr = {2, 4, 6, 8, 10};\n" +
                "        for (int i = 0; i < 5; i++) {\n" +
                "            System.out.print(arr[i] + \" \");\n" +
                "        }\n" +
                "    }\n" +
                "}\n";
       Answer answer = task.compileRun(preparedCode);
        System.out.println(answer);
    }
}

上面的这一过程中,Task 类进行了对代码的严格校验,是否编译有问题?是否运行有问题?对于不同的问题,以及正常的流程,都存储到文件中,以供程序员校验。而这一过程是我们基于 JDK 的 " javac " 和 " java " 命令来实现的,此外又提供了流对象的文件操作,才使得对一个代码进行了【编译、运行、校验】。

然而,上述的过程,实际上就是我们日常利用 IDEA 进行写代码的过程,只不过 IDEA 对我们写的代码进行了处理,省略了程序员编译的过程。 如果我们在 IDEA 写代码,编译过程出现问题,IDEA 就会出现受查异常;如果运行过程中出现问题,IDEA 就会出现非受查异常。

二、设计数据库

1. 通过自己写的 sql 语句,往 MySQL 数据库中,插入【oj_table 表】
表中预期用来存储题目的信息 ( 编号、标题、难度、描述、代码区、测试区 )

create database if not exists oj_database;

use oj_database;

drop table if exists oj_table;

create table oj_table(
    -- 题目编号
    id int primary key auto_increment,
    -- 题目标题
    title varchar(50),
    -- 题目难度
    level varchar(50),
    -- 题目描述
    description varchar(4096),
    -- 代码区
    codeTemplate varchar(4096),
    -- 测试用例
    testCase varchar(4096)
);

4

三、根据数据库设计实体类

Subject 实体类

public class Subject {
    private int id;
    private String title;
    private String level;
    private String description;
    private String codeTemplate;
    private String testCase;
}

四、封装数据库

JDBC 编程步骤

  1. 创建数据源
  2. 和数据库建立连接
  3. 构造 sql 语句并操作数据库
  4. 执行 sql
  5. 遍历结果集(select 查询的时候需要有这一步)
  6. 释放资源

1. 创建一个 DBUtil 类 ( Database Utility )

DBUtil 这个类,用来封装一些数据库的方法,供外面的类使用。

好处一:外面的类需要创建一些同样的实例, 这些实例是固定的。然而,有了DBUtil这个类,外面的类就不需要每次创建额外的实例,直接从 DBUtil 类 拿即可。
( DBUtil 中的单例模式正是做到了这一点)

好处二:同样地,外面的类需要用到一些同样的方法,有了 DBUtil 这个类,对于代码与数据库之间的一些通用的操作方法,直接从 DBUtil 类 导入即可。

我们可以将 DBUtil 这个类想象成一个充电宝,而将使用这个 DBUtil 公共类的其他类,称为手机、平板、mp3…毫无疑问,充电宝就是为电子设备提供服务的,而这些电子设备其实只有一个目的:通过充电宝这个公共资源为自己充电。

public class DBUtil {
    private static final String URL = "jdbc:mysql://127.0.0.1:3306/oj_database?characterEncoding=utf8&&useSSL=false";
    public static final String USERNAME = "root";
    public static final String PASSWORD = "lfm10101988";

    private static volatile DataSource dataSource = null;

    // 线程安全的单例模式
    private static DataSource getDataSource() {
        if (dataSource == null) {
            synchronized (DBUtil.class) {
                if (dataSource == null) {
                    dataSource = new MysqlDataSource();
                    ((MysqlDataSource)dataSource).setURL(URL);
                    ((MysqlDataSource)dataSource).setUser(USERNAME);
                    ((MysqlDataSource)dataSource).setPassword(PASSWORD);
                }
            }
        }
        return dataSource;
    }

    public static Connection getConnection() throws SQLException {
        return getDataSource().getConnection();
        // 外面的类实际上拿到的就是 connection = dataSource.getConnection();
    }

    public static void close(ResultSet resultSet, PreparedStatement statement, Connection connection){
        if (resultSet != null) {
            try {
                resultSet.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }

        if (statement != null) {
            try {
                statement.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }

        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

2. 封装 SubjectDB ( SubjectDatabase )

(1) insert 方法
新增一道题目

(2) delete 方法
删除一道题目

(3) selectAll 方法
查询题目列表,预期将 ( 题目的编号、标题、难度 ) 显示在题目列表页

(4) selectOne 方法
查询题目详情,预期进入某一题的详情页,可以进行代码编辑、提交代码…

3. 设计测试用例代码的思路

我们可以参考一下 leetcode 官网的编号第一题:两数之和。

我提交了很多次,有解答错误,也有通过的,也有编译错误…虽然页面给出了代码提示,但是它底层是怎么设计用例的,我们全然不知,当然,这是 leetcode 的核心技术所在,所以不会轻易暴露出来给用户看,它所呈现的只有提交结果的提示,以及你通过了几个用例、哪些用例没有通过。

4

但是,我们可以使用一种简单的方法来设计几个用例,虽然考虑不到所有的情况,但是考虑到一部分,还是可以的。

思路:由于我们在客户端提交的代码是写在 Solution 这个类中的,所以,我们就可以在服务器端通过客户端的 HTTP请求的 正文 中,拿到这个 Solution 类中的代码,然后在服务器端,创建一个 main 方法,在 main 方法中创建 Solution 的实例,并创建几个测试用例,验证代码。 程序如下所示:

class Solution {
    public int[] twoSum(int[] nums, int target) {
        int[] arr = {0, 0};

        for(int i = 0; i < nums.length; i++) {
            for(int j = i + 1; j < nums.length; j++) {
                if (nums[i] + nums[j] == target) {
                    arr[0] = i;
                    arr[1] = j;
                    return arr;
                }
            }
        }
        return null;
    }

    public static void main(String[] args) {
        Solution solution = new Solution();

        // 测试用例 1
        int[] nums1 = {2, 7, 11, 15};
        int target1 = 9;
        int[] result1 = solution.twoSum(nums1, target1);

        if (result1.length == 2 && result1[0] == 0 && result1[1] == 1) {
            System.out.println(" < Case1 passed. > ");
        } else {
            System.out.println(" < Case1 failed. > ");
        }

        // 测试用例 2
        int[] nums2 = {3, 2, 4};
        int target2 = 6;
        int[] result2 = solution.twoSum(nums2, target2);

        if (result2.length == 2 && result2[0] == 1 && result2[1] == 2) {
            System.out.println(" < Case2 passed. > ");
        } else {
            System.out.println(" < Case2 failed. > ");
        }

        // 测试用例 3
        int[] nums3 = {3, 3};
        int target3 = 6;
        int[] result3 = solution.twoSum(nums3, target3);

        if (result3.length == 2 && result3[0] == 0 && result3[1] == 1) {
            System.out.println(" < Case3 passed. > ");
        } else {
            System.out.println(" < Case3 failed. > ");
        }
    }
}

输出结果:

3

在上面的程序中,Solution 类中的代码就是我们在客户端提交的代码,main 方法中的代码就是我们自己设计的三个测试用例,我们后续需要将这道题的测试用例来放入数据库的 " testCase " 字段中。

必须明确:上面的程序是一个展示结果,但实际上,我们需要先在服务器端拿到客户端发来的代码,然后与 main 方法的代码进行字符串拼接,才能够达到最终的效果。

服务器端如何拿到客户端的代码呢?

答:实际上,客户端应该将提交的代码以 json 的数据格式,写入 HTTP 请求的正文中,然后,服务器端再利用一些方法,将 json 数据解析成 Java 的实体类,这样一来,就可以进行后续操作了。

这一步骤,在后面的环节,我会展开介绍,这是后端 API 需要处理的事情。

然而,本环节,我们需要着重替换测试用例的设计。

五、准备好纯前端页面

我认为,在约定前后端的访问路径、HTTP 中正文的内容这些东西之前,需要有一个前端的页面,或者说,需要有一个前端的基本框架。这样一来,就方便前后端的交互了。如果有一个基本的页面,前后端就不至于摸不着头脑,凭空去设计了。

对于前端来说,程序员就明确了哪些地方需要设置点击事件、哪些地方可以写成静态页面、哪些地方可以写成链接的形式了。

对于后端来说,最重要的就是提供 HTTP 响应中的数据,大多数都是以 json 格式写入报文中的,写入数据之前,也要明确前端用这个数据来干什么。

对于前后端交互的接口,后面我会着重介绍,前端需要利用 JS-WebAPI 来实现,后端需要利用 Servlet 实现。现在,请看下面两幅基本的结构图:

OJ 列表页

6-1

OJ 详情页

不知道为什么,详情页通过长截图截不下来,下面两幅图是一个页面。

6-2
6-3

部署到项目的 webapp 目录下

将纯前端的所有代码文件,都复制到项目的 webapp 目录下,以备后用。后面前端通过 ajax 或 form 表单的形式构造 HTTP 请求,就可以直接对项目中的前端文件进行修改。

6-4

六、实现题目列表页

作用:题目列表页主要用来展示所有题目的摘要 ( 编号、标题、难度 )

前端

约定 GET 请求 的路径:" /subjectList "(前端通过 ajax 这种方式来构造请求)

前端代码: 先按照纯前端代码创建相应的节点,之后再挂在 DOM 树上。此外,这里通过 a 标签,约定跳转链接的路径,题目列表页与题目详情页通过 " id " 这个参数进行连接。

<script>
    $.ajax({
        url: "subjectList",
        method: "GET",
        success: function(data, status) {

            // 从 HTTP 响应的正文中获取到的数据赋值给 subjectLists,名字正好对应起来
            let subjectList = data;
            let tbody = document.querySelector(".subjectTable");
            
            for( let subject of subjectList ) {
                let tr = document.createElement("tr");

                // 题目编号
                let idTd = document.createElement("td");
                idTd.innerHTML = subject.id;
                
                // 题目标题
                let titleA = document.createElement("a");
                let titleTd = document.createElement("td");
                titleA.innerHTML = subject.title;
                titleA.href = "oj_content.html?id=" + subject.id;
                
                // 题目难度
                let levelTd = document.createElement("td");
                levelTd.innerHTML = subject.level;

                tr.appendChild(idTd);
                titleTd.appendChild(titleA);
                tr.appendChild(titleTd);
                tr.appendChild(levelTd);

                tbody.appendChild(tr);
            }
        }
    })
</script>

后端

服务器端代码:创建一个 SubjectListServlet 来处理计算响应,在此类中,我们为 HTTP 响应的正文 body 写入 json 格式的数据。

@WebServlet("/subjectList")
public class SubjectListServlet extends HttpServlet {
    ObjectMapper objectMapper = new ObjectMapper();
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 设置 HTTP 响应的正文格式为 json
        resp.setContentType("application/json; charset=UTF-8");

        SubjectDB subjectDB = new SubjectDB();

        // 从数据库中选取所有题目的信息,保存在一个顺序表中
        List<Subject> subjectList = subjectDB.selectAll();
        // 将 顺序表这个 Java 对象转换成 json 格式的数据,并写入 HTTP 响应的正文中
        String jsonData =  objectMapper.writeValueAsString(subjectList);

        resp.getWriter().write(jsonData);
    }
}

七、实现题目详情页

作用:题目详情页用来展示题目描述、代码编辑框、测试用例,最重要的是,这里需要提交代码到服务器端。

1. 题目描述和代码编辑框

前端

约定 GET 请求 的路径:" /subjectContent " + location.search(前端通过 ajax 这种方式来构造请求)

location.search 就对应着 " id " 这样的参数

5-1

前端代码: 思想与之前一样,先创建节点,后挂在 DOM 树上。

<script>
    $.ajax({
        url: "subjectContent" + location.search,
        method: "GET",
        success:function(data, status) {
            let subject = data;

            // 题目描述
            let desc1 = document.querySelector(".desc1");
            desc1.innerHTML = subject.id + ".  " + subject.title + " [" + subject.level + "]";
            let desc2 = document.querySelector(".desc2");
            desc2.innerHTML = subject.description;

            // 代码编辑框
            let text = document.querySelector(".form-group textarea");
            text.innerHTML = subject.codeTemplate;
        }
    })
</script>

后端

服务器端代码: 创建一个 SubjectContentServlet 来处理计算响应,在此类中,我们先从 HTTP 请求的 " query string " 中读取 " id " 参数 ,之后根据这个参数来找到对应题目的所有详细数据,并为 HTTP 响应的正文 body 写入 json 格式的数据。

@WebServlet("/subjectContent")
public class SubjectContentServlet extends HttpServlet {
    ObjectMapper objectMapper = new ObjectMapper();
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 以 utf8 编码的方式从 HTTP 请求的正文中读数据
        req.setCharacterEncoding("utf8");
        resp.setContentType("application/json; charset=UTF-8");

        // 获取 参数 id
        String id = req.getParameter("id");
        SubjectDB subjectDB = new SubjectDB();
        Subject subject = subjectDB.selectOne(Integer.parseInt(id));

        // 将 subject 这个 Java 对象转换成 json 格式的数据,并写入 HTTP 响应的正文中
        String jsonData = objectMapper.writeValueAsString(subject);
        resp.getWriter().write(jsonData);
    }
}

注意事项: 我们都知道前端需要从 HTTP 响应的正文中拿到 json 数据,然而,在一大段文字中,我们不但要注意编码格式,同时也要注意,html 是否能够识别数据库的一些符号。

就拿下面的例子来说,Java 一开始往数据库中插入数据的时候,使用 " \n " 作为换行符,所以数据库中的换行符也是 " \n “,然而,在 html 的语法中,” br " 标签才是换行符,所以最终就是,html 并不能识别 " \n " 符号,如果想让 html 能够识别一些特殊符号,就需要为文本套上 " pre " 标签才行。如下代码:

<pre> 
    <p class="desc2"></p> 
</pre>

5-2

2. 提交代码和测试用例

前端

约定 GET 请求 的路径:" /compile "(前端通过 ajax 这种方式来构造请求)

前端代码思想:

(1) 将 " 提交代码 " 按钮设置为一个点击事件,为点击事件设置一个函数,里面使用 ajax 来发送 POST 请求,在请求中,最关键的就是要将用户提交的代码,以 json 的格式传入到 HTTP 请求的正文中。

(2) 如果 POST 请求发送成功后,后端也返回了响应,这个时候,就可以拿着 " 编译运行 " 后的测试数据,展现在前端页面上。

// 提交代码到服务器端
let sbutton = document.querySelector(".sbutton");
sbutton.onclick = function() {
    // 将 题目id 和 我们自己编写的代码封装成一个 body 对象
    let body =  {
        id: subject.id,
        code: template.value
    };

    $.ajax({
        url: "compile",
        method: "POST",
        // 将 body 对象写入 HTTP 请求的正文中,以 json 的格式存放在 HTTP 正文中
        data: JSON.stringify(body),  
        success: function(data, status) {
            // 这里 data 读到的就是测试用例是否通过、以及出错的原因...等等一些数据
            
            // 状态码 【 0 表示运行无误、1 表示编译出错、2 表示运行出错 】
            let respStatus = document.querySelector(".container .status");
            respStatus.innerHTML = "status: " + data.status + " ( 0 表示运行无误、1 表示编译出错、2 表示运行出错 )";

            // 出错的解释
            let respReason = document.querySelector(".container .reason");
            respReason.innerHTML = "reason: " + "</br>" + data.reason;

            // 运行无误的结果
            let respStdout = document.querySelector(".container .stdout");
            respStdout.innerHTML = "stdout: " +  "</br>" + data.stdout;

            // 运行有误的结果
            let respStderr = document.querySelector(".container .stderr");
            respStderr.innerHTML = "error: " + data.stderr;

        }
    })
}

后端

服务器端代码思想:

(1) 读取 HTTP 请求中的数据,即刚刚用户提交的代码,而这个提交的代码是一个 json 格式的数据,那么,我们就需要将其转换成一个实体类,以便于后面 Java 对象的使用。

(2) 将 用户提交的代码 和 我们自己设计的测试用例,融合在一起。

(3) 将刚刚拼接好的代码,创建编译任务、创建执行任务。

(4) 把测试完的结果,包装成一个实体类,再以 json 格式写入 HTTP 响应中。

@WebServlet("/compile")
public class CompileServlet extends HttpServlet {
    ObjectMapper objectMapper = new ObjectMapper();

    // 将从 HTTP 请求中读取的 json 数据封装成一个实体类
    static class CompileRequest{
        // 题目 id
        public int id;
        // 用户提交的代码
        public String code;

        @Override
        public String toString() {
            return "CompileRequest{" +
                    "id=" + id +
                    ", code='" + code + '\'' +
                    '}';
        }
    }

    // 将返回的数据封装成一个实体类,以备后续写入 HTTP 响应中
    static class CompileResponse{
        // 状态码 【 0 表示运行无误、1 表示编译出错、2 表示运行出错 】
        public int status;
        // 出错的解释
        public String reason;
        // 运行无误的结果
        public String stdout;
        // 运行有误的结果
        public String stderr;
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 临时加一个这样的代码,来获取到 SmartTomcat 的工作目录
        System.out.println("用户当前的工作目录: " + System.getProperty("user.dir"));

        req.setCharacterEncoding("utf8");
        resp.setContentType("application/json; charset=UTF-8");

        CompileRequest compileRequest = new CompileRequest();

        // 1. 读 HTTP 请求的数据
        // readBody 方法专门用来读取 HTTP 请求的正文中的数据
        // 拿字符串来接收 HTTP 请求中的数据,一来可以很好地验证是否接受到了数据;二来可以方便后续的使用
        String body = readBody(req);
        //System.out.println(body); // 验证

        // 将 HTTP 请求中 json 数据转换成一个 Java 实体类,以备后续使用 Java 对象
        compileRequest = objectMapper.readValue(body, CompileRequest.class);
        //System.out.println(compileRequest); // 验证

        // 代码走到这里,说明刚刚用户在前端提交的代码,已经完完全全地以 Java 的形式放入了 compileRequest 对象中
        // 接下来要做的就是,将前端代码与测试用例一拼接,来验证代码是否正确,
        // 而从 HTTP 请求传过来的 id 就能够找到当前是哪一题,从而就能找到当前这一题的测试用例

        // 2. 融合代码
        SubjectDB subjectDB = new SubjectDB();
        Subject subject = subjectDB.selectOne(compileRequest.id);

        String submitCode = compileRequest.code; // 用户提交的代码
        String testCode = subject.getTestCase(); // 测试用例的代码
        String finalCode = mergeCode(submitCode, testCode);

        // 代码走到这里,说明两个代码已经合并完成
        // 接下来要做的是,将刚刚融合的代码,用来编译,用来运行

        // 3. 创建任务。并执行任务
        Task task = new Task();
        Answer answer = task.compileRun(finalCode);

        // 4. 将编译运行后的结果存入实体类中
        CompileResponse compileResponse = new CompileResponse();
        compileResponse.status = answer.getStatus();
        compileResponse.reason = answer.getReason();
        compileResponse.stdout = answer.getStdout();
        compileResponse.stderr = answer.getStderr();

        // 5. 将 Java 转换成 json 格式的数据,并写入 HTTP 响应的正文中
        String jsonData = objectMapper.writeValueAsString(compileResponse);
        resp.getWriter().write(jsonData);

    }

    /**
     * 将用户提交的代码与测试用例拼接
     * 方法:
     * <1> 将用户提交的代码的最后一个大括号去掉
     * <2> 将用户提交的代码直接去拼接测试用例的代码
     * <3> 补全最后一个大括号
     */
    private String mergeCode(String submitCode, String testCode) {

        // <1>
        int index = submitCode.lastIndexOf("}");
        String newStr = submitCode.substring(0, index);

        // <2> + <3>
        return newStr + testCode + "\n" +"}";
    }

    /**
     * 以字节流的方式读取 HTTP 请求中的正文数据
     */
    private String readBody(HttpServletRequest req) throws IOException {
        // 得到 HTTP 请求正文的字节总数
        int contentLength = req.getContentLength();

        // 以刚刚得到的字节总数,new 一个字节数组
        byte[] bytes = new byte[contentLength];

        // 以流对象的形式获取 HTTP 请求的正文
        InputStream inputStream = req.getInputStream();

        // 一次性将正文中所有的数据读到字节数组中
        inputStream.read(bytes);

        // 将字节数组构造成一个字符串,并返回
        return new String(bytes, "utf8");
    }  
}

融合代码的思想:

融合代码的思想如下所示,其实就是利用了字符串提供的一些方法,来实现字符串的拼接过程而已,这部分的思想,如果平时有小伙伴经常刷字符串的题,很好理解。

6-1

示例:

下面是我自己设计的一个拼接测试,看看输出就能很好理解。

public class Test {
    public static void main(String[] args) {
        String str1 = "abc}}";
        String str2 = "xyz";
		
		// 获取到最后一个大括号的位置
        int index = str1.lastIndexOf("}");
        System.out.println(index); // 4

		// substring 遵循左闭右开
        String str3 = str1.substring(0, index);
        System.out.println(str3); // abc

		// 两者对比
        System.out.println(str1 + str2);
        System.out.println(str3 + str2);
    }
}

输出结果:

6-2

八、优化项目

1. 处理异常

当我提交的代码如下所示,这就会造成服务器端出现异常,那么客户端最终为用户呈现的可能就是 " 500 " 这样的状态码,此时如果刷题的人是一个小白用户,那么他就很懵。所以,作为开发人员,应该在后端将这些问题考虑进去。

class Solution {
    public int[] twoSum(int[] nums, int target) {

优化方案:

通过 try - catch - finally,将异常查出来,并为非法代码设置一个额外提示。


try{	
    String submitCode = compileRequest.code; // 用户提交的代码
    String testCode = subject.getTestCase(); // 测试用例的代码
    String finalCode = mergeCode(submitCode, testCode);
    if (finalCode == null) {
        throw new RuntimeException();
    }
} catch (RuntimeException e) {
    // 处理一些代码不合法的异常
    compileResponse.status = 2;
    compileResponse.reason = "代码不合法";
    compileResponse.stdout = null;
    compileResponse.stderr = null;

} finally {
    // 5. 将 Java 转换成 json 格式的数据,并写入 HTTP 响应的正文中
    String jsonData = objectMapper.writeValueAsString(compileResponse);
    resp.getWriter().write(jsonData);
}

2. 校验代码安全性

之前我提到,有些用户提交的代码就是不安全的,例如下面的代码:

学过 Linux 的小伙伴都知道,如果用户故意搞破坏,写了下面的语句,就直接对操作系统上的文件进行操作,而下面的 " rm -rf / " 就表示,将 Linux 系统上的数据全部清空,这是一个不可逆操作,很危险。所以作为后端开发人员,依旧要考虑这些特殊情况,毕竟我们控制不了别人的思想,但我们能够阻止类似危险的情况发生。

Runtime runtime = Runtime.getRuntime();
 Process process = runtime.exec("rm-rf /");

优化方案:

在 Task 类中,专门写一个函数,用来校验安全,在函数中,先要明确哪些是危险操作,之后,遍历用户提交的代码字符串,只要发现有危险代码,直接返回错误信息。

// 检验代码安全性
if (!checkCodeSafe(preparedCode)) {
    System.out.println("用户提交了不安全的代码");
    answer.setStatus(1);
    answer.setReason("您提交的代码可能会危害到服务器,禁止运行!");
    return answer;
}

...

/**
 * 检验代码安全性
 */
private boolean checkCodeSafe(String preparedCode) {
    // 创建一个黑名单
    List<String> blackList = new ArrayList<>();

    // 防止提交的代码运行恶意程序
    blackList.add("Runtime");
    blackList.add("exec");

    // 禁止提交的代码读写文件
    blackList.add("java.io");

    // 禁止提交的代码访问网络
    blackList.add("java.net");

    for (String target : blackList) {
        int pos = preparedCode.indexOf(target);
        if (pos >= 0) {
            // 找到任意的恶意代码特征,返回 false 表示不安全
            return false;
        }
    }
    return true;
}

3. 利用 UUID 生成不同目录

按照之前 Task 类的设计,后端为用户提交的代码进行测试,测试完之后,都是放到了一个固定目录 temp 下,然而,下一次提交、下下一次提交,就会覆盖之前的代码,长此以往,我们看到的测试信息永远是最新的,这很不合理。

所以解决上述问题的思想就是:我们可以使用 " 唯一 ID " 来为不同时刻生成不同目录,典型的方法就是使用 " UUID “,” UUID " 是计算机中常用的概念,表示 " 全世界唯一的 ID ",Java 也为我们提供了一个方法,请继续往下看。

优化方案:

(1) 首先,我们在 CompileServlet 类的开头,添加下面语句,方便后面我们找到 Tomcat 底下的目录。

// 临时加一个这样的代码,来获取到 SmartTomcat 的工作目录
System.out.println("用户当前的工作目录: " + System.getProperty("user.dir"));

(2) 其次,我们重新设置目录,取消之前的 " final " 关键字,利用 Task 类的构造方法,外部每一次 new 一个 Task 对象,都会重新生成一个目录。

public class Task {
    // 将一些 "编译+运行" 的临时文件放在此目录下
    private static String WORK_DIR;

    // 表示 ".java 文件",里面放着待编译的代码,等待 "javac" 命令编译
    private static  String PREPARED_CODE;

    // 表示 ".class 文件",里面放着二进制字节码,等待 "java" 命令运行
    private static  String CLASS_FILE;

    // 将 "编译出错" 的信息放在此文件中
    private static  String COMPILE_ERROR;

    // 将 "运行无误" 的信息放在此文件中
    private static  String STDOUT;

    // 将 "运行出错" 的信息放在此文件中
    private static  String STDERR;

    public Task() {
        WORK_DIR = "./temp/" + UUID.randomUUID().toString() + "/";

         PREPARED_CODE = WORK_DIR + "Solution.java";

         CLASS_FILE = "Solution";

         COMPILE_ERROR = WORK_DIR +"compile_error.txt";

         STDOUT = WORK_DIR +"stdout.txt";

         STDERR = WORK_DIR +"stderr.txt";
    }
}

4. 引入合理的代码编辑框

之前我们在前端页面写的代码编辑框,是利用 " textarea " 生成的,它只能够用来多行输入,并不能使用 " 代码补全 " 、" 语法高亮 " …等一系列代码优化操作,所以我们考虑从第三方库引入一个新的代码编辑框,提高用户体验。

引入的第三库名为 " ace.js ",这部分的代码我就不展示了,都是前端的一些固定写法,我们可以根据自己的需要来自定义代码编辑框。

7-1

页面展示结果

题目列表页

9-1

题目详情页

9-2

总结页面的交互逻辑

题目列表页

7-1

题目详情页

题目描述和代码编辑框

7-2

提交代码和测试用例

此过程是整个 OJ系统 最核心的地方,后端不但需要从 HTTP 请求中拿数据,也需要往 HTTP 响应中放数据。而在拿放数据之间,不但需要用到 json 数据与 Java 之间的转换,还需要通过 " 编译 + 运行 " 机制 进行检测代码是否合理。

7-3

总结

这是我做的第二个独立项目,刚开始觉得很难,不管是使用 Java 流对象来操作文件,还是通过 " javac " 和 " java " 这两个命令来编译运行,这些都让我有些措手不及。但

实际上,它确实很难,不仅要考虑到前后端交互的约定,还需要考虑到后端对于用户提交过来代码的业务处理,很多细节都需要顾虑到。

例如:后端需要考虑用户不能直接收到 " 500 " 这样状态码,后端应该人性化地考虑到每个用户使用的场景,以及提交之后发生的异常情况。

再例如:测试用例的设计是一个很不好处理的事情,因为每道题的测试用例不一样,而且每道题的测试情况,一般人是很难考虑周全。而当前,我只是用到了代码拼接这一简单的逻辑,但实际上,代码效率并不高。

相比于之前写的博客系统的项目中,我发现自己又进步了一点,实际上准确地说,自己掌握了更多细节的地方,以及更加 Java 面向对象编程的思想。

不管是前端,还是数据库,Java 始终是作为一个很重要的角色存在,很多操作都是需要先有类,再生成 Java 对象,最后才能进行与前端和数据库沟通。此外,HTTP 协议用的越来越熟了,可能是因为实践多了的缘故,这次项目,我抓包更少了,遇到问题,调试的更快了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

十七ing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值