콘텐츠로 이동

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.
struct player_t : fw::actor_t
{
    std::string nickname = "anonymous";

    fw::actor_context_t &context () noexcept override { return *_context; }
    const fw::actor_context_t &context () const noexcept override { return *_context; }

    void bind_context (fw::actor_context_t value)
    {
        _context = std::make_unique<fw::actor_context_t> (std::move (value));
    }

    void rename (std::string value) { nickname = std::move (value); }

  private:
    std::unique_ptr<fw::actor_context_t> _context;
};

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

// The Framework creates players through this factory rather than by calling a
// constructor, so dependencies can be injected here.
class player_factory_t : public fw::actor_factory_t<player_t>
{
  public:
    fw::task_t<std::shared_ptr<player_t>> create (fw::actor_context_t context,
                                                  std::stop_token) override
    {
        auto player = std::make_shared<player_t> ();
        player->bind_context (std::move (context));
        co_return player;
    }
};

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.
class lobby_spot_t : public fw::entry_spot_t<player_t>
{
  public:
    explicit lobby_spot_t (fw::entry_spot_context_t context) : _context (std::move (context)) {}

    fw::entry_spot_context_t &context () noexcept override { return _context; }
    const fw::entry_spot_context_t &context () const noexcept override { return _context; }

    // A message addressed to a player runs inside the Spot the player currently
    // occupies, so a handler receives both the Spot and the player.
    void configure () override
    {
        _context.handlers ().add_actor_send<&lobby_spot_t::change_nickname> ();
        _context.handlers ().add_actor_request<&lobby_spot_t::get_player> ();
    }

    fw::task_t<void>
    change_nickname (player_t &player, fw::message_context_t &, const change_nickname_t &message)
    {
        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 {
            co_await player.context ()
              .bound_session ()
              .send (nickname_changed_t{player.nickname})
              .async ();
        }
        catch (const fw::framework_exception_t &error) {
            if (error.kind () != fw::framework_error_kind_t::invalid_operation)
                throw;
        }
    }

    player_info_t get_player (const player_t &player, fw::message_context_t &, const get_player_t &)
    {
        return player_info_t{std::string (player.context ().actor_id ().value ()), player.nickname};
    }

    // Runs when a brand-new player is created. Rejecting here means the create
    // call fails and no player exists.
    fw::task_t<fw::actor_create_response_t> on_create_actor (player_t &,
                                                             const fw::message_t &) override
    {
        co_return fw::actor_create_response_t::accept ();
    }

    fw::task_t<void> on_actor_joined (player_t &) override { co_return; }

    fw::task_t<void> on_leave_actor (player_t &) override { co_return; }

  private:
    fw::entry_spot_context_t _context;
};

2.3 handler 작성

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

fw::task_t<void>
change_nickname (player_t &player, fw::message_context_t &, const change_nickname_t &message)
{
    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 {
        co_await player.context ()
          .bound_session ()
          .send (nickname_changed_t{player.nickname})
          .async ();
    }
    catch (const fw::framework_exception_t &error) {
        if (error.kind () != fw::framework_error_kind_t::invalid_operation)
            throw;
    }
}

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

player_info_t get_player (const player_t &player, fw::message_context_t &, const get_player_t &)
{
    return player_info_t{std::string (player.context ().actor_id ().value ()), player.nickname};
}

2.4 등록

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

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

// Nodes that register "player" are candidates to host one.
objects.add_actor_factory<player_t, player_factory_t> ("player").disable_relocation ();

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

호출하는 쪽 process

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

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

3.1 만들기

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

tutorial의 Server는 game route mesh를 먼저 정의한다. mesh 이름은 Actor를 호스팅할 수 있는 node 집합의 이름이고, Actor를 만들 때 in_mesh에 지정한 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.
zlink::routing_id_t routing_id = zlink::routing_id_t::from ("game-server-1");
auto mesh = options.add_route_mesh ("game")
              .listen ("tcp://127.0.0.1:7401")
              .set_routing_id (routing_id)
              // Publish the same loopback address that this node listens on.
              .set_advertise_host ("127.0.0.1");

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

// get_or_create returns the existing player if there is one. The caller does not
// choose which node hosts it.
class create_player_http_handler_t
{
  public:
    explicit create_player_http_handler_t (fw::actor_manager_t &players) : _players (players) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto player_id = request.route_values.at ("playerId");
        const auto body = nlohmann::json::parse (request.body).get<create_player_t> ();

        auto created = co_await _players.get_or_create (fw::actor_id_t (player_id), "player")
                         .in_mesh ("game")
                         .creation_request (body)
                         .timeout (std::chrono::seconds (10))
                         .async ();

        const auto state = std::holds_alternative<fw::actor_create_existing_t> (created)
                             ? "existing"
                           : std::holds_alternative<fw::actor_create_created_t> (created)
                             ? "created"
                             : "rejected";
        co_return fw::http_response_t{200, nlohmann::json (state).dump ()};
    }

  private:
    fw::actor_manager_t &_players;
};

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

3.2 호출하기

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

class change_nickname_http_handler_t
{
  public:
    explicit change_nickname_http_handler_t (fw::actor_client_t &players) : _players (players) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto player_id = request.route_values.at ("playerId");
        const auto body = nlohmann::json::parse (request.body).get<change_nickname_t> ();

        // Addressed by player id, like a room is by room id.
        co_await _players.send (fw::actor_id_t (player_id), body).async ();

        co_return fw::http_response_t{202, ""};
    }

  private:
    fw::actor_client_t &_players;
};

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

class get_player_http_handler_t
{
  public:
    explicit get_player_http_handler_t (fw::actor_client_t &players) : _players (players) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto player_id = request.route_values.at ("playerId");

        auto info = co_await _players.request (fw::actor_id_t (player_id), get_player_t{})
                      .timeout (std::chrono::seconds (3))
                      .async<player_info_t> ();

        co_return fw::http_response_t{200, nlohmann::json (info).dump ()};
    }

  private:
    fw::actor_client_t &_players;
};

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. 관련 문서