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:
1 parent
17b35223aa
commit
83e5120377
309 files changed
+1505
-1499
No files matched your search
@@ -0,0 +1,35 @@
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary>
|
||||
public static class Crc32c
|
||||
{
|
||||
private static readonly uint[] s_table = BuildTable();
|
||||
|
||||
public static uint Compute(ReadOnlySpan<byte> data)
|
||||
{
|
||||
uint crc = uint.MaxValue;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
crc = s_table[(crc ^ b) & 0xFF] ^ (crc >> 8);
|
||||
}
|
||||
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
private static uint[] BuildTable()
|
||||
{
|
||||
var table = new uint[256];
|
||||
for (uint i = 0; i < 256; i++)
|
||||
{
|
||||
uint c = i;
|
||||
for (int k = 0; k < 8; k++)
|
||||
{
|
||||
c = (c & 1) != 0 ? (c >> 1) ^ 0x82F63B78u : c >> 1;
|
||||
}
|
||||
|
||||
table[i] = c;
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
|
||||
public sealed class ChunkClock
|
||||
{
|
||||
[JsonPropertyName("agent_mono_us")]
|
||||
public long AgentMonoUs { get; set; }
|
||||
|
||||
[JsonPropertyName("utc_ms")]
|
||||
public long UtcMs { get; set; }
|
||||
|
||||
/// <summary>Agent 时钟 − Probe 时钟(微秒)。</summary>
|
||||
[JsonPropertyName("probe_offset_us")]
|
||||
public long ProbeOffsetUs { get; set; }
|
||||
|
||||
/// <summary>Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。</summary>
|
||||
[JsonPropertyName("probe_drift_ppb")]
|
||||
public long ProbeDriftPpb { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ColumnHeader
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>u8 | u16 | u32 | u64 | i32 | i64</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "";
|
||||
|
||||
/// <summary>plain | delta</summary>
|
||||
[JsonPropertyName("enc")]
|
||||
public string Enc { get; set; } = "plain";
|
||||
|
||||
[JsonPropertyName("nullable")]
|
||||
public bool Nullable { get; set; }
|
||||
|
||||
/// <summary>ts_probe | ts_agent | ts_local,其他为空。</summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StreamHeader
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
[JsonPropertyName("columns")]
|
||||
public List<ColumnHeader> Columns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
|
||||
public sealed class RawChunkHeader
|
||||
{
|
||||
[JsonPropertyName("chunk_id")]
|
||||
public ChunkId ChunkId { get; set; }
|
||||
|
||||
[JsonPropertyName("container_version")]
|
||||
public int ContainerVersion { get; set; } = ProtocolVersion.Container;
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("task_id")]
|
||||
public TaskId? TaskId { get; set; }
|
||||
|
||||
[JsonPropertyName("test_id")]
|
||||
public TestId? TestId { get; set; }
|
||||
|
||||
[JsonPropertyName("phase")]
|
||||
public int? Phase { get; set; }
|
||||
|
||||
[JsonPropertyName("agent_id")]
|
||||
public NodeId AgentId { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_id")]
|
||||
public ProbeId? ProbeId { get; set; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>参数的完整快照(包含默认值)。</summary>
|
||||
[JsonPropertyName("params")]
|
||||
public Dictionary<string, JsonElement> Params { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("direction")]
|
||||
public string? Direction { get; set; }
|
||||
|
||||
[JsonPropertyName("start_utc_ms")]
|
||||
public long StartUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
/// <summary>Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。</summary>
|
||||
[JsonPropertyName("partial")]
|
||||
public bool Partial { get; set; }
|
||||
|
||||
[JsonPropertyName("clock")]
|
||||
public ChunkClock Clock { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("streams")]
|
||||
public List<StreamHeader> Streams { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。
|
||||
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? "");
|
||||
|
||||
public const string LinePrefix = "line:";
|
||||
}
|
||||
|
||||
public sealed class RawColumn
|
||||
{
|
||||
public RawColumn(string name, ColumnType type, ColumnEnc enc, bool nullable, ColumnRole role, long[] values, bool[]? valid)
|
||||
{
|
||||
Name = name;
|
||||
Type = type;
|
||||
Enc = enc;
|
||||
Nullable = nullable;
|
||||
Role = role;
|
||||
Values = values;
|
||||
Valid = valid;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public ColumnType Type { get; }
|
||||
|
||||
public ColumnEnc Enc { get; }
|
||||
|
||||
public bool Nullable { get; }
|
||||
|
||||
public ColumnRole Role { get; }
|
||||
|
||||
/// <summary>所有列统一用 long 存放(u64 按位保存)。缺失值对应的位置内容无意义,请先看 <see cref="IsValid"/>。</summary>
|
||||
public long[] Values { get; }
|
||||
|
||||
public bool[]? Valid { get; }
|
||||
|
||||
public int Count => Values.Length;
|
||||
|
||||
public bool IsValid(int row) => Valid is null || Valid[row];
|
||||
}
|
||||
|
||||
public sealed class RawStream
|
||||
{
|
||||
public RawStream(string name, int count, IReadOnlyList<RawColumn> columns)
|
||||
{
|
||||
Name = name;
|
||||
Count = count;
|
||||
Columns = columns;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public IReadOnlyList<RawColumn> Columns { get; }
|
||||
|
||||
public RawColumn? Find(string name)
|
||||
{
|
||||
foreach (RawColumn column in Columns)
|
||||
{
|
||||
if (column.Name == name)
|
||||
{
|
||||
return column;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RawChunk
|
||||
{
|
||||
public RawChunk(RawChunkHeader header, IReadOnlyList<RawStream> streams)
|
||||
{
|
||||
Header = header;
|
||||
Streams = streams;
|
||||
}
|
||||
|
||||
public RawChunkHeader Header { get; }
|
||||
|
||||
public IReadOnlyList<RawStream> Streams { get; }
|
||||
|
||||
public RawStream? Find(string name)
|
||||
{
|
||||
foreach (RawStream stream in Streams)
|
||||
{
|
||||
if (stream.Name == name)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
|
||||
/// 布局:<c>"MSRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
|
||||
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
|
||||
/// </summary>
|
||||
public static class RawChunkCodec
|
||||
{
|
||||
private const int PrefixBytes = 4 + 1 + 4;
|
||||
|
||||
private static ReadOnlySpan<byte> Magic => "MSRC"u8;
|
||||
|
||||
/// <summary>压缩之前的正文:<c>u32 header_len | header JSON | 各列数据</c>。布局变化时黄金字节测试会失败,提醒升级版本。</summary>
|
||||
public static byte[] EncodeBody(RawChunk chunk)
|
||||
{
|
||||
RawChunkHeader header = chunk.Header;
|
||||
header.ContainerVersion = ProtocolVersion.Container;
|
||||
header.Streams = [];
|
||||
foreach (RawStream stream in chunk.Streams)
|
||||
{
|
||||
var streamHeader = new StreamHeader { Name = stream.Name, Count = stream.Count };
|
||||
foreach (RawColumn column in stream.Columns)
|
||||
{
|
||||
streamHeader.Columns.Add(new ColumnHeader
|
||||
{
|
||||
Name = column.Name,
|
||||
Type = TypeName(column.Type),
|
||||
Enc = column.Enc == ColumnEnc.Delta ? "delta" : "plain",
|
||||
Nullable = column.Nullable,
|
||||
Role = RoleName(column.Role),
|
||||
});
|
||||
}
|
||||
|
||||
header.Streams.Add(streamHeader);
|
||||
}
|
||||
|
||||
byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MoShiroJsonContext.Default.RawChunkHeader);
|
||||
|
||||
using var body = new MemoryStream();
|
||||
Span<byte> length = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(length, (uint)headerJson.Length);
|
||||
body.Write(length);
|
||||
body.Write(headerJson);
|
||||
foreach (RawStream stream in chunk.Streams)
|
||||
{
|
||||
foreach (RawColumn column in stream.Columns)
|
||||
{
|
||||
WriteColumn(body, column);
|
||||
}
|
||||
}
|
||||
|
||||
return body.ToArray();
|
||||
}
|
||||
|
||||
public static byte[] Encode(RawChunk chunk)
|
||||
{
|
||||
byte[] plain = EncodeBody(chunk);
|
||||
using var compressed = new MemoryStream();
|
||||
using (var brotli = new BrotliStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
|
||||
{
|
||||
brotli.Write(plain);
|
||||
}
|
||||
|
||||
byte[] payload = compressed.ToArray();
|
||||
byte[] result = new byte[PrefixBytes + payload.Length];
|
||||
Magic.CopyTo(result);
|
||||
result[4] = ProtocolVersion.Container;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
|
||||
payload.CopyTo(result, PrefixBytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Result<RawChunk> Decode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < PrefixBytes || !data[..4].SequenceEqual(Magic))
|
||||
{
|
||||
return Corrupt("bad magic");
|
||||
}
|
||||
|
||||
if (data[4] != ProtocolVersion.Container)
|
||||
{
|
||||
return Corrupt($"unsupported container version {data[4]}");
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> payload = data[PrefixBytes..];
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(data[5..]) != Crc32c.Compute(payload))
|
||||
{
|
||||
return Corrupt("crc mismatch");
|
||||
}
|
||||
|
||||
byte[]? plain = Inflate(payload);
|
||||
if (plain is null)
|
||||
{
|
||||
return Corrupt("invalid or oversized compressed data");
|
||||
}
|
||||
|
||||
if (plain.Length < 4)
|
||||
{
|
||||
return Corrupt("truncated header length");
|
||||
}
|
||||
|
||||
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(plain);
|
||||
if (headerLength > plain.Length - 4)
|
||||
{
|
||||
return Corrupt("header length exceeds data");
|
||||
}
|
||||
|
||||
RawChunkHeader? header;
|
||||
try
|
||||
{
|
||||
header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MoShiroJsonContext.Default.RawChunkHeader);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Corrupt("header is not valid json");
|
||||
}
|
||||
|
||||
if (header is null || header.Streams.Count > ProtocolLimits.MaxStreamsPerChunk)
|
||||
{
|
||||
return Corrupt("header is missing or has too many streams");
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> columns = plain.AsSpan(4 + (int)headerLength);
|
||||
var streams = new List<RawStream>(header.Streams.Count);
|
||||
foreach (StreamHeader streamHeader in header.Streams)
|
||||
{
|
||||
if (streamHeader.Count is < 0 or > ProtocolLimits.MaxRecordsPerChunk || streamHeader.Columns.Count > ProtocolLimits.MaxColumnsPerStream)
|
||||
{
|
||||
return Corrupt($"stream {streamHeader.Name} has an invalid size");
|
||||
}
|
||||
|
||||
var cols = new List<RawColumn>(streamHeader.Columns.Count);
|
||||
foreach (ColumnHeader columnHeader in streamHeader.Columns)
|
||||
{
|
||||
if (!TryParseType(columnHeader.Type, out ColumnType type) || !TryParseEnc(columnHeader.Enc, out ColumnEnc enc))
|
||||
{
|
||||
return Corrupt($"column {columnHeader.Name} has an unknown type or encoding");
|
||||
}
|
||||
|
||||
RawColumn? column = ReadColumn(ref columns, columnHeader, type, enc, streamHeader.Count);
|
||||
if (column is null)
|
||||
{
|
||||
return Corrupt($"column {streamHeader.Name}.{columnHeader.Name} is truncated or out of range");
|
||||
}
|
||||
|
||||
cols.Add(column);
|
||||
}
|
||||
|
||||
streams.Add(new RawStream(streamHeader.Name, streamHeader.Count, cols));
|
||||
}
|
||||
|
||||
return columns.Length == 0 ? Result<RawChunk>.Ok(new RawChunk(header, streams)) : Corrupt("trailing bytes");
|
||||
}
|
||||
|
||||
private static Result<RawChunk> Corrupt(string message) =>
|
||||
Result<RawChunk>.Fail(MoShiroErrors.RawCorrupt, message);
|
||||
|
||||
private static byte[]? Inflate(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
using var input = new MemoryStream(payload.ToArray());
|
||||
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
output.Write(buffer, 0, read);
|
||||
if (output.Length > ProtocolLimits.MaxDecodedChunkBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteColumn(MemoryStream body, RawColumn column)
|
||||
{
|
||||
int count = column.Count;
|
||||
if (column.Nullable)
|
||||
{
|
||||
byte[] bitmap = new byte[(count + 7) / 8];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (column.IsValid(i))
|
||||
{
|
||||
bitmap[i >> 3] |= (byte)(1 << (i & 7));
|
||||
}
|
||||
}
|
||||
|
||||
body.Write(bitmap);
|
||||
}
|
||||
|
||||
bool signed = column.Type is ColumnType.I32 or ColumnType.I64;
|
||||
long previous = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!column.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long value = column.Values[i];
|
||||
if (column.Enc == ColumnEnc.Delta)
|
||||
{
|
||||
VarInt.Write(body, VarInt.ZigZag(value - previous));
|
||||
previous = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
VarInt.Write(body, signed ? VarInt.ZigZag(value) : (ulong)value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RawColumn? ReadColumn(ref ReadOnlySpan<byte> data, ColumnHeader header, ColumnType type, ColumnEnc enc, int count)
|
||||
{
|
||||
bool[]? valid = null;
|
||||
if (header.Nullable)
|
||||
{
|
||||
int bitmapBytes = (count + 7) / 8;
|
||||
if (data.Length < bitmapBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
valid = new bool[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
valid[i] = (data[i >> 3] & (1 << (i & 7))) != 0;
|
||||
}
|
||||
|
||||
data = data[bitmapBytes..];
|
||||
}
|
||||
|
||||
bool signed = type is ColumnType.I32 or ColumnType.I64;
|
||||
long[] values = new long[count];
|
||||
long previous = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (valid is not null && !valid[i])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!VarInt.TryRead(data, out ulong raw, out int consumed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
data = data[consumed..];
|
||||
long value;
|
||||
if (enc == ColumnEnc.Delta)
|
||||
{
|
||||
value = previous + VarInt.UnZigZag(raw);
|
||||
previous = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = signed ? VarInt.UnZigZag(raw) : unchecked((long)raw);
|
||||
}
|
||||
|
||||
if (!InRange(type, value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
values[i] = value;
|
||||
}
|
||||
|
||||
return new RawColumn(header.Name, type, enc, header.Nullable, ParseRole(header.Role), values, valid);
|
||||
}
|
||||
|
||||
private static bool InRange(ColumnType type, long value) => type switch
|
||||
{
|
||||
ColumnType.U8 => value is >= 0 and <= byte.MaxValue,
|
||||
ColumnType.U16 => value is >= 0 and <= ushort.MaxValue,
|
||||
ColumnType.U32 => value is >= 0 and <= uint.MaxValue,
|
||||
ColumnType.I32 => value is >= int.MinValue and <= int.MaxValue,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
public static string TypeName(ColumnType type) => type switch
|
||||
{
|
||||
ColumnType.U8 => "u8",
|
||||
ColumnType.U16 => "u16",
|
||||
ColumnType.U32 => "u32",
|
||||
ColumnType.U64 => "u64",
|
||||
ColumnType.I32 => "i32",
|
||||
_ => "i64",
|
||||
};
|
||||
|
||||
private static bool TryParseType(string text, out ColumnType type)
|
||||
{
|
||||
switch (text)
|
||||
{
|
||||
case "u8": type = ColumnType.U8; return true;
|
||||
case "u16": type = ColumnType.U16; return true;
|
||||
case "u32": type = ColumnType.U32; return true;
|
||||
case "u64": type = ColumnType.U64; return true;
|
||||
case "i32": type = ColumnType.I32; return true;
|
||||
case "i64": type = ColumnType.I64; return true;
|
||||
default: type = default; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseEnc(string text, out ColumnEnc enc)
|
||||
{
|
||||
switch (text)
|
||||
{
|
||||
case "plain": enc = ColumnEnc.Plain; return true;
|
||||
case "delta": enc = ColumnEnc.Delta; return true;
|
||||
default: enc = default; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? RoleName(ColumnRole role) => role switch
|
||||
{
|
||||
ColumnRole.TsProbe => "ts_probe",
|
||||
ColumnRole.TsAgent => "ts_agent",
|
||||
ColumnRole.TsLocal => "ts_local",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static ColumnRole ParseRole(string? text) => text switch
|
||||
{
|
||||
"ts_probe" => ColumnRole.TsProbe,
|
||||
"ts_agent" => ColumnRole.TsAgent,
|
||||
"ts_local" => ColumnRole.TsLocal,
|
||||
_ => ColumnRole.None,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary>
|
||||
public sealed class StreamBuilder
|
||||
{
|
||||
private readonly StreamSpec _spec;
|
||||
private readonly List<long>[] _values;
|
||||
private readonly List<bool>[] _valid;
|
||||
|
||||
public StreamBuilder(StreamSpec spec)
|
||||
{
|
||||
_spec = spec;
|
||||
_values = new List<long>[spec.Columns.Count];
|
||||
_valid = new List<bool>[spec.Columns.Count];
|
||||
for (int i = 0; i < _values.Length; i++)
|
||||
{
|
||||
_values[i] = [];
|
||||
_valid[i] = [];
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get; private set; }
|
||||
|
||||
/// <summary>添加一行;<c>null</c> 表示缺失值(只允许用于可空列)。</summary>
|
||||
public StreamBuilder Add(params long?[] row)
|
||||
{
|
||||
if (row.Length != _values.Length)
|
||||
{
|
||||
throw new ArgumentException($"stream {_spec.Name} expects {_values.Length} values, got {row.Length}", nameof(row));
|
||||
}
|
||||
|
||||
for (int i = 0; i < row.Length; i++)
|
||||
{
|
||||
if (row[i] is null && !_spec.Columns[i].Nullable)
|
||||
{
|
||||
throw new ArgumentException($"column {_spec.Columns[i].Name} is not nullable", nameof(row));
|
||||
}
|
||||
|
||||
_values[i].Add(row[i] ?? 0);
|
||||
_valid[i].Add(row[i] is not null);
|
||||
}
|
||||
|
||||
Count++;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RawStream Build()
|
||||
{
|
||||
var columns = new List<RawColumn>(_values.Length);
|
||||
for (int i = 0; i < _values.Length; i++)
|
||||
{
|
||||
ColumnSpec spec = _spec.Columns[i];
|
||||
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. _values[i]], spec.Nullable ? [.. _valid[i]] : null));
|
||||
}
|
||||
|
||||
return new RawStream(_spec.Name, Count, columns);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user