콘텐츠로 이동

Node Protobuf 송수신

목차 | 이전: 게임 엔진 통합 | 다음: Node Protobuf codec과 타입

이 장을 읽고 나면

.proto에서 TypeScript용 클래스를 생성하고, Protobuf push와 요청 응답을 받을 수 있다. 코드는 framework/languages/node/tutorial/StreamClient의 실행 예제에서 읽는다.

서버가 Protobuf payload를 쓰는 경우 client도 같은 .proto 정의로 bytes를 읽어야 한다. 이 예제는 codec 하나로 Ping과 Pong push를 각각 on(Type, handler)에 지정한 타입으로 받는다. 메시지 종류가 255개여도 codec을 255개 만들 필요는 없다. 각 생성 클래스를 handler에 전달한다. 타입 선택과 이름의 관계는 Protobuf codec과 타입이 설명한다.

1. 메시지 코드 생성

.proto는 메시지의 field 번호와 타입을 정의하는 파일이다. 이 예제는 문자열을 보내는 Ping과 숫자를 보내는 Pong을 정의한다.

StreamClient/messages.proto
syntax = "proto3";
package tutorial;

// Both message types are received through one connector codec.
message Ping { string text = 1; }
message Pong { uint32 rank = 1; }

framework/languages/node/tutorial/StreamClient에서 의존성을 설치하고 생성 명령을 실행한다. protobufjs-cli의 static-module은 메시지마다 실행 시점에 존재하는 생성자를 만든다.

npm ci
npm run generate:protobuf

명령은 pbjs -t static-module -w commonjs로 generated/messages.cjs를 만들고, pbts로 generated/messages.d.cts를 만든다. .cjs 확장자는 이 ESM project에서 생성 파일을 CommonJS로 읽게 한다. 이 파일들은 빌드할 때 다시 생성한다.

2. codec 등록

생성 메시지와 @zlink-systems/framework-codec-protobuf의 browser 진입점을 가져온다. server serializer용 ./framework 진입점은 client에서 사용하지 않는다.

StreamClient/protobuf.ts
import { createZlinkStreamProtobufCodec } from '@zlink-systems/framework-codec-protobuf';
import {
  ZlinkStreamDispatchMode,
  zlinkStreamConnectorFactory,
  type ZlinkStreamMessage
} from '@zlink-systems/stream-connector';
import messages from './generated/messages.cjs';

const { Ping, Pong } = messages.tutorial;

connector를 만들 때 codec 하나를 지정한다. 여기서 Ping은 수신 타입이 없는 경우의 기본값이다. 송신은 생성 instance의 타입을, 타입 지정 수신은 handler에 전달한 생성자를 사용한다. endpoint는 Protobuf를 사용하는 서버의 WebSocket 주소다.

StreamClient/protobuf.ts
// Ping is the fallback when no runtime decoding type is supplied.
const connector = zlinkStreamConnectorFactory.create({
  endpoint,
  codec: createZlinkStreamProtobufCodec(Ping),
  dispatchMode: ZlinkStreamDispatchMode.Immediate
});

3. 받는 쪽 — push handler

on(Ping, handler)는 Ping을, on(Pong, handler)는 Pong을 디코딩 타입으로 전달한다. 같은 codec이 두 생성자의 decode를 각각 호출하므로 handler는 서로 다른 타입의 instance를 받는다. 예제는 이름을 직접 지정하는 형태와 타입을 생략해 기본값을 쓰는 형태도 함께 보여 준다.

↗ 크게 보기

StreamClient/protobuf.ts
// The constructor supplies the packet name and runtime decoding type.
const byType = connector.on<messages.tutorial.Ping>(Ping, handlePing);
// An explicit wire name can differ from the generated class name.
const byNameAndType = connector.on<messages.tutorial.Ping>('Ping', handlePing, Ping);
// Without a runtime type, the codec falls back to Ping.
const byName = connector.on<messages.tutorial.Ping>('Ping', handlePing);
// The same codec decodes Pong with the handler's generated constructor.
const byPong = connector.on<messages.tutorial.Pong>(Pong, (message) => {
  if (!(message.payload instanceof Pong)) {
    rejectPong(new Error('Pong handler received a payload with the wrong generated type.'));
    return;
  }
  resolvePong(message.payload.rank);
});

서버의 packet 이름이 Ping이므로 Ping 생성자를 받는 등록과 이름을 직접 받는 등록이 같은 push를 받는다. 생성 클래스 이름과 wire 이름이 다르면 명시 이름 형태를 사용한다.

4. 보내는 쪽 — send와 request

앞 절의 handler가 받을 메시지를 보내려면 생성 클래스의 instance를 사용한다. connector는 그 생성자에서 packet 이름을 정하고, codec은 해당 생성자의 encode로 bytes를 만든다.

StreamClient/protobuf.ts
// Use a generated instance so its constructor supplies the packet name.
await connector.send(new Ping({ text: 'hello' })).submit();
// The peer sends both push types after receiving Ping.
const [, pushRank] = await Promise.all([pushed, pongPushed]);
await connector.send(new Pong({ rank: 3 })).submit();

Pong으로 오는 응답은 submit(Pong)으로 받는다. request 빌더는 생성 클래스를 기존 codec의 decode(payload, Pong)에 전달하고 Pong instance를 반환한다. 취소 신호가 있으면 submit(Pong, signal)로 전달한다.

↗ 크게 보기

StreamClient/protobuf.ts
// Pass the generated constructor to the existing codec's decode method.
const reply = await connector.request(new Ping({ text: 'rank' })).submit(Pong);

callback으로 받으려면 submitCallback(Pong, callback)을 사용한다. 기본 dispatch 모드에서는 dispatch()를 호출할 때 callback이 실행된다. 이 tutorial은 Immediate 모드를 사용한다.

StreamClient/protobuf.ts
const callbackReply = await new Promise<messages.tutorial.Pong>((resolve, reject) => {
  connector.request(new Ping({ text: 'rank' })).submitCallback(Pong, (result) => {
    if (result.isSuccess) resolve(result.value!);
    else reject(new Error(result.error!.message));
  });
});

submit<Pong>()의 타입 인자는 실행 시점에 없어지므로 생성 클래스를 전달하는 submit(Pong)을 사용한다. 저수준 대안은 submitEncoded()로 받은 bytes를 fromProto(encodedReply, Pong)으로 읽는 것이다.

5. 실행 결과

이 명령은 StreamClient의 검증용 WebSocket peer를 임시 로컬 포트에 실행하고 같은 tutorial 함수를 호출한다. 별도로 Server를 실행할 필요가 없다. peer는 Ping과 Pong { rank: 3 }을 push로 보내고, request에는 Pong { rank: 7 }으로 응답한다.

cd framework/languages/node/tutorial/StreamClient
npm ci
# 수정 배포 전: 로컬 connector와 codec 소스를 빌드한다.
npm run prepare:local
npm run protobuf:check
# protobuf: Ping=hello, Pong.rank=3, reply.rank=7

검증은 실제 WebSocket 송수신, 두 메시지 타입의 handler payload와 송신 bytes, 응답 타입을 확인한다. 잘못된 codec 번호와 손상된 Protobuf bytes가 거부되는지도 확인한다. 검증 프로그램은 Node의 WebSocket으로 서버에 연결하며 browser용 connector 진입점을 실행한다.

6. 관련 문서