콘텐츠로 이동

Actor

가이드 홈 | 이전: Spot | 다음: STREAM

이 장을 읽고 나면

개체 하나를 id로 만들고, 그것에 메시지를 보내고 답을 받을 수 있다. 이 장의 코드는 예제 저장소의 tutorial README에서 가져왔으며, README의 「내려받기와 설치」·「빌드」·「실행」 절을 따르면 아래 결과를 재현할 수 있다.

Spot이 방·queue처럼 여럿이 함께 사용하는 자리를 다뤘다면, Actor는 플레이어 하나, 세션 하나처럼 개체 단위 상태를 맡는다. 둘 다 id로 호출하고 한 번에 하나씩 처리하는 것은 같고, 다른 것은 Actor가 언제나 어떤 Spot 안에 있다는 점이다. 이 장은 Actor 하나를 만들어 호출하기까지 다루며, 방 사이 이동과 membership은 Actor membership이 다룬다.

1. Actor는 언제나 어떤 Spot에 속한다

Actor는 반드시 어떤 Spot에 속한다. 만들어진 직후에는 Entry Spot에 속하고, 방에 들어가면 그 User Spot으로 옮겨 간다. Entry Spot은 Object Server가 시작할 때 Framework가 만드는 Spot이고, 아직 어느 방에도 속하지 않은 Actor가 속하는 기본 Spot이다.

↗ 크게 보기

그래서 handler가 Spot과 Actor를 함께 받는다. actor id로 보낸 메시지는 그 Actor가 지금 속한 Spot 안에서 실행되고, 그 Spot의 상태와 Actor의 상태를 한 자리에서 다룰 수 있다.

Actor도 Spot과 같은 Location Store를 사용한다. 등록 코드는 Spot에 있는 것과 같다.

2. 받는 쪽 — Actor를 호스팅하는 node

받는 쪽 process

이 절의 코드는 Actor를 실행하는 process에 들어간다.

2.1 Actor 작성

Actor는 상태를 필드로 들고, 자기 id를 context에서 읽는다.

// A player is addressed by its own id and carries state that outlives any one
// connection. Like a room, its messages run one at a time.
public sealed class Player(IZLinkActorContext context) : IZLinkActor
{
    public IZLinkActorContext Context { get; } = context;

    public string Nickname { get; private set; } = "anonymous";

    public void Rename(string nickname) => Nickname = nickname;
}

Actor는 생성자로 만들어지지 않는다. Framework가 factory를 통해 만들므로, 의존성이 필요하면 그 factory에서 받는다.

// The Framework creates players through this factory rather than by calling a
// constructor, so dependencies can be injected here.
public sealed class PlayerFactory : IZLinkActorFactory<Player>
{
    public ValueTask<Player> CreateAsync(
        IZLinkActorContext context,
        CancellationToken cancellationToken = default
    ) => ValueTask.FromResult(new Player(context));

    async ValueTask<IZLinkActor> IZLinkActorFactory.CreateAsync(
        IZLinkActorContext context,
        CancellationToken cancellationToken
    ) => await CreateAsync(context, cancellationToken);
}

2.2 Entry Spot 작성

Actor가 처음 들어갈 자리다. Entry Spot은 application이 만들지 않는다. Object Server마다 하나를 등록한다.

// Every new player lands here before joining a room, and returns here after
// leaving one. A node that hosts players registers exactly one of these.
public sealed class LobbySpot(IZLinkEntrySpotContext context) : IZLinkEntrySpot<Player>
{
    public IZLinkEntrySpotContext Context { get; } = context;

    // Runs after the move is committed, on the side the player arrived at.
    public ValueTask OnJoinedActorAsync(Player player, CancellationToken cancellationToken) =>
        ValueTask.CompletedTask;

    // Runs on the side the player left. The player still exists elsewhere.
    public ValueTask OnLeaveActorAsync(Player player, CancellationToken cancellationToken) =>
        ValueTask.CompletedTask;
}

2.3 handler 작성

handler는 Spot과 Actor를 함께 첫 두 인자로 받는다. 값을 돌려주지 않는 handler는 send로 온 메시지를 받는다.

public sealed class ChangeNicknameHandler
    : IZLinkEntrySpotActorSendHandler<LobbySpot, Player, ChangeNickname>
{
    public async ValueTask HandleAsync(
        LobbySpot lobby,
        Player player,
        IZLinkMessageContext context,
        ChangeNickname message,
        CancellationToken cancellationToken
    )
    {
        player.Rename(message.Nickname);

        // Pushes over the connection bound to this player. The same handler also runs
        // on an HTTP path with no bound connection, where push ends with InvalidOperation.
        // Rename is already complete, so only that failure is discarded.
        try
        {
            await player.Context.BoundSession.Send(new NicknameChanged(player.Nickname)).Async();
        }
        catch (ZLinkFrameworkException error)
            when (error.Kind == ZLinkFrameworkErrorKind.InvalidOperation) { }
    }
}

값을 돌려주는 handler는 그 값이 응답이 된다.

public sealed class GetPlayerHandler
    : IZLinkEntrySpotActorRequestHandler<LobbySpot, Player, GetPlayer, PlayerInfo>
{
    public ValueTask<PlayerInfo> HandleAsync(
        LobbySpot lobby,
        Player player,
        IZLinkMessageContext context,
        GetPlayer request,
        CancellationToken cancellationToken
    ) => ValueTask.FromResult(new PlayerInfo(player.Context.ActorId, player.Nickname));
}

2.4 등록

Entry Spot과 Actor factory를 같은 Object Server에 등록한다. actor type을 등록한 node가 생성 후보가 된다.

// One lobby per object server. Newly created players start there.
objects.AddEntrySpot<LobbySpot>();

// Nodes that register "player" are candidates to host one.
objects.AddActorFactory<Player, PlayerFactory>(
    "player",
    factory => factory.DisableRelocation()
);

3. 호출하는 쪽 — Actor를 호출하는 node

호출하는 쪽 process

이 절의 코드는 Actor를 만들고 호출하는 다른 process에 들어간다. 앞 절과 짝을 이룬다.

등록은 Spot을 부를 때와 같다 — Spot을 호출하는 node의 코드가 그대로 쓰인다.

3.1 만들기

Spot과 달리 id를 호출하는 쪽이 정한다. 플레이어 id처럼 이미 있는 값을 그대로 쓰기 때문이다. 같은 id로 다시 호출하면 만들지 않고 있던 것을 돌려준다.

tutorial의 Server는 game route mesh를 먼저 정의한다. mesh 이름은 Actor를 호스팅할 수 있는 node 집합의 이름이고, Actor를 만들 때 InMesh에 지정한 mesh 안에서 호스팅할 node를 고른다. 등록 코드는 Channel 메시징의 받는 쪽에 있다.

// Both sides must name the mesh identically, or they never see each other
// as peers. The routing id names this node; without it the Framework assigns
// a generated one, which a caller cannot type into a URL.
var mesh = options
    .AddRouteMesh("game")
    .Listen("tcp://127.0.0.1:7201")
    .SetRoutingId(RoutingId.From("game-server-1"));

그 mesh 안에 Actor를 만드는 호출은 다음과 같다.

app.MapPost(
    "/players/{playerId}",
    async (
        string playerId,
        CreatePlayer request,
        IZLinkActorManager players,
        CancellationToken cancellationToken
    ) =>
    {
        // GetOrCreate returns the existing player if there is one. The caller does
        // not choose which node hosts it.
        var result = await players
            .GetOrCreate(playerId, "player")
            .InMesh("game")
            .Request(request)
            .Timeout(TimeSpan.FromSeconds(10))
            .Async(cancellationToken);

        return result switch
        {
            ZLinkActorCreateResult.Existing => Results.Ok("existing"),
            ZLinkActorCreateResult.Created => Results.Ok("created"),
            _ => Results.BadRequest("rejected"),
        };
    }
);

만들었는지 이미 있었는지는 결과가 알려 준다. 처음 만들었으면 created, 있던 것을 찾았으면 existing이다.

3.2 호출하기

actor id만 준다. 그 Actor가 지금 어느 Spot 안에 있는지는 Framework가 찾는다.

app.MapPost(
    "/players/{playerId}/nickname",
    async (
        string playerId,
        ChangeNickname message,
        IZLinkActorClient players,
        CancellationToken cancellationToken
    ) =>
    {
        // Addressed by player id, like a room is by room id.
        await players.SendToActor(playerId, message).Async();

        return Results.Accepted();
    }
);

답이 필요하면 요청을 보낸다.

app.MapGet(
    "/players/{playerId}",
    async (string playerId, IZLinkActorClient players, CancellationToken cancellationToken) =>
    {
        var info = await players
            .RequestToActor(playerId, new GetPlayer())
            .Timeout(TimeSpan.FromSeconds(3))
            .Async<PlayerInfo>(cancellationToken);

        return Results.Ok(info);
    }
);

3.3 참조의 generation

ActorRef·SpotRef의 generation은 같은 id의 몇 번째 instance인지를 세는 값이다. Location Store가 object를 만들 때 발급하며 join·relocation·RecreateOnRelocation으로 바뀌지 않는다. 같은 id를 없앤 뒤 다시 만들면 새 generation을 받는다.

일반 message는 generation을 보지 않고 지금 존재하는 object로 간다. 반면 참조를 통한 닫기·지우기· 이동 같은 lifecycle 호출은 그 참조의 generation이 현재 generation과 같을 때만 효력이 있다. 그래서 낡은 참조는 같은 id로 새로 만든 instance를 건드릴 수 없다.

4. 실행 결과

tutorial README의 「실행」 절을 따라 띄운 상태에서 Server와 Client를 실행한 상태에서 Client의 HTTP 표면에 아래 curl 요청을 보내면, 각 HTTP 응답은 curl stdout에 나오고 handler 기록은 Server process의 stdout 또는 server.log에 나온다.

curl -X POST http://127.0.0.1:5080/players/p7 \
  -H 'Content-Type: application/json' -d '{"nickname":"rookie"}'
# "created"

curl -X POST http://127.0.0.1:5080/players/p7 \
  -H 'Content-Type: application/json' -d '{"nickname":"rookie"}'
# "existing"

curl -X POST http://127.0.0.1:5080/players/p7/nickname \
  -H 'Content-Type: application/json' -d '{"nickname":"veteran"}'
# 202

curl http://127.0.0.1:5080/players/p7
# {"playerId":"p7","nickname":"veteran"}

두 번째 호출은 existing을 돌려준다. 같은 id를 다시 호출하면 만들지 않는다. 이름은 세 번째 호출이 바꾸었고, 네 번째 호출이 바뀐 값을 읽었다.

5. Spot과의 차이

둘 다 id로 호출하고 한 번에 하나씩 처리한다. 고를 때는 id를 누가 정하느냐와 무엇을 담느냐를 본다. Spot의 id는 만들 때 Framework가 발급하고 방·queue처럼 여럿이 함께 사용하는 자리를 담는다. Actor의 id는 호출하는 쪽이 정하고 플레이어·세션처럼 개체 하나의 상태를 담는다.

방 사이 이동은 Actor membership이, 종류별 membership callback은 활성화와 수명이, 이동 중 상태 보존은 Relocation이 다룬다.

6. 관련 문서