[LOADED_DOCS: 2 files, no new loads]
AcBinary: ASCII string opt, Type-based API, MVC support - Add ASCII-optimized string serialization/deserialization with new FixStrAscii/StringAscii markers for fast byte→char widening. - Introduce non-generic Type-based Serialize/Deserialize overloads for runtime-typed scenarios (plugin, MVC, model binding). - Add AcBinaryInputFormatter/OutputFormatter and AddAcBinaryFormatters extensions for ASP.NET Core MVC integration. - Update project references and close ACCORE-BIN-T-N9G6 in docs.
This commit is contained in:
parent
265b89da0a
commit
7b94d81485
|
|
@ -1820,12 +1820,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ReadAsciiBytesAsString</c> (byte→char widen, no UTF-8 decode).
|
||||
/// </summary>
|
||||
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();");
|
||||
|
|
|
|||
|
|
@ -418,6 +418,44 @@ public static partial class AcBinaryDeserializer
|
|||
return DecodeUtf8(length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="byteLength"/> 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
|
||||
/// <c>FixStrAscii</c> / <c>StringAscii</c> marker (the marker IS the ASCII-validity contract).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skips the UTF-8 decoder entirely — every byte maps 1:1 to a char via simple widening.
|
||||
/// Uses <see cref="Encoding.Latin1"/>.<c>GetString</c> 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 <c>(char)b</c> widen but uses
|
||||
/// the BCL's SIMD-accelerated implementation (single-shot allocation + memcpy-class widen).
|
||||
///
|
||||
/// Beats a <c>string.Create</c> + 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom UTF-8 → UTF-16 string decoder.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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<TInput>(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<TInput>(length);
|
||||
}
|
||||
|
||||
// Register FixObj slot readers (0..SlotCount-1)
|
||||
for (var slot = 0; slot < BinaryTypeCode.SlotCount; slot++) readers[slot] = CreateFixObjReader<TInput>(slot);
|
||||
|
||||
|
|
@ -144,6 +154,18 @@ public static partial class AcBinaryDeserializer
|
|||
return (ctx, _, _) => ctx.ReadStringUtf8(length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static TypeReader<TInput> CreateFixStrAsciiReader<TInput>(int length) where TInput : struct, IBinaryInputBase
|
||||
{
|
||||
if (length == 0) return static (_, _, _) => string.Empty;
|
||||
|
||||
return (ctx, _, _) => ctx.ReadAsciiBytesAsString(length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a reader for FixObj slot (0..SlotCount-1).
|
||||
/// </summary>
|
||||
|
|
@ -353,6 +375,57 @@ public static partial class AcBinaryDeserializer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains a <see cref="PipeReader"/> end-to-end into a fresh <see cref="AsyncPipeReaderInput"/>
|
||||
/// and deserializes one message. Background <c>Task.Run</c> deserializes incrementally while
|
||||
/// the calling thread drains the reader. For long-lived multi-message scenarios use the
|
||||
/// <see cref="AsyncPipeReaderInput"/> overloads directly.
|
||||
/// </summary>
|
||||
public static async Task<T?> 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<T>(input, options), cancellationToken);
|
||||
await DrainPipeReaderToInputAsync(reader, input, cancellationToken).ConfigureAwait(false);
|
||||
return await deserTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic <c>Type</c>-based counterpart to <see cref="DeserializeFromPipeReaderAsync{T}"/>.
|
||||
/// For runtime-typed scenarios (MVC formatters, plugin frameworks).
|
||||
/// </summary>
|
||||
public static async Task<object?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pumps a <see cref="PipeReader"/> into a <see cref="AsyncPipeReaderInput"/> via repeated
|
||||
/// <see cref="AsyncPipeReaderInput.Feed"/> calls; signals <see cref="AsyncPipeReaderInput.Complete"/>
|
||||
/// at end-of-stream (in finally so consumer always wakes up on cancellation / exception).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: Deserialize with any TInput (multi-segment or other future input types).
|
||||
/// </summary>
|
||||
|
|
@ -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<TInput>.Readers[typeCode];
|
||||
if (reader != null)
|
||||
{
|
||||
|
|
@ -1113,6 +1204,19 @@ public static partial class AcBinaryDeserializer
|
|||
return context.ReadStringUtf8(length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a long ASCII string payload (after the <c>StringAscii</c> marker has been consumed).
|
||||
/// Wire format: <c>[VarUInt byteCount][ASCII bytes]</c>. Byte→char widen, no UTF-8 decode.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string ReadPlainStringAscii<TInput>(BinaryDeserializationContext<TInput> context)
|
||||
where TInput : struct, IBinaryInputBase
|
||||
{
|
||||
var length = (int)context.ReadVarUInt();
|
||||
if (length == 0) return string.Empty;
|
||||
return context.ReadAsciiBytesAsString(length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
|
|
|
|||
|
|
@ -713,6 +713,112 @@ public static partial class AcBinarySerializer
|
|||
_position += bytesWritten;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>FixStrAscii</c>,
|
||||
/// <c>FixStr</c>, <c>StringAscii</c>, or <c>String</c>). The reader uses the marker as an
|
||||
/// ASCII-validity contract — pure-ASCII payloads skip UTF-8 decode entirely (byte→char widen).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Layout (Compact wire): <c>[marker: 1 byte][optional VarUInt byteCount][encoded bytes]</c>
|
||||
/// — VarUInt is omitted for FixStr/FixStrAscii (length is encoded in the marker).
|
||||
///
|
||||
/// ASCII detection is free: <c>bytesWritten == charLength</c> 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 (<c>value.Length > 0</c>) — empty strings are
|
||||
/// handled by the higher-level <c>WriteString</c> via the <c>StringEmpty</c> marker.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -352,6 +352,51 @@ public static partial class AcBinarySerializer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic <c>Type</c>-based <see cref="Serialize{T}(T, AcBinarySerializerOptions)"/>. For
|
||||
/// runtime-typed scenarios (plugin frameworks, ASP.NET ModelBinding, MVC formatters). The
|
||||
/// <paramref name="type"/> parameter is the declared-type hint; the body uses
|
||||
/// <c>value.GetType()</c> for the runtime polymorphism path, identical to the generic version.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs only the scan pass (ScanForDuplicates) without writing.
|
||||
/// For benchmarking scan pass overhead in isolation.
|
||||
|
|
@ -433,6 +478,65 @@ public static partial class AcBinarySerializer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic <c>Type</c>-based counterpart to
|
||||
/// <see cref="Serialize{T}(T, IBufferWriter{byte}, AcBinarySerializerOptions)"/>. Body identical
|
||||
/// — <paramref name="type"/> is a declared-type hint only; runtime polymorphism uses <c>value.GetType()</c>.
|
||||
/// </summary>
|
||||
public static int Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, IBufferWriter<byte> 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<BufferWriterBinaryOutput>.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to a <see cref="System.IO.Pipelines.Pipe"/> as a chunked stream — pure AcBinary
|
||||
/// bytes are written via <see cref="AsyncPipeWriterOutput"/> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic <c>Type</c>-based counterpart to
|
||||
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.
|
||||
/// For runtime-typed scenarios (MVC formatters, plugin frameworks).
|
||||
/// </summary>
|
||||
public static int SerializeChunked(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
|
||||
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a value into a chunked stream where each chunk carries a self-describing
|
||||
/// frame header — <c>[201][UINT16 size][data]</c> per chunk, with a final <c>[202]</c>
|
||||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic <c>Type</c>-based counterpart to
|
||||
/// <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.
|
||||
/// </summary>
|
||||
public static int SerializeChunkedFramed(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
|
||||
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
|
||||
|
||||
/// <summary>
|
||||
/// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c>
|
||||
/// (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)]
|
||||
|
|
|
|||
|
|
@ -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<byte>, opts)` → `int`
|
||||
- `SerializeChunked(object?, Type, PipeWriter, opts)` → `int`
|
||||
- `SerializeChunkedFramed(object?, Type, PipeWriter, opts)` → `int`
|
||||
|
||||
Added in `AcBinaryDeserializer.cs`:
|
||||
- `DeserializeFromPipeReaderAsync<T>(PipeReader, opts, ct)` → `Task<T?>`
|
||||
- `DeserializeFromPipeReaderAsync(PipeReader, Type, opts, ct)` → `Task<object?>`
|
||||
|
||||
The `Deserialize(byte[], Type, opts)` / `Deserialize(ReadOnlySequence<byte>, 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<T>`:
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<Import Project="..//AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class AcBinaryInputFormatter : InputFormatter
|
||||
{
|
||||
/// <summary>Vendor media type. <c>application/vnd.acbinary</c> by default.</summary>
|
||||
public const string DefaultMediaType = "application/vnd.acbinary";
|
||||
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly ILogger<AcBinaryInputFormatter>? _logger;
|
||||
|
||||
public AcBinaryInputFormatter(AcBinarySerializerOptions? options = null, ILogger<AcBinaryInputFormatter>? logger = null)
|
||||
{
|
||||
_options = options ?? AcBinarySerializerOptions.Default;
|
||||
_logger = logger;
|
||||
SupportedMediaTypes.Add(DefaultMediaType);
|
||||
}
|
||||
|
||||
public override async Task<InputFormatterResult> 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AyCode.Services.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// MVC builder extensions to register AcBinary input/output formatters.
|
||||
/// Inserts at index 0 — AcBinary is preferred when the client's Accept header allows.
|
||||
/// </summary>
|
||||
public static class AcBinaryMvcBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers AcBinary input + output formatters on the MVC pipeline. Pass <paramref name="configure"/>
|
||||
/// to customize <see cref="AcBinarySerializerOptions"/>; otherwise
|
||||
/// <see cref="AcBinarySerializerOptions.Default"/> is used.
|
||||
/// </summary>
|
||||
public static IMvcBuilder AddAcBinaryFormatters(this IMvcBuilder builder, Action<AcBinarySerializerOptions>? 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<MvcOptions>(opts =>
|
||||
{
|
||||
opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options));
|
||||
opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options));
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AddAcBinaryFormatters(IMvcBuilder, Action{AcBinarySerializerOptions}?)"/>
|
||||
public static IMvcCoreBuilder AddAcBinaryFormatters(this IMvcCoreBuilder builder, Action<AcBinarySerializerOptions>? 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<MvcOptions>(opts =>
|
||||
{
|
||||
opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options));
|
||||
opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options));
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using System.IO.Pipelines;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace AyCode.Services.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class AcBinaryOutputFormatter : OutputFormatter
|
||||
{
|
||||
/// <summary>Vendor media type. <c>application/vnd.acbinary</c> by default.</summary>
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue