콘텐츠로 이동

Spot

가이드 홈 | 이전: Channel 메시징 | 다음: Actor

이 장을 읽고 나면

id로 호출하는 상태 객체를 만들고, 그것에 메시지를 보내고 답을 받을 수 있다. 이 장의 코드는 언어별 예제 저장소의 tutorial에서 가져왔다.

Channel 메시징의 호출은 이름을 맡은 node 중 하나가 받았다. 받을 대상이 정해져 있으면 그 경로를 사용할 수 없다. Spot은 id로 찾는 상태 객체이고, 자기 앞으로 온 일을 한 줄로 세워 처리한다. 이 장은 Spot 하나를 만들어 호출하기까지 다루며, 종류별 차이와 lifecycle 전체는 Spot 동작 원리에서 다룬다.

1. Spot의 역할

Spot은 채팅방 하나, 매칭 queue 하나처럼 무언가를 기억하면서 자기 앞으로 온 일을 순서대로 처리하는 단위를 만든다. channel로는 그것을 표현할 수 없다 — channel 호출은 그 이름을 맡은 node 중 하나로 가므로, 같은 방에 보낸 두 메시지가 서로 다른 node에 도착할 수 있다.

▶ 둘러보기에서 직접 눌러 보기 — 로비와 던전

Spot은 id를 가진다. 같은 id로 보낸 메시지는 언제나 같은 Spot에 도착하고, 그 Spot 안에서 한 번에 하나씩 실행된다. 그래서 Spot의 필드는 lock 없이 다뤄도 된다.

↗ 크게 보기

만드는 쪽도 호출하는 쪽도 node를 고르지 않는다. 그 점이 channel과 같고, 대상이 하나로 정해진다는 점이 channel과 다르다.

2. Location Store — Spot 등록의 선행 조건

Spot은 host가 아니라 id로 호출된다. 그래서 지금 어느 node에 있는지를 적어 둘 곳이 필요하다. 그것이 Location Store이고, Spot을 하나라도 등록하면 반드시 있어야 한다.

// Rooms and players are addressed by id, not by host, so their current
// location is kept here. Every node reads and writes the same store under
// the same prefix.
options.AddLocationStore(
    new ZLinkRedisLocationStore(redis =>
    {
        redis.ConnectionString = "127.0.0.1:6379";
        redis.KeyPrefix = "zlink-tutorial";
    })
);

Relocation Store도 함께 등록한다. Spot factory를 등록하는 것 자체가 조건이라, 이 장처럼 이동을 끄더라도 요구된다.

// Registering any instance-spot factory requires this store, even with
// relocation turned off: the registration itself is the condition.
options.AddRelocationStore(
    new ZLinkRedisRelocationStore(redis =>
    {
        redis.ConnectionString = "127.0.0.1:6379";
        redis.KeyPrefix = "zlink-tutorial";
    })
);

호출만 하는 node에는 Location Store만 있으면 된다 — 호출하는 쪽 — Spot을 호출하는 node.

3. 받는 쪽 — Spot을 호스팅하는 node

받는 쪽 process

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

3.1 Spot 작성

Spot은 상태를 필드로 들고, 만들어질 때 받은 payload로 자신을 초기화한다.

// A room owns its own state and is addressed by a global SpotId. Messages sent
// to one room run one at a time, so the fields below need no synchronization.
public sealed class GameRoom(IZLinkSpotContext context) : IZLinkSpot
{
    private readonly List<string> _chat = [];

    private string _title = "untitled";

    public IZLinkSpotContext Context { get; } = context;

    public string Title => _title;

    public IReadOnlyList<string> Chat => _chat;

    // Runs before the room accepts any message. Rejecting here means the create
    // call fails and no room exists. Omit this method to accept every request.
    public ValueTask<ZLinkSpotCreateResponse> OnCreateAsync(
        ZLinkMessage request,
        CancellationToken cancellationToken
    )
    {
        var room = request.Decode<OpenRoom>();
        _title = room.Title;
        return ValueTask.FromResult(ZLinkSpotCreateResponse.Accept());
    }

    public void Append(string line) => _chat.Add(line);
}

만들 때 거절하려면 생성 callback에서 거절한다. 그러면 생성 호출이 실패하고 Spot은 남지 않는다. 그 callback을 두지 않으면 모든 생성 요청을 받아들인다.

3.2 handler 작성

handler는 대상 Spot을 첫 인자로 받는다. channel handler와 달리 그 Spot의 상태를 직접 다룬다.

public sealed class PostChatHandler : IZLinkSpotPacketHandler<GameRoom, PostChat>
{
    public ValueTask HandleAsync(
        GameRoom room,
        PostChat message,
        CancellationToken cancellationToken
    )
    {
        var line = $"{message.PlayerId}: " + message.Text;
        room.Append(line);
        return ValueTask.CompletedTask;
    }
}

// The return value is the reply. This handler only reads.
public sealed class GetRoomStateHandler
    : IZLinkSpotRequestHandler<GameRoom, GetRoomState, RoomState>
{
    public ValueTask<RoomState> HandleAsync(
        GameRoom room,
        GetRoomState request,
        CancellationToken cancellationToken
    )
    {
        var chat = room.Chat.ToArray();
        var state = new RoomState(room.Title, chat);
        return ValueTask.FromResult(state);
    }
}

반환값이 없는 handler는 send로 온 메시지를 받고, 값을 돌려주는 handler는 그 값이 응답이 된다. 두 handler는 같은 Spot에서 한 번에 하나씩 실행된다.

3.3 등록

mesh node가 Object Server 역할을 한 번 고르고, 그 위에 Spot factory를 등록한다.

// A mesh node picks this role once. Keep the builder and reuse it, because
// calling Objects().Server() a second time is rejected at startup.
var objects = mesh.Objects().Server();

// "game-room" is the stable type a caller names when opening a room. Any
// node that registers it is a candidate to host one.
objects.AddSpotFactory<GameRoom>(
    "game-room",
    // Exactly one relocation policy is required. Moving a live room to
    // another node is a separate topic.
    factory => factory.DisableRelocation()
);

등록할 때 주는 이름이 stable type이다. 호출하는 쪽은 Spot을 만들 때 그 이름을 지정한다. 같은 이름을 등록한 node가 후보가 된다. Spot factory를 등록할 때 이동 정책을 하나 지정하며, 이 장의 코드는 이동을 끈 정책을 지정한다. adapter로 상태를 보존하려면 이 등록에서 preserveStateWith를 지정한다 — Relocation의 adapter가 그 상태를 담고 푸는 코드를 보인다.

4. 호출하는 쪽 — Spot을 호출하는 node

호출하는 쪽 process

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

호출하는 node는 Spot을 실행하지 않으므로 factory를 등록하지 않는다. 대신 그 mesh에서 client 역할을 고르고, 위치를 읽을 Location Store를 같은 prefix로 등록한다.

// Rooms and players are looked up by whoever calls them, so a node that
// hosts none still needs the store, pointed at the same prefix.
options.AddLocationStore(
    new ZLinkRedisLocationStore(redis =>
    {
        redis.ConnectionString = "127.0.0.1:6379";
        redis.KeyPrefix = "zlink-tutorial";
    })
);

// Client() rules out registering factories. This node creates and calls
// rooms and players; a node that picked Server() runs them.
mesh.Objects().Client();

4.1 만들기

같은 id에 항상 같은 Spot type을 사용하는 stable type을 지정해 Spot을 만든다. 응답으로 오는 id가 그 뒤 모든 호출의 주소다.

먼저 tutorial의 Server가 game route mesh를 정의한다. mesh 이름은 Spot이 배치될 수 있는 node 집합의 이름이고, InMesh는 route mesh에 참여한 node 가운데 Spot을 배치할 node를 고른다. route mesh를 등록하는 방법은 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 안에 Spot을 만드는 호출은 다음과 같다.

app.MapPost(
    "/rooms",
    async (OpenRoom request, IZLinkSpotManager rooms, CancellationToken cancellationToken) =>
    {
        var created = await rooms
            .Create("game-room") // Picks the factory and the candidate nodes.
            .InMesh("game")
            .Request(request) // Reaches the room's create callback.
            .Async(cancellationToken);

        // From here on the room is addressed by this id alone.
        return Results.Ok(created.Spot.SpotId);
    }
);

4.2 호출하기

만들 때 받은 id만 준다. 지금 어느 node에 있는지는 Framework가 찾는다.

app.MapPost(
    "/rooms/{roomId}/chat",
    async (
        string roomId,
        PostChat message,
        IZLinkSpotClient rooms,
        CancellationToken cancellationToken
    ) =>
    {
        // The id is enough; the Framework resolves where the room currently runs.
        await rooms.SendToSpot(roomId, message).Async();

        return Results.Accepted();
    }
);

답이 필요하면 요청을 보낸다. 대상이 옮겨 가는 중일 수 있으므로 timeout을 함께 준다.

app.MapGet(
    "/rooms/{roomId}",
    async (string roomId, IZLinkSpotClient rooms, CancellationToken cancellationToken) =>
    {
        var state = await rooms
            .RequestToSpot(roomId, new GetRoomState())
            .Timeout(TimeSpan.FromSeconds(3))
            .Async<RoomState>(cancellationToken);

        return Results.Ok(state);
    }
);

5. 실행 결과

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/rooms \
  -H 'Content-Type: application/json' -d '{"title":"lobby"}'
# "8213420c-03ff-47a6-8410-ddfb3df26660"

curl -X POST http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660/chat \
  -H 'Content-Type: application/json' -d '{"playerId":"p1","text":"hello"}'
# 202

curl http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660
# {"title":"lobby","chat":["p1: hello"]}

id는 Framework가 만든다. 두 번째 호출은 응답을 기다리지 않고, 세 번째는 방이 만든 답을 받는다. 방은 두 호출 사이에 두 호출 사이에도 상태를 유지한다.

6. Spot의 다른 종류

이 장이 만든 것은 application이 명시적으로 만드는 Spot이고, 대부분의 방·스테이지·존이 여기 해당한다. 그 밖에 Object Server가 시작할 때 Framework가 만드는 것과, 첫 메시지가 도착할 때 만들어지는 것이 있다. 생성 시점과 받는 lifecycle callback이 서로 다르다.

▶ 둘러보기에서 직접 눌러 보기 — Instance Spot

종류별 차이와 lifecycle 전체는 Spot이 다룬다.

7. 관련 문서

  • id로 호출하는 또 다른 단위 — Actor
  • 이름으로 호출하는 경로 — Channel 메시징
  • Location Store가 무엇을 적어 두는가 — Channel 동작 원리
  • 이 장 코드의 실행본 — framework/languages/dotnet/tutorial