ASP.NET Core Blazor 高级方案ASP.NET Core Blazor advanced scenarios

本文内容

作者:Luke LathamDaniel Roth

Blazor Server 线路处理程序Blazor Server circuit handler

Blazor Server 允许代码定义线路处理程序,后者允许在用户线路的状态发生更改时运行代码。线路处理程序通过从 CircuitHandler 派生并在应用的服务容器中注册该类实现。以下线路处理程序示例跟踪打开的 SignalR 连接:

  1. using System.Collections.Generic;
  2. using System.Threading;
  3. using System.Threading.Tasks;
  4. using Microsoft.AspNetCore.Components.Server.Circuits;
  5. public class TrackingCircuitHandler : CircuitHandler
  6. {
  7. private HashSet<Circuit> _circuits = new HashSet<Circuit>();
  8. public override Task OnConnectionUpAsync(Circuit circuit,
  9. CancellationToken cancellationToken)
  10. {
  11. _circuits.Add(circuit);
  12. return Task.CompletedTask;
  13. }
  14. public override Task OnConnectionDownAsync(Circuit circuit,
  15. CancellationToken cancellationToken)
  16. {
  17. _circuits.Remove(circuit);
  18. return Task.CompletedTask;
  19. }
  20. public int ConnectedCircuits => _circuits.Count;
  21. }

线路处理程序使用 DI 注册。每个线路实例都会创建区分范围的实例。借助前面示例中的 TrackingCircuitHandler 创建单一实例服务,因为必须跟踪所有线路的状态:

  1. public void ConfigureServices(IServiceCollection services)
  2. {
  3. ...
  4. services.AddSingleton<CircuitHandler, TrackingCircuitHandler>();
  5. }

如果自定义线路处理程序的方法引发未处理异常,则该异常对于 Blazor Server 线路而言致命。若要容忍处理程序代码或被调用方法中的异常,请使用错误处理和日志记录将代码包装到一个或多个 try-catch 语句中。

当线路因用户断开连接而结束且框架正在清除线路状态时,框架会处置线路的 DI 范围。处置范围时会处置所有实现 System.IDisposable 的区分线路范围的 DI 服务。如果有任何 DI 服务在处置期间引发未处理异常,则框架会记录该异常。

RenderTreeBuilder 手动逻辑Manual RenderTreeBuilder logic

Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder 提供用于操作组件和元素的方法,包括在 C# 代码中手动生成组件。

备注

使用 RenderTreeBuilder 创建组件是一种高级方案。格式不正确的组件(例如,未封闭的标记标签)可能导致未定义的行为。

考虑以下 PetDetails 组件,可将其手动生成到另一个组件中:

  1. <h2>Pet Details Component</h2>
  2. <p>@PetDetailsQuote</p>
  3. @code
  4. {
  5. [Parameter]
  6. public string PetDetailsQuote { get; set; }
  7. }

在以下示例中,CreateComponent 方法中的循环生成三个 PetDetails 组件。调用 RenderTreeBuilder 方法创建组件(OpenComponentAddAttribute)时,序列号为源代码行号。Blazor 差分算法依赖于对应于不同代码行(而不是不同调用的调用)的序列号。使用 RenderTreeBuilder 方法创建组件时,请对序列号的参数进行硬编码。通过计算或计数器生成序列号可能导致性能不佳。有关详细信息,请参阅序列号与代码行号相关,而不与执行顺序相关部分。

BuiltContent 组件:

  1. @page "/BuiltContent"
  2. <h1>Build a component</h1>
  3. @CustomRender
  4. <button type="button" @onclick="RenderComponent">
  5. Create three Pet Details components
  6. </button>
  7. @code {
  8. private RenderFragment CustomRender { get; set; }
  9. private RenderFragment CreateComponent() => builder =>
  10. {
  11. for (var i = 0; i < 3; i++)
  12. {
  13. builder.OpenComponent(0, typeof(PetDetails));
  14. builder.AddAttribute(1, "PetDetailsQuote", "Someone's best friend!");
  15. builder.CloseComponent();
  16. }
  17. };
  18. private void RenderComponent()
  19. {
  20. CustomRender = CreateComponent();
  21. }
  22. }

警告

Microsoft.AspNetCore.Components.RenderTree 中的类型允许处理呈现操作的结果这些是 Blazor 框架实现的内部细节。这些类型应视为不稳定,并且在未来版本中可能会有更改。

序列号与代码行号相关,而不与执行顺序相关Sequence numbers relate to code line numbers and not execution order

Razor 组件文件 ( .razor) 始终被编译。与解释代码相比,编译具有潜在优势,因为编译步骤可用于注入信息,从而在运行时提高应用性能。

这些改进的关键示例涉及序列号序列号向运行时指示哪些输出来自哪些不同的已排序代码行。运行时使用此信息在线性时间内生成高效的树上差分,这比常规树上差分算法通常可以做到的速度快得多。

考虑以下 Razor 组件 ( .razor) 文件:

  1. @if (someFlag)
  2. {
  3. <text>First</text>
  4. }
  5. Second

前面的代码编译为以下所示内容:

  1. if (someFlag)
  2. {
  3. builder.AddContent(0, "First");
  4. }
  5. builder.AddContent(1, "Second");

首次执行代码时,如果 someFlagtrue,则生成器会收到:

序列类型数据
0Text 节点First
1Text 节点

假设 someFlag 变为 false 且标记再次呈现。此时,生成器会收到:

序列类型数据
1Text 节点

当运行时执行差分时,它会看到序列 0 处的项目已被删除,因此,它会生成以下普通编辑脚本

  • 删除第一个文本节点。

以编程方式生成序列号的问题The problem with generating sequence numbers programmatically

想象一下,你编写了以下呈现树生成器逻辑:

  1. var seq = 0;
  2. if (someFlag)
  3. {
  4. builder.AddContent(seq++, "First");
  5. }
  6. builder.AddContent(seq++, "Second");

现在,第一个输出是:

序列类型数据
0Text 节点First
1Text 节点

此结果与之前的示例相同,因此不存在负面问题。在第二个呈现中,someFlagfalse,输出为:

序列类型数据
0Text 节点

此时,差分算法发现发生了两个变化,且算法生成以下编辑脚本:

  • 将第一个文本节点的值更改为 Second
  • 删除第二个文本节点。

生成序列号会丢失有关原始代码中 if/else 分支和循环的位置的所有有用信息。这会导致两倍于之前长度的差异。

这是一个普通示例。在具有深度嵌套的复杂结构(尤其是带有循环)的更真实的情况下,性能成本通常会更高。差分算法必须深入递归到呈现树中,而不是立即确定已插入或删除的循环块或分支。这通常导致必须生成更长的编辑脚本,因为差分算法获知了关于新旧结构之间关系的错误信息。

指南和结论Guidance and conclusions

  • 如果动态生成序列号,则应用性能会受到影响。
  • 该框架无法在运行时自动创建自己的序列号,因为除非在编译时捕获了必需的信息,否则这些信息不存在。
  • 不要编写手动实现的冗长 RenderTreeBuilder 逻辑块。优先使用 .razor 文件并允许编译器处理序列号。如果无法避免 RenderTreeBuilder 手动逻辑,请将较长的代码块拆分为封装在 OpenRegion/CloseRegion 调用中的较小部分。每个区域都有自己的独立序列号空间,因此可在每个区域内从零(或任何其他任意数)重新开始。
  • 如果序列号已硬编码,则差分算法仅要求序列号的值增加。初始值和间隔不相关。一个合理选择是使用代码行号作为序列号,或者从零开始并以 1 或 100 的间隔(或任何首选间隔)增加。
  • Blazor 使用序列号,而其他树上差分 UI 框架不使用它们。使用序列号时,差分速度要快得多,并且 Blazor 的优势在于编译步骤可为编写 .razor 文件的开发人员自动处理序列号。

在 Blazor Server 应用中执行大型数据传输Perform large data transfers in Blazor Server apps

在某些方案中,必须在 JavaScript 和 Blazor 之间传输大量数据。通常,大型数据传输在以下情况中发生:

  • 浏览器文件系统 API 用于上传或下载文件。
  • 需要与第三方库互操作。

Blazor Server 中存在限制,可防止传递可能导致性能问题的单个大型消息。

开发在 JavaScript 和 Blazor 之间传输数据的代码时,请考虑以下指南:

  • 将数据切成小块,然后按顺序发送数据段,直到服务器收到所有数据。
  • 不要在 JavaScript 和 C# 代码中分配大型对象。
  • 发送或接收数据时,请勿长时间阻止主 UI 线程。
  • 在进程完成或取消时释放消耗的所有内存。
  • 为了安全起见,请强制执行以下附加要求:
    • 声明可以传递的最大文件或数据大小。
    • 声明从客户端到服务器的最低上传速率。
  • 在服务器收到数据后,数据可以:
    • 暂时存储在内存缓冲区中,直到收集完所有数据段。
    • 立即使用。例如,在收到每个数据段时,数据可以立即存储到数据库中或写入磁盘。

以下文件上传程序类处理与客户端的 JS 互操作。上传程序类使用 JS 互操作:

  • 轮询客户端以发送数据段。
  • 在轮询超时的情况下中止事务。
  1. using System;
  2. using System.Buffers;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Threading.Tasks;
  6. using Microsoft.JSInterop;
  7. public class FileUploader : IDisposable
  8. {
  9. private readonly IJSRuntime _jsRuntime;
  10. private readonly int _segmentSize = 6144;
  11. private readonly int _maxBase64SegmentSize = 8192;
  12. private readonly DotNetObjectReference<FileUploader> _thisReference;
  13. private List<IMemoryOwner<byte>> _uploadedSegments =
  14. new List<IMemoryOwner<byte>>();
  15. public FileUploader(IJSRuntime jsRuntime)
  16. {
  17. _jsRuntime = jsRuntime;
  18. }
  19. public async Task<Stream> ReceiveFile(string selector, int maxSize)
  20. {
  21. var fileSize =
  22. await _jsRuntime.InvokeAsync<int>("getFileSize", selector);
  23. if (fileSize > maxSize)
  24. {
  25. return null;
  26. }
  27. var numberOfSegments = Math.Floor(fileSize / (double)_segmentSize) + 1;
  28. var lastSegmentBytes = 0;
  29. string base64EncodedSegment;
  30. for (var i = 0; i < numberOfSegments; i++)
  31. {
  32. try
  33. {
  34. base64EncodedSegment =
  35. await _jsRuntime.InvokeAsync<string>(
  36. "receiveSegment", i, selector);
  37. if (base64EncodedSegment.Length < _maxBase64SegmentSize &&
  38. i < numberOfSegments - 1)
  39. {
  40. return null;
  41. }
  42. }
  43. catch
  44. {
  45. return null;
  46. }
  47. var current = MemoryPool<byte>.Shared.Rent(_segmentSize);
  48. if (!Convert.TryFromBase64String(base64EncodedSegment,
  49. current.Memory.Slice(0, _segmentSize).Span, out lastSegmentBytes))
  50. {
  51. return null;
  52. }
  53. _uploadedSegments.Add(current);
  54. }
  55. var segments = _uploadedSegments;
  56. _uploadedSegments = null;
  57. return new SegmentedStream(segments, _segmentSize, lastSegmentBytes);
  58. }
  59. public void Dispose()
  60. {
  61. if (_uploadedSegments != null)
  62. {
  63. foreach (var segment in _uploadedSegments)
  64. {
  65. segment.Dispose();
  66. }
  67. }
  68. }
  69. }

在上面的示例中:

  • _maxBase64SegmentSize 设置为 8192,其由 _maxBase64SegmentSize = _segmentSize * 4 / 3 计算得出。
  • 低级别 .NET Core 内存管理 API 用于在 _uploadedSegments 中的服务器上存储内存段。
  • ReceiveFile 方法用于处理通过 JS 互操作进行的上传:
    • 通过与 _jsRuntime.InvokeAsync<FileInfo>('getFileSize', selector) 的 JS 互操作确定文件大小(以字节为单位)。
    • numberOfSegments 中计算要接收的段数并进行存储。
    • 通过与 _jsRuntime.InvokeAsync<string>('receiveSegment', i, selector) 的 JS 互操作在 for 循环中请求段。除最后一个段外,所有段都必须为 8,192 字节才能进行解码。客户端被强制以高效方式发送数据。
    • 对于接收的每个段,在使用 TryFromBase64String 解码之前执行检查。
    • 上传完成后,包含数据的数据流会作为新的Stream (SegmentedStream) 返回。

分段的数据流类将段列表作为不可查找的只读 Stream 公开:

  1. using System;
  2. using System.Buffers;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. public class SegmentedStream : Stream
  6. {
  7. private readonly ReadOnlySequence<byte> _sequence;
  8. private long _currentPosition = 0;
  9. public SegmentedStream(IList<IMemoryOwner<byte>> segments, int segmentSize,
  10. int lastSegmentSize)
  11. {
  12. if (segments.Count == 1)
  13. {
  14. _sequence = new ReadOnlySequence<byte>(
  15. segments[0].Memory.Slice(0, lastSegmentSize));
  16. return;
  17. }
  18. var sequenceSegment = new BufferSegment<byte>(
  19. segments[0].Memory.Slice(0, segmentSize));
  20. var lastSegment = sequenceSegment;
  21. for (int i = 1; i < segments.Count; i++)
  22. {
  23. var isLastSegment = i + 1 == segments.Count;
  24. lastSegment = lastSegment.Append(segments[i].Memory.Slice(
  25. 0, isLastSegment ? lastSegmentSize : segmentSize));
  26. }
  27. _sequence = new ReadOnlySequence<byte>(
  28. sequenceSegment, 0, lastSegment, lastSegmentSize);
  29. }
  30. public override long Position
  31. {
  32. get => throw new NotImplementedException();
  33. set => throw new NotImplementedException();
  34. }
  35. public override int Read(byte[] buffer, int offset, int count)
  36. {
  37. var bytesToWrite = (int)(_currentPosition + count < _sequence.Length ?
  38. count : _sequence.Length - _currentPosition);
  39. var data = _sequence.Slice(_currentPosition, bytesToWrite);
  40. data.CopyTo(buffer.AsSpan(offset, bytesToWrite));
  41. _currentPosition += bytesToWrite;
  42. return bytesToWrite;
  43. }
  44. private class BufferSegment<T> : ReadOnlySequenceSegment<T>
  45. {
  46. public BufferSegment(ReadOnlyMemory<T> memory)
  47. {
  48. Memory = memory;
  49. }
  50. public BufferSegment<T> Append(ReadOnlyMemory<T> memory)
  51. {
  52. var segment = new BufferSegment<T>(memory)
  53. {
  54. RunningIndex = RunningIndex + Memory.Length
  55. };
  56. Next = segment;
  57. return segment;
  58. }
  59. }
  60. public override bool CanRead => true;
  61. public override bool CanSeek => false;
  62. public override bool CanWrite => false;
  63. public override long Length => throw new NotImplementedException();
  64. public override void Flush() => throw new NotImplementedException();
  65. public override long Seek(long offset, SeekOrigin origin) =>
  66. throw new NotImplementedException();
  67. public override void SetLength(long value) =>
  68. throw new NotImplementedException();
  69. public override void Write(byte[] buffer, int offset, int count) =>
  70. throw new NotImplementedException();
  71. }

以下代码实现用于接收数据的 JavaScript 函数:

  1. function getFileSize(selector) {
  2. const file = getFile(selector);
  3. return file.size;
  4. }
  5. async function receiveSegment(segmentNumber, selector) {
  6. const file = getFile(selector);
  7. var segments = getFileSegments(file);
  8. var index = segmentNumber * 6144;
  9. return await getNextChunk(file, index);
  10. }
  11. function getFile(selector) {
  12. const element = document.querySelector(selector);
  13. if (!element) {
  14. throw new Error('Invalid selector');
  15. }
  16. const files = element.files;
  17. if (!files || files.length === 0) {
  18. throw new Error(`Element ${elementId} doesn't contain any files.`);
  19. }
  20. const file = files[0];
  21. return file;
  22. }
  23. function getFileSegments(file) {
  24. const segments = Math.floor(size % 6144 === 0 ? size / 6144 : 1 + size / 6144);
  25. return segments;
  26. }
  27. async function getNextChunk(file, index) {
  28. const length = file.size - index <= 6144 ? file.size - index : 6144;
  29. const chunk = file.slice(index, index + length);
  30. index += length;
  31. const base64Chunk = await this.base64EncodeAsync(chunk);
  32. return { base64Chunk, index };
  33. }
  34. async function base64EncodeAsync(chunk) {
  35. const reader = new FileReader();
  36. const result = new Promise((resolve, reject) => {
  37. reader.addEventListener('load',
  38. () => {
  39. const base64Chunk = reader.result;
  40. const cleanChunk =
  41. base64Chunk.replace('data:application/octet-stream;base64,', '');
  42. resolve(cleanChunk);
  43. },
  44. false);
  45. reader.addEventListener('error', reject);
  46. });
  47. reader.readAsDataURL(chunk);
  48. return result;
  49. }