Coding Paradise笔记

一. 项目的介绍

1.项目功能

项目的开发流程

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

项目的亮点

  • 权限校验

  • 谁能提代码,谁不提代码

  • 代码沙箱(安全沙箱)

  • 用户代码藏毒:写个木马文件、修改系统权限

  • 沙箱:隔离的、安全的环境,用户的代码不会影响到沙箱之外的系统的运行

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

  • 判题规则

  • 题目用例的比对,结果的验证

  • 任务调度

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

项目模块

  • 题目模块
  1. 创建题目(管理员)

  2. 删除题目(管理员)

  3. 修改题目(管理员)

  4. 搜索题目(用户)

  5. 在线做题

  6. 提交题目代码

  • 用户模块
  1. 注册

  2. 登录

  3. 用户管理(管理员)

  • 判题模块
  1. 提交判题(结果是否正确与错误)

  2. 错误处理(内存益出、安全性、超时)

  3. 自主实现 代码沙箱(安全沙箱)

  4. 开放接口(提供一个独立的新服务)

扩展功能

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

2.项目的设计

流程图

process

时序图

process1

判题服务:获取题目信息、预计的输入输出结果,返回给主业务后端:用户的答案是否正确
代码沙箱:只负责运行代码,给出程序运行的结果,不用管用户提交的程序是否正确。
因此判题服务和代码沙箱 实现了解耦

架构设计

process2

3.现有系统的调研

OJ系统调研

二. 前后端项目的搭建

前端

1.权限控制

定义权限枚举类和校验方法

权限枚举类

/**
 * 权限定义
 */
const ACCESS_ENUM = {
  NOT_LOGIN: "notLogin",
  USER: "user",
  ADMIN: "admin",
};

export default ACCESS_ENUM;

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. ctrl + shift + F 全局搜索后 ctrl + shift + R 替换项目名 springboot-init
  2. 替换 springboot 为自己包名
  3. 更换根目录名称 和项目二级名称
  4. 更改数据库配置,链接数据库

全局搜索记得在项目中更改,以及记得执行sql文件

二. 模板讲解介绍

1.AOP(权限校验和日志记录)

步骤

  • 创建切面类,@Aspect表示该类是一个切面,@Component
  • 作用范围

@PointCut:切点,不能有方法体。
@Before:程序执行之前
@After:程序执行之后
@Around:环绕整个程序执行
@AfterReturning:返回之前

使用切面表达式或者注解

  • JoinPoint常用参数,ProceedingJoinPoint是子接口,增加了proceed 方法 表示继续 执行
String toLongString();
//获得当前的代理类对象 
Object getThis();
//获取被代理的目标对象 常用
Object getTarget();
//获取被代理的方法参数 常用
Object[] getArgs();
//获取连接点的方法签名 展开具体的连接点的签名 常用
Signature getSignature(); 
//获取 资源的位置
SourceLocation getSourceLocation();
//获得连接点的通知类型

权限校验实例

/**
 * 权限校验 AOP
 * @author siyi
 */
@Aspect
@Component
public class AuthInterceptor {

    @Resource
    private UserService userService;

    /**
     * 执行拦截
     *
     * @param joinPoint //这个参数可以获取到方法名和参数
     * @param authCheck //这个参数可以获取到注解的值
     * @return
     */
    @Around("@annotation(authCheck)") // 拦截注解
    public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
        String mustRole = authCheck.mustRole();
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes(); // 获取请求,这个对象可以获取到请求的所有信息
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); // 获取请求,这个对象可以获取到请求的所有信息
        // 当前登录用户
        User loginUser = userService.getLoginUser(request);
        // 必须有该权限才通过
        if (StringUtils.isNotBlank(mustRole)) {
            UserRoleEnum mustUserRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
            if (mustUserRoleEnum == null) {
                throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
            }
            String userRole = loginUser.getUserRole();
            // 如果被封号,直接拒绝
            if (UserRoleEnum.BAN.equals(mustUserRoleEnum)) {
                throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
            }
            // 必须有管理员权限
            if (UserRoleEnum.ADMIN.equals(mustUserRoleEnum)) {
                if (!mustRole.equals(userRole)) {
                    throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
                }
            }
        }
        // 通过权限校验,放行
        return joinPoint.proceed();
    }
}
2.模板目录介绍
  • sql/post_es_mapping.json:帖子表在ES中的建表语句
  • aop:用于全局权限校验、全局日志记录
  • common:万用的类,比如通用响应类
  • config:用于接收application.yml中的参数,初始化一些客户端的配置类(比如对象存储客户端)
  • constant:定义常量
  • controller:接受请求
  • esdao:类似mybatis的mapper,用于操作ES
  • exception:异常处理相关
  • job:任务相关(定时任务、单次任务)
  • manager:服务层(一般是定义一些公用的服务、对接第三方APl等)
  • mapper:mybatis的数据访问层,用于操作数据库
  • mode:数据模型、实体类、包装类、枚举值
  • service:服务层,用于编写业务逻辑
  • utls工具类,各种各样公用的方法
  • test:单元测试MainApplication:项目启动入口
  • Dockerfile:用于构建Docker镜像

三.前后端联调

1.使用第三方工具open API

步骤

1.分别下载axios,以及open API

yarn add axios
npm install openapi-typescript-codegen --save-dev

官网: https://github.com/ferdikoomen/openapi-typescript-codegen

2.生存接口文件, API接口文档在后端接口文档地址的分组Url中复制粘贴,此处就是/api/v3/api-docs/default,后面的--output ./generated 指的是文件名,这里指定文件名generated

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

后期更换了端口与聚合文档新命令: openapi --input http://localhost:8101/api/user/v2/api-docs --output ./generated --client axios

3.成功后寻找文件generated 即可得到结果

四.主体业务开发

1.系统功能的梳理

  1. 用户模块
  • 注册(后端已实现)

  • 登录(后端已实现,前端已实现)

  1. 题目模块
  • 创建题目(管理员)

  • 删除题目(管理员)

  • 修改题目(管理员)

  • 搜索题目(用户)

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

  1. 判题模块
  • 提交判题(结果是否正确与错误)

  • 错误处理(内存益出、安全性、超时)

  • 自主实现 代码沙箱(安全沙箱)

  • 开放接口(提供一个独立的新服务)

2.数据库表的设计

用户表

-- 用户表
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 '密码',
    userName     varchar(256)                           null comment '用户昵称',
    userAvatar   varchar(1024)                          null comment '用户头像',
    userProfile  varchar(512)                           null comment '用户简介',
    gender       varchar(256) default '男'              null comment '性别 男 女',
    phone        varchar(128)                           null comment '电话',
    email        varchar(512)                           null comment '邮箱',
    userState    varchar(256) default '正常'            not null comment '状态:0-正常/1-注销/2-封号',
    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 (userAccount)
) comment '用户表' collate = utf8mb4_unicode_ci;

题目表

  • 题目内容:存放题目的介绍、输入输出提示、描述、具体的详情

  • 题目标签:栈、队列、链表、简单、中等、困难

  • 题目答案:管理员 / 用户设置的标准答案

  • 提交数:便于分析统计

  • 判题相关字段:

    • 输入用例:1、2

    • 输出用例:3、4

    • 时间限制

    • 内存限制

    judgeConfig 判题配置 (Json对象):

    • 时间限制 timeLimit

    • 内存限制 memoryLimit

    • 栈大小限制 stackLimit

如果说题目不是很复杂,用例文件大小不大的话,可以直接存在数据库表里

如果用例文件比较大,> 512KB 建议单独存放在一个文件中,数据库中只保存文件ul(类似存储用户头像)

judgeCase判题用例 (json数组)
[
	{
		"imput":"1 2"
		"output":"3 4"
	},
	{
		"imput":"1 3"
		"output":"2 4"
	}
]

问: 什么时候存Json格式

存 JSON 的前提:

  1. 不需要根据某个字段去倒查这条数据(例如这里不需要根据题目的条件去查寻一道题)
  2. 字段含义相关,属于同一类的值
  3. 字段存储空间占用不能太大
-- 题目表
create table if not exists question
(
    id          bigint auto_increment comment 'id' primary key,
    userId      bigint                             not null comment '创建题目用户 id',
    title       varchar(512)                       null comment '标题',
    content     text                               null comment '内容',
    tags        varchar(1024)                      null comment '标签列表(json 数组)',
    answer      text                               null comment '题目答案',
    judgeCase   text                               null comment '判题用例(json 数组)',
    judgeConfig text                               null comment '判题配置(json 对象)',
    submitNum   int      default 0                 not null comment '题目提交数',
    acceptedNum int      default 0                 not null comment '题目通过数',
    thumbNum    int      default 0                 not null comment '点赞数',
    favourNum   int      default 0                 not null comment '收藏数',
    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 对象)

提交代码后的返回:
{
  "message": "程序执行信息",
  "time": 1000, // 消耗时间,单位为 ms
  "memory": 1000, // 消耗内存,单位为 kb
}

参考北大OJ枚举

  • ○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,
    questionId     bigint                             not null comment '题目 id',
    userId         bigint                             not null comment '创建用户 id',
    judgeInfo      text                               null comment '判题信息(json 对象)',
    submitLanguage varchar(128)                       not null comment '编程语言',
    submitCode     text                               not null comment '用户提交代码',
    submitState    int      default 0                 not null comment '判题状态(0 - 待判题、1 - 判题中、2 - 成功、3 - 失败)',
    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 '题目提交';
关于索引

到底什么时候该加索引?

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

比如:where userId = 1 and questionId = 2

在这张表中,可以选择根据 userId 和 questionId 分别建立索引(需要分别根据这两个字段单独查询);根据需求也可以加到status

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

3.后端接口的开发

后端开发流程

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

流程

  1. 安装/BatisX插件
  2. 根据项目去调整生成配置,建议生成代码到独立的包,不要影响老的项目
  3. 把代码从生成包中移到实际项目对应目录中
  4. 找相似的代码去复制Controller (本项目question使用帖子,题目提交使用的是帖子点赞)
  5. 单表去复制单表Controller(比如question=>post)
  6. 关联表去复制关联表(比如question_submit=>post_thumb)
  7. 复制实体类相关的DTO、VO、枚举值字段(用于接受前端请求、或者业务间传递信息)复制之后,调整需要的字段
  8. 定义 VO 类:作用是专门给前端返回对象,可以节约网络传输大小、或者过滤字段(脱敏)、保证安全性。比如 judgeCase、answer 字段,一定要删,不能直接给用户答案。
  9. 校验Controller层的代码,看看除了要调用的方法缺失外,还有无报错
  10. 实现Service层的代码,从对应的已经编写好的实现类复制粘贴,全局替换(比如question=>post)
  11. 编写QuestionVO的json/对象转换工具类
  12. 用同样的方法,编写questionSubmit提交类,这次参考postThumb相关文件
  13. 编写枚举类

五.前端项目开发

1.需要的页面

  1. 用户注册页面
  2. 创建题目页面(管理员)
  3. 题目管理页面(管理员)
  • 查看(搜索)

  • 删除

  • 修改

  • 快捷创建

  1. 题目列表页(用户)

  2. 题目详情页(在线做题页)

  • 判题状态的查看
  1. 题目提交列表页

2.接入需要的组件

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

Markdown 编辑器

推荐的 Md 编辑器:https://github.com/bytedance/bytemd

安装命令:

 npm i @bytemd/vue-next  //安装Vue3 版本
 npm i @bytemd/plugin-highlight @bytemd/plugin-gfm//插件

//yarn安装
yarn add @bytemd/vue-next
yarn add bytemd  // 为了使用多语言 locals目录在bytemd下
yarn add @bytemd/plugin-highlight @bytemd/plugin-gfm

新建组件

<template>
  <div class="home">
    <MdEditor />
    <img alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import HelloWorld from "@/components/HelloWorld.vue";
import MdEditor from "@/components/MdEditor.vue"; // @ is an alias to /src

export default defineComponent({
  name: "HomeView",
  components: {
    MdEditor,
    HelloWorld,
  },
});
</script>

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

取消图标(这里需要注意不能使用scrop,否则样式会无效)

<style>
.bytemd-toolbar-icon.bytemd-tippy.bytemd-tippy-right:last-child {
  display: none;
}
</style>

子组件

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

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

const props = withDefaults(defineProps<Props>(), {
  value: () => "",
  handleChange: (v: string) => {
    console.log(v);
    return "";
  },
});
const plugins = [
  gfm(),
  highlight(),
  // Add more plugins here
];
const value = ref("");
const handleChange = (v: string) => {
  value.value = v;
};
</script>

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

<style scoped>
.bytemd-toolbar-icon.bytemd-tippy.bytemd-tippy-right:last-child {
  display: none;
}
</style>

父组件

<template>
  <div class="home">
    <MdEditor :handle-change="onChange" :value="value" />
    <img alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";
import HelloWorld from "@/components/HelloWorld.vue";
import MdEditor from "@/components/MdEditor.vue"; // @ is an alias to /src
const value = ref();
const onChange = (v: string) => {
  value.value = v;
};
</script>

代码编辑器

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

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

安装

npm install monaco-editor
npm install monaco-editor-webpack-plugin

//yarn安装

yarn add monaco-editor
yarn add monaco-editor-webpack-plugin # 将webpack和文本编辑器整合在一起,便于打包和安装

有两种导入方式,在 vue.config.js 中配置 webpack 插件:

按需加载

const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = {
  chainWebpack: config => {
    config.plugin('monaco-editor').use(MonacoWebpackPlugin, [
      {
        // Languages are loaded on demand at runtime
        languages: ['json', 'go', 'css', 'html', 'java', 'javascript', 'less', 'markdown', 'mysql', 'php', 'python', 'scss', 'shell', 'redis', 'sql', 'typescript', 'xml'], // ['abap', 'apex', 'azcli', 'bat', 'cameligo', 'clojure', 'coffee', 'cpp', 'csharp', 'csp', 'css', 'dart', 'dockerfile', 'ecl', 'fsharp', 'go', 'graphql', 'handlebars', 'hcl', 'html', 'ini', 'java', 'javascript', 'json', 'julia', 'kotlin', 'less', 'lexon', 'lua', 'm3', 'markdown', 'mips', 'msdax', 'mysql', 'objective-c', 'pascal', 'pascaligo', 'perl', 'pgsql', 'php', 'postiats', 'powerquery', 'powershell', 'pug', 'python', 'r', 'razor', 'redis', 'redshift', 'restructuredtext', 'ruby', 'rust', 'sb', 'scala', 'scheme', 'scss', 'shell', 'solidity', 'sophia', 'sql', 'st', 'swift', 'systemverilog', 'tcl', 'twig', 'typescript', 'vb', 'xml', 'yaml'],

        features: ['format', 'find', 'contextmenu', 'gotoError', 'gotoLine', 'gotoSymbol', 'hover' , 'documentSymbols'] //['accessibilityHelp', 'anchorSelect', 'bracketMatching', 'caretOperations', 'clipboard', 'codeAction', 'codelens', 'colorPicker', 'comment', 'contextmenu', 'coreCommands', 'cursorUndo', 'dnd', 'documentSymbols', 'find', 'folding', 'fontZoom', 'format', 'gotoError', 'gotoLine', 'gotoSymbol', 'hover', 'iPadShowKeyboard', 'inPlaceReplace', 'indentation', 'inlineHints', 'inspectTokens', 'linesOperations', 'linkedEditing', 'links', 'multicursor', 'parameterHints', 'quickCommand', 'quickHelp', 'quickOutline', 'referenceSearch', 'rename', 'smartSelect', 'snippets', 'suggest', 'toggleHighContrast', 'toggleTabFocusMode', 'transpose', 'unusualLineTerminators', 'viewportSemanticTokens', 'wordHighlighter', 'wordOperations', 'wordPartOperations']
      }
    ])
  }
}

另一种方式,全量加载

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

module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack(config) {
    config.plugin("monaco").use(new MonacoWebpackPlugin());
  },
});

整合教程:http://chart.zhenglinglu.cn/pages/2244bd/#在-vue-中使用

使用 Monaco Editor 教程:https://microsoft.github.io/monaco-editor/playground.html?source=v0.40.0#example-creating-the-editor-hello-world

编写页面edior

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

const codeEditorRef = ref(); // 代码编辑器的dom,如果不加ref,那么代码编辑器就不会显示
const codeEditor = ref(); // 代码编辑器的实例
/**
 * 定义组件属性类型
 */
interface Props {
  value: string;
  language?: string;
  handleChange: (v: string) => void;
}

/**
 * 给组件指定初始值
 */
const props = withDefaults(defineProps<Props>(), {
  value: () => "",
  language: () => "java",
  handleChange: (v: string) => {
    console.log("当前值:", v);
  },
});
// const fillValue = () => {
//   if (!codeEditor.value) {
//     return;
//   }
  codeEditor.value.setValue("hello world");
};

onMounted(() => {
  if (!codeEditorRef.value) {
    return;
  }
  codeEditor.value = monaco.editor.create(codeEditorRef.value, {
    value: props.value,
    language: "java",
    folding: true, // 是否折叠
    foldingHighlight: true, // 折叠等高线
    foldingStrategy: "indentation", // 折叠方式  auto | indentation
    showFoldingControls: "always", // 是否一直显示折叠 always | mouseover
    disableLayerHinting: true, // 等宽优化
    minimap: {
      enabled: true,
      size: "fill",
      maxColumn: 50,
    }, // 开启小地图
    emptySelectionClipboard: false, // 空选择剪切板
    selectionClipboard: false, // 选择剪切板
    automaticLayout: true, // 自动布局
    codeLens: false, // 代码镜头
    scrollBeyondLastLine: false, // 滚动完最后一行后再滚动一屏幕
    colorDecorators: true, // 颜色装饰器
    accessibilitySupport: "off", // 辅助功能支持  "auto" | "off" | "on"
    lineNumbers: "on", // 行号 取值: "on" | "off" | "relative" | "interval" | function
    lineNumbersMinChars: 4, // 行号最小字符   number
    readOnly: false,
    theme: "vs-dark",
  });
  codeEditor.value.onDidChangeModelContent(() => {
    props.handleChange(toRaw(codeEditor.value).getValue());
  });
});
</script>

<template>
  <div id="code-eidtor" ref="codeEditorRef" style="min-height: 400px"></div>
  {{ value }}
  <!--  <a-button @click="fillValue">填充值</a-button>-->
</template>

<style scoped></style>

Vue模板生成

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

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

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

再次使用命令生成前端请求

image-20240103220519603

3.页面开发(TODO)

  • 创建题目
  • 题目管理
  • 更新页面
  • 题目列表
  • 题目浏览

六.判题模块架构

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

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

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

image-20240105161700557

问: 为什么需要一组运行结果?

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

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

2.代码沙箱的开发

  1. 定义代码沙箱的接口,提高通用性
  • 之后我们的项目代码只调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不用去修改名称了, 便于扩展。
  1. 定义多种代码沙箱的实现
  • 示例代码沙箱:仅为了跑通业务流程

  • 远程代码沙箱:实际调用接口的沙箱

  • 第三方代码沙箱:调用网上现成的代码沙箱,https://github.com/criyle/go-judge

  1. 编写单元测试(使用Lombook的Builder创建对象)

  2. 使用工厂模式,根据传入的参数,生成对应的代码沙箱的实现类

  3. 参数配置化,将沙箱的选择设置为yml中可自行设置

  4. 判题业务流程:

    1)传入题目的提交 id,获取到对应的题目、提交信息(包含代码、编程语言等)

    2)如果题目提交状态不为等待中,就不用重复执行了

    3)更改判题(题目提交)的状态为 “判题中”,防止重复执行,也能让用户即时看到状态

    4)调用沙箱,获取到执行结果

    5)根据沙箱的执行结果,设置题目的判题状态和信息

  5. 判断逻辑

    1. 先判断沙箱执行的结果输出数量是否和预期输出数量相等

    2. 依次判断每一项输出和预期输出是否相等

    3. 判题题目的限制是否符合要求

    4. 可能还有其他的异常情况

初步逻辑的实现

@Override
    public QuestionSubmit judge(long questionId) {
        // 1)传入题目的提交 id,获取到对应的题目、提交信息(包含代码、编程语言等)
        QuestionSubmit questionSubmit = questionSubmitService.getById(questionId);
        if (questionSubmit == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "提交信息不存在不存在");
        }
        //这里需要先获取题目信息,因为题目信息中包含了输入用例,这里需要根据题目id获取题目信息
        Long id = questionSubmit.getQuestionId();
        Question question = questionService.getById(id);
        if (question == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "题目不存在");
        }
        // 2)如果题目提交状态不为等待中,就不用重复执行了
        if (questionSubmit.getSubmitState().equals(QuestionSubmitStatusEnum.WAITING.getValue())) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "题目正在判题中");
        }
        // 3)更改判题(题目提交)的状态为 “判题中”,防止重复执行,也能让用户即时看到状态
        QuestionSubmit updateQuestionSubmit = new QuestionSubmit();
        updateQuestionSubmit.setId(questionId);
        updateQuestionSubmit.setSubmitState(QuestionSubmitStatusEnum.RUNNING.getValue());
        boolean res = questionSubmitService.updateById(updateQuestionSubmit);
        if (!res) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "更新题目提交状态失败");
        }
        // 4)根据提交的编程语言,选择对应的沙箱执行代码,并获取执行结果
        CodeSandBox codeSandBox = new ExampleCodeSandBox();
        codeSandBox = new CodeSandBoXProxy(codeSandBox);
        String code = questionSubmit.getSubmitCode();
        String language = questionSubmit.getSubmitLanguage();
        //获取题目的输入输出用例
        String judgeCaseStr = question.getJudgeCase();
        List<JudgeCase> judgeCaseList = JSONUtil.toList(judgeCaseStr, JudgeCase.class);
        List<String> inputList = judgeCaseList.stream().map(JudgeCase::getInput).collect(Collectors.toList());
        ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
                .code(code)
                .language(language)
                .inputList(inputList)
                .build();
        //调用沙箱执行代码
        ExecuteCodeResponse executeCodeResponse = codeSandBox.execute(executeCodeRequest);
        List<String> outputList = executeCodeResponse.getOutputList();
        // 5)根据沙箱的执行结果,设置题目的判题状态和信息
        //默认判题信息为等待中
        JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.WAITING;
        if (outputList.size() != inputList.size()) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "沙箱执行代码失败");
        }
        for (int i = 0; i < judgeCaseList.size(); i++) {
            JudgeCase judgeCase = judgeCaseList.get(i);
            if (!judgeCase.getOutput().equals(outputList.get(i))) {
                judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
                return null;
            }
        }
        JudgeInfo judgeInfo = executeCodeResponse.getJudgeInfo();
        String actualMessage = judgeInfo.getMessage();
        Long actualMemory = judgeInfo.getMemory();
        Long actualTime = judgeInfo.getTime();
        //获取题目的限制
        String judgeConfigStr = question.getJudgeConfig();
        JudgeInfo expectConfig = JSONUtil.toBean(judgeConfigStr, JudgeInfo.class);
        String expectMessage = expectConfig.getMessage();
        Long expectedMemory = expectConfig.getMemory();
        Long expectedTime = expectConfig.getTime();
        if (actualMemory > expectedMemory) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;
            return null;
        }
        if (actualTime > expectedTime) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;
            return null;
        }
        // 6) 修改数据库中的题目状态
        return null;
    }
  1. 策略模式优化

3.使用Lombok Builder` 建造者模式

3.1注解的详细与建造者模式的讲解

builder模式也叫建造者模式,builder模式的作用将一个复杂对象的构建与他的表示分离,使用者可以一步一步的构建一个比较复杂的对象。

建造者模式使用场景: 当一个类的构造函数参数个数超过4个,而且这些参数有些是可选的参数,考虑使用构造者模式。

假设有一个User类:

public class User {
	private String id ;
	private String name ;
	private Integer age ;
 // ignore getter/setter 
 }

3.2Builder模式实现步骤:

​ 1、User类中创建一个静态内部类 Builder

​ 2、Builder 类中,包含User类的全部属性

​ 3、Builder 类中,每个属性创建赋值方法,并返回当前对象

​ 4、Builder 类中,创建 build方法,返回User对象并赋值

​ 5、User类中,创建静态builder方法,返回Builder对象

实现:

 
/**
 * description: Java Builder模式练习
 * @version v1.0
 * @author w
 * @date 2021年7月7日上午11:37:49
 **/
public class User {
	private String id ;
	private String name ;
	private Integer age ;
 
	public static Builder builder(){
		return new Builder();
	}
	
	public static class Builder{
		private String id ;
		private String name ;
		private Integer age ;
 
		public Builder id(String id) {
			this.id = id ;
			return this;
		}
 
		public Builder name(String name) {
			this.name = name ;
			return this ;
		}
		
		public Builder age(Integer age) {
			this.age = age ; 
			return this;
		}
 
		public User build() {
			return new User(this);
		}
		
		public User build2() {
			return new User(this.id , this.name , this.age);
		}
		
	}
 
	public String getId() {
		return id;
	}
 
	public void setId(String id) {
		this.id = id;
	}
 
	public String getName() {
		return name;
	}
 
	public void setName(String name) {
		this.name = name;
	}
 
	public Integer getAge() {
		return age;
	}
 
	public void setAge(Integer age) {
		this.age = age;
	}
 
	public User() {
		super();
	}
 
	public User(String id, String name, Integer age) {
		super();
		this.id = id;
		this.name = name;
		this.age = age;
	}
	
	public User(Builder builder) {
		this.id = builder.id;
		this.name = builder.name ;
		this.age = builder.age;
	}
	
}

使用时:

public static void main(String[] args) {
		User user = User.builder()
            		.id("11")
            		.name("小明")
            		.age(17)
            		.build();
		System.out.println(user);
	}

Lombok注解详细

@Builder可以放在类,构造函数或方法上。 虽然放在类上和放在构造函数上这两种模式是最常见的用例,但@Builder最容易用放在方法的用例来解释。

实例:

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

    private List<String> inputList;

    private String code;

    private String language;
}

使用链式调用赋值

ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
        .code(code)
        .language(language)
        .inputList(inputList)
        .build();

4.工厂模式对CodeSandBox的选择使用进行优化

单例模式有懒汉模式和饿汉模式,懒汉模式会有线程安全的问题,需要使用DDL检查锁

    //懒汉模式DDL实现
private static volatile CodeSandBoxFactory codeSandBoxFactoryInstance;
   private CodeSandBoxFactory() {
    }

    public static CodeSandBoxFactory getInstance() {
        if (codeSandBoxFactoryInstance == null) {
            synchronized (CodeSandBoxFactory.class) {
                if (codeSandBoxFactoryInstance == null) {
                    codeSandBoxFactoryInstance = new CodeSandBoxFactory();
                }
            }
        }
        return codeSandBoxFactoryInstance;
    }
    

单例工厂模式只需要将工厂本身单例就行,创建的对象本身不一定是单例的

public static CodeSandBox getCodeSandBox(String type) {
    return switch (type) {
        case "example" -> new ExampleCodeSandBox();
        case "remote" -> new RemoteCodeSandBox();
        case "third" -> new ThirdCodeSandBox();
        default -> null;
    };
}

5.策略模式对判题服务的优化

场景: 我们的判题策略可能会有很多种,比如:我们的代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,比如沙箱执行 Java 要额外花 10 秒。

我们可以采用策略模式,针对不同的情况,定义独立的策略,便于分别修改策略和维护。而不是把所有的判题逻辑、if ... else ... 代码全部混在一起写。

实现步骤如下:

5.1定义判题策略接口,让代码更加通用化

/**
 * 判题策略
 */
public interface JudgeStrategy {

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

5.2定义策略中传递的参数

/**
 * 上下文(用于定义在策略中传递的参数)
 */
@Data
public class JudgeContext {

    private JudgeInfo judgeInfo;

    private List<String> inputList;

    private List<String> outputList;

    private List<JudgeCase> judgeCaseList;

    private Question question;

    private QuestionSubmit questionSubmit;

}

5.3定义策略

在这个项目中或许也会有java语言以外的判题,因此这里定义多种策略,这里定义一个java的策略(10秒为例)

public class JavaLanguageJudgeStrategy implements JudgeStrategy{
    @Override
    public JudgeInfo doJudge(JudgeContext judgeContext) {
        List<String> inputList = judgeContext.getInputList();
        List<String> outputList = judgeContext.getOutputList();
        List<JudgeCase> judgeCaseList = judgeContext.getJudgeCaseList();
        Question question = judgeContext.getQuestion();
        //获取判题信息
        JudgeInfo judgeInfo = judgeContext.getJudgeInfo();
        //从 judgeIfo 中获取实际的判题信息,创建一个新的对象,防止修改原来的判题信息
        JudgeInfo judgeInfoResponse = new JudgeInfo();
        Long actualMemory = judgeInfo.getMemory();
        Long actualTime = judgeInfo.getTime();
        judgeInfoResponse.setMemory(actualMemory);
        judgeInfoResponse.setTime(actualTime);
        //默认判题信息为等待中
        JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.ACCEPTED;
        if (outputList.size() != inputList.size()) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
            judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());
            return judgeInfoResponse;
        }
        for (int i = 0; i < judgeCaseList.size(); i++) {
            JudgeCase judgeCase = judgeCaseList.get(i);
            if (!judgeCase.getOutput().equals(outputList.get(i))) {
                judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
                judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());
                return judgeInfoResponse;
            }
        }

        //获取题目的限制
        String judgeConfigStr = question.getJudgeConfig();
        JudgeInfo expectConfig = JSONUtil.toBean(judgeConfigStr, JudgeInfo.class);
        Long expectedMemory = expectConfig.getMemory();
        Long expectedTime = expectConfig.getTime();
        if (actualMemory > expectedMemory) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;
            judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());
            return judgeInfoResponse;
        }
        // Java 程序本身需要额外执行 10 秒钟
        long JAVA_PROGRAM_TIME_COST = 10000L;
        if (actualTime - JAVA_PROGRAM_TIME_COST > expectedTime) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;
            judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());
            return judgeInfoResponse;
        }
        judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());
        return judgeInfoResponse;
    }

}

5.4策略的管理

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

    /**
     * 执行判题
     *
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext) {
        QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
        //获取判题语言,动态的根据语言选择策略
        String language = questionSubmit.getSubmitLanguage();
        JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
        if ("java".equals(language)) {
            judgeStrategy = new JavaLanguageJudgeStrategy();
        }
        // 继续判断提交的语言,创建对应语言的判题策略
        return judgeStrategy.doJudge(judgeContext);
    }
}

5.5异步调用判题服务

首先在judgeService中传递配置

JudgeInfo judgeInfo = judgeManager.doJudge(judgeContext);

在主业务service中调用

@Resource
@Lazy
private JudgeService judgeService;

// 异步执行判题服务
CompletableFuture.runAsync(() -> {
    judgeService.doJudge(questionSubmitId);
});

这里使用了@Lazy, 防止循环依赖

七.代码沙箱的生实现

1.Java代码沙箱的原生实现

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

1.1 通过命令行实现

1.1.1 代码的执行流程

接收代码 => 编译代码(javac) => 执行代码(java)

程序示例代码,注意要去掉包名,放到沙箱项目的 resource 目录下:

public class SimpleCompute {
    public static void main(String[] args) {
        int a = Integer.parseInt(args[0]);
        int b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}

java 编译代码

javac {Java类代码路径}

整体流程:

  1. 防止包名发生冲突,在Resource下打开,编译代码
 javac .\Main.java  // 编译代码
 java -cp . Main 1  //带入参数

image-20240107211254942

1.1.2中文乱码问题

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

使用chcp可以查看当前的编码,GBK 是 936,UTF-8 是 65001。

改变编码不适用于多平台,因此此处使用特定编码编译解决

替换编译代码,问题解决

javac -encoding utf-8 .\Main.java
1.1.3 统一类名

在做 acm 模式的题目的时候。算法的类名都是统一的 Main,会对用户的输入的代码有一定的要求,便于后台进行统一的处理和判题

因此,使用Main作为类名,此处已替换

2.核心流程的实现

核心思路:用程序代理人工,用程序来操作命令行完成编译执行代码

核心依赖:需要依赖 java 的进程类 Process

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

2.1引入Hutool

<dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.24</version>
        </dependency>

新建目录,将每个用户的代码都存放在独立目录下,通过 UUID 随机生成目录名,便于隔离和维护:

2.2 整体沙箱的流程

  1. 把用户的代码保存为文件
        // 获取当前工作目录,例如:C:\code\siyioj-code-sandbox
        String userDir = System.getProperty("user.dir");
        // 创建临时文件夹,例如:C:\code\siyioj-code-sandbox\tmpCode
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        if (!FileUtil.exist(globalCodePathName)) {
            // 不存在,则创建文件目录
            FileUtil.mkdir(globalCodePathName);
        }
        //将用户代码隔离存放
        String userCodePathName = globalCodePathName + File.separator + UUID.randomUUID();
        //实际存放用户代码的文件夹
        String userCodePath = userCodePathName + File.separator + GLOBAL_JAVA_CLASS_NAME;
        File userCodeFile = FileUtil.writeString(code, userCodePath, "UTF-8");
  1. 编译代码

使用 Process 类在终端执行命令

可以使用 Spring 的 StopWatch 获取一段程序的执行时间:

String compileCmd = String.format("javac -encoding utf-8%s", userCodeFile.getAbsolutePath());
Process process = Runtime.getRuntime().exec(compileCmd) //创建进程执行命令

执行 process.waitFor 等待程序执行完成,并通过返回的 exitValue判断程序是否正常返回,然后从 Process 的输入流 inputStream和错误流 errorStream获取控制台输出。

可以使用最大值时间来计算每一个用例是否超时

StopWatch stopWatch = new StopWatch();
stopWatch.start();
... 程序执行
stopWatch.stop();
stopWatch.getLastTaskTimeMillis(); // 获取时间

提取出工具类的实现

    public static ExecuteMessage runProcessAndGetMessage(Process runProcess, String opName) {
        ExecuteMessage executeMessage = new ExecuteMessage();
        //使用stopWatch计算每最大时间
        try {
            StopWatch stopWatch = new StopWatch();
            stopWatch.start();
            // 等待程序执行,获取错误码
            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());

            } else {
                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).append("\n");
                }
                executeMessage.setMessage(compileOutputStringBuilder.toString());
                // 分批获取进程的错误输出
                BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
                StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
                // 逐行读取
                String errorCompileOutputLine;
                while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
                    errorCompileOutputStringBuilder.append(errorCompileOutputLine).append("\n");
                }
                executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());

            }
            stopWatch.stop();
            executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return executeMessage;
    }
  1. 执行程序

同样是使用Process 类运行Java命令,为了解决编译或运行时的编码中文乱码问题。在指令中添加 -Dfile.encoding=UTF-8

String runCmd=String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s",userCodeParentPath,inputArgs);
// 3、执行程序
for (String inputArgs : inputList) {
    String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
    try {
        Process runProcess = Runtime.getRuntime().exec(runCmd);
        ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
        System.out.println(executeMessage);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

拓展: 部分 acm 赛制需要进行Scanner控制台输入。对此类程序,需要使用OutPutStream向程序终端发送参数,并及时获取结果,最后需要记得关闭字节流释放资源。这里需要注意回车换行

示例程序

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner cin = new Scanner(System.in);
        int a = cin.nextInt(), b = cin.nextInt();
        System.out.println("结果:" + (a + b));
    }
}
/**
 * 交互式执行进程并获取进程信息
 *
 * @param runProcess
 * @return
 */
public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess, String args) {
    ExecuteMessage executeMessage = new ExecuteMessage();

    try {
        // 从控制台输入参数
        OutputStream outputStream = runProcess.getOutputStream();
        OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
        String[] arguments = args.split(" ");
        String join = StrUtil.join("\n", arguments) + "\n";
        outputStreamWriter.write(join);
        // 回车,发送参数
        outputStreamWriter.flush();

        // 通过进程获取正常输出到控制台的信息
        InputStream inputStream = runProcess.getInputStream();
        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());
        // 释放资源
        outputStream.close();
        outputStreamWriter.close();
        inputStream.close();
        runProcess.destroy();
    } catch (Exception e) {
        e.printStackTrace();
    }
    return executeMessage;
}

执行

// 3、执行程序
for (String inputArgs : inputList) {
    String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
    try {
        Process runProcess = Runtime.getRuntime().exec(runCmd);
        //ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
        ExecuteMessage executeMessage = ProcessUtils.runInteractProcessAndGetMessage(runProcess, inputArgs);
        System.out.println(executeMessage);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
  1. 整理输出
 ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        List<String> outputList = new ArrayList<>();
        long maxTime = 0;
        for (ExecuteMessage executeMessage : executeMessagesList) {
            String errorMessage = executeMessage.getErrorMessage();
            if (StrUtil.isNotBlank(errorMessage)) {
                executeCodeResponse.setStatus(3);
                executeCodeResponse.setMessage(errorMessage);
                break;
            }
            outputList.add(executeMessage.getMessage());
            Long time = executeMessage.getTime();
            if (time != null) {
                maxTime = Math.max(maxTime, time);
            }
        }
        //正常运行,也就是没有错误信息
// 这里需要和判题的信息进行比较检查是否数量相等(也就是检查是否有报错)
        if (outputList.size() == executeMessagesList.size()) {
            executeCodeResponse.setStatus(1);
        }
        executeCodeResponse.setOutputList(outputList);
        JudgeInfo judgeInfo = new JudgeInfo();
// TODO judgeInfo.setMemory();
//        judgeInfo.setMemory();
        judgeInfo.setTime(maxTime);
        executeCodeResponse.setJudgeInfo(judgeInfo);
  1. 防止服务器空间不足,删除用户对应的代码目录
if (userCodeFile.getParentFile() != null) {
    boolean del = FileUtil.del(userCodeParentPath);
    System.out.println("删除" + (del ? "成功" : "失败"));
}
  1. 统一返回,错误处理
/**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getResponse(Throwable e) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 代码沙箱错误,编译错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return executeCodeResponse;
    }
}

3. Java程序异常情况

要把写好的代码复制到 resources 中,并且一定要把类名改为 Main。包名一定要去掉!

3.1 执行时间超时

占用时间资源,导致程序卡死,不释放资源:

/**
 * @author siyi 2023/9/1 16:23
 */
public class Main {
    public static void main(String[] args) throws InterruptedException {
        long ONE_HOUR = 60 * 60 * 1000L;
        Thread.sleep(ONE_HOUR);
        System.out.println("睡醒了");
    }
}

解决: 使用一个守护线程等待一定运行时长后终止程序执行的进程

      3. 执行代码,得到输出结果
        List<ExecuteMessage> executeMessagesList = new ArrayList<>();
        for (String inputArgs : inputList) {
            String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodePathName, inputArgs);
            try {
                Process runProcess = Runtime.getRuntime().exec(runCmd);
                new Thread(() -> {
                    try {
                        Thread.sleep(TIME_OUT);
                        runProcess.destroy();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }).start();
                ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
                executeMessagesList.add(executeMessage);
                System.out.println(executeMessage);
            } catch (Exception e) {
                return getErrorResponse(e);

image-20240108122449415

3.2 占用内存

/**
 * @author siyi 2023/9/1 16:32
 * 无限占用空间(浪费空间内容)
 */
public class MemoryError {
    //这里防止gc影响,所以使用byte
    public static void main(String[] args) {
        List<byte[]> bytes = new ArrayList<>();
        while (true) {
            bytes.add(new byte[100000]);
        }
    }
}

image-20240108122658664

惊不惊喜,意不意外

这里可以发现jvm是有默认最大堆内存的,这里我设置了ide的最大堆内存为2048,但是我们可以发现最大的堆内存却是不严格按照限制的最大堆内存

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

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

接下来看看 JConsole 下是什么样子吧

jconsolejdk自带的一个jvm的一个监控和管理工具

image-20240108124154518

解决方式:

编译时指定最大堆内存,有时候为了程序启动的效率会指定最小的堆内存,这里oj系统用不到,所以不设置

String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8  -cp %s Main %s", userCodePathName, inputArgs);

image-20240108125511540

常用的JVM命令

命令描述
-Xms<size>设置初始堆大小,例如 -Xms256m 表示初始堆大小为256兆字节
-Xmx<size>设置最大堆大小,例如 -Xmx1024m 表示最大堆大小为1024兆字节
-Xmn<size>设置年轻代(Young Generation)的大小
-XX:PermSize=<size>设置永久代(PermGen)的初始大小(Java 8之前有效)
-XX:MaxPermSize=<size>设置永久代(PermGen)的最大大小(Java 8之前有效)
-XX:MaxMetaspaceSize=<size>设置元空间(Metaspace)的最大大小(Java 8及之后有效)
-XX:NewRatio=<ratio>设置年轻代与老年代的内存大小比例
-XX:SurvivorRatio=<ratio>设置Eden区与Survivor区的内存大小比例
-XX:MaxGCPauseMillis=<ms>设置垃圾回收最大暂停时间目标(G1收集器)
-XX:+UseParallelGC启用并行垃圾回收器(Parallel Garbage Collector)
-XX:+UseG1GC启用G1垃圾回收器(G1 Garbage Collector)
-XX:+UseConcMarkSweepGC启用CMS垃圾回收器(Concurrent Mark-Sweep Collector)

补充: jps 显示指定系统内所有的HotSpot虚拟机进程。

最后补充一个Jconsole 下的JVM 概要

image-20240108130207520

3.3 读文件,信息泄露

比如直接通过相对路径获取项目配置文件,获取到密码:

public static void main(String[] args) throws InterruptedException, 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));
}

image-20240108131111402

3.4 写文件,植入木马

4.4 写文件,植入木马

可以直接向服务器上写入文件,比如一个木马程序:java -version 2>&1(示例命令)

  1. java -version 用于显示 Java 版本信息。这会将版本信息输出到标准错误流(stderr)而不是标准输出流(stdout)。
  2. 2>&1 将标准错误流重定向到标准输出流。这样,Java 版本信息就会被发送到标准输出流。

解释:

  • 2 表示标准错误流(stderr),这是一个用于输出错误消息的流。
  • > 表示重定向。
  • &1 表示将标准错误流(stderr)重定向到与标准输出流(stdout)相同的地方。

这种语法的目的是将标准错误流的输出合并到标准输出流中,这样它们可以一起被重定向到同一个位置,例如写入到同一个文件或通过管道进行处理

/**
 * @author siyi 2023/9/1 16:59
 */
public class Main {
    public static void main(String[] args) throws InterruptedException, 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("执行异常程序成功");
    }
}

运行发现,能够查看到JDK的版本

3.5 运行其他程序

直接通过 Process 执行危险程序,或者电脑上的其他程序:

public static void main(String[] args) throws InterruptedException, IOException {
    String userDir = System.getProperty("user.dir");
    String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
    Process process = Runtime.getRuntime().exec(filePath);
    process.waitFor();
    // 分批获取进程的正常输出
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
    // 逐行读取
    String compileOutputLine;
    while ((compileOutputLine = bufferedReader.readLine()) != null) {
        System.out.println(compileOutputLine);
    }
    System.out.println("执行异常程序成功");
}

3.6执行高危操作

甚至都不用写木马文件,直接执行系统自带的危险命令!

  • 比如删除服务器的所有文件(太残暴、不演示)

  • 比如执行dirwindows)、ls(linux) 获取你系统上的所有文件信息

4. Java程序安全控制

针对上面的异常情况,分别有如下方案,可以提高程序安全性。

  1. 超时控制 (上述已实现)
  2. 限制给用户程序分配的资源 (上述已经实现)
  3. 限制代码 - 黑白名单 (字典树匹配敏感代码关键字)
  4. 限制用户的操作权限(文件、网络、执行等) SecurityManager
  5. 运行环境隔离(Docker)

4.1 限制代码 - 黑白名单

4.1.1 实现

先定义一个黑白名单,比如哪些操作是禁止的,可以就是一个列表:

// 黑名单
public static final List<String> blackList = Arrays.asList("Files", "exec");
4.1.2 字典树原理的实现

字典树(Trie树,也称为前缀树)是一种树形数据结构,用于高效地存储和搜索字符串集合。

  1. 字典树是由一个根节点开始的树形结构,根节点不包含任何字符信息。
  2. 每个节点都包含一个存储的字符和子节点的列表或映射。
  3. 从根节点到任意一个节点的路径上的字符连接起来就组成该节点对应的字符串
  4. 插入操作:将一个字符串的每个字符依次插入到树中的节点上。
  5. 查询操作:从树的根节点开始,按照要查询的字符顺序,逐级匹配字符并向下遍历树。
  6. 终止节点:可以用一个标志来标记某个节点是否为一个字符串的终止节点。例如,在终止节点处存储一个布尔值,用于表示是否存在以该节点为终止的字符串。

优点

  1. 高效地存储和查找字符串集合,特别适合处理大量字符串的前缀匹配和前缀搜索。
  2. 提供了最长公共前缀的查找能力。
  3. 可以快速地查找指定前缀的所有字符串。

示例

image-20240108140550528

这里使用Hutool的字典树,实现

  1. 初始化字典树
private static final WordTree WORD_TREE;

static {
    // 初始化字典树
    WORD_TREE = new WordTree();
    WORD_TREE.addWords(blackList);
}
  1. 检查用户是否有敏感关键字
// 校验代码是否包含有黑名单中命令
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord != null) {
    System.out.println("此文件包含敏感词:" + foundWord.getFoundWord());
    return null;
}

缺点:

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

4.2 Java 安全管理器

4.2.1 使用

Java 安全管理器(Security Manager)是 Java 提供的保护 JVM、Java 安全的机制,可以实现更严格的资源和操作限制。

JDK17以后这个类以及被官方废弃,官方推荐模块化管理权限以及使用第三方框架Spring Security、Apache Shiro等

编写安全管理器,只需要继承 Security Manager

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

    // 检查所有的权限
    @Override
    public void checkPermission(Permission perm) {
        System.out.println("默认不做任何限制");
        System.out.println(perm);
        // super.checkPermission(perm);
    }
}
4.2.2所有权限拒绝
/**
 * 禁用所有权限安全管理器
 */
public class DenySecurityManager extends SecurityManager {

    // 检查所有的权限
    @Override
    public void checkPermission(Permission perm) {
        throw new SecurityException("权限异常:" + perm.toString());
    }
}
4.2.3 限制读权限
@Override
    public void checkRead(String file) {
        System.out.println(file);
        if (file.contains("C:\\code\\siyioj-code-sandbox")) {
            return;
        }
//        throw new SecurityException("checkRead 权限异常:" + file);
    }
4.2.4 限制写文件权限
@Override
public void checkWrite(String file) {
    throw new SecurityException("checkWrite 权限异常:" + file);
}
4.2.5 限制执行文件的权限
@Override
public void checkExec(String cmd) {
	throw new SecurityException("checkExec 权限异常:" + cmd);
}
4.2.6 网络连接的权限
public void checkConnect(String host, int port) {
    throw new SecurityException("checkConnect 权限异常:" + host + ":" + port);
}

4.3 实际应用

实际情况下,不应该在主类(开发者自己写的程序)中做限制,只需要限制子程序的权限即可

具体操作如下:

  1. 根据需要开发自定义的安全管理器(比如 MySecurityManager

    public class MySecurityManager extends SecurityManager {
    
        // 检查所有的权限
        @Override
        public void checkPermission(Permission perm) {
    //        super.checkPermission(perm);
        }
    
        // 检测程序是否可执行文件
        @Override
        public void checkExec(String cmd) {
            throw new SecurityException("checkExec 权限异常:" + cmd);
        }
    
        // 检测程序是否允许读文件
    
        @Override
        public void checkRead(String file) {
            System.out.println(file);
            if (file.contains("C:\\code\\yuoj-code-sandbox")) {
                return;
            }
    //        throw new SecurityException("checkRead 权限异常:" + file);
        }
    
        // 检测程序是否允许写文件
        @Override
        public void checkWrite(String file) {
    //        throw new SecurityException("checkWrite 权限异常:" + file);
        }
    
        // 检测程序是否允许删除文件
        @Override
        public void checkDelete(String file) {
    //        throw new SecurityException("checkDelete 权限异常:" + file);
        }
    
        // 检测程序是否允许连接网络
        @Override
        public void checkConnect(String host, int port) {
    //        throw new SecurityException("checkConnect 权限异常:" + host + ":" + port);
        }
    
  2. 复制 MySecurityManager 类到 resources/security目录下,移除类的包名

  3. 手动输入命令编译 MySecurityManager类,得到 class文件

    java -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=MySecurityManager Main
    

这里需要单独编译MySecurityManager,这里本地环境JDK是17,因此单独使用另一条命令编译为1.8语言

-source-target 选项来指定源代码版本和目标类文件版本,从而确保与特定版本的JDK兼容。

javac -source 1.8 -target 1.8 -encoding utf-8 MySecurityManager.java

编译完成后,运行命令发现受到限制

image-20240108170719995

参数

//实际路径   
private static final String SECURITY_MANAGER_PATH = "D:\\KAIFAMIAO\\IDEAworkspace\\siyi-code-sandbox\\src\\main\\resources\\security";
//用户的代码存放目录
String userCodePathName = globalCodePathName + File.separator + UUID.randomUUID();
//类名
private static final String SECURITY_MANAGER_CLASS_NAME = "MySecurityManager";
  1. 在运行 java 程序时,指定安全管理器 class 文件的路径、安全管理器的名称。

依次执行之前的所有测试用例,发现资源成功被限制。

4.4 安全管理器的优缺点

4.4.1 安全管理器优点
  1. 权限控制很灵活
  2. 实现简单

4.4.2 安全管理器缺点

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

八.代码沙箱Docker的实现

1.Docker

为什么要用 Docker 容器技术?

为了进一步提升系统的安全性,把不同的程序和宿主机进行隔离,使得某个程序(应用)的执行不会影响到系统本身。

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

1.1 什么是容器

假设你正在准备一顿丰盛的早餐,你需要收集各种食材和调料。在这个场景中,容器可以通过以下方式体现:

  1. 食材的包装盒:当你购买一些蔬菜、水果、鸡蛋等食材时,它们通常被放在包装盒或塑料袋里。这些包装盒可以视为容器,可以容纳和保护食材,同时也能够将它们与其他物品进行隔离。
  2. 调料瓶:在烹饪过程中,你可能需要各种调料,如盐、胡椒粉、糖、酱油等等。这些调料通常以瓶子或罐子的形式出现,它们可以被视为容器,容纳和存储这些调料,使其易于使用和管理。
  3. 存储容器:在准备早餐时,你可能需要将一些食材提前切好,方便后续的烹饪。你可以使用各种存储容器,如塑料盒、玻璃罐等,来容纳和保存已经切好的食材,以防止交叉污染,并延长其保鲜时间。

这些容器的作用是将不同的物品隔离和包含在自己独立的空间中,以保护它们免受外界的影响,并使其易于管理和使用。在生活中,容器的概念常常用于各种场景,从食物存储到物品包装等,都可以看到容器的身影。

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

image-20240109091358838

推荐使用 docker 官方的镜像仓库:https://hub.docker.com/search?q=nginx

1.2 Docker的实现原理

对应题目:Docker 能实现哪些资源的隔离?

看图理解:

  1. Docker 运行在 Linux 内核上
  2. CGroups:实现了容器的资源隔离,底层是 Linux Cgroup 命令,能够控制进程使用的资源
  3. Network 网络:实现容器的网络隔离,docker 容器内部的网络互不影响
  4. Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同的命名空间下的进程互不影响。
  5. Storage 存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件

docker compose:是一种同时启动多个容器的集群操作工具(容器管理工具),一般情况下,开发者仅做了解即可,实际使用 docker compose 时去百度配置文件

1.3 安装Docker

因为使用的是UBuntu,所以需要加sudo命令才能进行下面的命令教程

1.3.1虚拟机环境的准备

如果发现没有网络,可以在本机中设置网络家庭共享,VM8,虚拟机也设置vm8即可

1.3.2 Docker安装
sudo apt-get install docker.io //docker 安装命令
docker -v //检查是否安装成功
1.3.3常用命令

使用xx –help 一般是大部分shell 操作命令的方式,这里可以记住用法

命令用途
docker --help查看Docker命令的用法
docker run --help查看具体子命令run的用法
`docker pull [OPTIONS] NAME[:TAG@DIGEST]`
docker create [OPTIONS] IMAGE [COMMAND] [ARG...]根据镜像创建容器实例
docker ps -a查看所有容器状态(包括存活和未运行的镜像)
docker start [OPTIONS] CONTAINER [CONTAINER...]启动容器实例
docker logs [OPTIONS] CONTAINER查看容器日志
docker rm [OPTIONS] CONTAINER [CONTAINER...]删除容器实例
docker rmi [OPTIONS] IMAGE [IMAGE...]删除镜像(注意:删除前需要删除使用此镜像的容器或停止运行中的容器)
`docker build [OPTIONS] PATHURL
docker push [OPTIONS] NAME[:TAG]推送镜像到远程仓库
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]在运行中的容器中执行命令

流程:

  1. pull 下载镜像
  2. 创建实例
  3. 启动实例(没有守护进程可能会直接关闭)
  4. 根据 需求查看日志

1.4 Java操作docker

可以先使用远程开发

使用 Docker-Java:https://github.com/docker-java/docker-java

官方入门:https://github.com/docker-java/docker-java/blob/main/docs/getting_started.md 先引入依赖:

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

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

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

1.4.1依赖
<!-- https://mvnrepository.com/artifact/com.github.docker-java/docker-java -->
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java</artifactId>
    <version>3.3.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.docker-java/docker-java-transport-httpclient5 -->
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java-transport-httpclient5</artifactId>
    <version>3.3.0</version>
</dependency>
1.4.2 dokcer镜像的下载
// 获取默认的 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("下载完成");

接口回调

这里解释一下接口回调PullImageResultCallback

A调用B,B处理完之后再调用A提供的回调方法(通常为callbakc())通知结果。

接口回调有两种

  • 第一,不需要调用结果,直接调用即可,比如发送消息通知;
  • 第二,需要异步调用结果,在Java中可使用Future+Callable实现。

在上面,awaitCompletion就是回调函数,但是不返回结果,而是继续执行

1.4.3 常用的命令
  1. 拉取镜像
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("下载完成");
  1. 创建容器
CreateContainerResponse createContainerResponse = containerCmd
        .withCmd("echo", "Hello Docker")
        .exec();
System.out.println(createContainerResponse);
  1. 查看容器状态
ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
List<Container> containerList = listContainersCmd.withShowAll(true).exec();
for (Container container : containerList) {
    System.out.println(container);
}
  1. 启动容器
dockerClient.startContainerCmd(containerId).exec();
  1. 查看日志
// 查看日志
LogContainerResultCallback logContainerResultCallback = new LogContainerResultCallback() {
    @Override
    public void onNext(Frame item) {
        System.out.println(item.getStreamType());
        System.out.println("日志:" + new String(item.getPayload()));
        super.onNext(item);
    }
};

// 阻塞等待日志输出
dockerClient.logContainerCmd(containerId)
        .withStdErr(true)
        .withStdOut(true)
        .exec(logContainerResultCallback)
        .awaitCompletion();
  1. 删除容器
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
  1. 删除镜像
dockerClient.removeImageCmd(image).exec();

2.两种远程开发的方法

远程开发前需要给系统安装openssh

//安装
sudo apt-get install openssh-server
//检查是否开启
sudo service ssh start

方法一: (不推荐)

因为这种方法类似是将代码推送的到远程进行开发,有时候会因为推送不过去会有很多的问题

1

image-20240110003426890

方法二:通过SSH远程到服务器进行远程开发

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

-Djdk.lang.Process.launchMechanism=vfork

(使用比较新的IDE就有可能出现此错误)

image-20240109175907216

如果启动失败,还不行就重启虚拟机

如果显示网络连接激活失败,查看vm fhcp是否有问题

## 查看 docker 用户组
cat /etc/group | grep 'docker'
## 将当前登录用户调价到 docker 用户组
sudo gpasswd -a ${USER} docker
## 更新 docker 组信息
newgrp docker
## 再次查看 docker用户组信息
cat /etc/group | grep 'docker'

遇见过的坑

  1. IDEA 找jdk 找不到
update-alternatives --display java  //这里的java可以换成别的,查找路径

image-20240110002507859

  1. 设置Linux中的maven镜像
sudo apt-get install maven  //安装
mvn help:effective-settings   //查看maven 路径
//如果还是找不到安装路径,可以使用java 的命令继续查找
update-alternatives --display mvn  

image-20240110012419238

在ide中寻找.m2文件找不到的话可以直接写路径名,因为m2文件是隐藏文件,ide找不到

  1. 踩坑(可能新版的ide会出的问题)

-Djdk.lang.Process.launchMechanism=vfork

image-20240110101238909

  1. 如果在上面的java操作docker发现权限不足,可以加入权限

如果这条解决方式还不行,那就 重启虚拟机!重启远程开发环境!重启程序!

## 查看 docker 用户组
cat /etc/group | grep 'docker'
## 将当前登录用户调价到 docker 用户组
sudo gpasswd -a ${USER} docker
## 更新 docker 组信息
newgrp docker
## 再次查看 docker用户组信息
cat /etc/group | grep 'docker'

image-20240110104532769

3.Docker 实现代码沙箱

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

浪费性能,所以要创建一个 可交互 的容器,能接受多次输入并且输出。

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

流程几乎和 Java 原生实现流程相同:

  1. 把用户的代码保存为文件
  2. 编译代码,得到 class 文件
  3. 把编译好的文件上传到容器环境内 (Docker)
  4. 在容器中执行代码,得到输出结果 (在一个容器中,不需要一个代码一个容器)
  5. 收集整理输出结果
  6. 文件清理,释放空间
  7. 错误处理,提升程序健壮性

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

3.1 镜像的创建

自定义容器的两种方式:

  1. 在已有镜像的基础上再扩充:比如拉取现成的 Java 环境(包含 jdk),再把编译后的文件复制到容器里。适合新项目、跑通流程
  2. 完全自定义容器:适合比较成熟的项目,比如封装多个语言的环境和实现

镜像拉取

 DockerClient dockerClient = DockerClientBuilder.getInstance().build();
// 3.1、拉取镜像
        String image = "openjdk:8-alpine";
        if (FIRST_INIT) {
            PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
            PullImageResultCallback resultCallback = new PullImageResultCallback() {
                @Override
                public void onNext(PullResponseItem item) {
                    System.out.println("拉取镜像:" + item.getStatus());
                    super.onNext(item);
                }
            };
            try {
                pullImageCmd.exec(resultCallback).awaitCompletion();
            } catch (InterruptedException e) {
                System.out.println("拉取镜像异常");
                throw new RuntimeException(e);
            }
        }
        System.out.println("镜像拉取完成");

镜像创建

镜像创建的时候需要指定好内存,然后挂载容器,

这里设置映射:任何在主机上对 userCodePathName 路径下的文件的更改都会反映到容器内的 "/app" 路径下。

 //创建容器
        CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
        HostConfig hostConfig = new HostConfig();
      // 限制内存
        hostConfig.withMemory(100 * 1000 * 1000L);
        // 限制内存
        hostConfig.withCpuCount(1L);
        // 设置容器挂载目录
        hostConfig.setBinds(new Bind(userCodePathName, new Volume("/app")));
        CreateContainerResponse createContainerResponse = containerCmd
                 .withHostConfig(hostConfig)
                .withAttachStderr(true) // 开启输入输出
                .withAttachStdin(true)
                .withAttachStdout(true)
                .withTty(true) // 开启一个交互终端
                .exec();
        //获取容器id
        String containerId = createContainerResponse.getId();
        System.out.println("创建容器id:" + containerId);

3.2启动镜像

执行命令,通过回调接口来获取程序的输出结果,并且通过 StreamType 来区分标准输出和错误输出。

   for (String inputArgs : inputList) {
            String[] inputArgsArray = inputArgs.split(" ");
            String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main", "1", "2"}, inputArgsArray);
            // 创建命令
            ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
                    .withCmd(cmdArray)
                    .withAttachStderr(true) // 开启输入输出
                    .withAttachStdin(true)
                    .withAttachStdout(true)
                    .exec();
            System.out.println("创建执行命令:" + execCreateCmdResponse);
            String execId = execCreateCmdResponse.getId();
            if (execId == null) {
                throw new RuntimeException("执行命令不存在");
            }
            //启动命令的回调函数
            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 对象,在异步接口中填充正常和异常信息,这样之后流程的代码都可以复用。

  • 并设置最大内存和时间,时间使用一个信号,如果执行完代表超时,因为complet无论是否超时都会继续执行
  • 内存使用的是周期性监控获取内存,但是最终需要的只要最大内存
  • 注意,程序执行完后要关闭统计命令,统计完时间后要关闭:
  dockerClient.startContainerCmd(containerId).exec();
        // 4、执行命令 docker exec containtId java -cp /app Main 1 2
        ArrayList<ExecuteMessage> executeMessagesList = 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", "2"}, 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};
            long time = 0L;
            final boolean[] timeout = {true};
            String execId = execCreateCmdResponse.getId();


            if (execId == null) {
                throw new RuntimeException("执行命令不存在");
            }
            //启动命令的回调函数
            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(frame.getPayload()));
                    } else {
                        message[0] = new String(frame.getPayload());
                        System.out.println("输出结果:" + new String(frame.getPayload()));
                    }
                    super.onNext(frame);
                }

            };

            final long[] maxMemory = {0L};

            //获取占用的内存
            StatsCmd statsCmd = dockerClient.statsCmd(containerId);
            ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {
                @Override
                public void onStart(Closeable closeable) {

                }

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

                }

                @Override
                public void onError(Throwable throwable) {

                }

                @Override
                public void onComplete() {

                }

                @Override
                public void close() throws IOException {

                }
            });
            //获取结果
            statsCmd.exec(statisticsResultCallback);
            //启动执行命令
            try {
                //计时
                stopwatch.start();
                dockerClient.execStartCmd(execId).exec(execStartResultCallback).awaitCompletion(TIME_OUT, TimeUnit.NANOSECONDS);
                stopwatch.stop();;
                time = stopwatch.getLastTaskTimeMillis();
                statsCmd.close();
            } catch (InterruptedException e) {
                System.out.println("程序执行异常");
            }
            executeMessage.setMessage(message[0]);
            executeMessage.setErrorMessage(errorMessage[0]);
            executeMessage.setTime(time);
            executeMessage.setMemory(maxMemory[0]);
            executeMessagesList.add(executeMessage);

3.3 Docker容器安全性

3.3.1 超时控制

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

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

解决方案:可以定义一个标志,如果程序执行完成,把超时标志设置为 false。 示例代码如下:

dockerClient.execStartCmd(execId)
        .exec(execStartResultCallback)
        .awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
@Override
public void onComplete() {
    // 执行完成,设置为 false 不超时
    isTimeOut[0] = false;
    super.onComplete();
}
3.3.2 内存资源

通过HostConfig的withMemory等方法,设置容器的最大内存和资源限制:

CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1000L);
hostConfig.withMemorySwap(0L);
hostConfig.withCpuCount(1L);
CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .exec();
3.3.3 网络资源

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

CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .withNetworkDisabled(true) // 禁用网络
3.3.4 权限管理
  1. 结合 Java 安全管理器和其他策略去使用
  2. 限制用户不能向 root 根目录写文件:
CreateContainerResponse createContainerResponse = containerCmd
        .withHostConfig(hostConfig)
        .withNetworkDisabled(true)
        .withReadonlyRootfs(true)  // 限制用户使用 root 权限写wen
  1. Linux 自带的一些安全管理措施,比如 seccomp(Secure Computing Mode)是一个用于 Linux 内核的安全功能,它允许你限制进程可以执行的系统调用,从而减少潜在的攻击面和提高容器的安全性。通过配置 seccomp,你可以控制容器内进程可以使用的系统调用类型和参数。
    示例 seccomp 配置文件 profile.json:
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "name": "write",
      "action": "SCMP_ACT_ALLOW"
    },
    {
      "name": "read",
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

在 hostConfig 中开启安全机制

String profileConfig = ResourceUtil.readUtf8Str("profile.json");
hostConfig.withSecurityOpts(Arrays.asList("seccomp=" + profileConfig));

4. 模板方法优化代码沙箱

4.1 什么是模板方法

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

适用场景: 适用于有规范的流程,且执行流程可以复用

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

4.2 流程

4.2.1 抽象出流程

将每个流程提取为方法

/**
     * 将用户代码保存为文件
     * @param code 用户代码
     * @return File 代码文件
     */
    public File saveCodeToFile(String code) {
        String userDir = System.getProperty("user.dir");
        // 创建临时文件夹,例如:C:\code\siyioj-code-sandbox\tmpCode
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        if (!FileUtil.exist(globalCodePathName)) {
            // 不存在,则创建文件目录
            FileUtil.mkdir(globalCodePathName);
        }
        //将用户代码隔离存放
        String userCodePathName = globalCodePathName + File.separator + UUID.randomUUID();
        //实际存放用户代码的文件夹的java文件路径,例如:C:\code\siyioj-code-sandbox\tmpCode\1\SleepError.java
        String userCodePath = userCodePathName + File.separator + GLOBAL_JAVA_CLASS_NAME;
        File userCodeFile = FileUtil.writeString(code, userCodePath, "UTF-8");
        return userCodeFile;
    }

    /**
     * 编译代码
     *
     * @param userCodeFile
     * @return
     */
    public ExecuteMessage compilerFile(File userCodeFile) {
        String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
        try {
            Process compileProcess = Runtime.getRuntime().exec(compileCmd);
            ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(compileProcess, "编译");

            if (executeMessage.getExitValue() != 0) {
                throw new RuntimeException("编译错误");
            }
            return executeMessage;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 执行文件获取结果列表
     *
     * @param userCodeFile
     * @param inputList
     * @return
     */
    public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
        String userDir = System.getProperty("user.dir");
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        String userCodePathName = globalCodePathName + File.separator + UUID.randomUUID();
        List<ExecuteMessage> executeMessagesList = new ArrayList<>();
        for (String inputArgs : inputList) {
//            String runCmd = String.format("java -Xmx1024m -Dfile.encoding=UTF-8  -cp %s Main %s", userCodePathName, inputArgs);
            String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=%s Main %s", userCodePathName, SECURITY_MANAGER_PATH, SECURITY_MANAGER_CLASS_NAME, inputArgs);

            try {
                Process runProcess = Runtime.getRuntime().exec(runCmd);
                new Thread(() -> {
                    try {
                        Thread.sleep(TIME_OUT);
                        runProcess.destroy();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }).start();
                ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
                executeMessagesList.add(executeMessage);
                System.out.println(executeMessage);
            } catch (Exception e) {
                throw new RuntimeException("执行错误", e);
            }
        }
        return executeMessagesList;
    }

    /**
     * 4. 获取响应输出结果
     *
     * @param executeMessagesList 执行结果
     * @return ExecuteCodeResponse 响应结果集合
     */
    public ExecuteCodeResponse getOutputResponse(List<ExecuteMessage> executeMessagesList) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        List<String> outputList = new ArrayList<>();
        long maxTime = 0;
        for (ExecuteMessage executeMessage : executeMessagesList) {
            String errorMessage = executeMessage.getErrorMessage();
            if (StrUtil.isNotBlank(errorMessage)) {
                executeCodeResponse.setStatus(3);
                executeCodeResponse.setMessage(errorMessage);
                break;
            }
            outputList.add(executeMessage.getMessage());
            Long time = executeMessage.getTime();
            if (time != null) {
                maxTime = Math.max(maxTime, time);
            }
        }
        //正常运行,也就是没有错误信息
        if (outputList.size() == executeMessagesList.size()) {
            executeCodeResponse.setStatus(1);
        }
        executeCodeResponse.setOutputList(outputList);
        JudgeInfo judgeInfo = new JudgeInfo();
// TODO  judgeInfo.setMemory();
        judgeInfo.setTime(maxTime);
        executeCodeResponse.setJudgeInfo(judgeInfo);
        return executeCodeResponse;
    }

    /**
     * 删除执行后的文件
     * @param userCodeFile
     * @return
     */
    public boolean clearFile(File userCodeFile) {
        String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
        if (userCodeFile.getParentFile() != null) {
            boolean del = FileUtil.del(userCodeParentPath);
            log.info("删除" + (del ? "成功!" : "失败!"));
            return del;
        }
        return true;
    }

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

提取出来模板方法后,只需要将对应不同流程(或是说方法),也就是在Docker中执行代码这一步对父类重写

@Component
public class JavaDockerCodeSandBox extends JavaCodeSandboxTemplate {
    public static final boolean FIRST_INIT = true;
    private static final long TIME_OUT = 5000L;

    /**
     * @param executeCodeRequest 请求参数
     * @return 执行结果
     */
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        return super.executeCode(executeCodeRequest);
    }

    /**
     * @param userCodeFile 用户执行代码的文件
     * @param inputList    输入
     * @return 执行结果集
     */
    @Override
    public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
    //docker3.4两个部分地方代码,这里省略
    }

5.sandboxAPI接口

调用安全性的解决方案:

  1. 调用方与服务提供方之间约定一个字符串 (加密)

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

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

private static final String AUTH_REQUEST_HEADER = "auth";

private static final String AUTH_REQUEST_SECRET = "secretKey";
  1. API 签名认证

    给允许调用的人员分配 accessKey、secretKey,然后校验这两组 key 是否匹配

@RestController("/")
public class MainController {
    public static final String AUTH_REQUEST_HEADER = "auth";
    public static final String AUTH_REQUEST_SECRET = "key";

    @Resource
    JavaNativeCodeSandbox javaNativeCodeSandbox;


    @PostMapping("executeCode")
    ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest, HttpServletRequest request, HttpServletResponse response) {
        String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
        if (!AUTH_REQUEST_SECRET.equals(authHeader)) {
            response.setStatus(403);
            return null;
        }
        if (executeCodeRequest == null) {
            throw new RuntimeException("请求参数为空");
        }
        return javaNativeCodeSandbox.executeCode(executeCodeRequest);
    }

}

为了后续改造微服务方便,将submit 服务层复制到 question内

九. 微服务

1.什么是微服务

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

分布式与微服务的区别:

微服务是将项目拆分,分布式是多台机器部署项目,可以理解为项目与服务器的区别

单体项目改为微服务项目

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

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

spring-cloud-alibaba-img-ca9c0e5c600bfe0c3887ead08849a03c

流程

image-20240113123402912

2.改造为微服务项目

改造前思考:

  1. 从业务需求出发,思考单机和分布式的区别。
  2. 用户登录功能:需要改造为分布式登录
    其他内容:
    ●有没有用到单机的锁?改造为分布式锁
    ●有么有用到本地缓存?改造为分布式缓存(Redis)
    ●需不需要用到分布式事务?比如操作多个库

2.1 分布式登录

  1. application.yml 增加 redis 配置

  2. 补充依赖:

    <!-- redis -->
    <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>
    
  3. 主类取消Redis自动配置的移除

    //将排除自动配置删除
    @SpringBootApplication (exclude = {RedisAutoConfiguration.class})
    
  4. 修改session存储方式:

    spring:
      session:
        # 取消注释开启分布式 session(须先配置 Redis)
        store-type: redis
    
  5. 使用 redis-cli 或者 redis 管理工具,查看是否有登录后的信息

使用无痕和正常登录检查是否有信息,这里发现多了一条数据

image-20240113174754762

2.2 微服务的划分

从业务出发,想一下哪些功能 / 职责是一起的?
公司老板给员工分工

依赖服务:

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

项目模块

  • 用户模块 (yuoj-backend-user-service:8102 端口):
  1. 注册

  2. 登录

  3. 用户管理

  • 题目模块 (yuoj-backend-question-service:8103)
  1. 创建题目(管理员)

  2. 删除题目(管理员)

  3. 修改题目(管理员)

  4. 搜索题目(用户)

  5. 在线做题

  6. 提交题目代码

  • 判题模块 (siyioj-backend-judge-service,8104 端口,较重的操作)
  1. 提交判题(结果是否正确与错误)

  2. 错误处理(内存溢出、安全性、超时)

  3. 自主实现 代码沙箱(安全沙箱)

  4. 开放接口(提供一个独立的新服务)

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

路由划分

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

  • 用户服务:
  1. /api/user
  2. /api/user/inner(内部调用,网关层面要做限制)
  • 题目服务:
  1. /api/question(也包括题目提交信息)
  2. /api/question/inner(内部调用,网关层面要做限制)
  • 判题服务:
  1. /api/judge

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

4.MQ安装笔记

  1. 安装好对应版本的erlang 25.3.2(因为 RabbitMQ 依赖 erlang),这个语言的性能非常高。

RabbitMQ Erlang Version Requirements — RabbitMQ

  1. 然后下载MQ https://www.rabbitmq.com/install-windows.html

  2. 设置两个的环境变量(MQ的是sbin)

  3. 启动MQ服务

  4. 在``MQsbin下执行: rabbitmq-plugins.bat enable rabbitmq_management`

  5. 继续在这个目录下执行是否安装成功rabbitmqctl status,如果不显示这种则安装没有成功

    image-20240114170328074

  6. 如果失败则:

将C:\Users{用户名}.erlang.cookie 复制到 C:\Windows\System32\config\systemprofile 目录。
重启rabbitMQ服务

  1. 没有失败则进入默认账号密码为guest

http://127.0.0.1:15672/#/

十. 项目上线

为了打包方便,这里需要修改一下pom.xml配置:

给每一个子项目添加:

<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<executions>
					<execution>
						<id>repackage</id>
						<goals>
							<goal>repackage</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>

1.服务器环境搭建

1.1 DockerDocker compose的安装

V1可能会有些问题, V2的官网:

Install Docker Engine on Ubuntu | Docker Docs

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update

安装 Docker 包

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

通过运行映像来验证 Docker 引擎安装是否成功。hello-world

sudo docker run hello-world

V1 遇见的问题

github源可能会无法下载,只需要将源也就是前缀换了就行

DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
mkdir -p $DOCKER_CONFIG/cli-plugins
curl -SL https://github.com/docker/compose/releases/download/v2.24.2/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose

因此切换

curl -L https://get.daocloud.io/docker/compose/releases/download/v2.24.2/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose

设置权限

chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose

最后测试安装,下载hello-world镜像运行———— sudo docker run hello-world

docker compose version

1.2 远程部署到服务器

这个在上面的远程开发第一种,忘记可以看上面,这里只说明路径映射

image-20240125200640900

在映射路径我填的是相对路径,因此会直接在/oj目录下

image-20240125200728923

然后上传项目,但是注意先clean,把target清理了,等同步到服务器再重新使用maven打包

1.3 maven安装

apt是ubuntu的安装,linux或centos可以使用yum来进行安装,安装命令:

# 安装maven
sudo apt-get -y install maven

安装完毕后使用mvn -v检查版本

1.4 打包

进入到项目根目录, 使用命令 sudo mvn package -DskipTests 打包

image-20240125202054026

成功后使用 sudo docker compose -f docker-compose.env.yml up

image-20240125225803756

开放端口

image-20240125230742135

当服务启动成功后,可以后面加 -d ,启动后crtl+C停止后, 可以使用docker stats查看当前docker运行

sudo docker compose -f docker-compose.env.yml up -d

一样的流程,然后启动项目服务

sudo docker compose -f docker-compose.service.yml up

这里我初步限制的内存如下:

  • MySQL: 512GB
  • Redis: 256MB
  • RabbitMQ: 256MB
  • Nacos: 256MB
  • 网关: 512MB
  • 题目服务: 384MB
  • 判题服务: 384MB
  • 用户服务: 384MB

实际:

image-20240308100122303

因此,nacos我们继续增加内存到375发现还是G了,继续调高,之后还是不行,看文档

Nacos Docker 快速开始

MYSQL_DATABASE_NUM数据库编号默认 :1
JVM_XMS-Xms默认 :1g
JVM_XMX-Xmx默认 :1g
JVM_XMN-Xmn默认 :512m
JVM_MS-XX:MetaspaceSize默认 :128m
JVM_MMS-XX:MaxMetaspaceSize默认 :320m
NACOS_DEBUG是否开启远程DEBUGy/n 默认 :n

基于这些默认值,我们重新进行分配,不断进行尝试

最终的DockerFile jvm

ENTRYPOINT [\
   "java", \
   "-Xgcpolicy:gencon",
   "-Xtune:virtualized", \
   "-Xms128m", \
   "-Xmx256m", \
   "-Xss256k", \
   "-Xshareclasses:name=shared_cache,cacheDir=/tmp,enableBCI", \
   "-Xscmx50m", \
   "-XX:+UseContainerSupport", \
   "-XX:InitialRAMPercentage=25.0", \
   "-XX:MaxRAMPercentage=75.0", \
   "-Xquickstart", \
   "-jar", \
   "/app/siyioj-backend-gateway-0.0.1-SNAPSHOT.jar", \
   "--spring.profiles.active=prod"]

OpenJ9 性能调优实例

利用OpenJ9大幅度降低JAVA内存占用 - 简书 (jianshu.com)

成功截图:

image-20240308175743449

这里访问接口文档异常,但是服务全部启动,是因为bug,可以先尝试一下get方法,访问一次接口文档就可以进去了

1.5前端项目打包

1.5.1前端项目的打包准备

首先进入vue部署的官方文档:推荐我们去构建工具,这是项目使用了vueCLI,这是官方文档:

部署 | Vue CLI (vuejs.org)

步骤:

  1. 安装docker
  2. 在项目根目录创建 Dockerfile 文件
FROM node:10
COPY ./ /app
WORKDIR /app
RUN npm install && npm run build

FROM nginx
RUN mkdir /app
COPY --from=0 /app/dist /app
COPY nginx.conf /etc/nginx/nginx.conf

这里贴出另一个配置:

FROM nginx

WORKDIR /usr/share/nginx/html/
USER root
# 这里是指将docker目录下的nginx.conf复制到指定目录
COPY ./docker/nginx.conf /etc/nginx/conf.d/default.conf  

COPY ./dist  /usr/share/nginx/html/

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

  1. 在项目根目录创建 .dockerignore 文件(这里注意.官网的方法是直接dockerfile中build,这里是自己build后上传到服务器)
**/node_modules
**/dist
  1. 在项目根目录创建 nginx.conf 文件

简易版:

server {
    listen 80;

    # gzip config
    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 9;
    gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
    gzip_vary on;
    gzip_disable "MSIE [1-6]\.";

    root /usr/share/nginx/html;
    include /etc/nginx/mime.types;

    location / {
        try_files $uri /index.html;
    }

}

完整版(官方版):

user  nginx;
worker_processes  1;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {
  worker_connections  1024;
}
http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;
  log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
  access_log  /var/log/nginx/access.log  main;
  sendfile        on;
  keepalive_timeout  65;
  server {
    listen       80;
    server_name  localhost;
    location / {
      root   /app;
      index  index.html;
      try_files $uri $uri/ /index.html;
    }
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
      root   /usr/share/nginx/html;
    }
  }
}
  1. 构建你的 Docker 镜像
docker build . -t my-app
# Sending build context to Docker daemon  884.7kB
# ...
# Successfully built 4b00e5ee82ae
# Successfully tagged my-app:latest
  1. 运行你的 Docker 镜像

这个例子基于官方 Nginx 镜像,因此已经设置了日志重定向并关闭了自我守护进程。它也提供了其他有利于 Nginx 在 Docker 容器中运行的默认设置。更多信息参阅 Nginx Docker 仓库

docker run -d -p 8080:80 my-app
curl localhost:8080
# <!DOCTYPE html><html lang=en>...</html>

1.6查找docker中mysql

  1. docker ps
  2. 选中id, docker exec -it <容器名称或ID> bash
  3. mysql -u root -p

1.7 修改springboot的yml配置修改后不管用

两种解决方法都需要上传到服务器后需要重新将项目重新打包,这里两种解决方法:

  1. 重建并重新启动服务:

使用docker-compose up命令时,添加--build选项来强制构建新的镜像,即使之前已经存在。同时,使用-d选项以后台模式运行。例如:

sudo docker-compose -f docker-compose.env.yml up -d --build
sudo docker-compose -f docker-compose.service.yml up -d --build
  1. 清理所有镜像:

在重新构建和启动服务之前,你可以先手动停止并移除现有的容器和镜像,以确保不会使用旧的配置。首先,停止并移除相关容器:

sudo docker-compose -f docker-compose.env.yml down
sudo docker-compose -f docker-compose.service.yml down

然后,根据需要移除旧的镜像。你可以列出所有镜像查看它们的ID或标签,然后移除它们:

sudo docker images
sudo docker rmi <IMAGE_ID_OR_TAG>

清理Docker:使用Docker提供的清理命令来移除悬挂的镜像、未使用的网络等,释放空间:

sudo docker system prune -a

1.8后端跨域问题解决

cookie报错: 尝试通过Set-Cookie 标头设置 Cookie 时被阻止,因为它具有"Samesite=Lax“属性,但来自跨站点响应,而不是对顶级导航的响应,

简单的来说就是出现了跨域请求,但浏览器默认的SameSite=Lax是不支持跨域下cookie操作的。因此设置cookie失败。具体原因可以参考上面的传送门。

这里试了很多方法都不行,决定使用Nginx反向代理了

2.后端绑定域名Nginx

2.1安装Nginx

docker镜像仓库:nginx Tags | Docker Hub

下载stable版本: docker pull nginx:stable-perl

  1. 使用docker images查看镜像
  2. 创建挂载目录
mkdir -p /home/nginx/conf

mkdir -p /home/nginx/log  #不必须

mkdir -p /home/nginx/html
  1. docker run --name nginx -p 80:80 -d nginx:stable-perl

命令意思为: 启动docker 容器,这里需要启动才能挂载对应目录

  • docker run: 这是Docker的一个命令,用于创建一个新的容器并运行一个命令。它是最常用的Docker命令之一。
  • --name nginx: 这个选项为即将运行的容器指定了一个名称,即nginx。指定名称后,可以使用这个名称来引用容器,而不是使用容器的ID。
  • -p 80:80: 这个选项用于端口映射。格式为-p <宿主机端口>:<容器端口>。这里指定将容器的80端口映射到宿主机的80端口上。这意味着通过宿主机的80端口可以访问到容器内部运行的服务(例如,一个web服务器)。
  • -d: 这个选项告诉Docker要在后台模式运行容器。也就是说,启动容器后,命令行会立即返回,容器在后台运行。
  • nginx: 这是要运行的Docker镜像的名称。在这个例子中,使用的是官方的nginx镜像。如果你没有指定版本,Docker默认使用latest标签的镜像,即最新版本的nginx镜像。

如果想直接使用域名访问项目而不加端口,这里建议改为80:80

  1. 为了持久化配置,这里挂载刚才的配置目录
# 将容器nginx.conf文件复制到宿主机
docker cp nginx:/etc/nginx/nginx.conf /home/nginx/conf/nginx.conf
# 将容器conf.d文件夹下内容复制到宿主机
docker cp nginx:/etc/nginx/conf.d /home/nginx/conf/conf.d
# 将容器中的html文件夹复制到宿主机
docker cp nginx:/usr/share/nginx/html /home/nginx/

这里已经是第二遍,这里贴出完整的挂载命令并启动

docker run --name nginx -p 80:80 \
  -v /home/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
  -v /home/nginx/conf/conf.d:/etc/nginx/conf.d \
  -v /home/nginx/html:/usr/share/nginx/html \
  -v /home/nginx/log:/var/log/nginx \
  -d nginx:stable-perl

之后docker重新加载配置:docker exec nginx nginx -s reload或者restart 容器

这里发现依然不管用,通过了解发现解决方案两种:

  • 脱离session,使用jwt
  • 使用https,因为secure属性需要https

2.2项目引入JWT

  1. 在网关服务和用户登录服务中添加依赖依赖,这里旧版本找不到了,是因为jjwt迁移到jwt-api的原因
	        <dependency>
				<groupId>io.jsonwebtoken</groupId>
				<artifactId>jjwt-api</artifactId>
				<version>0.11.2</version>
			</dependency>
  1. 在网关和用户服务写JwtUtil(如果有需要还需要放开注册服务)
public class JwtUtils {

    // 示例:从配置中加载密钥和有效时间
    // 这里只是示例,实际上应该从配置文件或环境变量中加载
    private static final String secretKeyBase64 = "hpetPMvbEBAsGrn4rVaELdCHDyutwEwu7aFOyEg4Rk0=";
    private static final long JWT_TTL = 3600000L; // 1小时

    /**
     * 创建token
     *
     * @param id        用户ID
     * @param ttlMillis 有效期(毫秒)
     * @return 生成的JWT token字符串
     */
    public static String createJWT(String id, Long ttlMillis) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        if (ttlMillis == null) {
            ttlMillis = JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);

        byte[] apiKeySecretBytes = Base64.getDecoder().decode(secretKeyBase64);
        Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
// 将生成的密钥转换为字符串(用于存储或配置)
        JwtBuilder builder = Jwts.builder()
                // 设置用户ID,唯一标识
                .setSubject(id)
                // 设置签发时间
                .setIssuedAt(now)
                // 设置签名, 算法
                .signWith(signingKey, signatureAlgorithm)
                // 设置过期时间
                .setExpiration(expDate);

        return builder.compact();
    }

    /**
     * 解析JWT
     *
     * @param jwtToken JWT token字符串
     * @return Claims
     * @throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException
     */
    public static Claims parseJWT(String jwtToken) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException {
        byte[] apiKeySecretBytes = Base64.getDecoder().decode(secretKeyBase64);
        Key signingKey = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());

        return Jwts.parserBuilder()
                .setSigningKey(signingKey)
                .build()
                .parseClaimsJws(jwtToken)
                .getBody();
    }
}

  1. 在用户登录出修改:
 public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
        if (userLoginRequest == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        String userAccount = userLoginRequest.getUserAccount();
        String userPassword = userLoginRequest.getUserPassword();
        if (StringUtils.isAnyBlank(userAccount, userPassword)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);
        // 检查登录用户对象是否为空
        if (loginUserVO == null) {
            // 处理用户对象为空的情况,例如返回错误信息
            return ResultUtils.error(40000, "用户信息不存在或登录失败");
        }
        // 创建JWT token
        String token = JwtUtils.createJWT(loginUserVO.getId().toString(), null);
        // 设置JWT token到登录用户对象
        loginUserVO.setToken(token);
        // 返回成功结果
        return ResultUtils.success(loginUserVO);
    }
  1. 修改前端登陆逻辑:
const handleSubmit = async () => {
  const res = await UserControllerService.userLoginUsingPost(form);
  // 登录成功,跳转到主页
  if (res.code === 0) {
    //存入sessionStorage
    sessionStorage.setItem("token", res.data.token);
    await store.dispatch("user/getLoginUser");
    router.push({
      path: "/",
      replace: true,
    });
  } else {
    message.error("登陆失败," + res.message);
  }
};
  1. 修改路由守卫逻辑,否则会导致网关收不到token拒绝访问:
router.beforeEach(async (to, from, next) => {
  console.log("登陆用户信息", store.state.user.loginUser);
  let loginUser = store.state.user.loginUser; // 获取当前登录用户

  // 特殊处理登录页面,如果已经是登录页面,则直接放行
  if (to.path === "/user/login") {
    next();
    return;
  }

  // 检查sessionStorage是否有token
  const token = sessionStorage.getItem("token");
  // 如果没有token,且不是访问登录页面,则重定向到登录页面
  if (!token) {
    next(`/user/login?redirect=${to.fullPath}`);
    return;
  }
    // ------其他代码-----------
}
  1. 请求拦截器:
axios.interceptors.request.use(
  function (config) {
    const token = sessionStorage.getItem("token");
    console.log("token", token);
    if (token) {
      config.headers.Authorization = `${token}`;
    }
    return config;
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error);
  }
);
  1. 最后,删除yml中的session配置

2.3 Sentinel 限流

首先查询需要的版本号,本项目cloud:2021.0.5,springboot 2.6.13

image-20240312160955329

来到官方文档: quick-start | Sentinel (sentinelguard.io)

官方的网关限流: api-gateway-flow-control | Sentinel (sentinelguard.io)

  • 核心库(Java 客户端):不依赖任何框架/库,能够运行于 Java 8 及以上的版本的运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持(见 主流框架适配)。
  • 控制台(Dashboard):Dashboard 主要负责管理推送规则、监控、管理机器信息等。

这里下载1.8.6的控制台版本: Release v1.8.6 · alibaba/Sentinel (github.com)

之后为了方便启动,我们这里选择8105作为端口访问sentinel,yml配置如下:

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8105 # Sentinel Dashboard地址
        port: 8719 # 应用与Sentinel Dashboard通信的端口,确保此端口未被占用

在Jar包打开终端, 利用启动命令

$ java -Dserver.port=8080 -Dcsp.sentinel.dashboard.server=localhost:8080 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar

利用默认用户名密码: sentinel

image-20240312213124232

如果发现没有网关设备,可以重新启动试试

接下来,要么在控制台进行限流,或者是通过硬编码进行限流的操作

这里介绍硬编码几个因素:

  • resource:资源名,即限流规则的作用对象
  • count: 限流阈值
  • grade: 限流阈值类型,QPS 或线程数
  • strategy: 根据调用关系选择策略

接下来介绍两种限流方法:

资源名限流(Direct Resource Level Flow Control)

资源名限流是Sentinel最常见的限流方式,它针对的是对单个资源(比如HTTP接口、Dubbo接口等)的访问频率进行控制。你可以为每个资源设置阈值,当访问频率超过这个阈值时,Sentinel将进行流量控制,比如直接拒绝、冷启动、排队等待等。

为了实现资源名限流,你需要定义资源,并且在代码中明确指出哪些部分是受保护的资源。然后,你可以通过Sentinel控制台对这些资源进行流量控制规则的设置。

例如,你可以设置一个规则来限制每秒对某个特定URL的请求不超过100次。如果请求频率超过这个限制,Sentinel可以配置为立即拒绝超出的请求,从而保护系统不被过多的请求压垮。

关联限流(Associated Resource Level Flow Control)

关联限流是一种更高级的限流策略,它允许你根据另一个资源的流量来限制当前资源的流量。这种方式非常适合处理应用中不同资源间的依赖关系,尤其是当某个资源的高流量会影响到另一个资源时。

比如,假设有两个资源A和B,在正常情况下它们的流量是独立的。但如果A的流量突然增加,并且A的处理逻辑中需要调用B,这可能会导致B的负载也随之增加。在这种情况下,即使B本身的流量没有超标,但由于A的高流量间接影响到B,最终可能导致B的处理能力不足。通过关联限流,你可以设置当A的流量超过一定阈值时,对B的访问也进行限制,从而保护B不会因A的高流量而受影响。

实施限流的策略
  • 直接拒绝:当QPS超过设定的阈值时,直接拒绝后续的请求。
  • Warm Up(预热/冷启动):逐渐增加通过的流量,用于应对突发流量。
  • 排队等待:请求在达到阈值时会排队等待,而不是直接被拒绀,以此平滑流量。

在这里介绍网关层面限流

使用时只需注入对应的 SentinelGatewayFilter 实例以及 SentinelGatewayBlockExceptionHandler 实例即可。比如:

官方demo

@Configuration
public class GatewayConfiguration {

    private final List<ViewResolver> viewResolvers;
    private final ServerCodecConfigurer serverCodecConfigurer;

    public GatewayConfiguration(ObjectProvider<List<ViewResolver>> viewResolversProvider,
                                ServerCodecConfigurer serverCodecConfigurer) {
        this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
        this.serverCodecConfigurer = serverCodecConfigurer;
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SentinelGatewayBlockExceptionHandler sentinelGatewayBlockExceptionHandler() {
        // Register the block exception handler for Spring Cloud Gateway.
        return new SentinelGatewayBlockExceptionHandler(viewResolvers, serverCodecConfigurer);
    }

    @Bean
    @Order(-1)
    public GlobalFilter sentinelGatewayFilter() {
        return new SentinelGatewayFilter();
    }
}

最后贴一张成功图片:

image-20240312232031277

image-20240312232044004

2.4JMeter的使用

官网: http://jmeter.apache.org/download_jmeter.cgi

启动: 双击JMeter解压路径(D:\Codings\JMeter\apache-jmeter-5.6.3\bin)bin下面的jmeter.bat即可

3.沙箱的部署

这里依然使用docker部署,因此需要编写dockerfile,这里为了安全性创建了新用户并且赋予 appuser 相关的权限,(踩坑: 无权限会报 no such file)

# 在基础镜像的基础上继续构建
FROM openjdk:8-jdk-alpine

# 创建一个新的用户`appuser`用于运行应用,避免使用root用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 创建 tmpCode 目录并赋予 appuser 相关的权限
RUN mkdir -p /app/tmpCode && chown appuser:appgroup /app/tmpCode

# (可选)设置工作目录
WORKDIR /app

# 将你的jar包添加到镜像中(请确保路径和文件名正确)
COPY ./siyi-code-sandbox-1.0-SNAPSHOT.jar /app/siyi-code-sandbox-1.0-SNAPSHOT.jar

# 切换到非root用户
USER appuser

# 暴露端口
EXPOSE 8106

# 启动命令
ENTRYPOINT ["java", \
    "-XX:+UseG1GC", \
    "-Xms256m", \
    "-Xmx512m", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", \
    "/app/siyi-code-sandbox-1.0-SNAPSHOT.jar", \
    "--spring.profiles.active=prod"]

之后在服务器创建目录并上传dockerfile和jar包,在该目录下打开终端执行:

docker build -t siyi-code-sandbox:v0.0.1 .
docker run -p 8106:8106 -m 512m --cpus="0.5" siyi-code-sandbox:v0.0.1

4.前端的部署

首先创建一个nginx的conf文件

server {
    listen 80;

    # gzip config
    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 9;
    gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
    gzip_vary on;
    gzip_disable "MSIE [1-6]\.";

    root /usr/share/nginx/html;
    include /etc/nginx/mime.types;

    location / { 
        try_files $uri /index.html; 
    }

}

try_files $uri /index.html; 是为了找不到对应路由回到index

编写dockerfile

FROM nginx:stable

WORKDIR /usr/share/nginx/html/
USER root

#将conf配置复制到nginx容器配置
COPY ./docker/nginx.conf /etc/nginx/conf.d/default.conf

#将dist目录替换为容器内html
COPY ./dist  /usr/share/nginx/html/ 
EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

这里是nginx容器的80端口映射到宿主机的8080, 构建,执行:

docker build -t siyi-oj-front:v0.0.1 .
docker run -p 8080:80 siyi-oj-front:v0.0.1
#后台运行
docker run -d -p 8080:80 siyi-oj-front:v0.0.1

目录结构

image-20240315165209182

5.nginx配置反向代理

这个项目的服务器架构是:

前端+沙箱: 2核2G

后端服务+环境: 2核4G

后端是全部基于docker部署,这里不再赘述,可以查看上面的笔记

前端使用docker部署, 绑定了8080端口,但是这里依然需要添加端口才能访问,因此这里新加一层,在服务器中的ngixn反向代理绑定,,并且代理沙箱服务

前端使用了宝塔, 配置:

server {
    listen 80;
    server_name coding.zhangyiduo.top; 

    access_log  /www/wwwlogs/coding.zhangyiduo.top_access.log;
    error_log  /www/wwwlogs/coding.zhangyiduo.top_error.log;

    location / {
        proxy_pass http://coding.zhangyiduo.top:8080;  
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}        
        
        
server {
    listen       80;
    server_name  sandbox.zhangyiduo.top; 

    access_log  /www/wwwlogs/api.zhangyiduo.top_access.log;
    error_log  /www/wwwlogs/api.zhangyiduo.top_error.log;

    location / {
        proxy_pass http://8.137.108.78:8106; 
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
   }
    }