👾 Fusion: 真实存在的"实时开启"开关
Fusion 是一个 .NET 库,它实现了 🦄 分布式反应式记忆化 (DREAM) – 这是一个新颖的抽象概念,有点类似于 MobX 或 Flux,但设计用于处理跨越后端微服务、API 服务器,甚至延伸到应用程序每个客户端的任意大规模状态。
Fusion 用一个工具解决了一系列臭名昭著的难题:
问题 | 所以你不需要... |
---|---|
📇 缓存 | Redis, memcached, ... |
🤹 实时缓存失效 | 没有好的解决方案 - 这是一个臭名昭著的难题 |
🚀 实时更新 | SignalR, WebSockets, gRPC, ... |
🤬 网络碎片化 | 大量代码 |
🔌 离线模式支持 | 大量代码 |
📱 客户端状态管理 | MobX, Flux/Redux, Recoil, ... |
💰 Blazor WebAssembly、Server 和 Hybrid/MAUI 的单一代码库 | 没有好的替代方案 |
最棒的是:Fusion 为你透明地处理所有这些,所以基于 Fusion 的代码几乎与不涉及它的代码相同。你只需要:
- 在你的 Fusion 服务上"实现"
IComputeService
(一个标记接口),以确保在编译时为它生成调用拦截代理。 - 用
[ComputeMethod]
标记需要"Fusion 行为"的方法 + 将它们声明为virtual
- 通过
serviceCollection.AddFusion().AddService<MyService>()
注册服务 - 像往常一样解析和使用它们 - 即,将它们作为依赖项传递,调用它们的方法等。
当调用 [ComputeMethod]
时,魔法就发生了:
- 当 Fusion 知道给定调用的值(考虑
(serviceInstance, method, args...)
缓存键)仍然一致时,Fusion 会立即返回它,而不让方法运行。 - 当值未缓存或被标记为不一致时,Fusion 让方法运行,但在过程中捕获新值的依赖关系。"依赖关系"是在评估另一个
[ComputeMethod]
调用期间触发的一个[ComputeMethod]
调用。
第二步允许 Fusion 跟踪当其中一个值改变时,哪些值预期会改变。这与批次可追溯性非常相似,但是针对任意函数而不是制造过程实现的。
拼图的最后一块是 Computed.Invalidate()
块,它允许将缓存结果标记为"与基本事实不一致"。以下是你如何使用它:
var avatars = await GetUserAvatars(userId);
using (Computed.Invalidate()) {
// 在此块内调用的任何 [ComputeMethod] 都不会正常运行,
// 而是使相同调用的结果失效。
// 这些调用同步完成并返回已完成的 Task<TResult>,
// 所以你不需要等待它们。
_ = userService.GetUser(userId);
foreach (var avatar in avatars)
_ = userAvatarService.GetAvatar(userId, avatar.Id);
}
失效总是传递性的: 如果 GetUserProfile(3)
调用 GetUserAvatar("3:ava1")
,而 GetUserAvatar("3:ava1")
失效,GetUserProfile(3)
也会失效。
为了使其工作,Fusion 维护了一个类似字典的结构,用于跟踪最近和"被观察"的调用结果:
- 键:
(serviceInstance, method, call arguments...)
- 值:[Computed
],它存储结果、一致性状态( Computing
、Consistent
、Invalidated
)和依赖-被依赖链接。Computed<T>
实例几乎是不可变的:一旦构造,它们只能转换到Inconsistent
状态。
你可以这样"提取"某个调用"背后"的 Computed<T>
实例:
var computed1 = await Computed.Capture(() => GetUserProfile(3));
// 你可以等待它的失效:
await computed1.WhenInvalidated();
Assert.IsFalse(computed1.IsConsistent());
// 并重新计算它:
var computed2 = await computed1.Recompute();
所以任何 Computed<T>
都是可观察的。而且,它可以是远程 Computed<T>
实例的"副本",在你的本地进程中镜像其状态,所以依赖图可以是分布式的。为了实现这一点,Fusion 使用了自己的基于 WebSocket 的 RPC 协议,它与任何其他 RPC 协议非常相似:
- 要将调用"发送"到远程对等端,客户端发送"调用"消息
- 对等端用"调用结果"消息响应。到目前为止,它与任何其他 RPC 协议没有区别。
- 这里是独特的步骤: 对等端可能稍后发送一条消息,告知它之前发送的调用结果已失效。
步骤 3 在网络流量方面并没有改变太多:每次调用最多只有一条额外消息(即最坏情况下是 3 条消息而不是 2 条)。但这个小小的添加允许计算服务客户端准确知道给定的缓存调用结果何时变得不一致。
步骤 3 的存在产生了巨大的差异:任何缓存的且仍然一致的结果都与你从远程服务器获得的数据一样好,对吧?所以在本地解决"命中"这样结果的调用是完全可以的,不会产生网络往返!
最后,任何计算服务客户端的行为都类似于本地计算服务。看看这段代码:
string GetUserName(id)
=> (await userService.GetUser(id)).Name;
你无法判断这里的 userService
是本地计算服务还是计算服务客户端,对吧?
- 两个选项都是相同的基本类型(例如
IUserService
)。但实现不同:Fusion 服务客户端通过fusion.AddClient<TInterface>()
注册,而服务器通过fusion.AddServer<TInterface, TService>()
注册。 - 而且行为相同:
- 如果之前的结果仍然一致,你对
userService
的每次调用都会立即终止 - 如果
GetUserName
是另一个计算服务(本地服务)的方法,支持它所做的GetUser(id)
调用的计算值会自动扩展GetUserName(id)
调用的 Fusion 依赖图!
- 如果之前的结果仍然一致,你对
因此,Fusion 抽象了服务的"位置",而且比传统的 RPC 代理做得更好:Fusion 代理默认不会"碎片化"!
文档
如果你喜欢幻灯片,请查看 "为什么实时 Web 应用需要 Blazor 和 Fusion?"演讲 - 它解释了我们解决的许多问题是如何联系在一起的,Fusion 如何解决根本原因,以及如何用 C# 编写 Fusion 关键抽象的简化版本。
幻灯片稍微有些过时 - 例如,现在Fusion客户端使用
Stl.Rpc
而不是HTTP与服务器通信,但它们涵盖的所有概念仍然完整。
查看示例;本文档后面会介绍其中的一些内容。
"你的证据是什么?"*
**这一切听起来太好了,简直不像是真的,对吧?**这就是为什么在本文档的剩余部分有大量的视觉证明。但如果你在Fusion的源代码或示例中发现任何令人担忧的地方,请随时在Discord上向我们提出质疑!
让我们从一些重量级的内容开始:
看看Actual Chat – 一个由Fusion背后的团队打造的全新聊天应用。
Actual Chat融合了实时音频、实时转录和AI辅助, 让你以最高效率进行沟通。 它拥有WebAssembly、iOS、Android和Windows的客户端, 在这些平台上实现了近100%的代码共享。 除了实时更新,它的一些功能,如离线模式, 都是由Fusion驱动的。
我们在这里发布了一些来自Actual Chat代码库的示例, 加入这个聊天室,了解我们如何在真实应用中使用它。
现在,来看示例:
下面是Fusion+Blazor示例 向3个浏览器窗口提供实时更新:
立即体验 此示例的在线版本!
该示例支持Blazor Server和Blazor WebAssembly 两种托管模式。 即使在不同窗口中使用不同的模式, Fusion仍然能保持共享状态的每一位同步, 包括登录状态:
Fusion快吗?
**是的,它快得令人难以置信。**以下是Actual Chat上最频繁的RPC调用之一的调用持续时间分布:
IChats.GetTile
读取一个小的"聊天瓦片" - 通常是固定在特定ID范围内的5个条目,因此可以高效缓存。即使对于这些调用,典型的响应时间也几乎无法测量:每个X轴标记都比前一个大10倍,所以你看到的最高峰值在0.03ms
!
接下来在约4-5ms
处的凸起是服务实际访问数据库的时间 - 也就是说,这是你在没有Fusion的情况下预期看到的时间。不过,负载会高得多,因为你在这个图表上看到的调用是"抵达"服务器的调用 - 换句话说,它们没有被客户端或其Fusion服务消除。
Fusion测试套件中的一个小型综合基准测试 比较了基于"原始"Entity Framework Core的 数据访问层(DAL)与其依赖Fusion的版本:
每秒调用次数 | PostgreSQL | MariaDB | SQL Server | Sqlite |
---|---|---|---|---|
单个读取器 | 1.02K | 645.77 | 863.33 | 3.79K |
960个读取器(高并发) | 12.96K | 14.52K | 16.66K | 16.50K |
单个读取器 + Fusion | 9.54M | 9.28M | 9.05M | 8.92M |
960个读取器 + Fusion | 145.95M | 140.29M | 137.70M | 141.40M |
在Ryzen Threadripper 3960X上运行此测试的原始输出在这里。读取器的数量乍看起来很疯狂,但它是为了最大化DAL的非Fusion版本的输出而调整的(读取器是异步的,所以它们大部分时间都在等待数据库响应)。
Fusion的透明缓存确保你的代码产生的每个API调用结果都被缓存,而且,即使在重新计算这些结果时,它们大多使用其他缓存的依赖项,而不是访问速度慢得多的存储(在这种情况下是数据库)。
有趣的是,即使没有依赖项的"层"(只考虑"零层"),Fusion也能将此测试运行的API调用速度提高8,000到12,000倍。
是什么让Fusion如此快速:
- 这个概念本身就是为了消除任何不必要的计算。想想
msbuild
,但是用于你的方法调用结果:已计算且一致的内容永远不会被重新计算。 - Fusion在内存中缓存调用结果,所以如果命中缓存,它们立即可用。无需往返外部缓存,无需序列化/反序列化等。
- 此外,也没有克隆:缓存的是调用返回的.NET对象或结构,所以任何调用结果都是"共享的"。这比例如在每次命中时反序列化一个新副本更加CPU缓存友好。
- Fusion使用自己的
Stl.Interception
库来拦截方法调用,虽然还没有基准测试,但这些是.NET上可用的最快的调用拦截器 - 它们比如Castle.DynamicProxy提供的拦截器稍快。它们不会装箱调用参数,每次调用只需要1次分配。 Stl.Rpc
(Fusion负责RPC调用的部分)也是如此。它的初步基准测试结果显示它比SignalR快约1.5倍,比gRPC快约3倍。Stl.Rpc
使用.NET上可用的最快序列化器 – 默认为MemoryPack(它不需要运行时IL Emit),尽管你也可以使用MessagePack(它稍快,但需要IL Emit)或你喜欢的任何其他序列化器。- Fusion中所有关键执行路径都经过大量优化。本页面的存档版本显示,上述测试的性能目前比2年前提高了3倍。
Fusion可扩展吗?
是的。Fusion做的事情类似于任何[MMORPG]游戏引擎所做的:尽管完整的游戏状态是巨大的,但仍然可以为100万+玩家实时运行游戏,因为每个玩家只观察到完整游戏状态的一小部分,因此你只需要确保被观察的部分状态适合内存即可。
这正是Fusion所做的:
- 它按需生成被观察的状态部分(即当你调用[Compute Service]方法时)
- 确保支持这部分状态的依赖图在有人使用时保留在内存中
- 销毁未被观察的部分。
查看教程中的"扩展Fusion服务"部分,了解Fusion如何扩展的更详细描述。
说得够多了。给我看代码!
一个典型的Compute Service如下所示:
public class ExampleService : IComputeService
{
[ComputeMethod]
public virtual async Task<string> GetValue(string key)
{
// 此方法从非Fusion"源"读取数据,
// 因此在写入时需要失效(参见SetValue)
return await File.ReadAllTextAsync(_prefix + key);
}
```csharp
[ComputeMethod]
public virtual async Task<string> GetPair(string key1, string key2)
{
// 此方法只使用其他 [ComputeMethod] 或静态数据,
// 因此不需要在写入时进行失效处理
var v1 = await GetNonFusionData(key1);
var v2 = await GetNonFusionData(key2);
return $"{v1}, {v2}";
}
public async Task SetValue(string key, string value)
{
// 此方法更改了 GetValue 和 GetPair 读取的数据,
// 但由于 GetPair 使用了 GetValue,因此一旦我们使 GetValue 失效,
// GetPair 也会自动失效。
await File.WriteAllTextAsync(_prefix + key, value);
using (Computed.Invalidate()) {
// 这是使此方法更改的内容失效的方式。
// 调用参数很重要:您只使具有匹配参数的调用结果失效,
// 而不是每个 GetValue 调用的结果!
_ = GetValue(key);
}
}
}
[ComputeMethod]
表示每次调用此方法时,其结果都由 [Computed Value] 支持,因此它在运行时捕获依赖关系,并在当前计算值仍然一致时立即返回结果。
计算服务的注册方式几乎类似于单例:
var services = new ServiceCollection();
var fusion = services.AddFusion(); // 可以多次调用
// ~ 类似于 service.AddSingleton<[TService, ]TImplementation>()
fusion.AddService<ExampleService>();
查看 HelloBlazorServer 示例 中的 CounterService 以了解计算服务的实际代码。
现在,我猜你很好奇使用 Fusion 的 UI 代码是什么样的。你会惊讶地发现,它简单得不能再简单了:
// MomentsAgoBadge.razor
@inherits ComputedStateComponent<string>
@inject IFusionTime _fusionTime
<span>@State.Value</span>
@code {
[Parameter]
public DateTime Value { get; set; }
protected override Task<string> ComputeState()
=> _fusionTime.GetMomentsAgo(Value) ;
}
MomentsAgoBadge
是一个 Blazor 组件,用于显示 "N [秒/分钟/...] 前"
的字符串。上面的代码与其 实际代码 几乎完全相同,只是后者由于处理 null
而稍微复杂一些。
你可以看到它使用了 IFusionTime
- 这是一个内置的计算服务,提供 GetUtcNow
和 GetMomentsAgo
方法。你可能猜到了,这些方法的结果会自动失效;查看 FusionTime
服务 以了解其工作原理。
但这里重要的是 MomentsAgoBadge
继承自 ComputedStateComponentComputeState
方法。你可能猜到了,这个方法的行为类似于 [Compute Method]。
ComputedStateComponent<T>
暴露了 State
属性(类型为 ComputedState<T>
),允许你通过其 Value
属性获取 ComputeState()
的最新输出。"State" 是 Fusion 的另一个关键抽象 - 它实现了一个"等待失效并重新计算"循环,类似于这个:
var computed = await Computed.Capture(_ => service.Method(...));
while (true) {
await computed.WhenInvalidated();
computed = await computed.Update();
}
唯一的区别是它以更健壮的方式执行此操作 - 特别是,它允许你控制失效和更新之间的延迟,访问最新的非错误值等。
最后,ComputedStateComponent
在其 State
更新时自动调用 StateHasChanged()
,以确保显示新值。
因此,如果你使用 Fusion,你就不需要在 UI 中编写任何反应代码。 反应(即部分更新和重新渲染)会自动发生,这是因为依赖链将 UI 组件与它们使用的数据提供者连接起来,而数据提供者又与它们使用的数据提供者相连,以此类推 - 直到最基本的"成分提供者",即在变化时失效的计算方法。
如果你想看到更多类似简单的 UI 组件示例,可以查看:
- Counter.razor - 一个使用 HelloBlazorServer 示例 中的 CounterService 的 Blazor 组件
- ChatMessageCountBadge.razor 和 AppUserBadge.razor,来自 [Board Games]。
为什么 Fusion 对实时应用来说是一个游戏改变者?
实时通常意味着你使用事件向每个可能受此变化影响的客户端传递变更通知,因此你必须:
- 知道要通知哪些客户端关于特定事件。 单单这一点就是一个相当困难的问题 - 特别是,你需要知道每个客户端现在"看到"什么。发送当前不在"视口"中的事件(例如,你可能看到但现在没有看到的帖子)是没有意义的,因为这是一种巨大的浪费,严重限制了可扩展性。与 [MMORPG] 类似,对于大多数网络应用来说,"可见"部分的状态与"可用"部分相比也是很小的。
- 将事件应用到客户端状态。 这也是一个相对简单的问题,但请注意,你也应该在服务器端执行相同的操作,而且在两个完全不同的处理程序中保持每个事件的逻辑同步是未来潜在问题的源头。
- 使 UI 在每次客户端状态变化时正确更新其事件订阅。 这是客户端代码必须做的事情,以确保第 1 点在服务器端正常工作。同样,这在纸面上看起来是一个可以解决的问题,但如果你想确保你的 UI 提供真正最终一致的视图,事情会变得更加复杂。只需思考一下你会以什么顺序运行"查询初始数据"和"订阅后续事件"这两个操作,就能看出其中的一些问题。
- 降低某些事件的速率(例如,每个热门帖子的"点赞"事件)。 在纸面上看起来很容易,但如果你想确保用户看到系统的最终一致视图,事情会变得更复杂。 特别是,这意味着你发送的每个事件都要"总结"它和你丢弃的每个事件所做的更改,所以可能 你需要为每个这样的事件设计专用的类型、生产者和处理程序。
而 Fusion 使用单一的抽象来解决所有这些问题,允许它自动识别和跟踪数据依赖关系。
为什么 Fusion 对具有复杂 UI 的 Blazor 应用来说是一个游戏改变者?
Fusion 允许你创建真正独立的 UI 组件。 你可以将它们嵌入到 UI 的任何部分,而不需要担心它们如何相互交互。
这使得 Fusion 非常适合 Blazor 上的微前端: 在这里,创建松耦合的 UI 组件的能力至关重要。
除此之外,如果你的失效逻辑正确, Fusion 保证你的 UI 状态最终是一致的。
你可能认为所有这些只在 Blazor Server 模式下工作。 但事实并非如此,所有这些 UI 组件在 Blazor WebAssembly 模式下也能工作,这是 Fusion 提供的另一个独特功能。 任何 [Compute Service] 都可以替换为 [Compute Service Client],它不仅仅是代理调用,还完全 消除了你在常规客户端代理中可能遇到的频繁通信问题。
下一步
- 阅读快速入门、速查表或完整的教程
- 查看示例
- 加入我们的Discord 服务器提问和跟踪项目更新。如果你好奇"为什么是Discord",该服务器的创建早于Actual Chat的第一行代码。不过,一个基于Fusion的替代方案很快就会推出:)
文章和其他内容
- Fusion:一周岁生日、GitHub上1000+星、v1.4版本支持System.Text.Json
- 流行UI架构对比及Blazor+Fusion UI如何融入其中
- Fusion:当前状态和即将推出的功能
- 非环保的网络:为什么我们的网络应用效率如此低下?
- 为什么实时UI是Web应用不可避免的未来?
- Fusion与SignalR有多相似?
- Fusion与Knockout / MobX有多相似?
- 用简单的话解释Fusion
附言: 如果你已经花时间了解了Fusion, 请帮助我们改进它,完成Fusion反馈表 (1…3分钟)。