引言
本次项目用到的技术
协议:HTTP.
前端:HTML, CSS, JavaScript, JS-WebAPI, jQuery, ace.js
后端:Servlet, Jackson, JDBC, 流对象, 多进程编程, " javac ", " java "
数据库:MySQL.
测试:IDEA, Chrome, Fiddler.
本次项目的业务流程
- 设计编译运行方案
- 设计数据库
- 根据数据库设计实体类
- 封装数据库
- 准备好纯前端页面
- 实现题目列表页
- 实现题目详情页
- 优化项目
一、理解 OJ 系统的核心思想并设计编译运行方案
1. 在线提交代码的后端处理方案
我们日常使用 leetcode 刷题的时候,都是在代码区域写好代码,然后提交。然而,我们在编辑代码的时候,编辑区并不像 IDEA 那样,为我们提示语法是否正确。所以,我们就需要考虑到,用户提交代码后可能发生的情况,也许是编译时期出现了错误,此时就生成不了 " .class " 文件了,也许编译无误,但是运行时期出现了错误,那么我们就应该抛出一个异常。
回顾多线程与多进程
多线程和多进程都可以进行并发编程,然而多线程更加轻量,多进程更加独立。
我的项目有一个服务器进程,它运行着 Servlet,用来接收用户的请求,返回响应…
而用户提交的代码,我认为也应该是一个独立的运行逻辑。很多情况下,我们无法控制用户到底提交了什么样的代码,也许用户提交的代码会正常通过用例,也许会抛出异常、也许会损害整个服务器端。这些都是可能发生的情况,有的人说 " 损害服务器 " 比较夸张。那就举个例子吧:如果有用户通过代码对服务器端的文件进行操作,那么是不是直接就接触到服务器端的本地数据了呢?虽然这种概率很低,但我们仍然要考虑进去。
综上所述,像 leetcode 这样的网站,同一时刻可能就有几万次提交代码的用户。那么,先抛开危险性、优化好坏不说,如果使用多线程编程,其中有一个用户代码出现了异常,可能就会导致整个服务器端进程崩溃,从而导致网站崩溃。
所以说,让 " 用户提交代码这个运行逻辑 " 使用多进程的方式,就是得益于它的独立性,使得每个用户提交的代码互不影响,这是一个很关键的思想。
Java 多进程编程
我们期望,由服务器端进程作为父进程,用来接收请求,返回响应。由 Runtime 类 和 Process 类创建一个子进程,来执行用户提交的代码,用这个子进程去处理 " 编译 + 运行 " 的整个过程,也就是 " javac " 和 " java " 命令。让这些命令传入 " exec " 方法,最后,将结果写入文件中。
Runtime runtime = Runtime.getRuntime(); Process process = runtime.exec(command);
进程与文件
计算机中一个进程在启动的时候,会自动打开三个文件:
- 标准输入对应到键盘
- 标准输出对应到显示器
- 标准错误对应到显示器
必须明确,JDK 为我们提供的 " javac " 命令是一个控制台程序,它的输出是输出到 " 标准输出 " 和 " 标准错误 " 这两个特殊的文件中的,要想看到这个程序的运行效果,就得获取到标准输出和标准错误的内容。
" javac " 这样的命令并不像我们在控制台输入一个 " notepad " 命令,直接打开了记事本,因为记事本程序是以图形化界面为我们呈现出来的。
此外,虽然子进程启动后同样也打开了这三个文件,但是由于子进程没有和 IDEA 的终端关联,因此在 IDEA 中是看不到子进程的输出的,要想获取到输出,就需要在代码中手动获取到。
在下面的 CommandUtil 类中,我就将 子进程的 " 标准输出 " 和 " 标准错误 " 写入文件中,以来观察 " 编译期 " 和 " 运行期 " 的代码是否有误。
2. 设计 " 编译 + 运行 " 方案
再通过上面的理论思想介绍后,其实总结下来就是一个方案。
① 让用户提交的代码经过 JVM 进行 " 编译+ 运行 ",让 JVM 为我们自动判断提交的代码到底是正确,还是哪里出现了错误。所以我们就可以通过 JDK 提供的 " javac " 和 " java " 命令来分别执行 " 编译+ 运行 " 。
② 此外,因为这两个命令是在控制台上输入的,那么命令的执行结果,我们只能在控制台观察,如果我们想要直观地观察,可以将上面两个命令执行的结果写入本地文件中,再通过 Java 流对象 将文件数据读出来,返回给用户观察即可。
封装 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,即表示子进程并不是正常执行了编译过程。
创建一个 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) );
三、根据数据库设计实体类
Subject 实体类
public class Subject { private int id; private String title; private String level; private String description; private String codeTemplate; private String testCase; }
四、封装数据库
JDBC 编程步骤
- 创建数据源
- 和数据库建立连接
- 构造 sql 语句并操作数据库
- 执行 sql
- 遍历结果集(select 查询的时候需要有这一步)
- 释放资源
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 的核心技术所在,所以不会轻易暴露出来给用户看,它所呈现的只有提交结果的提示,以及你通过了几个用例、哪些用例没有通过。
但是,我们可以使用一种简单的方法来设计几个用例,虽然考虑不到所有的情况,但是考虑到一部分,还是可以的。
思路:由于我们在客户端提交的代码是写在 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. > "); } } }
输出结果:
在上面的程序中,Solution 类中的代码就是我们在客户端提交的代码,main 方法中的代码就是我们自己设计的三个测试用例,我们后续需要将这道题的测试用例来放入数据库的 " testCase " 字段中。
必须明确:上面的程序是一个展示结果,但实际上,我们需要先在服务器端拿到客户端发来的代码,然后与 main 方法的代码进行字符串拼接,才能够达到最终的效果。
服务器端如何拿到客户端的代码呢?
答:实际上,客户端应该将提交的代码以 json 的数据格式,写入 HTTP 请求的正文中,然后,服务器端再利用一些方法,将 json 数据解析成 Java 的实体类,这样一来,就可以进行后续操作了。
这一步骤,在后面的环节,我会展开介绍,这是后端 API 需要处理的事情。
然而,本环节,我们需要着重替换测试用例的设计。