diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index 19c7f83..45e1eff 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -1820,12 +1820,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } /// - /// Emits inline string read from type code. Handles all string wire formats. - /// FixStr (range 34-65) is checked first as hot path for short strings. - /// Remaining codes use switch for O(1) JIT jump-table dispatch: - /// String=16, StringInterned=17, StringEmpty=18, StringInternFirst=19, Null=0. - /// This eliminates the sequential if-else chain that penalized StringInterned - /// (the hot path for repeated interned strings) with 4 comparisons. + /// Emits inline string read from type code. Handles all string wire formats: + /// FixStr (UTF-8 short, 103-134), FixStrAscii (ASCII short, 135-166), String (UTF-8 long, 91), + /// StringAscii (ASCII long, 167), StringInterned, StringEmpty, StringInternFirst, Null. + /// FixStr/FixStrAscii are checked first as hot paths for short strings — ASCII variant + /// dispatches to ReadAsciiBytesAsString (byte→char widen, no UTF-8 decode). /// private static void EmitReadString(StringBuilder sb, string a, string tc, string i) { @@ -1835,8 +1834,14 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine($"{i} var flen = BinaryTypeCode.DecodeFixStrLength({tc});"); sb.AppendLine($"{i} {a} = flen == 0 ? string.Empty : context.ReadStringUtf8(flen);"); sb.AppendLine($"{i}}}"); - // Switch gives O(1) dispatch via JIT jump table for codes 0, 16-19. - // StringInterned (17) is the hot path for repeated interned strings — no longer buried at 4th else-if. + // FixStrAscii — ASCII short strings, byte→char widen path (skips UTF-8 decode). + sb.AppendLine($"{i}else if (BinaryTypeCode.IsFixStrAscii({tc}))"); + sb.AppendLine($"{i}{{"); + sb.AppendLine($"{i} var falen = BinaryTypeCode.DecodeFixStrAsciiLength({tc});"); + sb.AppendLine($"{i} {a} = falen == 0 ? string.Empty : context.ReadAsciiBytesAsString(falen);"); + sb.AppendLine($"{i}}}"); + // Switch gives O(1) dispatch via JIT jump table for the long markers. + // StringInterned is the hot path for repeated interned strings. sb.AppendLine($"{i}else switch ({tc})"); sb.AppendLine($"{i}{{"); sb.AppendLine($"{i} case BinaryTypeCode.StringInterned:"); @@ -1848,6 +1853,12 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine($"{i} {a} = slen == 0 ? string.Empty : context.ReadStringUtf8(slen);"); sb.AppendLine($"{i} break;"); sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} case BinaryTypeCode.StringAscii:"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var salen = (int)context.ReadVarUInt();"); + sb.AppendLine($"{i} {a} = salen == 0 ? string.Empty : context.ReadAsciiBytesAsString(salen);"); + sb.AppendLine($"{i} break;"); + sb.AppendLine($"{i} }}"); sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirst:"); sb.AppendLine($"{i} {{"); sb.AppendLine($"{i} context.DisableStringCaching();"); diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs index 9fd809c..792b858 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs @@ -418,6 +418,44 @@ public static partial class AcBinaryDeserializer return DecodeUtf8(length); } + /// + /// Reads ASCII bytes from the wire and widens them to a UTF-16 + /// string. Caller MUST guarantee the payload is pure ASCII — typically by dispatching on a + /// FixStrAscii / StringAscii marker (the marker IS the ASCII-validity contract). + /// + /// + /// Skips the UTF-8 decoder entirely — every byte maps 1:1 to a char via simple widening. + /// Uses .GetString for the widen — Latin1 is byte→char + /// 1:1 (codepoints 0..255), and ASCII (0..127) is a strict subset, so for marker-validated + /// ASCII payloads the result is identical to a hand-rolled (char)b widen but uses + /// the BCL's SIMD-accelerated implementation (single-shot allocation + memcpy-class widen). + /// + /// Beats a string.Create + scalar callback widen by avoiding the lambda-state passing + /// and JIT-trust on auto-vectorization across the callback boundary. + /// + /// FastWire mode never emits ASCII markers — they're a Compact-mode-only optimization. If + /// FastWire encounters one (cross-mode wire mismatch), the read still works but the FastWire + /// raw-memcpy fast path doesn't apply. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string ReadAsciiBytesAsString(int byteLength) + { + if (byteLength == 0) return string.Empty; + + EnsureAvailable(byteLength); + + // Cached short-string path (WASM optimization) — leverages full-content hash + Ascii.Equals + // verification (which is a no-op fast path on ASCII content). + if (_useStringCaching && byteLength <= _maxCachedStringLength) + { + return ReadStringUtf8Cached(byteLength); + } + + var pos = _position; + _position += byteLength; + return Encoding.Latin1.GetString(_buffer, pos, byteLength); + } + /// /// Custom UTF-8 → UTF-16 string decoder. /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs index c41224e..209cf47 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; @@ -100,6 +101,7 @@ public static partial class AcBinaryDeserializer readers[BinaryTypeCode.StringInterned] = static (ctx, _, _) => ctx.GetInternedString((int)ctx.ReadVarUInt()); readers[BinaryTypeCode.StringEmpty] = static (_, _, _) => string.Empty; readers[BinaryTypeCode.StringInternFirst] = static (ctx, _, _) => ReadAndRegisterInternedString(ctx); + readers[BinaryTypeCode.StringAscii] = static (ctx, _, _) => ReadPlainStringAscii(ctx); readers[BinaryTypeCode.DateTime] = static (ctx, _, _) => ctx.ReadDateTimeUnsafe(); readers[BinaryTypeCode.DateTimeOffset] = static (ctx, _, _) => ctx.ReadDateTimeOffsetUnsafe(); readers[BinaryTypeCode.TimeSpan] = static (ctx, _, _) => ctx.ReadTimeSpanUnsafe(); @@ -125,6 +127,14 @@ public static partial class AcBinaryDeserializer readers[code] = CreateFixStrReader(length); } + // Register FixStrAscii readers (135..166) — pure-ASCII short-string fast path. + // The marker IS the validity contract — reader byte→char widens without UTF-8 decode. + for (var code = BinaryTypeCode.FixStrAsciiBase; code <= BinaryTypeCode.FixStrAsciiMax; code++) + { + var length = BinaryTypeCode.DecodeFixStrAsciiLength(code); + readers[code] = CreateFixStrAsciiReader(length); + } + // Register FixObj slot readers (0..SlotCount-1) for (var slot = 0; slot < BinaryTypeCode.SlotCount; slot++) readers[slot] = CreateFixObjReader(slot); @@ -144,6 +154,18 @@ public static partial class AcBinaryDeserializer return (ctx, _, _) => ctx.ReadStringUtf8(length); } + /// + /// Creates a reader for FixStrAscii with the given byte length (also char count, ASCII = 1:1). + /// Skips UTF-8 decode — byte→char widen only. Marker enforces ASCII validity. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TypeReader CreateFixStrAsciiReader(int length) where TInput : struct, IBinaryInputBase + { + if (length == 0) return static (_, _, _) => string.Empty; + + return (ctx, _, _) => ctx.ReadAsciiBytesAsString(length); + } + /// /// Creates a reader for FixObj slot (0..SlotCount-1). /// @@ -353,6 +375,57 @@ public static partial class AcBinaryDeserializer } } + /// + /// Drains a end-to-end into a fresh + /// and deserializes one message. Background Task.Run deserializes incrementally while + /// the calling thread drains the reader. For long-lived multi-message scenarios use the + /// overloads directly. + /// + public static async Task DeserializeFromPipeReaderAsync<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(PipeReader reader, AcBinarySerializerOptions options, CancellationToken cancellationToken = default) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2); + var deserTask = Task.Run(() => Deserialize(input, options), cancellationToken); + await DrainPipeReaderToInputAsync(reader, input, cancellationToken).ConfigureAwait(false); + return await deserTask.ConfigureAwait(false); + } + + /// + /// Non-generic Type-based counterpart to . + /// For runtime-typed scenarios (MVC formatters, plugin frameworks). + /// + public static async Task DeserializeFromPipeReaderAsync(PipeReader reader, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options, CancellationToken cancellationToken = default) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2); + var deserTask = Task.Run(() => Deserialize(input, targetType, options), cancellationToken); + await DrainPipeReaderToInputAsync(reader, input, cancellationToken).ConfigureAwait(false); + return await deserTask.ConfigureAwait(false); + } + + /// + /// Pumps a into a via repeated + /// calls; signals + /// at end-of-stream (in finally so consumer always wakes up on cancellation / exception). + /// + private static async Task DrainPipeReaderToInputAsync(PipeReader reader, AsyncPipeReaderInput input, CancellationToken cancellationToken) + { + try + { + while (true) + { + var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + foreach (var segment in result.Buffer) input.Feed(segment.Span); + reader.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + } + finally + { + input.Complete(); + } + } + /// /// Internal: Deserialize with any TInput (multi-segment or other future input types). /// @@ -1026,11 +1099,22 @@ public static partial class AcBinaryDeserializer propInfo.SetValue(target, length == 0 ? string.Empty : context.ReadStringUtf8(length)); return true; } + if (BinaryTypeCode.IsFixStrAscii(typeCode)) + { + var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode); + propInfo.SetValue(target, length == 0 ? string.Empty : context.ReadAsciiBytesAsString(length)); + return true; + } if (typeCode == BinaryTypeCode.String) { propInfo.SetValue(target, ReadPlainString(context)); return true; } + if (typeCode == BinaryTypeCode.StringAscii) + { + propInfo.SetValue(target, ReadPlainStringAscii(context)); + return true; + } if (typeCode == BinaryTypeCode.StringEmpty) { propInfo.SetValue(target, string.Empty); @@ -1090,6 +1174,13 @@ public static partial class AcBinaryDeserializer return length == 0 ? string.Empty : context.ReadStringUtf8(length); } + // Handle FixStrAscii (short ASCII strings — byte→char widen, no UTF-8 decode) + if (BinaryTypeCode.IsFixStrAscii(typeCode)) + { + var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode); + return length == 0 ? string.Empty : context.ReadAsciiBytesAsString(length); + } + var reader = TypeReaderTable.Readers[typeCode]; if (reader != null) { @@ -1113,6 +1204,19 @@ public static partial class AcBinaryDeserializer return context.ReadStringUtf8(length); } + /// + /// Reads a long ASCII string payload (after the StringAscii marker has been consumed). + /// Wire format: [VarUInt byteCount][ASCII bytes]. Byte→char widen, no UTF-8 decode. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ReadPlainStringAscii(BinaryDeserializationContext context) + where TInput : struct, IBinaryInputBase + { + var length = (int)context.ReadVarUInt(); + if (length == 0) return string.Empty; + return context.ReadAsciiBytesAsString(length); + } + /// /// Read interned string (StringInternFirst marker) and register in cache at specified index. /// Wire format: [StringInternFirst][VarUInt cacheIndex][VarUInt length][UTF8 bytes] @@ -1989,6 +2093,15 @@ public static partial class AcBinaryDeserializer return; } + // Handle FixStrAscii (short ASCII strings — same skip layout as FixStr, just different marker range) + if (BinaryTypeCode.IsFixStrAscii(typeCode)) + { + var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode); + if (length > 0) + context.Skip(length); + return; + } + switch (typeCode) { case BinaryTypeCode.True: @@ -2034,6 +2147,8 @@ public static partial class AcBinaryDeserializer context.Skip(16); return; case BinaryTypeCode.String: + case BinaryTypeCode.StringAscii: + // Same skip layout: [VarUInt byteCount][bytes]. ASCII vs UTF-8 distinction is content-only. SkipPlainString(context); return; case BinaryTypeCode.StringInterned: diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index a7cea6d..f838f7b 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -713,6 +713,112 @@ public static partial class AcBinarySerializer _position += bytesWritten; } + /// + /// Writes a non-empty string with marker-dispatch: detects ASCII vs UTF-8 in-place from the + /// encoder's byte count and emits the appropriate wire marker (FixStrAscii, + /// FixStr, StringAscii, or String). The reader uses the marker as an + /// ASCII-validity contract — pure-ASCII payloads skip UTF-8 decode entirely (byte→char widen). + /// + /// + /// Layout (Compact wire): [marker: 1 byte][optional VarUInt byteCount][encoded bytes] + /// — VarUInt is omitted for FixStr/FixStrAscii (length is encoded in the marker). + /// + /// ASCII detection is free: bytesWritten == charLength after a UTF-8 encode is a + /// necessary AND sufficient condition for the input being pure ASCII (every UTF-16 char + /// < 0x80 produces exactly 1 UTF-8 byte; non-ASCII chars always produce 2-4 bytes). + /// + /// Caller MUST guarantee non-empty input (value.Length > 0) — empty strings are + /// handled by the higher-level WriteString via the StringEmpty marker. + /// + public void WriteStringWithDispatch(string value) + { + if (FastWire) + { + // FastWire: char count (VarUInt) + raw UTF-16 memcopy. ASCII detection adds no value + // here — the wire size is identical (2 bytes/char) and the read path is memcpy-based, + // so the encoder/decoder UTF-8 cost (which the ASCII marker would skip) doesn't apply. + WriteByte(BinaryTypeCode.String); + var charLenF = value.Length; + var byteLenF = charLenF * 2; + WriteVarUInt((uint)charLenF); + EnsureCapacity(byteLenF); + MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLenF)); + _position += byteLenF; + return; + } + + var charLength = value.Length; + + // Hot-path split: encode position is chosen to MINIMIZE post-encode shifts. + // + // • charLength ≤ 31 → MIGHT be FixStr (bytesWritten ≤ 31) or long String (multibyte + // expansion). Encode optimistically at savedPos+1 (FixStr position). FixStr hit ⇒ 0 shift, + // only marker byte write. Long-fallback (rare, requires Hungarian/CJK chars in a + // short-char string AND post-expand size > 31) ⇒ shift bytes RIGHT by 1 (since the + // long lane needs 1 VarUInt byte after the marker; charLength ≤ 31 ⇒ maxBytes ≤ 124 + // ⇒ VarUInt size = 1). + // + // • charLength > 31 → ALWAYS long String (bytesWritten ≥ charLength > 31). Use full + // D-2 layout [marker][reserveVarUInt][bytes], encode at savedPos+1+reserveVarUInt. + // Backfill compacts only when actual VarUInt size < reserved (rare). + if (charLength <= BinaryTypeCode.FixStrMaxLength) + { + var maxBytesShort = charLength * 4; // ≤ 124, fits in 1-byte VarUInt + EnsureCapacity(2 + maxBytesShort); // marker + 1-byte VarUInt + bytes (worst case) + + var savedPosShort = _position; + var bytesWrittenShort = EncodeUtf8SinglePass( + value.AsSpan(), + _buffer.AsSpan(savedPosShort + 1, maxBytesShort)); + var isAsciiShort = bytesWrittenShort == charLength; + + if (bytesWrittenShort <= BinaryTypeCode.FixStrMaxLength) + { + // Hot path: FixStr hit → bytes already at savedPos+1, no shift. + _buffer[savedPosShort] = isAsciiShort + ? BinaryTypeCode.EncodeFixStrAscii(bytesWrittenShort) + : BinaryTypeCode.EncodeFixStr(bytesWrittenShort); + _position = savedPosShort + 1 + bytesWrittenShort; + } + else + { + // Cold: multibyte expansion pushed bytes > 31 → become long String/StringAscii. + // Shift bytes right by 1 to insert the 1-byte VarUInt slot. + _buffer.AsSpan(savedPosShort + 1, bytesWrittenShort) + .CopyTo(_buffer.AsSpan(savedPosShort + 2, bytesWrittenShort)); + _buffer[savedPosShort] = isAsciiShort ? BinaryTypeCode.StringAscii : BinaryTypeCode.String; + _position = savedPosShort + 1; + WriteVarUIntUnsafe((uint)bytesWrittenShort); + _position += bytesWrittenShort; + } + + return; + } + + // Long path: charLength > 31 ⇒ bytesWritten > 31 ⇒ always String / StringAscii. + // D-2 layout [marker:1][VarUInt slot:reserveVarUInt][bytes], encode at savedPos+1+reserveVarUInt. + var maxBytes = charLength * 4; + var reserveVarUInt = VarUIntSize((uint)maxBytes); + + EnsureCapacity(1 + reserveVarUInt + maxBytes); + + var savedPos = _position; + var encodeStart = savedPos + 1 + reserveVarUInt; + var bytesWritten = EncodeUtf8SinglePass(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes)); + var isAscii = bytesWritten == charLength; + + _buffer[savedPos] = isAscii ? BinaryTypeCode.StringAscii : BinaryTypeCode.String; + var actualVarUIntSize = VarUIntSize((uint)bytesWritten); + if (actualVarUIntSize < reserveVarUInt) + { + var shift = reserveVarUInt - actualVarUIntSize; + _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten)); + } + _position = savedPos + 1; + WriteVarUIntUnsafe((uint)bytesWritten); + _position += bytesWritten; + } + public void WriteFixStr(string value) { var length = value.Length; diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index c987bbc..ef3c6d0 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -352,6 +352,51 @@ public static partial class AcBinarySerializer } } + /// + /// Non-generic Type-based . For + /// runtime-typed scenarios (plugin frameworks, ASP.NET ModelBinding, MVC formatters). The + /// parameter is the declared-type hint; the body uses + /// value.GetType() for the runtime polymorphism path, identical to the generic version. + /// + public static byte[] Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, AcBinarySerializerOptions options) + { + if (value == null) return [BinaryTypeCode.Null]; + + var runtimeType = value.GetType(); + var context = AcquireArrayOutputContext(options); + + try + { + if (options.UseGeneratedCode) + { + var wrapper = context.GetWrapper(runtimeType); + if (wrapper.GeneratedWriter != null) + { + ScanForDuplicates(value, runtimeType, context); + context.WriteHeader(); + WriteObject(value, wrapper, context, 0); + + if (options.UseCompression != Lz4CompressionMode.None) + return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression); + return context.Output.ToArray(context._buffer, context._position); + } + } + + var actualValue = ConvertExpressionValue(value, ref runtimeType); + ScanForDuplicates(actualValue, runtimeType, context); + context.WriteHeader(); + WriteValue(actualValue, runtimeType, context, 0); + + if (options.UseCompression != Lz4CompressionMode.None) + return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression); + return context.Output.ToArray(context._buffer, context._position); + } + finally + { + ReturnContext(context, options); + } + } + /// /// Runs only the scan pass (ScanForDuplicates) without writing. /// For benchmarking scan pass overhead in isolation. @@ -433,6 +478,65 @@ public static partial class AcBinarySerializer } } + /// + /// Non-generic Type-based counterpart to + /// . Body identical + /// — is a declared-type hint only; runtime polymorphism uses value.GetType(). + /// + public static int Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, IBufferWriter writer, AcBinarySerializerOptions options) + { + if (value == null) + { + var span = writer.GetSpan(1); + span[0] = BinaryTypeCode.Null; + writer.Advance(1); + return 1; + } + + var runtimeType = value.GetType(); + var context = BinarySerializationContextPool.Get(options); + context.Output = new BufferWriterBinaryOutput(writer, options.BufferWriterChunkSize); + context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd); + + try + { + if (options.UseGeneratedCode) + { + var wrapper = context.GetWrapper(runtimeType); + if (wrapper.GeneratedWriter != null) + { + ScanForDuplicates(value, runtimeType, context); + context.WriteHeader(); + WriteObject(value, wrapper, context, 0); + + if (options.UseCompression != Lz4CompressionMode.None) + ThrowCompressionNotSupportedWithBufferWriter(context); + + var bytesWritten = context.Output.GetTotalPosition(context._position); + context.Output.Flush(context._buffer, context._position); + return bytesWritten; + } + } + + var actualValue = ConvertExpressionValue(value, ref runtimeType); + ScanForDuplicates(actualValue, runtimeType, context); + context.WriteHeader(); + WriteValue(actualValue, runtimeType, context, 0); + + if (options.UseCompression != Lz4CompressionMode.None) + ThrowCompressionNotSupportedWithBufferWriter(context); + + var totalBytesWritten = context.Output.GetTotalPosition(context._position); + context.Output.Flush(context._buffer, context._position); + return totalBytesWritten; + } + finally + { + context.Output = default; + ReturnContext(context, options); + } + } + /// /// Serialize to a as a chunked stream — pure AcBinary /// bytes are written via in raw mode (no per-chunk header). @@ -494,6 +598,14 @@ public static partial class AcBinarySerializer public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false); + /// + /// Non-generic Type-based counterpart to + /// . + /// For runtime-typed scenarios (MVC formatters, plugin frameworks). + /// + public static int SerializeChunked(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) + => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false); + /// /// Serialize a value into a chunked stream where each chunk carries a self-describing /// frame header — [201][UINT16 size][data] per chunk, with a final [202] @@ -539,6 +651,13 @@ public static partial class AcBinarySerializer public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true); + /// + /// Non-generic Type-based counterpart to + /// . + /// + public static int SerializeChunkedFramed(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) + => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true); + /// /// Internal flush-tunable framed PipeWriter overload — used by AyCode.Services /// (SignalR hub protocol) on Kestrel transport output, which is parallel-capable. External @@ -1325,32 +1444,15 @@ public static partial class AcBinarySerializer #endif } - // FastWire: skip FixStr optimization (UTF-8 specific), write String marker + UTF-16 data - if (context.FastWire) - { - context.WriteByte(BinaryTypeCode.String); - context.WriteStringUtf8(value); - return; - } - - // BASELINE TEMP: ASCII fast paths disabled — every string takes the pure UTF-8 D-2 path - // (String marker + VarUInt byte count + UTF-8 bytes). Used to measure custom UTF-8 decoder - // performance in isolation, without FixStr-vs-String dispatch interference. Re-enable the - // FixStr dispatch below once the decoder optimization is benchmarked and verified. - // - //// Fast path for short strings: check length first (cheap), then ASCII - //// FixStr encodes type+length in single byte for strings <= 31 chars - //var length = value.Length; - //if (length <= BinaryTypeCode.FixStrMaxLength) - //{ - // // For short strings, use direct ASCII copy (avoids double validation) - // context.WriteFixStrDirect(value); - // return; - //} - - // All strings (short and long) — standard UTF-8 encoding via D-2 single-pass path - context.WriteByte(BinaryTypeCode.String); - context.WriteStringUtf8(value); + // Marker-dispatch (ACCORE-BIN-T-M3R7): WriteStringWithDispatch encodes UTF-8 once, detects + // ASCII via bytesWritten == charLength, and emits the optimal wire marker: + // • bytesWritten ≤ 31 + ASCII → FixStrAscii (single byte marker + length, ASCII payload) + // • bytesWritten ≤ 31 + UTF-8 → FixStr (single byte marker + length, UTF-8 payload) + // • bytesWritten > 31 + ASCII → StringAscii (marker + VarUInt length + ASCII payload) + // • bytesWritten > 31 + UTF-8 → String (marker + VarUInt length + UTF-8 payload) + // Reader dispatches on the ASCII marker to skip UTF-8 decode (byte→char widen only). + // FastWire path is handled inside WriteStringWithDispatch (no marker dispatch — UTF-16 raw). + context.WriteStringWithDispatch(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/AyCode.Core/docs/BINARY/BINARY_TODO.md b/AyCode.Core/docs/BINARY/BINARY_TODO.md index be806e9..fc759e5 100644 --- a/AyCode.Core/docs/BINARY/BINARY_TODO.md +++ b/AyCode.Core/docs/BINARY/BINARY_TODO.md @@ -361,7 +361,23 @@ Symmetric to T8K3's analysis: - `leaveOpen` parameter behaves per the System.Text.Json / MessagePack convention across all three modes. ## ACCORE-BIN-T-N9G6: Add non-generic `Type`-based `Serialize(object, Type, ...)` overloads -**Priority:** P2 · **Type:** Feature · **Related:** `ACCORE-BIN-T-T8K3` +**Priority:** P2 · **Type:** Feature · **Status:** Closed (2026-05-04) · **Related:** `ACCORE-BIN-T-T8K3` + +### Resolution + +Added in `AcBinarySerializer.cs`: +- `Serialize(object?, Type, opts)` → `byte[]` +- `Serialize(object?, Type, IBufferWriter, opts)` → `int` +- `SerializeChunked(object?, Type, PipeWriter, opts)` → `int` +- `SerializeChunkedFramed(object?, Type, PipeWriter, opts)` → `int` + +Added in `AcBinaryDeserializer.cs`: +- `DeserializeFromPipeReaderAsync(PipeReader, opts, ct)` → `Task` +- `DeserializeFromPipeReaderAsync(PipeReader, Type, opts, ct)` → `Task` + +The `Deserialize(byte[], Type, opts)` / `Deserialize(ReadOnlySequence, Type, opts)` / `Deserialize(AsyncPipeReaderInput, Type, opts)` overloads already existed. + +Consumed by ASP.NET Core MVC formatter package (`AyCode.Services/Mvc/`) — `AcBinaryInputFormatter`, `AcBinaryOutputFormatter`, `AddAcBinaryFormatters` extension. Media type: `application/vnd.acbinary`. Plugin frameworks, ASP.NET ModelBinding, DI middleware, and DataContractSerializer-style "generic-API container" use-cases need to serialize an `object` whose type is known only at runtime. Current AcBinary surface forces a reflection trampoline through the generic `Serialize`: diff --git a/AyCode.Services/AyCode.Services.csproj b/AyCode.Services/AyCode.Services.csproj index 84c0277..4473311 100644 --- a/AyCode.Services/AyCode.Services.csproj +++ b/AyCode.Services/AyCode.Services.csproj @@ -6,6 +6,7 @@ + diff --git a/AyCode.Services/Mvc/AcBinaryInputFormatter.cs b/AyCode.Services/Mvc/AcBinaryInputFormatter.cs new file mode 100644 index 0000000..c068bda --- /dev/null +++ b/AyCode.Services/Mvc/AcBinaryInputFormatter.cs @@ -0,0 +1,56 @@ +using System.IO.Pipelines; +using AyCode.Core.Serializers.Binaries; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace AyCode.Services.Mvc; + +/// +/// ASP.NET Core MVC InputFormatter for AcBinary wire format. Reads request body via PipeReader, +/// drains into AsyncPipeReaderInput, deserializes to ModelType. Standard ProblemDetails error +/// flow on failure (ModelState.AddModelError → 400 + application/problem+json). +/// +public class AcBinaryInputFormatter : InputFormatter +{ + /// Vendor media type. application/vnd.acbinary by default. + public const string DefaultMediaType = "application/vnd.acbinary"; + + private readonly AcBinarySerializerOptions _options; + private readonly ILogger? _logger; + + public AcBinaryInputFormatter(AcBinarySerializerOptions? options = null, ILogger? logger = null) + { + _options = options ?? AcBinarySerializerOptions.Default; + _logger = logger; + SupportedMediaTypes.Add(DefaultMediaType); + } + + public override async Task ReadRequestBodyAsync(InputFormatterContext context) + { + if (context is null) throw new ArgumentNullException(nameof(context)); + + var ct = context.HttpContext.RequestAborted; + var reader = PipeReader.Create(context.HttpContext.Request.Body); + try + { + var model = await AcBinaryDeserializer.DeserializeFromPipeReaderAsync(reader, context.ModelType, _options, ct).ConfigureAwait(false); + return await InputFormatterResult.SuccessAsync(model).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "AcBinary deserialization failed for type {ModelType}", context.ModelType); + context.ModelState.TryAddModelError(context.ModelName ?? string.Empty, ex.Message); + return await InputFormatterResult.FailureAsync().ConfigureAwait(false); + } + finally + { + await reader.CompleteAsync().ConfigureAwait(false); + } + } + + protected override bool CanReadType(Type type) => true; +} diff --git a/AyCode.Services/Mvc/AcBinaryMvcBuilderExtensions.cs b/AyCode.Services/Mvc/AcBinaryMvcBuilderExtensions.cs new file mode 100644 index 0000000..afed90c --- /dev/null +++ b/AyCode.Services/Mvc/AcBinaryMvcBuilderExtensions.cs @@ -0,0 +1,58 @@ +using AyCode.Core.Serializers.Binaries; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace AyCode.Services.Mvc; + +/// +/// MVC builder extensions to register AcBinary input/output formatters. +/// Inserts at index 0 — AcBinary is preferred when the client's Accept header allows. +/// +public static class AcBinaryMvcBuilderExtensions +{ + /// + /// Registers AcBinary input + output formatters on the MVC pipeline. Pass + /// to customize ; otherwise + /// is used. + /// + public static IMvcBuilder AddAcBinaryFormatters(this IMvcBuilder builder, Action? configure = null) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + + var options = AcBinarySerializerOptions.Default; + if (configure != null) + { + options = new AcBinarySerializerOptions(); + configure(options); + } + + builder.Services.Configure(opts => + { + opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options)); + opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options)); + }); + + return builder; + } + + /// + public static IMvcCoreBuilder AddAcBinaryFormatters(this IMvcCoreBuilder builder, Action? configure = null) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + + var options = AcBinarySerializerOptions.Default; + if (configure != null) + { + options = new AcBinarySerializerOptions(); + configure(options); + } + + builder.Services.Configure(opts => + { + opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options)); + opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options)); + }); + + return builder; + } +} diff --git a/AyCode.Services/Mvc/AcBinaryOutputFormatter.cs b/AyCode.Services/Mvc/AcBinaryOutputFormatter.cs new file mode 100644 index 0000000..00652f9 --- /dev/null +++ b/AyCode.Services/Mvc/AcBinaryOutputFormatter.cs @@ -0,0 +1,43 @@ +using System.IO.Pipelines; +using AyCode.Core.Serializers.Binaries; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace AyCode.Services.Mvc; + +/// +/// ASP.NET Core MVC OutputFormatter for AcBinary wire format. Wraps Response.Body as PipeWriter, +/// serializes via AcBinarySerializer.SerializeChunked (raw mode — pure AcBinary bytes, no per-chunk +/// framing). Auto-detected flush strategy on the underlying StreamPipeWriter (sequential per chunk). +/// +public class AcBinaryOutputFormatter : OutputFormatter +{ + /// Vendor media type. application/vnd.acbinary by default. + public const string DefaultMediaType = "application/vnd.acbinary"; + + private readonly AcBinarySerializerOptions _options; + + public AcBinaryOutputFormatter(AcBinarySerializerOptions? options = null) + { + _options = options ?? AcBinarySerializerOptions.Default; + SupportedMediaTypes.Add(DefaultMediaType); + } + + public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context) + { + if (context is null) throw new ArgumentNullException(nameof(context)); + + var ct = context.HttpContext.RequestAborted; + var pipeWriter = PipeWriter.Create(context.HttpContext.Response.Body); + try + { + AcBinarySerializer.SerializeChunked(context.Object, context.ObjectType ?? typeof(object), pipeWriter, _options); + } + finally + { + await pipeWriter.CompleteAsync().ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + } + } + + protected override bool CanWriteType(Type? type) => true; +}