1 Star 5 Fork 1

星星starss/OnlineJudge

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
Apache-2.0

yuoj线上判断系统

image-20230819171913811

1.前端

小型扩展点

1.关于界面以及头像问题

// 获取登录用户信息
const loginUser: LoginUserVO = computed(
  () => store.state.user?.loginUser
  // eslint-disable-next-line no-undef
) as LoginUserVO;

获取用户登录信息

<template
          v-if="loginUser && loginUser.userRole !== AccessEnum.NOT_LOGIN"
        >
          <template v-if="loginUser.userAvatar">
            <a-avatar shape="circle" :image-url="loginUser.userAvatar">
            </a-avatar>
          </template>
          <template v-else>
            <a-avatar shape="circle">
              <IconUser />
            </a-avatar>
          </template>
        </template>
        <template v-else>
          <a-avatar shape="circle" :style="{ backgroundColor: '#3370ff' }">
            <IconUser />
          </a-avatar>
        </template>

2.后端

1.初始化项目模板

  1. 先阅读 README.md 文件
  2. sql/create_table.sql: 定义了数据库初始化建表语句
  3. sql/post_es_mapping.json: 帖子表在ES 中的建表语句
  4. aop: 用于全局权限校验 全局日志记录
  5. common: 万用的类 比如通用相应类
  6. config: 用与接收 application.yml 中的参数,初始化一些客户端的配置类(比如对象存储客户端)
  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. wsmp:公众号相关的包
  18. MainApplication:项目主启动入孔
  19. Dockerfile:用于构建 Docker (docker 更细粒度的服务器 比如两个用户使用一个服务系统 docker可以开启一个小系统包含主系统的所有功能 小模块 并且相互之间独立互不影响)

2.前后端联调

前端和后端是通过 接口/请求 来连接

  1. 安装请求axios 文档 起步 | Axios 中文文档 | Axios 中文网 (axios-http.cn)

  2. 编写调用后端的代码

    传统情况下 每个请求都要单独编写代码 至少得写一个 请求路径

  3. 直接使用 生成 Service 代码。 直接调用函数发送请求即可

    openapi --input http://localhost:8121/api/v2/api-docs --output ./generated --client axios
    
    

    注意- 用户登陆后仍显示未登录

    是因为 OpenApi.ts 文件下的WITH_CREDENTIALS 赋值未改成True

    export const OpenAPI: OpenAPIConfig = {
        BASE: 'http://localhost:8121',
        VERSION: '1.0',
        WITH_CREDENTIALS: true,
        CREDENTIALS: 'include',
        TOKEN: undefined,
        USERNAME: undefined,
        PASSWORD: undefined,
        HEADERS: undefined,
        ENCODE_PATH: undefined,
    };
    

    比如 获取用户登录信息

      actions: {
        async getLoginUser({ commit, state }, payload) {
          // 从远程获取用户登录信息
          const res = await UserControllerService.getLoginUserUsingGet();
          if (res.code === 0) {
            commit("updateUser", res.data);
          } else {
            commit("updateUser", {
              ...state.loginUser,
              userRole: ACCESS_ENUM.NOT_LOGIN,
            });
          }
        },
      },
    
  4. 如果想要自定义参数 怎么办?

    1. 修改使用代码生成器 提供的全局参数修改对象

      image-20230803191711380

      export const OpenAPI: OpenAPIConfig = {
          BASE: 'http://localhost:8121',
          VERSION: '1.0',
          WITH_CREDENTIALS: false,
          CREDENTIALS: 'include',
          TOKEN: undefined,
          USERNAME: undefined,
          PASSWORD: undefined,
          HEADERS: undefined,
          ENCODE_PATH: undefined,
      };
      
      
    2. 直接定义 axios 请求库的全局参数, 比如全局请求响应拦截器

      在 src 下新建包 plugins

      import axios from "axios";
      
      axios.interceptors.response.use(
        function (response) {
          console.log("响应", response);
          // 2xx 范围内的状态码都会触发该函数。
          // 对响应数据做点什么
          return response;
        },
        function (error) {
          // 超出 2xx 范围的状态码都会触发该函数。
          // 对响应错误做点什么
          return Promise.reject(error);
        }
      );
      
      

3. 用户登录

自动登录

  1. 在 store\user.ts 编写远程登录用户信息的代码

    actions: {
        async getLoginUser({ commit, state }, payload) {
          // 从远程获取用户登录信息
          const res = await UserControllerService.getLoginUserUsingGet();
          if (res.code === 0) {
            commit("updateUser", res.data);
          } else {
            commit("updateUser", {
              ...state.loginUser,
              userRole: ACCESS_ENUM.NOT_LOGIN,
            });
          }
        },
      },
    
  2. 在哪里去触发一下 getLoginUser 的函数执行?

    应当在一个全局的位置

    有很多选择:

    • 路由拦截
    • 全局页面入口 app.vue
    • 全局通用布局(所有页面共享的组件)

全局权限管理优化

  1. 新建 access\index 文件,把原有的路由拦截,权限校验逻辑放在独立的文件中

    • 优势:只要不引入 就不会开启 不会对项目有影响
  2. 编写权限管理和自动登录的逻辑

    router.beforeEach(async (to, from, next) => {
      // 登录的数据 权限信息
      const loginUser = store.state.user.loginUser;
      //  如果 之前没登陆过 自动登录
      if (!loginUser || !loginUser.userRole) {
        //
        await store.dispatch("user/userLogin");
      }
    
      const needAccess = to.meta?.access ?? ACCESS_ENUM.NOT_LOGIN;
      //  要跳转的页面必须要登录
      if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
        // 如果没有登录 则跳转到登录页面
        if (!needAccess) {
          next("/user/login?redirect=${to.fullPath}");
          return;
        }
        //如果已经登录了  但是权限不足 则跳转到无权限页面
        if (!checkAccess(loginUser, needAccess)) {
          next("/noAuth");
          return;
        }
      }
    
      next();
    });
    

    支持多套布局

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

       {
          path: "/user",
          name: "用户",
          component: UserLayout,
          children: [
            {
              path: "/user/login",
              name: "用户登录",
              component: UserLoginView,
            },
            {
              path: "/user/register",
              name: "用户注册",
              component: UserRegisterView,
            },
          ],
        },
      
    2. 新建一套 UserLayout UserLoginView UserRegisterView 页面,并且在 routes 中引入

    3. 在 app.vue 根页面文件 根据路由去区分多套布局

      <div id="app">
          <template v-if="route.path.startsWith('/user')">
            <router-view />
          </template>
          <template v-else>
            <BasicLayout />
          </template>
        </div>
      

    image-20230803230811131

    登录页面开发

4.数据库表的创建

题目表

将判题输入用例,输出用例等 存入json对象

好处是: 便于扩展 只需要改变对象内部的字段 ,而不去修改数据库表(可能会影响数据库)

judgeConfig 判题配置 (json对象):

  • 时间限制: timeLimit
  • 内存限制: memoryLimit
  • 输入用例: inputCase

judgeCase 判题用例(json 数组)

  • 每一个元素包含一个输入用例和一个相对应的输出用例

    [
       {
       "input": "1,2",
       "output": "3,4"
       },
       {
       "input": "1,5",
       "output": "3,4"
       }
    ]
    

judgeConfig

{
    "timeLimit" :1000,
    "memoryLimit": 1000,
    "stackLimit":1000
}

存 json 的前提:

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

题目表

-- 题目表
create table if not exists post
(
    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;

题目提交表

哪儿个用户提交了哪道题目,存放判题结果等

提交用户 id : userId

题目 id : questionId

语言: language

用户的代码: code

判题状态: status(0-待判题,1-判题中,2-成功,3- 失败)

判题信息(判题过程中得到的一些信息, 比如程序的失败原因,程序执行消耗的空间 时间):judgeInfo(Json对象)

--  题目提交表
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-判题中,2-成功,3- 失败)',
    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 0                 not null comment '是否删除',
    index idx_questionId (questionId),
    index idx_userId (userId)
) comment '题目提交表';
{
    "message": "程序执行信息",
    "time": 1000, // ms
    "memory": 1000  //kb
}

判断信息枚举值:

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

小知识 - 数据库索引

什么情况下适合加索引? 如何选择给哪个字段加索引

答: 首先从业务出发,无论是单个索引,还是联合索引,都要从实际的查询语句,字段枚举值的区分度,字段的类型考虑 (where条件的指定字段)

比如 select * from 表 where userId= ? and questionId=?

可以根据userId 和 questionId 分别建立索引(需要分别根据这两个字段单独查询) ; 也可以选择给这两个字段建立联合索引(所查询的字段总是捆绑在一起的)

原则上: 能不用索引就不用索引; 能用单个索引就不要用联合/多个索引。 不要给没区分度的字段加索引 (比如性别 就 男 或者 女) 因为索引也是要占用空间的

后端接口开发

后端开发流程

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

代码生成方法

1)安装 mybatisX 插件

2)根据项目去调整生成配置,建议生成代码到独立的包 不要影响老的项目

image-20230814192935606

3) 把代码从生成包中转移到实际项目包中

4)找相似的代码去复制 Controller

  • 单表去复制单表 Controller (比如 question => post)
  • 关联表去复制关联表 (比如 question_submit => post_thumb ) 也就是说 用户 => 点赞 可类比为 用户 =>提交

5)复制实体类相关的DTO、VO、枚举字段(用于接受前端请求,或者业务间的传递信息)复制之后,调整需要的字段

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

6)为了更方便地处理 json字段中的某个字段,需要给对应的json 字段编写独立的类。比如 judgeCase judgeInfo judgeConfig 示例代码:

/**
 * 题目用例
 */
@Data
public class JudgeCase {

    /**
     * 输入用例
     */
    private String input;

    /**
     * 输出用例
     */
    private String output;

}

DTO: 业务层面的封装类

什么情况下要加业务前缀? 什么情况下不加?

加业务前缀的好处: 能防止多个表都有类似的类。产生冲突;不加的前提:因为可能这个类是多个业务之间共享的 能够复用的

定义 VO 类: 作用是专门给前端返回对象,可以节约网络传输大小、或者过滤字段(脱敏) 保证安全性

比如: judgeCase answer 这些字段是不允许直接让用户看到的

脱敏:是屏蔽敏感数据,对某些敏感信息(比如,身份证号、手机号、卡号、客户姓名、客户地址、邮箱地址、薪资等等 )通过脱敏规则进行数据的变形,实现隐私数据的可靠保护。****

7) 校验Controller 层的代码, 看看除了要调用的方法缺失外,还有无报错

8) 实现 Service 层的代码 从对应的已经编写号的实现类复制粘贴 全局替换(比如 question => post)

9) 编写QuestionVO 的 json / 对象转换工具类

10)用同样的方法,编写 questionSubmit 提交类, 这次参考 postthumb 相关文件

11)编写枚举类

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

小知识

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

    /**
     * id
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

(Integer不穿可以用默认)

查询提交信息

功能: 能够根据用户id,或者题目id,编程语言,题目状态,去查询提交记录

注意事项: 仅本人和管理员能看见自己(提交的 userId 和登录用户 id 不同的)提交代码的答案、提交代码

实现方案: 先查询 再根据权限去脱敏

核心代码:

@Override
    public QuestionSubmitVO getQuestionSubmitVO(QuestionSubmit questionSubmit, User loginUser) {
        QuestionSubmitVO questionSubmitVO = QuestionSubmitVO.objToVo(questionSubmit);
        //脱敏:仅本人和管理员能看见自己(提交的 userId 和登录用户 id 不同的)提交代码
        Long userId = loginUser.getId();
        //处理脱敏
        if (userId != questionSubmit.getUserId() && !userService.isAdmin(loginUser)){
            questionSubmitVO.setCode(null);
        }
        return questionSubmitVO;
    }

3.前端

以开发前端页面为主:

1)用户注册页面

2)创建题目页面(管理员)

3)题目管理页面(管理员)

  • 查看
  • 删除
  • 修改
  • 快捷创建

4)题目列表页(用户)

5)题目详情页(在线做题页)

  • 判题状态查看

6)题目提交列表页

扩展: 提交统计页、用户个人页

接入要用到的组件

先接入可能用到的组件,再去写页面,避免因为后续依赖冲突,整合组件失败带来的返工

Markdown 编辑器

为什么用 Markdown?

一套通用的文本编辑语法,可以在各大网站上统一标准 渲染出统一的样式 比较简单易学

推荐的Md编辑器 github-> bytemd

阅读官方文档,下载编辑器主题,以及gfm(表格支持)插件,highlight 代码高亮插件

image-20230820091831876

新建MdEditor 组件,编写代码

<template>
  <Editor :value="value" :plugins="plugins" @change="handleChange" />
</template>

<script setup lang="ts">
import gfm from "@bytemd/plugin-gfm";
import highlight from "@bytemd/plugin-highlight";
import { Editor, Viewer } from "@bytemd/vue-next";
import { ref, withDefaults, defineProps } from "vue";

interface Props {
  value: string;
  handleChange: (v: string) => void;
}

const plugins = [
  gfm(),
  highlight(),
  // Add more plugins here
];

const props = withDefaults(defineProps<Props>(), {
  value: () => "",
  handleChange: (v: string) => {
    console.log(v);
  },
});
</script>

<style scoped

></style>

父组件给子组件传值的方式

要把 MdEditor 当前输入的值暴露给父组件,便于父组件去使用, 同时也是提高组件的通用性,需要定义属性,要把 value 和 handleChange 事件交给父组件去管理:

MdEditor示例代码

<template>
  <Editor :value="value" :plugins="plugins" @change="handleChange" />
</template>

<script setup lang="ts">
import gfm from "@bytemd/plugin-gfm";
import highlight from "@bytemd/plugin-highlight";
import { Editor, Viewer } from "@bytemd/vue-next";
import { ref, withDefaults, defineProps } from "vue";

/**
 * 定义组件类型
 */
interface Props {
  value: string;
  handleChange: (v: string) => void;
}

const plugins = [
  gfm(),
  highlight(),
  // Add more plugins here
];

const props = withDefaults(defineProps<Props>(), {
  value: () => "",
  handleChange: (v: string) => {
    console.log(v);
  },
});
</script>

<style scoped></style>

代码编辑器

微软官方: manaco-editor

1)安装编辑器

npm instal了monaco-editor

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

npm install monaco-editor-webpack-plugin
const { defineConfig } = require("@vue/cli-service");
const MonacoWebPackPlugin = require("monaco-editor-webpack-plugin");

module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack(config) {
    config.plugin(new MonacoWebPackPlugin({}));
  },
});

image-20230821092837989

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

注意, monaco editor 在读写值的时候 要使用toRaw(编辑器实例) 的语法

页面开发

创建题目页面

重新根据后端生成前端请求代码

openapi --input http://localhost:8121/api/v2/api-docs --output ./generated --client axios
{
  "answer": "暴力破解",
  "content": "题目内容",
  "judgeCase": [
    {
      "input": "1 2",
      "output": "3 4"
    }
  ],
  "judgeConfig": {
    "memoryLimit": 1000,
    "stackLimit": 1000,
    "timeLimit": 1000
  },
  "tags": ["栈","简单"],
  "title": "A+B"
}

代码模板

在 JetBrains 系列编辑器中

file->setting-Live template 先创建一个模板组,在模板组下创建代码模板, 操作完之后直接输入模板名+tab键即可获得代码模板

示例模板:

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

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

<style scoped>
#$ID$ {
}
</style>

image-20230821204838422

使用表单组件,先复制示例代码 再修改 arco-design的组件库

此处我们用到了嵌套表和动态增减表单

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

题目管理页面开发

  1. 使用表格组件 找到自定义操作的示例

  2. 查询数据

  3. 定义表格列

  4. 加载数据

  5. 调整格式

    比如 json 不好看 有两种方法调整

    1. 使用组件库自带的方法,自动格式化 (更方便) 表格列 arco-design
    2. 完全自定义渲染,想展示什么展示什么 (更灵活)
  6. 添加删除 更新操作

    删除后如何更新:可以使用 table 组件自带的 table methods

  7. 优化

更新页面开发

策略: 由于更新和创建都是相同的表单,所以完全没必要开发/复制2遍, 可以直接复用创建页面。

关键实现: 如何区分两个页面

  1. 路由 (/add/question 和 /update/question)
  2. 请求参数 (id=1..)

更新页面相比于创建页面, 多了2个改动:

  1. 在加载页面时,更新页面需要加载出之前的数据
  2. 在提交时,请求的地址不同

第四期

当前代码优化

1) 处理菜单项的权限控制和显示隐藏

通过 meta.hideInMenu 和 meta.access 属性控制

2) 管理页面分页问题的修复

核心实现: 在分页页号改变时, 触发 @page-change 事件,通过改变 searchParams 的值, 并且通过 watchEffect 监听 searchParams 的改便 (然后执行loadData 重新加载), 实现了页号变化时触发的重新加载

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

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

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

题目列表搜索页

核心实现 表格组件

1) 复制管理题目的表格

2) 只保留需要的 columns 字段

3)自定义表格列的渲染

标签: 使用 tag组件

通过率: 自行计算

创建时间: 使用meoment 库进行 格式化渲染

操作按钮:跳转到做题页面 使用模板字符串的写法

4) 编写搜索表单, 使用 form 的 layout = inline 布局, 让用户的输入和 searchParam 同步, 并且给提交按钮绑定修改 searchParams 从而被 watchEffect 监听到,触发查询

题目浏览页

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

  {
    path: "/view/question/:id",
    name: "在线做题",
    component: ViewQuestionsView,
    props: true,
    meta: {
      access: ACCESS_ENUM.USER,
      hideInMenu: true,
    },
  },

2) 定义布局: 左侧是题目信息, 右侧是代码编辑器

3) 左侧题目信息:

  • tabs 切换展示内容
  • 定义 MdViewer 组件展示题目内容
  • 使用 description 组件展示判题配置

4) 使用 select 组件让用户选择编程语言

判题机模块预开发

目的:跑通完整的业务流程

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

判题模块: 调用代码沙箱,把代码和输入交给代码沙箱去执行

代码沙箱: 只负责接受代码和输入, 返回编译运行的结果, 不负责判题 (代码沙箱 可作为一个独立的项目, 提供给其他需要执行代码项目去使用)

这两个模块完全解耦

image-20230827162145644

思考:为什么代码沙箱需要接受和输出一组运行用例

前提: 我们的每一道题目有多组测试用例

如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译,记录程序的执行状态(重复的代码 不重复编译)

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

开发代码沙箱模块

1) 定义代码沙箱的接口 提高通用性

之后我们的代码只需要调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不需要去修改名称了 便于扩展

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

2)定义多种不同的代码沙箱实现

示例代码沙箱:

远程代码沙箱:

第三方代码沙箱: github => go-judge

小知识 - LomBok Builder 注解

实体类加上:

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

    private List<String> inputList;

    private  String code ;

    private String language;
}

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

3)

    @Test
    void executeCode(){
        CodeSandBox codeSandBox = new ExampleCodeSandBox();
        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);

    }

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

扩展思路: 如果确定代码沙箱不会出现线程安全问题, 可复用 那么可以使用单例工厂模式

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

application.yml 配置文件中配置

# 代码沙箱配置
codesandbox:
  type: example

在 spring 的 bean 中通过 @Value 注解读取

    @Value("${codesandbox.type:example}")
    private String type;

6) 代码沙箱能力增强

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

显然 每一个代码沙箱类的前后 写一个 log.info 是不合理的 这里引入代理模式

使用代理模式, 提供一个 proxy 来增强代码沙箱的能力

使用代理后: 不仅不用改变原本的代码沙箱实现类, 而且对调用这来说,调用机会方式没有改变,也不需要在每个调用代码沙箱的地方去写统计代码。

7)实现示例的代码沙箱

/**
 * 示例代码沙箱 (仅为了跑通业务流程)
 */
public class ExampleCodeSandBox implements CodeSandBox {
    /**
     * 执行代码
     *
     * @param executeCodeRequest
     * @return
     */
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        List<String> inputList = executeCodeRequest.getInputList();
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputlist(inputList);
        executeCodeResponse.setMessage("测试执行成功");
        executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());
        JudgeInfo judgeInfo = new JudgeInfo();
        judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());
        judgeInfo.setMemory(100L);
        judgeInfo.setTime(100L);
        executeCodeResponse.setJudgeInfo(judgeInfo);
        return null;
    }
}

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

判题服务业务流程:

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

判断逻辑:

  1. 先判断沙箱执行的结果 输出数量是否和预期输出数量相等
  2. 依次判断每一项输出和预期输出是否相等
  3. 判断题目的限制是否符合要求
  4. 可能还有其他的异常情况

策略模式优化判题代码

我们的判题策略会有很多种, 比如:我i们的代码沙箱本身执行程序索要花费的事件 这个时间不同的编程语言时不同的,

我们可以采用策略模式, 针对不同的情况, 定义独立的策略, 而不是把所有的 if else 代码全部混在一提起

首先编写默认判题模块

如果选择某种判题策略过程比较复杂, 都写在调用判题服务的代码中,代码会越来越复杂, 会有很多的 if---*--else 所以 建议单独编写写一个类

/**
 * 判题管理 (简化调用)
 */
@Service
public class JudgeManager {

    /**
     * 执行判题
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext){
        QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
        String language = questionSubmit.getLanguage();
        JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
        if ("java".equals(language)){
            judgeStrategy= new JavaLanguageJudgeStrategy();
        }
        return judgeStrategy.doJudge(judgeContext);
    }
}

定义 JudgeManager 目的时尽量简化对判题功能的调用,让调用方最简单

也就是说 我们将判断的方法再封装 这次包含有判断语言的功能 可以不需要if判断

/**
 * 判题管理 (简化调用)
 */
@Service
public class JudgeManager {

    /**
     * 执行判题
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext){
        QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
        String language = questionSubmit.getLanguage();
        JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
        if ("java".equals(language)){
            judgeStrategy= new JavaLanguageJudgeStrategy();
        }
        return judgeStrategy.doJudge(judgeContext);
    }
}

代码沙箱的实现

代码沙箱: 只负责接受代码和输入 返回编译运行的结果, 不负责判题(可以作为独立的项目/ 服务, 提供给其他的需要执行代码的项目去使用)

以java编程语言为主 ,实现代码沙箱

扩展: 实现 c++ 语言的代码沙箱

将代码沙箱做成微服务项目

新建一个 Springboot web 项目, 最终这个项目提供一个能够执行代码 操作代码沙箱的解雇偶

选择java8、 springboot 2.7 版本

编写测试接口,验证能否访问

package com.yuoj.yuojcodesandbox.controller;


import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController("/")
public class MainController {


    @GetMapping("/health")
    public String healthCheck(){
        return "OK";
    }
}

Java 原生实现代码沙箱

原生: 尽可能不借助第三方库和依赖,用最干净、最原始的方式实现代码沙箱

代码沙箱需要: 接收代码 => 编译代码(javac) => 执行代码 (java)

去掉包名放在 resource 目录下

为什么编译后会中文乱码

因为 命令行的编码是 GBK, 和 java 代码文件本身的编码 UTF-8 不一致 导致乱码

通过chcp 命令查看编码 GBK 是 936 UTF-8 时 65001

但是不建议改变终端代码来解决乱码,因为其他运行你代码时也要改变环境 兼容性很差

正确的代码支持:

D:\Oj\yuoj-code-sandbox\src\main\resources\testCode\simpleComputeArgs>javac -encoding utf-8 SimpleCompute.java

实际OJ系统中, 对用户输入的代码会有一定的要求, 便于系统统一地处理,所以此处,我们把用户输入代码的类名限制为 Main(参考北大 OJ) 可以减少类名不一致带来的风险。 而且不用从用户代码中提取类名 更方便


核心实现流程

核心实现思路: 用程序的代替人工, 用程序来操作命令行 ,去编译执行代码

Java 进程执行管理类: Process

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

引入Hutool 工具类

      <!-- https://hutool.cn/docs/index.html#/-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>

新建目录,把每个用户的代码存放在独立目录中 便于维护

编译代码, 得到class文件

java执行程序

 String compileCmd = String.format("javac -encoding utf-8%s",userCodeFile.getAbsoluteFile());
  Process compileProcess = Runtime.getRuntime().exec(compileCmd);

Java获取控制台输出

通过 exitValue 判断程序是否正常返回 从 inputStream 和 errorStream 获取控制台的输出

  Process compileProcess = Runtime.getRuntime().exec(compileCmd);
            // 等待程序执行 获取错误码
            int exitValue = compileProcess.waitFor();
            //正常退出
            if (exitValue ==0){
                System.out.println("编译成功");
                // 分批获取程序的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine())!= null){
                    compileOutputStringBuilder.append(compileOutputLine);
                }
                System.out.println(compileOutputStringBuilder);
            }else {
                // 分批获取程序的正常输出 从进程的输入流里面读取
                System.out.println("编译失败 . 错误码"+exitValue);
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine())!= null){
                    compileOutputStringBuilder.append(compileOutputLine);
                }

                // 分批获取程序的正常输出
                System.out.println("编译失败 . 错误码"+exitValue);
                BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getErrorStream()));
                StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String errorCompileOutputLine;
                while ((errorCompileOutputLine = errorBufferedReader.readLine())!= null){
                    errorCompileOutputStringBuilder.append(errorCompileOutputLine);
                }
                System.out.println(compileOutputStringBuilder);
            }

编写一个工具类,执行进程,并且获取输出

package com.yuoj.yuojcodesandbox.utils;

import com.yuoj.yuojcodesandbox.model.ExecuteMessage;

import java.io.BufferedReader;
import java.io.InputStreamReader;

/**
 * 进程工具类
 */
public class ProcessUtils {

    /**
     * 执行进程并获取信息
     * @param runProcess
     * @param opName
     * @return
     */
    public static ExecuteMessage runProcessAndGetMessage(Process runProcess,String opName){
        ExecuteMessage executeMessage = new ExecuteMessage();
        try {
            int exitValue = runProcess.waitFor();
            executeMessage.setExitValue(exitValue);
            //正常退出
            if (exitValue ==0){
                System.out.println(opName+"编译成功");
                // 分批获取程序的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine())!= null){
                    compileOutputStringBuilder.append(compileOutputLine);
                }
                executeMessage.setMessage(compileOutputStringBuilder.toString());
                System.out.println(compileOutputStringBuilder);
            }else {
                // 分批获取程序的正常输出 从进程的输入流里面读取
                System.out.println(opName+"编译失败 . 错误码"+exitValue);
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                StringBuilder compileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine())!= null){
                    compileOutputStringBuilder.append(compileOutputLine);
                }
                executeMessage.setMessage(compileOutputStringBuilder.toString());

                // 分批获取程序的正常输出
                System.out.println("编译失败 . 错误码"+exitValue);
                BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
                StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String errorCompileOutputLine;
                while ((errorCompileOutputLine = errorBufferedReader.readLine())!= null){
                    errorCompileOutputStringBuilder.append(errorCompileOutputLine);
                }
                executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return executeMessage;

    }
}

执行程序

如果遇到乱码 加上 -Dfile.encoding=UTF-8

 //3.  执行代码, 得到输出结果
        for (String inputArgs : inputList) {
            String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s",userCodeParentPath,inputArgs);
            try {
                Process process = Runtime.getRuntime().exec(runCmd);
                ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(process, "运行");
                System.out.println(executeMessage);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

        }

很多 OJ 都是ACM 模式 , 需要和用户进行交互, 让用户不断的输入内容并输出比如:


对于此类程序,我们需要使用 OutputStream 想程序中断发送参数 并及时获取结果

注意关闭流

    /**
     * 执行交互式进程并获取信息
     * @param runProcess
     * @param opName
     * @return
     */
    public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess,String opName,String args){
        ExecuteMessage executeMessage = new ExecuteMessage();
        try {
            // 想控制台输入程序
            InputStream inputStream = runProcess.getInputStream();
            OutputStream outputStream = runProcess.getOutputStream();
            String[] s = args.split(" ");
            OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
            outputStreamWriter.write(StrUtil.join("\n",s)+"\n");
            outputStreamWriter.flush();

            // 分批获取程序的正常输出
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder compileOutputStringBuilder = new StringBuilder();
            // 逐行读取
            String compileOutputLine;
            while ((compileOutputLine = bufferedReader.readLine())!= null){
                compileOutputStringBuilder.append(compileOutputLine);
            }
            executeMessage.setMessage(compileOutputStringBuilder.toString());
            outputStreamWriter.close();
            outputStream.close();
            inputStream.close();
            runProcess.destroy();
        }catch (Exception e){
            e.printStackTrace();
        }
        return executeMessage;

    }
整理输出

获取程序执行时间,使用 spring 的StopWatch

            StopWatch stopWatch = new StopWatch();
            stopWatch.start();
            stopWatch.stop();
            executeMessage.setTime(stopWatch.getLastTaskTimeMillis());

此处我们使用最大来统计时间:

扩展:可以每个测试用例都有一个独立的内存, 时间占用的统计

5.文件清理

防止服务器空间不足:

        if(userCodeFile.getParentFile()!=null){
            boolean del = FileUtil.del(userCodeParentPath);
            System.out.println("删除"+(del ? "成功" : "失败"));
        }
6. 错误处理

封装一个错误处理方法,当程序抛出异常时,直接返回错误响应

   /**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getErrorResponse(Throwable e){
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputlist(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 表示代码沙箱错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return  executeCodeResponse;
        
    }

异常情况演示

到目前为止, 核心流程已经实现, 但是想要上线的话 是不安全的

用户会提交恶意代码 怎么办?

1) 执行阻塞 占用资源不释放

时间上搞

/**
 * 无限睡眠 (程序阻塞)
 */
public class SleepError {
    public static void main(String[] args) throws InterruptedException {
        long ONE_HOUR = 60*60*1000L;
        Thread.sleep(ONE_HOUR);
        System.out.println("睡完了~·");
    }
}

要将写过的代码复制到 resource 目录下 并且把类名改为 Main 报名去掉

2) 占用内存,不释放

空间上搞

package com.yuoj.yuojcodesandbox.unsafeCode;

import java.util.ArrayList;

public class MemoryError {
    public static void main(String[] args) {
        ArrayList<byte[]> bytes = new ArrayList<>();
        while (true){
            bytes.add(new byte[100000]);
        }
    }
}

在实际的运行中, 我们会发现,内存占用到达一定空间后, 程序就会报错:

image-20230905201222811

这是 JVM 的一个保护机制

JVisualVM 或 JConsole工具, 可以连接到 JVM 虚拟机上 来查看JVM 的状态

System.getProperty("user.dir");** 解释: 默认定位到的当前用户目录("user.dir")(即工程根目录) JVM就可以据"user.dir" + "你自己设置的目录" 得到完整的路径(即绝对路径) 这有个前提,你的工程不是web项目,不然,这个返回值就不是项目的根目录啦,是tomcat的bin目录。

3) 读文件 信息泄露

直接通过相对路径获取文件信息

package testCode.unsafeCode;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class Main {
    public static void main(String[] args) throws IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator+ "src/main/resources/application.yml";
        List<String> allLines = Files.readAllLines(Paths.get(filePath));
        System.out.println(String.join("\n",allLines));
    }
}

4) 写文件 , 越权植入木马

假设有一个木马程序: java -version 2>&1

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;

public class Main {
    public static void main(String[] args) throws IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator+ "src/main/resources/木马程序.bat";
        String errorProgram = "java -version 2>&1";
        Files.write(Paths.get(filePath), Arrays.asList(errorProgram));
        System.out.println("木马植入成功, 你完了 哈哈");
    }
}

5) 执行电脑上的程序

操作电脑上的其他程序

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * 执行程序
 */
public class Main {
    public static void main(String[] args) throws IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator+ "src/main/resources/木马程序.bat";
        Process process = Runtime.getRuntime().exec(filePath);
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
        String compileLine;
        while ((compileLine = bufferedReader.readLine())!= null){
            System.out.println(compileLine);
        }
        System.out.println("木马程序执行成功");
    }
}

6) 执行高危命令

甚至都不用写木马程序,直接执行系统自带的高危命令

比如删除服务器的所有文件(残暴的一批)

比如执行 dir(windows) 、 ls(Linux) 获取你系统上的所有文件信息

解决方案:

1) 超时控制

通过创建一个守护线程, 超时后终端process实现

     //超时控制
                new Thread(()->{
                    try {
                        Thread.sleep(TIME_OUT);
                        System.out.println("超时了。。中断");
                        process.destroy();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }).start();

2)限制给用户分配的资源

我们不能让每个 java 进程的执行占用的 JVM 最大堆内存空间都和系统的一致,实际上应该小一点,比如说256MB

在启动 Java 时 可以指定 JVM 的参数: -Xmx256M (最大堆空间大小) -Xms(初始堆空间大小)

String compileCmd = String.format("javac -Xmx256m -encoding utf-8 %s", userCodeFile.getAbsoluteFile());

注意: -Xmx 参数。 JVM的堆内存限制,不等同于系统实际占用的最大资源,可能会超出。

如果需要更严格的内存限制,要在系统层面去限制,而不是JVM层面的限制。

如果是Linux 系统, 可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配

3)限制代码 - 黑白名单

初始化字典树

Hutool 字典树工具类: WordTree, 可以用更少的空间存储更多的敏感词汇,实现更高效的敏感词查找

    private static final List<String> blackList = Arrays.asList("Files","exec");

    private static final WordTree WORD_TREE;

    static {
        //初始化字典树
        WORD_TREE = new WordTree();
        WORD_TREE.addWords(blackList);
    }
    //校验黑名单代码
        FoundWord foundWord = WORD_TREE.matchWord(code);
        if (foundWord!=null){
            System.out.println(foundWord.getFoundWord());
            return null;
        }

使用字典树可以节约空间

可以写在简历上

缺点:

  1. 我们无法遍历所有的黑名单
  2. 不同的编程语言, 不同的领域、关键词都不一样,限制人工成本很大

4)限制用户的操作权限(文件,网络,执行)

限制用户对文件、内存、CPU、网络等资源的操作和访问

Java 安全管理器 (Security Manager)来实现更严格的限制

package com.yuoj.yuojcodesandbox.security;

import java.security.Permission;

/**
 * 默认安全管理器
 */
public class DefaultSecurityManager extends  SecurityManager{

    @Override
    public void checkPermission(Permission perm) {
        System.out.println("默认不做任何操作");
        super.checkPermission(perm);
    }
}


package com.yuoj.yuojcodesandbox.security;

import java.security.Permission;

/**
 * 禁用所有的安全管理器
 */
public class DenySecurityManager extends  SecurityManager{

    @Override
    public void checkPermission(Permission perm) {
        throw new SecurityException("权限异常00"+ perm.toString());
    }
}

实际情况下, 我们只需要限制子程序的权限即可,没必要限制开发者自己写的程序,

1)编写安全管理器

2)编译安全管理器,去掉包名

3)在运行java程序时,指定安全管理器的路径、安全管理器的名称。

-Djava.security.manager=MySecurityManager Main %s

优点

权限控制灵活,实现简单

缺点:

  1. 如果要做比较严格的权限限制,需要自己去判断那些文件,包名允许读写,粒度太细了,难以精细化控制
  2. 安全管理器本身也是java代码, 也有可能存在漏洞(本身也是程序上的限制,没到系统层面)

5)运行环境隔离

系统层面上,把用户程序封装到沙箱里, 和宿主机(我们的电脑/服务器) 隔离开

Docker 容器技术能够实现(底层是用 cgroup,namespace 等方式实现的)

第六期,Docker

为什么要用 Docker 容器技术? 为了提升系统的安全性 把程序和宿主机隔离

Docker 技术可以实现和宿主机的隔离

什么是容器?

理解为一系列得到应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内、对外整体提供服务

Docker 基本概念

镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像

容器:通过镜像来创建一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例

Dockerfile: 制作镜像的文件,可以理解为制作镜像的一个清单

image-20230909155129140

镜像仓库: 存放镜像的仓库,用户可以从仓库下载线程的镜像,也可以把做好的镜像放到仓库里

推荐使用 docker 官方的镜像仓库

Docker实现原理

看图理解:

1)Docker 运行在 Linux 内核上

2)CGroups: 实现了容器的资源隔离,底层是 Linux CGroup 命令,能够控制进程使用的资源

3)NetWork网络:实现容器的网络隔离,docker容器内部的网络互不影响

4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同命名空间下的进程互补影响。

5)storage:存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件

image-20230909155418859

image-20230909160929398

安装Docker

一般情况下,不建议在 Windows 系统上安装

Windows 本身就是带了个虚拟机 叫 WSL,不建议,不如一个专业的 隔离的虚拟机软件要方便

Linux使用的是 Ubuntu系统

使用免费的 VMWare Workstation Player 软件:

Docker 常用操作 - 命令行

如果启动失败

1)增加权限

2)重启虚拟机! 重启远程开发环境! 重启程序!

1)查看命令方法

docker --help

查看具体子命令的方法:

docker run --help

2) 从远程仓库拉取现成的镜像

用法:

 docker pull [OPTIONS] NAME[:TAG|@DIGEST]
docker pull hello-world

3)根据镜像创建容器实例:

启动实例,得到容器实例 containerId:

sudo docker create hello-world

4) 查看容器状态:

sudo docker ps -a

5) 启动容器:

docker start [OPTIONS] CONTAINER [CONTAINER...]

启动实例:

sudo docker start confident_banzai

6) 查看日志

docker logs[OPTIONS] CONTAINER

启动日志

sudo docker logs confident_banzai

7) 删除容器实例

sudo docker rm confident_banzai

8) 删除进项

docker rmi --help

Java 操作 Docker

使用 docker-java: github 搜索

引入pom依赖

        <dependency>
            <groupId>com.github.docker-java</groupId>
            <artifactId>docker-java</artifactId>
            <version>3.3.0</version>
        </dependency>

        <dependency>
            <groupId>com.github.docker-java</groupId>
            <artifactId>docker-java-transport-httpclient5</artifactId>
            <version>3.3.0</version>
        </dependency>

DockerClientConfig: 用于定义初始化 DockerClient 的配置(类比 MYSQL 的连接,线程数配置)

DockerHttpClient: 用于向 Docker 的守护进程 (操作 Docker 的接口) 发送请求的客户端,低层封装 不推荐使用, 自己构建请求参数 (类比 JDBC)

DockerClient(推荐): 才是真正和 Docker 守护进程交互的 最方便的 SDK,高层封装 是对 DockerHttpCLient 再去进行了一层封装(理解为 Mybatis) 提供了增删改查

Linux Docker 远程开发

使用 IDEA Development 先上传代码到Linux,然后使用 JetBrains 远程开发完全连接Linux 实时开发

如果无法启动程序,修改 setting 的compiler配置

-Djdk.lang.Process.launchMechanism=vfork

拉取镜像:

public static void main(String[] args) throws InterruptedException {
        // 获取默认的 Docker Client
        DockerClient dockerClient = DockerClientBuilder.getInstance().build();
        String image="nginx:latest";
        PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
        PullImageResultCallback pullImageResultCallback = new PullImageResultCallback(){
            @Override
            public void onNext(PullResponseItem item) {
                System.out.println("下载镜像"+item.getStatus());
                super.onNext(item);
            }
        };
        pullImageCmd.
                exec(pullImageResultCallback).
                awaitCompletion();
        System.out.println("下载完成");
    }

创建容器:

    //创建容器
        CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
        CreateContainerResponse createContainerResponse = containerCmd
                .withCmd("echo","Hello docker")
                .exec();
        System.out.println(createContainerResponse);	

查看容器状态:

      //查看容器状态
        ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
        List<Container> containerList = listContainersCmd.withShowAll(true).exec();
        for (Container container : containerList) {
            System.out.println(container);
        }

启动容器:

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

启动日志

// 查看日志
        LogContainerResultCallback logContainerResultCallback = new LogContainerResultCallback() {
            @Override
            public void onNext(Frame item) {
                System.out.println("日志信息: " + new String(item.getPayload()));
                super.onNext(item);
            }
        };
        dockerClient.logContainerCmd(containerId).
                withStdErr(true)
                .withStdOut(true).
                exec(logContainerResultCallback).awaitCompletion();

删除容器

删除镜像

  //删除容器
        dockerClient.removeContainerCmd(containerId).withForce(true).exec();

        //删除镜像
        dockerClient.removeImageCmd(containerId).exec();

Docker 实现代码沙箱

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

  1. 把用户的代码保存为文件
  2. 编译代码, 得到class文件
  3. 把编译好的文件上传到容器环境内
  4. 在容器中执行代码, 得到输出结果
  5. 收集整理输出结果
  6. 文件清理
  7. 错误处理,提升程序健壮性
 `扩展:模板方法设计模式,定义同一套实现流程,让不同的子类去负责不同流程中的具体实现,执行步骤一样,但每个步骤的实现方式不一样

3.创建容器, 上传编译文件

自定义容器的两种方式:

1)在已有镜像的基础上再扩充:比如拉取线程的 Java环境 (包含jdk),再把编译后的文件复制到容器里,适合新项目、跑通流程

2)完全自定义容器: 适合比较成熟的项目, 比如封装多个语言的环境和实现

思考: 我们每个测试用例都单独创建一个容器,每个容器只执行一次 java 命令吗?

不是的 这样会浪费性能, 所有要创建一个可交互的容器,能够接收多次输入并且输出

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

4. 启动容器 执行代码

Docker 执行容器命令 (操作已启动容器):

Usage:  docker exec [OPTIONS] CONTAINER COMMAND [ARG...]

docker exec kenn_blackwell java -cp /app Main 1 3

注意,要把命令按照空格拆分,作为一个数组传递,否则可能会被识别为一个字符串 而不是多个参数

创建命令:

  CreateContainerResponse createContainerResponse = containerCmd
                .withHostConfig(hostConfig)
                .withAttachStderr(true)
                .withAttachStdin(true)
                .withAttachStdout(true)
                .withTty(true)
                .exec();

执行命令:

 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) {
                throw new RuntimeException(e);
            }
        }

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

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

获取程序占用内存:

 // 监控占用的内存 获取占用的内存
            StatsCmd statsCmd = dockerClient.statsCmd(containerId);
            ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {

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

                @Override
                public void onStart(Closeable closeable) {

                }
                @Override
                public void onError(Throwable throwable) {

                }

                @Override
                public void onComplete() {

                }

                @Override
                public void close() throws IOException {

                }
            });
            //无休止的监控 这个回调没有 awaitCompletion

Docker 容器安全

1) 超时控制

执行容器时,可以增加超时参数控制

   dockerClient.execStartCmd(execId)
                        .exec(execStartResultCallback)
                        .awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);

但是,这种方式无论超时与否,程序都会继续往下执行,无法判断是否超时。

可以定义一个超时标志, 如果程序执行完成, 就把这个超时标志设置为Fakse

2) 内存控制

通过HostConfig 进行限制

 CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
        HostConfig hostConfig = new HostConfig();
        hostConfig.withMemory(100 * 1000 * 1024L);
        hostConfig.withCpuCount(1L);
        // 和 硬盘的交互
        hostConfig.withMemorySwap(0L);
        hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));

        // 三个 with 必须开启  作用:把docker容器和本地的终端进行连接 能够获取到输入并且能够获取到输出
        CreateContainerResponse createContainerResponse = containerCmd
                .withHostConfig(hostConfig)
                .withAttachStderr(true)
                .withAttachStdin(true)
                .withAttachStdout(true)
                .withTty(true)
                .exec();

3) 网络资源

创建容器时,设置网络配置为关闭:

   CreateContainerResponse createContainerResponse = containerCmd
                .withHostConfig(hostConfig)
                .withNetworkDisabled(true)

4) 权限管理配置

Docker 容器已经做了系统层面的隔离, 比较安全,但不能保证绝对安全

  1. 结合 Java 安全管理器和其他策略去使用

  2. 限制用户不能向 根目录root 去写文件

      CreateContainerResponse createContainerResponse = containerCmd
                    .withHostConfig(hostConfig)
                    .withNetworkDisabled(true)
                    .withReadonlyRootfs(true)
    
  3. Linux 自带的一些安全管理措施 seccomp(Security Compute Mode) Linux内核安全机制

微服务模块开发

模板方法优化代码沙箱

模板方法; 定义一套通用的执行流程,让子类负责每个执行步骤的具体实现

模板方法的适用场景: 使用于有规范的流程,且执行流程可以复用

作用: 大幅节省重复代码,便于项目扩展,更好维护

1.抽象出具体的流程

定义模板方法抽象类。

先复制具体的实现类, 把代码从完整的方法抽离成一个一个的子写法

 @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
//        System.setSecurityManager(new DenySecurityManager());

        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.  文件清理

        //6.  错误处理,提升程序健壮性
        boolean b = deleteFile(userCodeFile);
        if (!b){
            log.error("deleteFile Error,userCodeFIle={}",userCodeFile.getAbsoluteFile());
        }
        return outputResponse;
    }

setting->editor action->folding 查看折叠

3.定义子类的具体实现

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

/**
 * Java  原生代码沙箱实现(直接复用模板方法)
 */
public class JavaNativeCodeSandbox extends JavaCodeSandboxTemplate {

    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        return super.executeCode(executeCodeRequest);
    }
}

Docker实现代码沙箱 使用模板方法实现

/**
 * Java Docker 代码沙箱模板方法的具体实现
 */
public class JavaDockerCodeSandBox extends JavaCodeSandboxTemplate{


    private static final long TIME_OUT = 5000L;

    public static final Boolean First_INIT = true;


    public static void main(String[] args) {
        JavaDockerCodeSandBox javaNativeCodeSandBox = new JavaDockerCodeSandBox();

        ExecuteCodeRequest executeCodeRequest = new ExecuteCodeRequest();
        executeCodeRequest.setInputList(Arrays.asList("1 2", "1 3"));
//        String code = "";

//        executeCodeRequest.setInputList(Arrays.asList(" 1 2 ", "3 4 "));

//        String code = ResourceUtil.readStr("/home/xingxing/yuoj-code-sandbox/src/main/java/testCode/simpleCompute/SimpleCompute.java", StandardCharsets.UTF_8);
//        String code = ResourceUtil.readStr("testCode/unsafeCode/RunFileError.java", StandardCharsets.UTF_8);
        String code = ResourceUtil.readStr("/home/xingxing/yuoj-code-sandbox/src/main/resources/testCode/unsafeCode/SleepError.java", StandardCharsets.UTF_8);
        executeCodeRequest.setCode(code);
        executeCodeRequest.setLanguage("java");
        ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandBox.executeCode(executeCodeRequest);
        System.out.println(executeCodeResponse);

    }

    /**
     * 3. 创建容器 把文件赋值到容器内
     * @param userCodeFile
     * @param inputList
     * @return
     */
    @Override
    public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {

        String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
        // 获取默认的 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 item) {
                    System.out.println("下载镜像" + item.getStatus());
                    super.onNext(item);
                }
            };
            try {
                pullImageCmd.
                        exec(pullImageResultCallback).
                        awaitCompletion();
            } catch (InterruptedException e) {
                System.out.println("拉取镜像异常");
                throw new RuntimeException(e);
            }
        }

        System.out.println("下载完成");
        //创建容器
        CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
        HostConfig hostConfig = new HostConfig();
        hostConfig.withMemory(100 * 1000 * 1024L);
        hostConfig.withCpuCount(1L);
        // 和 硬盘的交互
        hostConfig.withMemorySwap(0L);
        hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));

        // 三个 with 必须开启  作用:把docker容器和本地的终端进行连接 能够获取到输入并且能够获取到输出
        CreateContainerResponse createContainerResponse = containerCmd
                .withHostConfig(hostConfig)
                .withNetworkDisabled(true)
                .withReadonlyRootfs(true)
                .withAttachStderr(true)
                .withAttachStdin(true)
                .withAttachStdout(true)
                .withTty(true)
                .exec();
        System.out.println(createContainerResponse);
        String containerId = createContainerResponse.getId();

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

        //执行命令 并获取结果
        //docker exec kenn_blackwell java -cp /app Main 1 3
        List<ExecuteMessage> executeMessageList = new ArrayList<>();
        for (String inputArgs : inputList) {
            StopWatch stopWatch = new StopWatch();
            String[] inputArgsArray = inputArgs.split(" ");
            String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main", "1", "3"}, inputArgsArray);
            ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
                    .withCmd(cmdArray)
                    .withAttachStderr(true)
                    .withAttachStdin(true)
                    .withAttachStdout(true)
                    .exec();

            System.out.println("创建执行命令: " + execCreateCmdResponse);

            ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
            ExecuteMessage executeMessage = new ExecuteMessage();
            final String[] message = {null};
            final String[] errorMessage = {null};
            long time = 0L;
            final boolean[] timeout = {true};
            String execId = execCreateCmdResponse.getId();
            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("输出错误结果" + errorMessage[0]);
                    } else {
                        message[0] = new String(frame.getPayload());
                        System.out.println("输出结果" + message[0]);
                    }
                    super.onNext(frame);
                }
            };

            final long[] maxMemory = {0L};
            // 监控占用的内存 获取占用的内存
            StatsCmd statsCmd = dockerClient.statsCmd(containerId);
            ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {

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

                }
                @Override
                public void onError(Throwable throwable) {

                }

                @Override
                public void onComplete() {

                }

                @Override
                public void close() throws IOException {

                }
            });
            //无休止的监控 这个回调没有 awaitCompletion
            statsCmd.exec(statisticsResultCallback);
            try {
                stopWatch.start();
                dockerClient.execStartCmd(execId)
                        .exec(execStartResultCallback)
                        .awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
                stopWatch.stop();
                time = stopWatch.getLastTaskTimeMillis();
                statsCmd.close();
            } 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;
    }




}

2.给代码沙箱提供开放 API

直接再controller层暴露接口

/**
     * 执行代码
     *
     * @param executeCodeRequest
     * @return
     */
    ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
        if (executeCodeRequest == null){
            throw new RuntimeException("请求参数为空");
        }
        return javaNativeCodeSandbox.executeCode(executeCodeRequest);
    }

调用安全性

如果将服务不做任何的权限校验,直接发到公网,是不安全的

1) 调用方与服务方之间约定一个字符串, (最好加密)

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

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

代码沙箱服务, 先定义约定的字符串:

   public static final String AUTH_REQUEST_HEADER="auth";
   public static final String AUTH_REQUEST_SECRET="secretKey";

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

 /**
     * 执行代码
     *
     * @param executeCodeRequest
     * @return
     */
    @PostMapping("/executeCode")
    ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest,
                                    HttpServletRequest request,
                                    HttpServletResponse response){
        String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
        if (!authHeader.equals(AUTH_REQUEST_HEADER)){
            response.setStatus(403);
            return null;
        }
        if (executeCodeRequest == null){
            throw new RuntimeException("请求参数为空");
        }
        return javaNativeCodeSandbox.executeCode(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();

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

详细看 API 开放平台项目

跑通整个项目流程

1) 移动 questionSubmitController 到questionController 里面

2) 由于后端改了接口地址,前端需要重新生成接口调用代码

3)后端调试

4) 开发提交列表页面

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

单体项目改造成微服务

新建一个项目

什么是微服务?

服务: 提供某类功能的代码

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

微服务几个重要的实现因素:服务管理,服务调用,服务拆分

一个公司:一个人干活,这个人生病了 公司寄了

一个公司有多个不同的岗位,多个人干活,一个人不影响整个公司的运作。各个模块各个组之间可能需要交互来完成大的项目/

微服务实现技术

Spring Cloud

本项目采用 Spring Cloud Alibaba

Dubbo(Dubbox)

RPC (GRPC、TRPC)

本质上是通过HTTP、 或者其他网络协议进行通讯开发实现的

Spring Cloud Alibaba

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

  1. 1. Spring Cloud Gateway:网关
    2. Nacos:服务注册和配置中心
    3. Sentinel:熔断限流
    4. Seata:分布式事务
    5. RocketMQ:消息队列,削峰填谷
    6. Docker:使用Docker进行容器化部署
    7. Kubernetes:使用k8s进行容器化部署
    

spring-cloud

注意: 一定要选择对应的版本

image-20230919214043742

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

整个微服务请求流程:

项目结构示意图

Discovery 另一个分布式微服务框架

改造前思考

从业务需求出发,思考单机和分布式锁的区别。

用户登录功能:需要改造为分布式登录

其他内容

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

改造分布式登录

1) application.yml 增加 redis 配置

2) 补充依赖

3 ) 主类取消 Redis 自动配置的移除

4) 修改 session 存储方式:

 session:
   # todo 取消注释开启分布式 session(须先配置 Redis)
    store-type: redis
   # 30 天过期
    timeout: 2592000

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

image-20230927091544118

微服务的划分

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

公司老板给员工分工

依赖服务:

  • 注册中心: Nacos
  • 微服务网关(yuoj-backed-gateway): Gateway 聚合所有的接口,统一接受处理前端的请求

公共模块:

  • common 公共模块(yuoj-backed-common): 全局异常处理器,请求响应封装类、公用的工具类等
  • model 模型模块(yuoj-backed-model): 很多服务都有公用的实体类
  • 公用接口模块(yuoj-backed-service-client): 只存放接口,不存放实现(多个服务之间要共享)

1 用户服务(yuoj-backed-user-service : 8102 端口)

  • 注册
  • 登录
  • 用户管理

2 题目服务(yuoj-backed-question-service: 8103 端口)

  • 创建题目(管理员)
  • 删除题目(管理员)
  • 修改题目(管理员)
  • 搜索题目(管理员)
  • 在线做题(题目详情页)
  • 题目提交(管理员)

3 . 判题模块((yuoj-backed-judge-service: 8104 端口)较重的操作)

  • 执行判题逻辑
  • 错误处理(内存溢出,安全性,超时)
  • 自主实现代码沙箱
  • 开放接口
代码沙箱服务本身就是独立在这个项目以外的,可以不用纳入 Spring Cloud 的管理

路由的划分

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

用户服务:

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

题目服务:

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

判题服务:

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

Nacos 注册中心启动 2.2.0 版本

在bin目录下输入

startup.cmd -m standalone

默认账号面膜 nacos nacos

新建工程

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

建议用脚手架创建项目

给项目增加全局依赖配置文件。

使用spring-cloud-alibaba 也需要引入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>

openFeign : 模块之间相互引用的组件

依次使用 new modules 和 spring boot Initializr 创建各模块:

image-20230922174504935

需要给字符模块之间绑定父子依赖关系:

父模块声明

 <modules>
        <module>yuoj-backend-common</module>
        <module>yuoj-backend-model</module>
        <module>yuoj-backend-gateway</module>
        <module>yuoj-backend-service-client</module>
        <module>yuoj-backend-judge-service</module>
        <module>yuoj-backend-user-service</module>
        <module>yuoj-backend-question-service</module>
    </modules>

子模块引入 parent

    <parent>
        <groupId>com.yupi</groupId>
        <artifactId>yuoj-backed-microservice</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

通过继承父模块配置,统一项目的定义和版本号

同步代码和依赖

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

在外层的xml中引入公共类

 <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.9.1</version>
        </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>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
            <version>4.4</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>

2) model 模型模块(yuoj-backed-model): 很多服务都有公用的实体类

3) 公用接口模块(yuoj-backed-service-client): 只存放接口,不存放实现(多个服务之间要共享)

先无脑搬运所有service 在剔除 judgeService 也需要粘

image-20230922195705307

无法识别的版本可以取父依赖中寻找

4) 具体业务服务实现

给所有业务服务引入依赖

  <dependency>
                <groupId>com.yupi</groupId>
                <artifactId>yuoj-backend-common</artifactId>
                <version>0.0.1-SNAPSHOT</version>
            </dependency>
        <dependency>
            <groupId>com.yupi</groupId>
            <artifactId>yuoj-backend-model</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    <dependency>
            <groupId>com.yupi</groupId>
            <artifactId>yuoj-backend-service-client</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

引入主类注解

引入 application.yml 配置

服务内部调用

现在的问题是,题目服务依赖用户服务,但是代码已经分到不同的包,找不到对应的Bean。

可以使用 Open Feign 组件实现跨服务的远程调用

Open Feign:Http 调用客户端, 提供了更方便的方式来让你远程调用其他服务 不用关心项目的调用地址

Nacos 注册中心获取服务调用地址

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

用户服务:没有其他依赖

题目服务:

 userService.getById(userId)
userService.getUserVO(user)
 userService.listByIds(userIdSet)
userService.getUserVO(user)
 userService.getLoginUser(request)

判题服务:

 questionFeignClient.getQuestionSubmitById(questionSubmitId);
questionService.getById(questionId)
 questionSubmitService.updateById(questionSubmitUpdate)

2) 确认要提供哪些服务

用户服务:没有其他依赖

userService.getById(userId):

userService.getUserVO(user)

userService.listByIds(userIdSet)

userService.getUserVO(user)

userService.getLoginUser(request)

题目服务

questionFeignClient.getQuestionSubmitById(questionSubmitId);

questionService.getById(questionId)

questionSubmitService.updateById(questionSubmitUpdate)

判题服务

judgeService.doJudge(questionSubmitId)

3) 实现client 接口

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

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

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

服务提供者:理解为接口的实现类,实际提供服务提供方法的模块

服务消费者:理解为接口的调用方,需要取调用服务提供者的具体实现,解决问题

server:
  address: 0.0.0.0
  port: 8104
  servlet:
    context-path: /api/judge

注意事项:

  1. 要给接口的每个方法打上请求注解,注意区分 Get,Post
  2. 要给请求参数打上注解,比如 RequestParam、 RequestBody
  3. FeignClient 定义的请求路径一定要和服务提供方实际的请求路径保持一致

4)修改各业务服务的调用代码为 feignClient

5) 编写 feignClient 客户端服务的实现类

@RestController
@RequestMapping("/inner") 
public class JudgeInnerController implements JudgeFeignClient {

    @Resource
    private JudgeService judgeService;

    @Override
    @PostMapping("/do")
    public QuestionSubmit doJudge(@RequestParam("questionSubmitId") long  questionSubmitId){
        return judgeService.doJudge(questionSubmitId);
    }
}

6) 开启 Nacos 让服务之间可以互相发现

所有模块引入 Nacos依赖 。然后给所有服务(包括网关) 添加如下配置

spring:
    cloud:
      nacos:
        discovery:
          server-addr: 127.0.0.1:8848

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

@EnableDiscoveryClient
@EnableFeignClients(basePackages = {"com.yupi.yuojbackendserviceclient.service"})

全局引入负载均衡依赖

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

微服务网关

微服务网关(yuoj-backed-gateway): Gateway 聚合所有的接口,统一接受处理前端的请求

是什么?

为什么要用?

  • 所有的服务端口不同,增大了前端调用成本
  • 所有服务是分散的,你可以集中进行管理,操作,比如集中解决跨域,鉴权,接口文档,服务的路由,接口安全性、流量染色、限流
Gateway: 想自定义一些功能,需要对这个基数有比较深的理解

Gateway 是应用层网关: 会有一定的业务逻辑(比如根据用户信息判断权限)

Nginx 是接入层网关: 比如每个请求的日志,通常没有业务逻辑

接口路由

统一地接受前端的请求,转发请求到对应的服务

如何找到路由? 可以编写一套路由, 通过 api 地址前缀来找到对应的服务

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
    gateway:
      routes:
        - id: yuoj-backend-user-service
          uri: lb://yuoj-backend-user-service
          predicates:
            - Path=/api/user/
        - id: yuoj-backend-question-service
          uri: lb://yuoj-backend-question-service
          predicates:
            - Path=/api/question/**
        - id: yuoj-backend-judge-service
          uri: lb://yuoj-backend-judge-service
          predicates:
            - Path=/api/judge/**
  application:
    name: yuoj-backend-gateway
  main:
    web-application-type: reactive
server:
  port: 8101
 

聚合文档

以一个全局的视角集中查看管理接口文档

使用 Knifej 接口文档生成器, 非常方便: 1) 先给所有业务服务引入依赖,同时开启接口文档的配置

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-jakarta-spring-boot-starter</artifactId>
    <version>4.3.0</version>
</dependency>
Knife4j:
  enable: true

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

引入依赖

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-gateway-spring-boot-starter</artifactId>
    <version>4.3.0</version>
</dependency>

引入配置

knife4j:
  gateway:
    # ① 第一个配置,开启gateway聚合组件
    enabled: true
    # ② 第二行配置,设置聚合模式采用discover服务发现的模式
    strategy: discover
    discover:
      # ③ 第三行配置,开启discover模式
      enabled: true
      # ④ 第四行配置,聚合子服务全部为Swagger2规范的文档
      version: swagger2

地址:http://localhost:8101/doc.htm

必须引入分布式依赖

        <!-- redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
<!--        将session放在redis中存储-->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

解决cookie 的跨路径问题

跨域解决

  @Bean
    public CorsWebFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedMethod("*");
        config.setAllowCredentials(true);
        // todo 实际改为线上真实域名、本地域名
        config.setAllowedOriginPatterns(Arrays.asList("*"));
        config.addAllowedHeader("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);
        return new CorsWebFilter(source);
    }

扩展: 可以在网关实现 Sentinel 接口限流降级

​ 可以使用JWT Token实现用户登录, 在网关层面通过 token 获取登录信息,实现鉴权

思考

真的有必要使用微服务吗?

真的有必要使用 Spring Cloud 实现微服务么?

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

消息队列解耦

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

·

交换机

   private static final String EXCHANGE_NAME = "fanout-exchange";
    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()) {
            // 创建交换机
            channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNext()) {
                String message = scanner.nextLine();
                channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));
                System.out.println(" [x] Sent '" + message + "'");
            }
        }
    }
}
Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

简介

微服务-在线判题系统 展开 收起
Java 等 2 种语言
Apache-2.0
取消

发行版

暂无发行版

贡献者

全部

近期动态

不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
Java
1
https://gitee.com/programmer-star/online-judge.git
git@gitee.com:programmer-star/online-judge.git
programmer-star
online-judge
OnlineJudge
master

搜索帮助