从 Orleans 3.x 迁移到 7.0

Orleans 7.0 引入了一些有益的更改,包括对托管、自定义序列化、不可变性和 grain 抽象的改进。

迁移

由于 Orleans 识别 grain 和流的方式发生了变化,使用提醒、流或 grain 持久性的现有应用程序不能轻松地迁移到 Orleans 7.0。 我们计划以增量方式为这些应用程序提供迁移路径。

运行早期版本的 Orleans 的应用程序无法通过滚动升级的方式顺利升级到 Orleans 7.0。 因此,必须使用不同的升级策略,例如部署新群集并解除旧群集的授权。 Orleans 7.0 以一种不兼容的方式更改线路协议,这意味着群集不能包含 Orleans 7.0 主机和运行早期版本 Orleans 的主机的组合。

多年来,我们一直避免这种中断性变更,甚至在主要版本中也是如此,那么为什么现在要进行变更呢? 有两个主要原因:标识和序列化。 关于标识,grain 和流标识现在由字符串组成,让 grain 可以正确地对泛型类型信息进行编码,并让流可以更轻松地映射到应用程序域。 grain 类型以前是使用复杂的数据结构标识的,该结构无法表示泛型 grain,导致出现极端情况。 流以前由 string 命名空间和 Guid 键标识,无论多么高效,开发人员都很难将其映射到他们的应用程序域。 序列化现在具有版本容错能力,这意味着你可以按照一组规则以某些兼容的方式修改类型,并确信你可以升级应用程序而不会出现序列化错误。 当应用程序类型保留在流或 grain 存储中时,这个问题尤为明显。 下面几节详细介绍了主要变更,并更详细地讨论了这些更改。

打包更改

如果要将项目升级到 Orleans 7.0,需要执行以下操作:

提示

所有 Orleans 示例都已升级到 Orleans 7.0,可以作为一个参考,看看进行了哪些更改。 有关详细信息,请参阅 Orleans 问题 #8035,其中逐条列出了对每个示例所做的更改。

Orleansglobal using 指令

所有 Orleans 项目都直接或间接引用了 Microsoft.Orleans.Sdk NuGet 包。 当 Orleans 项目配置为启用隐式使用(例如 <ImplicitUsings>enable</ImplicitUsings>)时,OrleansOrleans.Hosting 命名空间都被隐式使用。 这意味着应用代码不需要这些指令。

有关详细信息,请参阅 ImplicitUsingsdotnet/orleans/src/Orleans.Sdk/build/Microsoft.Orleans.Sdk.targets

Hosting

ClientBuilder 类型已替换为 IHostBuilder 上的 UseOrleansClient 扩展方法。 IHostBuilder 类型源自 Microsoft.Extensions.Hosting NuGet 包。 这意味着,可以将 Orleans 客户端添加到现有主机,而无需创建单独的依赖关系注入容器。 客户端在启动期间连接到群集。 IHost.StartAsync 完成后,客户端将自动连接。 添加到 IHostBuilder 的服务按注册顺序启动,因此在调用 ConfigureWebHostDefaults 之前先调用 UseOrleansClient 可确保 Orleans 在 ASP.NET Core 启动之前启动,这样你就可以立即从 ASP.NET Core 应用程序访问客户端。

如果要模拟之前的 ClientBuilder 行为,可以创建一个单独的 HostBuilder,并使用 Orleans 客户端对其进行配置。 IHostBuilder 可以配置 Orleans 客户端或 Orleans 接收器。 所有接收器都注册应用程序可以使用的 IGrainFactoryIClusterClient 实例,因此单独配置客户端是不必要的,也不受支持。

OnActivateAsyncOnDeactivateAsync 签名更改

Orleans 允许 grain 在激活和停用期间执行代码。 这可用于执行从存储或日志生命周期消息中读取状态等任务。 在 Orleans 7.0 中,这些生命周期方法的签名已更改:

  • OnActivateAsync() 现在接受 CancellationToken 参数。 取消 CancellationToken 时,应放弃激活过程。
  • OnDeactivateAsync() 现在接受 DeactivationReason 参数和 CancellationToken 参数。 DeactivationReason 指示激活被停用的原因。 开发人员应将此信息用于日志记录和诊断目的。 取消 CancellationToken 后,应立即完成停用过程。 请注意,由于任何主机都可能随时发生故障,因此不建议依赖 OnDeactivateAsync 来执行重要操作,例如保留关键状态。

来看看以下重写这些新方法的 grain 示例:

public sealed class PingGrain : Grain, IPingGrain
{
    private readonly ILogger<PingGrain> _logger;

    public PingGrain(ILogger<PingGrain> logger) =>
        _logger = logger;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("OnActivateAsync()");
        return Task.CompletedTask;
    }

    public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token)
    {
        _logger.LogInformation("OnDeactivateAsync({Reason})", reason);
        return Task.CompletedTask;
    }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

POCO grain 和 IGrainBase

Orleans 中的 grain 不再需要从 Grain 基类或任何其他类继承。 此功能称为 POCO grain。 若要访问以下任一扩展方法:

grain 必须实现 IGrainBase 或继承自 Grain。 下面是在 grain 类上实现 IGrainBase 的示例:

public sealed class PingGrain : IGrainBase, IPingGrain
{
    public PingGrain(IGrainContext context) => GrainContext = context;

    public IGrainContext GrainContext { get; }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

IGrainBase 还使用默认实现来定义 OnActivateAsyncOnDeactivateAsync,从而允许 grain 参与其生命周期(如果需要):

public sealed class PingGrain : IGrainBase, IPingGrain
{
    private readonly ILogger<PingGrain> _logger;

    public PingGrain(IGrainContext context, ILogger<PingGrain> logger)
    {
        _logger = logger;
        GrainContext = context;
    }

    public IGrainContext GrainContext { get; }

    public Task OnActivateAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("OnActivateAsync()");
        return Task.CompletedTask;
    }

    public Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token)
    {
        _logger.LogInformation("OnDeactivateAsync({Reason})", reason);
        return Task.CompletedTask;
    }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

序列化

Orleans 7.0 中最繁琐的变更是引入版本容错序列化程序。 之所以进行此更改,是因为应用程序往往是不断发展的,这给开发者带来了很大的隐患,因为以前的序列化程序无法容忍将属性添加到现有类型。 另一方面,序列化程序非常灵活,开发人员无需修改即可表示大多数 .NET 类型,包括泛型、多形性和引用跟踪等特性。 虽然早就应该进行替换,但用户仍然需要高保真地表示他们的类型。 因此,Orleans 7.0 中引入了替换序列化程序,它支持 .NET 类型的高保真表示,同时也允许类型的发展。 新的序列化程序比以前的序列化程序效率高得多,使端到端的吞吐量提高了 170%。

有关详细信息,请参阅与 Orleans 7.0 相关的以下文章:

grain 标识

每个 grain 都有唯一的标识,由 grain 的类型及其键组成。 早期版本的 Orleans 使用 GrainId 的复合类型来支持以下任一项的 grain 键:

在处理 grain 键时,涉及到一些复杂性。 grain 标识由两个部分组成:类型和键。 类型部分以前由一个数字类型代码、一个类别和 3 个字节的泛型类型信息组成。

grain 标识现在采用 type/key 形式,其中 typekey 都是字符串。 最常用的 grain 键接口是 IGrainWithStringKey。 这大大简化了 grain 标识的工作流程,并改进了对泛型 grain 类型的支持。

grain 接口现在也使用用户可读的名称来表示,而不是哈希代码和任何泛型类型参数的字符串表示形式的组合。

新系统更容易进行自定义,这些自定义可以由属性来驱动。

  • grain class 上的 GrainTypeAttribute(String) 指定其 grain id 的类型部分。
  • grain interface 上的 DefaultGrainTypeAttribute(String) 指定获取 grain 引用时 IGrainFactory 默认应解析的 grain 类型。 例如,在调用 IGrainFactory.GetGrain<IMyGrain>("my-key") 时,如果 IMyGrain 指定了上述属性,grain 工厂将返回对 grain "my-type/my-key" 的引用。
  • GrainInterfaceTypeAttribute(String) 允许重写接口名称。 使用此机制显式指定名称可以重命名接口类型,不会破坏与现有 grain 引用的兼容性。 请注意,在这种情况下,接口还应具有 AliasAttribute,因为它的标识可能已序列化。 有关指定类型别名的详细信息,请参阅有关序列化的部分。

如上所述,重写类型的默认 grain 类和接口名称可以重命名基础类型,而不会破坏与现有部署的兼容性。

流标识

当 Orleans 流第一次发布时,只能使用 Guid 来标识流。 这在内存分配方面很有效,但用户很难创建有意义的流标识,通常需要一些编码或间接方式来确定适当的流标识以达到特定的目的。

在 Orleans 7.0 中,流现在使用字符串进行标识。 Orleans.Runtime.StreamIdstruct 包含三个属性:StreamId.NamespaceStreamId.KeyStreamId.FullKey。 这些属性值是经过编码的 UTF-8 字符串。 例如,StreamId.Create(String, String)

将 SimpleMessageStreams 替换为 BroadcastChannel

SimpleMessageStreams(也称为 SMS)已在 7.0 中删除。 SMS 具有与 Orleans.Providers.Streams.PersistentStreams 相同的接口,但它的行为非常不同,因为它依赖于直接的 grain 到 grain 调用。 为了避免混淆,已删除 SMS,并引入了名为 Orleans.BroadcastChannel 的新替代项。

BroadcastChannel 仅支持隐式订阅,在这种情况下可以直接替换。 如果需要显式订阅或需要使用 PersistentStream 接口(例如,你在测试中使用的是 SMS,而在生产中使用 EventHub),那么 MemoryStream 是最佳选择。

BroadcastChannel 的行为与 SMS 相同,而 MemoryStream 的行为与其他流提供程序相同。 来看看下面的广播频道使用示例:

// Configuration
builder.AddBroadcastChannel(
    "my-provider",
    options => options.FireAndForgetDelivery = false);

// Publishing
var grainKey = Guid.NewGuid().ToString("N");
var channelId = ChannelId.Create("some-namespace", grainKey);
var stream = provider.GetChannelWriter<int>(channelId);

await stream.Publish(1);
await stream.Publish(2);
await stream.Publish(3);

// Simple implicit subscriber example
[ImplicitChannelSubscription]
public sealed class SimpleSubscriberGrain : Grain, ISubscriberGrain, IOnBroadcastChannelSubscribed
{
    // Called when a subscription is added to the grain
    public Task OnSubscribed(IBroadcastChannelSubscription streamSubscription)
    {
        streamSubscription.Attach<int>(
          item => OnPublished(streamSubscription.ChannelId, item),
          ex => OnError(streamSubscription.ChannelId, ex));

        return Task.CompletedTask;

        // Called when an item is published to the channel
        static Task OnPublished(ChannelId id, int item)
        {
            // Do something
            return Task.CompletedTask;
        }

        // Called when an error occurs
        static Task OnError(ChannelId id, Exception ex)
        {
            // Do something
            return Task.CompletedTask;
        }
    }
}

迁移到 MemoryStream 会更容易,因为只需要更改配置。 请考虑以下 MemoryStream 配置:

builder.AddMemoryStreams<DefaultMemoryMessageBodySerializer>(
    "in-mem-provider",
    _ =>
    {
        // Number of pulling agent to start.
        // DO NOT CHANGE this value once deployed, if you do rolling deployment
        _.ConfigurePartitioning(partitionCount: 8);
    });

OpenTelemetry

Orleans 7.0 中更新了遥测系统,并且删除了以前的系统,取而代之的是标准化的 .NET API,例如用于衡量指标的 .NET 指标和用于跟踪的 ActivitySource

在此过程中,删除了现有 Microsoft.Orleans.TelemetryConsumers.* 包。 我们正在考虑引入一组新包,用来简化将 Orleans 发出的指标集成到所选监视解决方案的过程。 和往常一样,欢迎提供反馈和建议。

dotnet-counters 工具的特点是性能监测,用于临时运行状况监视和初级性能调查。 对于 Orleans 计数器,可以使用 dotnet-counters 工具来监视它们:

dotnet counters monitor -n MyApp --counters Microsoft.Orleans

同样,OpenTelemetry 指标可以添加 Microsoft.Orleans 计量,如以下代码所示:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddPrometheusExporter()
        .AddMeter("Microsoft.Orleans"));

若要启用分布式跟踪,需要配置 OpenTelemetry,如以下代码所示:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.SetResourceBuilder(ResourceBuilder.CreateDefault()
            .AddService(serviceName: "ExampleService", serviceVersion: "1.0"));

        tracing.AddAspNetCoreInstrumentation();
        tracing.AddSource("Microsoft.Orleans.Runtime");
        tracing.AddSource("Microsoft.Orleans.Application");

        tracing.AddZipkinExporter(options =>
        {
            options.Endpoint = new Uri("http://localhost:9411/api/v2/spans");
        });
    });

在前面的代码中,已将 OpenTelemetry 配置为监视以下内容:

  • Microsoft.Orleans.Runtime
  • Microsoft.Orleans.Application

若要传播活动,请调用 AddActivityPropagation

builder.Host.UseOrleans((_, clientBuilder) =>
{
    clientBuilder.AddActivityPropagation();
});

将功能从核心包重构为单独的包

在 Orleans 7.0 中,我们致力于将扩展纳入独立的包中,不依赖 Orleans.Core。 也就是说,Orleans.StreamingOrleans.RemindersOrleans.Transactions 都已经从核心中分离出来。 这意味着,这些包完全是根据所使用内容付费的,Orleans 核心中没有任何代码专用于这些功能。 这样可以缩小核心 API 图面和程序集大小,简化核心并提高性能。 在性能方面,Orleans 中的事务以前需要为每个方法执行一些代码,以协调潜在事务。 这一点后来被移到了每个方法上。

这是一项编译中断性变更。 你可能有一些现有的代码,通过调用以前在 Grain 基类上定义的方法(但现在是扩展方法)与提醒或流进行交互。 这样的调用不指定 this(例如 GetReminders),需要更新为包含 this(例如 this.GetReminders()),因为扩展方法必须是经过限定的。 如果你不更新这些调用,会出现编译错误;如果你不知道更改的内容,所需的代码更改可能并不明显。

事务客户端

Orleans 7.0 引入了一个用于协调事务的新抽象 Orleans.ITransactionClient。 以前,事务只能由 grain 协调。 使用 ITransactionClient(可通过依赖关系注入获得),客户端也可以协调事务,不需要一个中间的 grain。 以下示例从一个帐户提取额度,并在单个事务中将其存入另一个帐户。 可以从 grain 内部调用此代码,也可以从外部客户端(它从依赖关系注入容器检索 ITransactionClient)调用。

await transactionClient.RunTransaction(
  TransactionOption.Create,
  () => Task.WhenAll(from.Withdraw(100), to.Deposit(100)));

对于由客户端协调的事务,客户端必须在配置期间添加所需的服务:

clientBuilder.UseTransactions();

BankAccount 示例演示了 ITransactionClient 的用法。 有关详细信息,请参阅 Orleans 事务

调用链重入

grain 是单线程的,默认情况下,从开始到完成逐个处理请求。 换句话说,默认情况下,grain 不是可重入的。 将 ReentrantAttribute 添加到 grain 类,就可以通过交错方式并发处理多个请求,同时仍然是单线程的。 这对于不保留内部状态或执行大量异步操作(例如发出 HTTP 调用或写入数据库)的 grain 非常有用。 当请求可以交错进行时,需要格外小心:有可能在异步操作完成和方法恢复执行时,在 await 语句更改之前会观察到 grain 的状态。

例如,下面的 grain 表示一个计数器。 它被标记为 Reentrant,允许多个调用交错进行。 Increment() 方法应递增内部计数器值并返回观察到的值。 但是,由于 Increment() 方法主体在 await 点之前观察 grain 的状态,并在之后更新它,因此多次交错执行 Increment() 可能会导致 _value 小于接收到的 Increment() 调用总数。 这是由于不正确地使用重入而导致的错误。

删除 ReentrantAttribute 就可以解决这个问题。

[Reentrant]
public sealed class CounterGrain : Grain, ICounterGrain
{
    int _value;
    
    /// <summary>
    /// Increments the grain's value and returns the previous value.
    /// </summary>
    public Task<int> Increment()
    {
        // Do not copy this code, it contains an error.
        var currentVal = _value;
        await Task.Delay(TimeSpan.FromMilliseconds(1_000));
        _value = currentVal + 1;
        return currentValue;
    }
}

为防止出现此类错误,grain 默认是不可重入的。 这样做的缺点是降低了在实现中执行异步操作的 grain 的吞吐量,因为当 grain 等待异步操作完成时,不能处理其他请求。 为了缓解这种情况,Orleans 提供了几个选项,在某些情况下允许重入:

  • 对于整个类:将 ReentrantAttribute 放在 grain 上,允许对 grain 的任何请求与其他任何请求交错进行。
  • 对于方法的子集:将 AlwaysInterleaveAttribute 放在 grain 接口方法上,允许对该方法的请求与任何其他请求交错进行,对该方法的请求也可以被任何其他请求交错进行。
  • 对于方法的子集:将 ReadOnlyAttribute 放在 grain 接口方法上,允许对该方法的请求与任何其他 ReadOnly 请求交错进行,对该方法的请求也可以被任何其他 ReadOnly 请求交错进行。 从这个意义上说,它是 AlwaysInterleave 的一种更受限的形式。
  • 对于调用链中的任何请求:可以通过 RequestContext.AllowCallChainReentrancy() 和 <xref:Orleans.Runtime.RequestContext.SuppressCallChainReentrancy?displayProperty=nameWithType 选择加入和退出允许下游请求重新进入 grain。 调用两者都返回一个必须在退出请求时处理的值。 因此,正确的用法如下:
public Task<int> OuterCall(IMyGrain other)
{
    // Allow call-chain reentrancy for this grain, for the duration of the method.
    using var _ = RequestContext.AllowCallChainReentrancy();
    await other.CallMeBack(this.AsReference<IMyGrain>());
}

public Task CallMeBack(IMyGrain grain)
{
    // Because OuterCall allowed reentrancy back into that grain, this method 
    // will be able to call grain.InnerCall() without deadlocking.
    await grain.InnerCall();
}

public Task InnerCall() => Task.CompletedTask;

必须在每个 grain、每个调用链上选择加入调用链重入。 例如,考虑粒度 A 和粒度 B 两个粒度。如果粒度 A 在调用粒度 B 之前启用调用链重入,则粒度 B 可以在该调用中回调到粒度 A 中。 但是,如果 grain B 未同时启用调用链重入,则 grain A 无法回调到 grain B。 它是在每个 grain、每个调用链上操作的。

grain 还可以使用 using var _ = RequestContext.SuppressCallChainReentrancy() 抑制调用链重入信息沿调用链向下流动。 这可以防止后续调用重入。

ADO.NET 迁移脚本

若要确保与依赖于 ADO.NET 的 Orleans 群集、持久性和提醒向前兼容,你需要相应的 SQL 迁移脚本:

为所使用的数据库选择文件,并按顺序应用它们。