OJ 单机版沙箱
08-单机版沙箱
Docker沙箱改造
1.模板方法优化代码沙箱
先确认设计模式的应用场景和现有的业务场景是否匹配
模板方法概念
模版方法:定义一套通用的执行流程,让子类负责每个执行步骤的具体实现
模版方法的适用场景:适用于有规范的流程,且执行流程可以复用
作用:大幅节省重复代码量,便于项目扩展、更好维护
对比JavaNativeCodeSandbox、JavaDockerCodeSandbox的方法实现流程,基本都是遵循同样的一套思路,只不过可能每个步骤的实现细节可能有所不同
- 抽离一套通用的执行流程
- 能复用的尽量复用,不能复用的让子类自行进行扩展
定义一个模板方法抽象类:JavaCodeSandboxTemplate(定义一套通用的执行流程),先复制具体的实现类,把代码从完整的方法中抽离成一个子写法
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(因为RunFile的执行方式不一样,需要重写)
public class JavaDockerCodeSandbox extends JavaCodeSandboxTemplate{
private static final long TIME_OUT = 5000L;
private static final Boolean FIRST_INIT = true;
/**
* 3、创建容器,把文件复制到容器内
* @param userCodeFile
* @param inputList
* @return
*/
@Override
public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
...... 业务逻辑 ......
}
}
2.给代码沙箱提供API
直接在controller层暴露接口(CondeSandbox定义的接口实现)
@PostMapping("/executeCode")
ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest) {
if (executeCodeRequest == null) {
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandbox.executeCode(executeCodeRequest);
}
oj-platform-backend:改造远程调用代码沙箱的调用RemoteCodeSandbox
/**
* 远程代码沙箱(实际调用接口的沙箱)
*/
public class RemoteCodeSandbox implements CodeSandbox {
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
// ----- 修改为调用远程代码沙箱接口进行响应 ------
// System.out.println("远程代码沙箱");
// return null;
}
}
调用安全性改造:如果将服务不做任何的权限校验,直接发到公网,是不安全的。
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);
}
2)API签名认证
给允许调用的角色分配AK、SK,然后分别校验这两组key是否匹配
3.跑通业务流程
代码沙箱测试
代码沙箱测试:通过CodeSandboxTest进行测试,注意调整application.yml中的配置(codesandbox.type)为remote配置
页面测试
需确保题目设定的正确性(测试用例的正确性以及输出结果校验正确性),如果题目设定异常,则在编译执行代码的时候就会出现错误(例如数组越界异常等情况)
以Main.java的测试案例为例子,输入两个数字,输出两数之和,则输入的内容应该为1 2
,输出为3
,然后在代码沙箱中设置断点查看数据是否正常
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));
}
}
如果此处输入的测试用例不满足规则限定,例如多输入了一些内容或者其他,则在实际代码处理的时候就会报错(例如数组越界等)