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的判题策略。在策略类中只需要判断语言的类型是什么样的,然后选择具体的策略进行处理

image.png

二、总结二: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做权限校验

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
如何呢
作者分享
AI零代码应用生成《第四期》总结!
4
腾讯云智Timeline
23
如何选择呢??先说一下我自身的一个情况,目前是大三学生,前端掌握vue、react框架,独立开发没什么问题,但是如果要用css美化页面可能会很吃力;然后后端就是Java开发。然后目前有两个选择。 第一个是游戏公司,base上海,开发内容:后端需要使用NodeJs写一些比较简单的接口,前端会用框架进行开发,可能还涉及到根据psd设计页面。和自己学的专业不太对口。 第二个是一个外包,base北京,开发内容和掌握的技术栈完全对口。薪资待遇会比第一个公司要好。 现在困扰我的点就是:选择第一个的话,公司比较有名气,可能会有点说服力,但是产出成果感觉不是太多;选择第二个的话,实习经历中可能不太亮眼,因为是一个外包公司,但是实习产出成果会更好。
4
分片上传、断点上传
7
今天学习了JVM的一些内容,还是有难度的,面试鸭上面没找到对象的创建过程,那就在这默写一下对象的创建过程,以及类的加载过程吧! 一、对象的创建过程 1.当我们遇到new关键字、反射、或者初始化父类等情况时,就要执行对象的创建过程,首先根据对象的全限定类名去常量池中寻找符号引用,然后需要检查这个符号引用代表的类有没有被加载过(类加载器会维护一个已经加载过的类),如果没有的话,就需要进行类的加载过程,如果有的话就可以直接获取到类的直接引用。(类的加载过程在下面写) 2.类加载检查(这一步可以确保我们的类已经被加载到方法区中了)完成之后,JVM就会在堆内存中给对象分配内存,一般是在Eden区域中。 2.1然后这里分配内存的方式有两种,一种是指针碰撞,另一种是空闲列表。可能从名字上我们看不出来这两种的区别是什么,但实际上这两种内存的分配方式是根据堆内存是否规整来决定的,堆内存是否规整和我们的垃圾回收算法有关系(标记复制/标记压缩->没有内存碎片,标记清除->由内存碎片)。 先说一下指针碰撞,应用场景是在内存规整的堆内存中,通过一个指针将数据区和空闲区分隔开,当需要给对象开辟一块内存时,只需要将指针在空闲区移该对象内存大小的距离即可。 再来说空闲列表,JVM会通过一个空闲列表来维护我们可用的内存,应用场景是在内存不规整的堆内存中,当需要给对象开辟一块内存时,需要查找足够大的一个内存块给对象分配内存 2.2 在分配内存的时候,可能还会遇到线程安全问题(可能会存在多个线程同时执行分配内存操作,并且分配到了相同地址的内存上面)。 遇到线程安全问题,应该如何解决呢?有两种办法,第一种就是通过+CAS锁,如果竞争失败就去自旋;第二种是通过TLAB,JVM会预先给每一个线程在Eden区域中分配一定的空间,如果创建的对象需要分配的内存不够之后,在通过cas锁去竞争。 3.内存分配完成之后,需要给对象实例上的属性赋初始值,直接在堆内存中该对象实例的属性上赋值即可。 4.给对象的对象头设置相关信息(JMM对象内存),对象内存模型包括对象头(Mark Word对象标记、类元信息(指针))、实例数据、对齐填充。需要给对象头中的对象标记设置相关的类信息、GC分代年龄等信息,也可以添加偏向锁状态等。 5.执行init方法,对于JVM来说,一个新的对象就已经创建了, 就可以去执行构造参数对该类的属性进行具体赋值了。 二、类的加载过程 类的加载过程(主要分为三个阶段,加载阶段、连接阶段、初始化阶段) 1.加载阶段,根据类的全限定类名,获取到对应的.class字节码文件,然后交给类加载器根据双亲委派机制去加载相应的类信息,并且把类的相关信息存储在方法区中,然后把Classs对象存储在堆内存中,作为访问入口。(在这个阶段还要对class字节码文件的格式进行校验) 2.连接阶段,这个阶段分为三部分,验证、准备、解析。连接阶段顾名思义就是将我们的class字节码文件和JVM运行时环境进行连接。 2.1 验证阶段,要对class字节码文件进行验证,比如说加载阶段中的文件格式的校验、还有元数据的校验(对字节码描述信息做语义分析,并且判断是否继承了xx父类,实现了xx接口)、对字节码的校验(通过对数据流和控制流分析,确保程序不会伤害虚拟机) 2.2 准备阶段,在这个阶段中,会给静态变量分配空间,然后赋默认的初始值(这里会更倾向于分配空间)。如果是被final修饰的static变量,在编译的阶段就已经分配好空间了。 2.3 解析阶段,解析阶段是将运行时常量池中的符号引用解析为直接引用。符号引用一般是通过全限定类名来代替某个对象的具体地址的,然后直接引用是通过指针或者句柄来指向对象在堆中的一个具体地址。比如说类A在加载的过程中,对类B有一个引用,但是只是符号引用,然后就会先让类B进行一个类加载,加载完成之后就可以获取到它在堆内存中存储的地址,然后直接引用即可。 3.初始化阶段,在这个阶段中就是对静态变量进行一个赋值,然后执行静态代码块。
6
下载 APP