当前位置:   article > 正文

OJ 网上判题系统_oj判题系统

oj判题系统

信息

基于 Spring Boot + Spring Cloud 微服务 + Docker(+ Vue 3 + Arco Design)的编程题目在线评测系统。在系统前台,管理员可以创建、管理题目;用户可以自由搜索题目、阅读题目、编写并提交代码。在系统后端,能够根据管理员设定的题目测试用例在 自主实现的代码沙箱 中对代码进行编译、运行、判断输出是否正确。其中,代码沙箱可以作为独立服务,提供给其他开发者使用。

前端

  1. 基于Vue3+ArcoDesign组件库,自主实现了在线做题、题目检索和管理、提交列表、用户登录等页面。
  2. 使用Vue-CLI脚手架初始化项自,并自行开发了全局页面布局和通用前端项目模板,便于后续复用。
  3. 使用TypeScript+ESLint+Prettier+Husky保证项目编码和提交规范,提高项目的质量。(虽然是由脚手架自动帮你整合了,但你要知道这些技术各自的作用)
  4. 全局导航生成:基于VueRouter的路由配置文件自动生成导航菜单,并通过给路由的meta属性增加hidden字段实现集中控制页面的显隐。
  5. 全局权限管理:通过给VueRouter路由的meta属性增加access字段来定义页面权限,然后通过beforeEach全局路由守卫集中校验用户进入页面的权限,并进一步将权限管理相关代码统一封装为access.ts模块,简化用户使便用。
  6. 全局状态管理:基于Vuex定义UserModule实现了对登录用户的状态存储,并通过组合式API(useStore)在页面中访问用户信息。
  7. 前后端联调:使用openapi-typescript-codegen工具根据后端Swagger接口文档自动生成请求后端的代码,大幅提高开发效率。
  8. 为提高前端开发效率,使用IDEA的LiveTemplates功能自定义了一套基础前端代码模板,能够通过快捷键高效生成代码。
  9. 选用ByteMD开源Markdown文本编辑器组件,引入gfm插件(支持表格语法)并进一步自行封装了可复用的Editor和Viewer,实现了题目内容及答案的编辑功能。
  10. 基于Webpack整合了MonacoEditor开源代码编辑器组件,并进一步基于ref自行封装了可复用的Editor和Viewer,实现了用户编写代码功能,支持多种语言的高亮。
  11. 使用ArcoDesign的Table组件实现了题目检索页面,并通过自定义插槽将后端返回的JSON数据解析为美观的格式。



后端

  1. 系统架构:根据功能职责,将系统划分为负责核心业务的后端模块、负责校验结果的判题模块、负责编译执行代码的可复用代码沙箱。各模块相互独立,通过API接口和分包的方式实现协作。
  2. 库表设计:根据业务流程自主设计用户表、题目表、题目提交表,并通过给题目表添加userId索引提升检索性能。(感兴趣的同学可以自己测试一下性能的提高比例)
  3. 自主设计判题机模块的架构,定义了代码沙箱的抽象调用接口和多种实现类(比如远程/第三方代码沙箱),并通过静态工厂模式+Spring配置化的方式实现了对多种代码沙箱的灵活调用。
  4. 使用代理模式对代码沙箱接口进行能力增强,统一实现了对代码沙箱调用前后的日志记录,减少重复代码。
  5. 由于判题逻辑复杂,且不同题目的判题算法可能不同(比如Java题目额外增加空间限制),选用策略模式代替if-else独立封装了不同语言的判题算法,提高系统的可维护性。
  6. 使用JavaRuntime对象的exec方法实现了对Java程序的编译和执行,并通过Process类的输入流获取执行结果,实现了Java原生代码沙箱。
  7. 通过编写Java脚本自测代码沙箱,模拟了多种程序异常情况并针对性解决,如使用守护线程+Thread.sleep等待机制实现了对进程的超时中断、使用VM-Xmx参数限制用户程序占用的最大堆内存、使用黑白名单+字典树的方式实现了对敏感操作的限制。(选1-2种即可)
  8. 使用Java安全管理器和自定义的SecurityManager对用户提交的代码进行权限控制,比如关闭写文件、执行文件权限,进一步提升了代码沙箱的安全性。
  9. 为保证沙箱宿主机的稳定性,选用Docker隔离用户代码,使用DockerJava库创建容器隔离执行代码,并通过网络和Docker进行传参交互,从而实现了更安全的代码沙箱。
  10. 使用VMware Workstation虚拟机软件搭建Ubuntu Linux+Docker环境,并通过JetBrains Client连接虚拟机进行实时远程开发,提高了开发效率。
  11. 为提高Docker代码沙箱的安全性,通过HostConfig限制了容器的内存限制和网络隔离,并通过设置容器执行超时时间解决资源未及时释放的问题。
  12. 由于Java原生和Docker代码沙箱的实现流程完全一致(编译、执行、获取输出、清理),选用模板方法模式定义了一套标准的流程,让子类自行扩展部分流程,提高代码一致性并大幅简化冗余代码。
  13. 为防止用户恶意请求代码沙箱服务,采用API签名认证的方式,给调用端分配签名密钥,并通过校验请求头中的密钥保证了API调用安全。
  14. 为保证项目各模块的稳定性,选用Spring Cloud Alibaba重构单体项目,使用Redis分布式Session存储登录用户信息,并将项目划分为用户服务、题目服务、判题服务、公共模块。
  15. 使用阿里云原生脚手架初始化微服务项目,并结合Maven子交模块的配置,保证了微服务各模块依赖的版本一致性,避免依赖冲突。
  16. 通过工具(JetBrains的FindUsage功能+表格整理)梳理微服务间的调用关系,并通过Nacos+OpenFeign实现了各模块之间的相互调用,如判题服务调用题目服务来获取题目信息。
  17. 使用Spring Cloud Gateway对各服务接口进行聚合和路由,保护服务的同时简化了客户端的调用(前端不用根据业务请求不同端口的服务),并通过自定义CorsWebFilterBean全局解决了跨域问题。
  18. 使用Knife4j Gateway在网关层实现了对各服务Swagger接口文档的统一聚合,无需通过切换地址查看各服务的文档。
  19. 为保护内部服务接口,给接口路径统一设置inner前缀,并通过在网关自定义GlobalFilter(全局请求拦截器)实现对内部请求的检测和拦截,集中解决了权限校验问题。
  20. 为防止判题操作执行时间较长,系统选用异步的方式,在题目服务中将用户提交Id发送给RabbitMQ消息队列,并通过Direct交换机转发给判题队列,由判题服务进行消费,异步更新提交状态。相比于同步,响应时长由xx秒减少至xx秒,且系统性能提升了xx%(需要自己使用JMeter等工具进行测试)。
  21. 基于自己二次开发的SpringBoot初始化模板+MyBatisX插件,快速生成图表、用户数据的增删改查。



项目截图

登录界面
在这里插入图片描述

浏览题目
在这里插入图片描述

提交题目界面
在这里插入图片描述

创建题目界面
在这里插入图片描述

管理题目界面
在这里插入图片描述

做题界面
在这里插入图片描述


1. OJ 创造前言

Git上传文件到github:
https://blog.csdn.net/qq_54026286/article/details/129292346
https://zhuanlan.zhihu.com/p/601907342
https://zhuanlan.zhihu.com/p/655747166

OJ = Online Judge 在线判题评测系统
知名大学都会有自己的oj判题系统,搜索[大学名oj]

OJ系统的做大难点在于 判题系统
用于在线评测编程题目代码的系统,能够根据用户提交的代码,出题人预先设置的题目和输出用例,进行编译代码,运行代码,判断代码运行结果是否正确
判题系统作为一个开放API提供给大家,便于开发者开发自己的OJ系统

OJ系统的常用概念

题目限制:时间限制,内存限制
题目介绍
题目输入
题目输出
题目输入用例
题目输出用例

普通测评:管理员设置题目的输入和输出用例,如:我输入1,你输入2才是正确的;交给判题机去执行用户的代码,给用户的代码输入用例,比如1,看用户程序执行结果是否和标椎答案输出一致(对比用例文件)
特殊测评(SPJ):管理员设置题目的输入和输出用例,如:我输入1,你输出答案是 > 0 或者 < 2才是正确的;这里我们需要使用特判程序,不是通过对比用例文件是否一致这种四班的程序来检验,而是要专门根据这道题目写一个特殊的判断程序,程序接收题目的输入(1),标准输出用例(2),用户的结果(1.5),特盘程序根据这些值比较是否是正确的
交互测评:让用户输入一个例子,就给一个输出结果,交互比较灵活,没办法通过简单的,死板的输入输出文件来搞定。

不能让用户随便引入包,随便遍历,暴力破解,需要使用正确的算法 => 安全性
判题过程是异步的 => 异步化
提交之后,会生成一个提交记录,有运行的结果以及运行信息(时间限制,内存限制)

复习做项目的流程

1,项目介绍,项目调研,需求分析
2,核心业务流程
3,项目要做的功能(功能模块)
4,技术选型(技术预言)
5,项目初始化
6,项目开发
7,测试
8,优化
9,代码提交,代码审核
10,产品验收
11,上线

现有系统调研

https://github.com/HimitZH/HOJ(比较推荐)
https://github.com/QingdaoU/OnlineJudge(很成熟,python,不好学)
https://github.com/hzxie/voj(不成熟,但是好学)

实现核心

1)权限调研
谁能提代码,谁不能提代码

2)代码沙箱(安全沙箱)
用户代码藏毒:写个木马问文件,修改系统权限
沙箱:隔离的,安全的环境,用户的代码不会影响到沙箱之外的系统的运行

资源分配:系统的内存就2个G,用户疯狂占用资源沾满你的内存,其他人就用不了。所以要限制用户程序的占用资源

3)判题规则
题目用例的对比,结果的验证

4)任务调度
服务资源有限,用户要排队,按照顺序去依次执行判题,而不是直接拒绝

核心业务流程

判题服务:获取题目信息,预计的输入输出结果,返回给业务后端:用户的答案是否正确
代码沙箱:只负责运行代码,给出结果,不管什么结果是正确的

实现了解耦

功能

  1.  题目提交,
    
    • 1
  2.  在线做题,在线提交
    
    • 1

项目扩展思路

1,支持多种语言
2,Remote Judge
3,完善的评测功能:普通评测,特殊评测,交互评测,在线评测,子任务分组评测,文件IO
4,统计分析用户判题记录
5,权限效验

技术选型

前端:Vue3,Arco Desgin组件库,手撸项目模板,在线代码编辑器,在线文档浏览,Java进程控制,Java安全服务器,部分JVM知识点
虚拟机(云服务器),Docker(代码沙箱实现)
Spring Cloud微服务,消息队列

主流的OJ系统实现方案

开发原则:能用别人现成的,就不要自己写

1)用现成的OJ系统,比如judge0
https://github.com/judge0/judge0
自己用源码来部署,公有云,私有云

2)用现成的判题API(比如judge0),现成的代码沙箱
https://rapidapi.com/judge0-official/api/judge0-ce

API的作用:接受代码,返回执行结果

3)自主开发

4)用AI当做代码沙箱

5)移花接木,通过操作模拟浏览器的方式,用别人的OJ来帮你判题
比如无头浏览器,向人一样去别人的降魔中提交代码,并获取结果



2. 前端项目初始化

确认环境

NodeJS版本:v18.16.0或16
命令:node -v
切换和管理node版本的工具:https://github.com/nvm-sh/nvm/

npm 版本:9.5.1
命令:npm -v

初始化

使用vue-cli脚手架:https://cli.vuejs.org/zh/

安装脚手架工具
npm install -g @vue/cli

vue是否安装成功
vue -V
image.png

前端工程化配置

脚手架已经帮我们配置了代码美化,自动校检,格式化插件等,无需再进行配置
倒是需要在IDEA中开启代码美化插件:

image.png

在vue文件中执行格式化快捷键,不报错,表示配置工程化成功

引入组件

引入组件库:https://arco.design/
快速上手:https://arco.design/vue/docs/start
安装:
npminstall --save-dev @arco-design/web-vue

项目通用布局

新建一个布局,在app.vue中引入(BasicLayout)
选用arco design的layout组件,先把上中下布局编排号,然后在填充内容

实现通用菜单
https://arco.design/vue/component/menu

把菜单上的路由改成路由文件,实现更通用的动态配置(将route将index.ts中抽离出来,单独建一个routes.ts)

GlobalHeader.vue

// 路由使用
const router = useRouter();
  • 1
  • 2

1)提取通用路由文件
2)菜单组件读取路由,动态渲染菜单项
3)绑定跳转事件
4)同步路由到菜单项

首先点击菜单项 => 跳转更新路由 => 更新路由后, 同步去更新菜单栏的高亮状态

// ------------------ 回到默认主页

// 默认主页
const selectedKeys = ref(["/"]);

// 路由跳转后,更新选中的菜单项
router.afterEach((to, from, next) => {
  selectedKeys.value = [to.path];
});

// --------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

全局状态管理

Vuex: https://vuex.vuejs.org/zh/guide/
所有页面全局共享的变量,而不局限于某个页面中
适合作为全局状态的数据:已登录用户信息,
本质上:给你提供了一套增删改查全局变量的API,只不过可能多了一些功能()

image.png

可以直接参考购物车实例:
https://github.com/vuejs/vuex/tree/main/examples/classic/shoping-cart

state:存储的状态信息,比如用户信息
mutation(尽量同步):定义了对变量进行增删改查的方法
actions(支持异步):执行异步操作,并且触发mutation的更改(actions调用mutation)

先定义user模块:
Store/index.ts

import { createStore } from "vuex";
import user from "./user";

export default createStore({

  mutations: {},

  actions: {},

  modules: {
    user,
  },

});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

store/user.ts

//initial state
import { StoreOptions } from "vuex";


export default {

  namespaced: true,

  state: () => ({

    loginUser: {

      userName: "未登录",

    },

  }),

  actions: {

    getLoginUser({ commit, state }, payload) {

      commit("updateUser", { userName: "鱼皮" });

    },

  },

  mutations: {

    updateUser(state, payload) {

      state.loginUser = payload;

    },

  },

} as StoreOptions<any>;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

获取状态变量:

// 获取存储的信息

const store = useStore();
  • 1
  • 2
  • 3

store.state.user?.loginUser

修改状态变量:

store.dispatch("user/getLoginUser", {
  userName: "管理员",
});
  • 1
  • 2
  • 3

权限修改

我能直接以一套通用的机制,去定义哪个页面需要那些权限

思路:
1,在全局页面组件中,绑定一个全局路由监听。每次访问页面时,根据用户要访问的路由信息,先判断用户是否有对应的访问权限
2,如果有,跳转到原页面。否则,拦截或跳转到401鉴权或登录页

1,在路由配置文件,定义某个路由的访问权限
2,在全局页面组件中,绑定一个全局路由监听。每次访问页面时,根据用户要访问的路由信息,先判断用户是否有对应的访问权限
3,如果有,跳转到原页面。否则,拦截或跳转到401鉴权或登录页

根据权限隐藏菜单

1)routes.ts给路由新增一个标志位,用于判断路由是否显藏

2{

  path: "/admin",
    name: "管理员可见",
    component: AdminView,
    meta: {
    access: "canAdmin",
      },
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

2)不要用v-for v-if去条件渲染元素,这样会循环所有的元素,导致性能的浪费

<div v-for="item in routes" :key="item.path">
  <a-menu-item :key="item.path" v-if="!item.meta?.hideInMenu">
                                                                {{ item.name }}
  </a-menu-item>
    </div>
  • 1
  • 2
  • 3
  • 4
  • 5

推荐:先过滤需要展示的页面数组

<a-menu-item v-for="item in visibleRoutes" :key="item.path">
                                                              {{ item.name }}
                                                                </a-menu-item>
  • 1
  • 2
  • 3
// 展示在菜单的路由数组
const visibleRoutes = routes.filter((item, index) => {
  if (item.meta?.hideInMenu) {
    return false;
  }
  return true;
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

全局权限管理

1)定义权限
Access/accessEnum.ts

/**
 * 权限定义
 */

const ACCESS_ENUM = {
  NOT_LOGIN: "notLogin",
  USER: "user",
  ADMIN: "admin",
};

export default ACCESS_ENUM;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

2)定义一个公用的权限校检方法
为什么,因为菜单组件中要判断权限,权限拦截也要用到权限判断功能,所以抽离成公共方法
Access/checkAccess.ts

import ACCESS_ENUM from "@/access/accessEnum";


/**

 * 检查权限 (判断当前登录用户是否具有某个权限)

 * @param loginUser 当前登录用户

 * @param needAccess 需要有的权限

 * @return boolean 有无权限

 */

const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
  // 获取当前登录用户具有的权限 (如果没有loginUser, 则表示未登录)
  const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN;
  if (needAccess === ACCESS_ENUM.NOT_LOGIN) {
    return true;
  }

  // 如果用户登录才能访问
  if (needAccess === ACCESS_ENUM.USER) {
    // 如果用户没登录 那么表示无权限
    if (loginUserAccess === ACCESS_ENUM.NOT_LOGIN) {
      return false;
    }
  }

  // 如果需要管理员权限
  if (needAccess === ACCESS_ENUM.ADMIN) {

    // 如果不为管理员, 表示无权限
    if (loginUserAccess !== ACCESS_ENUM.ADMIN) {
      return false;
    }
  }

  return true;

};

export default checkAccess;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44

3)修改GlobalHeader动态菜单组件,根据权限来过滤菜单
注意:这里使用计算属性,为了当前登录用户信息发生变更时,触发菜单栏的重新渲染,展示新增权限的菜单栏

// 展示在菜单的路由数组 (如果要监视它不断刷新,则可以使用computed属性)

const visibleRoutes = computed(() => {
  return routes.filter((item, index) => {

    // 此处如果是隐藏页面, 那就过滤掉
    if (item.meta?.hideInMenu) {
      return false;
    }

    // todo 根据权限过滤菜单
    if (
      !checkAccess(store.state.user.loginUser, item?.meta?.access as string)
    ) {
      return false;
    }

    return true;
  });
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20



3. 后端项目初始化

后端项目初始化

打开springboot-init (万能后端使用模板)
1)替换全局名称
Ctrl+shift+F à在文件中查找
或者
编辑 à查找 à在文件中查找 à ctrl+shift+R(全局替换)

Springboot-init 替换为 自己的项目名
Springbootinit 替换为 自己的包名

在pom.xml中加入

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-site-plugin</artifactId>
  <version>3.9.1</version>
  <!-- 其他配置 -->
</plugin>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

具体位置如下
1)先阅读README.md
2)sql/create_table.sql定义了数据可的初始化建库建表语句
3)sql/post_es_mapping.json帖子表在ES中的建表语句
4)aop:用于全局权限校检,全局日志记录
5)common:万用的类,比如通用响应类
6)config:用于接受application.yaml中的参数,初始化一些客户端的配置类(比如对象存储客户端)
7)constant:定义常量
8)controller:接受请求
9)esdao:类似mybatis的mapper,用于操作ES
10)exception:异常处理相关
11)job:任务相关(定时任务,单次任务)
12)manager:服务层(一般是定义一些公用的服务,对接第三方API等)
13)mapper:mybatis的数据访问层,用于操作数据库
14)model:数据模型,实体类,包装类,枚举值
15)service:服务层,用于编写业务逻辑
16)utils:工具类,各种各样公用的方法
17)wxmp:公众号相关的包
18)test:单元测试
19)MainApplication:项目启动入口
20)Dockerfile:用于构建Docker镜像



4. 前后端联调

问:前端和后端如何连接起来?接口/请求
答:前端发送请求调用后端接口

1)安装请求工具类 Axios
官方文档:https://axios-http.com/docs/intro
代码:
安装:

npm install axios

2)编写调用后端的代码
传统情况下,每个请求都要单独编写代码,至少得写一个请求路径
现在完全不用!!!
直接自动生成即可

可以使用更多的请求
使用:https://github.com/ferdikoomen/openapi-typescript-codegen
安装:

npm install openapi-typescript-codegen --save-dev

在localhost:port//api/doc.html#home 中选择 分组Url:

/api/v3/api-docs/default

http://localhost:8103/api/v3/api-docs/default
openapi --input http://localhost:8103/api/v3/api-docs/default --output ./generated --client axios

前端执行:

openapi --input http://localhost:8101//api/v3/api-docs --output ./generated --client axios

3)直接使用生成的Service代码,直接调用函数发送请求即可,比如获取登录信息

const res = await UserControllerService.getLoginUser();

if (res.code === 0) {

  commit("updateUser", payload);

} else {

  commit("updateUser", {

    ...state.logUser,

    userRole: ACCESS_ENUM.NOT_LOGIN,

  });

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

4)如果想要自定义请求参数,怎么办?
1】 使用代码生成器提供的全局参数修改对象
https://github.com/ferdikoomen/openapi-typescript-codegen/wiki/OpenAPI-object

在generated/core/OpenApi.ts 中

export const OpenAPI: OpenAPIConfig = {

  BASE: 'http://localhost:8101/api',

  VERSION: '0',

  WITH_CREDENTIALS: false,  # 项目中应该设置为true

    CREDENTIALS: 'include',

  TOKEN: undefined,

  USERNAME: undefined,

  PASSWORD: undefined,

  HEADERS: undefined,

  ENCODE_PATH: undefined,

  };
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • WITH_CREDENTIALS在客户端请求中设置,告诉浏览器在发起跨域请求时是否应该包含凭证。如果设置为true,浏览器会在请求中包含凭证。

2】直接定义axios请求库的全局参数,比如全局请求响应拦截器
文档:https://axios-http.com/docs/interceptors

import axios from "axios";



// Add a request interceptor

axios.interceptors.request.use(

  function (config) {

    // Do something before request is sent

    return config;

  },

  function (error) {

    return Promise.reject(error);

  }

);



// Add a response interceptor

axios.interceptors.response.use(

  function (response) {

    console.log("响应", response);

    // Any status code that lie within the range of 2xx cause this function to trigger

    // Do something with response data

    return response;

  },

  function (error) {

    // Any status code that false outside the range of 2xx cause this function to trigger

    // Do something with response error

    return Promise.reject(error);

  }

);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53



5. 登录界面

自动登录

1)在 store/user.ts 编写获取远程登录用户信息的代码

actions: {
  async getLoginUser({ commit, state }, payload) {
    const res = await UserControllerService.getLoginUser();
    if (res.code === 0) {
      commit("updateUser", payload);
    } else {
      commit("updateUser", {
        ...state.logUser,
        userRole: ACCESS_ENUM.NOT_LOGIN,
      });
    }
    // todo 改为从远程请求获取登录信息

  },

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

2)在哪里去触发一下getLoginUser函数的执行?应当在一个全局的位置
有很多选择:
1 路由选择
2 全局页面入口app.vue
3 全局通用布局(所有页面都想用的组件)

所以说将app.vue中的全局路由守卫移动到新的文件下 access/index.ts,然后在main.ts引入该文件
import “@/access/index”;

全局权限管理优化

1)新建access/index.ts文件,把原有的路由拦截,权限校检逻辑放在独立的文件中
优势:只要不引入,就不会开启,不会对项目有影响

2)编写权限管理和自动登录逻辑
如果没登录过,自动登录

const loginUser = store.state.user.loginUser;

// 如果之前没登过,自动登录
if (!loginUser || !loginUser.userRole) {
  // 加await 是为了等用户登录成功后,在执行后续的代码
  await store.dispatch("user/getLoginUser");

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

如果用户访问的项目不需要登录,是否需要强制跳转到登录页?
答:不需要

示例代码:

const needAccess = to.meta?.access ?? ACCESS_ENUM.NOT_LOGIN;

// 要跳转的页面必须要登录
if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
  // 要跳转没登录,跳转到登录页面
  if (!loginUser) {
    next(`"/user/login?redirect=${to.fullPath}"`);
    return;
  }

  // 如果已经登录了,但是权限不厚,那么跳转到无权限页面
  if (!checkAccess(loginUser, needAccess)) {
    next("/noAuth");
    return;
  }
}

next();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

access/index.ts示例:

import router from "@/router";
import store from "@/store";
import ACCESS_ENUM from "@/access/accessEnum";
import checkAccess from "@/access/checkAccess";

// 进行权限判断拦截
router.beforeEach(async (to, from, next) => {
  // // 仅管理员可见 判断当前用户是否有权限
  // if (to.meta?.access === "canAdmin") {
  //   if (store.state.user.loginUser?.rule !== "admin") {
  //     next("/noAuth");
  //     return;
  //   }
  // }

  console.log("登录用户信息", store.state.user.logerUser);
  const loginUser = store.state.user.loginUser;

  // 如果之前没登过,自动登录
  if (!loginUser || !loginUser.userRole) {
    // 加await 是为了等用户登录成功后,在执行后续的代码
    await store.dispatch("user/getLoginUser");
  }

  const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN;
  // 要跳转的页面必须要登录
  if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
    // 要跳转没登录,跳转到登录页面
    if (!loginUser || !loginUser.userRole) {
      next(`"/user/login?redirect=${to.fullPath}"`);
      return;
    }

    // 如果已经登录了,但是权限不厚,那么跳转到无权限页面
    if (!checkAccess(loginUser, needAccess)) {
      next("/noAuth");
      return;
    }
  }

  next();

});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

支持多套布局

1)在routes路由文件中新建一套用户路由,使用vue-router自带的子路由机制,实现布局和嵌套路由

{

  path: "/user",
    name: "用户",
    component: UserLayout,
    children: [
    {
      path: "/user/login",
      name: "用户登录",
      component: UserLoginView,
    },
    {
      path: "/user/register",
      name: "用户注册",
      component: UserRegisterView,
    },
  ],
},
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

2)新建UserLayout,UserLoginView,UserRegisterView,并且在router中引入
3)在app.vue跟页面文件,根据路由去区分多套布局

<div id="app">
  <template v-if="route.path.startsWith('/user')">
    <router-view />
  </template>
  <template v-else>
    <BasicLayout />
  </template>
</div>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

当前这种app.vue中通过if else区分布局的方式,不是最优雅的,理想情况下是直接读取routes.ts,在这个文件中定义多套布局,然后自动使用页面布局

登录页面开发

  <div id="userLoginView">
    <h2 style="margin-bottom: 16px">用户登录</h2>
    <a-form
      style="max-width: 480px; margin: 0 auto"
      label-align="left"
      auto-label-width
      :model="form"
      @submit="handleSubmit"
    >
      <a-form-item field="userAccount" label="账号">
        <a-input v-model="form.userAccount" placeholder="请输入账号" />
      </a-form-item>
      <a-form-item field="userPassword" tooltip="密码不少于 8 位" label="密码">
        <a-input-password
          v-model="form.userPassword"
          placeholder="请输入密码"
        />
      </a-form-item>
      <a-form-item>
        <a-button type="primary" html-type="submit" style="width: 120px">
          登录
        </a-button>
      </a-form-item>
    </a-form>
  </div>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

系统功能的梳理

1 用户模块
A 注册(后端已实现)
B 登录(后端已实现,前端已实现)
2 题目模块
A 创建题目(管理员)
B 删除题目(管理员)
C 修改题目(管理员)
D 搜索题目(用户)
E 在线做题(题目详细页)
3 判断模块
A 提交判断(结果是否正确与错误)
B 错误处理(内存溢出,安全性,超时)
C 自主实现,代码沙箱(安全沙箱)
D 开放接口(提供一个独立的新服务)



6. 表设计

用户表

只有管理员才能发布和管理题目,普通用户只能看题

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;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

题目表

题目标题
题目内容:存放题目的介绍,输入,提示,描述,具体的详情
题目标签(json数组字符串):栈,队列,链表,简单中等,困难
题目答案:管理员/用户设置的标椎答案
提交数, 通过的人数:便于分析统计

判断相关字段:
如果说题目不是很复杂,用例文件大小不打的话,可以直接存放数据库表里
但是如果用例文件比较大,> 512 KB 建议单独存放在一个文件中,数据库中只保存文件 url (类似存储用户头像)

judgeConfig判断配置(json对象):
事件限制 timeLimit
内存限制 memoryLimit
输入用例:inputCase
输出用例:outputCase
judgeCase判断用例(json数组)
每一个元素是:一个输入用例对应一个输出用例

[
  {
    "input":"1 2",
    "output":"3"
  },
  {
    "input":"3 4",
    "output":"7"
  }
]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

存json的好处:便于扩展,植物药改变对象内部的字段,而不用修改数据库表(可能影响到数据库)

{
  "timeLimit": 1000,
  "memoryLimit": 1000,
  "stackLimit": 1000
}
  • 1
  • 2
  • 3
  • 4
  • 5

存json的前提:
1 你不需要根据某个字段去倒查这条数据
2 你的字段含义相关,属于同一类的值
3 你的字段存储空间占用不能太大

其他扩展字段
通过率
判断类型

代码:

create table if not exists questiton

(

  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;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

用户提交表

哪个用于提交了拿到题目,存放判题结果等

提交用户id:userId
题目:questionId
语言:language
用户的代码:code
判题状态:status(0 待判题,1 判题中,2 成功,3 失败)
判题信息(判题过程中得到的一些信息,比如程序的失败原因,程序执行小号的时间,空间)
judgeInfo(json对象)

{
  "message": “程序执行信息”,
  "time": 1000 ,  // 单位为ms
  "memory": 1000  // 单位为kb
}
  • 1
  • 2
  • 3
  • 4
  • 5

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

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-)',

  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 CURRENT_TIMESTAMP  not null comment '是否删除',

  index idx_questionId(questionId),

  index idx_userId(userId)

) comment '题目提交';
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

小知识-索引数据库

什么情况下适合加索引,如何选择给哪个字段加索引?
答:首先从业务触发,无论是单个索引,还是联合索引,都要从你实际的查询语句,字段枚举值的区分度,字段的类型考虑(where条件指定的字段)

比如:where userId = 1 and questionId = 2
可以选择根据userId和questionId分别建立索引(需要分别根据两个字段单独查询);也可以选择给这两个字段建立联合索引(所查询的字段是绑定在一起的)
原则上:能不用索引就不用索引;能用单个索引就别用联合/多个索引;不要给没区分度的字段加索引(比如性别,就男/女),因为索引也是占用空间的。



7. 后端接口设计

后端开发流程

1)根据功能设计表
2)自动生成对数据库基本的增删改查(mapper和service层的基本功能)
3)编写Controller层,实现基本的增删改查和权限校检
4)区根据业务定制开发新的功能/编写新的代码

更好地方法,编写自己的代码生成器(https://github.com/liyupi/sql-father-frontend-public

代码生成方法

1 安装MyBatisX
2 根据项目区调整生成配置

右键点击所选的表,选择MybatisX-Generator
image.png

只需加入当前项目的路径
image.png

然后进行如下选项
image.png

3 把代码从生成包中移到实际项目对应目录中
4 找相似的代码去复制Controller
单表去复制单表:QuestionController由PostController复制而来
关联表去复制关联表:QuestionSubmitController由PostThumbController复制而来
(注意替换当中的路径)
5 复制实体类相关的DTO,VO,枚举值字段(用于接受前端请求,或者业务间传递信息)
复制之后,补充需要的字段

updateRequest和editRequest的区别:前者是给管理员更新用的,可以指定更多地字段;后者是给用户用的,只能指定部分字段。

6为了更方便的处理json字段,需要给对应的json字段编写独立的类。

小知识:什么情况下要加业务前缀?什么情况下不加?
加业务前缀的好处,防止多个表都有类似的类,产生冲突;不加的前提,因为可能是这个类是多个业务之间共享的,能够复用。

定义vo类:作用是专门给前端返回对象,可以节约网络传输大小,或者过滤字段(脱敏),保证安全性。比如 judgeCase,answer 字段,一定要删,不能直接给用户答案。

7 校检 Controller 层的代码,看看出路要调用的方法缺失外,还有无报错。
8 实现QuestionService :从对应的编写好的实现类,如 post à question

9 编写QuestionVO的json / 对象装换工具类
10 编写枚举类,编写questionSubmit提交类,

编写好基本代码后,通过Swagger或者编写单元测试去验证。

查询提交信息接口

功能:能够根据用户id,或者题目id,编程语言,题目状态,去查询提交记录。
注意事项:
仅本人能看到自己和管理员(提交userId和登录用户id不同)代码的答案,提交代码。

实现方案:先查询,在根据权限在去做脱敏
核心代码:

@Override

public QuestionSubmitVO getquestionSubmitVO(QuestionSubmit questionSubmit, User loginUser) {

    QuestionSubmitVO questionSubmitVO = QuestionSubmitVO.objToVo(questionSubmit);

    // 脫敏: 仅本人和管理员能看见自己 (提交 userId 和登录用户 id 不同) 提交代码的答案,提交代碼
    //        User loginUser = userService.getLoginUser(request);

    long userId = loginUser.getId();

    // 处理脱敏
    if(userId != questionSubmit.getUserId()&&!userService.isAdmin(loginUser)){
        questionSubmitVO.setCode(null);
    }

    return questionSubmitVO;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

小知识:

@TableLogic

/**
 * id 为了防止用户按照di顺序爬取题目,建议把id的生成规则改为ASSIGN_ID 而不是从1开始自增
 */

@TableId(type = IdType.ASSIGN_ID)
private Long id;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

@EqualsAndHashCode(callSuper = true)

通常用于在子类中自动生成 equals 和 hashCode 方法,同时考虑到了父类的成员变量。
具体来说,当你在子类中使用 @EqualsAndHashCode(callSuper = true) 时,它会在生成 equals 和 hashCode 方法时考虑到父类的成员变量。如果没有这个参数,只会考虑子类自己的成员变量。

插件 Generate All Getter And Setter

.allget() 得到所有的get方法 插件 Generate All Getter And Setter
转为大写 插件 String Manipulation
创建多种的vue页面 IntelliVue



8. 前端页面设计

页面设计

1)用户注册页面
2)创建题目页面(管理员)
3)题目管理页面(管理员)
查看
删除
修改
快捷创建
4)题目列表页(用户)
5)题目详情页(在线做题页)
判题状态的查看
6)题目提交列表页

接入要用到的组件

Markdown编辑器
一套通用的文本编辑语法,

推荐的Markdown编辑器:
http://github.com/bytedance/bytemd

安装:

npm i @bytemd/vue-next

其他组件:

npm i @bytemd/plugin-highlight 代码高亮插件
npm i @bytemd/plugin-gfm 表格支持

隐藏编辑器中不需要操作的图标,

代码编辑器

微软官方编辑器
http://github.com/microsoft/monaco-editor

官方提供的整合教程:
https://github.com/microsoft/monaco-editor/blob/main/docs/integrate-esm.md

官方实例教程:
https://microsoft.github.io/monaco-editor/playground.html?source=v0.47.0#example-creating-the-editor-hello-world

1)安装编辑器:

npm install monaco-editor

2)vue-cli项目(webpack项目)整合monaco-editor:

npm install monaco-editor-webpack-plugin

在vue.config.js 中配置webpack插件:

const { defineConfig } = require("@vue/cli-service");

const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");

module.exports = defineConfig({

  transpileDependencies: true,

  lintOnSave: false,

  chainWebpack(config) {

    config.plugin("monaco").use(new MonacoWebpackPlugin());

  },

});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

babel.config.js 添加插件 plugins

module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
  plugins: ["@babel/plugin-transform-class-static-block"],
};
  • 1
  • 2
  • 3
  • 4

CodeEditor的基本使用

<template>
  <div id="code-editor" ref="codeEditorRef" style="height: 400px"></div>
  {{ value }}
  <a-button @click="fillValue"></a-button>
</template>

<script setup lang="ts">
  import * as monaco from "monaco-editor";
  import { onMounted, ref, toRaw } from "vue";

  const codeEditorRef = ref();
  const codeEditor = ref();
  const value = ref("hello world");
  
  const fillValue = () => {
    if (!codeEditorRef.value) {
      return;
    }
    
    // 改变值
    toRaw(codeEditor.value).setValue("hello")
  };

  onMounted(() => {
    if (!codeEditorRef.value) {
      return;
    }


    // 初始值
    codeEditor.value = monaco.editor.create(codeEditorRef.value, {
      value: value.value,
      language: "java",
      automaticLayout: true,
      minimap: {
        enabled: true,
      },
      lineNumbers: "off"
      roundedSelection: false,
      scrollBeyondLastLine: false,
      readOnly: false,
      theme: "vs-dark",
    });

    // 编辑 监听内容变化
    codeEditor.value.onDidChangeModelContent(()=>{
      console.log("目前内容为 :",toRaw(codeEditor.value).getValue())
    })
  });

</script>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

通Md编辑器一样,也要接受父组件的传值,把显示的输入,得到交给父组件去控制,从而实时得到用户输入的代码

自定义代码模板

设置 —— 编辑器 —— 实时模板 (live Template)

创建模板组
image.png

输入新的组名
image.png

创建实时模板
选中custom后,点击实时模板,输入实时模板名字(myvuepage)
image.png

输入模板内容
然后点击右侧 编辑变量
image.png

编辑后的结果是这样的
image.png

模板如下:

<template>
  <div id="$ID$"></div>
</template>

<script setup lang="ts">
  $END$
</script>

<style scoped>
  #$ID${

  }
</style>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

选择语言
所有任务完成后,点击右下方 更改,选择对应的语言即可
image.png

创建题目页面

重新生成后端生成前端模块代码:

注意:用户登录后仍显示未登录
因为代码生成后,OpenAPI文件的CREDENTIALS参数重置了,应该改为 true;

export const OpenAPI: OpenAPIConfig = {
  BASE: 'http://localhost:8101//api',
  VERSION: '0',
  WITH_CREDENTIALS:true,
  CREDENTIALS: 'include',
  TOKEN: undefined,
  USERNAME: undefined,
  PASSWORD: undefined,
  HEADERS: undefined,
  ENCODE_PATH: undefined,
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

使用表单组件,先复制示例代码,在修改:https://arco.design/vue/component/form
此处我们用到了
嵌套表单:https://arco.design/vue/component/form#net
动态增减表单:https://arco.design/vue/component/form#dynamic

注意:我们自定义的代码编译器不会被组件库识别,需要手动指定value和handleChange函数

题目管理页面开发

1)使用表格组件
2)查询数据
3)定义表格列
4)加载数据
5)调整
比如json格式不好看,有2种方法调整:
1.使用组件库自带的语法,自动格式化
2.完全自定义渲染,想展示什么就展示什么
6)添加删除,更新操作
删除后要执行 loadData 刷新数据

更新页面开发

由于更新和创建都是相同的表单,所以可以完全复用

问题是,如何区分两个页面
实现:
1)路由(/add/question 和 /update/question)
2)请求参数(id = 1)

更新页面相比于创建页面,多个两个改动
1)在加载页面时,更新页面需要加载出之前的数据
2)在提交时,请求的地址不同

页面优化

1)处理菜单项的权限控制和显示隐藏
通过meta.hideInMenu 和 meta.access 属性控制

2)管理页面分页问题的修复
可以参考聚合搜索项目得搜索
核心原理:在分页页号改变时,触发 @page-change 事件,通过改变searchParams的值,并且通过watchEffect监听searchParams的改变(然后执行loadData重新加载),实现了页号变化时触发数据的重新加载。

3)修复刷新页面未登录问题

修改 access/index.ts 中的获取登录用户信息,把登录后的信息更新到loginUser变量上

// todo 自动登录
if (!loginUser || !loginUser.userRole) {

  // 加 await 是为了等用户登录后 在执行后续的代码
  await store.dispatch("user/getLoginUser");
  loginUser = store.state.user.loginUser;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

题目列表搜索页

核心实现:表格组件
1)赋值管理题目页的表格
2)只保留需要的 columns 字段
3)自定义选项的渲染
标签:使用 tag 组件
通过率:自行计算
创建时间:使用 moment 库进行格式化 https://momentjs.com/
操作按钮:补充跳转到做题页的按钮
4)编写搜索表单,使用 form 的 layout = inline 布局,让用户的输入和searchParams 同步,并且给提交按钮绑定修改 searchParams,从而被watchEffect监听到,触发查询

在线做题页面

1)先定义动态参数路由,开启props为true,可以在页面的props中直接获取动态参数(题目id)

{
  path: "/view/question/:id",
    name: "在线做题",
    component: () => import("@/views/question/ViewQuestionView.vue"),
    props: true,
    meta: {
      access: ACCESS_ENUM.USER,
      hideInMenu: true,
    }
},
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

2)定义布局(左侧是题目信息,右侧是)
3)左侧题目信息
Tabs 切换展示的内容
定义MdViewer组件展示的题目内容
使用descriptions组件展示判题配置
4)使用select组件让用户选择编程语言

在代码编辑器中监听属性的变化,注意监听props要使用箭头函数

https://blog.csdn.net/wuyxinu/article/details/124477647

判题机模块预开发

目的:跑通完整的流程

判题模块和代码沙箱的关系

判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱:只负责接受和输入,返回编译运行的结果,不负责判题

这两个模块完全解耦

为什么代码沙箱和输出一组运行用例

前提:我们的每道题目有多组测试用例
如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译,记录程序的执行状态(重复的代码不重复编译)

这是一种很常见的性能优化方法。(待处理)



9. 代码沙箱开发

代码沙箱开发

1)定义代码沙箱的接口,提高复用性
之后我们的项目只调用项目,不调用具体的实现类,这样使用其他的代码沙箱实现类时,就不用去修改名称了,便于扩展。

代码沙箱的请求接口中,timeList可加可不加,可自行扩展,既中断程序

扩展思路:增加一个查看代码沙箱状态的接口

2)定义多种不同的代码沙箱实现
示例代码沙箱
远程代码沙箱
第三方代码沙箱:https://github.com/criyle/go-judge

1)编写单元测试,验证单个验证代码沙箱的执行:

@Test

void test1() {

    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);

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

但现在的问题是,我们把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();

        }

    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

5)参数配置化,把项目中的一些可以交给用户自定义的选项或字符串,写到配置文件中,这样开发者只需要改配置文件,不需要看项目代码,

# 代码沙箱配置
codesandbox:
  type: example
  • 1
  • 2
  • 3

然后通过@Value() 注解解读即可

6)代码沙箱能力增强
比如:我们需要在调用代码沙箱前,输出请求参数日志;在代码沙箱后,输出响应结果日志,便于管理员分析。

难道每代码沙箱都写一遍,重复写 log.info 吗

答:使用代理模式,提供一个proxy,来增强代码沙箱的能力
原本:需要用户自己去调用多次

代码沙箱代理

/**

 * 代码沙箱工厂代理

 */

@Slf4j

@AllArgsConstructor

public class CodeSandboxProxy implements CodeSandbox {

    private CodeSandbox codeSandbox;



    @Override

    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {

        log.info("代码沙箱请求信息: " + executeCodeRequest.toString());

        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);

        log.info("代码沙箱响应信息: " + executeCodeResponse.toString());

        return executeCodeResponse;

    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

代码沙箱代理使用方式

CodeSandbox codeSandbox = CodeSandboxFactory.newInstance("type");

codeSandbox = new CodeSandboxProxy(codeSandbox);
  • 1
  • 2
  • 3

小知识——Lombok Builder注解

可以使用链式的方式更方便的给对象赋值

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

    private List<String> outList;

    /**
     * 接口信息
     */
    private String message;

    /**
     * 执行状态
     */
    private Integer status;

    /**
     * 判题信息
     */
    private JudgeInfo judgeInfo;

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
    .code(code)
    .language(language)
    .inputList(inputList)
    .build();
  • 1
  • 2
  • 3
  • 4
  • 5

判题服务完整业务流程实现

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

判断逻辑
1)先判断沙箱执行的结果输出数量是否和预期输出数量相等
2)依次判断每一项输出和预期输出是否相等
3)判题题目的限制条件

策略模式优化判题模式
思考:
我们的判题策略可能是由跟多种,比如:我们的代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,java要花额外的时间。

首先编写默认判题模块

我们可以采用策略模式,针对不同的情况,定义独立的策略,而不是所有的判题逻辑,if…else代码全部混在

JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();

if(language.equals("java")){
    judgeStrategy = new JavaLanguageJudgeStrategy();
}
  • 1
  • 2
  • 3
  • 4
  • 5

定义JudgeManager,目的是为了简化对判题功能的调用,让调用方最简单

/**
 * 判题管理
 */

public class JudgeManager {

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

        QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();

        String language = questionSubmit.getLanguage();

        JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();

        if(language.equals("java")){

            judgeStrategy = new JavaLanguageJudgeStrategy();

        }
        return judgeStrategy.doJudge(judgeContext);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

理解

理解代码沙箱架构的实现,掌握工厂模式,策略模式,代理模式

代码沙箱实现

代码沙箱:只负责代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目/服务,提供给其他的需要执行代码的项目去使用)
扩展:可以自行实现C++语言的实现
新建一个springboot web项目

Java原生实现代码沙箱

原生:不借用第三方库和依赖
代码沙箱需要:接受代码 => 编译代码 => 执行代码

为何编译后的中文是乱码呢:因为一般的文本用的是TUF-8,而终端用的是GBK

通过chcp命令:
https://blog.csdn.net/weixin_49114503/article/details/127945397

但是不推荐用chcp

编译:

javac -encoding utf-8 .\java.class

执行:

java -cp c:\path\java.class

实际OJ系统中,对用户的输入代码有一定的要求,便于系统统一的处理,所以,我们把用户输入的类名限制为 Main

Docker实现代码沙箱

实现流程:docker负责运行的java程序,并且得到结果

1 将用户输入的代码保存为文件
2 编译代码,得到class文件
3 把编译好的文件上传到容器环境中
3 执行代码,得到输出结果
4 收集整理输出结果
5 文件清理
6 错误处理,提升程序健壮性

扩展:模块方法设计模式,定义同一套实现流程,让不同的子类去负责不同流程中具体实现。执行步骤一样,每个步骤实现方式不一样

创建容器,上传编译文件

自定义容器的两种方式
1)在已有容器上扩展:比如拉取现成的jdk,在将编译后的文件复制到容器中,适合新项目,跑通流程
2)完全自定义容器:适合比较成熟的项目,比如封装多个语言的项目

思考:我们每个测试都要单独创建一个容器,每个容器只能执行一次java命令,浪费性能,所以创建一个可交互性的容器,能接受多次输入并输出

创建容器时,可以指定文件路径(Volumn)映射,作用是把本地同步的文件到容器中,可以让容器访问。

也可以叫容器挂载目录

HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1000L);
hostConfig.withMemory(1L);
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
  • 1
  • 2
  • 3
  • 4

启动容器,执行代码

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

示例执行:

docker exec keen_blackwell java -cp /app Main 1 3
  • 1

注:要把输入参数之间用空格隔开

创建命令

String[] inputArgsArray = inputArgs.split(" ");
String[] cmdArray = new String[] {"java", "-cp", "/app", "Main"};
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
    .withCmd(cmdArray)
    .withAttachStderr(true)
    .withAttachStdin(true)
    .withAttachStdout(true)
    .exec();
System.out.println("创建执行命令: " + execCreateCmdResponse);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

执行命令:

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

尽量复用之前的ExecuteMessage模式,在异步接口中填充正常和异常信息。

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

获取程序占用内存:
程序占用的内存每个时刻都在变化,所以不可能

模板方法优化代码沙箱

模板方法:定义一套通用的执行流程,让子类负责每个执行步骤的具体实现
模板方法的使用场景:适用于有规范的流程,且执行流程可以复用。
作用:大幅度节省重复代码量,便于项目扩展,更好维护。

大体框架

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

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

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

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

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

// 5 文件整理
boolean b = deleteFile(userCodeFile);
if(!b){
    log.error("Delete File error ,userCodeFiliePath = {}", userCodeFile.getAbsolutePath());
}
return outputResponse;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

具体子类的实现

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

/**
 * java 原生代码沙箱实现 (模板方法的实现)
 */
public class JavaNativeCodeSandboxNew extends CodeSandboxTemplate{
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
        return super.executeCode(executeCodeRequest);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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

@Override
public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList){
    // 3 创建容器,把文件复制到容器中
    // 获得默认的 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 pullResponseItem){
                System.out.println("下载镜像: " + pullResponseItem.getStatus());
                super.onNext(pullResponseItem);
            }
        };

        System.out.println("即将下载镜像");

        try{
            pullImageCmd
                .exec(pullImageResultCallback)
                .awaitCompletion(); // 阻塞,如果上述不成功,则不执行下一步
        }catch(InterruptedException e){
            System.out.println("拉取镜像失败");
            throw new RuntimeException(e);
        }
    }

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

    // 创建容器
    //  容器挂载目录
    HostConfig hostConfig = new HostConfig();
    hostConfig.withMemory(100 * 1000 * 1000L);
    hostConfig.withMemorySwap(0L);
    hostConfig.withCpuCount(1L);
    //        hostConfig.withSecurityOpts(Arrays.asList("seccomp=安全管理配置字符串"));
    hostConfig.setBinds(new Bind(userCodeFile.getParentFile().getAbsolutePath(), new Volume("/app")));
    CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(image);
    CreateContainerResponse createContainerResponse = createContainerCmd
        .withReadonlyRootfs(true) // 限制向root根目录写文件
        .withNetworkDisabled(true) // 限制访问网络
        .withHostConfig(hostConfig)
        .withAttachStdin(true)
        .withAttachStderr(true)
        .withAttachStdout(true)
        .withTty(true)
        .exec();
    System.out.println("createContainerResponse = ");
    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();
        final long[] maxMemory = {0L};
        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};
        String execId = execCreateCmdResponse.getId();
        long time = 0L;
        final boolean[] timeout = {true};
        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("输出错误结果: " + new String(errorMessage[0]));
                }else{
                    message[0] = new String(frame.getPayload());
                    System.out.println("输出结果: " + message[0]);
                }
                super.onNext(frame);
            }
        };

        // 获取占用的内存
        StatsCmd statsCmd = dockerClient.statsCmd(containerId);
        ResultCallback<Statistics> statisticResultCallback = statsCmd.exec(new ResultCallback<Statistics>(){
            @Override
            public void close() throws IOException {

            }

            @Override
            public void onStart(Closeable closeable) {

            }

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

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onComplete() {

            }
        });
        statsCmd.exec(statisticResultCallback);

        try{
            // 开始计时
            stopWatch.start();
            // 开始执行程序
            dockerClient
            .execStartCmd(execId)
            .exec(execStartResultCallback)
            .awaitCompletion(super.TIME_OUT, TimeUnit.SECONDS);
            // 停止计时
            stopWatch.stop();
            // 内存计算停止
            statsCmd.close();
            time = stopWatch.getLastTaskTimeMillis();
        }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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157

给代码沙箱开放安全API

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

/**
     * 执行代码
     * @param executeCodeRequest
     * @return
     */
@PostMapping("/executeCode")
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
    if(executeCodeRequest == null){
        throw new RuntimeException("请求参数为空");
    }
    ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandboxNew.executeCode(executeCodeRequest);
    return executeCodeResponse;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
调用安全性

如果将服务不做任何的限制,直接发布到公网上,是不安全的。

1)调用方和提供方统一一个字符串 (最好加密)

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

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

代码沙箱服务,先约定好字符串

// 设置 请求头和密匙
private static final String AUTH_REQUEST_HEADER = "auth";
private static final String AUTH_REQUEST_SECRET = "securityKey";
  • 1
  • 2
  • 3

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

@PostMapping("/executeCode")
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest, HttpServletRequest request,
                                       HttpServletResponse response){

    // 基本的认证
    String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
    if(authHeader.equals(AUTH_REQUEST_SECRET)){
        response.setStatus(403);
        return null;
    }

    if(executeCodeRequest == null){
        throw new RuntimeException("请求参数为空");
    }
    ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandboxNew.executeCode(executeCodeRequest);
    return executeCodeResponse;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
    System.out.println("远程代码沙箱");
    String url = "http://localhost:8081/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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

2)API签名认证,给允许调用的人员分配accessKey,secreKey,然后验证这两组key是否匹配



10. 跑通整个项目

1)移动 QuestionSubmitController 的代码到 QuestionController
2)由于后端改了接口地址,前端需要修改路径

openapi --input http://localhost:8101//api/v3/api-docs --output ./generated --client axios
  • 1

还需要更改前端调用的Controller

3)后端调试
4)开发提交列表页面

扩展:每个一段时间刷新一下提交状态,因为后端是异步判题的。



11. 单体项目改造为微服务

新建一个项目

什么是微服务

服务:提供某类功能的代码
微服务:专注于提供某类特定功能的代码,而不是把所有的代码全部放到同一个项目里,会把整个大的项目按照一定的功能,逻辑进行拆分的,拆分为多个子模块,每个子模块可以独立运行,子模块相互独立互不影响。

一个人一个公司:这个人icu了,整个公司倒闭
多个人一个公司:一个组垮了,公司不会直接倒闭,各组之间可能需要交互,来完成大的目标

微服务实现技术

Spring Cloud
Spring Cloud Alibaba
Dubbo(DubboX)
RPC(GRPC,TRPC)
本质是通过HTTP,或者其他的网络协议进行通讯来实现的。

Spring Cloud Alibaba

https://github.com/alibaba/spring-cloud-alibaba
推荐使用中文文档学习:https://sca.aliyun.com/zh-cn/

本质:是在Spring Cloud的基础上,进行了增强,补充了一些额外的能力,根据阿里多年的业务沉淀做了一些定制化开发。

注意:一定要选择对应的版本:
https://sca.aliyun.com/zh-cn/docs/2021.0.5.0/overview/version-explain

此处选择 2021.0.5.0:

Nacos:集中存管项目中所有服务的信息,便于服务之间找到彼此;同时还支持储存整个项目中的配置。

扩展:感兴趣可以了解另一个分布式微服务框架:https://github.com/Nepxion/Discovery

改造前思考

从业务需求出发,思考单机和分布式的区别。
用户登录功能:需要改造为分布式登录
其他内容:

  • 有没有送到单机的锁?该作为分布式锁(伙伴匹配系统)
  • 有没有用到本地缓存?该作为分布式缓存(Redis)
  • 需不需要用到分布式事务?比如操作多个库

改造分布式登录

1)application.yml增加redis配置

spring:
  session: # 修改session的储存方法
    # todo 取消注释开启分布式 session (须先配置Redis)
    store-type: redis
    # 30天过期
    timeout: 2592000
  redis:
    database: 1
    host: localhost
    port: 6379
    timeout: 5000
    password: 123456
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

2)补充依赖:

<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>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

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

微服务的划分

从业务上出发,想以下那些功能/职责是在一起的

公司老板给员工分工

依赖服务:

  • 注册中心:Nacos
  • 微服务网关:Gateway聚合所有的接口,统一接收处理前端的请求

公共模块:

  • common公共模块:全局异常处理器,请求响应封装类,公用的工具类
  • model模型模块:很多服务公用的实体类
  • 公用接口模块:只存放接口,不存放实现
  1. 用户模块

a. 注册(后端已实现)
b. 登录(后端已实现,前端已实现)

  1. 题目模块

a. 创建题目(管理员)
b. 删除题目(管理员)
c. 修改题目(管理员)
d. 搜索题目(用户)
e. 在线做题(题目详情页)
f. 题目提交

  1. 判题模块(较重的任务)

a. 提交判题(结果是否正确与错误)
b. 错误处理(内存溢出,安全性,超时)
c. 自主实现 代码沙箱(安全沙箱)
d. 开发接口(提供一个独立的新服务)

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

路由划分

用springboot的context-path 统一修改项目的接口前缀,比如:
用户服务:

  • /api/user
  • /api/user/inner (内部调用,网关层面要做限制)

判题服务:

  • /api/judge
  • /api/judge/inner (内部调用,网关层面要做限制)

题目服务:

  • /api/question
  • /api/question/inner(内部调用,网关要做限制)

Nacos注册中心启动

一定要选择 2.2.0 版本
教程:https://sca.aliyun.com/docs/2021/user-guide/nacos/overview/
Nacos官网教程:https://nacos.io/zh-cn/docs/quick-start.html
到官网下载Nacos:https://github.com/alibaba/nacos/releases/tag/2.2.0
安装好后,进入bin/启动

startup.cmd -m standalone
  • 1

新建工程

Spring Cloud 有相当多的依赖,参差不齐,不建议随意找一套,或者自己写。

线上新建springcloud项目

https://start.aliyun.com/

image.png

image.png

image.png

image.png

给项目增加全局配置文件
创建初始化项目后,补充Spring Cloud依赖

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2021.0.5</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

依次使用new Modules和spring boot Initializr创建各模块
image.png

父模块定义 modules,子模块引入parent语法,可以通过继承父模块继承配置,统一项目的定义和版本号。

给每个子模块添加如下依赖:

    <parent>
        <groupId>com.cmd</groupId>
        <artifactId>oj-backend-microservice</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <!-- <groupId></groupId>  如果有分组,去掉,咱们不需要-->
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

image.png

同步代码和依赖

给所有的业务加入公共依赖:

        <dependency>
            <groupId>com.cmd</groupId>
            <artifactId>oj-backend-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.cmd</groupId>
            <artifactId>oj-backend-model</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.cmd</groupId>
            <artifactId>oj-backend-service-client</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

1)common公共模块:全局异常处理器,请求响应封装类,公用的工具类

在外层的pom.xml中引入公共类:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
</dependency>
<!-- https://github.com/alibaba/easyexcel -->
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>easyexcel</artifactId>
  <version>3.1.1</version>
</dependency>
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
  <groupId>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.8.8</version>
</dependency>
        <!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

2)model模型模块:跟多服务公用的实体类
直接复制model包,注意代码沙箱model的引入

3)公用接口模块:只存放接口,不存放实现(多个服务之间要分享)
先无脑搬运所有的service, judgeService

需要指定openfeign(客户端调用工具)的版本:

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

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

4)具体实现

主类引入配置

@SpringBootApplication
@MapperScan("com.cmd.ojbackenduserservice.mapper")
@EnableScheduling
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
@ComponentScan("com.cmd")
  • 1
  • 2
  • 3
  • 4
  • 5

服务内部调用

现在的问题是,题目服务依赖用户服务,但是代码已经分到不同的包,找不到对应的Bean。
可以使用open Feign组件实现服务的远程调用

Open Feign:http调用客户端,提供了更方便的方式来让你远程调用其他服务
Nacos注册中心获取服务调用地址

1)梳理服务的调用关系,确定那些服务(接口)需要内部调用

用户服务:没有其他依赖

userService.getById(userId)
userService.getUserVo(user)
userService.listByIds(userIdSet)
userService.isAdmin(loginUser)
userService.getLoginUser(request)
  • 1
  • 2
  • 3
  • 4
  • 5

题目服务:

questionService.getById(questionId)
questionSubmitService.getById(questionSubmitId)
questionSubmitService.updateById(questionSubmitUpdate)
  • 1
  • 2
  • 3

判题服务:

judgeService.judge(questionSubmitId);
  • 1

3)实现client接口
对于用户服务,有一些不利于远程调用参数传递,或者实现起来非常简单(工具类,可以用默认方法实现)

开启openfeign的支持,把我们的接口暴露出去(服务注册到注册中心上),作为API给其他服务调用(其他服务从注册中心寻找)

需要修改每个服务提供者的context-path全局请求路径

服务提供者:理解为接口的实现类,实际提供服务的模块
服务消费者:理解为接口的调用方,需要去找到服务提供者,然后调用。

注意事项:

  1. 给每个接口添加GetMapper,PostMapper
  2. 该请求参数打上注解,比如:RequestParam,RequestBody
  3. FeignClient定义的请求路径一定要和服务提供方实际的请求路劲保持一致

4)修改各业务服务的调用者代码为:feignClient
5)编写 feign client 的实现类,注意要和之前定义的客户端保持一致

@RestController
@RequestMapping("/inner")
public class UserInnerController implements UserFeignClient {
    
    @Resource
    private UserService userService;

    /**
     * 根据 id 获取用户
     * @param userId
     * @return
     */
    @Override
    @GetMapping("/get/id")
    public User getById(@RequestParam("userId") long userId){
        return userService.getById(userId);
    }

    /**
     * 根据 id 获取用户列表
     * @param idList
     * @return
     */
    @Override
    @GetMapping("/get/ids")
    public List<User> listByIds(@RequestParam("idList") Collection<Long> idList) {
        return userService.listByIds(idList);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

6)开启Nacos,让各模块之间能够互相发现

需要引入starter (父模块)

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
  • 1
  • 2
  • 3
  • 4

增加配置(所有模块)

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
  • 1
  • 2
  • 3
  • 4
  • 5

给项目启动类上打上注解,开启服务发现,找到对应的 Bean 客户端的位置

@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.cmd.ojbackendserviceclient.service")
  • 1
  • 2

全局引入负载均衡依赖

<!-- 负载均衡 -->
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-loadbalancer</artifactId>
  <version>3.1.5</version>
</dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

7)启动项目,查看是否完成

微服务网关

微服务网关:Gateway聚合所有的接口,统一处理前端的请求

为什么要用

  • 所有的服务端口不同,增大了前端的调用成本
  • 所有服务是分散的,需要集中管理,比如:集中解决跨域,鉴权,接口文档,服务的路由,接口安全性,流量染色。

Gateway:向自定义一些功能,需要对这个技术有较深的理解

Gateway是应用层网关:有一定的业务逻辑(比如用户信息,判断权限)
Nginx是接入层网关:比如每个请求的日志,通常没有逻辑

接口路由

统一处理前端请求,转发收到的请求
如何找到目标路由,可以使用路由配置的 /api/来寻找对应的路径

spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      routes:
        - id: oj-backend-judge-service
          uri: lb://oj-backend-judge-service
          predicates:
            - PATH=/api/judge/**
        - id: oj-backend-question-service
          uri: lb://oj-backend-question-service
          predicates:
            - PATH=/api/question/**
        - id: oj-backend-user-service
          uri: lb://oj-backend-user-service
          predicates:
            - PATH=/api/user/**
  application:
    name: ob-backend-gateway

  main:
    web-application-type: reactive

server:
  port: 8085
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
聚合文档

使用Knife4j:https://doc.xiaominfo.com/docs/quick-start
使用Knife4j配置网关:https://doc.xiaominfo.com/docs/middleware-sources/spring-cloud-gateway/spring-gateway-introduction
以一个全局的视角集中查看管理接口文档
1)先给所有业务引入依赖,同时开启文档的配置
https://doc.xiaominfo.com/docs/quick-start#openapi2

knife4j:
  enable: true
  • 1
  • 2
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>
  • 1
  • 2
  • 3
  • 4
  • 5

2)给网关配置集中管理接口文档

knife4j:
  gateway:
    # 是否开启
    enabled: true
    # 排序规则(tag/operation排序自4.2.0版本新增)
    # 取值:alpha-默认排序规则,官方swagger-ui默认实现,order-Knife4j提供的增强排序规则,开发者可扩展x-order,根据数值来自定义排序
    tags-sorter: order
    operations-sorter: order
    # 指定服务发现的模式聚合微服务文档,并且是默认`default`分组
    strategy: discover
    # 服务发现模式的配置
    discover:
      # 开启
      enabled: true
      # 指定版本号(swagger2|openapi3)
      version: openapi3
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-gateway-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>
  • 1
  • 2
  • 3
  • 4
  • 5

3)访问网关地址即可:http://localhost:GatePort/doc.html#home

分布式 session 登录

解决 cookie 跨域解决问题:

server:
  address: 0.0.0.0
  port: 8202
  servlet:
    context-path: /api/user
    # cookie 30 天过期
    session:
      cookie:
        max-age: 2592000
        path: /api
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

跨域解决

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * 全局跨域配置
 */
@Configuration
public class CorsConfig {

    //当前跨域请求最大有效时间,这里设置为1天
    private static final long MAX_AGE = 24 * 60 * 60;

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        //        corsConfiguration.addAllowedOrigin("http://a.lalala.website");
        //        corsConfiguration.addAllowedOrigin("https://a.lalala.website"); //设置访问源地址
        // 改为实际的地址
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*"); //设置访问源请求头
        corsConfiguration.addAllowedMethod("*"); //设置访问源请求方法
        corsConfiguration.setMaxAge(MAX_AGE);
        source.registerCorsConfiguration("/**", corsConfiguration); //对接口配置跨域设置
        return new CorsFilter(source);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

权限验证

可以使用 Spring Cloud Gateway的 Filter 请求拦截方法,接受请求后的路径判断能否访问。

扩展:可以在网关实现接口限流降级:https://sca.aliyun.com/docs/2021/user-guide/sentinel/overview/
扩展:可以使用JTW Token实现用户登录,在网关层面通过token获取登录信息,实现鉴权

思考

真的有必要使用微服务吗
真的有必要使用 spring cloud 实现微服务吗
企业内部一般使用 API (RPC,HTTP)实现跨部门,跨服务的调用,数据格式和调用代码全部自动生成,保持统一,同时解耦。

12. 消息队列

消息队列解耦

此处选用消息队列改造项目,解耦判题服务和题目服务,题目服务只需要向消息队列发消息,判题服务从消息队列中取消息去执行判题,然后异步更新数据库即可。

基础实战

1)引入依赖:
注:使用的版本一定要和你的springboot版本对应

<!-- Spring Boot RabbitMQ Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
        <version>2.7.2</version>
    </dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

配置文件:

spring:
  rabbitmq:
    host: localhost
    port: 5672
    password: xxx
    username: xxx
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

初始化消息队列:

@Slf4j
public class InitRabbitMQ {
    public static void initRA() {
        try{

            ConnectionFactory connectionFactory = new ConnectionFactory();
            connectionFactory.setHost("localhost");
            Connection connection = connectionFactory.newConnection();
            Channel channel = connection.createChannel();
            String EXCHANGE_NAME = "code_exchange";
            channel.exchangeDeclare(EXCHANGE_NAME, "direct");

            // 创建队列 随机分配一个队列名称
            String queueName = "code_queue";
            channel.queueDeclare(queueName, true, false, false, null);
            channel.queueBind(queueName, EXCHANGE_NAME, "my_routingKey");
            log.info("消息队列启动成功");
        }catch (Exception e){
            log.error("消息队列启动失败");
        }
    }

    public static void main(String[] args){
        initRA();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

生产者代码:

@Component
public class MyMessageProducer {
    
    @Resource
    private RabbitTemplate rabbitTemplate;
    
    public void sendMessage(String exchange, String routingKey, String message){
        rabbitTemplate.convertAndSend(exchange,routingKey,message);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

消费者代码:

@Component
@Slf4j
public class MyMessageConsumer {

    // 指定程序监听的消息队列和确认机制
    @SneakyThrows
    @RabbitListener(queues = {"code_queue"}, ackMode = "MANUAL")
    public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag){
        log.info("receiveMessage message = {}", message);
        channel.basicAck(deliveryTag, false);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Li_阴宅/article/detail/984347
推荐阅读
相关标签
  

闽ICP备14008679号