OJ判题系统小总结
总结一:OJ判题系统大致流程
1.前端发起‘提交题目’请求。
2.然后后端questionController题目服务(question和questionSubmit封装到一个控制器了,属于一个服务)中的/submit/add方法映射到这个请求。
3**.题目服务:**简单的进行参数的校验(比如用户选择的语言、代码是否为空、相关题目是否存在)以及用户权限的校验;校验完毕之后使用消息队列,传递一个quetsionSubmitId给判题服务
4.判题服务: 接收到questionSubmitId之后,查询相关的题目提交信息和题目信息,判空;然后判断题目信息的状态是否为:'等待判题',如果已经为‘判题中’就不需要再去处理。还要对评测用例进行判断,输入用例和输出用例是否为空。然后修改我们的题目提交信息状态为'判题中',然后调用代码沙箱进行处理!
补充:这里在选择调用哪个种类的代码沙箱时,使用了一个静态工厂类根据type判断;然后使用代理类进行增强处理(主要是一些输出一些日志文件),
5.调用代码沙箱: 在调用我们的代码沙箱中通过API数字签名做了一个用户的认证,第一道防线,可以防止我们的代码沙箱被外人调用; 然后开始调用第三方API代码沙箱
6.代码沙箱中的逻辑 :
6.1:首先构建一个存放用户代码的文件夹,通过获取当前代码的根目录,然后创建一个公共的父文件夹来存储,然后通过UUID构造路径的后半部分,然后再在末尾加上Main.java
6.2:开始编译,通过 javac -encoding utf-8 ${filePath} 命令来文件进行编译,然后将编译的结果compileProgess传到封装好的函数中进行处理。
6.3:
▼java复制代码//Process compileProgess=Runtime.getRuntime.exec(compileCmd) //0.如果编译成功 int exitValue=compileProgess.waitFor(); ////这个是编译或者运行返回的一个类,运行的话会包含time\memory等信息 ExecuteMessage msg=new ExecuteMessage(); if(exitValue==0){ //1.编译输出的内容以输入流的形式读取出来! InputStream stream=compileProgess.getInputStream(); //2.创建一个读取工具类,准备将流中的数据读取出来。 BufferedReader bufferedReader=new BufferedReader(new InputStreamReader(stream)); //3.逐行读取 StringBuilder sb=new StringBuilder(); String lineMsg; while((lineMsg=sb.readLine()!=null){ System.out.println(lineMsg); sb.append(lineMsg); } //4.存储到我们要返回的messgae中。 msg.setMesssage(sb.toString); }else{ //如果遇到异常状况的话,我们先把编译输出的内容存储起来, //和上面的情况类似,只是这次是存储到errorMessage中 ... ... ... msg.setMessage(sb.toStirng()); } return msg;
6.4 编译完毕之后,就要开始运行代码了;切记这里执行运行命令的时候,是通过循环遍历每一个输入用例通过cmd命令:
▼bash复制代码java -Dfile.encodind=utf-8 -cp ${classPath} Main ${args}
使用runProcess(Process类型的)变量,继续调用我们处理流的方法,将runProcess的输出内容获取出来,也就是我们的输出用例,然后收集到一个List集合里面。
6.5 从List集合中比较案例运行的时间,选取一个耗时最长的,添加到我们的返回信息中
7.判题服务:然后继续返回到我们的判题服务中,对得到的输出信息进行判断。如果我们输出的信息和预期的答案都相同的话,那就代表这个代码是成功的!
判题这块使用了一个策略设计方式进行处理!因为我们对于不同的语言需要不同的判题策略来处理!我们设置了默认的判题策略、Java的判题策略。在策略类中只需要判断语言的类型是什么样的,然后选择具体的策略进行处理
二、总结二:OJ判题系统中几种设计模式的使用
1.工厂模式+代理:
在对代码沙箱种类选择的时候,本地的?远程的?还是第三方他人的?一个父类,三种实现,这时候就需要我们使用静态工厂来选择了。
不再需要if else多重嵌套处理,这样代码看起来太恶心了。只需要我们提供一个参数type,根据我们传递参数进行判断具体创建什么类。
这个type可以是一个静态常量类,也可以定义成一个枚举量,也可以存储在我我们的application.yaml中,使用@value注解来引用
静态工厂类的好处:
首先,静态工厂类将创建对象的代码都统一到一起,不需要再分散零落在代码的各个地方,这样可以降低耦合!
其次,假如我们的创建对象的代码需要修改,仅仅在静态工厂类中统一做修改就行,不需要再去代码中一个一个修改。
最后,提高了代码的复用率,避免了重复的代码去创建对象!
▼java复制代码//首先传入一个type参数,工厂模式根据参数来创建我们需要的代码沙箱类! CodeSendBox codeSendBox = CodeSendBoxFactory.createCodeSendBox(type); //然后再将codeSendBox作为参数,codeSendBoxProxy会将调用代码沙箱的方法再做一次封装,加一些打印日志 CodeSendBoxProxy codeSendBoxProxy = new CodeSendBoxProxy(codeSendBox);
2.模板设计模式
模板设计模式是用在:代码沙箱具体实现的过程中(设计到了oj-sandbox服务)。
在我们实现本地代码沙箱和运行在docker上的代码沙箱时候,我们发现有大量的重复代码,这时候不用个模板设计模式,手都痒痒。
1.第一步,将整个步骤都抽取出来
a.创建代码保存的文件路径。(System.getProperty("user.dir"),然后拼接公共目录名,使用uuid拼接每一个code对应的文件名,然后在最后加一个Main.java即可)
b.编译代码,Runtime.getRunTime().exec(compileCmd),然后对输出的内容进行处理,通过流的形式读取
c.运行代码,Runtime.getRunTime().exec(runCmd),同样的原理
d.对运行代码步骤中的输出结果进行处理,如果返回的是错误信息,直接将错误信息的值给到executeCodeResponse中,然后直接将状态置为失败;还有找到最大的运行时间;
e.删除文件,将我们创建的临时文件进行删除(我们只需要使用到运行结果),如果不删除的话,可能会占用很多的内存。
2.第二步,找到公共代码的部分
a.b.e这三步的代码是相同的,因此可以服用!因此模板方法中需要抽象的方法只有c.d两个方法
3.第三步:分别实现不同种类的代码沙箱对应的c.d方法。(需要继承模板类,然后实现他们的抽象方法)
4.调用的时候,只需要根据用户的选择,利用java的多态特性去创造用户想要的xx
3.策略设计模式
策略设计模式是用在:对代码沙箱处理返回的类ExecuteCodeResponse进行处理,然后最后封装一个类返回给controller。
▼java复制代码//1.代码执行情况 private JudgeInfo judgeInfo; //2.代码执行其它信息 private String message; //3.执行状态? private Integer status; //4.输出用例 private List<String> output;
针对不同的编码语言,我们会有不同的判断策略。那因此我们实现的方案也就不一样。
三、总结三:实现的一些扩展点!
1.通过sentinel进行限流降级处理!
通过整合Sentinel+gateway网关来控制访问整个微服务请求。当并发请求量达到一个峰值时,可能会导致请求失败的概率提高。当在一段时间内的失败率达到20%或者失败次数达到xx时,就会启动熔断策略+降级处理。
2.线程池+异步并发处理mq中的消息
定义动态线程池,使用了CompletableFuture类,调用了它的runAsync/supplyAsync方法,对消息异步处理。
3.添加用户级锁防止用户恶意提交代码,恶意调用代码沙箱接口。
实现方案一:
通过ConCurrentHashMap存储用户的id,底层是基于cas锁(尽量和自己会的贴合起来,到时候诱导面试官问你会的问题),Synchronized(lock)来实现
实现方案二:
使用原子类Atomic,争取不到就自旋去吧。
▼java复制代码//3.进行改造 存放到消息队列中 可以使用ConcurrentHashMap/或者是Atomic原子类 //3.1 实现方法1. 使用ConcurrentHashMap+弱引用类型 防止内存泄露 // ConcurrentHashMap // ConcurrentHashMap<Long,Object> userLocks=new ConcurrentHashMap<>(); // Object userLock = userLocks.computeIfAbsent(loginUser.getId(), k->new WeakReference<>(new Object()).get()); // synchronized (userLock){ // try { // System.out.println("有一个用户进入了!"); // myMessageProducer.sendMessage(EXCHANGE_NAME,ROUTING_KEY,String.valueOf(questionSubmit.getId())); // } catch (Exception e) { // throw new RuntimeException(e); // } // } //3.2实现方法2 使用Atomic原子类加锁 如果没有就自旋 userAtomicReference=new AtomicReference<>(); long userSpins=0L; while(userSpins<MAX_SPINS){ System.out.println("我是用户:"+loginUser.getId()+",我是第"+userSpins+"次自旋"); if(userAtomicReference.compareAndSet(null,loginUser)){ System.out.println("我获取到了锁"+System.currentTimeMillis()); try { myMessageProducer.sendMessage(EXCHANGE_NAME,ROUTING_KEY,String.valueOf(questionSubmit.getId())); } catch (Exception e) { throw new RuntimeException(e); } finally { //释放锁资源 userAtomicReference.set(null); } break; } userSpins++; Thread.yield(); } if(userSpins==MAX_SPINS){ log.info("用户"+loginUser.getId()+"自旋超时,未能获取锁"); throw new BusinessException(ErrorCode.SYSTEM_ERROR,"获取超时,请稍后重试"); }
4.API数字签名
5.使用JWT令牌,在gateway做权限校验
