基于QingDaoU OJ开发在线编程教育网

2022年05月03日 未雨晴空 0评论 2113阅读 0喜欢

前言

QingdaoU OnlineJudge是一款开源的在线判题系统,我当时也是第一次接触基于该系统开发在线编程教育系统,也是踩了不少坑,这里分享下我的个人经验。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

QingDaoU OnlineJudge相关模块

后端(Django): https://github.com/QingdaoU/OnlineJudge
前端(Vue): https://github.com/QingdaoU/OnlineJudgeFE
部署(docker+docker-compose):https://github.com/QingdaoU/OnlineJudgeDeploy
判题沙箱(Seccomp): https://github.com/QingdaoU/Judger
判题服务器(对Judger的封装): https://github.com/QingdaoU/JudgeServer

安装

linux安装

linux安装就很简单了,只需要在服务器安装好docker和docker-compose,不会安装可参考我的博客文章 docker以及docker-compose笔记。安装完成后只需要上传上面的部署模块源代码到服务器目录下,
需要保证关于docker-compose里面的宿主机器映射端口都可用,如postgresqll的5432、redis的6379、oj-backend的80和443端口都可用,然后切换到部署目录下执行

docker-compose up -d

windows安装

如果只是想部署一下看看不打算而开的话,windos下安装同linux下安装一样,只不过docker换成docker desktop,当然docker-compose也需要安装的,github有提供可执行文件安装的,很方便。不过也有坑,貌似windows家庭版的安装docker desktop有坑,我踩过了,忘记记录,后面有再次安装,到时候再来记录。

环境搭建

先说下我当时二开的步骤,因为当时公司配的电脑是windows系统,所以在搭建环境的时候也是走了不少路。

前端

先说下前端项目,前端项目安装的时候切记node版本一定要是8.12.0, 一开始我就是官网下载的最新版那边node,直接依赖都安装不上。按照前端的源码的readme的代码如下:

npm install
# we use webpack DllReference to decrease the build time,
# this command only needs execute once unless you upgrade the package in build/webpack.dll.conf.js
set NODE_ENV=development 
npm run build:dll

# the dev-server will set proxy table to your backend
set TARGET=http://Your-backend

# serve with hot reload at localhost:8080
npm run dev

首先上面的命令都是可行的,但是这个环境变量只会作用于当前终端窗口,也就说你下次再打开终端还是不重新设置的话就没了。如果想永久,windows直接设置个环境变量就行。
在这里插入图片描述

后端

再说回后端,后端肯定是重中之中了,要想二开那肯定得好好看后端得代码了,要看代码肯定免不了要debug,所以后端是必须要在windows下跑起来。这个倒不难,后端源码下载下来正常启动django项目得步骤即可。数据库得的配置可以改为自己本地的pg库。

然后的话就是需要评测功能,而评测功能是依赖另一个项目也就是judge-server,可以下载OnlineJudgeDeploy下载下来然后修改如下部分即可

judge-server:
    image: registry.cn-hangzhou.aliyuncs.com/onlinejudge/judge_server
    container_name: judge-server
    restart: always
    read_only: false
    cap_drop:
    - SETPCAP
    - MKNOD
    - NET_BIND_SERVICE
    - SYS_CHROOT
    - SETFCAP
    - FSETID
    tmpfs:
    - /tmp
    volumes:
    - ./data/backend/test_case:/test_case:ro
    - ./data/judge_server/log:/log
    - ./data/judge_server/run:/judger
    environment:
    - SERVICE_URL=http://127.0.0.1:8080
    - BACKEND_URL=http://host.docker.internal:8000/api/judge_server_heartbeat/
    - TOKEN=3c8e8bd79d66259c46d8a3d057278dc8
    - judger_debug=1
    ports:
    - "0.0.0.0:8080:8080"

其中SERVICE_URL一定要和下面的宿主机端口:容器端口对应,不然到时候后端提交的数据时候会找不到地址。

BACKEND_URL是 judge-server会访问后端接口维持心跳(前提是一定要保证后端配置项里面的token一致)

然后的话下载 docker desktop 安装docker-compose,然后切换到OnlineJudgeDeploy的目录下执行

docker-compose up judge-server -d

启动 judge-server即可。

因为QDU OJ用到了dramatiq任务队列,如果不想测试队列功能而只是开发可以修改根目录下submission/views/oj.py的第81行代码为如下,本地开发时就不需要启动dramatiq了

from django.conf import settings
from judge.dispatcher import JudgeDispatcher
if settings.DEBUG:
    JudgeDispatcher(submission.id, problem.id).judge()
else:
    judge_task.send(submission.id, problem.id)

如果需要启动dramatiq异步任务队列,终端切换到根目录下执行一下命令 python manage.py rundramatiq --processes 2 --threads 4 --log-file ./data/log/dramatiq.log

一些你需要的手段

OnlineJudge全局修改中文

前端修改
我们编辑前端源代码路径src/il8n/index.js如下

import Vue from 'vue'
import VueI18n from 'vue-i18n'
// ivew UI
import ivenUS from 'iview/dist/locale/en-US'
import ivzhCN from 'iview/dist/locale/zh-CN'
import ivzhTW from 'iview/dist/locale/zh-TW'
// element UI
import elenUS from 'element-ui/lib/locale/lang/en'
import elzhCN from 'element-ui/lib/locale/lang/zh-CN'
import elzhTW from 'element-ui/lib/locale/lang/zh-TW'

Vue.use(VueI18n)

const languages = [
  {value: 'en-US', label: 'English', iv: ivenUS, el: elenUS},
  {value: 'zh-CN', label: '简体中文', iv: ivzhCN, el: elzhCN},
  {value: 'zh-TW', label: '繁體中文', iv: ivzhTW, el: elzhTW}
]
const messages = {}

// combine admin and oj
for (let lang of languages) {
  let locale = lang.value
  let m = require(`./oj/${locale}`).m
  Object.assign(m, require(`./admin/${locale}`).m)
  let ui = Object.assign(lang.iv, lang.el)
  messages[locale] = Object.assign({m: m}, ui)
}
// load language packages
export default new VueI18n({
  locale: languages[1]['value'],
  messages: messages
})

export {languages}

这样改的话其实也就是修改了默认的页面语言,其实通过看src/pages/oj/views/setting/children/ProfileSetting.vue的代码如下部分:

          <FormItem label="Language">
            <Select v-model="formProfile.language">
              <Option v-for="lang in languages" :key="lang.value" :value="lang.value">{{lang.label}}</Option>
            </Select>
          </FormItem>

可以看到QindDaoU OJ是支持用户自定义设置自己的页面国际化的,如果不想让用户自主设置国家化的话那可以注释这块代码,不让用户选择同时修改后端源码的account/models.py里面的UserProfile模型给language 一个默认值,这样以后所有的用户都是默认中文

 language = models.TextField(default="zh-CN")

但是还需要执行sql语句改变以前已经创建过的用户了

update user_profile set language='zh-CN'

另外个人觉得如果你怕麻烦,执行sql语句是最方便的哈哈

用户密码忘记了?

因为用户的密码是通过django提供的密码加密过了,所以数据库修改不现实

上服务器修改

# 先切换到你的oj部署目录下
docker exec -it oj-backend /bin/sh 
python3 manage.py inituser --username USERNAME --password NEW_PASSWORD --action=reset

本地连接线上数据库

这种修改方式需要保证SECRET_KEY一样哦

python3 manage.py inituser --username USERNAME --password NEW_PASSWORD --action=reset

或者

# 切换到项目根目录下
python manage.py shell
from django.contrib.auth.hashers import make_password
from accout.models import User
User.objects.filter(username="xxxx").update(password=make_password("123456"))

定义返回数据格式

用过QDU OJ都知道,有用到djangorestframework,但是仅仅使用到了drf提供的序列化器serializer,然后基于django.views.generic.Viewn封装了一个APIView,该APIView提供参数解析、异常信息抽取、数据分页以及统一统一返回格式如下

{
    "error": null,
    "data": null
}

没有完全发挥drf提供的功能,如分页器PageNumberPagination和LimitOffsetPagination、viewsets(视图集)和generic(通用试图)以及权限验证

封装response

{
    "code": "ok",
    "msg“: "请求成功'
    "data": null
}
from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK,HTTP_400_BAD_REQUEST
class APIResponse:

    @staticmethod
    def response(code="", msg="", data=None, status=None, headers=None, **kwargs):
        result = {"code": code, "msg": msg, "data": data}
        return Response(data=result, status=status, headers=headers, **kwargs)

    @staticmethod
    def success(msg="请求成功", data=None, status=HTTP_200_OK, headers=None, **kwargs):
        result = {"code": "ok", "msg": msg, "data": data}
        return Response(data=result, status=status, headers=headers, **kwargs)

    @staticmethod
    def error(msg="请求失败", data=None, status=HTTP_200_OK, headers=None, **kwargs):
        result = {"code": "error", "msg": msg, "data": data}
        return Response(data=result, status=status, headers=headers, **kwargs)

异常拦截

需要添加统一的异常原因是因为在使用drf的序列化器时会直接抛出异常,不加处理的话,返回格式不会和自定义response返回格式统一

from rest_framework import status
from system.response import APIResponse
from django.http import Http404, JsonResponse
from rest_framework import exceptions
from rest_framework.exceptions import PermissionDenied
from rest_framework.views import set_rollback
from rest_framework_simplejwt.exceptions import InvalidToken
import logging

logger = logging.getLogger(__name__)

exception_map = {"not_found": "请求数据不存在",
                 "permission_denied": "权限未通过",
                 "parse_error": "请求参数异常",
                 "authentication_failed": "认证失败",
                 "not_authenticated": "尚未认证",
                 "method_not_allowed": "请求方式不支持",
                 "throttled": "超过限流次数",
                 "invalid": "请求参数异常",
                 "not_acceptable": "要获取的数据格式不支持",
                 "unsupported_media_type": "不支持的媒体类型",
                 }


def exception_handler(exc, context):
    if isinstance(exc, Http404):
        exc = exceptions.NotFound()
    elif isinstance(exc, PermissionDenied):
        exc = exceptions.PermissionDenied()
    if isinstance(exc, exceptions.APIException):
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait
        code = exc.default_code
        if isinstance(exc, InvalidToken):
            msg = "token无效或已过期"
        else:
            msg = extract_validate_errors(exc.detail)
        set_rollback()
        return APIResponse.response(code=code, msg=msg, status=status.HTTP_200_OK, headers=headers)
    else:
        logger.error(exc)
        return APIResponse.error(msg="服务器出错了", status=status.HTTP_500_INTERNAL_SERVER_ERROR)


"""提取序列化验证的错误"""

def extract_validate_errors(errors):
    _list = []
    if isinstance(errors, dict):
        for k, v in errors.items():
            if isinstance(v, list):
                if k=="non_field_errors":
                    _list.append("".join(v))
                else:
                    _list.append("{0}:{1}".format(k, ";".join(v)))
            else:
                print(v)
                # _list.append(v)
        msg = ",".join(_list)
    else:
        msg = errors
    return msg


class MyJSONRenderer(JSONRenderer):
    def render(self, data, accepted_media_type=None, renderer_context=None):
        if "code" not in data and "msg" not in data:
            data = {"code": "ok", "msg": "请求成功", "data": data}
        return super(MyJSONRenderer, self).render(data, accepted_media_type, renderer_context)

在settings.py的REST_FRAMEWORK里面添加自定义的异常处理器和渲染类

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'system.exceptions.exception_handler',
    'DEFAULT_RENDERER_CLASSES': [
        'system.renderers.MyJSONRenderer',
    ],
}

模块扩展

用户管理

用户管理这块QDU OJ缺乏细颗粒度的权限控制,而且QDU OJ没有保留django-admin自带的user-group-permission权限,只是添加了一个AdminType来区分权限。这块我也还没做,后期考虑做成基于RBAC设计权限模型。

题库管理

QDU OJ只是有评测题,题目类型不丰富,在此基础上新增了选择题、填空题两种题型。

选择题界面
选择题界面
填空题界面
填空题界面
选择题和填空题的内容字段考虑到内容的丰富性继续沿用原本的富文本编辑编辑器。
将题目模型里面的难度、标签、来源单独抽出来放到字典模型里面

class SysDictType(object):
    PROBLEM_DIFFICULTY = "PROBLEM_DIFFICULTY"
    PROBLEM_TAGS = "PROBLEM_TAGS"
    PROBLEM_SOURCE = "PROBLEM_SOURCE"
    PROBLEM_YEAR = "PROBLEM_YEAR"


class StatusType(object):
    INVALID = 0
    VALID = 1


class SysDict(models.Model):
    type = models.CharField(verbose_name="类型", max_length=50)
    name = models.CharField(verbose_name="名称", max_length=50)
    status = models.SmallIntegerField(verbose_name="状态", help_text="状态(0无效,1有效)", default=StatusType.VALID)
    create_time = models.DateTimeField(verbose_name="创建时间", auto_now_add=True, null=True)
    update_time = models.DateTimeField(verbose_name="创建时间", auto_now=True, null=True)
    order = models.IntegerField(verbose_name="排序", help_text="数字越大,越靠后", default=1)

    class Meta:
        db_table = "sys_dict"
        unique_together = (("name", "dict_type"),)
        verbose_name = "系统字典表"
        verbose_name_plural = verbose_name

这样抽出来之后在后台界面就可以方便管理这些字典属性了。

由于QDU OJ的赛事逻辑是在题目表里面增加了contest字段来保证题目属于赛事,这里我增加了新的模型试卷模型,通过试卷和题目一对多,赛事和试卷一对一来实现赛事出试卷的功能。
在这里插入图片描述

数据模型

应用 表模型
系统应用 用户表 学生表 教师表
课程应用 课程表 课次表 班级表 赛事表
题目应用 评测题表 选择题表 填空题表 试卷表 试卷题目关联表
操作应用 学生课程关联表 学生班级关联表 老师课次关联表 学生课次关联表 试卷成绩表 题目成绩表 学生赛事关联表

因为评测题目是支持多种语言的判题,所以我觉得可以增对多种语言开设课程,如python 、java、C++等
学生在首页选择对应的课程体系,然后查看课程详情
这里面关于学生选班的问题有两种思路,一种就是课程详情下面有班级列表(学员是否满班),学生自己在选择班级,当然这种的话就需要后台管理员要及时新增新的班级。另一种就是学员只需要自己报名课程,系统会自动分配班级,因为同一个课程下不同班级上的课程内容都是一致的,只不过是班级进度存在不同。这里面也考虑不引入班级概念,学生只需要报名课程,就可以学习课程下的若干课次,这样的话说不定更好。

接口文档

如果是基于QDU OJ单人开发自己用的话,接口文档确实没必要。但是如果是团队开发,接口文档有必要的。而且drf有提供自动生成接口文档的功能。但是如果想实现这种效果,则需要改掉很多代码,目前尝试重构QDU OJ代码来实现自动生成接口文档。

发表评论 取消回复

电子邮件地址不会被公开。

请输入以http或https开头的URL,格式如:https://oneisall.top