开发札记
在开发队式的 workshop 的时候难免遇到许许多多的问题,在这里聊做记录吧。
这个开发札记是此 workshop 原初的开发者建立的,后续的开发者如有想写的内容就接在后面写吧。在每一节的开头,可以注明自己的身份,或者写下的时间。原初开发者给自己起的代号为「开发者 A」,后续的开发者可以继续选用剩余的代号,例如 B 到 Z,再然后是 AA 到 ZZ……
目录
[TOC]
关于选题
本部分提到的笔者指的是此 workshop 的原初开发者(以下均简称为「开发者 A」),也是最初开发时写下的,用于记录这个项目立项之初的诸多考量。
开发队式的 workshop 的第一个挑战就是选题,究竟要选择什么来作为项目的主题。这对队式部分的 workshop 来说是一个相当有挑战性的问题。
选题目标
对于选题,我希望达到的目标有以下三点:
- R1: 项目要足够地小,能够让几乎只有 C89 语言和 C++98 语言基础的小同学在短短一周的闲暇时间内就可以做完,甚至积极的同学可以每一天就能够完成当天部分的作业;
- R2: 项目要能够尽可能地涵盖暑培队式部分讲解的重要知识点,达到对暑培内容的训练目的;
- R3: 项目要足够地贴近实际,尽可能做到,如果实际存在和我们课题类似的需求,那么我们这个项目给出的解决方案虽然不能说是最优的,但至少是勉强符合开发规范的。这样,才能够尽可能防止对学生造成误导作用(这一点至关重要,也困扰了我很久,在后面会提到)
内容取舍与问题规约
队式 workshop 选题最大的难度来源于队式部分内容之庞大。由于暑培队式部分需要讲解过多的内容——C# 语法、面向对象程序设计、多线程、异步、gRPC、Avalonia、Unity、现代 C++ 等,要通过一个能让几乎只有 C89 语言和 C++98 语言基础的小同学在短短一周的闲暇时间内就可以做完(甚至是每一天就要完成当天部分的作业)的小项目来涵盖是不可能的——如果一个项目包罗了这么多的内容,甚至需要网络通信、跨语言,那么这个项目一定不会很小;如果项目足够小,那么很难包括全部的内容。因此,首先要做的就是对内容进行合适的取舍。
首先被舍弃的就是现代 C++。首先,其他的内容多可以用单一的 C# 技术栈来完成,而增加一个 C++ 语言的章节是徒增烦恼——且不说由于众所周知的原因,配置一个工程意义上合格的 C++ 环境是十分耗时的操作,这是对 R1 的违反,而对 C++ 的开发环境的熟悉完全可以在额外的小作业或是直接在未来的队式开发过程当中学习到。况且,如果确有增加 C++ 章节的需要,也是非常容易的——在完全确定了选题之后,只需要在通信的章节增加一个 C++ 语言的 CLI 客户端来与 Avalonia 客户端并行即可,如同 Kubernetes 的 kubectl 客户端以及众多语言的 API 一样,无论用什么语言写都是可以的。
其次,Avalonia 技术的训练虽然在项目中是必要的,但也不需要费心思去考虑它们在选题中的应用问题——因为无论选了什么主题的软件来开发,都可以提出一个为这个软件增加一个 GUI 客户端的需求。因此,在选题时无需考虑 Avalonia,只要在确定了选题后将 Avalonia 进行适配即可。由此,Unity 也是类似的,只需要给软件做一个灵动的、趣味的、可游玩的客户端即可。
C# 语法自不必谈,只需要选用 C# 作为开发语言,语法的训练是必然的。而 gRPC 存在异步接口,因此只需要锚定 gRPC 就会自带异步的训练。且面向对象程序设计的训练也是相对平凡的,因为 面向对象设计模式 已经是前人总结好的经验(虽然有被滥用之嫌,但如果可以做到不为用而用依然是优秀的经验参考),无论什么软件都可以在其中选用合适的架构来对面向对象思想进行训练。因此,问题被规约为,我只需要选取一个能够同时容纳多线程和 gRPC 这两项知识点的题目即可。
但光是同时容纳多线程和 gRPC 这两项知识点就已经让我头痛。
曾考虑过但被否决的选题
为何?首先我们来说多线程的问题。在暑培中,我们希望做到的是交给大家非常底层的线程同步问题,例如如何管理线程、使用互斥量、条件变量(或管程)、信号量进行同步和互斥等操作,或是生产者消费者等问题。但这实际上是很难构造场景的。多线程最常见的场景之一是进行并行计算以起到加速的作用。但当前已经有很多的高性能并行计算库来做这件事情,自己管理线程和同步互斥等问题是非常之愚蠢的,这严重违背了前面提到的 R3。虽然现在手动管理线程十分少见,但我依然希望找一个至少看起来不那么的愚蠢的场景。
可能有人会说,为什么不找游戏场景,做一个简单的小游戏呢?每个线程操控一个人物,看起来很合理?这很容易解释——因为几乎全部的游戏引擎都不是这么做的。一般的游戏都是在统一的线程中控制帧率,在每一帧完成对全部实体的计算,而不是多线程来控制游戏实体(虽然多线程会可能体现在具体的计算过程中,但主要是并行计算问题,这又回到了上一段提到的并行计算)。因此,如果使用游戏来作为场景,这又违反了 R3。
可能还有人会问,为什么不直接用有序队式开发需要用到的线程同步的场景,平移到这个 workshop 项目里呢?这是因为,当前队式开发中用到线程同步的地方主要有以下几个地方:
- 第一是通信部分。一部分是服务器需要进行线程同步来处理 gRPC 请求——但问题在于多线程是在通信之前讲授的,不能和 gRPC 耦合在一起;另一部分是在客户端选手接口处接收服务端传来的游戏信息时进行状态更新,为了防止与选手代码产生数据竞争,需要做互斥——这个问题不仅在和 gRPC 耦合在一起,而且诸多数据竞争的处理对于小项目来说过于庞大繁杂,严重违反了 R1;
- 第二是下载器部分。这部分可能存在并行下载小文件等,但这需要同学自己租用云存储,这显然不行;
- 第三是游戏引擎部分。队式笔者当前(截至 2026 年春)的游戏引擎是本 workshop 原初的开发者(即本段当前的笔者)在 THUAI3.0 基础上经大规模重构和魔改并在 THUAI4 完成的基础设计,且于 THUAI6 增加状态转移机制而成,而前两者存在巨大的设计错误。THUAI3.0 为每一位游戏实体创建了一个定时器
Timer,而 THUAI4 更是离经叛道改成了为每一位游戏实体创建一个线程Thread(不过 THUAI9 似乎由 THUAI9 的开发者改为了每一位游戏实体创建一个Task,出现了更大的错误),这就是我之前说的,游戏理应按照帧率控制,而由当初完全不懂的笔者写成了每个游戏实体一个线程,就具有极大的误导作用。笔者几年前即意识到了这个问题,但游戏引擎已经写完,不敢贸然更改以防止影响比赛进度。这几年来笔者更是想要推动游戏引擎在这部分的重构以更正错误,但由于种种原因一直没有实施。
苦思不得结果之后,笔者亦和 ChatGPT、Google Gemini 等多种大模型进行了一次又一次的对话,但给出的方案均存在如上一种或几种的问题。大模型甚至还给出过一个相当抽象的选题——任务处理系统,提交一项任务,随时完成,处理并发任务。不过这也被我否决了——因为这个过于抽象,需求完全不够实际,属于是先射箭后画靶,完全是对着知识点设计问题,没有身临其境解决需求的感觉。
随后,笔者还想出了做一个现实生活的模拟器的主题(大模型也想到过这个主题),例如餐馆模拟器、打印机模拟器等等,用线程模拟点菜的人流,或是打印机的打印任务。但问题在于,现实生活中真的存在这样的需求吗?这种模拟器有什么用的?如果是一种游戏,那么为什么像游戏引擎一样不按帧率控制呢?于是,这种方案也被我否决了。
给笔者带来曙光的是笔者的一个念头——并行编译。如同 GNU Make 一样,编译可以被拆分为多任务,并行编译不同的文件。这是笔者相处的第一版看起来接近于可行的选题。但这又存在很多的问题。例如,一个几乎只有 C89 语言和 C++98 语言基础的小同学,真的可以理解那么多的编译概念吗?词法分析、文法分析……等等这些编译原理的专业知识,短时间内掌握并写出来这是很难的啊!而如果是框架把编译器全都写好,只需要学生调用即可,那么学生的工作又会面临过少的问题——学生几乎不需要写什么东西!很难达到训练目标。况且,并行编译如何结合 gRPC 存在呢?如果是本地的并行编译,那么根本不需要网络通信,编译器几乎都是本地运行的应用程序。如果是像 Compiler Explorer 一样的远程编译器,那么工作量未免十分之庞大——需要并行编译的远程编译器,这需要在 GUI 实现多文件,并传输到服务端。编译又不是吃显卡算力的东西,并且 GUI 需要安装 C# 运行环境和 C# 编写的应用程序——它不像 Web 一样开箱即用,而是需要特殊安装的——既然这样,为何不直接安装一个 C# 编写的本地编译器呢?这种需求简直太奇怪了!
想到这里,笔者似乎有些觉得,是否可以再放宽一些目标呢?不那么地计较实际需求,大概也可以罢!编译原理复杂的问题,可以用 JSON 解析器解决——做一个并行的 JSON 解析器(虽然这更加需求奇怪,但管不了这么多了)!JSON 比编程语言要简单地多,只要框架给好完整的词法分析器,以及文法分析中递归下降的分析框架,几小段讲好 LL(1) 分析用代码怎么写,学生们完全可以自己填空完成。
最终选题的确立
本以为到此为止了,JSON 解析器可能会成为选题。但笔者依然认为这太奇怪了,完全无法上台面,真是太可笑了,呵呵。
转机来自于 2026 年 6 月 3 日的晚上,笔者头脑中突然闪出一丝诡异的光——解析 JSON,不如解析应用程序的日志有意义啊!笔者突然想起来,在 2026 年的年初,笔者曾看到过 2025 年国际 AIOps 挑战赛 的题目。该比赛的题目是,给定云原生微服务应用程序所产生的日志,选手需要根据这些日志来分析出微服务调用链,并进行异常检测和定位。同时,基于日志的分析和异常检测也是网络领域的一个科研方向。那么,我可不可以把这个赛题中最简单的第一步即应用程序日志预处理单独拿出来,并进行大幅地简化,作为 workshop 的选题呢?
这在逻辑上是完全自洽的——首先,应用程序会产生多个日志文件,并行地读取和解析是非常常见的。虽然读取占用的是 I/O 资源,但解析这一步却是占用 CPU 资源,CPU 密集型的程序由多线程来执行以进行加速是很有意义的。
而且,在这个选题上引入 gRPC 是一个看起来也比较自然的事情。用于运行云服务的集群自然是在远端的,而我在本地使用一个用于控制远端的客户端来远程控制日志的分析,乃至于可视化,是一个比较合情合理的需求。而且,队式的开发本就存在需要在 Avalonia 界面中显示一些信息(如文件类型、日志、人物动作、游戏事件等),如果设计一个对日志本身内容的显示作业,也是对日后队式开发的前瞻性训练。
最终,我将云服务日志处理系统作为本 workshop 的选题。
关于 Unity 部分
笔者:开发者 A;记录时间:最初开发时
笔者的最初版是没有将 Unity 放入 workshop 的(不知道后来其他人有没有加,但札记一定是要记的)。这是为什么呢?
首先,自然是笔者对 Unity 不熟了!笔者对 Unity 虽然不能说是小白,但其实也没会多少,完全不熟,不知道怎么设置作业才难度适中,不如留给其他人设计!
第二,这个云服务日志处理系统本身并不是游戏,而是偏向于工具。Unity 作为游戏引擎应当做什么呢?其实笔者是有思考的,就是用 Unity 绘制出各个微服务或者结点的关系,每个微服务做成一个小房子,然后做一个小人可以在小房子之间游走、开门。开了哪扇门,就可以查看这扇门所代表的微服务或者结点内部的日志文件情况。但首先是笔者对 Unity 不熟,第二是笔者依然觉得这好像有点怪,Unity 似乎还是做个游戏更好。
第三,Unity 很难用 GitHub 做项目管理,就算合并到 workshop 了也很难像其他章节一样提交。本来就是分开的东西,合并到一个 workshop 似乎也没有必要。
嗯,就是这样。
关于开发环境中的文件读写问题
笔者:开发者 A
在编写测试程序时,需要对日志文件的读取进行测试——这时会出现一个问题。
在 Visual Studio 中启动程序时,应用程序启动会以可执行文件所在位置为工作目录,而该目录是应当被 .gitignore 文件忽略掉的。而如果要将数据集放入 Git 进行托管,数据集就不应当处于该目录中。因此,在使用相对路径 指定数据集文件所在位置时,就产生了一个矛盾:究竟是要临时人为地把数据集复制过去,还是在测试中使用相对目录时多写若干个 ../../../ 呢?前者固然太过烦琐,而后者问题更大——因为这极大地破坏了兼容性,并且会导致相同的代码在开发环境与生产环境中的行为不一致,严重影响后续的开发。
幸而 MSBuild 存在一个解决该问题的机制。只需要在项目文件(.csproj 文件)中的 <ItemGroup> 元素内添加:
<None Include="..\dataset\*.log">
<Link>dataset\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
在编译时,MSBuild 就会自动将数据集文件复制到目标位置。其中,Include 的值是目标数据集相对于项目根目录(.csproj 文件所在目录)的路径,而 <Link> 元素的值是数据集要被复制的相对于可执行文件所在目录的目标位置相对路径。
于是问题被完美解决。
关于 AvaloniaUI 的网页端和移动端
笔者:开发者 A
AvaloniaUI 是跨平台的,但是笔者删除了基础章节中 Android 和 iOS 移动端的要求。这首先是因为移动端适配是一个比较繁琐的事情,各类控件均需要重新布局;第二是因为 Android 适配还需要信任证书等等,这并不是此项目的重点。
关于网页端,如果要能够成功运行网页端,需要安装 .NET 的 WASM 工具:
dotnet workload install wasm-tools
如果是使用 Visual Studio 调试,则需要在 Visual Studio Installer 中安装 .NET 10 WebAssembly Tools。
关于编写 WebAssembly 目标平台客户端的额外难题
笔者:开发者 A
偶然看到 Avalonia 是支持多平台客户端的,例如 Desktop(Windows、Linux、macOS)、Android、iOS,还有 WebAssembly(即可以在浏览器中运行)。Android 有一堆签名的事情需要处理,iOS 系统我手里没有,所以想试试能否同时支持 Desktop 和 WebAssembly(即 LogAnalyzerClient.Browser 项目),没想到遇到了巨大的困难。
首先就是 gRPC Web 和本地的 gRPC 是有区别的。gRPC Web 在连接之前要先使用 HTTP/1.1 做一次浏览器 CORS 预检请求 OPTIONS,而本地的 gRPC 则没有这一过程。这导致 Agent 如果只开启 HTTP/2 协议则 gRPC Web 无法连接,而同时开启 HTTP/1.1 和 HTTP/2 则本地的 gRPC 无法连接:
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureEndpointDefaults(listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2; // gRPC Web 需要 HttpProtocols.Http1AndHttp2
});
});
也正是因此,同一个端口无法同时接受本地桌面客户端和浏览器客户端的连接,只能开两个不同的端口,分别接受本地客户端和浏览器客户端。这应该会给做 workshop 的同学带来不必要的困惑,因此我决定不给 workshop 加浏览器平台了。但 Browser 项目还是保留了下来,给有兴趣的同学探索。
还有就是如果要支持浏览器平台需要配置 CORS,这是网站要讲的东西,不适合在队式中掌握。顺带一提,如要支持浏览器的 gRPC Web,还要给 Agent 加这两行:
app.UseGrpcWeb();
app.MapGrpcService<AgentService>()
.EnableGrpcWeb();
有一个给我折腾了最长时间的困难是两点:Avalonia 和 Grpc.AspNetCore 无法兼容于同一个项目中,以及 gRPC 客户端的创建方式在 Desktop 中和 Browser 中不一致的问题。
对于Avalonia 和 Grpc.AspNetCore 无法兼容于同一个项目中的问题,我最开始 LogAnalyzerRpc 项目引用的是 Grpc.AspNetCore 包,结果发现它被 Avalonia 项目引用的时候,Grpc.AspNetCore 会被 Avalonia 项目间接依赖而存在于同一个项目中,而 Avalonia 与它是不兼容的,会编译不过。因此,经过我一番详细地拆解,LogAnalyzerRpc 只包含 Google.Protobuf、Grpc.Tools、Grpc.Core.Api(Grpc.Core 都不行,必须要带 .Api,服了)几个包。
当然下一个问题就是版本的问题,应该选取哪个版本,以最大可能地和我选取的 2.80.0 版本的 Grpc.AspNetCore 保证兼容。于是我去翻了 gRPC for .NET Release v2.80.0 版本的源码,在里面的 依赖配置文件 中查了一下版本,引用了该版本的包:
<Project>
<PropertyGroup>
<GrpcDotNetPackageVersion>2.70.0</GrpcDotNetPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Grpc.Tools" Version="2.80.0" />
<PackageVersion Include="Grpc.Core.Api" Version="$(GrpcDotNetPackageVersion)"/>
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup>
</Project>
对于 gRPC 客户端的创建方式在 Desktop 中和 Browser 中不一致的问题,Desktop 应该使用的是 Grpc.Net.Client 包来创建客户端,而 Browser 平台则还要多依赖一个 Grpc.Net.Client.Web 包来创建客户端,且两者创建客户端的方式也不相同:
// Desktop
var channel = GrpcChannel.ForAddress(address);
var client = new LogAnalyzerAgentServiceClient(channel);
// Browser
var handler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions()
{
HttpHandler = handler
});
var client = new LogAnalyzerAgentServiceClient(channel);
所以我使用了工厂方法模式来解决这一问题。
此外,在全部跑通之后,发现部分的 gRPC 的 RPC 函数在调用的时候还存在一些问题,会发生调用失败的现象,但我暂时没有精力去弄清楚这些问题了。因此,跑通 Browser 平台的计划暂时被全面搁置。