“ 作业帮 “ (Servlet)(上)

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,高可用系列 2核4GB
简介: “ 作业帮 “ (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 流对象 将文件数据读出来,返回给用户观察即可。


c2561a6add3b47c284c8f608e4cac5ec.png


封装 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,即表示子进程并不是正常执行了编译过程。


0302bef78c0e4ab19d7977ee1ba8cbf2.png


创建一个 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)
);

22a3593db31b4c40aa4067f4f44dc96c.png


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



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 的核心技术所在,所以不会轻易暴露出来给用户看,它所呈现的只有提交结果的提示,以及你通过了几个用例、哪些用例没有通过。


7e6c8a011efd4c22871453dfde416cd3.png


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


思路:由于我们在客户端提交的代码是写在 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. > ");
        }
    }
}


输出结果:


305031d1643e44ada1a2402d5c3b663c.png


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


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


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


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


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


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


目录
相关文章
|
JSON 前端开发 JavaScript
“ 作业帮 “ (Servlet)(下)
“ 作业帮 “ (Servlet)(下)
117 0
“ 作业帮 “ (Servlet)(下)
|
4月前
|
缓存 安全 Java
Java服务器端技术:Servlet与JSP的集成与扩展
Java服务器端技术:Servlet与JSP的集成与扩展
39 3
|
4月前
|
存储 缓存 前端开发
Servlet与JSP在Java Web应用中的性能调优策略
Servlet与JSP在Java Web应用中的性能调优策略
35 1
|
4月前
|
存储 Java 关系型数据库
基于Servlet和JSP的Java Web应用开发指南
基于Servlet和JSP的Java Web应用开发指南
72 0
|
4月前
|
前端开发 安全 Java
在Java服务器端开发的浩瀚宇宙中,Servlet与JSP犹如两颗璀璨的明星,它们联袂登场,共同编织出动态网站的绚丽篇章。
在Java服务器端开发的浩瀚宇宙中,Servlet与JSP犹如两颗璀璨的明星,它们联袂登场,共同编织出动态网站的绚丽篇章。
30 0
|
6月前
|
自然语言处理 前端开发 Java
Servlet与JSP:Java Web开发的基石技术详解
【6月更文挑战第23天】Java Web的Servlet与JSP是动态网页的核心。Servlet是服务器端的Java应用,处理HTTP请求并响应;JSP则是结合HTML与Java代码的页面,用于动态内容生成。Servlet通过生命周期方法如`init()`、`service()`和`destroy()`工作,而JSP在执行时编译成Servlet。两者在MVC架构中分工,Servlet处理逻辑,JSP展示数据。尽管有Spring MVC等框架,Servlet和JSP仍是理解Web开发基础的关键。
110 12
|
6月前
|
存储 Java 关系型数据库
基于Servlet和JSP的Java Web应用开发指南
【6月更文挑战第23天】构建Java Web应用,Servlet与JSP携手打造在线图书管理系统,涵盖需求分析、设计、编码到测试。通过实例展示了Servlet如何处理用户登录(如`LoginServlet`),JSP负责页面展示(如`login.jsp`和`bookList.jsp`)。应用基于MySQL数据库,包含用户和图书表。登录失败显示错误信息,成功后展示图书列表。部署到Tomcat服务器测试功能。此基础教程为深入Java Web开发奠定了基础。
125 10
|
6月前
|
缓存 小程序 前端开发
Java服务器端技术探秘:Servlet与JSP的核心原理
【6月更文挑战第23天】Java Web开发中的Servlet和JSP详解:Servlet是服务器端的Java小程序,处理HTTP请求并响应。生命周期含初始化、服务和销毁。创建Servlet示例代码展示了`doGet()`方法的覆盖。JSP则侧重视图,动态HTML生成,通过JSP脚本元素、声明和表达式嵌入Java代码。Servlet常作为控制器,JSP处理视图,遵循MVC模式。优化策略涉及缓存、分页和安全措施。这些技术是Java服务器端开发的基础。
67 9
|
6月前
|
缓存 安全 Java
Java服务器端技术:Servlet与JSP的集成与扩展
【6月更文挑战第23天】Java Web开发中,Servlet和JSP是构建动态Web应用的基础。Servlet处理逻辑,JSP专注展示。示例展示了Servlet如何通过`request.setAttribute`传递数据给JSP渲染。JSP自定义标签提升页面功能,如创建`WelcomeTag`显示欢迎消息。Servlet过滤器,如`CacheControlFilter`,用于预处理数据或调整响应头。这些集成和扩展技术增强了应用效率、安全性和可维护性,是Java服务器端开发的关键。
71 7
|
6月前
|
前端开发 安全 Java
Java服务器端开发实战:利用Servlet和JSP构建动态网站
【6月更文挑战第23天】**Servlet和JSP在Java Web开发中扮演关键角色。Servlet处理业务逻辑,管理会话,JSP则结合HTML生成动态页面。两者协同工作,形成动态网站的核心。通过Servlet的doGet()方法响应请求,JSP利用嵌入式Java代码创建动态内容。实战中,Servlet处理数据后转发给JSP展示,共同构建高效、稳定的网站。虽然新技术涌现,Servlet与JSP仍为Java Web开发的基石,提供灵活且成熟的解决方案。**
79 8