“ 作业帮 “ (Servlet)
在线 OJ 系统 [Servlet]
- 引言
- 本次项目用到的技术
- 本次项目的业务流程
- 一、理解 OJ 系统的核心思想并设计编译运行方案
- 1. 在线提交代码的后端处理方案
- 回顾多线程与多进程
- Java 多进程编程
- 进程与文件
- 2. 设计 " 编译 + 运行 " 方案
- 封装 CommandUtil 类
- 创建一个 Task 类
- 二、设计数据库
- 三、根据数据库设计实体类
- Subject 实体类
- 四、封装数据库
- JDBC 编程步骤
- 1. 创建一个 DBUtil 类 ( Database Utility )
- 2. 封装 SubjectDB ( SubjectDatabase )
- 3. 设计测试用例代码的思路
- 五、准备好纯前端页面
- OJ 列表页
- OJ 详情页
- 部署到项目的 webapp 目录下
- 六、实现题目列表页
- 前端
- 后端
- 七、实现题目详情页
- 1. 题目描述和代码编辑框
- 前端
- 后端
- 2. 提交代码和测试用例
- 前端
- 后端
- 八、优化项目
- 1. 处理异常
- 2. 校验代码安全性
- 3. 利用 UUID 生成不同目录
- 4. 引入合理的代码编辑框
- 页面展示结果
- 题目列表页
- 题目详情页
- 总结页面的交互逻辑
- 题目列表页
- 题目详情页
- 题目描述和代码编辑框
- 提交代码和测试用例
- 总结
引言
本次项目用到的技术
协议: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();// 测试用例 1int[] 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. > ");}// 测试用例 2int[] 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. > ");}// 测试用例 3int[] 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();@Overrideprotected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {// 设置 HTTP 响应的正文格式为 jsonresp.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();@Overrideprotected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {// 以 utf8 编码的方式从 HTTP 请求的正文中读数据req.setCharacterEncoding("utf8");resp.setContentType("application/json; charset=UTF-8");// 获取 参数 idString 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{// 题目 idpublic int id;// 用户提交的代码public String code;@Overridepublic 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;}@Overrideprotected 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 协议用的越来越熟了,可能是因为实践多了的缘故,这次项目,我抓包更少了,遇到问题,调试的更快了。
“ 作业帮 “ (Servlet)相关推荐
- 练习2:课工场响应式导航条_作业帮直播课APP下载最新版入口
作业帮直播课app由作业帮一课升级更名而来,作业帮直播课app课程设置科学合理,课程紧贴校内教学进度,老师根据学习要求进行同步教学.作业帮直播课app更有长期班课.专题课.小学兴趣班等,欢迎加入. 软 ...
- 让线上学习不打折扣,作业帮如何用技术促进课堂高频互动场?
"在大班直播课上,可能有数千甚至上万学员同时上课,但是他们彼此看不见也听不见,是千千万万个'孤独的个体',而'小组直播间'却可以让他们随时随刻感觉到自己置身于一个温暖的集体之中." ...
- 算法岗面试复盘:阿里,百度,作业帮,华为
↑↑↑关注后"星标"Datawhale 每日干货 & 每月组队学习,不错过 Datawhale干货 作者:苏young,整理:NewBeeNLP 写在前面 先介绍下个人情况 ...
- 十分钟了解算法面经:百度,寒武纪,作业帮,科大讯飞等常面问题
作者 | Miss 整理 | NewBeeNLP 面试锦囊之面经分享系列. PS.这篇文章中的公司我也都有面过,问题真的差不多甚至一模一样,所以面经分享还是很有帮助的! 个人情况 双非本科,985研究 ...
- 跟老齐学python轻松入门_【英语动词后面跟什么词?】作业帮
一般情况下跟名词作宾语,但是有的动词,跟ving,有的跟 to +v,有的既可以跟ving, 也可以跟to+v.,那得看情况而定.下面是我给我的学生复习时的资料,希望对你有用.望及时采纳. 非谓语动词 ...
- 算法岗面试复盘 | 阿里、百度、作业帮、华为
来源:NewBeeNLP.小小挖掘机 本文约1500字,建议阅读5分钟. 本文作者为你总结7月至今的各种面试. 写在前面 先介绍下个人情况,本科金融,辅修数学,研究生转应用统计,主要申算法去岗,从今年 ...
- 作业帮冯雪胡不归问题_作业帮推出辅导老师“家庭陪伴计划” 让教育更有温度|讲题...
近日,作业帮郑州分校短训班的辅导老师陈威振在公司楼下面馆给老板的儿子"义务"讲题被同事拍下.不到10分钟,陈威振就把一道复杂的初一数学题讲得明明白白,甚至还在考虑能否运用作业帮的& ...
- 作业帮、猿题库们烧了千亿争市场,家长陷入选择焦虑
在线教育的广告已经无孔不入. 2020年,几乎所有的热门综艺,都有在线教育公司广告.<幸福三重奏>能看到斑马AI的广告,看<向往的生活>能看到作业帮的冠名,看<极限挑战& ...
- 作业帮电脑版在线使用_在线K12赛道六虎争霸:猿辅导、作业帮又宣布新一轮融资...
在线教育依然处于高速发展期,艾媒咨询数据显示,预计2020年,中国在线教育市场规模将达到4858亿元.庞大的体量之下,诞生数个百亿美金的公司机会依然存在. 作者 | 李子璇 编辑 | 大橙子 根据晚点 ...
最新文章
- php里面的log是什么文件夹,用PHP生成自己的LOG文件
- C++运算符重载-mfc演示
- 互联网算法面试高频题目
- 【传智播客】Javaweb程序设计任务教程 黑马程序员 第五章 课后答案
- java file rename 失败_java重命名文件造成文件不可读写
- 手把手教你写一个java的orm(二)
- ghost系统之优劣?
- 各国在计算机视觉领域论文数,计算机视觉论文
- 何香伊的脸儿,战痘经历
- 4.7 Case Study on Sandy Bridge C…
- 神经网络预测参数有哪些,神经网络预测参数包括
- Unity 检测手机性能,区分高中低端机型(URP)
- Flex布局搭建网页布局更方便
- Windows系统下载
- 魔兽世界模拟器注册器
- 微信公众号服务器配置详解一览
- 如何进行MQ技术选型
- 宝宝看的启蒙动画片哪里找?三款电视软件推荐,孩子启蒙不怕难
- 结构阻尼比的4种常用测量方法概述
- 六轴机械臂算法正解(FK)和逆解(IK)
热门文章
- sqlserver分组统计最新一条数据
- 2022年全球及中国工程机械租赁行业头部企业市场占有率及排名调研报告
- 计算机作业我家乡的变化英语作文,家乡的变化 Changes in My Hometown
- 科技新品 | 索尼最新高级条形音箱;Bose消噪耳塞全新配色;新一代人工智能社交机器人Musio S...
- 从融360到理财魔方、再到韭菜财经,新金融正确姿势为哪般?
- urdf转sdf制作模型包
- Deepin 20.5 安装nvidia驱动
- VB编程:UBound获取数组上限;LBound获取数组下限-25_彭世瑜_新浪博客
- iOS转场动画之微信朋友圈图片查看器
- 微信分享给朋友 图片显示正常,但是分享到朋友圈图片黑色【显示不出来】