Zum Inhalt springen
Deutsch

Von Microsoft Orleans

Microsoft Orleans ist ein grainbasiertes Actor-Framework für .NET. Grains sind virtuell - über ID adressiert, beim ersten Eingang automatisch gespawnt, im Leerlauf automatisch deaktiviert. Sharded Entities in actor-ts sind das nächste Analogon.

Orleans: actor-ts:
IGreeter Grain-Interface → TypeScript-Nachrichten-Typen (Command-Union)
GreeterGrain : Grain → Actor, der PersistentActor erweitert (oder normaler Actor)
IGrainKey → string Entity-ID
client.GetGrain<IGreeter>(id)→ Referenz + tell über Sharding-Region
// Orleans (C#):
public interface IUserGrain : IGrainWithStringKey {
Task<UserProfile> GetProfile();
Task UpdateName(string name);
}
public class UserGrain : Grain, IUserGrain {
private UserProfile _profile = new();
public Task<UserProfile> GetProfile() => Task.FromResult(_profile);
public Task UpdateName(string name) {
_profile = _profile with { Name = name };
return Task.CompletedTask;
}
}
// actor-ts:
import { match } from 'ts-pattern';
import { Actor, type ActorRef } from 'actor-ts';
type Command =
| { entityId: string; kind: 'get-profile'; replyTo: ActorRef<UserProfile> }
| { entityId: string; kind: 'update-name'; name: string };
class UserActor extends Actor<Command> {
private profile: UserProfile = { name: '', email: '' };
override onReceive(command: Command): void {
match(command)
.with({ kind: 'get-profile' }, (c) => this.onGetProfile(c))
.with({ kind: 'update-name' }, (c) => this.onUpdateName(c))
.exhaustive();
}
private onGetProfile(command: GetProfileCommand): void {
command.replyTo.tell(this.profile);
}
private onUpdateName(command: UpdateNameCommand): void {
this.profile = { ...this.profile, name: command.name };
}
}

Unterschiede:

  • Methodenaufrufe werden zu Nachrichten-Arten + Handlern.
  • Task<T>-Rückgaben werden zu expliziten replyTo-Refs.
  • IGrainWithStringKey ist implizit - extractEntityId(command) zieht die ID auf der Sharding-Schicht.
// Orleans:
var grain = grainFactory.GetGrain<IUserGrain>("user-42");
var profile = await grain.GetProfile();
await grain.UpdateName("Alice");
// actor-ts:
import { ClusterSharding } from 'actor-ts';
const region = cluster.sharding.start<Command>({
typeName: 'user',
entityActor: UserActor,
extractEntityId: (command) => command.entityId,
});
// Profil via ask holen:
const profile = await region.ask({
entityId: 'user-42',
kind: 'get-profile',
replyTo: undefined as any,
}, 5_000);
// Update via tell:
region.tell({ entityId: 'user-42', kind: 'update-name', name: 'Alice' });

Die Region ist eine einzelne ActorRef - Nachrichten werden per extractEntityId geroutet. Jede einzigartige ID hat einen Actor, der beim ersten Eingang spawnt. Anders als ein Orleans-Grain deaktiviert er sich nicht von selbst: es gibt kein Standard-Idle-Fenster, eine Entity bleibt also resident, bis sie jemand passiviert (siehe unten).

// Orleans (Lifecycle-Methoden):
public override Task OnActivateAsync(CancellationToken token) {
// State aus Persistenz laden
return base.OnActivateAsync(token);
}
public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token) {
// Cleanup
return base.OnDeactivateAsync(reason, token);
}
// actor-ts:
class UserActor extends Actor<Command> {
override preStart(): void {
// Äquivalent zu OnActivateAsync
}
override postStop(): void {
// Äquivalent zu OnDeactivateAsync
}
}

Die preStart- + postStop-Methoden des Frameworks mappen direkt.

Für automatisches Passivieren bei Leerlauf:

sharding.start({
// ...
passivationIdleMs: 30_000, // nach 30 s Leerlauf passivieren - wie Orleans' Default
});
// Orleans Event Sourcing:
[LogConsistencyProvider(ProviderName = "EventStore")]
public class AccountGrain : JournaledGrain<AccountState, AccountEvent>, IAccount {
public Task Deposit(decimal amount) {
RaiseEvent(new Deposited(amount));
return ConfirmEvents();
}
}
// actor-ts:
class Account extends PersistentActor<Command, Event, State> {
readonly persistenceId = `account-${this.entityId}`;
initialState(): State { return { balance: 0 }; }
onEvent(state: State, event: Event): State {
return match(event)
.with({ kind: 'deposited' }, (e) => ({ balance: state.balance + e.amount }))
.exhaustive();
}
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.exhaustive();
}
private onDeposit(command: DepositCommand): void {
this.persist({ kind: 'deposited', amount: command.amount }, () => {});
}
}

Sehr ähnliches Pattern. Orleans nutzt RaiseEvent + ConfirmEvents; actor-ts nutzt persist mit Callback.

Orleansactor-ts
Virtuelle Actors - immer existierend, auf Anforderung gespawnt.Sharded Entities sind ähnlich; müssen als sharded Typ gestartet werden.
Verzeichnisbasierte Platzierung - Silos schlagen Grain-Standorte nach.Hash-Mod-Region-Platzierung per Default; Koordinator verwaltet.
Streams - Push-basierter Datenfluss.DistributedPubSub für ähnliche Fan-out-Semantik.
Reminders - dauerhafte Timer.context.timers + Persistenz - manuell kombinieren.
Transaktionen über Grains hinweg - eingebaut.NICHT eingebaut - nutze Sagas oder Two-Phase-Commit-Patterns.
// Orleans:
await this.RegisterOrUpdateReminder("daily-check", TimeSpan.FromDays(1), TimeSpan.FromDays(1));
public Task ReceiveReminder(string reminderName, TickStatus status) {
// handle
}
// actor-ts - kein direktes Äquivalent, baue mit Persistenz + Timern:
class MyActor extends PersistentActor<Command, Event, State> {
override preStart(): void {
super.preStart();
this.context.timers.startTimerWithFixedDelay(
'daily-check',
{ kind: 'daily-check' },
24 * 60 * 60_000,
);
}
}

Timer in actor-ts überleben Restart nicht - für echte “dauerhafte Reminders” würdest du den Zeitplan persistieren und in onRecoveryComplete neu armieren.

// Orleans Streams:
var stream = streamProvider.GetStream<Order>(streamId, "orders");
await stream.OnNextAsync(order);
await stream.SubscribeAsync((order, _) => ...);
// actor-ts-Äquivalent - DistributedPubSub:
const ps = system.extension(DistributedPubSubId);
ps.start(cluster);
ps.mediator.tell(new Publish('orders', order));
ps.mediator.tell(new Subscribe('orders', subscriberRef));

Cluster-weites PubSub. Keine Grain-Beteiligung; topic-basierter Fan-out.

// Orleans Transactions (.NET 8+):
[Transaction(TransactionOption.Required)]
public async Task Transfer(...) {
await fromAccount.Withdraw(amount);
await toAccount.Deposit(amount);
}

actor-ts hat keine eingebauten Transaktionen über Actors hinweg. Baue sie mit Sagas:

class TransferSaga extends PersistentFSM<...> {
// Schritt 1: vom Quellkonto abheben.
// Schritt 2: auf Zielkonto einzahlen.
// Bei Fehlschlag: kompensieren (Quellkonto rückerstatten).
}

Siehe PersistentFSM für das Saga-Pattern.

// Orleans (mit K8s):
var host = Host.CreateDefaultBuilder()
.UseOrleans(siloBuilder => siloBuilder
.UseKubernetesHosting()
.ConfigureEndpoints(siloPort: 11111, gatewayPort: 30000)
)
.Build();
// actor-ts:
import { KubernetesApiSeedProvider, KubernetesApiSeedProviderOptions } from 'actor-ts';
const kubernetesApiSeedProviderOptions = KubernetesApiSeedProviderOptions.create()
.withNamespace(process.env.K8S_NAMESPACE!)
.withServiceName('my-app')
.withSystemName('my-system')
.withPort(2552);
const nodes = await new KubernetesApiSeedProvider(
kubernetesApiSeedProviderOptions,
).lookup();
const seeds = nodes.map(String); // 'system@host:port'
const clusterOptions = ClusterOptions.create()
.withHost(process.env.POD_IP!)
.withPort(2552)
.withSeeds(seeds);
const cluster = await Cluster.join(system, clusterOptions);

Andere API-Form, dieselbe Idee. K8s-Pod-Discovery → Cluster-Join.

  • Method-Call-Semantik - Orleans-Grain-Aufrufe sind typisierte Methodenaufrufe; actor-ts hat Nachrichten-Typen + Handler. Verboser; expliziter.
  • Verteilte Transaktionen über Grains hinweg - nicht eingebaut.
  • Reminders als First-Class-Primitive - kombiniere Timer + Persistenz.
  • .NET-Ökosystem - Wechsel zur TS- / JS-Runtime.
  • TypeScript für End-to-End-Typisierung.
  • Buns schneller Start vs. .NETs Warm-up.
  • Open-Source-Ökosystem für Runtime + Persistenz-Backends.