OJ平台远端代码沙箱开发第二周:整体架构设计与gRPC+Protobuf入门
承接第一周的Docker基础学习与需求拆解,本周核心工作围绕独立远端RPC式代码沙箱展开,重点完成了沙箱服务的整体架构设计,同时系统学习了gRPC与Protobuf的核心知识,实现了gRPC服务端的基础工程搭建和接口测试。作为可被OJ后端远端调用的独立判题服务,架构的解耦性和接口的标准化是本周的核心考量,而gRPC+Protobuf则是实现这一需求的核心技术栈。本周的工作让沙箱服务的开发框架彻底落地,也为后续将Docker沙箱逻辑与远端调用结合打下了关键基础,以下是详细的开发与学习记录。
一、远端判题沙箱整体架构设计
结合第一周拆解的核心需求(独立部署、远端调用、并行执行、强隔离),以及项目整体的Go技术栈选型,本周设计了分层式的远端判题沙箱服务架构,整体遵循高内聚、低耦合、可扩展的设计原则,将沙箱服务与OJ后端完全解耦,仅通过标准化的gRPC接口进行交互,同时为后续的并发优化、多语言扩展预留了开发空间。
1.1 核心设计原则
- 服务独立化:沙箱服务作为单独的微服务部署,拥有独立的进程和资源,与OJ后端互不影响,OJ后端仅需通过gRPC调用沙箱的判题接口,无需关心沙箱内部实现;
- 分层解耦:将沙箱服务按功能拆分为不同层级,各层级仅通过定义好的内部接口交互,后续修改某一层逻辑不会影响其他层级;
- 并发友好:架构适配Go协程特性,支持多请求并行处理,同时预留容器并发数控制的扩展点;
- 结果标准化:所有判题结果按统一格式封装返回,保证OJ后端能高效解析。
1.2 分层架构设计
沙箱服务整体分为四层,从上层到下层依次为gRPC接口层、业务处理层、沙箱执行层、基础工具层,各层级的核心职责如下:
- gRPC接口层:作为沙箱服务的对外入口,负责接收OJ后端的gRPC判题请求,做基础的参数校验(如代码非空、语言合法、资源限制值合理),并将请求转发至业务处理层,最终将业务层返回的判题结果封装为gRPC响应返回;
- 业务处理层:沙箱服务的核心调度层,负责解析请求参数、管理判题任务、控制沙箱并发执行数、协调沙箱执行层的操作,同时处理判题过程中的通用异常(如参数错误、沙箱执行超时);
- 沙箱执行层:沙箱服务的核心执行层,基于Go Docker SDK实现,负责接收业务层的指令,完成Docker容器的创建、资源限制配置、代码编译运行、结果采集、容器销毁等核心操作,是与Docker交互的唯一层级;
- 基础工具层:为各层级提供通用工具支持,包括日志记录、临时目录管理、配置读取、资源监控等,抽离通用逻辑,避免代码冗余。
1.3 沙箱服务与OJ后端的交互流程
整体交互流程遵循请求-响应模式,基于gRPC的TCP长连接实现,单次判题的完整交互流程如下:
- OJ后端接收用户的代码提交请求后,封装为标准化的gRPC判题请求(包含代码内容、编程语言、测试用例、CPU/内存限制等),调用沙箱服务的
SubmitJudge接口; - 沙箱服务的gRPC接口层接收请求,完成基础参数校验,校验通过则转发至业务处理层;
- 业务处理层解析请求参数,通过信号量控制并发数后,向沙箱执行层下发判题指令;
- 沙箱执行层基于Go Docker SDK创建隔离的Docker容器,挂载代码和测试用例,配置CPU/内存等资源限制,执行代码的编译与运行,采集运行结果、错误信息、资源使用情况;
- 沙箱执行层将判题结果返回至业务处理层,业务处理层对结果进行标准化封装;
- gRPC接口层将标准化的判题结果封装为gRPC响应,返回给OJ后端;
- OJ后端解析gRPC响应,将判题结果展示给用户。
整个流程中,OJ后端仅需关注如何调用接口和如何解析结果,沙箱服务则负责判题的全流程实现,实现了彻底的解耦。
二、gRPC+Protobuf核心知识学习
要实现沙箱服务的远端RPC调用,选择合适的RPC框架是关键,结合项目Go技术栈和高并发需求,最终选定gRPC+Protobuf的组合,本周系统学习了两者的核心知识,重点掌握了Protobuf的语法规范和gRPC服务的定义、开发与测试。
2.1 为什么选择gRPC+Protobuf?
- 天生适配Go协程:gRPC的Go服务端默认使用goroutine处理每个请求,无需手动管理并发,完美契合沙箱服务的并行执行需求;
- 高效的序列化协议:Protobuf是二进制的序列化格式,相比JSON/XML,序列化后的数据体积更小、传输速度更快、解析效率更高,适合跨网络的远端调用;
- 跨语言支持:Protobuf定义的接口可生成多语言的代码(Go/Java/Python等),若后续OJ后端更换技术栈,沙箱服务无需修改核心代码,仅需生成对应语言的客户端代码即可,扩展性极强;
- 强类型约束:Protobuf通过严格的字段类型定义实现强类型校验,避免因数据类型不匹配导致的解析错误,保证接口调用的稳定性;
- 开箱即用的RPC能力:gRPC内置了请求分发、连接管理、超时控制等功能,无需手动实现底层网络逻辑,专注于业务开发即可。
2.2 Protobuf3核心语法入门
Protobuf是一种接口描述语言,核心作用是定义RPC服务的接口和请求/响应的消息结构,本次开发使用Protobuf3版本(语法更简洁,跨语言兼容性更好),以下是沙箱开发中高频使用的核心语法和判题服务的Protobuf接口定义。
(1)Protobuf3基础语法要点
- 所有文件以
syntax = "proto3";开头,声明使用Protobuf3语法; - 通过
package声明包名,避免命名冲突; - 通过
message定义消息体(对应Go的结构体),消息体中包含多个字段,每个字段需指定类型、字段名、字段编号(唯一,1-15占用1个字节,推荐常用字段使用); - 通过
service定义RPC服务,服务内声明具体的RPC方法,指定方法的请求消息和响应消息; - 支持的基础类型包括:
string、int32、int64、bool等,满足判题服务的参数定义需求。
(2)判题服务的Protobuf接口定义(核心)
结合沙箱服务的判题需求,定义了名为judge.proto的Protobuf文件,包含判题请求、判题响应的消息体,以及核心的判题服务,该文件是沙箱服务与OJ后端交互的唯一接口标准,内容如下(带详细注释):
// 声明使用Protobuf3语法
syntax = "proto3";
// 声明包名,避免命名冲突
package judge;
// 生成的Go代码的包名,指定为judgepb,方便后续导入
option go_package = "./judgepb;judgepb";
// 判题请求消息体:OJ后端向沙箱服务传递的参数
message JudgeRequest {
string code = 1; // 用户提交的代码内容
string language = 2; // 编程语言,如"cpp"、"go"
string test_input = 3; // 测试用例输入内容
int32 time_limit = 4; // CPU时间限制,单位:毫秒(ms)
int32 memory_limit = 5; // 内存限制,单位:兆字节(MB)
}
// 判题响应消息体:沙箱服务向OJ后端返回的结果
message JudgeResponse {
string status = 1; // 判题状态,如"AC"、"CE"、"WA"、"TLE"、"MLE"、"RE"
string output = 2; // 代码运行的标准输出
string error = 3; // 错误信息,如编译错误、运行时错误详情
int32 use_time = 4; // 代码实际运行时间,单位:毫秒(ms)
int32 use_memory = 5; // 代码实际占用内存,单位:兆字节(MB)
string log = 6; // 判题全流程日志,用于调试
}
// 判题RPC服务:定义沙箱服务的对外接口
service JudgeService {
// 单题判题接口:简单RPC模式(请求-响应),满足核心判题需求
rpc SubmitJudge (JudgeRequest) returns (JudgeResponse);
}
关键说明:本次先实现简单RPC模式的SubmitJudge接口,满足单题单测试用例的判题需求,后续若需要批量判题,可在该服务中新增流式RPC接口,扩展性极强。
2.3 Protobuf代码生成:生成Go语言的接口代码
Protobuf文件仅为接口定义,无法直接在Go代码中使用,需要通过protoc编译器结合Go语言的插件,将.proto文件生成为Go语言的代码(包含消息体的结构体、服务的接口定义等),以下是具体的操作步骤和命令。
(1)安装必要工具
首先需要安装protoc编译器和Go语言的gRPC插件,执行以下命令(基于Linux/Ubuntu环境,Windows/Mac环境可参考gRPC官方文档):
# 安装protoc编译器(若已安装可跳过)
sudo apt install -y protobuf-compiler
# 安装Go的Protobuf插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
# 安装Go的gRPC插件
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 将插件加入环境变量(确保protoc能找到)
export PATH="$PATH:$(go env GOPATH)/bin"
(2)生成Go语言代码
在judge.proto文件所在目录,执行以下命令,生成Go语言的接口代码,生成的代码会放在./judgepb目录下:
# 生成Protobuf消息体代码和gRPC服务代码
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
judge.proto
执行成功后,会在judgepb目录下生成两个文件:
judge.pb.go:包含JudgeRequest、JudgeResponse的Go结构体定义,以及序列化/反序列化方法;judge_grpc.pb.go:包含JudgeService的服务端接口定义(JudgeServiceServer)和客户端调用代码(JudgeServiceClient)。
这两个文件是gRPC开发的核心,后续沙箱服务端只需实现JudgeServiceServer接口,OJ后端则通过JudgeServiceClient调用接口。
三、Go gRPC服务端基础工程搭建与实现
完成Protobuf接口定义和代码生成后,本周基于Go语言搭建了gRPC服务端的基础工程,实现了JudgeServiceServer接口的空实现,并完成了服务端的启动与基础测试,为后续嵌入Docker沙箱逻辑做好了准备。
3.1 工程目录结构设计
为保证代码的可读性和可维护性,设计了简洁的工程目录结构,后续所有开发都将遵循该结构,核心目录如下:
sandbox-server/ # 沙箱服务根目录
├── go.mod # Go模块依赖
├── go.sum
├── judgepb/ # Protobuf生成的Go代码目录
│ ├── judge.pb.go
│ └── judge_grpc.pb.go
├── server/ # gRPC服务端代码目录
│ └── main.go # 服务端主程序,实现接口并启动服务
└── config/ # 配置文件目录(后续新增)
3.2 初始化Go模块
在沙箱服务根目录执行以下命令,初始化Go模块,引入gRPC和Protobuf的依赖:
# 初始化Go模块,模块名可自定义
go mod init sandbox-server
# 引入gRPC和Protobuf的核心依赖
go get google.golang.org/grpc
go get google.golang.org/protobuf
3.3 gRPC服务端核心代码实现
在server/main.go中实现JudgeServiceServer接口,启动gRPC服务并监听指定端口,核心代码如下(带详细注释),目前为空实现,仅返回模拟的判题结果,后续将嵌入Docker沙箱的核心逻辑:
package main
import (
"context"
"log"
"net"
// 导入生成的gRPC代码包
"sandbox-server/judgepb"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection" // 服务反射,方便调试
)
// 定义服务端结构体,实现JudgeServiceServer接口
type judgeServer struct {
judgepb.UnimplementedJudgeServiceServer // 嵌入未实现的接口,避免编译错误
}
// 实现JudgeServiceServer的核心接口:SubmitJudge
// 每个gRPC请求都会启动一个goroutine执行该方法,天然支持并发
func (s *judgeServer) SubmitJudge(
ctx context.Context,
req *judgepb.JudgeRequest,
) (*judgepb.JudgeResponse, error) {
// 打印请求参数,方便调试
log.Printf("接收到判题请求:语言=%s,时间限制=%dms,内存限制=%dMB",
req.Language, req.TimeLimit, req.MemoryLimit)
// 目前为模拟实现,返回固定的判题结果
// 后续将替换为Docker沙箱的实际判题逻辑
return &judgepb.JudgeResponse{
Status: "AC",
Output: "Hello, OJ Sandbox!",
Error: "",
UseTime: 10,
UseMemory: 2,
Log: "判题成功:模拟执行完成",
}, nil
}
func main() {
// 定义gRPC服务监听的端口
listenAddr := ":8080"
// 监听TCP端口
lis, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("监听端口失败:%v", err)
}
log.Printf("gRPC服务端启动,监听端口:%s", listenAddr)
// 创建gRPC服务实例
grpcServer := grpc.NewServer()
// 将自定义的judgeServer注册到gRPC服务中
judgepb.RegisterJudgeServiceServer(grpcServer, &judgeServer{})
// 注册服务反射,方便使用grpcurl等工具调试
reflection.Register(grpcServer)
// 启动gRPC服务,阻塞运行
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("gRPC服务启动失败:%v", err)
}
}
关键说明:
- 嵌入
UnimplementedJudgeServiceServer是Protobuf3的规范,避免因未实现服务的所有接口导致编译错误; SubmitJudge方法的第一个参数为context.Context,用于实现请求的超时控制、取消等功能,后续将基于该上下文实现判题任务的超时终止;- 注册服务反射后,可使用
grpcurl工具在终端直接调用gRPC接口,方便开发调试。
3.4 启动gRPC服务端
在server目录执行以下命令,启动gRPC服务端:
go run main.go
若启动成功,终端会输出以下日志,说明服务端已正常监听8080端口,可接收OJ后端的gRPC请求:
2024/XX/XX XX:XX:XX gRPC服务端启动,监听端口::8080
四、gRPC服务端基础测试:使用grpcurl调试接口
为验证gRPC服务端是否能正常接收和响应请求,本周使用grpcurl工具(gRPC的命令行调试工具)进行了接口测试,无需编写客户端代码,直接在终端模拟OJ后端的请求,步骤如下:
4.1 安装grpcurl工具
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
4.2 测试接口连通性
在终端执行以下命令,检查gRPC服务端的接口是否可访问:
# 查看服务端的所有服务和接口
grpcurl -plaintext 127.0.0.1:8080 list
执行成功后,会输出以下内容,说明服务注册成功:
judge.JudgeService
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
4.3 模拟调用SubmitJudge接口
执行以下命令,模拟OJ后端发送判题请求,调用SubmitJudge接口:
# 模拟请求:提交C++代码,时间限制1000ms,内存限制256MB
grpcurl -plaintext -d '{
"code": "#include <iostream>\\nint main() { std::cout << \\"Hello\\"; return 0; }",
"language": "cpp",
"test_input": "",
"time_limit": 1000,
"memory_limit": 256
}' 127.0.0.1:8080 judge.JudgeService/SubmitJudge
执行成功后,会收到服务端返回的模拟判题结果,如下:
{
"status": "AC",
"output": "Hello, OJ Sandbox!",
"error": "",
"useTime": 10,
"useMemory": 2,
"log": "判题成功:模拟执行完成"
}
测试结果表明,gRPC服务端已能正常接收请求、处理请求并返回响应,远端调用的基础框架已完全落地。
五、Docker SDK与gRPC的结合思路梳理
本周的核心工作是搭建远端调用的框架,同时也梳理了Go Docker SDK与gRPC的结合思路,这是后续开发的核心方向,简单来说,就是在gRPC的SubmitJudge接口实现中,嵌入Docker沙箱的核心执行逻辑,具体的结合步骤如下:
- 在
SubmitJudge方法中,从JudgeRequest中提取核心参数:用户代码、编程语言、测试用例、时间限制、内存限制; - 调用基础工具层的方法,创建临时目录,将用户代码写入临时文件(如
main.cpp、main.go),将测试用例写入输入文件; - 调用Go Docker SDK的API,创建Docker容器,配置以下核心参数:
- 指定对应语言的判题镜像(如sandbox-cpp:v1.0);
- 将宿主机的临时目录挂载到容器的指定工作目录;
- 设置CPU、内存、进程数等资源限制;
- 禁用容器网络,保证沙箱安全性;
- 启动Docker容器,执行代码的编译和运行命令,通过Docker SDK采集容器的标准输出、标准错误、退出码;
- 根据采集的结果,判定判题状态(AC/CE/WA等),统计实际运行时间和内存占用;
- 调用Docker SDK销毁容器,删除宿主机的临时目录,释放资源;
- 将判题状态、输出、错误信息等封装为
JudgeResponse,返回给OJ后端。
该思路将远端调用与沙箱执行完美结合,后续的开发工作将围绕该思路逐步实现,本周的框架搭建为该思路的落地提供了坚实的基础。
六、本周学习总结与下周规划
6.1 学习总结
本周完成了远端判题沙箱服务的分层架构设计,明确了服务的功能分层和与OJ后端的交互流程,实现了服务的解耦;同时系统学习了gRPC+Protobuf的核心知识,完成了判题接口的Protobuf定义,生成了Go语言的接口代码;搭建了gRPC服务端的基础工程,实现了核心接口的空实现,并通过grpcurl工具完成了接口测试,验证了远端调用的可行性;最后梳理了Docker SDK与gRPC的结合思路,为后续的核心开发指明了方向。
本周的工作让沙箱服务从“需求”和“技术选型”落地为“实际的开发框架”,所有后续开发都将在本周的基础上展开,确保开发方向不偏离。
6.2 下周规划
下周将进入沙箱服务的核心功能开发阶段,重点将Docker沙箱逻辑与gRPC接口结合,实现C++版单语言判题沙箱的完整闭环,具体目标如下:
- 基于第一周构建的C++判题镜像(sandbox-cpp:v1.0),使用Go Docker SDK实现C++代码的编译、运行、结果采集;
- 在gRPC的
SubmitJudge接口中嵌入Docker沙箱逻辑,替换本周的模拟实现,实现真实的判题功能; - 完善Docker容器的资源限制配置,实现CPU时间、内存的精准限制,确保超出限制时容器能被正常杀死;
- 实现C++代码判题结果的初步判定,能区分AC(通过)、CE(编译错误)、RE(运行时错误)三种基础状态;
- 完成单语言判题的端到端测试,确保“OJ后端模拟请求→沙箱服务判题→返回真实结果”的流程能正常跑通。
参考资料:
- gRPC官方文档(Go语言):https://grpc.io/docs/languages/go/
- Protobuf3官方语法指南:https://protobuf.dev/programming-guides/proto3/
- grpcurl官方使用文档:https://github.com/fullstorydev/grpcurl
- Go Docker SDK官方文档:https://pkg.go.dev/github.com/docker/docker/client

浙公网安备 33010602011771号