五、准备好纯前端页面
我认为,在约定前后端的访问路径、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 协议用的越来越熟了,可能是因为实践多了的缘故,这次项目,我抓包更少了,遇到问题,调试的更快了。