单体OJ项目

news2025/4/25 10:15:38

单体项目版本、微服务版还需我再钻研钻研。

项目介绍

在系统前台,管理员可以创建、管理题目;用户可以自由搜索题目、阅读题目、编写并提交代码。
在系统后端,能够根据管理员设定的题目测试用例在代码沙箱 中对代码进行编译、运行、判断输出是否正确。
其中,代码沙箱可以作为独立服务,提供给其他开发者使用。

核心业务流程

在这里插入图片描述
判题服务:获取题目信息、预计的输入输出结果,返回给主业务后端:用户的答案是否正确

代码沙箱:只负责运行代码,给出结果,不管什么结果是正确的。

功能

1. 题目模块

管理员:
创建题目
删除题目
修改题目
用户:
搜索题目
在线做题
提交题目代码

2. 用户模块

登录
注册

判题模块

提交判题(判断结果是否正确)
错误处理(内存溢出、安全性、超时)
自主实现代码沙箱(安全沙箱)
开放接口(提供一个独立新服务)

库表设计

用户表

-- 用户表
create table if not exists user
(
    id           bigint auto_increment comment 'id' primary key,
    userAccount  varchar(256)                           not null comment '账号',
    userPassword varchar(512)                           not null comment '密码',
    unionId      varchar(256)                           null comment '微信开放平台id',
    mpOpenId     varchar(256)                           null comment '公众号openId',
    userName     varchar(256)                           null comment '用户昵称',
    userAvatar   varchar(1024)                          null comment '用户头像',
    userProfile  varchar(512)                           null comment '用户简介',
    userRole     varchar(256) default 'user'            not null comment '用户角色:user/admin/ban',
    createTime   datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime   datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete     tinyint      default 0                 not null comment '是否删除',
    index idx_unionId (unionId)
) comment '用户' collate = utf8mb4_unicode_ci;

题目表

-- 题目表
create table if not exists question
(
    id         bigint auto_increment comment 'id' primary key,
    title      varchar(512)                       null comment '标题',
    content    text                               null comment '内容',
    tags       varchar(1024)                      null comment '标签列表(json 数组)',
    answer     text                               null comment '题目答案',
    submitNum  int  default 0 not null comment '题目提交数',
    acceptedNum  int  default 0 not null comment '题目通过数',
    judgeCase text null comment '判题用例(json 数组)',
    judgeConfig text null comment '判题配置(json 对象)',
    thumbNum   int      default 0                 not null comment '点赞数',
    favourNum  int      default 0                 not null comment '收藏数',
    userId     bigint                             not null comment '创建用户 id',
    createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete   tinyint  default 0                 not null comment '是否删除',
    index idx_userId (userId)
) comment '题目' collate = utf8mb4_unicode_ci;

题目提交表

-- 题目提交表
create table if not exists question_submit
(
    id         bigint auto_increment comment 'id' primary key,
    language   varchar(128)                       not null comment '编程语言',
    code       text                               not null comment '用户代码',
    judgeInfo  text                               null comment '判题信息(json 对象)',
    status     int      default 0                 not null comment '判题状态(0 - 待判题、1 - 判题中、2 - 成功、3 - 失败)',
    questionId bigint                             not null comment '题目 id',
    userId     bigint                             not null comment '创建用户 id',
    createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete   tinyint  default 0                 not null comment '是否删除',
    index idx_questionId (questionId),
    index idx_userId (userId)
) comment '题目提交';

judgeInfo(json对象)

{
  "message": "程序执行信息",
  "time": 1000, // 单位为 ms
  "memory": 1000, // 单位为 kb
}

判题信息枚举值:

Accepted 成功
Wrong Answer 答案错误
Compile Error 编译错误
Memory Limit Exceeded 内存溢出
Time Limit Exceeded 超时
Presentation Error 展示错误
Output Limit Exceeded 输出溢出
Waiting 等待中
Dangerous Operation 危险操作
Runtime Error 运行错误(用户程序的问题)
System Error 系统错误(做系统人的问题)

判题机模块架构

判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱:只负责接受代码和输入,返回编译运行的结果,不负责判题,可以作为独立的项目、服务,提供给其他的需要执行代码的项目去使用。
这俩模块完全解耦。
在这里插入图片描述
注意!:代码沙箱接受和输出一组运行用例,因为我们没到题目有多组测试用例,如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译。
所以我们进行性能优化:批处理。

代码沙箱架构开发

(1)定义代码沙箱接口,提高通用性。
之后我们的项目代码只调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不用去修改名称了,便于扩展。
(2)定义多种不同的代码沙箱实现
示例代码沙箱:跑通流程
远程代码沙箱:实际调用接口的沙箱
第三方代码沙箱:调用网上现成的代码沙箱
(3)编写单元测试 验证单个代码沙箱的执行
```
@SpringBootTest
class CodeSandboxTest {

@Test
void executeCode() {
    CodeSandbox codeSandbox = new RemoteCodeSandbox();
    String code = "int main() { }";
    String language = QuestionSubmitLanguageEnum.JAVA.getValue();
    List<String> inputList = Arrays.asList("1 2", "3 4");
    ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
            .code(code)
            .language(language)
            .inputList(inputList)
            .build();
    ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
    Assertions.assertNotNull(executeCodeResponse);
}

问题:我们把new某个沙箱的代码写死,如果后面要改用其他的沙箱,要改很多代码。
(4)使用工厂模式
根据用户传入的字符串参数(沙箱类别)来生成对应的代码沙箱实现类
本项目使用静态工厂模式 实现简单 符合需求

/**
 * 代码沙箱工厂(根据字符串参数创建指定的代码沙箱实例)
 */
public class CodeSandboxFactory {

    /**
     * 创建代码沙箱示例
     *
     * @param type 沙箱类型
     * @return
     */
    public static CodeSandbox newInstance(String type) {
        switch (type) {
            case "example":
                return new ExampleCodeSandbox();
            case "remote":
                return new RemoteCodeSandbox();
            case "thirdParty":
                return new ThirdPartyCodeSandbox();
            default:
                return new ExampleCodeSandbox();
        }
    }
}

此时 我们可以根据字符串动态生成实例,提高通用性

public static void main(String[] args) {
    Scanner scanner = new Scanner(System.in);
    while (scanner.hasNext()) {
        String type = scanner.next();
        CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);
        String code = "int main() { }";
        String language = QuestionSubmitLanguageEnum.JAVA.getValue();
        List<String> inputList = Arrays.asList("1 2", "3 4");
        ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
        .code(code)
        .language(language)
        .inputList(inputList)
        .build();
        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
    }
}

(5)参数配置化
把项目中一些可以交给用户去自定义的选项或字符串,写到配置文件中。这样开发者只需要改配置文件,而不需要去看项目代码就能够自定义和使用你项目的很多功能。
yml文件中指定变量:

# 代码沙箱配置
codesandbox:
  type: example

在Spring的bean中通过@Value注解读取

@Value("${codesandbox.type:example}")
private String type;

(6)代码沙箱能力增强
比如:我们需要在调用代码沙箱前,输出请求参数日志;在调用之后,输出响应结果日志,便于管理员去分析。
每个代码沙箱类都写一遍 log.info?难道每次调用代码沙箱前后都执行 log?
解决:使用代理模式,提供一个 Proxy,来增强代码沙箱的能力(代理模式的作用就是增强能力)

代理模式的实现原理:
1 实现被代理的接口
2 通过构造函数接受一个被代理的接口实现类
3 调用被代理的接口实现类,在调用前后增加对应的操作。
示例代码:

@Slf4j
public class CodeSandboxProxy implements CodeSandbox {

    private final CodeSandbox codeSandbox;


    public CodeSandboxProxy(CodeSandbox codeSandbox) {
        this.codeSandbox = codeSandbox;
    }

    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        log.info("代码沙箱请求信息:" + executeCodeRequest.toString());
        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
        log.info("代码沙箱响应信息:" + executeCodeResponse.toString());
        return executeCodeResponse;
    }
}

使用方式:

CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);
codeSandbox = new CodeSandboxProxy(codeSandbox);

示例代码沙箱:

/**
 * 示例代码沙箱(仅为了跑通业务流程)
 */
@Slf4j
public class ExampleCodeSandbox implements CodeSandbox {
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        List<String> inputList = executeCodeRequest.getInputList();
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(inputList);
        executeCodeResponse.setMessage("测试执行成功");
        executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());
        JudgeInfo judgeInfo = new JudgeInfo();
        judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());
        judgeInfo.setMemory(100L);
        judgeInfo.setTime(100L);
        executeCodeResponse.setJudgeInfo(judgeInfo);
        return executeCodeResponse;
    }
}

小知识-Lombok Builder 注解

以前我们是使用 new 对象后,再逐行执行 set 方法的方式来给对象赋值的。
还有另外一种可能更方便的方式 builder。
(1)实体类加上@Builder等注解

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {

    private List<String> inputList;

    private String code;

    private String language;
}

(2)可以使用链式的方式更方便地给对象赋值

ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
    .code(code)
    .language(language)
    .inputList(inputList)
    .build();

判题服务开发

判题服务业务流程
1)传入题目的提交 id,获取到对应的题目、提交信息(包含代码、编程语言等)
2)如果题目提交状态不为等待中,就不用重复执行了
3)更改判题(题目提交)的状态为“判题中”,防止重复执行,也能让用户即时看到状态
4)调用沙箱,获取到执行结果
5)根据沙箱的执行结果,设置题目的判题状态和信息

判断逻辑
1.先判断沙箱执行的结果输出数量是否和预期输出数量相等
2.依次判断每一项输出和预期输出是否相等
3.判题题目的限制是否符合要求
4.可能还有其他的异常情况

策略模式优化

我们的判题策略可能会有多种,比如:我们的代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,Java就比较耗时。
我们可以采用策略模式,针对不同的情况,定义独立的策略,便于分别修改策略和维护,而不是把所有的判题逻辑if…else…代码全部混在一起写。

实现步骤如下:
(1)定义判题策略接口,让代码更加通用化:

public interface JudgeStrategy {

    /**
     * 执行判题
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext);
}

(2)定义上下文对象,用于定义在策略中传递的参数(可理解为一种DTO):

@Data
public class JudgeContext {

    private JudgeInfo judgeInfo;

    private List<String> inputList;

    private List<String> outputList;

    private List<JudgeCase> judgeCaseList;

    private Question question;

    private QuestionSubmit questionSubmit;

}

(3)实现默认判题策略
(4)再新增一种判题策略,通过if…else…的方式选择使用哪种策略:

JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
if (language.equals("java")) {
    judgeStrategy = new JavaLanguageJudgeStrategy();
}
JudgeInfo judgeInfo = judgeStrategy.doJudge(judgeContext);

但是,如果选择某种判题策略的过程比较复杂,如果都写在调用判题服务的代码里,代码会越来越复杂,会有大量if else,所以要单独编写一个判断策略的类。
(5)定义JudgeManager,目的是尽量简化对判题功能的调用,让调用方写最少的代码、调用最简单。对于判题策略的选取,也是在JudgeManager里处理的。
示例代码如下:

/**
 * 判题管理(简化调用)
 */
@Service
public class JudgeManager {

    /**
     * 执行判题
     *
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext) {
        QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
        String language = questionSubmit.getLanguage();
        JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
        if ("java".equals(language)) {
            judgeStrategy = new JavaLanguageJudgeStrategy();
        }
        return judgeStrategy.doJudge(judgeContext);
    }

}

Java代码沙箱原生实现

Java程序执行流程

接收代码-> 编译代码(javac)->执行代码(java)

先编写实例代码 注意去掉包名 放到resources目录下:

public class SimpleCompute {
    public static void main(String[] args) {
        int a = Integer.parseInt(args[0]);
        int b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}

用javac命令编译代码:

javac {Java代码路径}

用java命令执行代码:

java -cp {编译后的class文件所在路径} SimpleCompute 1 2

程序中文乱码
为什么编译后的 class 文件出现中文乱码呢?
原因:命令行终端的编码是 GBK,和 java 代码文件本身的编码 UTF-8 不一致,导致乱码。通过 chcp 命令查看命令行编码,GBK是936,UTF-8是65001。但是 不建议 大家改变终端编码来解决编译乱码,因为其他运行你代码的人也要改变环境,兼容性很差。推荐的 javac 编译命令,用 -encoding utf-8 参数解决:

javac -encoding utf-8 .\SimpleCompute.java
java -cp C:\code\yuoj-code-sandbox\src\main\java\com\yupi\yuojcodesandbox SimpleCompute

统一类名
实际的 OJ系统中,对用户输入的代码会有一定的要求。便于系统进行统一处理和判题。此处我们把用户输入代码的类名限制为 Main(参考 Poj),可以减少编译时类名不一致的风险;而且不用从用户代码中提取类名,更方便。
文件名 Main.java,示例代码如下:

public class Main {
    public static void main(String[] args) {
        int a = Integer.parseInt(args[0]);
        int b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}

实际执行命令时 可以统一使用Main类名

javac -encoding utf-8 .\Main.java
java -cp . Main 1 2

核心流程实现

核心实现思路:用程序代替人工,用程序来操作命令行,去编译执行代码
核心依赖:Java 进程类 Process
1.把用户的代码保存为文件
2.编译代码,得到 class 文件
3.执行代码,得到输出结果
4.收集整理输出结果
5.文件清理,释放空间
6.错误处理,提升程序健壮性

1 保存代码文件
新建目录,将每个用户的代码都存放在独立目录下,通过 UUID 随机生成目录名,便于隔离和维护:

String userDir = System.getProperty("user.dir");
String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
// 判断全局代码目录是否存在,没有则新建
if (!FileUtil.exist(globalCodePathName)) {
    FileUtil.mkdir(globalCodePathName);
}

// 把用户的代码隔离存放
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);

2 编译代码
使用Process类在终端执行命令:

String compileCmd = String.format("javac -encoding utf-8%s", userCodeFile.getAbsolutePath());
Process process = Runtime.getRuntime().exec(compileCmd)

执行 process.waitFor 等待程序执行完成,并通过返回的 exitValue 判断程序是否正常返回,然后从 Process 的输入流 inputStream 和错误流 errorStream 获取控制台输出。

// 等待程序执行,获取错误码
int exitValue = compileProcess.waitFor();
// 正常退出
if (exitValue == 0) {
    System.out.println("编译成功");
    // 分批获取进程的正常输出
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
    StringBuilder compileOutputStringBuilder = new StringBuilder();
    // 逐行读取
    String compileOutputLine;
    while ((compileOutputLine = bufferedReader.readLine()) != null) {
        compileOutputStringBuilder.append(compileOutputLine);
    }
    System.out.println(compileOutputStringBuilder);
} else {
    // 异常退出
    System.out.println("编译失败,错误码: " + exitValue);
    // 分批获取进程的正常输出
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
    StringBuilder compileOutputStringBuilder = new StringBuilder();
    // 逐行读取
    String compileOutputLine;
    while ((compileOutputLine = bufferedReader.readLine()) != null) {
        compileOutputStringBuilder.append(compileOutputLine);
    }
    // 分批获取进程的错误输出
    BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getErrorStream()));
    StringBuilder errorCompileOutputStringBuilder = new StringBuilder();

    // 逐行读取
    String errorCompileOutputLine;
    while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
        errorCompileOutputStringBuilder.append(errorCompileOutputLine);
    }
    System.out.println(compileOutputStringBuilder);
}

可以把上述代码提取为工具类 ProcessUtils,执行进程并获取输出,并且使用 StrinaBuilder 拼接控制台输出信息:

package com.yupi.yuojcodesandbox.utils;

import com.yupi.yuojcodesandbox.model.ExecuteMessage;

import java.io.BufferedReader;
import java.io.InputStreamReader;

/**
 * 进程工具类
 */
public class ProcessUtils {

    /**
     * 执行进程并获取信息
     *
     * @param runProcess
     * @param opName
     * @return
     */
    public static ExecuteMessage runProcessAndGetMessage(Process runProcess, String opName) {
        ExecuteMessage executeMessage = new ExecuteMessage();

        try {
            // 等待程序执行,获取错误码
            int exitValue = runProcess.waitFor();
            executeMessage.setExitValue(exitValue);
            // 正常退出
            if (exitValue == 0) {
                System.out.println(opName + "成功");
                // 分批获取进程的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine()) != null) {
                    compileOutputStringBuilder.append(compileOutputLine);
                }
                executeMessage.setMessage(compileOutputStringBuilder.toString());
            } else {
                // 异常退出
                System.out.println(opName + "失败,错误码: " + exitValue);
                // 分批获取进程的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine()) != null) {
                    compileOutputStringBuilder.append(compileOutputLine);
                }
                executeMessage.setMessage(compileOutputStringBuilder.toString());

                // 分批获取进程的错误输出
                BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
                StringBuilder errorCompileOutputStringBuilder = new StringBuilder();

                // 逐行读取
                String errorCompileOutputLine;
                while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
                    errorCompileOutputStringBuilder.append(errorCompileOutputLine);
                }
                executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return executeMessage;
    }
}

3 执行程序
同样是使用 Process 类运行java 命令,命令中记得增加-Dfile.encoding=UTF-8 参数,解决中文乱码:

String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);

上述命令适用于执行从输入参数(args)中获取值的代码。很多 OJ 都是 ACM 模式,需要和用户交互,让用户不断输入内容并获取输出,比如:

import java.io.*;
import java.util.*;

public class Main
{
    public static void main(String args[]) throws Exception
    {
        Scanner cin=new Scanner(System.in);
        int a=cin.nextInt(),b=cin.nextInt();
        System.out.println(a+b);
    }
}

对于此类程序,我们需要使用 Outputstream 向程序终端发送参数,并及时获取结果,注意最后要关闭流释放资源。
示例代码如下:

/**
 * 执行交互式进程并获取信息
 *
 * @param runProcess
 * @param args
 * @return
 */
public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess, String args) {
    ExecuteMessage executeMessage = new ExecuteMessage();

    try {
        // 向控制台输入程序
        OutputStream outputStream = runProcess.getOutputStream();
        OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
        String[] s = args.split(" ");
        String join = StrUtil.join("\n", s) + "\n";//将分割后的字符串用换行符 \n 连接起来,并在末尾添加一个换行符。这样模拟了用户逐行输入的效果。
        outputStreamWriter.write(join);//将处理后的输入内容写入进程的输入流。
        // 相当于按了回车,执行输入的发送
        outputStreamWriter.flush();//刷新输出流,确保所有输入内容都被发送到进程。

        // 分批获取进程的正常输出
        InputStream inputStream = runProcess.getInputStream();
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
        StringBuilder compileOutputStringBuilder = new StringBuilder();
        // 逐行读取
        String compileOutputLine;
        while ((compileOutputLine = bufferedReader.readLine()) != null) {
            compileOutputStringBuilder.append(compileOutputLine);
        }
        executeMessage.setMessage(compileOutputStringBuilder.toString());
        // 记得资源的释放,否则会卡死
        outputStreamWriter.close();
        outputStream.close();
        inputStream.close();
        runProcess.destroy();
    } catch (Exception e) {
        e.printStackTrace();
    }
    return executeMessage;
}

4 整理输出
1)通过 for 循环遍历执行结果,从中获取输出列表
2)获取程序执行时间
可以使用 Spring 的 StopWatch 获取一段程序的执行时间:

StopWatch stopWatch = new StopWatch();
stopWatch.start();
... 程序执行
stopWatch.stop();
stopWatch.getLastTaskTimeMillis(); // 获取时间

此处我们使用最大值来统计时间,便于后续判题服务计算程序是否超时

// 取用时最大值,便于判断是否超时
long maxTime = 0;
for (ExecuteMessage executeMessage : executeMessageList) {
    ...
    Long time = executeMessage.getTime();
    if (time != null) {
        maxTime = Math.max(maxTime, time);
    }
}

(3)获取内存信息
实现比较复杂,因为无法从 Process 对象中获取到子进程号,也不推荐在 Java 原生实现代码沙箱的过程中获取。
5 文件清理
防止服务器空间不足,删除代码目录:

if (userCodeFile.getParentFile() != null) {
    boolean del = FileUtil.del(userCodeParentPath);
    System.out.println("删除" + (del ? "成功" : "失败"));
}

6 错误处理
封装一个错误处理方法,当程序抛出异常时,直接返回错误响应。

private ExecuteCodeResponse getErrorResponse(Throwable e) {
    ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
    executeCodeResponse.setOutputList(new ArrayList<>());
    executeCodeResponse.setMessage(e.getMessage());
    // 表示代码沙箱错误
    executeCodeResponse.setStatus(2);
    executeCodeResponse.setJudgeInfo(new JudgeInfo());
    return executeCodeResponse;
}

Java程序异常情况

到目前为止 核心流程已经实现 但是想上线的话并不安全
用户要是提交恶意代码咋整
情况1:执行超时
占用时间资源,导致程序卡死,不释放资源:

/**
 * 无限睡眠(阻塞程序执行)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException {
        long ONE_HOUR = 60 * 60 * 1000L;
        Thread.sleep(ONE_HOUR);
        System.out.println("睡完了");
    }
}

情况2:占用内存
占用内存资源,导致空间浪费

import java.util.ArrayList;
import java.util.List;

/**
 * 无限占用空间(浪费系统内存)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException {
        List<byte[]> bytes = new ArrayList<>();
        while (true) {
            bytes.add(new byte[10000]);
        }
    }
}

实际运行上述程序时,我们会发现,内存占用到达一定空间后,程序就自动报错: java.1ang.0utofMemoryError:Java heap space ,而不是无限增加内存占用,直到系统死机。
这是 JVM 的一个保护机制。
可以使用 JisualVM 或 JConsole 工具,连接到 JM 虚拟机上来可视化查看运行状态。
情况3:读文件,信息泄露
比如直接通过相对路径获取项目配置文件,获取到密码

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

/**
 * 读取服务器文件(文件信息泄露)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException, IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/application.yml";
        List<String> allLines = Files.readAllLines(Paths.get(filePath));
        System.out.println(String.join("\n", allLines));
    }
}

情况4:写文件 植入木马

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;

/**
 * 向服务器写文件(植入危险程序)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException, IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
        String errorProgram = "java -version 2>&1";
        Files.write(Paths.get(filePath), Arrays.asList(errorProgram));
        System.out.println("写木马成功,你完了哈哈");
    }
}

5 运行其他程序
直接通过 Process 执行危险程序,或者电脑上的其他程序!

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * 运行其他程序(比如危险木马)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException, IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
        Process process = Runtime.getRuntime().exec(filePath);
        process.waitFor();
        // 分批获取进程的正常输出
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
        // 逐行读取
        String compileOutputLine;
        while ((compileOutputLine = bufferedReader.readLine()) != null) {
            System.out.println(compileOutputLine);
        }
        System.out.println("执行异常程序成功");
    }
}

6 执行高危操作
甚至都不用写木马文件,直接执行系统自带的危险命令!
比如删除服务器的所有文件(太残暴、不演示)
比如执行 dir(windows)、ls(linux)获取你系统上的所有文件信息

Java程序安全控制

针对上面的异常情况,分别有如下方案,可以提高程序安全性。
1.超时控制
2.限制给用户程序分配的资源
3.限制代码-黑白名单
4.限制用户的操作权限
5.运行环境隔离

1 超时控制

通过创建一个守护线程,超时后自动终端Process实现。

// 超时控制
new Thread(() -> {
    try {
        Thread.sleep(TIME_OUT);
        System.out.println("超时了,中断");
        runProcess.destroy();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}).start();

2 限制资源分配

我们不能让每个Java进程的执行占用的JVM最大堆内存空格都和系统默认的一致,实际上应该更小,比如说256M。
在启动Java程序时,可以指定JVM参数:-Xmx256m(最大堆空间大小)
示例命令如下:

java -Xmx256m

注意!-Xmx 参数、JM 的堆内存限制,不等同于系统实际占用的最大资源,可能会超出。
如果需要更严格的内存限制,要在系统层面去限制,而不是JVM层面的限制。
如果是 Linux 系统,可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配。
小知识:什么是cgroup?
cgroup 是 Linux 内核提供的一种机制,可以用来限制进程组(包括子进程)的资源使用,例如内存、CPU、磁盘 I/O等。通过将 Java 进程放置在特定的 cgroup 中,你可以实现限制其使用的内存和 CPU 数。
小知识:常用JVM启动参数
1.内存相关参数:
O -Xms: 设置 JVM 的初始堆内存大小。
O -Xmx: 设置 JM 的最大堆内存大小。
O -Xss: 设置线程的栈大小。
O -XX:MaxMetaspaceSize:设置 Metaspace(元空间)的最大大小。
O -XX:MaxDirectMemorySize: 设置直接内存(Direct Memory)的最大大小。
2.垃圾回收相关参数:
O -XX:+UseSerialGc: 使用串行垃圾回收器。
O -XX:+UseParallelGc: 使用并行垃圾回收器。
O -XX:+UseConcMarkSweepGc: 使用 CMS 垃圾回收器。
O -XX:+UseG1GC: 使用 G1 垃圾回收器。
3. 线程相关参数:
O -XX:ParallelGCThreads: 设置并行垃圾回收的线程数。
O -XX:ConcGCThreads: 设置并发垃圾回收的线程数。
O -XX:ThreadStackSize:设置线程的栈大小。
4.JT 编译器相关参数:
O -XX:TieredStopAtLevel: 设置 JT 编译器停止编译的层次。
5.其他资源限制参数:
O -XX:MaxRAM: 设置 JM 使用的最大内存。

限制代码-黑白名单

实现
先定义一个黑白名单,比如哪些操作时禁止的,可以就是一个列表:

private static final List<String> blackList = Arrays.asList("Files", "exec");

还可以使用字典树代替列表存储单词,用 更少的空间 存储更多的敏感词汇,并且实现 更高效 的敏感词查找。
字典树的原理:
在这里插入图片描述
此处使用 HuTool 工具库的字典树工具类:WordTree,不用自己写字典树!
(1)先初始化字典树,插入禁用词:

private static final WordTree WORD_TREE;

static {
    // 初始化字典树
    WORD_TREE = new WordTree();
    WORD_TREE.addWords(blackList);
}

(2)校验用户代码是否包含禁用词

//  校验代码中是否包含黑名单中的禁用词
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord != null) {
    System.out.println("包含禁止词:" + foundWord.getFoundWord());
    return null;
}

方案缺点:
1)你无法遍历所有的黑名单
2)不同的编程语言,你对应的领域、关键词都不一样,限制人工成本很大

限制权限-Java安全管理器

目标:限制用户对文件、内存、CPU、网络等资源的操作和访问。
Java安全管理器的使用
Java 安全管理器(Security Manager)是 Java 提供的保护 JVM、Java 安全的机制,可以实现更严格的资源和操作限制。
编写安全管理器,只需要继承 Security Manager。
(1)所有权限放开

package com.yupi.yuojcodesandbox.security;

import java.security.Permission;

/**
 * 默认安全管理器
 */
public class DefaultSecurityManager extends SecurityManager {

    // 检查所有的权限
    @Override
    public void checkPermission(Permission perm) {
        System.out.println("默认不做任何限制");
        System.out.println(perm);
        // super.checkPermission(perm);
    }
}

(2)所有权限拒绝:

package com.yupi.yuojcodesandbox.security;

import java.security.Permission;

/**
 * 禁用所有权限安全管理器
 */
public class DenySecurityManager extends SecurityManager {

    // 检查所有的权限
    @Override
    public void checkPermission(Permission perm) {
        throw new SecurityException("权限异常:" + perm.toString());
    }
}

(3)限制读、写、执行文件权限

@Override
public void checkRead(String file) {
    throw new SecurityException("checkRead 权限异常:" + file);
}
@Override
public void checkWrite(String file) {
    throw new SecurityException("checkWrite 权限异常:" + file);
}
@Override
public void checkExec(String cmd) {
	throw new SecurityException("checkExec 权限异常:" + cmd);
}

(4)限制网络连接权限:

@Override
public void checkConnect(String host, int port) {
    throw new SecurityException("checkConnect 权限异常:" + host + ":" + port);
}

结合项目运用
实际情况下,不应该在主类(开发者自己写的程序)中做限制,只需要限制子程序的权限即可。启动子进程执行命令时,设置安全管理器,而不是在外层设置(会限制住测试用例的读写和子命令的执行)。具体操作如下:
1)根据需要开发自定义的安全管理器(比如 MySecurityManager)
2)复制 MySecurityManager类到 resources/security 目录下,移除类的包名
3)手动输入命令编译 MySecurityManager 类,得到 class 文件
4)在运行 java 程序时,指定安全管理器 class 文件的路径、安全管理器的名称。
注意,windows 下要用分号间隔多个类路径!
命令如下:

java -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=MySecurityManager Main

安全管理器优点

1.权限控制很灵活
2.实现简单

安全管理器缺点

1.如果要做比较严格的权限限制,需要自己去判断哪些文件、包名需要允许读写。粒度太细了,难以精细化控制。
2.安全管理器本身也是 Java 代码,也有可能存在漏洞。本质上还是程序层面的限制,没深入系统的层面。

运行环境隔离

原理:操作系统层面上,把用户程序封装到沙箱里,和宿主机(我们的电脑/服务器)隔离开,使得用户的程序无法影响宿主机。
实现方式:Docker 容器技术(底层是用 cgroup、namespace 等方式实现的),也可以直接使用 cgroup 实现。

Docker容器技术

为什么要用 Docker 容器技术?
为了进一步提升系统的安全性,把不同的程序和宿主机进行隔离,使得某个程序(应用)的执行不会影响到系统本身。
Docker 技术可以实现程序和宿主机的隔离。

什么是容器

理解为对一系列应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内,对外整体提供服务。

Docker基本概念

镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像
容器:通过镜像来创建的一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例Dockerfile:制作镜像的文件,可以理解为制作镜像的一个清单
镜像仓库:存放镜像的仓库,用户可以从仓库下载现成的镜像,也可以把做好的镜像放到仓库里推荐使用 docker 官方的镜像仓库:https://hub.docker.com/search?q=nginx
在这里插入图片描述

Docker实现核心(Docker能实现那些资源的隔离)

1)Docker 运行在 Linux 内核上
2)CGroups:实现了容器的资源隔离,底层是 Linux Cgroup 命令,能够控制进程使用的资源
3)Network 网络:实现容器的网络隔离,docker 容器内部的网络互不影响
4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同的命名空间下的进程互不影响。
5)Storage 存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件
在这里插入图片描述

命令行操作Docker

//查看命令用法
docker --help
//查看具体子命令用法
docker run --help
//从远程仓库拉取现成的镜像 比如
docker pull hello-world
//根据镜像创建容器实例 比如
sudo docker create hello-world
// 查看容器状态
sudo docker ps -a
//启动容器
sudo docker start mystifying_shamir
//查看日志
sudo docker logs mystifying_shamir
//删除容器实例
sudo docker rm mystifying_shamir
//删除镜像
docker rmi --help
sudo docker rmi hello-world -f //强制删除

Java操作Docker

1 引入依赖

<!-- https://mvnrepository.com/artifact/com.github.docker-java/docker-java -->
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java</artifactId>
    <version>3.3.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.docker-java/docker-java-transport-httpclient5 -->
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java-transport-httpclient5</artifactId>
    <version>3.3.0</version>
</dependency>

远程开发

使用 IDEA Development 先上传代码到 Linux,然后使用 JetBrains 远程开发完全连接 Linux 实时开发。
如果无法启动程序,修改 settings 的compiler 配置: -Djdk.lang.Process.launchMechanism=vfork
在这里插入图片描述

常用操作

(1)拉取镜像

String image = "nginx:latest";
PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
PullImageResultCallback pullImageResultCallback = new PullImageResultCallback() {
    @Override
    public void onNext(PullResponseItem item) {
        System.out.println("下载镜像:" + item.getStatus());
        super.onNext(item);
    }
};
pullImageCmd
        .exec(pullImageResultCallback)
        .awaitCompletion();
System.out.println("下载完成");

(2)创建容器

CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
CreateContainerResponse createContainerResponse = containerCmd
        .withCmd("echo", "Hello Docker")
        .exec();
System.out.println(createContainerResponse);

(3)查看容器状态

ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
List<Container> containerList = listContainersCmd.withShowAll(true).exec();
for (Container container : containerList) {
    System.out.println(container);
}

(4)启动容器

dockerClient.startContainerCmd(containerId).exec();

(5)查看日志

// 查看日志
LogContainerResultCallback logContainerResultCallback = new LogContainerResultCallback() {
    @Override
    public void onNext(Frame item) {
        System.out.println(item.getStreamType());
        System.out.println("日志:" + new String(item.getPayload()));
        super.onNext(item);
    }
};

// 阻塞等待日志输出
dockerClient.logContainerCmd(containerId)
        .withStdErr(true)
        .withStdOut(true)
        .exec(logContainerResultCallback)
        .awaitCompletion();

(6)删除容器

dockerClient.removeContainerCmd(containerId).withForce(true).exec();

(7)删除镜像

dockerClient.removeImageCmd(image).exec();

回调机制

Docker 操作(如拉取镜像、查看容器日志)通常是异步的,这意味着这些操作可能需要一些时间才能完成。例如:
拉取一个大型镜像可能需要几分钟。
查看容器日志时,日志数据可能分批次返回。
如果直接在主线程中同步等待这些操作完成,会导致主线程被阻塞,程序无法响应其他任务。这种阻塞式编程在处理 I/O 操作时效率非常低。
**解决方案:**使用回调机制,可以让程序在操作完成时得到通知,而不会阻塞主线程。
Docker 操作通常会产生一系列事件(如日志数据分批次返回、镜像拉取进度更新)。回调机制允许程序在每个事件发生时执行特定的逻辑,而不是等待所有事件完成后再统一处理。

例如:

在拉取镜像时,Docker 会不断返回进度信息(如“下载层 1/5”、“下载层 2/5”等)。
在查看容器日志时,日志数据可能分批次返回。

如果直接等待所有数据返回后再处理,会导致程序无法及时响应每个事件。回调机制允许程序在每个事件发生时立即处理,从而实现更灵活的事件驱动编程。

比如说查看日志和拉取镜像:

LogContainerResultCallback:
这是一个回调类,用于处理容器日志的输出。
onNext(Frame item) 方法会在接收到每条日志时被调用。每次接收到日志数据时,都会立即打印出来,而不是等待所有日志数据全部返回。
awaitCompletion():
阻塞等待日志查看操作完成。虽然这里使用了阻塞等待,但日志数据的处理是逐条进行的,不会导致程序长时间无响应。

PullImageResultCallback:
这是一个回调类,用于处理镜像拉取的进度。
onNext(PullResponseItem item) 方法会在接收到每条拉取进度更新时被调用。每次接收到进度信息时,都会立即打印出来,而不是等待镜像拉取完成。
awaitCompletion():
阻塞等待镜像拉取操作完成。虽然这里使用了阻塞等待,但进度信息的处理是逐条进行的,用户可以实时看到镜像拉取的进度。

那为什么这样写捏

(1)避免阻塞主线程:
使用回调机制可以避免在主线程中同步等待操作完成,从而提高程序的响应性和性能。
(2)实时处理事件:
回调机制允许程序在每个事件发生时立即处理,而不是等待所有事件完成后再统一处理。这对于需要实时反馈的操作(如查看日志、显示进度)非常重要。
(3)代码的模块化和复用:
回调机制允许将操作的执行逻辑与处理逻辑解耦,使得代码更加模块化,便于复用和维护。
(4)灵活性:
回调机制允许在不同的场景下使用相同的操作逻辑,而只需提供不同的回调实现。例如,你可以为日志输出提供不同的处理逻辑,而无需修改核心的查看日志逻辑。

Docker实现代码沙箱

实现思路:docker 负责运行java 程序,并且得到结果。
流程几乎和 Java 原生实现流程相同:
1.把用户的代码保存为文件
2.编译代码,得到 class 文件
3.把编译好的文件上传到容器环境内
4.在容器中执行代码,得到输出结果
5.收集整理输出结果
6.文件清理,释放空间
7.错误处理,提升程序健壮性
创建容器,上传编译文件
我们每个测试用例都单独创建一个容器,每个容器只执行一次 java 命令?
浪费性能,所以要创建一个 可交互 的容器,能接受多次输入并且输出。
创建容器时,可以指定文件路径(Volumn) 映射,作用是把本地的文件同步到容器中,可以让容器访问。

也可以叫容器挂载目录

HostConfig hostConfig = new HostConfig();
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));

启动容器,执行代码

执行代码
Docker 执行容器命令(操作已启动容器):
例如:

docker exec keen_blackwell java -cp /app Main 1 3

注意,要把命令按照空格拆分,作为一个数组传递,否则可能会被识别为一个字符串,而不是多个参数。
创建命令

String[] inputArgsArray = inputArgs.split(" ");
String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main"}, inputArgsArray);
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
        .withCmd(cmdArray)
        .withAttachStderr(true)
        .withAttachStdin(true)
        .withAttachStdout(true)
        .exec();

执行命令
通过回调接口来获取程序的输出结果,并且通过 StreamType 来区分标准输出和错误输出。
示例代码:

String execId = execCreateCmdResponse.getId();
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {
    @Override
    public void onNext(Frame frame) {
        StreamType streamType = frame.getStreamType();
        if (StreamType.STDERR.equals(streamType)) {
            System.out.println("输出错误结果:" + new String(frame.getPayload()));
        } else {
            System.out.println("输出结果:" + new String(frame.getPayload()));
        }
        super.onNext(frame);
    }
};
try {
    dockerClient.execStartCmd(execId)
            .exec(execStartResultCallback)
            .awaitCompletion();
} catch (InterruptedException e) {
    System.out.println("程序执行异常");
    throw new RuntimeException(e);
}

获取程序执行时间
和 Java 原生一样,使用 StopWatch 在执行前后统计时间。

stopWatch.start();
dockerClient.execStartCmd(execId)
        .exec(execStartResultCallback)
        .awaitCompletion(TIME_OUT, TimeUnit.SECONDS);
stopWatch.stop();
time = stopWatch.getLastTaskTimeMillis();

获取程序占用内存
程序占用的内存每个时刻都在变化,所以你不可能获取到所有时间点的内存。

我们要做的是,定义一个周期,定期地获取程序的内存。

Docker-Java 提供了内存定期统计的操作,示例代码如下:

// 获取占用的内存
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {

    @Override
    public void onNext(Statistics statistics) {
        System.out.println("内存占用:" + statistics.getMemoryStats().getUsage());
        maxMemory[0] = Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]);
    }

    @Override
    public void close() throws IOException {

    }

    @Override
    public void onStart(Closeable closeable) {

    }

    @Override
    public void onError(Throwable throwable) {

    }

    @Override
    public void onComplete() {

    }
});
statsCmd.exec(statisticsResultCallback);

注意,程序执行完后要关闭统计命令:

statsCmd.close()

Docker容器安全性

超时控制
执行容器时,可以增加超时参数控制值

dockerClient.execStartCmd(execId)
        .exec(execStartResultCallback)
        .awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);

但是,这种方式无论超时与否,都会往下执行,无法判断是否超时。
解决方案:可以定义一个标志,如果程序执行完成,把超时标志设置为 false。

// 判断是否超时
final boolean[] timeout = {true};
String execId = execCreateCmdResponse.getId();
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {
    @Override
    public void onComplete() {
        // 如果执行完成,则表示没超时
        timeout[0] = false;
        super.onComplete();
    }
    
	...
};

内存资源
通过 HostConfig 的 withMemory 等方法,设置容器的最大内存和资源限制:

CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1000L);
hostConfig.withMemorySwap(0L);
hostConfig.withCpuCount(1L);
CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .exec();

网络资源
创建容器时,设置网络配置为关闭
禁用容器的网络功能可以增强容器的安全性。如果容器不需要访问外部网络,禁用网络可以防止潜在的网络攻击或数据泄露。
如果容器运行的代码来自不可信的来源,禁用网络可以防止容器内的恶意代码访问外部网络。

CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .withNetworkDisabled(true)

权限管理
Docker 容器已经做了系统层面的隔离,比较安全,但不能保证绝对安全。
(1)限制用户不能向 root 根目录写文件:

CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .withNetworkDisabled(true)
        .withReadonlyRootfs(true)

(2)Linux 自带的一些安全管理措施,比如 seccomp(Secure Computing Mode)是一个用于 Linux 内核的安全功能,它允许你限制进程可以执行的系统调用,从而减少潜在的攻击面和提高容器的安全性。通过配置 seccomp,你可以控制容器内进程可以使用的系统调用类型和参数。
示例 seccomp 配置文件 profile.json:

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "name": "write",
      "action": "SCMP_ACT_ALLOW"
    },
    {
      "name": "read",
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

在 hostConfig 中开启安全机制:

String profileConfig = ResourceUtil.readUtf8Str("profile.json");
hostConfig.withSecurityOpts(Arrays.asList("seccomp=" + profileConfig));

模版方法优化代码沙箱

模板方法:定义一套通用的执行流程,让子类负责每个执行步骤的具体实现

模板方法的适用场景:适用于有规范的流程,且执行流程可以复用

作用:大幅节省重复代码量,便于项目扩展、更好维护

1 抽象出具体流程

定义一个模板方法抽象类。
先复制具体的实现类,把代码从完整的方法抽离成一个一个子写法

    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        List<String> inputList = executeCodeRequest.getInputList();
        String code = executeCodeRequest.getCode();
        String language = executeCodeRequest.getLanguage();

//        1. 把用户的代码保存为文件
        File userCodeFile = saveCodeToFile(code);

//        2. 编译代码,得到 class 文件
        ExecuteMessage compileFileExecuteMessage = compileFile(userCodeFile);
        System.out.println(compileFileExecuteMessage);

        // 3. 执行代码,得到输出结果
        List<ExecuteMessage> executeMessageList = runFile(userCodeFile, inputList);

//        4. 收集整理输出结果
        ExecuteCodeResponse outputResponse = getOutputResponse(executeMessageList);

//        5. 文件清理
        boolean b = deleteFile(userCodeFile);
        if (!b) {
            log.error("deleteFile error, userCodeFilePath = {}", userCodeFile.getAbsolutePath());
        }
        return outputResponse;
    }

2 定义子类的具体实现

Java 原生代码沙箱实现,直接复用模板方法定义好的方法实现:

/**
 * Java 原生代码沙箱实现(直接复用模板方法)
 */
public class JavaNativeCodeSandbox extends JavaCodeSandboxTemplate {

    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        return super.executeCode(executeCodeRequest);
    }
}

Docker 代码沙箱实现,需要自行重写 RunFile:

    /**
     * 3、创建容器,把文件复制到容器内
     * @param userCodeFile
     * @param inputList
     * @return
     */
    @Override
    public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
        String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
        // 获取默认的 Docker Client
        DockerClient dockerClient = DockerClientBuilder.getInstance().build();

        // 拉取镜像
        String image = "openjdk:8-alpine";
        if (FIRST_INIT) {
            PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
            PullImageResultCallback pullImageResultCallback = new PullImageResultCallback() {
                @Override
                public void onNext(PullResponseItem item) {
                    System.out.println("下载镜像:" + item.getStatus());
                    super.onNext(item);
                }
            };
            try {
                pullImageCmd
                        .exec(pullImageResultCallback)
                        .awaitCompletion();
            } catch (InterruptedException e) {
                System.out.println("拉取镜像异常");
                throw new RuntimeException(e);
            }
        }

        System.out.println("下载完成");

        // 创建容器

        CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
        HostConfig hostConfig = new HostConfig();
        hostConfig.withMemory(100 * 1000 * 1000L);
        hostConfig.withMemorySwap(0L);
        hostConfig.withCpuCount(1L);
        hostConfig.withSecurityOpts(Arrays.asList("seccomp=安全管理配置字符串"));
        hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
        CreateContainerResponse createContainerResponse = containerCmd
                .withHostConfig(hostConfig)
                .withNetworkDisabled(true)
                .withReadonlyRootfs(true)
                .withAttachStdin(true)
                .withAttachStderr(true)
                .withAttachStdout(true)
                .withTty(true)
                .exec();
        System.out.println(createContainerResponse);
        String containerId = createContainerResponse.getId();

        // 启动容器
        dockerClient.startContainerCmd(containerId).exec();

        // docker exec keen_blackwell java -cp /app Main 1 3
        // 执行命令并获取结果
        List<ExecuteMessage> executeMessageList = new ArrayList<>();
        for (String inputArgs : inputList) {
            StopWatch stopWatch = new StopWatch();
            String[] inputArgsArray = inputArgs.split(" ");
            String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main"}, inputArgsArray);
            ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
                    .withCmd(cmdArray)
                    .withAttachStderr(true)
                    .withAttachStdin(true)
                    .withAttachStdout(true)
                    .exec();
            System.out.println("创建执行命令:" + execCreateCmdResponse);

            ExecuteMessage executeMessage = new ExecuteMessage();
            final String[] message = {null};
            final String[] errorMessage = {null};
            long time = 0L;
            // 判断是否超时
            final boolean[] timeout = {true};
            String execId = execCreateCmdResponse.getId();
            ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {
                @Override
                public void onComplete() {
                    // 如果执行完成,则表示没超时
                    timeout[0] = false;
                    super.onComplete();
                }

                @Override
                public void onNext(Frame frame) {
                    StreamType streamType = frame.getStreamType();
                    if (StreamType.STDERR.equals(streamType)) {
                        errorMessage[0] = new String(frame.getPayload());
                        System.out.println("输出错误结果:" + errorMessage[0]);
                    } else {
                        message[0] = new String(frame.getPayload());
                        System.out.println("输出结果:" + message[0]);
                    }
                    super.onNext(frame);
                }
            };

            final long[] maxMemory = {0L};

            // 获取占用的内存
            StatsCmd statsCmd = dockerClient.statsCmd(containerId);
            ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {

                @Override
                public void onNext(Statistics statistics) {
                    System.out.println("内存占用:" + statistics.getMemoryStats().getUsage());
                    maxMemory[0] = Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]);
                }

                @Override
                public void close() throws IOException {

                }

                @Override
                public void onStart(Closeable closeable) {

                }

                @Override
                public void onError(Throwable throwable) {

                }

                @Override
                public void onComplete() {

                }
            });
            statsCmd.exec(statisticsResultCallback);
            try {
                stopWatch.start();
                dockerClient.execStartCmd(execId)
                        .exec(execStartResultCallback)
                        .awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
                stopWatch.stop();
                time = stopWatch.getLastTaskTimeMillis();
                statsCmd.close();
            } catch (InterruptedException e) {
                System.out.println("程序执行异常");
                throw new RuntimeException(e);
            }
            executeMessage.setMessage(message[0]);
            executeMessage.setErrorMessage(errorMessage[0]);
            executeMessage.setTime(time);
            executeMessage.setMemory(maxMemory[0]);
            executeMessageList.add(executeMessage);
        }
        return executeMessageList;
    }

给代码沙箱提供开放API

直接在 controller 暴露 CodeSandbox 定义的接口:

    /**
     * 执行代码
     *
     * @param executeCodeRequest
     * @return
     */
    @PostMapping("/executeCode")
    ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest) {
        if (executeCodeRequest == null) {
            throw new RuntimeException("请求参数为空");
        }
        return javaNativeCodeSandbox.executeCode(executeCodeRequest);
    }

调用安全性

如果将服务不做任何的权限校验,直接发到公网,是不安全的。

1)调用方与服务提供方之间约定一个字符串 (最好加密)

优点:实现最简单,比较适合内部系统之间相互调用(相对可信的环境内部调用)

缺点:不够灵活,如果 key 泄露或变更,需要重启代码

代码沙箱服务,先定义约定的字符串:

    // 定义鉴权请求头和密钥
    private static final String AUTH_REQUEST_HEADER = "auth";

    private static final String AUTH_REQUEST_SECRET = "secretKey";

改造请求,从请求头中获取认证信息,并校验:

    @PostMapping("/executeCode")
    ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest, HttpServletRequest request,
                                    HttpServletResponse response) {
        // 基本的认证
        String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
        if (!AUTH_REQUEST_SECRET.equals(authHeader)) {
            response.setStatus(403);
            return null;
        }
        if (executeCodeRequest == null) {
            throw new RuntimeException("请求参数为空");
        }
        return javaNativeCodeSandbox.executeCode(executeCodeRequest);
    }

调用方,在调用时补充请求头:

    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        System.out.println("远程代码沙箱");
        String url = "http://localhost:8090/executeCode";
        String json = JSONUtil.toJsonStr(executeCodeRequest);
        String responseStr = HttpUtil.createPost(url)
                .header(AUTH_REQUEST_HEADER, AUTH_REQUEST_SECRET)
                .body(json)
                .execute()
                .body();
        if (StringUtils.isBlank(responseStr)) {
            throw new BusinessException(ErrorCode.API_REQUEST_ERROR, "executeCode remoteSandbox error, message = " + responseStr);
        }
        return JSONUtil.toBean(responseStr, ExecuteCodeResponse.class);
    }
  1. API签名认证
    给允许调用的人员分配 accessKey、secretKey,然后校验这两组 key 是否匹配

单体项目改微服务

微服务:专注于提供某类特定功能的代码,而不是把所有的代码全部放到同一个项目里。会把整个大的项目按照一定的功能、逻辑进行拆分,拆分为多个子模块,每个子模块可以独立运行、独立负责一类功能,子模块之间相互调用、互不影响。
微服务的几个重要的实现因素:服务管理、服务调用、服务拆分
本项目使用Spring Cloud Alibaba
本质:是在 Spring Cloud 的基础上,进行了增强,补充了一些额外的能力,根据阿里多年的业务沉淀做了一些定制化的开发
1.Spring Cloud Gateway: 网关
2.Nacos:服务注册和配置中心
3.Sentinel:熔断限流
4.Seata:分布式事务
5.RocketMQ:消息队列,削峰填谷
6.Docker:使用Docker进行容器化部署
7.Kubernetes:使用k8s进行容器化部署
在这里插入图片描述
Nacos:集中存管项目中所有服务的信息,便于服务之间找到彼此;同时,还支持集中存储整个项目中的配置。
整个微服务请求流程:
在这里插入图片描述

改造分布式登录

(1)application.yml 增加 redis 配置
(2)补充依赖

        <!-- redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

(3)主类取消Redis自动配置的移除
(4)修改session存储方式

spring.session.store-type: redis

(5)使用 redis-cli 或者 redis 管理工具,查看是否有登录后的信息

微服务的划分

公共模块:
common 公共模块(mikeyoj-backend-common):全局异常处理器、请求响应封装类、公用的工具类等
model 模型模块(mikeyoj-backend-model):很多服务公用的实体类
公用接口模块(mikeyoj-backend-service-client):只存放接口,不存放实现(多个服务之间要共享)

业务功能:
用户服务(yuoj-backend-user-service:8102端口)
注册:后端已实现。
登录:后端已实现,前端已实现。
用户管理:管理员可进行用户信息管理。
题目服务(yuoj-backend-question-service:8103端口)
创建题目:管理员操作。
删除题目:管理员操作。
修改题目:管理员操作。
搜索题目:用户可搜索。
在线做题:用户可在详情页做题。
题目提交:用户提交代码。
判题服务(yuoj-backend-judge-service:8104端口)
执行判题逻辑:对用户代码进行判断。
错误处理:处理内存溢出、安全性和超时问题。
代码沙箱:独立实现,提供安全运行环境。
开放接口:提供独立服务接口。

代码沙箱服务本身就是独立的,不用纳入 Spring Cloud 的管理

路由划分
用Spring Boot 的 context-path 统一修改后的接口前缀整理:
用户服务
对外接口前缀:/api/user
内部接口前缀:/api/user/inner
题目服务
对外接口前缀:/api/question(包括题目提交信息)
内部接口前缀:/api/question/inner
判题服务
对外接口前缀:/api/judge
内部接口前缀:/api/judge/inner
网关层面需对内部接口进行访问限制。
Nacos注册中心启动
安装好后 进入bin目录启动

startup.cmd -m standalone

需要给各模块之间绑定子父依赖关系,效果如下:
在这里插入图片描述
父模块定义 modules,子模块引入 parent 语法,可以通过继承父模块配置,统一项目的定义和版本号。

  1. common 公共模块(yuoj-backend-common):全局异常处理器、请求响应封装类、公用的工具类等

  2. model 模型模块(yuoj-backend-model):很多服务公用的实体类

  3. 公用接口模块(yuoj-backend-service-client):只存放接口,不存放实现(多个服务之间要共享)

  4. 具体业务服务实现
    给所有业务服务引入公共依赖:

        <dependency>
            <groupId>com.yupi</groupId>
            <artifactId>yuoj-backend-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.yupi</groupId>
            <artifactId>yuoj-backend-model</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.yupi</groupId>
            <artifactId>yuoj-backend-service-client</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

服务内部调用
现在的问题是,题目服务依赖用户服务,但是代码已经分到不同的包,找不到对应的 Bean。可以使用 Open Feign 组件实现跨服务的远程调用。
解决方案:Open Feign
Open Feign 是一个基于 HTTP 的客户端,它可以让服务之间的调用变得更加简单。你只需要定义一个接口,Feign 会自动帮你处理底层的 HTTP 请求,你不需要关心服务的具体调用地址。

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
            <version>3.1.5</version>
        </dependency>

实现步骤
(1)梳理服务调用关系
用户服务:提供用户信息相关的接口,没有其他服务依赖。
userService.getById(userId):根据用户 ID 获取用户信息。
userService.getUserVO(user):获取脱敏的用户信息。
userService.listByIds(userIdSet):根据用户 ID 列表获取用户列表。
userService.isAdmin(loginUser):判断用户是否为管理员。
userService.getLoginUser(request):获取当前登录用户。
题目服务:依赖用户服务。
questionService.getById(questionId):根据题目 ID 获取题目信息。
questionSubmitService.getById(questionSubmitId):根据提交 ID 获取提交信息。
questionSubmitService.updateById(questionSubmitUpdate):更新提交信息。
判题服务:依赖题目服务。
judgeService.doJudge(questionSubmitId):执行判题操作。

先不想整理这个了 讨厌微服务

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2342331.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

豆包桌面版 1.47.4 可做浏览器,免安装绿色版

自己动手升级更新办法&#xff1a; 下载新版本后安装&#xff0c;把 C:\Users\用户名\AppData\Local\Doubao\Application 文件夹的文件&#xff0c;拷贝替换 DoubaoPortable\App\Doubao 文件夹的文件&#xff0c;就升级成功了。 再把安装的豆包彻底卸载就可以。 桌面版比网页版…

【MySQL】索引失效问题详解

目录 1. 最左前缀原则 2. 条件左边有函数或运算 3. 隐式类型转换 4. LIKE 模糊查询以 % 开头 5、MySQL 优化器选择全表扫描 ⭐对 in 关键字特别说明⭐ &#xff08;1&#xff09;列表太大时&#xff0c;走全表扫描了 &#xff08;2&#xff09;隐式类型转换 &#xff…

优选算法第十讲:字符串

优选算法第十讲&#xff1a;字符串 1.最长公共前缀2.最长回文子串3.二进制求和4.字符串相乘 1.最长公共前缀 2.最长回文子串 3.二进制求和 4.字符串相乘

【扣子Coze 智能体案例四】五行八卦占卜智能体

目录 一、意图识别 二、时间格式转换 三、八字转换 四、八字提取 五、八字提取2 六、数据汇总 七、统计五行占比 八、雷达图生成 九、表格生成 十、AI占卜 十一、结束节点 一、意图识别 用户输入的信息包含各种时间格式的年月日时 用户输入的信息包含天干地支八字…

5.学习笔记-SpringMVC(P61-P70)

SpringMVC-SSM整合-接口测试 (1)业务层接口使用junit接口做测试 (2)表现层用postman做接口测试 (3)事务处理— 1&#xff09;在SpringConfig.java&#xff0c;开启注解&#xff0c;是事务驱动 2&#xff09;配置事务管理器&#xff08;因为事务管理器是要配置数据源对象&…

【专题刷题】二分查找(一):深度解刨二分思想和二分模板

&#x1f4dd;前言说明&#xff1a; 本专栏主要记录本人的基础算法学习以及LeetCode刷题记录&#xff0c;按专题划分每题主要记录&#xff1a;&#xff08;1&#xff09;本人解法 本人屎山代码&#xff1b;&#xff08;2&#xff09;优质解法 优质代码&#xff1b;&#xff…

硬核解析!电动汽车能耗预测与续驶里程的关键技术研究

引言 随着电动汽车的普及,续航里程和能耗表现成为用户关注的核心痛点。然而,表显续航与实际续航的差异、低温环境下的电量衰减等问题始终困扰着消费者。本文基于《电动汽车能耗预测与续驶里程研究》的实验成果,深入剖析电动汽车能耗预测的核心模型、多环境测试方法及续航里…

【OceanBase相关】01-OceanBase数据库部署实践

文章目录 一、前言1、介绍说明2、部署方案二、部署说明1、环境准备2、软件安装2.1、安装OAT2.2、安装OCP3、软件部署三、集群管理1、MySQL租户管理四、Q&A1、OBServer 服务器重启后 observer 进程未能自动启动1.1、问题说明1.2、解决措施2、ERROR 1235 (0A000) at line 1: …

详细讲解 QMutex 线程锁和 QMutexLocker 自动锁的区别

详细讲解 QMutex 线程锁和 QMutexLocker 自动锁的区别 下面我们详细拆解 Qt 中用于线程同步的两个核心类&#xff1a;QMutex 和 QMutexLocker。 &#x1f9f1; 一、什么是 QMutex&#xff1f; QMutex 是 Qt 中的互斥锁&#xff08;mutex&#xff09;类&#xff0c;用于防止多个…

PCB 过孔铜厚的深入指南

***前言&#xff1a;在上一期的文章中介绍了PCB制造的工艺流程&#xff0c;但仍然想在过孔的铜厚和PCB的过孔厚径比两个方面再深入介绍。 PCB铜厚的定义 电路中铜的厚度以盎司(oz)**表示。那么&#xff0c;为什么用重量单位来表示厚度呢? 盎司(oz)的定义 将1盎司(28.35 克)的铜…

Spring Security认证流程

认证是Spring Security的核心功能之一&#xff0c;Spring Security所提供的认证可以更好地保护系统的隐私数据与资源&#xff0c;只有当用户的身份合法后方可访问该系统的资源。Spring Security提供了默认的认证相关配置&#xff0c;开发者也可以根据自己实际的环境进行自定义身…

TXPOLARITY/RXPOLARITY设置

TXPOLARITY/RXPOLARITY&#xff1a;该端口用来反向输出数据的极性。 0&#xff1a;表示不反向。TXP是正&#xff0c;TXN是负&#xff1b; 1&#xff1a;标识反向。TXP是负&#xff0c;TXN是正&#xff1b; 如下图所示&#xff1a;

2026届华为海思秋暑期IC实习秋招笔试真题(2025.04.23更新)

今天给大家分享下华为海思2025.04.23号最新IC笔试真题。 华为海思IC前端中后端(COT&XPU)岗位笔试机考题 更多华为海思数字IC岗秋招实习笔试真题&#xff0c;可以私信小编。 数字后端培训实战项目六大典型后端实现案例 秒杀数字后端实现中clock gating使能端setup viola…

优考试V4.20机构版【可注册】

优考试V4.20机构版&#xff0c;可通过注册机完美激活。 优考试机构版‌是一个功能强大的在线考试系统&#xff0c;适用于各种 考试场景&#xff0c;包括在线考试、培训、学习等多种用途。以下是优考试机构版的主要功能和特点&#xff1a; ‌多层级管理‌&#xff1a;优考试机…

携国家图书馆文创打造AI创意短片,阿里妈妈AIGC能力面向商家开放

在4月23日“世界读书日”之际&#xff0c;阿里妈妈联合国家图书馆文创正式发布了三条AI创意视频。 该系列视频以“千年文脉典籍奇谈”为主题&#xff0c;借助阿里妈妈的AIGC能力&#xff0c;以AI链接古今&#xff0c;打开阅读典籍新方式&#xff0c;引起不少人强烈兴趣。据悉&…

MMsegmentation第一弹-(认识与安装)

前言 在刚接触MMsegmentation的时候&#xff0c;我是怎么看都看不明白&#xff0c;那个过程实在是太痛苦了&#xff0c;所以我当时就想着一定要把这个写成文章&#xff0c;希望后来者能很轻松的就上手。该系列文章不涉及框架的底层原理&#xff0c;仅以一个使用者的身份带领读…

React19源码阅读之commitRoot

commitRoot入口 在finishConcurrentRender函数&#xff0c;commitRootWhenReady函数&#xff0c;commitRoot函数。 commitRoot流程图 commitRoot函数 commitRoot 函数是 React 渲染流程中用于提交根节点的关键函数。它的主要作用是设置相关的优先级和状态&#xff0c;然后调…

目标检测:视觉系统中的CNN-Transformer融合网络

一、背景 无人机&#xff08;UAVs&#xff09;在城市自动巡逻中发挥着重要作用&#xff0c;但它们在图像识别方面面临挑战&#xff0c;尤其是小目标检测和目标遮挡问题。此外&#xff0c;无人机的高速飞行要求检测系统具备实时处理能力。 为解决这些问题&#xff0c;我们提出…

Turso:一个基于 libSQL的分布式数据库

Turso 是一个完全托管的数据库平台&#xff0c;支持在一个组织中创建高达数十万个数据库&#xff0c;并且可以复制到任何地点&#xff0c;包括你自己的服务器&#xff0c;以实现微秒级的访问延迟。你可以通过Turso CLI&#xff08;命令行界面&#xff09;管理群组、数据库和API…

深度学习前沿 | TransNeXt:仿生聚合注意力引领视觉感知新时代

目录 1. 引言 2. 背景与挑战 3. TransNeXt 核心创新 3.1 像素聚合注意力&#xff08;PAA&#xff09; 3.2 长度缩放余弦注意力&#xff08;LSCA&#xff09; 3.3 卷积 GLU&#xff08;ConvGLU&#xff09; 4. 模型架构详解 5. 实验与性能评估 5.1 图像分类&#xff08;I…