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:区分编译型和解释型语言,判题流程据此决定是否执行编译步骤
  • CompileCmdRunCmd:编译和运行的完整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 状态选择

状态名称采用完整英文单词而非缩写,原因有三:

  1. JSON中的全称对前端开发者更友好,无需查阅缩写对照表
  2. 日志排查时全称比缩写更容易搜索和过滤

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。

all_tests_mock

-v 详细输出的全量测试输出过长,按包分组运行并截图:

分组 测试函数数 截图
API + Domain + gRPC Client 5 api_domain_grpc_verbose
Judger(Mock 模式,含优先级链) 15 judger_mock_verbose
Queue + Repository 19 queue_repo_verbose
Sandbox + Service + Worker 24 sandbox_service_worker_verbose

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健康检查

六、本周总结

完成内容

  1. C++17/Go 1.22/Python 3.11三语言配置化判题支持
  2. 7个HTTP API接口完整实现
  3. 参数校验体系(空值/null byte/长度/语言/资源限制/限流)
  4. 完整判题状态流转模型(4中间态+7终态+System Error)
  5. Mock模式4个包8个测试用例全部通过

调研查阅的资料

posted @ 2026-04-11 20:46  宋佳奇  阅读(30)  评论(0)    收藏  举报