OJ平台远端判题子系统开发(四):多语言支持与HTTP API完善
本周的工作围绕两个重点:将沙箱从单一C++支持扩展为C++17、Go 1.22、Python 3.11三语言判题,以及实现完整的HTTP API接口。作为ACM参赛者,对OJ系统的接口设计和判题状态模型有直接的使用经验,这些经验在本周的设计中提供了明确的参考。
一、多语言配置化设计
1.1 设计思路
扩展多语言时面临的首要问题是:每种语言的编译命令、运行方式、文件后缀各不相同,如何组织这些差异?
直接硬编码的思路——为每种语言单独写一套判题逻辑——扩展时需修改核心代码,且判题流程中的共性部分(准备临时目录、超时控制、结果比对)会造成大量重复。
最终采用配置化方案,每种语言定义为一份静态配置:
var SupportedLanguages = map[string]LanguageSpec{
"cpp17": {
ID: "cpp17",
SourceFile: "main.cpp",
Compiled: true,
CompileCmd: []string{"sh", "-lc", "g++ -O2 -std=c++17 -o main main.cpp"},
RunCmd: []string{"./main"},
DockerImage: "remote-judge-cpp17",
},
"go1.22": {
ID: "go1.22",
SourceFile: "main.go",
Compiled: true,
CompileCmd: []string{"sh", "-lc",
"GOCACHE=/tmp/go-build HOME=/tmp GOMAXPROCS=1 go build -p=1 -o main main.go"},
RunCmd: []string{"./main"},
DockerImage: "remote-judge-go122",
},
"python3.11": {
ID: "python3.11",
SourceFile: "main.py",
Compiled: false,
CompileCmd: nil,
RunCmd: []string{"python3", "main.py"},
DockerImage: "remote-judge-python311",
},
}
关键字段:
Compiled: true/false:区分编译型和解释型语言,判题流程据此决定是否执行编译步骤CompileCmd和RunCmd:编译和运行的完整shell命令DockerImage:每种语言映射到不同的判题镜像
新增语言只需添加一份配置,判题核心逻辑无需改动。
1.2 Go编译的问题与解决
问题描述:在Alpine容器中执行Go编译命令 go build -o main main.go 时失败,报错:
failed to initialize build cache at /root/.cache/go-build: mkdir ... permission denied
原因分析:沙箱容器以 runner 用户(uid=1000)运行,没有 /root 目录的写权限。Go编译器默认将构建缓存写入当前用户的 $HOME/.cache/go-build,而 /root 作为root用户的HOME目录,runner用户无权访问。
解决方案:在编译命令中指定 GOCACHE=/tmp/go-build,将构建缓存重定向到可写的tmp目录。同时添加 GOMAXPROCS=1 和 -p=1 限制编译并发度,防止Go编译器在判题场景中占用过多CPU资源。
1.3 测试验证
针对三语言配置进行了单元测试验证:
| 测试用例 | 验证内容 | 结果 |
|---|---|---|
| TestJudgeAccepted (C++) | Mock沙箱下C++ AC流程走通 | PASS |
| TestJudgeWrongAnswer (Python) | Python WA判定正确 | PASS |
| TestJudgeCompileError (C++) | CE判定正确(Mock沙箱识别"compile_error"关键词) | PASS |
| TestJudgeMemoryLimitExceeded | MLE判定正确(MemoryKB超限) | PASS |
二、HTTP API接口实现
2.1 接口列表
项目实现了7个HTTP API接口:
| 接口 | 方法 | 功能 |
|---|---|---|
/api/submissions |
POST | 提交代码判题 |
/api/submissions |
GET | 查询提交记录列表(支持分页) |
/api/submissions/:id |
GET | 查询单个提交结果 |
/api/submissions/:id/cases |
GET | 查询测试点详情 |
/api/judge/languages |
GET | 获取支持的语言列表 |
/api/system/health |
GET | 系统健康检查 |
/api/system/stats |
GET | 系统运行统计 |
2.2 提交接口
func (h *Handler) handleCreateSubmission(w http.ResponseWriter, r *http.Request) {
var req domain.CreateSubmissionRequest
json.NewDecoder(r.Body).Decode(&req)
submission, err := h.service.Create(r.Context(), &req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, submission)
}
接口采用标准REST设计:POST创建资源返回201,GET查询返回200。请求和响应均使用JSON格式,Content-Type为 application/json。
2.3 参数校验
提交接口包含多层校验逻辑:
func (s *SubmissionService) Create(ctx context.Context, req *domain.CreateSubmissionRequest) (*domain.Submission, error) {
// 1. 代码非空校验
if strings.TrimSpace(req.Code) == "" {
return nil, ErrCodeEmpty
}
// 2. null byte过滤
if strings.ContainsRune(req.Code, 0) {
return nil, ErrCodeContainsNull
}
// 3. 代码长度限制(128KB)
if len(req.Code) > MaxCodeLength {
return nil, ErrCodeTooLong
}
// 4. 语言合法性校验
if _, ok := domain.SupportedLanguages[req.Language]; !ok {
return nil, ErrLanguageNotSupported
}
// 5. 资源限制合理性校验
...
// 6. 限流:每用户每分钟12次
...
}
null byte过滤的发现过程:null byte(\x00)在Go的字符串中是合法字符,但当代码内容通过shell命令传递给Docker容器时,null byte会导致命令被截断。如果用户提交的代码中嵌入 \x00 字符,后续Shell命令会在该字符处被截断,可能绕过命令参数限制。通过 strings.ContainsRune(req.Code, 0) 强制过滤。
限流实现:每用户每分钟最多12次提交。采用计数器+时间窗口的简单方案——记录每个用户最近一分钟的提交次数,超过阈值返回 ErrRateLimited。
2.4 查询接口与轮询
func (h *Handler) handleGetSubmission(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
submission, err := h.query.FindByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "submission not found")
return
}
writeJSON(w, http.StatusOK, submission)
}
查询接口的轮询机制是异步判题架构的关键组成部分。客户端通过定时GET请求查询提交状态,直到状态不再是中间态(Pending、Queueing、Compiling、Running),即为最终结果。轮询间隔建议设置为1-2秒。
三、判题状态模型
3.1 状态定义
从ACM比赛的体验出发,判题状态是用户最直接感知的反馈。标准OJ平台的状态模型已非常成熟,本系统直接采用:
中间态:Pending → Queueing → Compiling → Running
│
终 态: ▼
Accepted / Wrong Answer / Compile Error / Runtime Error /
Time Limit Exceeded / Memory Limit Exceeded / Output Limit Exceeded / System Error
3.2 状态选择
状态名称采用完整英文单词而非缩写,原因有三:
- JSON中的全称对前端开发者更友好,无需查阅缩写对照表
- 日志排查时全称比缩写更容易搜索和过滤
3.3 判题结果结构
每个判题结果包含三个层次的信息:
- 全局结果:最终状态、总分、总耗时
- 编译信息:编译命令、编译输出(成功时的警告信息或失败时的错误信息)
- 逐测试点详情:每个测试点消耗的时间、内存、获得的分数、输出预览(截断)
四、Judger核心判题流程
func (s *Service) Judge(ctx context.Context, req domain.JudgeRequest) domain.JudgeResult {
// 1. 准备临时工作目录
workDir := prepareWorkspace(req)
defer cleanup(workDir)
// 2. 编译(编译型语言)
if spec.Compiled {
compileRes := s.sandbox.Compile(ctx, compileReq)
if compileRes.ExitCode != 0 {
return CompileErrorResult(compileRes.Stderr)
}
}
// 3. 逐测试点运行并比对
for i, tc := range req.TestCases {
runRes := s.sandbox.Run(ctx, runReq(tc))
status := compareCase(runRes, tc.Expected)
if status != Accepted {
return result // 短路:第一个失败即返回
}
}
return AcceptedResult()
}
短路逻辑符合OJ评测的标准行为——只要有一个测试点未通过,后续测试点不再运行,直接返回当前判定结果。
五、测试验证
5.1 Mock模式全量单元测试
测试命令(不依赖Docker):
go test ./internal/api/... ./internal/service/... ./internal/worker/... ./internal/transport/... -count=1 -timeout 60s
测试结果:
| 包 | 测试内容 | 结果 | 耗时 |
|---|---|---|---|
| internal/api | HTTP接口创建/查询/健康检查/统计 | PASS | 0.698s |
| internal/service | 提交服务/限流/null byte过滤 | PASS | 0.525s |
| internal/worker | Worker消息消费/状态更新 | PASS | 0.704s |
| internal/transport/grpcclient | gRPC客户端调用/健康检查 | PASS | 0.154s |
4个包全部通过,总耗时约2.1s。

带 -v 详细输出的全量测试输出过长,按包分组运行并截图:
| 分组 | 测试函数数 | 截图 |
|---|---|---|
| API + Domain + gRPC Client | 5 | ![]() |
| Judger(Mock 模式,含优先级链) | 15 | ![]() |
| Queue + Repository | 19 | ![]() |
| Sandbox + Service + Worker | 24 | ![]() |
5.2 单测覆盖的功能点
| 测试文件 | 测试用例 | 验证内容 |
|---|---|---|
| api/http_test.go | TestHTTPServerCreateAndQuery | POST创建+GET查询+health+stats接口 |
| api/http_test.go | TestHTTPServerRejectsBlankCode | 空白代码返回400 |
| service/submission_test.go | TestSubmissionServiceCreate | 创建提交并验证队列消息 |
| service/submission_test.go | TestSubmissionServiceRateLimit | 12次提交后触发限流 |
| service/submission_test.go | TestSubmissionServiceRejectsNullByte | null byte被正确拒绝 |
| worker/judge_worker_test.go | TestJudgeWorkerHandle | Worker处理消息并更新状态 |
| grpcclient/client_test.go | TestClientJudge | gRPC客户端判题调用 |
| grpcclient/client_test.go | TestClientHealth | gRPC健康检查 |
六、本周总结
完成内容
- C++17/Go 1.22/Python 3.11三语言配置化判题支持
- 7个HTTP API接口完整实现
- 参数校验体系(空值/null byte/长度/语言/资源限制/限流)
- 完整判题状态流转模型(4中间态+7终态+System Error)
- Mock模式4个包8个测试用例全部通过
调研查阅的资料
- Alpine Linux Wiki - Go环境配置:https://wiki.alpinelinux.org/wiki/Go
- OI Wiki - 判题状态分类与评测流程:https://oi-wiki.org/intro/judge/
- Docker官方镜像(Python/Golang):https://hub.docker.com/_/python
- Docker多阶段构建文档:https://docs.docker.com/build/building/multi-stage/





浙公网安备 33010602011771号