Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ dotnet_diagnostic.MA0046.severity = none
# 选项校验用 nameof(属性) 作为 ArgumentOutOfRangeException 参数名:校验对象是属性而非方法参数
dotnet_diagnostic.MA0015.severity = none

# 兼容 shim 层:一个文件聚合多个小类型是刻意设计(与 #if 块对应)
[src/StreamFrame/Compatibility/**.cs]
dotnet_diagnostic.MA0048.severity = none

# 测试侧兼容层同理
[test/StreamFrame.Tests/TestCompat*.cs]
dotnet_diagnostic.MA0048.severity = none

# 测试项目:xUnit 无同步上下文,ConfigureAwait 与比较器重载建议均为噪音
[test/**.cs]
dotnet_diagnostic.MA0002.severity = none
Expand Down
21 changes: 18 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ permissions:

jobs:
build-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# 现代 .NET 在 linux 跑;net48(验证 netstandard2.0 资产)需要 Windows 运行时
- os: ubuntu-latest
frameworks: "net8.0 net10.0"
- os: windows-latest
frameworks: "net48"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7

Expand All @@ -26,5 +35,11 @@ jobs:
- name: Build
run: dotnet build StreamFrame.slnx -c Release

- name: Test
run: dotnet test StreamFrame.slnx -c Release --no-build
- name: Test (${{ matrix.frameworks }})
shell: bash
env:
FRAMEWORKS: ${{ matrix.frameworks }}
run: |
for fw in $FRAMEWORKS; do
dotnet test StreamFrame.slnx -c Release --no-build -f $fw
done
44 changes: 37 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,42 @@ permissions:
id-token: write # OIDC Trusted Publishing 换取 nuget.org 短时 API key

jobs:
# 测试矩阵:所有 OS×TFM 组合通过后才允许发布
test:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
frameworks: "net8.0 net10.0"
- os: windows-latest
frameworks: "net48"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7

- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
8.0.x
10.0.x

- name: Build
run: dotnet build StreamFrame.slnx -c Release

- name: Test (${{ matrix.frameworks }})
shell: bash
env:
FRAMEWORKS: ${{ matrix.frameworks }}
run: |
for fw in $FRAMEWORKS; do
dotnet test StreamFrame.slnx -c Release --no-build -f $fw
done

release:
needs: test
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand All @@ -31,7 +66,6 @@ jobs:
10.0.x

- name: Verify tag matches csproj versions
if: startsWith(github.ref, 'refs/tags/')
run: |
version="${GITHUB_REF_NAME#v}"
for proj in src/StreamFrame/StreamFrame.csproj src/StreamFrame.Protocols.Xml/StreamFrame.Protocols.Xml.csproj; do
Expand All @@ -56,7 +90,6 @@ jobs:
# ---------- 以下步骤仅在 tag 推送时执行 ----------

- name: Extract changelog section for this version
if: startsWith(github.ref, 'refs/tags/')
id: notes
run: |
version="${GITHUB_REF_NAME#v}"
Expand All @@ -73,7 +106,7 @@ jobs:
fi

- name: Create Release (from CHANGELOG)
if: startsWith(github.ref, 'refs/tags/') && steps.notes.outputs.has_notes == 'true'
if: steps.notes.outputs.has_notes == 'true'
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
Expand All @@ -84,7 +117,7 @@ jobs:
artifacts/*.snupkg

- name: Create Release (auto-generated notes)
if: startsWith(github.ref, 'refs/tags/') && steps.notes.outputs.has_notes != 'true'
if: steps.notes.outputs.has_notes != 'true'
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
Expand All @@ -95,7 +128,6 @@ jobs:
artifacts/*.snupkg

- name: Check NUGET_USER variable
if: startsWith(github.ref, 'refs/tags/')
run: |
if [ -z "${NUGET_USER}" ]; then
echo "::error::NUGET_USER 为空。Settings → Secrets and variables → Actions → Variables 添加 NUGET_USER(nuget.org 用户名,注意是 Variables 不是 Secrets)。"
Expand All @@ -105,14 +137,12 @@ jobs:
NUGET_USER: ${{ vars.NUGET_USER }}

- name: NuGet Login (OIDC)
if: startsWith(github.ref, 'refs/tags/')
id: login
uses: NuGet/login@v1
with:
user: ${{ vars.NUGET_USER }}

- name: Push to nuget.org
if: startsWith(github.ref, 'refs/tags/')
run: |
dotnet nuget push "artifacts/*.nupkg" \
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
Expand Down
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,24 @@

## [Unreleased]

### 变更
- CI Actions 升级到 Node 24 运行时:`actions/checkout@v7`、`actions/setup-dotnet@v6`、`softprops/action-gh-release@v3`(消除 Node 20 弃用告警)。
### 新增
- 暂无

### 修复
- 暂无

## [2.1.0] - 2026-08-27

### 新增
- 多目标框架(第一阶段):`net8.0;net10.0`——net10 为当前 LTS(支持至 2028-11),CI 双框架全量测试;net9+ 目标采用 `System.Threading.Lock`。
- **多目标框架:`netstandard2.0;net8.0;net10.0`**——打开 .NET Framework 4.6.2+/Unity/Mono 受众(设备通讯/WMS 场景存量巨大)。ns2.0 资产经 net48 全量测试(真实 TCP 回环)验证;CI 矩阵 Ubuntu(net8/net10)+ Windows(net48);发布流水线改为"测试矩阵全过 → 再发布"。兼容要点:PolySharp 语言 polyfill、Socket TaskExtensions 回退 + 可取消等待、KeepAlive 经 SIO_KEEPALIVE_VALS、StxEtx 定界器改手工扫描(SequenceReader 无 ns2.0 包资产)。net9+ 目标采用 `System.Threading.Lock`。
- 帧定界基准测试 `bench/StreamFrame.Benchmarks`(BenchmarkDotNet):LengthPrefix/StxEtx 的流式 vs 纯函数编码对比与切帧吞吐,为"流式零拷贝"说法提供可复现数据。
- 项目 LOGO(`docs/logo/`,SVG 母版 + PNG),README 双语版接入展示、两个 NuGet 包启用包图标(随本版本发布生效)。
- README 新增 CI 构建状态徽章与"支持框架"矩阵;main 分支保护(必须走 PR 且 CI 通过、禁 force push/删除、强制线性历史)。
- 英文版 README(`README.en.md`,与中文版互链),便于国际用户检索与阅读。

### 变更
- CI Actions 升级到 Node 24 运行时:`actions/checkout@v7`、`actions/setup-dotnet@v6`、`softprops/action-gh-release@v3`(消除 Node 20 弃用告警)。
- `GetMessages`/发送 worker 改用 `WaitToReadAsync+TryRead` 等价循环(ns2.0 的 Channels 包无 `ReadAllAsync`;各目标行为一致)。
- 帧定界基准测试 `bench/StreamFrame.Benchmarks`(BenchmarkDotNet):LengthPrefix/StxEtx 的流式 vs 纯函数编码对比与切帧吞吐,为"流式零拷贝"说法提供可复现数据。
- 项目 LOGO(`docs/logo/`,SVG 母版 + PNG),README 双语版接入展示、两个 NuGet 包启用包图标(随下个版本发布生效)。
- README 新增 CI 构建状态徽章;main 分支保护(必须走 PR 且 CI 通过、禁 force push/删除、强制线性历史)。
Expand Down Expand Up @@ -98,7 +111,8 @@
- 发布自动化:`release.yml`(tag 自动建 GitHub Release)、`publish-nuget.yml`(OIDC Trusted Publishing 推送 nuget.org)。
- 单元测试 25 个 + 三场景端到端 demo。

[Unreleased]: https://github.com/CSJ608/StreamFrame/compare/v2.0.0...HEAD
[Unreleased]: https://github.com/CSJ608/StreamFrame/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/CSJ608/StreamFrame/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/CSJ608/StreamFrame/compare/v1.2.0...v2.0.0
[1.2.0]: https://github.com/CSJ608/StreamFrame/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/CSJ608/StreamFrame/compare/v1.0.1...v1.1.0
Expand Down
10 changes: 10 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ Measured with BenchmarkDotNet (details and how to reproduce in [bench/README.md]
- **Streaming encode**: halves per-frame heap allocation (single vs. double buffer); 25–35% faster for small payloads, on par for large ones;
- **Frame decoding**: `LengthPrefixFramer` ≈10 ns/frame vs `StxEtxFramer` ≈1.1 µs/frame (byte-by-byte scanning) — prefer length-prefix for high-throughput scenarios.

## Supported frameworks

| Package target | Runtime |
|---|---|
| `net10.0` (recommended, LTS until 2028-11) | .NET 10 |
| `net8.0` (LTS until 2026-11) | .NET 8 |
| `netstandard2.0` | .NET Framework 4.6.2+, Unity, Mono, ... |

The `netstandard2.0` asset is validated by the **full net48 test suite** (real TCP loopback); TCP KeepAlive parameters are set via `SIO_KEEPALIVE_VALS` on .NET Framework. CI runs all tests on Ubuntu (net8/net10) and Windows (net48).

## Dependencies

- [System.IO.Pipelines](https://www.nuget.org/packages/System.IO.Pipelines)
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ BenchmarkDotNet 实测(详见 [bench/README.md](bench/README.md),可本地
- **流式编码**:每帧堆分配减半(单缓冲 vs 双缓冲),小负载耗时快 25–35%,大负载持平;
- **切帧吞吐**:`LengthPrefixFramer` ≈10 ns/帧,`StxEtxFramer` ≈1.1 µs/帧(逐字节扫描)——高吞吐场景优先长度前缀。

## 支持框架

| 包目标 | 运行环境 |
|---|---|
| `net10.0`(推荐,LTS 至 2028-11) | .NET 10 |
| `net8.0`(LTS 至 2026-11) | .NET 8 |
| `netstandard2.0` | .NET Framework 4.6.2+、Unity、Mono 等 |

`netstandard2.0` 资产经 **net48 全量测试套件**(真实 TCP 回环)验证;TCP KeepAlive 参数在 .NET Framework 上通过 `SIO_KEEPALIVE_VALS` 设置。CI 在 Ubuntu(net8/net10)与 Windows(net48)双平台运行全部测试。

## 依赖

- [System.IO.Pipelines](https://www.nuget.org/packages/System.IO.Pipelines)
Expand Down
9 changes: 9 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ socket --ReceiveAsync--> pipe.Writer --[Pipe]--> FrameDecoder.RunAsync

**原始字节与诊断(1.2.0)**:`RawBytesReceived/Sent` 全量输出(含被定界器丢弃的噪声字节),发送侧按实际写出分片回调;两者与 `ConnectionChanged`、`FrameError` 的用户回调异常均被隔离,不反噬会话。`IFrameDiscardReporting`(可选接口,内置两个 codec 实现)让定界器精确上报重同步丢弃的字节,经 `FrameError(DiscardedByResync)` 交上层调试。

### netstandard2.0 兼容层(2.1.0)

多目标 `netstandard2.0;net8.0;net10.0`:
- **PolySharp**(PrivateAssets):Range/Index/init 等语言级 polyfill,不进包。
- **`Compatibility/NetStandard20Shims.cs`**:`Task.WaitAsync` 等价物;Socket 的 Memory 收发回退到 `SocketTaskExtensions`——System.Net.Sockets 4.3.0 没有带 CancellationToken 的重载,用 3 参重载 + "可取消等待"包装(netfx 本就无法中止已提交的 socket I/O,取消仅放弃等待);TCP KeepAlive 参数经 `SIO_KEEPALIVE_VALS` 设置(netfx 无 TcpKeepAliveTime/Interval 选项名)。
- **StxEtx 定界器改为手工扫描**:`SequenceReader<T>` 从未发布 netstandard2.0 包资产。单段缓冲走 Span 快路径(零分配),多段租借临时数组;所有 TFM 共用同一实现(`ReadOnlySequence.FirstSpan` 同理改用 `First.Span`)。
- ns2.0 目标额外引用 `System.Threading.Channels` + `Microsoft.Bcl.AsyncInterfaces`(现代 .NET 内置于共享框架,NU1510 要求移除显式引用)。
- **验证矩阵**:测试项目多目标 `net8.0;net10.0;net48`;CI 在 Ubuntu 跑 net8/net10、Windows 跑 net48(真实 .NET Framework 运行时加载 ns2.0 资产执行全量测试)。

### 不内置(上移驱动层)

- 心跳 / 控制消息(泛型下框架不知消息形态)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,27 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="PolySharp" Version="1.15.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Meziantou.Analyzer" Version="3.0.187">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<PropertyGroup>
<PackageId>StreamFrame.Protocols.Xml</PackageId>
<Version>2.0.0</Version>
<Version>2.1.0</Version>
<Description>StreamFrame 的 XML 报文示例驱动:XmlDocumentCodec(XmlSerializer / XmlWriter 编解码)。</Description>
<Authors>Marci</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
8 changes: 7 additions & 1 deletion src/StreamFrame/Codec/BufferWriterStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,15 @@ public override void SetLength(long value)
=> throw new NotSupportedException();

public override void Write(byte[] buffer, int offset, int count)
=> Write(buffer.AsSpan(offset, count));
=> WriteCore(buffer.AsSpan(offset, count));

#if !NETSTANDARD2_0
// Stream 的 Span 虚方法重载自 netstandard2.1 起才有;ns2.0 走上面的 byte[] 重载(同样直写 IBufferWriter)
public override void Write(ReadOnlySpan<byte> buffer)
=> WriteCore(buffer);
#endif

private void WriteCore(ReadOnlySpan<byte> buffer)
{
while (!buffer.IsEmpty)
{
Expand Down
69 changes: 69 additions & 0 deletions src/StreamFrame/Compatibility/NetStandard20Shims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#if NETSTANDARD2_0
using System;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace System.Threading.Tasks
{
internal static class TaskWaitAsyncCompat
{
/// <summary>Task.WaitAsync(CancellationToken) 的 netstandard2.0 等价物(net5+ 内置)。</summary>
public static async Task WaitAsync(this Task task, CancellationToken cancellationToken)
{
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = cancellationToken.Register(
static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true),
cancelled,
useSynchronizationContext: false);

var completed = await Task.WhenAny(task, cancelled.Task).ConfigureAwait(false);
if (completed != task)
throw new OperationCanceledException(cancellationToken);

await task.ConfigureAwait(false);
}

/// <summary>
/// 旧 SocketTaskExtensions(System.Net.Sockets 4.3.0)没有带 CancellationToken 的重载:
/// 用 3 参重载发起操作,等待阶段可被取消(netfx 本就无法真正中止已提交的 socket I/O,
/// 取消后操作结果被丢弃,socket 随后关闭)。
/// </summary>
public static async Task<int> WithCancellation(this Task<int> task, CancellationToken cancellationToken)
{
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = cancellationToken.Register(
static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true),
cancelled,
useSynchronizationContext: false);

var completed = await Task.WhenAny(task, cancelled.Task).ConfigureAwait(false);
if (completed != task)
throw new OperationCanceledException(cancellationToken);

return await task.ConfigureAwait(false);
}
}
}

namespace System.Net.Sockets
{
internal static class SocketMemoryCompat
{
public static Task<int> ReceiveAsync(
this Socket socket, Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)
=> MemoryMarshal.TryGetArray((ReadOnlyMemory<byte>)buffer, out var segment)
? SocketTaskExtensions.ReceiveAsync(socket, segment, socketFlags).WithCancellation(cancellationToken)
: Task.FromException<int>(new ArgumentException(
"netstandard2.0 目标要求接收缓冲为数组支撑的 Memory。", nameof(buffer)));

public static Task<int> SendAsync(
this Socket socket, ReadOnlyMemory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)
=> MemoryMarshal.TryGetArray(buffer, out var segment)
? SocketTaskExtensions.SendAsync(socket, segment, socketFlags).WithCancellation(cancellationToken)
: Task.FromException<int>(new ArgumentException(
"netstandard2.0 目标要求发送缓冲为数组支撑的 Memory。", nameof(buffer)));
}
}
#endif
Loading