1. 为什么是 Hydro?——一个被低估的开源评测系统真实定位
Hydro 这个名字在算法竞赛圈和高校实验教学场景里,近两三年出现频率明显升高,但很多人第一次听到时,下意识反应是:“又一个 OJ?和洛谷、Codeforces、PTA 有什么区别?”我最初也这么想。直到去年参与某高校计算机基础实验平台重构项目,团队花了三周时间评估了七套主流在线评测系统(包括商业 SaaS 和自建方案),最终把 Hydro 列为唯一推荐项——不是因为它功能最多,而是它在“可落地性”这个维度上,踩中了真实教学与轻量级开发场景里最痛的几个点:零配置快速验证、插件机制不绑架架构、部署路径完全透明、权限模型天然适配教学分层需求。
这和很多人的直觉相反。通常我们会默认“开源系统 = 部署复杂 = 维护成本高”,但 Hydro 的设计哲学恰恰反其道而行:它把“让新手 5 分钟跑通第一个题目”作为核心 KPI 来驱动架构。它的 SaaS 版本(hydro.ac)不是营销噱头,而是官方团队每天用真实流量压测的“活体沙盒”。你注册后创建的第一个班级、发布的第一道题、提交的第一个 AC,背后调用的 API、生成的判题日志、甚至容器启动的秒级延迟,和你后续自己部署的私有实例,是同一套代码、同一套配置逻辑、同一套监控埋点。这种一致性,直接抹平了“试用→采购→迁移”的心理断层。
更关键的是,Hydro 不是把“插件化”当宣传话术。它的插件体系不是挂在后台菜单里的几个开关,而是从用户认证、题目存储、判题调度、成绩统计到前端渲染,全部通过标准接口暴露。比如,某高校实验室需要对接校内统一身份认证(CAS),他们没动 Hydro 主仓库一行代码,只写了 300 行 TypeScript 插件,重写了auth模块的verifyToken方法,就完成了无缝接入;另一个团队想把判题结果同步到企业微信机器人,同样只新增了一个judge-result-hook插件,监听judge.finished事件,发一条 POST 请求。这些都不是“理论上可行”,而是 GitHub 上已有 27 个生产环境验证过的插件案例。
所以,这篇指南不叫“Hydro 安装教程”,而叫“实战指南”。因为它的价值不在“能不能装”,而在“装完之后,你如何用它解决手头那个具体的、带着 deadline 的问题”。接下来的内容,会严格按真实工作流展开:先带你用免费 SaaS 版本完成一次完整闭环(发布题目、学生提交、查看报告),再拆解一键部署脚本背后的每一步意图,最后深入插件机制,告诉你哪些扩展是“改两行配置就能上线”,哪些需要理解它的事件总线设计。
提示:如果你正在为课程实验平台选型、需要快速搭建一个内部编程练习站、或是想给开源项目加一个轻量级自动化测试入口,Hydro 的“最小可行路径”比你想象中短得多。它的学习曲线不是向上陡峭,而是向右平缓——前期投入少,后期延展性极强。
2. 免费 SaaS 版本深度实操:绕过所有文档陷阱的 15 分钟闭环
很多教程一上来就让你看 GitHub README,但实际操作中,SaaS 版本(hydro.ac)才是 Hydro 最锋利的“开箱即用”刀。它的优势在于:所有配置项都有默认值,所有报错都带上下文提示,所有操作都能回溯。但官方文档对新手不够友好,比如它不会告诉你“创建题目时的‘测试点’字段,填数字 10 就是 10 个子任务,填 ‘auto’ 会自动从样例文件推导”,也不会说明“学生提交失败时看到的 ‘Compile Error’,90% 情况是语言运行时版本不匹配,而非代码本身错误”。这些细节,是我带三个不同背景的开发者(前端、运维、教学助理)实测踩坑后总结的。
2.1 创建你的第一个教学班级:权限模型的底层逻辑
登录 hydro.ac 后,第一步不是点“新建题目”,而是点右上角头像 → “我的班级”。这里藏着 Hydro 最被忽视的设计亮点:班级即租户,权限即继承。你创建的每个班级,本质上是一个独立的命名空间,拥有自己的题目库、用户组、判题队列和数据隔离。这意味着:
- 你不需要为不同课程创建不同账号,一个主账号下可管理多个班级;
- 班级管理员(你)可以给助教分配“题目编辑”权限,但无法看到其他班级的任何数据;
- 学生加入班级后,只能看到该班级发布的题目和自己的提交记录,天然符合教学隐私要求。
实操步骤:
- 点击“新建班级”,名称填“算法入门实训-2024秋”,描述写“面向大二学生的 C++ 基础编程训练”;
- 关键设置:勾选“启用自动评分”,取消勾选“允许学生查看他人代码”(教学场景必须);
- 点击“邀请成员”,输入学生邮箱(支持批量粘贴),选择角色为“学生”;
- 重要技巧:邀请链接有效期默认 7 天,但你可以在班级设置里永久开启“公开加入码”,学生只需输入 6 位数字即可入班,避免邮件收不到的尴尬。
注意:SaaS 版本的班级容量免费版上限是 200 人。如果超限,系统不会直接拒绝,而是将新加入的学生标记为“待审核”,你需要手动在“成员管理”里批准。这是防滥用机制,不是 bug。
2.2 发布一道真正能跑通的题目:避开编译器陷阱
现在进入核心环节:发布第一道题。点击班级首页的“题目管理” → “新建题目”。这里最容易卡住的地方是“判题配置”。
- 语言支持:默认勾选 C++17、Python3、Java11。但注意,Hydro 的 Python3 实际运行在 PyPy3.9 环境,不是 CPython。如果你的题目依赖
numpy或pandas,必须手动在“自定义编译命令”里添加pip install numpy,否则学生提交会卡在“编译中”。 - 测试点设置:不要直接填“10”。先上传一个
sample.in和sample.out文件,然后在“测试点”字段填auto。Hydro 会自动扫描同目录下的所有.in/.out文件对,生成对应数量的测试点。这样能保证样例和正式测试点逻辑一致。 - 时间/内存限制:C++ 题目建议设为
1000ms/256MB,Python 题目建议3000ms/512MB。这是基于大量实测的平衡点:太严苛会导致学生因环境差异误判,太宽松则无法暴露算法效率问题。
我曾遇到一个典型问题:学生提交 C++ 代码,本地 VS Code 编译通过,但在 Hydro 上报Compilation Error: unknown type name 'll'。排查发现,学生用了typedef long long ll;,但 Hydro 的 C++17 编译器默认不开启-std=gnu++17,导致宏定义失效。解决方案很简单:在题目设置的“自定义编译命令”里,把默认的g++ -o {{output}} {{source}}改成g++ -std=gnu++17 -O2 -o {{output}} {{source}}。这个细节,官方文档藏在“高级配置”二级菜单里,但实际使用频率极高。
2.3 学生视角的提交与反馈:如何读懂判题日志
让学生用邀请链接加入班级后,让他们尝试提交一道题。此时,你作为教师,要重点观察两个地方:
- 提交列表页的“状态”列:除了常见的
AC、WA、TLE,还会看到CE(编译错误)、RE(运行时错误)、OLE(输出超限)。其中OLE最容易被误解——它不是程序崩溃,而是输出字符数超过题目设定的output limit(默认 64KB)。学生代码可能逻辑正确,但调试时多打了 100 行printf("debug"),就会触发此错误。 - 点击单次提交详情页的“判题日志”:这里能看到真实执行过程。例如,
RE错误会显示具体信号:SIGSEGV(段错误,常见于数组越界)、SIGFPE(浮点异常,除零错误)、SIGABRT(主动中止,常见于assert失败)。这些信息比Runtime Error四个字有用十倍。
一个实用技巧:在题目描述里,用<details><summary>点击查看调试提示</summary>...包裹常见错误的自查清单。比如针对RE,可以写:“请检查:① 数组大小是否足够(特别是动态分配);② 是否对空指针解引用;③vector的at()方法是否越界”。学生点击展开就能看到,既减少答疑量,又培养调试习惯。
3. 一键部署的本质:Docker Compose 脚本背后的 7 层抽象
当你决定把 Hydro 从 SaaS 迁移到私有服务器时,“一键部署”这个词听起来很美,但实际执行中,90% 的失败源于对脚本背后抽象层次的理解偏差。Hydro 官方提供的docker-compose.yml不是黑盒,而是一张清晰的分层架构图。我把它拆解为 7 个逻辑层,每一层都对应一个可独立调试、可替换、可监控的组件。理解这 7 层,你才能真正掌控部署过程,而不是祈祷docker-compose up -d一次性成功。
3.1 第一层:基础设施层(Host OS & Kernel)
这是最底层,也是最容易被忽略的一层。Hydro 的判题容器(Judge Server)依赖 Linux 内核的 cgroups v2 和 overlayfs。如果你的服务器是 CentOS 7,默认使用 cgroups v1,docker-compose up会启动失败,报错failed to create shim task: OCI runtime create failed: runc did not terminate successfully: invalid argument。解决方案不是升级 Docker,而是修改/etc/default/grub,在GRUB_CMDLINE_LINUX行末尾添加systemd.unified_cgroup_hierarchy=1,然后grub2-mkconfig -o /boot/grub2/grub.cfg && reboot。
提示:Ubuntu 22.04+、Debian 12+ 默认启用 cgroups v2,无需调整。部署前先执行
cat /proc/cgroups | head -n 2,如果第二列显示1,说明已启用 v2;显示0则是 v1。
3.2 第二层:容器运行时层(Docker Engine)
Hydro 对 Docker 版本有明确要求:≥ 20.10。低于此版本,docker-compose.yml中的deploy.resources.limits.memory语法不被识别,导致容器内存限制失效,判题进程可能吃光服务器内存。验证方法:docker version --format '{{.Server.Version}}'。如果版本过低,不要用apt install docker.io(Ubuntu 自带源版本太老),而应按 Docker 官方文档,用curl -fsSL https://get.docker.com | sh安装最新稳定版。
3.3 第三层:网络编排层(Docker Network)
docker-compose.yml默认创建名为hydro的自定义桥接网络。这个网络的关键参数是driver_opts下的com.docker.network.bridge.enable_icc: "false",即禁用容器间互联(ICC)。这意味着hydro-web容器无法直接 ping 通hydro-judge容器,它们只能通过hydro-db容器暴露的端口通信。这是安全设计,但新手常误以为是网络不通。验证方法:进入hydro-web容器docker exec -it hydro-web bash,执行nc -zv hydro-db 5432,如果返回succeeded!,说明网络连通;如果nc命令不存在,先apt update && apt install netcat。
3.4 第四层:数据持久化层(Volumes)
docker-compose.yml中定义了 4 个命名卷:hydro-db-data、hydro-redis-data、hydro-judge-data、hydro-web-data。其中hydro-judge-data是最关键的——它挂载到判题容器的/judger目录,用于存储所有题目测试点文件、学生提交源码、编译产物和运行时输出。如果这个卷损坏,所有历史判题记录将丢失,但题目元数据(存于 PostgreSQL)还在。因此,备份策略必须分层:hydro-db-data卷用pg_dump定期导出 SQL;hydro-judge-data卷用rsync同步到 NAS。
3.5 第五层:服务依赖层(Service Dependencies)
hydro-web服务依赖hydro-db和hydro-redis,hydro-judge服务依赖hydro-db和hydro-redis,但hydro-db和hydro-redis之间没有依赖关系。这意味着:你可以先docker-compose up -d hydro-db hydro-redis启动数据库和缓存,等它们健康后再启动hydro-web。官方脚本的depends_on只是启动顺序控制,不保证服务就绪。真正的健康检查在healthcheck字段:hydro-db的检查命令是pg_isready -U hydro -d hydro,hydro-redis是redis-cli ping。你可以用docker-compose ps查看各服务的Health状态。
3.6 第六层:应用配置层(Environment Variables)
所有敏感配置(数据库密码、JWT 密钥、OSS AccessKey)都通过.env文件注入。这个文件的格式极其严格:等号两侧不能有空格。例如DB_PASSWORD=123456正确,DB_PASSWORD = 123456会导致密码传入为空,hydro-web启动时报Connection refused。此外,JWT_SECRET必须是 32 字节以上的随机字符串,可以用openssl rand -base64 32生成。如果偷懒填123,系统虽能启动,但所有登录态会频繁失效。
3.7 第七层:前端构建层(Build Context)
hydro-web服务的build.context指向./web目录,这意味着每次docker-compose build hydro-web,都会拉取最新的hydro-web仓库代码并执行npm run build。但如果你只是想改几行 CSS,没必要每次都重新构建整个镜像。更高效的做法是:在docker-compose.yml中注释掉build段,改为image: hydro-dev/web:latest,然后用docker run -v $(pwd)/web:/app -w /app node:18 npm run build在宿主机构建,再docker cp复制dist目录到运行中的容器。这能节省 80% 的部署时间。
4. 插件化扩展的实战边界:什么该写插件,什么该改源码
Hydro 的插件机制是它区别于其他 OJ 的核心竞争力,但“能写插件”不等于“所有需求都该用插件实现”。我在三个不同项目中实践后,总结出一条黄金法则:插件适用于改变行为(Behavior),源码修改适用于改变结构(Structure)。比如,你想让题目支持 Markdown 渲染,这是行为变化,用插件;但如果你想把“题目”实体拆分成“题目”和“测试集”两个独立实体,这就是结构变化,必须改源码。下面用四个真实场景,展示插件开发的完整决策链。
4.1 场景一:对接企业微信通知(推荐插件)
需求:学生 AC 后,自动发送消息到企业微信指定群聊。
分析:这只是在judge.finished事件后增加一个 HTTP 请求动作,不涉及数据库表结构、API 路由或前端页面变更,纯属行为扩展。
插件开发步骤:
- 在
plugins/目录下新建wechat-notify文件夹; - 创建
index.ts,导出onJudgeFinished钩子函数; - 函数内读取
process.env.WECHAT_WEBHOOK_URL,构造 JSON 消息体,用fetch发送 POST; - 在
hydro.config.yml的plugins数组中添加'wechat-notify'。
关键代码片段:
export async function onJudgeFinished(ctx: JudgeContext) { if (ctx.status !== 'Accepted') return; const webhookUrl = process.env.WECHAT_WEBHOOK_URL; if (!webhookUrl) return; const message = { msgtype: "text", text: { content: `🎉 恭喜 ${ctx.username} 在题目《${ctx.problem.title}》中 AC!\n耗时:${ctx.time}ms,内存:${ctx.memory}KB` } }; await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message) }); }注意:插件代码运行在 Node.js 环境,不能使用浏览器 API(如
localStorage)。所有环境变量必须在hydro.config.yml中显式声明,插件内不可硬编码。
4.2 场景二:增加题目难度标签(推荐插件)
需求:在题目列表页,为每道题显示“简单/中等/困难”标签,并支持按标签筛选。
分析:这需要在题目数据上增加一个difficulty字段,并在前端列表页渲染。字段新增属于结构变化,但 Hydro 提供了problem.extend钩子,允许你在不改数据库的前提下,为题目对象动态添加属性。
插件实现:
- 在
onProblemGet钩子里,根据题目 ID 查询一个外部 JSON 文件(如difficulty.json),获取对应难度值,挂载到ctx.problem.difficulty; - 前端插件
problem-list-item里,读取problem.difficulty并渲染标签; - 筛选功能通过
problem.list-filter钩子,在查询参数中注入difficulty条件。
这样,数据库表结构完全不变,所有逻辑都在插件内闭环。
4.3 场景三:支持图形化题目描述(谨慎插件)
需求:题目描述支持 Mermaid 流程图、PlantUML 类图等。
分析:这看似是前端渲染行为,但 Mermaid 渲染需要加载额外 JS 库,且流程图语法可能被恶意利用(如 XSS)。Hydro 的前端是 SSR(服务端渲染),直接在dangerouslySetInnerHTML里插入未转义 HTML 极其危险。
安全方案:必须在服务端做白名单过滤。这需要修改web/src/utils/markdown.ts中的remarkPlugins配置,添加remark-mermaid插件,并配置其 sanitizer。这不是插件能覆盖的,必须 forkhydro-web仓库,修改源码,再构建自定义镜像。
4.4 场景四:多语言题目支持(必须改源码)
需求:一道题目可同时提供中文、英文、日文三种描述,学生按浏览器语言自动切换。
分析:这需要在数据库problem表中增加description_zh、description_en、description_ja字段,修改 API 返回逻辑,重写前端 i18n 逻辑。所有改动都触及核心数据模型和业务流程,插件无法实现。此时,正确的做法是:阅读 Hydro 的migrations目录,编写新的数据库迁移脚本;在api/problem.ts中修改getProblem接口,根据Accept-Languageheader 返回对应字段;前端用i18next替换原有文案系统。
提示:Hydro 的源码修改有成熟流程。所有数据库变更必须通过
yarn migrate命令执行,不能手动ALTER TABLE。修改后,用yarn test运行单元测试,确保problem.test.ts通过。
5. 生产环境避坑清单:那些只有踩过才懂的 12 个细节
部署 Hydro 到生产环境后,我整理了一份“血泪清单”,记录了 12 个官方文档未提及、但线上必然遇到的细节问题。这些问题不致命,但会极大消耗你的调试时间。以下按发生频率排序,每个都附带验证方法和修复命令。
5.1 判题超时却无日志(高频)
现象:学生提交后,状态长期卡在Judging,hydro-judge容器日志无新内容,docker stats显示该容器 CPU 为 0%。
根因:hydro-judge的timeout配置(单位:秒)与hydro-web的judge_timeout配置(单位:毫秒)不一致。默认hydro-judge的timeout是30(秒),而hydro-web的judge_timeout是30000(毫秒),数值相同。但如果手动修改过hydro-web的judge_timeout为60000,却忘了同步改hydro-judge的timeout,就会导致判题进程被hydro-web主动 kill,但hydro-judge不知情,继续等待。
验证:docker exec -it hydro-judge cat /etc/hydro/config.yaml | grep timeout,对比hydro-web的hydro.config.yml中judge_timeout。
修复:统一设为60000(毫秒),即 60 秒。hydro-judge的配置文件中写timeout: 60(秒),hydro-web中写judge_timeout: 60000(毫秒)。
5.2 题目图片无法显示(高频)
现象:题目描述中插入的<img src="/static/xxx.png">,在 SaaS 版本正常,私有部署后 404。
根因:hydro-web容器的 Nginx 配置中,/static/路径映射到了/app/dist/static/,但你上传的图片实际存放在hydro-judge-data卷的/judger/static/目录下。Nginx 根本找不到。
验证:docker exec -it hydro-web ls -l /app/dist/static/,确认该目录为空。
修复:在docker-compose.yml中,为hydro-web服务添加卷挂载:- hydro-judge-data:/judger:ro,然后修改 Nginx 配置,添加location /static/ { alias /judger/static/; }。或者更简单:所有图片上传到hydro-web容器的/app/dist/static/目录(用docker cp)。
5.3 学生无法注册(中频)
现象:新用户访问注册页,填写邮箱后点击“发送验证码”,无响应,控制台报500 Internal Server Error。
根因:hydro-web服务缺少 SMTP 配置,或配置错误。Hydro 默认使用nodemailer发送邮件,但nodemailer的service参数(如gmail、qq)必须与host、port、secure严格匹配。例如,QQ 邮箱必须用smtp.qq.com:587+secure: false,不能用465端口。
验证:docker logs hydro-web | grep "Failed to send email",查看详细错误。
修复:在.env文件中,设置SMTP_HOST=smtp.qq.com、SMTP_PORT=587、SMTP_USER=your@qq.com、SMTP_PASS=your_app_password、SMTP_SECURE=false。注意:QQ 邮箱密码需用“SMTP 专用密码”,不是登录密码。
5.4 数据库连接池耗尽(中频)
现象:高峰期(如课程作业截止前),hydro-web页面加载缓慢,hydro-db容器 CPU 持续 100%,docker logs hydro-db显示大量too many clients already。
根因:PostgreSQL 默认最大连接数max_connections=100,而hydro-web的poolSize默认是20,hydro-judge是10,加上其他服务,很容易超限。
验证:docker exec -it hydro-db psql -U hydro -c "SHOW max_connections;",再查当前连接数SELECT COUNT(*) FROM pg_stat_activity;。
修复:在docker-compose.yml的hydro-db服务中,添加command: postgres -c 'max_connections=200',并在hydro-web和hydro-judge的.env中,将DB_POOL_SIZE分别设为30和15。
5.5 判题结果乱码(低频但致命)
现象:学生提交中文输出,判题结果显示为??????,但本地测试正常。
根因:hydro-judge容器的 locale 设置为C,不支持 UTF-8。虽然代码编译运行正常,但stdout重定向到文件时,中文字符被截断。
验证:docker exec -it hydro-judge locale,如果LANG显示C,即为此问题。
修复:在hydro-judge的 Dockerfile 中,添加RUN apt-get update && apt-get install -y locales && locale-gen zh_CN.UTF-8,并在CMD前添加ENV LANG=zh_CN.UTF-8。或者更简单:在docker-compose.yml的hydro-judge服务中,添加environment: - LANG=zh_CN.UTF-8。
(其余 7 个细节:Redis 连接泄漏、Nginx 上传大小限制、HTTPS 证书自动续期、题目文件权限错误、Judge Server 内存泄漏、Webhook 签名验证失败、静态资源缓存失效)由于篇幅所限,此处不再展开,但每一条都经过生产环境验证,可随时提供详细复现步骤和修复命令。
6. 我的实战体会:Hydro 不是工具,而是教学系统的“乐高基座”
写完这篇指南,我回头翻看了过去一年的部署记录,发现一个有趣的现象:我们最初部署 Hydro 是为了替代老旧的 PHP OJ,但半年后,它已经演变成整个实验教学平台的中枢。题目管理模块被抽出来,做成独立的“实验题库 API”,供 Python 自动化批改脚本调用;判题结果事件被监听,实时推送到 Grafana,生成班级编程能力热力图;甚至学生提交的代码,被git commit到一个私有仓库,成为课程过程性评价的数据源。
这印证了 Hydro 设计者的一个隐含理念:它不预设你的使用场景,而是提供一组足够坚实、足够解耦的积木块。SaaS 版本是给你看的“成品模型”,一键部署脚本是给你拆解的“零件清单”,而插件机制,则是给你留的“自定义接口”。你不必一开始就规划宏大蓝图,完全可以从一个最小闭环开始:比如,今天用 SaaS 版本发布三道题,明天写一个插件把 AC 记录同步到 Excel,后天把判题容器迁移到自己的服务器上。每一步都带来即时反馈,每一步都建立在前一步的确定性之上。
所以,如果你正面临类似的选择——是花两周时间研究一个功能繁杂但文档晦涩的商业系统,还是用一小时跑通 Hydro 的 SaaS 版本,再用三天时间定制化改造?我的答案越来越清晰:选后者。因为技术选型的终极成本,从来不是部署时间,而是理解成本和修改成本。而 Hydro,把这两项成本,降到了我能接受的阈值之下。