在看这期之前,建议先看前三期:
Java 原生实现代码沙箱(OJ判题系统第1期)——设计思路、实现步骤、代码实现-CSDN博客
Java 原生实现代码沙箱之Java 程序安全控制(OJ判题系统第2期)——设计思路、实现步骤、代码实现-CSDN博客
Java 原生实现代码沙箱之代码沙箱 Docker 实现(OJ判题系统第3期)——设计思路、实现步骤、代码实现-CSDN博客
判题模块和代码沙箱的关系
判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱:只负责接受代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目 / 服务,提供给其他的需要执行代码的项目去使用)
代码沙箱架构开发
1.定义代码沙箱的接口,提高通用性
之后我们的项目代码只调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类 时,就不用去修改名称了, 便于扩展。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeResponse {private List<String> outputList;/*** 接口信息*/private String message;/*** 执行状态*/private Integer status;/*** 判题信息*/private JudgeInfo judgeInfo;
}
字段说明:
字段名 类型 含义说明 outputList
List<String>
每个测试用例对应的标准输出结果列表。例如 ["Hello World\n", "Error: ..."]
message
String
接口级别的提示信息,如编译错误、超时等。 status
Integer
执行状态码。例如:<br>1=成功<br>2=编译失败<br>3=运行时错误<br>4=超时 judgeInfo
JudgeInfo
更详细的判题信息,如最大内存占用、最大时间消耗等。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {private List<String> inputList;private String code;private String language;
}
字段说明:
字段名 类型 含义说明 inputList
List<String>
用户提供的多个输入用例列表。例如 ["1 2", "3 4"]
表示两个测试用例。code
String
用户提交的源代码字符串。比如一段 Java 程序。 language
String
用户选择的编程语言,如 "java"
、"python"
、"cpp"
等。
public interface CodeSandbox {/*** 执行代码** @param executeCodeRequest* @return*/ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest);
}
方法说明:
方法名 参数类型 返回类型 含义说明 executeCode
ExecuteCodeRequest
ExecuteCodeResponse
执行用户提交的代码,并返回执行结果
2.定义多种不同的代码沙箱实现
示例代码沙箱:仅为了跑通业务流程
// 使用 Lombok 的 @Slf4j 注解,自动生成一个日志对象 log,用于记录运行时信息(例如调试、错误等)
@Slf4j
public class ExampleCodeSandbox implements CodeSandbox {/*** 执行用户提交的代码,并返回执行结果。* 这是一个示例实现类,不实际执行任何真实代码,仅模拟响应结果。** @param executeCodeRequest 包含用户输入参数、代码内容、编程语言等的请求对象* @return ExecuteCodeResponse 返回执行结果,包含输出、状态码、提示信息、判题信息等*/@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {// 从请求对象中获取用户的输入用例列表// inputList 是一个 List<String>,表示多个测试用例的输入,如 ["1 2", "3 4"]List<String> inputList = executeCodeRequest.getInputList();// 创建一个空的 ExecuteCodeResponse 对象,用于封装并返回最终的执行结果ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();// 设置响应中的 outputList 字段// 在这个示例中,直接将输入用例原样作为输出结果返回,仅为演示使用executeCodeResponse.setOutputList(inputList);// 设置响应中的 message 字段,用于给调用者一个简要的执行结果说明// 在这个示例中,固定设置为“测试执行成功”executeCodeResponse.setMessage("测试执行成功");// 设置响应中的 status 字段,表示本次执行的状态// QuestionSubmitStatusEnum.SUCCEED.getValue() 表示成功的状态码,比如 1executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());// 创建一个 JudgeInfo 对象,用于封装更详细的执行信息,如内存占用、执行时间等JudgeInfo judgeInfo = new JudgeInfo();// 设置判题信息中的 message 字段// JudgeInfoMessageEnum.ACCEPTED.getText() 表示程序正确通过测试,例如 "Accepted"judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());// 设置判题信息中的 memory 字段,表示程序运行过程中使用的最大内存(单位:字节)// 示例中设为 100L,仅为演示judgeInfo.setMemory(100L);// 设置判题信息中的 time 字段,表示程序运行的总时间(单位:毫秒或微秒,视具体定义而定)// 示例中设为 100L,仅为演示judgeInfo.setTime(100L);// 将构建好的 judgeInfo 对象设置到 executeCodeResponse 中executeCodeResponse.setJudgeInfo(judgeInfo);// 最终返回完整的执行结果对象return executeCodeResponse;}
}
远程代码沙箱:实际调用接口的沙箱
public class RemoteCodeSandbox implements CodeSandbox {// 定义鉴权请求头名称,用于HTTP请求中携带认证信息private static final String AUTH_REQUEST_HEADER = "auth";// 定义鉴权密钥,作为身份验证的一部分发送到远程服务private static final String AUTH_REQUEST_SECRET = "secretKey";/*** 执行用户提交的代码,并返回执行结果。* 这个实现类通过调用远程服务来执行代码,并接收执行结果。** @param executeCodeRequest 包含用户输入参数、代码内容、编程语言等的请求对象* @return ExecuteCodeResponse 返回执行结果,包含输出、状态码、提示信息、判题信息等*/@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {// 打印日志,标识当前使用的是远程代码沙箱System.out.println("远程代码沙箱");// 定义远程服务的URL地址String url = "http://localhost:8090/executeCode";// 将executeCodeRequest对象转换为JSON字符串,便于通过HTTP请求传递String json = JSONUtil.toJsonStr(executeCodeRequest);// 发起POST请求到远程服务,并处理响应String responseStr = HttpUtil.createPost(url).header(AUTH_REQUEST_HEADER, AUTH_REQUEST_SECRET) // 设置请求头中的认证信息.body(json) // 设置请求体为JSON字符串.execute() // 发送请求并获取响应.body(); // 获取响应体内容(字符串形式)// 检查响应字符串是否为空或空白if (StringUtils.isBlank(responseStr)) {// 如果响应为空,则抛出业务异常,表示API请求错误throw new BusinessException(ErrorCode.API_REQUEST_ERROR, "executeCode remoteSandbox error, message = " + responseStr);}// 将响应字符串转换为ExecuteCodeResponse对象,并返回return JSONUtil.toBean(responseStr, ExecuteCodeResponse.class);}
}
第三方代码沙箱:调用网上现成的代码沙箱
public class ThirdPartyCodeSandbox implements CodeSandbox {@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {System.out.println("第三方代码沙箱");return null;}
}
编写单元测试,验证单个代码沙箱的执行
// 使用 SpringBootTest 注解表示这是一个 Spring Boot 测试类
// 会启动整个 Spring Boot 应用上下文,用于集成测试
@SpringBootTest
class CodeSandboxTest {/*** 从 application.yml 或 application.properties 中读取配置项:* codesandbox.type 的值,默认为 "example"* 可以根据该配置决定使用哪个具体的 CodeSandbox 实现类*/@Value("${codesandbox.type:example}")private String type;/*** 单元测试方法:测试 CodeSandbox 接口的 executeCode 方法是否能正常执行* 这里直接测试了 RemoteCodeSandbox 的实现*/@Testvoid executeCode() {// 创建一个远程代码沙箱实例(RemoteCodeSandbox)// 该类通过 HTTP 请求调用远程服务来执行用户的代码CodeSandbox codeSandbox = new RemoteCodeSandbox();// 定义一段示例代码字符串// 注意:这里虽然是 C/C++ 风格的 main 函数,但在实际使用中应传入符合语言类型的实际代码String code = "int main() { }";// 设置编程语言类型,从枚举 QuestionSubmitLanguageEnum 中获取 Java 对应的值// 例如可能是:"java"String language = QuestionSubmitLanguageEnum.JAVA.getValue();// 定义输入列表 inputList,模拟多个测试用例的输入参数// 每个元素是一个测试用例的输入,如 "1 2" 表示运行程序时的标准输入内容List<String> inputList = Arrays.asList("1 2", "3 4");// 构建 ExecuteCodeRequest 请求对象,封装所有必要的参数// 包括用户提交的代码、使用的编程语言、多个测试用例的输入ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder().code(code) // 用户提交的源代码.language(language) // 编程语言.inputList(inputList)// 输入参数列表.build(); // 构建请求对象// 调用 codeSandbox.executeCode 方法,向远程代码沙箱发送执行请求// 返回结果封装在 ExecuteCodeResponse 对象中ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);// 使用 JUnit 的 Assertions.assertNotNull 方法断言返回结果不为空// 如果为 null,则测试失败,说明 executeCode 方法未正确返回结果Assertions.assertNotNull(executeCodeResponse);}
}
工厂模式
问题背景:多个 CodeSandbox 实现类如何统一创建?
在你的在线判题系统中,可能有多种不同类型的代码沙箱实现:
沙箱类型 功能描述 ExampleCodeSandbox
示例沙箱,模拟执行结果,适用于开发测试 RemoteCodeSandbox
远程沙箱,通过 HTTP 请求调用远程服务执行代码 ThirdPartyCodeSandbox
第三方沙箱,调用其他平台 API 执行代码 当你需要根据配置或用户输入动态选择不同的沙箱时,就面临以下问题:
❌ 如果不用工厂模式,会出现什么问题?
调用者需要知道所有实现类
- 调用方必须了解每个具体的实现类,并根据条件手动 new 出来。
- 增加了耦合度,不利于扩展。
创建逻辑分散
- 创建对象的逻辑分布在多个地方,修改起来麻烦。
- 容易出错,维护成本高。
不便于统一管理
- 比如想统一记录日志、做权限控制等,都得在每个地方写一遍。
解决方案:使用工厂模式集中管理对象创建
为了解决上述问题,我们可以使用 工厂模式(Factory Pattern)。
💡 什么是工厂模式?
工厂模式是一种创建型设计模式,用于封装对象的创建过程。它将对象的创建和使用解耦,使得客户端无需关心具体实现类,只需告诉工厂“我要什么”,就能得到对应的实例。
📈 工厂模式的优势:
优势 描述 ✅ 解耦 调用者不需要知道具体类名,只需要一个标识符(如字符串) ✅ 易于扩展 新增沙箱类型时,只需修改工厂类,符合开闭原则 ✅ 统一管理 可以在工厂中加入统一逻辑(如日志、缓存、异常处理) ✅ 简化调用 调用者使用方式简单,不需要重复写 if-else 或 switch-case
使用工厂模式,根据用户传入的字符参数(沙箱类别),来生成对应的代码沙箱实现类
此处使用静态工厂模式
/*** 代码沙箱工厂类(根据传入的类型字符串创建对应的代码沙箱实例)** 工厂模式是一种常见的设计模式,用于统一管理对象的创建过程。* 通过该工厂类,可以根据配置动态决定使用哪种 CodeSandbox 实现。*/
public class CodeSandboxFactory {/*** 根据指定的沙箱类型创建并返回对应的 CodeSandbox 实例。** @param type 沙箱类型,支持:"example"、"remote"、"thirdParty"* 如果不匹配任何类型,默认返回 ExampleCodeSandbox* @return CodeSandbox 返回一个实现了 CodeSandbox 接口的对象*/public static CodeSandbox newInstance(String type) {// 使用 switch-case 结构判断传入的类型字符串,并返回对应的实现类实例switch (type) {// 如果类型是 "example",返回示例代码沙箱(模拟执行结果,不实际运行代码)case "example":return new ExampleCodeSandbox();// 如果类型是 "remote",返回远程代码沙箱(调用远程服务执行代码)case "remote":return new RemoteCodeSandbox();// 如果类型是 "thirdParty",返回第三方代码沙箱(可能调用外部平台 API)case "thirdParty":return new ThirdPartyCodeSandbox();// 默认情况:如果传入的类型不匹配以上任意一种,返回示例代码沙箱作为兜底方案default:return new ExampleCodeSandbox();}}
}
由此,我们可以根据字符串动态生成实例,提高了通用性:
public static void main(String[] args) {// 创建一个 Scanner 对象,用于从标准输入(控制台)读取用户输入内容Scanner scanner = new Scanner(System.in);// 进入一个无限循环,持续监听用户的输入,直到手动终止程序while (scanner.hasNext()) {// 从控制台读取下一个字符串作为沙箱类型(例如:"example"、"remote"、"thirdParty")String type = scanner.next();// 使用 CodeSandboxFactory 工厂类根据用户输入的类型创建对应的代码沙箱实例CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);// 定义一段示例代码字符串(注意:这里虽然是 C/C++ 风格的 main 函数,但语言设置为 Java)// 实际使用时应确保 code 和 language 字段匹配,否则可能执行失败String code = "int main() { }";// 设置编程语言类型,从枚举 QuestionSubmitLanguageEnum 中获取 Java 的值// 例如可能是:"java"String language = QuestionSubmitLanguageEnum.JAVA.getValue();// 定义输入列表 inputList,模拟多个测试用例的输入参数// 每个元素是一个测试用例的输入,如 "1 2" 表示运行程序时的标准输入内容List<String> inputList = Arrays.asList("1 2", "3 4");// 构建 ExecuteCodeRequest 请求对象,封装所有必要的参数// 包括用户提交的代码、使用的编程语言、多个测试用例的输入ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder().code(code) // 用户提交的源代码.language(language) // 编程语言.inputList(inputList)// 输入参数列表.build(); // 构建请求对象// 调用 codeSandbox.executeCode 方法,向指定的代码沙箱发送执行请求// 返回结果封装在 ExecuteCodeResponse 对象中ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);// 此处没有输出或断言,只是简单调用 executeCode 方法// 在实际调试中可以加入 System.out.println 或日志打印,观察返回结果}
}
工厂模式适用场景总结
场景 是否适合用工厂 多个相似类的选择创建 ✅ 适合 对象创建过程复杂 ✅ 适合 需要隐藏具体类名 ✅ 适合 希望统一管理创建逻辑 ✅ 适合 提供默认实现兜底机制 ✅ 适合
最终总结一句话:
工厂模式就像一个智能制造车间,你只需要告诉它“生产哪种型号的产品”,它就会自动返回合适的实例,而你完全不需要关心它是怎么造出来的。
代理模式
问题背景:代码沙箱调用前后的日志记录需求
在开发一个在线判题系统(OJ)时,我们经常需要对代码沙箱的调用行为进行监控和日志记录。比如:
- 在调用代码沙箱之前,记录用户传入的请求参数(如代码内容、输入数据等)。
- 在调用代码沙箱之后,记录返回结果(如输出内容、执行状态、资源占用等)。
- 这些日志对于后续的调试、审计、性能分析都非常有用。
❌ 如果不做统一处理,会出现什么问题?
假设我们有多个
CodeSandbox
实现类(例如:ExampleCodeSandbox
、RemoteCodeSandbox
、ThirdPartyCodeSandbox
),如果我们在每个实现类中都手动添加日志代码,就会出现以下问题:
问题 描述 ✅ 重复代码多 每个类都要写一遍相同的日志打印逻辑,造成代码冗余 ✅ 扩展性差 后续如果要增加新的功能(如统计耗时、权限校验等),需要修改所有类 ✅ 职责不单一 日志记录属于通用逻辑,不应该污染核心业务逻辑(执行代码)
解决方案:使用代理模式统一增强能力
为了解决上述问题,我们可以使用 代理模式(Proxy Pattern)。
💡 什么是代理模式?
代理模式是一种结构型设计模式,用于控制对某个对象的访问,通常用来增强其功能,而不改变其接口或调用方式。
📈 代理模式的优势:
不改变原有类的代码:原有的
CodeSandbox
实现类不需要做任何改动。不改变调用者的行为:调用者依然通过
codeSandbox.executeCode(...)
的方式调用,透明无感知。集中管理增强逻辑:如日志记录、权限控制、耗时统计等功能,都可以统一放在代理类中。
具体实现思路
// 使用 Lombok 的 @Slf4j 注解,自动生成一个日志对象 log(类型为 org.slf4j.Logger)
// 可以直接使用 log.info(...) 来打印日志信息,无需手动创建 logger 实例
@Slf4j
public class CodeSandboxProxy implements CodeSandbox {// 被代理的真实代码沙箱对象(被包装的对象)// 所有的 executeCode 请求最终都会委托给这个对象来处理private final CodeSandbox codeSandbox;/*** 构造函数,用于创建代理对象时传入真实的代码沙箱实例** @param codeSandbox 真实的代码沙箱对象,例如 ExampleCodeSandbox、RemoteCodeSandbox 等*/public CodeSandboxProxy(CodeSandbox codeSandbox) {this.codeSandbox = codeSandbox;}/*** 重写 CodeSandbox 接口中的 executeCode 方法* 这是一个代理方法:在调用真实对象前后添加了额外的功能(如日志记录)** @param executeCodeRequest 用户提交的执行代码请求对象,包含代码内容、输入数据、语言等* @return ExecuteCodeResponse 执行结果响应对象,包含输出内容、状态码、判题信息等*/@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {// 【前置增强】在执行代码沙箱前,打印请求参数日志// 便于管理员查看用户提交了什么代码、输入是什么、使用的编程语言是什么// 使用 log.info 记录日志,方便后续调试和问题追踪log.info("代码沙箱请求信息:" + executeCodeRequest.toString());// 【核心功能】将用户的请求交给真实的代码沙箱对象去执行// 这里并没有改变原来的业务逻辑,只是在调用前后增加了日志记录// executeCodeResponse 是真实沙箱返回的结果ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);// 【后置增强】在代码沙箱执行完成后,打印响应结果日志// 包括输出内容、执行状态、判题信息等,方便管理员分析运行情况log.info("代码沙箱响应信息:" + executeCodeResponse.toString());// 将执行结果原样返回给调用者,保持接口的一致性return executeCodeResponse;}
}
代理模式适用场景
场景 是否适合用代理 简要说明 控制对象访问 ✅ 适合 通过代理控制对对象的访问权限,比如只有登录用户才能执行某些操作。 增强对象功能 ✅ 适合 在不修改原对象的前提下,为其添加额外功能,如日志记录、性能统计等。 远程调用代理 ✅ 适合 为远程服务提供本地代理,屏蔽网络通信细节,如远程代码沙箱调用。 延迟加载(虚拟代理) ✅ 适合 只有在真正需要时才创建和加载资源,提高系统性能,例如图片懒加载。 缓存结果 ✅ 适合 代理可先检查缓存是否存在结果,存在则直接返回,避免重复计算或请求。
代理模式的核心价值总结
代理模式就像给目标对象穿上一件“智能外衣”,不仅让它保持原有的行为不变,还能在其前后增加各种增强逻辑(如日志记录、权限校验、性能监控),而且这一切都是透明的,不需要修改目标对象本身。
代理模式的优势总结
优势 描述 ✅ 解耦 调用者无需关心具体实现细节,只需通过代理进行操作 ✅ 易于扩展 新增功能时只需修改代理类,符合开闭原则 ✅ 统一管理 可以在代理中集中处理公共逻辑(如日志、权限、性能统计) ✅ 简化调用 调用者使用方式简单,不需要重复写相同的逻辑