CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
综合实战:迷你在线判题平台
这一课你将能做到
把前 11 课的模块组装成一个分层清晰的判题平台后端:提交 → 队列 → 判题 → 推送全链路可运行
用分层结构(Domain / Application / Infrastructure / Api)组织代码,说出每层与 Spring 项目的对应关系
拿到一份上线前检查清单,能独立完成「从空仓库到容器化部署」的完整交付
为什么要学
单点知识学完不等于会做系统——功夫在「接线」:谁持有谁、事务边界在哪、失败谁兜底。这一课把判题平台当验收考题:每处接线都对应前面某一课,含糊就回那课补。
人话:前 11 课是零件,这一课是总装车间。装完你就有一套「能写进简历、面试讲 20 分钟」的项目。
先建立图像
项目分层(Clean Architecture 精简版)与 Spring 项目对照:
OJ.Platform/
├─ src/
│ ├─ Oj.Domain/ 实体与领域规则(无框架) ≙ domain 模块
│ ├─ Oj.Application/ 用例编排(接口+实现) ≙ service 模块
│ ├─ Oj.Infrastructure/ 出站适配:EF/Redis/MQ/沙箱 ≙ persistence+client
│ ├─ Oj.Api/ 入站适配:REST + SignalR ≙ web/controller
│ └─ Oj.Worker/ 判题消费者(独立进程) ≙ 消息监听应用
└─ OJ.Platform.sln
依赖方向:Api/Worker → Application → Domain;Infrastructure 实现 Application 的接口,由组合根装配。.NET 版六边形架构,与 Spring 分层治理同源。
跟着做
步骤 1:组合根——一个文件看全装配
// Oj.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);
// 数据(第 3 课)
builder.Services.AddDbContext<OjDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// 业务与 DI 生命周期(第 4 课)
builder.Services.AddScoped<SubmitUseCase>();
builder.Services.AddSingleton<IJudgeQueue, RabbitJudgeQueue>(); // 队列连接全局唯一
builder.Services.AddScoped<ProblemCacheService>();
// 缓存(第 6 课)
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
// 消息(第 9 课)
builder.Services.AddMassTransit(x => x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMq:Host"]!);
cfg.ConfigureEndpoints(ctx);
}));
// 实时/文档/全局异常(第 5、7 课)
builder.Services.AddSignalR();
builder.Services.AddSwaggerGen();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }
app.MapSubmissionEndpoints(); // /api/submissions
app.MapHub<JudgeHub>("/hubs/judge");
app.MapHealthChecks("/healthz");
app.Run();
public partial class Program { } // 集成测试复用(第 8 课)
步骤 2:领域层——纯 C#,无框架痕迹
// Oj.Domain/Submission.cs
public class Submission
{
public long Id { get; private set; }
public long ProblemId { get; private set; }
public string UserName { get; private set; } = null!;
public string Language { get; private set; } = null!;
public string Status { get; private set; } = "PENDING"; // PENDING→JUDGING→AC/WA/TLE/RE
public int TimeMs { get; private set; }
// 状态机收敛变更入口:非法流转直接拒绝(幂等第一道防线)
public void MarkJudged(string verdict, int timeMs)
{
if (Status is not ("PENDING" or "JUDGING"))
throw new InvalidOperationException($"状态 {Status} 不允许再判定");
Status = verdict;
TimeMs = timeMs;
}
}
步骤 3:提交用例——事务与消息解耦 // Oj.Application/SubmitUseCase.cs
public class SubmitUseCase(OjDbContext db, IJudgeQueue queue)
{
public async Task<SubmitResult> ExecuteAsync(SubmitDto dto, string user, CancellationToken ct)
{
if (!await db.Problems.AnyAsync(p => p.Id == dto.ProblemId, ct))
return SubmitResult.NotFound();
var submission = new Submission { ProblemId = dto.ProblemId, UserName = user, Language = dto.Language };
db.Submissions.Add(submission);
await db.SaveChangesAsync(ct); // ① 数据先落库(真相源)
await queue.EnqueueAsync(new JudgeTask(submission.Id, dto.Language, dto.Code), ct); // ② 再投递
return SubmitResult.Created(submission.Id);
}
}
①②之间的原子性缺口由 Outbox(第 9 课)或「PENDING 超时补偿任务」兜底——面试主动讲出来,是体现深度的点。
步骤 4:判题 Worker——消费/判定/回写/推送 // Oj.Worker/JudgeWorker.cs
public class JudgeWorker(IServiceScopeFactory scopes, IJudgeQueue queue,
IHubContext<JudgeHub> hub, ILogger<JudgeWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var slots = new SemaphoreSlim(4); // 沙箱并发上限
while (await queue.WaitToReadAsync(ct))
{
var task = await queue.ReadAsync(ct);
await slots.WaitAsync(ct);
_ = ProcessAsync(task, slots, ct);
}
}
private async Task ProcessAsync(JudgeTask task, SemaphoreSlim slots, CancellationToken ct)
{
try
{
using var scope = scopes.CreateScope(); // 后台无请求作用域(第 4 课)
var db = scope.ServiceProvider.GetRequiredService<OjDbContext>();
var outcome = await SandboxRunner.RunAsync(task.Code, task.Language, ct);
var verdict = VerdictEvaluator.Evaluate(outcome.Matched, outcome.TimeMs, 2000, outcome.ExitCode);
var submission = await db.Submissions.FirstAsync(s => s.Id == task.SubmissionId, ct);
submission.MarkJudged(verdict, outcome.TimeMs); // 幂等
await db.SaveChangesAsync(ct);
await hub.Clients.Group($"user-{submission.UserName}") // 推送(第 7 课)
.SendAsync("judgeResult", new { task.SubmissionId, verdict, outcome.TimeMs }, ct);
}
catch (Exception ex)
{
log.LogError(ex, "判题失败 {SubmissionId}", task.SubmissionId);
await queue.NackAsync(task); // 重试/死信由队列层接管
}
finally { slots.Release(); }
}
}
步骤 5:端到端验证 docker compose up -d # 第 11 课的 compose:pg/redis/rabbitmq + api + worker
dotnet ef database update
curl -X POST localhost:8080/api/submissions -H "Content-Type: application/json" \
-d '{"problemId":1,"language":"cpp","code":"int main(){return 0;}"}' # → 201 PENDING
# 浏览器连 /hubs/judge → 秒级收到 {"verdict":"AC"}
步骤 6:上线前检查清单
讲清楚要点
全链路的「真相源」是数据库 :消息/缓存/SignalR 都是可重建的加速层,任何一个挂掉,重启后以数据库为准自愈(PENDING 重投、缓存回填、页面重拉)。
生命周期贯穿设计 :Singleton(队列连接、判题引擎配置)→ Scoped(DbContext、用例)→ Transient(轻工具类);Worker 世界里没有请求,用 IServiceScopeFactory 手动造作用域。写错生命周期是本项目最高频 Bug。
横切关注点有明确归属 :校验在 API 边界、业务规则在 Domain、外部访问在 Infrastructure、事务边界在 Application——对应 Spring「Controller 薄、Service 编排、Repository 持久化」。
你可能会懵的地方
为什么判题 Worker 不做成 Web 应用的一部分?
资源特征不同(CPU 密集,独立扩容);安全边界不同(跑用户代码的机器能随时重建);故障隔离(沙箱崩溃不拖垮 API)。Spring 同理。
为什么 SubmitUseCase 先 SaveChanges 再发消息,而不是事务包住?
MQ 发送不能参与数据库事务,硬包是伪原子。正确姿势是 Outbox 或接受短暂不一致 + 补偿——经典的「双写问题」。
测试怎么组织?
Domain 全单测(VerdictEvaluator 分支);Application 用 NSubstitute 假依赖;Api 用 WebApplicationFactory 全链路(假沙箱);Worker 单测「重投同一消息」。金字塔照搬第 8 课。
简历/面试怎么讲项目?
业务(削峰与实时性)→ 架构(消息解耦 + 实时推送)→ 深度点(幂等、Outbox、缓存三防)→ 数据(QPS、P99、积压告警),每点都能回溯到某一课。
错法对照 错法 问题 正解 Worker 复用 API 的 DbContext 单例并发判题 线程安全灾难 每任务 CreateScope 取独立 DbContext 状态字段直接 Status = "AC" 散落各处 非法流转无法拦截 领域方法 MarkJudged 收敛校验 只做推送不做超时补偿 Worker 重启后提交永远 PENDING 定时任务扫描超时 PENDING 重投 沙箱结果直接信前端回传 用户可伪造 AC 判定只在服务端沙箱完成后落库
卡点急救
消息与库状态打架(已 AC 又来一次) :状态机拒绝非法流转 + 消费端先查状态,冲突消息记 Warning 后 ack 丢弃。
本地全链路起不来 :compose 分层起(先 db/redis/mq,再 worker,最后 api),每层健康检查过再起下层;CI 用同 compose。
推送延迟高 :别新建 scope 查库(用户组映射缓存在 Hub 内);大 payload 改传 ID 让前端回查。
面试常问
自测三问
为什么 Domain 层不允许引用 EF Core?
「数据库为准」在判题链路的哪些环节体现?
上线前清单哪三项缺失会直接造成资损?
小结一句话 总装的核心是依赖方向与真相源:领域不依赖框架、数据库是唯一事实、其余组件全是可重建的加速层——做到这两点,平台经得起流量与面试的拷问。
延伸