refactor!: rename the project from Monitor to MoShiro

Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
This commit is contained in:
justme committed 2026-10-07 01:49:04 +08:00
1 parent 17b35223aa
commit 83e5120377
309 files changed
+1505 -1499

No files matched your search

@@ -0,0 +1,128 @@
namespace MoShiro.Protocol;
[Flags]
public enum Scope
{
Line = 1,
Target = 2,
}
public enum ColumnType
{
U8,
U16,
U32,
U64,
I32,
I64,
}
public enum ColumnEnc
{
Plain,
Delta,
}
/// <summary>时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
public enum ColumnRole
{
None,
TsProbe,
TsAgent,
TsLocal,
}
public sealed record ColumnSpec(string Name, ColumnType Type, ColumnEnc Enc = ColumnEnc.Plain, bool Nullable = false, ColumnRole Role = ColumnRole.None);
/// <summary>数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。</summary>
public enum Side
{
Probe,
Agent,
}
/// <summary>
/// 一个记录流(一张表)。<see cref="Driver"/> 是行的主导方:行由它发出的包或事件决定,另一侧的列按 <see cref="Key"/>(例如 <c>seq</c>)补充,
/// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 <see cref="Key"/> 的流只有主导方一侧的列,原样透传。
/// 每一列属于哪一侧由它的时钟决定(<c>TsProbe</c> 属于 Probe,<c>TsAgent</c> 属于 Agent),其他列属于主导方。
/// <see cref="Union"/> 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。
/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。
/// </summary>
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false)
{
public Side OwnerOf(ColumnSpec column) => column.Role switch
{
ColumnRole.TsProbe => Side.Probe,
ColumnRole.TsAgent => Side.Agent,
_ => Driver,
};
/// <summary>
/// 该侧记录的列(不含连接键:键两侧都有,由行的编号表示)。<see cref="Union"/> 流两侧各自记录完整的行(例如各自套接字的内核采样),
/// 所以每一侧拥有所有列。
/// </summary>
public IReadOnlyList<ColumnSpec> ColumnsOf(Side side) =>
[.. Columns.Where(c => c.Name != Key && (Union || OwnerOf(c) == side))];
}
/// <summary>积木组合(只描述,不含代码;节点按这个描述组装执行图)。</summary>
public sealed record BlockSet(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList<string> Samplers);
/// <summary>
/// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。
/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
/// </summary>
public sealed record KindSpec(
string Id,
string Title,
string Description,
Scope Scopes,
BlockSet Blocks,
IReadOnlyList<ParamSpec> Params,
IReadOnlyList<StreamSpec> Streams,
Func<ParamValues, long> BudgetBitsPerSecond)
{
public StreamSpec? FindStream(string name)
{
foreach (StreamSpec stream in Streams)
{
if (stream.Name == name)
{
return stream;
}
}
return null;
}
}
public static class Col
{
public static ColumnSpec U8(string name, bool nullable = false) => new(name, ColumnType.U8, ColumnEnc.Plain, nullable);
public static ColumnSpec U16(string name, bool nullable = false) => new(name, ColumnType.U16, ColumnEnc.Plain, nullable);
public static ColumnSpec U32(string name, bool nullable = false) => new(name, ColumnType.U32, ColumnEnc.Plain, nullable);
public static ColumnSpec U64(string name, ColumnEnc enc = ColumnEnc.Plain) => new(name, ColumnType.U64, enc);
/// <summary>单调递增的序号:差分编码。</summary>
public static ColumnSpec Seq(string name) => new(name, ColumnType.U32, ColumnEnc.Delta);
public static ColumnSpec TsProbe(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsProbe);
public static ColumnSpec TsAgent(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsAgent);
public static ColumnSpec TsLocal(string name) => new(name, ColumnType.I64, ColumnEnc.Delta, false, ColumnRole.TsLocal);
}
/// <summary>带宽预算的估算函数(bit/s)。</summary>
public static class Bandwidth
{
private const int UdpOverheadBytes = 28;
private const int TcpOverheadBytes = 52;
public static long UdpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + UdpOverheadBytes) * 8) * 2;
public static long TcpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + TcpOverheadBytes) * 8) * 2;
}
@@ -0,0 +1,161 @@
namespace MoShiro.Protocol;
/// <summary>
/// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 <see cref="All"/>;
/// 参数一律用 <see cref="P"/> 声明默认值和上下限。
/// </summary>
public static class Kinds
{
private static readonly string[] s_directions = ["fwd", "rev", "bidir"];
private static readonly StreamSpec s_echoMsg = new(
"msg",
[
Col.Seq("seq"), Col.U16("size"),
Col.TsProbe("t1"), Col.TsAgent("t2", nullable: true), Col.TsAgent("t3", nullable: true), Col.TsProbe("t4", nullable: true),
],
Side.Probe,
"seq");
private static readonly StreamSpec s_tcpInfo = new(
"tcp_info",
[
Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), Col.U32("rttvar_us"), Col.U32("cwnd"),
Col.U32("retrans_total"), Col.U32("segs_out"), Col.U64("bytes_acked"),
],
Union: true);
public static readonly KindSpec UdpEcho = new(
"udp_echo",
"UDP echo",
"Fixed-rate UDP echo with four timestamps per packet.",
Scope.Line,
new BlockSet("udp", "fixed", "fixed", "echo", []),
[
P.Int("rate_hz", 10, 1, 200, "Hz"),
P.Int("size", 64, 44, 1400, "B"),
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly KindSpec UdpTrain = new(
"udp_train",
"UDP train",
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
Scope.Line,
new BlockSet("udp", "ladder", "fixed", "sink", []),
[
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
P.Int("size", 1250, 200, 1400, "B"),
P.Int("duration_s", 10, 1, 120, "s"),
P.Enum("direction", "rev", s_directions),
P.Int("port", 7443, 1, 65535),
],
[
new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsAgent("t_send"), Col.TsProbe("t_recv", nullable: true)], Side.Agent, "seq"),
new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsProbe("start_ts"), Col.TsProbe("end_ts")]),
],
p => (long)(p.GetNumber("mbps") * 1_000_000));
public static readonly KindSpec TcpEcho = new(
"tcp_echo",
"TCP echo",
"Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.",
Scope.Line,
new BlockSet("tcp", "fixed", "uniform", "echo", ["tcp_info"]),
[
P.Int("rate_hz", 20, 1, 200, "Hz"),
P.Int("min", 32, 16, 1400, "B"),
P.Int("max", 64, 16, 1400, "B"),
P.Int("tcp_info_ms", 100, 10, 1000, "ms"),
P.Int("port", 7444, 1, 65535),
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
p => Bandwidth.TcpEcho(p.GetInt("rate_hz"), p.GetInt("max")));
public static readonly KindSpec TcpBulk = new(
"tcp_bulk",
"TCP bulk",
"Bulk TCP transfer on several streams with periodic byte counters and tcp_info.",
Scope.Line,
new BlockSet("tcp", "fixed", "fixed", "source", ["tcp_info"]),
[
P.Int("streams", 4, 1, 16),
P.Int("duration_s", 4, 1, 120, "s"),
P.Enum("direction", "rev", s_directions),
P.Int("sample_ms", 100, 20, 1000, "ms"),
P.Int("port", 7444, 1, 65535),
P.Bool("tls", true),
],
[
new StreamSpec("bytes", [Col.TsProbe("ts"), Col.U8("conn"), Col.U64("bytes")]),
s_tcpInfo,
],
_ => 0);
public static readonly KindSpec Connect = new(
"connect",
"Connect",
"Repeated TCP and TLS connection establishment with per-phase timings and outcome.",
Scope.Line | Scope.Target,
new BlockSet("tcp", "fixed", "fixed", "connect", []),
[
P.Int("per_minute", 6, 1, 60),
P.Bool("tls", true),
P.Int("timeout_ms", 5000, 500, 30000, "ms"),
P.Int("port", 7444, 1, 65535),
],
[
new StreamSpec(
"conn",
[Col.Seq("attempt"), Col.TsProbe("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]),
],
p => p.GetInt("per_minute") * 4000 / 60);
public static readonly KindSpec Hold = new(
"hold",
"Hold",
"A long-lived TLS connection with periodic heartbeats; records resets and lifetime.",
Scope.Line,
new BlockSet("tls", "fixed", "fixed", "hold", []),
[
P.Int("heartbeat_s", 30, 5, 300, "s"),
P.Int("port", 7444, 1, 65535),
],
[
new StreamSpec("hold", [Col.TsProbe("ts"), Col.U8("event"), Col.U32("lifetime_ms")]),
],
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
public static readonly KindSpec IcmpEcho = new(
"icmp_echo",
"ICMP echo",
"Probe or Agent side ICMP echo to a target address.",
Scope.Target,
new BlockSet("icmp", "fixed", "fixed", "echo", []),
[
P.Int("rate_hz", 1, 1, 20, "Hz"),
P.Int("size", 64, 44, 1400, "B"),
],
[
new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsLocal("t1"), new ColumnSpec("t4", ColumnType.I64, ColumnEnc.Delta, true, ColumnRole.TsLocal)]),
],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly IReadOnlyList<KindSpec> All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho];
public static KindSpec? Find(string id)
{
foreach (KindSpec kind in All)
{
if (kind.Id == id)
{
return kind;
}
}
return null;
}
}
@@ -0,0 +1,223 @@
using System.Globalization;
using System.Text.Json;
namespace MoShiro.Protocol;
public enum ParamType
{
Int,
Number,
Bool,
Enum,
}
/// <summary>
/// 参数定义:类型、默认值、上下限。节点与 Backend 使用同一套校验,禁止写没有上下限的参数。
/// </summary>
public abstract record ParamSpec(string Name, string Description, string? Unit)
{
public abstract ParamType Type { get; }
/// <summary>用于目录展示的默认值。</summary>
public abstract JsonElement DefaultValue { get; }
/// <summary>校验并规范化一个参数值;失败时返回错误说明。</summary>
public abstract bool TryNormalize(JsonElement input, out JsonElement normalized, out string error);
protected static JsonElement Of(long value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Int64);
protected static JsonElement Of(double value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Double);
protected static JsonElement Of(bool value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Boolean);
protected static JsonElement Of(string value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.String);
}
public sealed record IntParam(string Name, long Default, long Min, long Max, string? Unit = null, string Description = "")
: ParamSpec(Name, Description, Unit)
{
public override ParamType Type => ParamType.Int;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind != JsonValueKind.Number || !input.TryGetInt64(out long value))
{
error = "must be an integer";
return false;
}
if (value < Min || value > Max)
{
error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}";
return false;
}
normalized = Of(value);
return true;
}
}
public sealed record NumberParam(string Name, double Default, double Min, double Max, string? Unit = null, string Description = "")
: ParamSpec(Name, Description, Unit)
{
public override ParamType Type => ParamType.Number;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind != JsonValueKind.Number || !input.TryGetDouble(out double value) || !double.IsFinite(value))
{
error = "must be a number";
return false;
}
if (value < Min || value > Max)
{
error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}";
return false;
}
normalized = Of(value);
return true;
}
}
public sealed record BoolParam(string Name, bool Default, string Description = "")
: ParamSpec(Name, Description, null)
{
public override ParamType Type => ParamType.Bool;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
error = "must be a boolean";
return false;
}
normalized = Of(input.GetBoolean());
return true;
}
}
public sealed record EnumParam(string Name, string Default, IReadOnlyList<string> Values, string Description = "")
: ParamSpec(Name, Description, null)
{
public override ParamType Type => ParamType.Enum;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
string? value = input.ValueKind == JsonValueKind.String ? input.GetString() : null;
if (value is null || !Values.Contains(value))
{
error = "must be one of: " + string.Join(", ", Values);
return false;
}
normalized = Of(value);
return true;
}
}
/// <summary>参数声明的简写。</summary>
public static class P
{
public static ParamSpec Int(string name, long @default, long min, long max, string? unit = null, string description = "") =>
new IntParam(name, @default, min, max, unit, description);
public static ParamSpec Number(string name, double @default, double min, double max, string? unit = null, string description = "") =>
new NumberParam(name, @default, min, max, unit, description);
public static ParamSpec Bool(string name, bool @default, string description = "") => new BoolParam(name, @default, description);
public static ParamSpec Enum(string name, string @default, string[] values, string description = "") =>
new EnumParam(name, @default, values, description);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(long))]
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
[System.Text.Json.Serialization.JsonSerializable(typeof(bool))]
[System.Text.Json.Serialization.JsonSerializable(typeof(string))]
internal sealed partial class ParamJson : System.Text.Json.Serialization.JsonSerializerContext;
/// <summary>已经校验并补全了默认值的参数。</summary>
public sealed class ParamValues
{
private readonly Dictionary<string, JsonElement> _values;
public ParamValues(Dictionary<string, JsonElement> values)
{
_values = values;
}
public IReadOnlyDictionary<string, JsonElement> Values => _values;
public long GetInt(string name) => _values[name].GetInt64();
public double GetNumber(string name) => _values[name].GetDouble();
public bool GetBool(string name) => _values[name].GetBoolean();
public string GetString(string name) => _values[name].GetString()!;
}
public sealed record ParamResolution(ParamValues? Values, IReadOnlyDictionary<string, string> Errors)
{
public bool IsValid => Errors.Count == 0;
}
public static class ParamResolver
{
/// <summary>未知参数视为错误;缺失的参数取默认值。</summary>
public static ParamResolution Resolve(IReadOnlyList<ParamSpec> specs, IReadOnlyDictionary<string, JsonElement>? input)
{
var errors = new Dictionary<string, string>();
var values = new Dictionary<string, JsonElement>(specs.Count);
foreach (ParamSpec spec in specs)
{
if (input is not null && input.TryGetValue(spec.Name, out JsonElement raw))
{
if (spec.TryNormalize(raw, out JsonElement normalized, out string error))
{
values[spec.Name] = normalized;
}
else
{
errors[spec.Name] = error;
}
}
else
{
values[spec.Name] = spec.DefaultValue;
}
}
if (input is not null)
{
foreach (string key in input.Keys)
{
if (!values.ContainsKey(key) && !errors.ContainsKey(key))
{
errors[key] = "is not a parameter of this kind";
}
}
}
return new ParamResolution(errors.Count == 0 ? new ParamValues(values) : null, errors);
}
}