第 0 阶段:教材项目 2 任务 1 在网页中原样运行
- runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、 GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、 教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式) - server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发 - web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏 - 端到端测试与 100 个程序并发的压力测试 - 教材逐任务摘要(docs/教材摘要) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
98be8331bc
71 files changed
+7919
No files matched your search
@@ -0,0 +1,27 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 针对教材上常见写法错误的中文提示(书上代码有 True/False 大写、Catch(EventArgs ex) 等印刷错误,学生照抄后会遇到)。
|
||||
/// 只在编译器的原始报错之外补充一句说明,不改变报错本身。
|
||||
/// </summary>
|
||||
internal static class CompileHints
|
||||
{
|
||||
public static string? For(Diagnostic diagnostic)
|
||||
{
|
||||
var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? "";
|
||||
return (diagnostic.Id, source) switch
|
||||
{
|
||||
("CS0103", "True" or "False") => $"C# 的布尔值是小写的 {source.ToLowerInvariant()}。",
|
||||
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
|
||||
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
|
||||
$"“{name}”像是类型和名字之间少了一个空格。",
|
||||
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
|
||||
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
||||
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
||||
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
||||
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||
/// </summary>
|
||||
internal static class CompileServer
|
||||
{
|
||||
public const string AssemblyFile = "StudentApp.dll";
|
||||
public const string PdbFile = "StudentApp.pdb";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
|
||||
public static int Run()
|
||||
{
|
||||
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
||||
using var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true };
|
||||
|
||||
WarmUp();
|
||||
output.WriteLine(new JsonObject { ["type"] = "ready" }.ToJsonString(JsonOptions));
|
||||
|
||||
while (input.ReadLine() is { } line)
|
||||
{
|
||||
JsonObject reply;
|
||||
JsonNode? id = null;
|
||||
try
|
||||
{
|
||||
var request = JsonNode.Parse(line)!.AsObject();
|
||||
id = request["id"]?.DeepClone();
|
||||
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reply = new JsonObject { ["type"] = "compileFailed", ["message"] = ex.Message };
|
||||
}
|
||||
reply["id"] = id;
|
||||
output.WriteLine(reply.ToJsonString(JsonOptions));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static JsonObject Compile(string dir)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
var result = StudentCompiler.Compile(dir);
|
||||
if (result.Success)
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(dir, AssemblyFile), result.Assembly);
|
||||
File.WriteAllBytes(Path.Combine(dir, PdbFile), result.Pdb);
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "compiled",
|
||||
["ok"] = result.Success,
|
||||
["diagnostics"] = result.Diagnostics,
|
||||
["ms"] = watch.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
||||
private static void WarmUp()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory("sim-warmup-");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir.FullName, "Program.cs"),
|
||||
"using System.Windows.Forms; static class P { static void Main() { var f = new Form(); f.Text = \"预热\"; } }");
|
||||
StudentCompiler.Compile(dir.FullName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
dir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Sim.Host;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary>
|
||||
internal sealed class ConsoleForwarder : TextWriter
|
||||
{
|
||||
private readonly StringBuilder _buffer = new();
|
||||
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
|
||||
public override void Write(char value)
|
||||
{
|
||||
lock (_buffer)
|
||||
{
|
||||
_buffer.Append(value);
|
||||
if (value == '\n') FlushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (value == null) return;
|
||||
lock (_buffer)
|
||||
{
|
||||
_buffer.Append(value);
|
||||
if (value.Contains('\n')) FlushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
lock (_buffer) FlushBuffer();
|
||||
}
|
||||
|
||||
private void FlushBuffer()
|
||||
{
|
||||
if (_buffer.Length == 0) return;
|
||||
SimHost.SendConsole(_buffer.ToString());
|
||||
_buffer.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Sim.Runner;
|
||||
|
||||
// 两种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
||||
// dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录
|
||||
// dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程
|
||||
// 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。
|
||||
|
||||
return args switch
|
||||
{
|
||||
["compile-server"] => CompileServer.Run(),
|
||||
["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir),
|
||||
_ => Usage(),
|
||||
};
|
||||
|
||||
static int Usage()
|
||||
{
|
||||
Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner run <工程目录>");
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- 运行器:编译学生工程,在模拟的界面线程上运行,通过标准输入输出与 Bun 服务器交换 JSON 消息。
|
||||
用法:dotnet Sim.Runner.dll <工程目录> -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>Sim.Runner</AssemblyName>
|
||||
<RootNamespace>Sim.Runner</RootNamespace>
|
||||
<!-- 只保留简体中文的编译器提示资源 -->
|
||||
<SatelliteResourceLanguages>zh-Hans</SatelliteResourceLanguages>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Emit;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>用 Roslyn 在内存里编译学生工程,报错信息用简体中文</summary>
|
||||
internal static class StudentCompiler
|
||||
{
|
||||
private static readonly CultureInfo Chinese = CultureInfo.GetCultureInfo("zh-Hans");
|
||||
|
||||
/// <summary>
|
||||
/// 框架里不能给学生代码引用的程序集:System.Drawing.dll 门面把 Font、Image 等转发到 Linux 不支持的
|
||||
/// System.Drawing.Common,会和仿真库里的同名类型冲突。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExcludedFrameworkAssemblies = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"System.Drawing.dll",
|
||||
};
|
||||
|
||||
private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll" };
|
||||
|
||||
public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics);
|
||||
|
||||
public static Result Compile(string projectDir)
|
||||
{
|
||||
var parseOptions = new CSharpParseOptions(LanguageVersion.Latest);
|
||||
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(projectDir, path))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.Select(path => CSharpSyntaxTree.ParseText(
|
||||
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
||||
parseOptions,
|
||||
path: Path.GetRelativePath(projectDir, path)))
|
||||
.ToList();
|
||||
|
||||
var options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
||||
.WithOptimizationLevel(OptimizationLevel.Debug)
|
||||
.WithNullableContextOptions(NullableContextOptions.Disable)
|
||||
.WithAllowUnsafe(false)
|
||||
.WithConcurrentBuild(true);
|
||||
|
||||
var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, options);
|
||||
|
||||
using var peStream = new MemoryStream();
|
||||
using var pdbStream = new MemoryStream();
|
||||
var emit = compilation.Emit(peStream, pdbStream,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
||||
|
||||
var diagnostics = new JsonArray();
|
||||
foreach (var diagnostic in emit.Diagnostics
|
||||
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
|
||||
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
||||
{
|
||||
diagnostics.Add(ToJson(diagnostic));
|
||||
}
|
||||
return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||
}
|
||||
|
||||
private static JsonObject ToJson(Diagnostic diagnostic)
|
||||
{
|
||||
var span = diagnostic.Location.GetMappedLineSpan();
|
||||
var message = diagnostic.GetMessage(Chinese);
|
||||
var json = new JsonObject
|
||||
{
|
||||
["file"] = span.Path,
|
||||
["line"] = span.StartLinePosition.Line + 1,
|
||||
["column"] = span.StartLinePosition.Character + 1,
|
||||
["severity"] = diagnostic.Severity == DiagnosticSeverity.Error ? "error" : "warning",
|
||||
["code"] = diagnostic.Id,
|
||||
["message"] = message,
|
||||
};
|
||||
var hint = CompileHints.For(diagnostic);
|
||||
if (hint != null) json["hint"] = hint;
|
||||
return json;
|
||||
}
|
||||
|
||||
private static bool IsBuildOutput(string projectDir, string path)
|
||||
{
|
||||
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
||||
return relative.StartsWith("bin/") || relative.StartsWith("obj/") || relative.Contains("/bin/") || relative.Contains("/obj/");
|
||||
}
|
||||
|
||||
/// <summary>学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库</summary>
|
||||
private static readonly Lazy<IReadOnlyList<MetadataReference>> References = new(() =>
|
||||
{
|
||||
var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
||||
var framework = Directory.EnumerateFiles(frameworkDir, "*.dll")
|
||||
.Where(path => !ExcludedFrameworkAssemblies.Contains(Path.GetFileName(path)))
|
||||
.Where(IsManagedAssembly);
|
||||
var sim = SimAssemblies.Select(name => Path.Combine(AppContext.BaseDirectory, name));
|
||||
return framework.Concat(sim).Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)).ToList();
|
||||
});
|
||||
|
||||
private static bool IsManagedAssembly(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
AssemblyName.GetAssemblyName(path);
|
||||
return true;
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return false; // 框架目录里的原生库
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using Sim.Host;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 运行一个已编译的学生程序:在模拟的界面线程上执行 Main,主线程从标准输入读取浏览器的操作。
|
||||
/// 这个类不能引用 Roslyn 的任何类型,否则运行模式也会加载编译器。
|
||||
/// </summary>
|
||||
internal static class StudentProgramHost
|
||||
{
|
||||
public static int Run(string dir)
|
||||
{
|
||||
var processStart = Process.GetCurrentProcess().StartTime;
|
||||
SimHost.Initialize(Console.OpenStandardOutput());
|
||||
var console = new ConsoleForwarder();
|
||||
Console.SetOut(console);
|
||||
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
|
||||
|
||||
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出
|
||||
var zhCN = CultureInfo.GetCultureInfo("zh-CN");
|
||||
CultureInfo.DefaultThreadCurrentCulture = zhCN;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
|
||||
CultureInfo.CurrentCulture = zhCN;
|
||||
CultureInfo.CurrentUICulture = zhCN;
|
||||
|
||||
var assembly = AssemblyLoadContext.Default.LoadFromStream(
|
||||
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))),
|
||||
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile))));
|
||||
SimHost.StudentAssembly = assembly;
|
||||
var entryPoint = assembly.EntryPoint;
|
||||
if (entryPoint == null)
|
||||
{
|
||||
SimHost.Send(new JsonObject { ["type"] = "exception", ["message"] = "程序中找不到 Main 方法", ["detail"] = "" });
|
||||
return 1;
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
// 学生创建的后台线程抛出未处理异常时,和真实程序一样整个程序结束
|
||||
if (e.ExceptionObject is Exception ex) SimHost.ReportException(ex);
|
||||
console.Flush();
|
||||
};
|
||||
|
||||
var uiThread = new Thread(() =>
|
||||
{
|
||||
SimHost.Ui.BindToCurrentThread();
|
||||
SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
|
||||
SimHost.Send(new JsonObject { ["type"] = "started", ["ms"] = (long)(DateTime.Now - processStart).TotalMilliseconds });
|
||||
try
|
||||
{
|
||||
var parameters = entryPoint.GetParameters().Length == 0 ? null : new object[] { Array.Empty<string>() };
|
||||
entryPoint.Invoke(null, parameters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SimHost.ReportException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
console.Flush();
|
||||
SimHost.FlushChanges();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}) { Name = "UI" };
|
||||
uiThread.Start();
|
||||
|
||||
ReadActions();
|
||||
// 服务器关闭了标准输入:结束运行
|
||||
Environment.Exit(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>读取浏览器发来的操作,排队到界面线程执行</summary>
|
||||
private static void ReadActions()
|
||||
{
|
||||
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
||||
while (input.ReadLine() is { } line)
|
||||
{
|
||||
JsonObject? message;
|
||||
try
|
||||
{
|
||||
message = JsonNode.Parse(line) as JsonObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
switch ((string?)message?["type"])
|
||||
{
|
||||
case "action":
|
||||
if (SimHost.FindControl((string?)message!["id"] ?? "") is { } control)
|
||||
{
|
||||
var action = UserAction.FromJson(message);
|
||||
SimHost.Ui.Post(() => control.PerformUserAction(action));
|
||||
}
|
||||
break;
|
||||
case "reply":
|
||||
SimHost.CompleteRequest((int?)message!["requestId"] ?? 0, message["result"]?.DeepClone());
|
||||
break;
|
||||
case "stop":
|
||||
Environment.Exit(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user