在线 OJ 系统 [Servlet]
引言
本次项目用到的技术
协议: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 需要处理的事情。
然而,本环节,我们需要着重替换测试用例的设计。
五、准备好纯前端页面
我认为,在约定前后端的访问路径、HTTP 中正文的内容这些东西之前,需要有一个前端的页面,或者说,需要有一个前端的基本框架。这样一来,就方便前后端的交互了。如果有一个基本的页面,前后端就不至于摸不着头脑,凭空去设计了。
对于前端来说,程序员就明确了哪些地方需要设置点击事件、哪些地方可以写成静态页面、哪些地方可以写成链接的形式了。
对于后端来说,最重要的就是提供 HTTP 响应中的数据,大多数都是以 json 格式写入报文中的,写入数据之前,也要明确前端用这个数据来干什么。
对于前后端交互的接口,后面我会着重介绍,前端需要利用 JS-WebAPI 来实现,后端需要利用 Servlet 实现。现在,请看下面两幅基本的结构图:
OJ 列表页
OJ 详情页
不知道为什么,详情页通过长截图截不下来,下面两幅图是一个页面。
部署到项目的 webapp 目录下
将纯前端的所有代码文件,都复制到项目的 webapp 目录下,以备后用。后面前端通过 ajax 或 form 表单的形式构造 HTTP 请求,就可以直接对项目中的前端文件进行修改。
六、实现题目列表页
作用:题目列表页主要用来展示所有题目的摘要 ( 编号、标题、难度 )
前端
约定 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 " 这样的参数
前端代码: 思想与之前一样,先创建节点,后挂在 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>
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");
}
}
融合代码的思想:
融合代码的思想如下所示,其实就是利用了字符串提供的一些方法,来实现字符串的拼接过程而已,这部分的思想,如果平时有小伙伴经常刷字符串的题,很好理解。
示例:
下面是我自己设计的一个拼接测试,看看输出就能很好理解。
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);
}
}
输出结果:
八、优化项目
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 ",这部分的代码我就不展示了,都是前端的一些固定写法,我们可以根据自己的需要来自定义代码编辑框。
页面展示结果
题目列表页
题目详情页
总结页面的交互逻辑
题目列表页
题目详情页
题目描述和代码编辑框
提交代码和测试用例
此过程是整个 OJ系统 最核心的地方,后端不但需要从 HTTP 请求中拿数据,也需要往 HTTP 响应中放数据。而在拿放数据之间,不但需要用到 json 数据与 Java 之间的转换,还需要通过 " 编译 + 运行 " 机制 进行检测代码是否合理。
总结
这是我做的第二个独立项目,刚开始觉得很难,不管是使用 Java 流对象来操作文件,还是通过 " javac " 和 " java " 这两个命令来编译运行,这些都让我有些措手不及。但
实际上,它确实很难,不仅要考虑到前后端交互的约定,还需要考虑到后端对于用户提交过来代码的业务处理,很多细节都需要顾虑到。
例如:后端需要考虑用户不能直接收到 " 500 " 这样状态码,后端应该人性化地考虑到每个用户使用的场景,以及提交之后发生的异常情况。
再例如:测试用例的设计是一个很不好处理的事情,因为每道题的测试用例不一样,而且每道题的测试情况,一般人是很难考虑周全。而当前,我只是用到了代码拼接这一简单的逻辑,但实际上,代码效率并不高。
相比于之前写的博客系统的项目中,我发现自己又进步了一点,实际上准确地说,自己掌握了更多细节的地方,以及更加 Java 面向对象编程的思想。
不管是前端,还是数据库,Java 始终是作为一个很重要的角色存在,很多操作都是需要先有类,再生成 Java 对象,最后才能进行与前端和数据库沟通。此外,HTTP 协议用的越来越熟了,可能是因为实践多了的缘故,这次项目,我抓包更少了,遇到问题,调试的更快了。