【Spring Boot实战】在线学习平台

目录

 一、项目总体介绍

1.1 项目介绍

1.2 项目功能及开发顺序

1.3 模块的开发流程

二、博客模块问题及解决措施总结

2.1 数据库部分

2.1.1 用户头像如何存储?

2.1.2 登录密码如何加密?

2.2 创建实体类

2.2.1 @Data的作用?

2.2.2 设计实体类时不同表之间有很多公共字段,怎么处理?

2.2.3 实体类需要加上@component吗?(Spring Boot和Spring MVC的区别)

2.3 持久层

2.3.1 useGeneratedKeys="true" keyProperty="userId"的意义?

2.3.2 resultType和ResultMap的区别?

2.3.3 ResultMap的使用方式

2.4 如何进行单元测试

2.5 异常处理

2.5.1 为什么会有异常

2.5.2 怎么处理异常

2.5.3 具体思路

2.5.4 统一异常处理

2.6 规范响应格式

2.6.1 分析数据作用,对从数据库读出来的数据进行筛选

2.6.2 统一规范响应格式

2.7 session的使用

2.7.1 为什么要使用session

2.7.2 如何获取session

2.8 拦截器的使用

2.9 if和trim标签的使用,使用同一个sql语句实现多个功能

2.10 前端问题汇总

2.10.1 头像的展示

2.10.1 使用form表单实现上传文件的的注意事项

2.10.2 将页面按照markdown的形式显示

2.10.3 博客的内容太多,超过设置的背景

三、在线OJ模块

3.1 实现思路

3.2 前期准备

3.2.1 为什么创建的是进程不是线程?

3.2.2 如何创建进程?

3.2.3 如何获取标准输出和标准错误?

3.3 实现FileUtil类(封装读写文件的操作)

3.3.1 实现代码

3.3.2 注意事项

①对于文本文件来说,字节流和字符流都可以进行读写,为什么选择字符流?

②返回的类型是一个字节byte,为什么使用int来接收?

③文件读写完需要关闭,否则会导致文件资源泄露

④为什么不使用StringBuffer

3.4 实现CommandUtil类(利用cmd执行命令)

3.5 创建Task类,实现编译运行的全过程

3.5.1 实现思路

3.5.2 检查用户提交代码安全性的补充(使用docker)

3.6 前端问题汇总

3.6.1 向服务器发送当前用户编写的代码,并且获取运行结果

3.6.2 引入代码编辑器组件ace.js

四、总结

六、源码


 一、项目总体介绍

1.1 项目介绍

为了更好的巩固所学的Java相关知识,开发了在线学习平台。在线学习平台分为博客模块和在线OJ模块。

博客模块的功能是:

博客列表页:展示登录用户信息和所有的博客信息,其中,博客的内容只显示前200个词。

博客详情页:展示博客作者的信息和博客的详细信息。在博客详情页,增加了评论功能和提问功能。在阅读博客的时候提一些问题,之后可以通过回答这些问题实现对博客内容的复习和巩固。

博客新增页面:使用markdown编辑器,新增一篇带排版的博客。

博客修改页面:将博客的内容渲染到markdown编辑器后,用户在此基础上进行修改。

头像上传页面:实现用户头像的上传。

问题列表页面:以列表的形式展示在博客详情页提交的问题,每行后面都会有一个回答按钮,按该按钮后,就会跳转到问题回答页面。

问题回答页面:显示问题的内容,有一个输入框,可以输入问题的答案,回答后,点击查看答案按钮,可以查看与该问题相关的博客。

在线OJ模块的核心功能为:

题目列表页:可以以表格的形式展示题目列表

题目详情页:可以查看某个题目的详细信息,并有代码编辑框。

提交并运行题目,展示运行结果:在代码编辑框提交代码后,点击题目详情页的提交按钮。网页就会将代码提交到服务器上,服务器执行代码后,返回用例是否通过的结果。

1.2 项目功能及开发顺序

开发原则:先实现基础、简单的功能,遵循增查删改的原则。

博客模块:注册、登录、上传头像、注销、新增博客、查找博客、删除博客、修改博客、新增评论、查找评论、新增提问、查找提问、删除提问、查找登录用户所有博客的总访问次数、查找某一篇博客的访问次数

在线OJ模块:新增OJ题目、OJ题目列表、某一个OJ题目的在线编译

1.3 模块的开发流程

数据库:根据需要实现的功能,确定数据,并设计数据库。

实体类:基于数据库中的表,设计实体类。

持久层开发:根据业务逻辑,规划相关的SQL语句,并实现接口的抽象方法

业务层开发:实现业务的核心逻辑,规划业务层可能出现的异常

控制层开发:接收前端的请求,规划控制层可能出现的异常,返回响应

前端开发:JQuery、Ajax

二、博客模块问题及解决措施总结

2.1 数据库部分

2.1.1 用户头像如何存储?

  • 错误思路:直接将头像存到数据库中,需要头像时访问数据库,数据库将头像解析为字节流返回,最后写到本地的某一个文件。这种方法太耗费资源和时间。
  • 正确思路:将头像保存在操作系统上,然后把这个文件路径记录下来存储到数据库。记录路径非常便捷和方便。如果要打开头像可以依据这个路径找到这个头像。稍微大一点的公司都会将所有的静态资源(图片,文件,其他资源文件)放到某台电脑上,再把这台电脑作为一台单独的服务器使用。

2.1.2 登录密码如何加密?

密码加密作用:后端不能直接看到用户的密码、忽略用户密码原来的强度,提升数据的安全性

如何对密码进行加密:使用盐值。盐值是使用UUID生成的一个随机字符串。盐值+用户输入的密码+盐值,对这个整体使用md5算法连续加密三次。将加密后的密码和盐值存储到数据库中。

为什么要存储盐值呢?当用户登录时,根据用户输入的密码和数据库存储的盐值,使用与注册时同样的加密算法,如果加密后的结果和数据库存储的密码一致,证明密码输入正确。

        String salt = UUID.randomUUID().toString().toUpperCase();
        String md5Password = getMD5Password(password,salt);
    private String getMD5Password(String password, String salt) {
        for (int i = 0; i < 3; i++) {
            password = DigestUtils.md5DigestAsHex((salt+password+salt).getBytes()).toUpperCase();
        }
        System.out.println(password);
        return password;
    }

2.2 创建实体类

2.2.1 @Data的作用?

提高代码的简洁性,可以省去代码中大量的get()、set()等方法。

2.2.2 设计实体类时不同表之间有很多公共字段,怎么处理?

假设每个表都有createdUser、createdTime、modifiedUser、emodifiedTime这四个属性。在设计实体类时,创建一个BaseEntity实体类基类,将四这个公共属性放入该类中。其他类继承该基类即可。就不用相同的属性在每个实体类中都写一遍,提高了代码的简洁性。

2.2.3 实体类需要加上@component吗?(Spring Boot和Spring MVC的区别)

ssm框架开发项目的时候需要在实体类上面加@Component然后spring才能自动进行对象的创建维护,而springboot不再需要,因为springboot遵循的原则是约定大于配置,如果字段名称相同那就可以自动完成字段的初始化

2.3 持久层

2.3.1 useGeneratedKeys="true" keyProperty="userId"的意义?

useGeneratedKeys="true"表示开启主键自增,keyProperty="userId"指明userId是主键。

2.3.2 resultType和ResultMap的区别?

select语句的查询语句有两种:一个对象或者多个对象

  • resultType:用来指定查询结果对应的实体类的类型,需要包含完整的包结构。这种写法要求表的字段的名字和类的属性名一模一样。
  • resultMap:当表的字段和类的对象属性名不一致时,来自定义查询结果集的映射规则。映射的好处:使SQL语句和java代码分离,解耦了,方便后期代码的维护

2.3.3 ResultMap的使用方式

 数据库中字段:userId(主键)、username、is_delete、create_user

实体类的属性:userId、username、isDelete、createUser

可以看到,数据库中的字段和实体类的属性不一样,怎么使用resultMap表示查询结果呢?

step1:使用resultMap标签定义映射规则

<resultMap id="UserEntityMap" type="com.cy.store.entity.User">
    <id column="userId" property="userId"></id>
    <result column="is_delete" property="isDelete"></result>
    <result column="create_user" property="createUser"></result>
</resultMap>

注意事项:

  • id:给映射规则分配一个唯一的id值
  • type:是一个类,表示数据库的查询结果要映射Java中实体类的名称
  • 将表的字段和类的属性名不一致的进行匹配指定,名称一致的也可以指定,但没必要
  • 在定义映射规则时无论主键名称是否一致都不能省
  • column属性:表示表中的字段名称
  • property属性:表示类中的属性名称

step2:在select语句中应用该映射规则

<select id="getUserByName" resultMap="UserEntityMap">
    select * from user where username=#{username}
</select>

2.4 如何进行单元测试

 方法:Lesson 8: MyBatis_刘减减的博客-CSDN博客2.3.2小结

注意事项:

①加注解@SpringBootTest,表明当前单元测试的框架是SpringBoot,不是其他

②将需要测试的类引入

package com.example.demo.mapper;

import com.example.demo.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class UserMapperTest {

    @Resource
    private UserMapper userMapper;
    @Test
    void register() {
        User user = new User();
        user.setUserName("a");
        user.setPassword("1111");
        userMapper.register(user);
    }
    @Test
    void getUserByName() {
        User user = userMapper.getUserByName("aaa");
        System.out.println(user);
    }
}

2.5 异常处理

2.5.1 为什么会有异常

以注册业务为例,如果用户名已经注册过,此时需要抛一个异常。在往数据库插入数据时,因为数据库宕机等原因,导致插入失败,此处也需要抛一个异常。

2.5.2 怎么处理异常

抛异常时,不能直接抛出RuntimeException。原因是:太笼统了。开发者没办法定位到异常的具体位置。

在开发时,控制层和业务层都可能产生异常,为了更科学准确的定位到异常出现的位置,我们对异常进行分级,创建ServiceException业务层异常基类和ControllerException控制层异常基类,这两个异常继承RuntimeException

2.5.3 具体思路

  • 在ex包下创建ServiceException业务层异常基类,重写父类的所有构造方法,以便后期抛出自己定义的异常
package com.example.demo.service.exuser;

public class ServiceException extends RuntimeException{
    public ServiceException() {
        super();
    }

    public ServiceException(String message) {
        super(message);
    }

    public ServiceException(String message, Throwable cause) {
        super(message, cause);
    }

    public ServiceException(Throwable cause) {
        super(cause);
    }

    protected ServiceException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
  • 根据业务层需要实现的功能,分析可能存在的异常。对这些异常进行定义,并继承ServiceException。以注册功能为例,可能会出现用户名已经存在这种异常。创建DuplicatedUsernameException异常类,继承ServiceException,并重写所有的构造方法。
package com.example.demo.service.exuser;

public class DuplicatedUsernameException extends ServiceException{
    public DuplicatedUsernameException() {
        super();
    }

    public DuplicatedUsernameException(String message) {
        super(message);
    }

    public DuplicatedUsernameException(String message, Throwable cause) {
        super(message, cause);
    }

    public DuplicatedUsernameException(Throwable cause) {
        super(cause);
    }

    protected DuplicatedUsernameException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
  • 正在执行数据插入操作的时候,服务器宕机或数据库宕机.这种情况是处于正在执行插入的过程中所产生的异常,起名InsertFailedException异常
package com.example.demo.service.exuser;

public class InsertFailedException extends ServiceException{
    public InsertFailedException() {
        super();
    }

    public InsertFailedException(String message) {
        super(message);
    }

    public InsertFailedException(String message, Throwable cause) {
        super(message, cause);
    }

    public InsertFailedException(Throwable cause) {
        super(cause);
    }

    protected InsertFailedException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

2.5.4 统一异常处理

step1:在控制层创建一个BaseController父类,在这个父类中统一处理异常。

public class BaseController {
    // 操作成功的状态码
    public static final int OK = 200;

    @ExceptionHandler({ServiceException.class, ControllerException.class})
    public JsonResult<Void> handleException(Throwable e){
        JsonResult<Void> result = new JsonResult<>();
        if(e instanceof DuplicatedUsernameException){
            result.setState(5000);
            result.setMessage(e.getMessage());
        }else if(e instanceof InsertFailedException){
            result.setState(5001);
            result.setMessage(e.getMessage());
        }else if(e instanceof IllegalParaException){
            result.setState(5002);
            result.setMessage(e.getMessage());
        }
        return result;
    }

}

Question1:@ExceptionHandler的作用?

该注解表示被修饰的方法用于处理捕获的异常。怎样的异常会被这个方法处理呢?ControllerException.class说明了异常的类型。

如果异常发生后,异常对象就会自动被传递到此方法的参数列表上,所以形参写的是Throwable。

step2:让UserController继承BaseController。此时,不需要关注异常的处理,只需要关注请求的处理过程。

2.6 规范响应格式

2.6.1 分析数据作用,对从数据库读出来的数据进行筛选

以登录为例,login返回的用户数据是为了辅助其他页面做数据展示使用(只会用到uid,username,avatar),所以可以new一个新的user只赋这三个变量的值,这样使层与层之间传输时数据体量变小,后台层与层之间传输时数据量越小性能越高,前端也是的,数据量小了前端响应速度就变快了。

2.6.2 统一规范响应格式

 当后端收到前端发来的请求时,需要返回响应。为了规范响应的格式。约定:响应中包含状态码、状态描述信息和数据三部分。将这三个参数封装到JsonResult类中,将这个类作为方法的返回值返回给前端。

package com.example.demo.controller;

import lombok.Data;

import java.io.Serializable;

@Data
public class JsonResult<E> implements Serializable {
    // 状态码
    private int state;
    // 描述消息
    private String message;
    // 数据类型不确定。用E表示任何的数据类型
    // 如果类里有声明的泛型,数据类型,类也要声明为泛型类
    private E data;
    public JsonResult(){
    }
    public JsonResult(int state){
        this.state = state;
    }
    public JsonResult(int state, E data){
        this.state = state;
        this.data = data;
    }
    public JsonResult(int state, String message){
        this.state = state;
        this.message = message;
    }
}

2.7 session的使用

2.7.1 为什么要使用session

首次登录后,需要将用户的userId和userName信息保存在session中,也要保证其他类和方法要能访问到session中存储的对象。获取session中对象的属性值使用session.getAttribute(“key”)。因为很多功能都会访问session中存储的对象,因此,可以将从session中获取数据的方法进行封装。

可以封装在一个工具类中,在当前的项目中,只可能会在控制层使用session。controller包里的类都继承BaseController。所以,将从session获取uid和username的方法封装到BaseController这个类下。

    public final int getUserIDFromSession(HttpSession session){
        // getAttribute返回的是Object对象,需要先转为String,再转为int
        return Integer.valueOf(session.getAttribute("userId").toString());
    }

    public final String getUsernameBySession(HttpSession session){
        return session.getAttribute("userName").toString();
    }

注意事项:getAttribute返回的是Object对象,如果向获取userId,需要先转为String,再转为int

2.7.2 如何获取session

  •  自己创建:HttpSession session = req.getSession(true);
  • 使用服务器创建的session:服务器本身会自动创建session,并且该session是全局的。如何获取这个session呢?将HttpSession作为处理请求方法的参数,SpringBoot会自动将全局的session对象注入到请求方法的形参上。
    @RequestMapping("/login")
    public JsonResult<Void> login(String username, String password, HttpSession session){
        if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)){
            throw new IllegalParaException("输入的用户名或者密码不能为空");
        }
        User user = userService.login(username,password);
        session.setAttribute("userId",user.getUserId());
        session.setAttribute("userName",user.getUserName());
        session.setAttribute("avatar",user.getAvatar());
        return new JsonResult<>(OK);
    }

2.8 拦截器的使用

博客系统中每个功能的实现都需要先判断用户是否登录同样功能的代码写了多次,增加了程序的冗余度,同时增加了后期的修改成本和维修成本。

在用户登录之后,将用户的登录信息保存在session中,在实现其他的功能时,需要先读取session中的userId信息,如果可以无法读到登录的用户信息,则跳转到登录页面。

同时,并不是所有得方法都需要验证用户的登录信息,如登录和注册。将问题总结为以下两点:

我们可以使用Spring拦截器HandlerInterceptor,拦截器的实现分两个步骤:

setp1:创建自定义拦截器,实现HandlerInterceptor接口的preHandle方法。

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if(session == null || session.getAttribute("userId") == null){
            response.sendRedirect("login.html");
            // 如果是false,,访问到此结束
            return false;
        }
        // 如果时true,可以接着访问你想要访问的目标方法
        return true;
    }
}

step2:将自定义的拦截器加入WebMvcConfigure的addInterceptors方法中。

// 将拦截器添加到全局的配置的文件中
@Configuration
public class LoginInterceptorConfigue implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 自定义的拦截器
        HandlerInterceptor interceptor = new LoginInterceptor();
        // 将白名单放在一个List集合里
        List<String> patterns = new ArrayList<>();
        patterns.add("/css/**");
        patterns.add("/editor.md/**");
        patterns.add("/images/**");
        patterns.add("/pictures/**");
        patterns.add("/regist.html");
        patterns.add("/login.html");
        patterns.add("/user/reg");
        patterns.add("/user/login");
        registry.addInterceptor(interceptor) // 指明拦截器
                .addPathPatterns("/**")  // 需要拦截的URL,**表示拦截任意方法
                .excludePathPatterns(patterns);  // 需要排除的URL和静态文件,如图片、css、js、登录、注册
    }
}

注意事项:

@Configuration不能少。如果没有这个注解,这个拦截器将不会被加到全局配置文件中,拦截器就会不起作用。

2.9 if和trim标签的使用,使用同一个sql语句实现多个功能

<update id="editBlog">
        update blog set
            <trim prefixOverrides="," suffixOverrides=",">
                <if test="blogTitle != null and blogTitle !=''">
                    blogTitle = #{blogTitle},
                </if>
                <if test="blogContent != null and blogContent !=''">
                    blogContent = #{blogContent}
                </if>
            </trim>
        where
            blogId = #{blogId}
    </update>

2.10 前端问题汇总

2.10.1 头像的展示

因为SpringBoot有内置的Tomcat服务器,上传的文件是存放在一个临时目录里。重启后会生成新的目录,这就会导致原来上传的图片重启后访问不到。

为了解决这个问题,使用配置本地资源映射路径的方案。可以将图片保存在本地

第一步:

静态资源路径是指系统可以直接访问的路径,且路径下的所有文件均可被用户直接读取。

Springboot中默认的静态资源路径有:classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,从这里可以看出这里的静态资源路径都是在classpath中(也就是在项目路径下指定的这几个文件夹)

如果将上传的文件存放在静态资源路径下,会导致以下问题:

网站数据和程序代码不能有效分离、网站数据会很难备份这些问题。

为了解决这个问题,可以将静态资源设置到本地磁盘的某个目录。

创建WebAppConfig类,实现WebMvcConfigurer接口,重写addResourceHandlers方法。

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebAppConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/image/**").addResourceLocations("file:C:\\Users\\Desktop\\Java\\images\\");
    }
}

addResourceHandler:访问的映射路径

addResourceLocations:资源的绝对路径(打算在本地磁盘上存储的位置)

将图片存储在"C:\\Users\\Desktop\\Java\\images\\"这个目录下,使用"/image/"+fileName来访问这个图片。

    @RequestMapping("/updateAvatar")
    public JsonResult<String> updateAvatar(HttpServletRequest request, MultipartFile file){
        if(file.isEmpty()){
            throw new FileEmptyException("文件为空");
        }
        if(file.getSize() > AVATAR_MAX_SIZE){
            throw new FileSizeException("文件大小超出了限制");
        }
        if(!AVATAR_TYPE.contains(file.getContentType())){
            throw new FileTypeException("文件类型不支持");
        }
        HttpSession session = request.getSession(false);
//        // 创建存储头像的目录
        String rootPath = "C:\\Users\\LIUJIA\\Desktop\\Java\\images\\";  //上传后的路径

        File dir = new File(rootPath);
        if(!dir.exists()){
            dir.mkdirs();
        }
        // 确定保存的文件名,用uuid生成一个新的文件名
        String oriFileName = file.getOriginalFilename();
        int lastIndex = oriFileName.lastIndexOf(".");
        String suffix = oriFileName.substring(lastIndex);
        System.out.println(suffix);
        String fileName = UUID.randomUUID().toString().toUpperCase(Locale.ROOT)+suffix;
        System.out.println(fileName);
        // 在dir目录下创建文件,第一个参数表示存储的文件夹,第二个表示存储的文件
        File destFile = new File(dir,fileName);
        try {
            // 保存上传文件
            file.transferTo(destFile);  // 将file文件中的数据写入到dest文件中
        } catch (FileStateException e){
            throw new FileStateException("文件状态异常");
        } catch (IOException e) {
            throw new FileLoadException("文件读写异常");
        }
        Integer userId = getUserIDFromSession(session);
        String avator = "/image/"+fileName;
        userService.uploadAvatar(userId,avator);
        return new JsonResult<>(OK,avator);
    }

2.10.1 使用form表单实现上传文件的的注意事项

form表单的请求方式必须设置为POST,并配置属性enctype="multipart/form-data",文件上传input控件的name属性值需设置为file值。

2.10.2 将页面按照markdown的形式显示

先在博客编辑区的div中放一个隐藏的text area。再在初始化editor对象的时候,指定一个特殊的选项saveHTMLToTextArea:true,这个选项的功能就是editor.md把用户编辑的正文放到这个text area里面。

<div class="container">
        <div class="container-edit">
            <!-- 包含两个部分,标题编辑区和内容编辑区 -->
            <form action="edit" method="post" style="height: 100%">
                <div class="container-edit-title">
                    <input type="text"  id="title" autocomplete="off" placeholder="在这里输入标题" name="title">
                    <!-- <input type="button" value="发布文章" > -->
                    <input type="submit" value="发布文章" class="submit-button">
                </div>
                <div id="editor" >
                    <textarea name="content" style="display: none"></textarea>
                </div>
            </form>
            
        </div>
    </div>
    <script>
            // 初始化 editor.md
            var editor = editormd("editor", {
            // 这里的尺寸必须在这里设置. 设置样式会被 editormd 自动覆盖掉. 
            width: "1000px",
            // 设定编辑器高度
            height: "calc(100% - 60px)",
            // 编辑器中的初始内容
            markdown: "# 在这里写下一篇博客",
            // 指定 editor.md 依赖的插件路径
            path: "./editor.md/lib/",
            saveHTMLToTextArea:true
        });
    </script>

借助editor.md的内置功能,将博客显示出来。

    <script>
        // 先从html中拿到要渲染的字符串
        var markdown = document.querySelector("#content").innerHTML;
        // 清空原来的div
        document.querySelector("#content").innerHTML = "";
        // 通过内置的方式完成渲染,要想能够调用, 也需要先引入 editor.md 的 js 
        editormd.markdownToHTML('content',{markdown:markdown})
    </script>

2.10.3 博客的内容太多,超过设置的背景

给背景加上个css属性:overflow:auto 如果当前元素的内容溢出,就会自动加个滚动条。此时指的是给标签加上滚动条,而不是给页面加上滚动条。

三、在线OJ模块

3.1 实现思路

有一个服务器的进程一直运行着,用户的代码从前端传来。服务器的进程先创建一个子进程对该代码进行编译。随后,再创建一个子进程对编译好的代码进行运行。随后,服务器返回运行结果。

3.2 前期准备

3.2.1 为什么创建的是进程不是线程?

因为每个用户提供的代码是一个独立的逻辑,同时,我们无法控制用户输入的代码,很可能代码运行会导致系统崩溃。考虑到进程的稳定性,所以采用多进程编程的思路。

3.2.2 如何创建进程?

首先,被创建的进程称为子进程,创建子进程的进程称为父进程。在该项目中,服务器进程相当于父进程,根据用户的代码创建的进程称为子进程。一个父进程可以有多个子进程。(谈到多进程,会经常涉及到父进程和子进程,但是线程没有父线程和子线程的说法)。

其次,关于多进程编程,JAVA提供了两个操作。第一个是进程创建,第二个是进程等待。

Process process = Runtime.getRuntime().exec(cmd);

通过Runtime获取到Runtime对象,此对象是一个单例对象。然后exec得到Process对象。通过这个代码,可以创建出子进程。此时父子进程之间,是并发执行的关系。但是,在该项目中,当用户提交的代码编译运行完毕了后,需要将结果返回给用户。因此,我们需要让父进程知道子进程的执行状态。因此,需要加上进程等待的语句

int exitCode = process.waitFor();

执行Runtime.getRuntime().exec(cmd)这行代码,就相当于在cmd输入一条命令。当在cmd输入一条命令。操作系统会去一些特定的目录中查找这个可执行文件,找到才能执行。怎么确保能够找到?将javac所在的目录加到PATH环境变量中。

Javac是一个控制台程序,他的输出是存放在“标准输出”和“标准错误”这两个特殊的文件中。要想看到这个程序的运行效果,就需要获得标准输出和标准错误的内容。

3.2.3 如何获取标准输出和标准错误?

当一个进程在启动时,会自动打开三个文件。标准输入(对应到键盘)、标准输出(对应到显示器)、标准错误(对应到显示器)。虽然子进程启动后同样也打开这三个文件,但是由于子进程没有和IDEA的终端关联,因此在IDEA中看不到子进程的输出的。要想获得输出,就需要在代码中手动获取到。

获取标准输出:

InputStream stdoutFrom = process.getInputStream();

获取标准错误:

InputStream stderrFrom = process.getErrorStream();

获取到标准输出和标准错误后,我们需要将其写入到文件中。随后通过读文件,就可以看到程序的执行效果。

3.3 实现FileUtil类(封装读写文件的操作)

3.3.1 实现代码

 在第四小结,将进程的标准输出和标准错误写到文件中,在QuestionController模块,我们需要将 编译子进程、运行子进程的标准输入和标准输入从文件中读取出来。虽然Java本身已经提供了很多关于文件读写操作的方法,但是使用稍微麻烦一点。为了便于使用,对这些操作进一步封装为一个类FileUtil,创建两个方法,第一个方法是将文件的内容读出来,第二个方法是将String写入文件中。

package com.example.demo.compile;

import org.springframework.stereotype.Component;

import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

@Component
public class FileUtil {
    // 将文件的值读到一个String里面
    public static String readFile(String path){
        StringBuilder content = new StringBuilder();
        try(FileReader fileReader = new FileReader(path)){
            while (true){
                int ch = fileReader.read();
                if(ch == -1){
                    break;
                }
                content.append((char)ch);
            }

        }catch (IOException e){
            e.printStackTrace();
        }
        return content.toString();
    }
    // 将String写入文件中
    public static void writeFile(String content,String path){
        try(FileWriter fileWriter = new FileWriter(path)){
            fileWriter.write(content);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        String path = "E:\\hello.txt";
        String content = readFile(path);
        System.out.println(content);
        writeFile("mybatis","E:\\hello.txt");
    }
}

3.3.2 注意事项

①对于文本文件来说,字节流和字符流都可以进行读写,为什么选择字符流?

与字符流相比,字节流会更麻烦一点,体现为:当文件中包含中文的时候,需要手动的处理编码格式。

②返回的类型是一个字节byte,为什么使用int来接收?

Java中不存在无符号数,byte也是有符号的,范围是[-128,127]。byte占一个字节空间,最高位是符号位,剩余7位能表示0-127,加上符号位的正负,就是-127至+127,但负0没必要,为充分利用,就用负零表示-128(即原码1000,0000)。(计算机转补码后存储)

但是在实际中,因为读出来的只是表示一个单纯的字符,并不是要进行加减运算,因此期望读到的是一个无符号的数字,将范围改为(0,255)使用int表示,文件还没读完时,返回一个int。如果读到末尾返回EOF,end of file,用-1表示,证明文件已经读完。

③文件读写完需要关闭,否则会导致文件资源泄露

受限于操作系统内核里面的实现,一个进程能够同时打开的文件个数是存在上限的。对于Linux来说,进程PCB中的文件操作符表属性,大小是存在上限的。可以通过ulimit命令来查看和修改进程能够支持的最大文件个数。

④为什么不使用StringBuffer

因为String是不可变对象,为了方便对String的修改。可以使用StringBuilder(线程不安全)和StringBuffer(线程安全)。当多个线程同时修改同一个变量,会触发线程安全问题。刚刚创建的StringBuilder是函数里面的局部变量在栈上,由于每个线程都有自己的栈。所以不会引起线程安全问题。没必要使用StringBuffer。

3.4 实现CommandUtil类(利用cmd执行命令)

根据传入的语句创建进程——》获取进程的标准输出,并将其写入到特定文件中——》获取进程的标准错误,并将其写入到特定文件中——》返回进程的状态码。

package com.example.demo.compile;

import org.springframework.stereotype.Component;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@Component
public class CommandUtil {
    public static int run(String cmd,String stdoutFilePath,String stderrFilePath){
        try {
            Process process = Runtime.getRuntime().exec(cmd);
            if(stdoutFilePath != null){
                // 获取运行的标准输出
                InputStream stdoutFrom = process.getInputStream();
                // 将标准输出写入到文件中
                OutputStream stdoutTo = new FileOutputStream(stdoutFilePath);
                while (true){
                    int ch = stdoutFrom.read();
                    if(ch == -1){
                        break;
                    }
                    stdoutTo.write(ch);
                }
                stdoutFrom.close();
                stdoutTo.close();
            }
            if(stderrFilePath != null){
                // 获取运行的标准错误
                InputStream stderrFrom = process.getErrorStream();
                // 将标准错误写入到文件中
                OutputStream stderrTo = new FileOutputStream(stderrFilePath);
                while (true){
                    int ch = stderrFrom.read();
                    if(ch == -1){
                        break;
                    }
                    stderrTo.write(ch);
                }
                stderrFrom.close();
                stderrTo.close();
            }
            // 等到子进程结束,拿到子进程的状态码,并返回
            int exitCode = process.exitValue();
            return exitCode;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return 1;
    }

    public static void main(String[] args) {
        run("javac","E://stdout.txt","E://stderr.txt");
    }
}

3.5 创建Task类,实现编译运行的全过程

3.5.1 实现思路

 实现一个Task类。基于CommandUtil,Task类主要是实现一个完整的编译和运行这样的模块。该模块的输入是用户提交的代码,输出是代码的编译结果和运行结果。该模块的执行逻为:

前置条件:服务器进程一直在运行

  • 第一步:服务器收到用户发送的代码,判断其安全性。主要检查代码中是否包含Runtime、exec(这两个防止提交的代码运行恶意程序)、java.io(禁止提交的代码读写文件)、java.net(禁止提交的代码访问网络)这三个关键字。
  • 第二步:服务器进程创建子进程,执行javac命令进行编译。需要指定.class文件的位置,免得运行时找不到。
  • 第三步:服务器进程读取编译生成的标准错误的内容,判断是否为空。不为空则证明编译不通过,返回编译出错结果。如果为空,证明编译成功,进入下一步运行。
  • 第四步:服务器进程创建子进程,执行java命令进行运行。.class文件的位置与第二步保存的位置一致。
  • 第五步:服务器进程读取运行的标准错误的内容,如果为空证明运行错误,返回运行出错信息。
  • 第六步:服务器进程读取运行的标准输出的内容,返回运行结果。

注意:

javac进程和Java这两个进程需要通信。Linux系统提供管道、消息队列、信号量、信号、socket、文件等通信方式,在该项目中,使用文件来进行通信。因此,我们需要约定一个临时文件。

②因为在JAVA中,类名和文件名需要一致,我们约定好类名和文件名都是Solution。

③约定

error = 0,编译运行都通过

error = 1,编译错误

error = 2,运行错误

reason:存放错误信息

stdout:存放标准输出

stderr:存放标准错误

3.5.2 检查用户提交代码安全性的补充(使用docker)

目前使用的这个黑名单的方式,只能简单粗暴的处理掉一批明显的安全漏洞,但是还是存在安全隐患。有一种更彻底的解决这个问题的方式:

docker相当于一个轻量级虚拟机,每次用户提交的代码,都会给这个代码分配一个docker容器,让用户提交的代码在容器中执行,如果这个容器中有恶意代码,最多也就是将docker容器搞坏了,对物理机没有任何影响。同时docker的设计天然就是很轻量的,一个容器可以随时创建随时删除。

该容器和tomcat、servlet容器没有关系,和Spring中的bean容器也没有关系,也和c++STL里的容器没有关系。

package com.example.demo.compile;

import com.example.demo.model.Answer;
import com.example.demo.model.UserCode;
import org.springframework.stereotype.Component;

import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Component
public class Task {
    // 用一组常量来约定临时变量的名字
    private String WORK_DIR = null;  // 目录
    private String CLASSNAME = null; // 类名
    private String CODE = null; //代码的文件名
    private String COMPILE_ERROR = null; // 编译错误
    private String STDOUT = null; //  标准输出
    private String STDERR = null;  // 标准输入

    public Task(){
        WORK_DIR = "E://OJ//"+ UUID.randomUUID()+"//";
        CLASSNAME = "Solution";
        CODE = WORK_DIR+"Solution.java";
        COMPILE_ERROR = WORK_DIR + "compileError.txt";
        STDOUT = WORK_DIR + "stdout.txt";
        STDERR = WORK_DIR + "stderr.txt";
    }

    public Answer compileAndRun(UserCode userCode){
        Answer answer = new Answer();
        // 准备用来存放临时文件的目录
        File workFile = new File(WORK_DIR);
        if(!workFile.exists()){
            workFile.mkdirs();
        }

        // 对代码的安全性进行判定
        if(!checkCodeSafe(userCode.getUserCode())){
            System.out.println("用户提交了不安全的代码");
            answer.setError(3);
            answer.setReason("\"您提交的代码可能会危害到服务器, 禁止运行!\"");
            return answer;
        }
        // 将question中的code写入到CODE中
        FileUtil.writeFile(userCode.getUserCode(),CODE);

        // 进行编译,javac -d 用来指定生成的 .class 文件的位置
        String compileCmd = String.format("javac -encoding utf8 %s -d %s",CODE,WORK_DIR);
        System.out.println("编译的命令"+compileCmd);
        CommandUtil.run(compileCmd,null,COMPILE_ERROR);
        String compileError = FileUtil.readFile(COMPILE_ERROR);
        if(!compileError.equals("")){
            System.out.println("编译出错");
            answer.setError(1);
            answer.setReason(compileError);
            return answer;
        }
        //进行运行
        String runCmd = String.format("java -classpath %s %s",WORK_DIR,CLASSNAME);
        System.out.println("编译命令"+runCmd);
        CommandUtil.run(runCmd,STDOUT,STDERR);
        String stdErr = FileUtil.readFile(STDERR);
        if(!stdErr.equals("")){
            System.out.println("运行出错");
            answer.setError(2);
            answer.setReason(stdErr);
            return answer;
        }
        answer.setError(0);
        answer.setStdout(FileUtil.readFile(STDOUT));
        return answer;
    }

    private boolean checkCodeSafe(String code) {
        List<String> blackList = new ArrayList<>();
        // 防止提交的代码运行恶意程序
        blackList.add("Runtime");
        blackList.add("exec");
        // 禁止提交的代码读写文件
        blackList.add("java.io");
        // 禁止提交的代码访问网络
        blackList.add("java.net");
        for(String str:blackList){
            int pos = code.indexOf(str);
            if(pos >= 0){
                return false; // 找到任意的恶意代码特征, 返回 false 表示不安全
            }
        }
        return true;

    }

    public static void main(String[] args) {
        Task task = new Task();
        UserCode userCode = new UserCode();
        userCode.setUserCode("public class Solution {\n" +
                "    public static void main(String[] args) {\n" +
                "        System.out.println(\"hello\");\n" +
                "    }\n" +
                "}");
        Answer answer = task.compileAndRun(userCode);
        System.out.println(answer.toString());
    }
}

3.6 前端问题汇总

3.6.1 向服务器发送当前用户编写的代码,并且获取运行结果

 思路为:从请求中获取题目id—》根据id去数据库查题目——》获取该题目的测试代码——》从请求中获取用户提交的代码——》将两个代码组合,并判断是否为空——》实例化一个Task,进行编译运行该代码——》构造响应,并返回。

注意:@RequestBody注解指明返回的数据类型是json。

    @RequestMapping("/compile")
    public CompileResponse solve(@RequestBody RequestCode requestCode){
        try{
            // 获取题目id
            int questionId = requestCode.questionId;
            // 去数据库查询题目
            QuestionInfo questionInfo = questionMapper.selectQuestionById(questionId);
            // 没找到,报异常
            if(questionInfo == null){
                throw new QuestionNotFountException();
            }
            // 获取用户提交的代码
            String userCode = requestCode.code;
//            String userCode = "public class Solution {\n" +
//                    "    public static int sum(int a,int b){\n" +
//                    "        return a+b;\n" +
//                    "    }\n" +
//                    "}";
            // 获取题目的测试代码
            String testCode = questionInfo.getTestCode();
//            String testCode = "    public static void main(String[] args) {\n" +
//                    "        int a = 1;\n" +
//                    "        int b = 2;\n" +
//                    "        int c = sum(a,b);\n" +
//                    "        System.out.println(c);\n" +
//                    "    }";
            // 将两个代码组合
            String finalCode = merge(userCode,testCode);
            System.out.println(finalCode);
            // 判断代码是否合法
            if(finalCode == null){
                throw new CodeInvalidException();
            }
            // 实例化一个Task,编译运行代码
            Task task = new Task();
            UserCode userCode1 = new UserCode();
            userCode1.setUserCode(finalCode);
            Answer answer = task.compileAndRun(userCode1);
            System.out.println(answer);

            // 构造响应
            compileResponse.error = answer.getError();
            compileResponse.reason = answer.getReason();
            compileResponse.stdout = answer.getStdout();

        }catch (QuestionNotFountException e){
            compileResponse.error = 3;
            compileResponse.reason = "没有找到指定的题目! id="+requestCode.questionId;
            e.printStackTrace();
        }catch (CodeInvalidException e){
            compileResponse.error = 3;
            compileResponse.reason = "提交的代码不符合要求!"+requestCode.questionId;
            e.printStackTrace();
        }finally {
            return compileResponse;
        }
    }

3.6.2 引入代码编辑器组件ace.js

// 引入ace.js
<script src="https://cdn.bootcss.com/ace/1.2.9/ace.js"></script>
<script src="https://cdn.bootcss.com/ace/1.2.9/ext-language_tools.js"></script>

// 将页面编辑框外面套一层 div, id 设为 editor, 并且一定要设置 min-height 属性.
<div id="editor" style="min-height:400px">
    <textarea style="width: 100%; height: 200px"></textarea>
</div>

// 对编译器进行初始化
function initAce() {
    // 参数 editor 就对应到刚才在 html 里加的那个 div 的 id
    let editor = ace.edit("editor");
    editor.setOptions({
        enableBasicAutocompletion: true,
        enableSnippets: true,
        enableLiveAutocompletion: true
    });
    editor.setTheme("ace/theme/twilight");
    editor.session.setMode("ace/mode/java");
    editor.resize();
    document.getElementById('editor').style.fontSize = '20px';

    return editor;
}

//获取刚刚初始化好的编译器
let editor = initAce();

// 由于ace.js会重新绘制页面,导致之前弄得textarea没了。因此需要换种方式
// 将代码设置到编译器中
editor.setValue(question.templateCode);

// 获取编译器中得代码
editor.getValue()

四、总结

本项目基于SpringMVC和MyBatis框架,实现了一个在线学习平台。该平台由博客模块和在线OJ模块组成。其中,在线OJ模块:

编译运行子模块:创建了CommandUtil类、FileUtil类和Task类。在CommandUtil类中实现了创建进程并将进程的标准输出和标准错误写入到文件中的方法。在FileUtil类中封装了读写文件的操作。在Task类,基于多进程编程,实现编译运行的全过程。

题目管理子模块:基于MyBatis,创建了QuestionMapper接口,在该接口中实现了增删查操作。

API子模块:首先设计了前后端交互的接口,创建QuestionController类,实现了获取题目列表、获取题目详情、编译运行、增加题目信息、删除题目这5个api接口。

前端子模块:创建了题目列表页、题目详情页、题目信息在线录入页面。引入代码编辑器组件ace.js使得编辑框变得更加好用。通过js代码,实现了调用后端HTTP API的过程。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

刘减减

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值