콘텐츠로 이동

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 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.add_location_store<fw::redis::redis_location_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:location:");

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

// Registering any Spot factory requires this store, even with relocation
// turned off: the registration itself is the condition.
options.add_relocation_store<fw::redis::redis_relocation_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:relocation:");

호출만 하는 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.
//
// Every C++ user Spot names the actor type it can admit. This room admits none,
// so it names the base type and rejects every join. Actors are a later chapter.
class game_room_t : public fw::spot_t<fw::actor_t>
{
  public:
    explicit game_room_t (fw::spot_context_t context) : _context (std::move (context)) {}

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

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

    // Handlers are member functions named here. A member returning nothing is a
    // send handler; one returning a value answers a request with it.
    void configure () override
    {
        _context.handlers ().add_handler<&game_room_t::post_chat> ();
        _context.handlers ().add_handler<&game_room_t::get_room_state> ();
    }

    void post_chat (const post_chat_t &message)
    {
        auto line = message.player_id + ": " + message.text;
        _chat.push_back (line);
    }

    // The return value is the reply. This handler only reads.
    room_state_t get_room_state (const get_room_state_t &) const
    {
        return room_state_t{_title, _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.
    fw::task_t<fw::spot_create_response_t> on_create (const fw::message_t &request) override
    {
        const auto &body = request.decode<open_room_t> ();
        _title = body.title;
        co_return fw::spot_create_response_t::accept ();
    }

    // No actor ever joins this room, so the three membership callbacks below say
    // so and do nothing else.
    fw::task_t<fw::spot_actor_join_result_t> on_actor_join (std::string_view,
                                                            const fw::message_t &) override
    {
        co_return fw::spot_actor_join_result_t ::reject ();
    }

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

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

  private:
    fw::spot_context_t _context;
    std::string _title = "untitled";
    std::vector<std::string> _chat;
};

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

3.2 handler 작성

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

// Handlers are member functions named here. A member returning nothing is a
// send handler; one returning a value answers a request with it.
void configure () override
{
    _context.handlers ().add_handler<&game_room_t::post_chat> ();
    _context.handlers ().add_handler<&game_room_t::get_room_state> ();
}

void post_chat (const post_chat_t &message)
{
    auto line = message.player_id + ": " + message.text;
    _chat.push_back (line);
}

// The return value is the reply. This handler only reads.
room_state_t get_room_state (const get_room_state_t &) const
{
    return room_state_t{_title, _chat};
}

반환값이 없는 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.
auto 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. Exactly one
// relocation policy is required; moving a live room to another node is a
// separate topic.
objects.add_spot_factory<game_room_t> ("game-room").disable_relocation ();

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

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

호출하는 쪽 process

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

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

// Rooms are looked up by whoever calls them, so a node that hosts none
// still needs the store, pointed at the same prefix.
options.add_location_store<fw::redis::redis_location_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:location:");

// client() rules out registering factories. This node creates and calls
// rooms; 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 집합의 이름이고, in_mesh는 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.
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 안에 Spot을 만드는 호출은 다음과 같다.

// spot_manager_t creates a room; the reply carries the id every later call uses.
class open_room_http_handler_t
{
  public:
    explicit open_room_http_handler_t (fw::spot_manager_t &rooms) : _rooms (rooms) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto body = nlohmann::json::parse (request.body).get<open_room_t> ();

        auto created = co_await _rooms
                         .create ("game-room") // Picks the factory and the candidate nodes.
                         .in_mesh ("game")
                         .creation_request (body) // Reaches the room's create callback.
                         .async ();

        // From here on the room is addressed by this id alone.
        co_return fw::http_response_t{200, nlohmann::json (created.spot.spot_id ()).dump ()};
    }

  private:
    fw::spot_manager_t &_rooms;
};

4.2 호출하기

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

class post_chat_http_handler_t
{
  public:
    explicit post_chat_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto room_id = request.route_values.at ("roomId");
        const auto message = nlohmann::json::parse (request.body).get<post_chat_t> ();

        // The id is enough; the Framework resolves where the room currently runs.
        co_await _routes.send_to_spot (room_id, message).async ();

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

  private:
    fw::route_client_t &_routes;
};

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

class get_room_state_http_handler_t
{
  public:
    explicit get_room_state_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}

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

        auto state = co_await _routes.request_to_spot (room_id, get_room_state_t{})
                       .timeout (std::chrono::seconds (3))
                       .async<room_state_t> ();

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

  private:
    fw::route_client_t &_routes;
};

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/cpp/tutorial