跳转至

开发

无论你有多少工程开发上的基础,我们都很欢迎你参与 UOJ 的开发!

开发 UOJ 主要使用的语言有 PHP、C++、SQL,此外你还需要知道一点 HTML、JavaScript、CSS。如果你有些不会的,没关系!只要你会 C++,其他都很好学!

当然,你并不需要读懂所有 UOJ 的代码。如果你只想改一改测评端,那么你只用熟练使用 C++ 就足够了。比如你在搞 OI 的时候,未必知道 printf 或者 cout 的内部实现,也照样可以正常使用,因为你知道他们的使用方式。同样,阅读 UOJ 代码的时候,首先要搞清楚的是每个部分的用途,然后再深入阅读你感兴趣的部分,尝试作出修改。

下面我们从零开始讲讲 UOJ 的基本工作原理。如果你已经有一定工程开发的基础了,可以跳过那些有关基础知识的介绍。

一、基本工作原理

UOJ 主要由两部分组成:网页端测评端。顾名思义,网页端就是用来通过网页与用户交互的,测评端则是在用户发出测评请求时负责测评并将结果发送给网页端的。

1. 网页端

什么是网页端?顾名思义,就是管网页的部分。网页端又分为网页前端网页后端。所谓网页前端,就是你打开浏览器能看到的那部分。不过浏览器是如何知道自己要显示什么东西的呢?这就是因为服务器上还有网页后端,会以 HTML 代码的形式告诉浏览器它要显示些什么。

下面我们来理一下你从输入网址到看到网页的全过程。首先,你输入了 UOJ 的网址,然后浏览器就会根据你的网址去找到对应的服务器,并发一个消息给该服务器。这种消息称为 HTTP 请求(HTTP Request)。当然了,这取决于你的网址是以什么开头的。如果是 https 开头那么就是个加了密的 HTTP 请求,称为 HTTPS 请求。一个 HTTP 请求报文会包含很多很多信息,例如你输入的网址啦,例如你的 IP 啦。

服务器收到 HTTP 请求之后,会根据请求的内容进行相应的处理,并生成一个 HTTP 响应(HTTP Response),然后发回给你的浏览器。HTTP 响应报文也会包括很多信息。比如会包括状态码,正常情况下是 200,但如果对应的网址上没有东西可显示,那么会返回一个喜闻乐见的 404。当然最重要的是 HTTP 响应报文里面会包含一份 HTML 代码,这份代码会指示你的浏览器如何渲染出一个完整的网页。只要你在浏览器里鼠标右键,点击审查元素,就能看到这份 HTML 代码了。实际上,HTML 代码中可能还会内嵌有其他的元素,例如内嵌一些 JavaScript 代码就可以做一些动态的页面效果,内嵌一些 CSS 代码就可以更好的控制网页的排版。

总而言之,想要理解网页端,就得理解两部分:第一,浏览器是如何把 HTML 代码变成你所看到的网页的;第二,服务器是如何根据 HTTP 请求报文生成 HTML 代码的。前者属于网页前端的范畴,而后者属于网页后端的范畴。

2. 测评端

光有网页端是不够的,因为如果用户提交了一份代码,UOJ 就得负责评测,而这往往不是几秒钟内能完成的,所以不能用 HTTP 响应的方式快速返回结果。因此,UOJ 的网页端服务器得把该测评请求发给另一个被我们称之为测评端的服务器,让它测评之后将测评结果发回,再通过网页端告知用户。

UOJ 官网的网页端和测评端服务器是分别部署在两台机器上的,不过为了方便起见,在我们发布的这版 UOJ 里网页端和测评端是放在同一台机器上的。

测评端又分为负责通信的部分和(真正)负责测评的部分。顾名思义,负责通信的代码控制的是和网页端服务器的交互,而负责测评的代码则是给选手交过来的文件打分的,最后负责通信的部分会将测评结果发回网页端。

二、网页端代码简介

下面我们稍微展开讲一讲 UOJ 网页前端和网页后端分别是如何实现的。

1. 网页后端

UOJ 使用了一款开源的网页服务器软件,叫作 Apache。Apache 支持使用 PHP 语言来实现网页后端。进入 UOJ 的 docker,网页后端的代码就位于 /opt/uoj/web/ 目录下。Apache 收到一个 HTTP 请求之后,会运行 index.php(这一行为其实是由同目录下的 .htaccess 指定的)。index.php 会加载所需的函数库和类库,然后根据路由文件(route file) 去给请求中的网址匹配用于生成响应报文的 PHP 代码。所谓路由文件其实也是 PHP 代码,只不过这种文件记录了一个从网址到代码文件路径的映射。控制主站路由的是 app/route.php,控制博客路由的是 app/controllers/subdomain/blog/route.php

我们称路由文件中被映射到的代码为控制器(controller),位于 app/controllers 目录下。index.php 给请求中的网址匹配到对应的控制器后,控制器会根据用户的 HTTP 请求报文的具体内容生成一个 HTTP 响应报文,交由 Apache 负责发回给作出 HTTP 请求的浏览器。

早期 UOJ 的网页后端是我们一点一点自己写的,架构上没有参考任何的现有开源项目。后来我们发现有些架构的设计思路理不太清楚,于是去学习了一下著名的 Laravel 框架。所以代码架构实则是介于早期 UOJ 架构和 Laravel 之间的一种架构,没有像早期架构那么乱,也没用像专业的 Laravel 那样包装得很深,希望这样能兼顾代码的可维护性和可阅读性吧。

2. 数据库

数据库是网页后端的一部分,但又是较为独立的一部分。通常我们在 OI 中会使用数组来存储数据,但一旦你把程序掐了,或者电脑关机了,数组里的内容就丢失了。这是因为数组通常是存储在内存里的,而非机械硬盘、固态硬盘等可持久存储的介质上。那么如何把数据保存在硬盘上呢?你可能已经开始摩拳擦掌准备写个 B 树了,但是且慢 —— 如今有很多数据库软件已经帮你写好了。

UOJ 使用的是 MySQL 数据库。在 UOJ 的后端 PHP 代码里,你可以通过访问 DB 这个静态类来跟数据库进行交互。交互使用的是 SQL 语言,一种专门为数据库中数据的查询、修改、插入、删除而设计的语言,你可能需要稍微学习一番这种特殊的语言。DB 是对 SQL 的一种简单包装,请参照现有代码调用 DB 的方式来更好的理解其用法。

3. 网页前端

大部分的网页前端代码实际上是由 PHP 一行一行输出出来的。负责这种输出的代码一部分位于各个控制器代码中,一部分 app/views 目录下。理想情况下,如果一段 PHP 代码能生成一长段 HTML 代码,且生成的过程不包含任何控制逻辑或者数据库查询,那么该 PHP 代码就应该放在 app/views 里作为一种视图(view);否则就应该放在 app/controllers 下作为一种控制器。例如,每个页面都会包含的页头和页尾就写在了 app/views/page-header.phpapp/views/page-footer.php 这两个视图文件里。当然了,严格遵守这样的规则会让一些简单的代码变得结构很复杂,所以目前的 UOJ 代码并没有严格遵守这一点。

其实,并非所有前端代码都是 PHP 一行行输出出来的。HTML 代码中通常会内嵌一些 CSS 和 JavaScript 来控制页面排版和制造一些动态页面效果,而这些部分往往是跟页面具体内容独立的。所以有很多优秀的前端框架,例如 Bootstrap。UOJ 目前使用的是 Bootstrap 3。所以,要想完全理解前端的代码,你不仅需要阅读对应的控制器和视图的内容,还要学习一下 Bootstrap 的使用方式。如果你尚未接触过前端开发,不妨先找点前端开发教程,试着做一点不需要 PHP 的静态小网站,然后再回过头来阅读 UOJ 的代码。

4. 重要文件目录

网页端的代码和相关文件主要在 /opt/uoj/web/ 目录下。下面列出了一些重要的文件和子目录:

  • app/:UOJ 主要的后端 PHP 代码目录
    • controllers/:存放控制器文件的目录
    • locale/:存放页面上的文字在不同语言下的翻译的目录
    • models/:UOJ 运行所需的一些 PHP 类
    • storage/:存储一些文件数据的目录
      • submissions/:存放用户提交的测评请求中附带的文件的目录
      • tmp/:存放临时文件的目录
    • views/:存放视图文件的目录
    • route.php:主站路由文件
    • uoj-*-lib.php:一些 UOJ 运行所需的函数库文件
  • vendor/:一些 UOJ 使用的第三方 PHP 代码库,由 Composer 管理。
  • public/:一些可以不经过 PHP 直接访问的资源
    • css/:存放部分 CSS 代码文件的目录
    • fonts/:存放字体文件的目录
    • js/:存放部分 JavaScript 代码文件的目录
    • libs/:一些 UOJ 使用的前端库,例如 Bootstrap,jQuery 等等
    • pictures/:存放部分图片文件的目录

三、测评端代码简介

下面我们稍微展开讲一讲 UOJ 测评端的通信部分和测评部分是如何实现的。

1. 通信部分

测评端使用的是轮询机制(polling)与网页端通信的:每隔一段时间(默认是 2s),测评端会给网页端发送一个 HTTP 请求,询问是否有新的测评请求;如果有的话网页端会返回相应的信息,否则返回为空。

具体负责通信部分的代码由 Python 编写。进入 UOJ 的 docker,测评端所有代码就位于 /opt/uoj/judger/judge_client/1 目录下,其中 judge_client 这一没有后缀名的可执行文件就是负责通信部分的 Python 脚本。启动 UOJ 的 docker 容器后,该脚本就会自动运行,对网页端进行轮询。

网页端响应轮询的代码在 app/controllers/judge/submit.php 这个文件里。主要做的事情是依次扫描各种类型的提交记录(包括 Hack、包括自定义测试等等),如果发现有待测评的,就以 JSON 格式返回相应的信息。

测评请求通常会包含一些附加的文件,如选手提交的代码文件。网页端返回的 JSON 里并不会包含这些文件本身,而是给了一个下载链接。测评端收到 JSON 之后会根据指定的下载链接去下载这些附加文件。

最后,测评端会启动实际负责测评的程序 uoj_judger/main_judger,在测评完成后再跟网页端发送一个 HTTP 请求,提交测评结果。

2. 测评部分

测评部分的启动程序是主测评器(main judger),位置在 uoj_judger/main_judger。给定一个测评请求,主测评器会进行一系列运算,最后给出一个评分。其中,输入测评请求与输出评分均是通过文件输入输出实现的。

具体来说,运行主测评器前需要先切换到一个空的工作目录(具体实现中我们选择了 /tmp/ 下的临时文件夹,例如 /tmp/local_main_judger/0),我们暂且称之为主测评目录。然后,在主测评目录下建立子文件夹 work/result/,我们分别称之为测评工作目录测评结果目录。最后,将待测评请求的相关文件放在测评工作目录下,其中包括元信息文件 submission.conf、选手提交的代码文件等等。当主测评器启动时,它会从测评工作目录下读取这些文件,最后将评分输出到测评结果目录下的 result.txt 中。

实际上,主测评器的主要任务是启动题目所指定的测评器(judger)。元信息文件 submission.conf 会包含题号、测评类型(是不是 Hack?是不是自定义测试?)、选手代码语言等等。主测评器会在 uoj_judger/data/ 下找到与元信息文件中记录的题号对应的题目数据文件夹,读取该题的数据配置文件 problem.conf。根据数据配置文件的内容,主测评器要么会运行默认的内置测评器 uoj_judger/builtin/judger/judger,要么会运行该题指定的自定义测评器。

关于测评器的具体功能,可以参见题目上传教程。测评器在运行结束后应把评分输出到测评结果目录下的 result.txt 中,如果没有,那么主测评器将会代为输出一份,并标记为 Judgment Failed(测评失败)。

3. 沙箱

测评器运行时,不可避免地会需要涉及到运行选手程序的操作。但直接运行是十分危险的,因为选手有可能会提交恶意代码。

所以,UOJ 自己实现了一个基于 seccomp 和 ptrace 技术的沙箱,能够运行选手程序并在该程序做出可疑行为时果断杀死。沙箱位置在 uoj_judger/run/run_program。每当测评器想要运行选手程序时,都会交由沙箱代为执行。

如果你想研究沙箱的实现方式,可以先了解 seccomp 和 ptrace 技术,然后再来仔细阅读代码。如果你只是想写一个自定义的测评器,只要搞清楚沙箱的输入输出方式即可。你可以不加参数地运行该沙箱来查看一个关于命令行参数的简单文档。同时,我们在头文件 uoj_judger/include/uoj_judger.h 中也将其封装为了一个 C++ 函数,可以直接调用。

4. 重要文件目录

测评端的代码和相关文件主要在 /opt/uoj/judger/judge_client/1 目录下。下面列出了一些重要的文件和子目录:

  • judge_client:控制通信部分的 Python 脚本

  • Makefile:用于生成测评端所有可执行文件的 Makefile,主要功能是直接调用子目录下的 Makefile

  • log/:存放测评端日志的目录

  • uoj_judger/:测评部分的主目录

    • main_judger:主测评器(main judger)
    • builtin/:存放内置的答案检查器(checker)和测评器(judger)的目录
    • include/:测评相关的头文件
    • Makefile:用于生成测评部分的可执行文件的 Makefile
    • data/:一个链接到存放所有题目数据的文件夹的符号链接
    • run/
      • run_program:用于安全地运行程序的沙箱
      • run_interaction:一个用于辅助测评通信题的程序,运行多个程序并通过管道连接不同程序的输入输出
      • compile:根据给定的程序语言,通过调用其他编译器进行编译的“总”编译器
      • formatter:用于去掉行末空格、文末回车的程序

四、如何修改代码

听说你想写代码?不错不错。

对于网页端代码,你可以直接修改 /opt/uoj/web/ 下的代码。PHP 代码不需要编译,所以如果是修改 PHP 代码的话,改完了就立马能看到效果了。

如果是测评端,那么稍微复杂一点。如果你只有一个测评端(默认情况也只有一个),那么建议你先通过 su local_main_judger 切换到 local_main_judger 用户,然后再修改修改 /opt/uoj/judger/judge_client/1 目录下的代码。修改完成后在该目录下运行 make 命令编译生成所有可执行文件,最后重新启动测评端。

如果你配置了多个测评端,那么你需要通过 SVN 来同时更新所有测评端代码。首先你需要给自己加上操作测评端 SVN 仓库的权限。在 /var/svn/judge_client/conf/passwd 这个文件中加一行:

uoj = 666666

来增加一位名为 "uoj",密码为 "666666" 的 svn 仓库管理员。

然后你可以自己 checkout 一个工作副本,修改代码,最后 commit。commit 时 UOJ 会分发到所有测评端,并自动编译。

在本地写完代码之后,如果你想与别人分享,你可以把修改后的代码复制到你的 git 的工作副本里,再 push 到 github。

五、更新工具:Upgrader

虽然这个功能没什么人用,但文档还是要写写的。

如果你想更新的东西已经不局限于网站代码,还想对数据库、文件系统之类的折腾一番,请在 app/upgrade 目录下建立文件夹,举个例子,叫 2333_create_table_qaq。即,以一串数字开头,后面加一个下划线,再后面随便取名字吧,仅能包含数字字母和下划线。

在这个文件夹下你可以放一些小脚本。大概 UOJ 运行这些小脚本是这么个逻辑:

<?php
if (is_file("{$dir}/upgrade.php")) {
    $fun = include "{$dir}/upgrade.php";
    $fun("up");
}
if (is_file("{$dir}/up.sql")) {
    runSQL("{$dir}/up.sql");
}
if (is_file("{$dir}/upgrade.sh")) {
    runShell("/bin/bash {$dir}/upgrade.sh up");
}
你只需要在 /var/www/uoj/app 下执行 php cli.php upgrade:up 2333_create_table_qaq 就可以运行了,这个运行过程我们称为 up

请务必再写一下还原的小脚本,我们称为 down。写完代码后你需要保证 updown 能回到原来的系统。与 up 类似,你需要执行 php cli.php upgrade:down 2333_create_table_qaq,执行 down 的逻辑如下:

<?php
if (is_file("{$dir}/upgrade.php")) {
    $fun = include "{$dir}/upgrade.php";
    $fun("down");
}
if (is_file("{$dir}/down.sql")) {
    runSQL("{$dir}/down.sql");
}
if (is_file("{$dir}/upgrade.sh")) {
    runShell("/bin/bash {$dir}/upgrade.sh down");
}

在数据库中,UOJ 会记录自己已经加载了哪些 upgrade,当你执行 php cli.php upgrade:latest 的时候,UOJ 会把所有已经在 app/upgrade 文件夹下但还没有加载的 upgrade 都给 up 一下,并且是按前缀上的那一串数字从小到大执行。所以写好了这种小 upgrade 之后,你就可以跟别人分享了!