跳转至

后端架构介绍

本系统使用 Go 语言开发,前后端使用 gRPC-Web 进行通信,API 协议定义在 proto/api.proto 文件中。后端可以嵌入前端 Web 网站资源,这样只使用一个二进制就可以启动服务。后端服务除了提供 Web 访问界面以及接口以外,还负责评测任务的调度和评测机管理。

目前,考虑到系统一般用于 100 人左右的课堂教学,并发访问量以及总的数据量都不大,为了方便部署,数据存储使用了 Pebble KV 数据库,数据均存储在本地。如果有需要,可以修改 repository 下面的文件,实现不同 Repository 接口,对接其他的如 SQL 数据库存储。

API 鉴权

除了登录注册等少数接口外,所有接口都需要进行用户鉴权以及访问鉴权。为了复用代码,将鉴权逻辑抽象了出来,使用 Interceptor 功能统一处理,相关代码位于 pkg/grpc/interceptor.go 中。

鉴权首先会从请求头中获取 authorization 头,并检查 JWT Token 是否有效,然后,后续会针对不同的功能进行鉴权。例如检查用户是否有权限修改课程、创建作业等。

在鉴权过程中获取的一些信息,例如用户在某个课程内的身份,会保存在 Context 中,后续的处理函数可以使用对应的 Key 从 Context 中获取。例如,用户登录信息会使用 userInfoCtxKey 保存在 Context 中,可以使用 ctx.Value(userInfoCtxKey{}).(*model_pb.UserTokenPayload) 获取。

文件上传与下载

由于 gRPC 并不适合用于传输文件,因此所有的文件都是用普通的 HTTP 协议传输的。在上传和下载文件前,需要先发送一个 gRPC 请求获取 Token,然后使用该 Token 向服务器发起文件传输的 HTTP 请求。

message InitUploadRequest {
  uint64 manifest_id = 1;
  string filename = 2;
  string token = 3;
  uint64 filesize = 4;
}

message InitUploadResponse {
  string token = 1;
}

message InitDownloadRequest {
  uint64 submission_id = 1;
  string filename = 2;
  bool is_directory = 3;
  bool is_output = 4;
}

enum DownloadFileType {
  Binary = 0;
  Text = 1;
  Image = 2;
  PDF = 3;
}

message InitDownloadResponse {
  string token = 1;
  DownloadFileType file_type = 2;
  string filename = 3;
  int64 filesize = 4;
}

service AutograderService {
  rpc InitUpload(InitUploadRequest) returns (InitUploadResponse);
  rpc InitDownload(InitDownloadRequest) returns (InitDownloadResponse);
}

其中上传请求中的 manifest_id 需要先使用 CreateManifest 请求创建临时的上传目录,并获取 ID 以及将目录绑定给用户。

message CreateManifestRequest {
  uint64 assignment_id = 1;
}

message CreateManifestResponse {
  uint64 manifest_id = 1;
}

其中 assignment_id 用来获取该作业的上传限制,后续添加文件的时候,总的大小不能超过该限制。

用户上传的文件都将存储在本地。对于上传了的文件,系统会定时检查,如果超过一定时间没有提交,则会自动删除,以节省空间。

评测任务管理

由于评测机和后端服务可能不是部署在同一台机器上,他们之间使用 gRPC 进行通信,并使用 HTTP 协议传输文件。用于评测机通信的端口和用于 Web 服务的端口不同,且 gRPC 和 HTTP 端口是分开的。也就是说后端服务需要占用三个端口。

创建提交

用户在文件上传对话框上传完文件并点击提交后,将根据 manifest_id 以及 assignment_id 将文件归档、创建提交记录,并生成一个评测任务发送给评测机进行评测。

message CreateSubmissionRequest {
  uint64 assignment_id = 1;
  uint64 manifest_id = 2;
}

message CreateSubmissionResponse {
  uint64 submission_id = 1;
}

service AutograderService {
  rpc CreateSubmission(CreateSubmissionRequest) returns (CreateSubmissionResponse);
}

在创建提交的时候,后端服务会先获取本次上传的所有文件列表并保存到提交信息中,然后获取 assignment_id 对应的评测配置,将该提交标记为待评测状态后,调用评测服务将评测任务添加到调度队列中。然后,启动单独的线程,监听评测服务发送的评测报告。监听线程收到评测报告后,会先将报告写入数据库,然后查找订阅了该提交的用户,将评测报告实时发送给用户。

订阅提交

为了给用户及时的反馈,评测报告将通过一个长连接实时发送给用户展示。对于未完成的提交,可以通过 SubscribeSubmission 这个接口订阅信息。

message SubscribeSubmissionRequest {
  uint64 submission_id = 1;
}

message SubscribeSubmissionResponse {
  uint64 score = 1;
  uint64 maxScore = 2;
  SubmissionStatus status = 3;
  PendingRank pending_rank = 4;
}

service AutograderService {
  rpc SubscribeSubmission(SubscribeSubmissionRequest) returns (streams SubscribeSubmissionResponse);
}

无论评测是否运行中,在订阅请求发送后,后端服务会先发送一次提交的当前状态。如果评测任务已经结束(评测完成、被取消、发生错误),则会立即关闭连接。否则,后端服务会将连接添加到订阅列表,接收评测报告。评测结束后同样会关闭连接并清理资源。

评测任务调度

在后端服务器中,会在内存中保存所有待评测的提交任务,并在后台调度评测任务。评测任务调度是事件驱动的,以下事件发生的时候将触发任务调度:

  • 提交任务被添加到待评测队列
  • 有提交任务完成了评测或被取消
  • 有新的评测机上线

在每一轮调度中,后端服务会遍历待调度的提交任务,筛选出符合提交标签的评测机,并根据评测机上报的并发度将其分配给空闲的评测机。如果没有空闲的评测机,则会等待直到有空闲的评测机上线或者有其他任务结束。

当一个任务分配到了评测机后,后端服务会先将该任务以及对应的评测机 ID 写入数据库,并将该任务移除出调度队列,加入运行队列。在收到评测结束的报告后,后端服务会将评测结果写入数据库,并将该任务从运行队列中移除,清理所有相关资源。

评测机下线处理

如果一台评测机在评测过程中丢失心跳或关闭了心跳连接,后端服务将认为该评测机下线并标记其为“离线”状态。同时,会将已经调度给该评测机所有提交重新添加到调度队列中。

评测机上线处理

在评测机上线后,后端会将接收其注册请求,并将对应信息写入数据库。然后标记其为“在线”状态,并触发调度器尝试进行调度。

服务重启处理

如果在有评测任务还没结束运行的时候重启了服务进程,服务进程会先在启动后扫描数据库中未完成的提交,并将其重新添加到调度队列。当评测任务结束运行之后,会清理未完成标记。

评测文件传输

评测机使用单独的 HTTP 端口上传和下载文件。HTTP 请求的路径就是文件的路径,GET 请求为下载,POST 请求为上传。文件内容都是直接放在 HTTP Body 里,无需 Multipart Form 编码。为了保证接口安全,需要在每个请求头中携带后端服务配置中的文件服务 Token。

服务指标监控

后端服务还集成了 Prometheus 客户端,并再使用一个单独的 HTTP 接口提供监控数据。


最后更新: 2022-03-10 23:43:14
本页作者: Howard Lau