본문 바로가기

기타 개발 스킬

Protocol Buffers Language Guide (휘리릭 읽는 프로토콜 버퍼 문법 및 작성 시 유의 사항, 이것만 알면 .proto 파일을 이해할 수 있다?)

반응형

Protocol Buffer를 사용한다면, .proto 파일에 쓰이는 문법을 알고 있어야 한다.

그래야 시행착오를 덜 겪고, 원하는 포맷으로 적절하게 이용할 수 있을 것이다.

* 이 포스트는 protocol buffer 공식 문서에 나오는 "Language Guide(proto3)"를 보고 정리한 내용이다.

(참고로 번역한 것이 아니라 공부하면서 주의해야할 것들만 정리한 것이라 모든 내용을 포함하고 있지 않는다.

추가적으로 이 포스트의 목적은 "휘리릭"이다. 빨리 필요한 것들만 읽고 사용하는데 목적이 있다.)


Protocol buffer(proto3) 문법 및 작성시 유의 사항

- 어떤 문법(proto2, Proto3)을 사용할 지는 proto파일의 첫 줄에 있어야하고 비어있으면 안된다. 주석 또한 있으면 안된다.

- 기본 자료형은 2가지가 있다. 하나는 scalar types(int32, string, ...)이고 다른 하나는 composite types다.

(composite types는 enumerations와 다른 message types를 섞어서 만든다.

- scalar types 정리 (generated class가 어떤 타입으로 생성되는지 프로그래밍 언어별로 알 필요가 있다. (여기서는 자바고 괄호친 타입이 자바의 타입이다.))

  • double(double)
  • float(float)
  • int32(int)
  • int64(long)
  • uint32(int)
  • uint64(long)
  • sint32(int)
  • sint64(long)
  • fixed32(int)
  • fixed64(long)
  • sfixed32(int)
  • sfixed64(long)
  • bool(bool)
  • string(String)
  • byte(ByteString)

- default 값

  • string -> ""(empty string)
  • bytes -> ""(empty string)
  • bool -> false
  • numeric -> 0(zero)
  • enums -> 첫번 째 enum 값 (사실 0(zero)다. enums type을 정의할 때 첫번 째 값은 반드시 0으로 지정해야만 하는데, 그래서 0이 지정되면서 내부적으로 0이 가리키는 그 값이 지정되는 것이다.
  • message fields -> 프로그래밍 언어에 종속성이 있다. (generated code guide를 봐야 한다)
  • List같은 repeated fields -> 비어 있음 

* 데이터에서 명시적으로 default 값을 넣었는지 값을 넣지 않아서 default 값이 적용되었는지 알 수 없다.

* scalar 필드를 default 값으로 설정하면 실제 바이트에서 직렬화되지 않는다. (직렬화해서 default값을 넣어주나 안 넣어주나 어차피 받는 입장에서 없으면 default로 생각할 것이기 때문에! 안 보내는 것이 조금이라도 바이트를 줄일 수 있는 이득이 있다.)

- enums type

enum Hello{
	option allow_alias = true;
	HI = 0;
	HELLO = 1;
	HELLOWORLD = 1;
}

option allow_alias = true;를 설정하면 서로 다른 필드(HELLO, HELLOWORLD)에 대해서 같은 태그 번호(1)를 줄 수 있다.

설정하지 않으면 서로 다른 필드에 같은 태그 번호를 붙일 수 없다.

* 참고로 아까 설명한 enum타입의 첫번째 태그번호는 반드시 0이어야 한다는 것을 지킨 것을 볼 수 있다.

  • enum 값은 32비트 정수 범위여야만 한다. (varint encoding을 사용해서 비효율적이란다.)
  • message type안에 enum을 정의해도 된다. 정의한 enum은 .proto파일의 모든 message type에서 접근 가능하다.
  • python에서는 특별하게 EnumDescriptor 클래스를 사용한다.
  • deserialization(byte->object)중에 인식할 수 없는 값이 오면 default값으로 적용된다. (자바의 경우는 특별하게 UNRECOGNIZED라는 enum type을 generate할 때 추가로 생성해주는데 그것으로 들어온다.)
  • 이미 한 번 정의했던 message type에서 필드를 제거하거나 주석 처리로 없앨 수도 있다. (단, 이렇게 처리하면 추후에 누군가 proto파일을 수정할 텐데, 다른 사람이 무심결에 기존에 사용했던 필드, 태그번호를 사용하다가 동기화되지 않은 proto파일을 사용하는 다른 프로그램과 통신할 때 원치 않는 에러를 만날 수 있는 문제가 있다. 이 문제는 reserved 키워드를 필드에 붙으로 해결할 수 있다.)
enum Foo{
	reserved 2, 15, 3 to 9, 40 to max;
	reserved "FOO", "BAR";
	//...
}

- 필드 번호 지정 룰

  • 필드 번호를 지정할 때는 1번에서 15번까지는 1바이트를 사용하고 16번부터 2047번 까지는 2바이트를 사용한다. 따라서 자주 발생하는 메세지의 필드 번호는 1~15를 매기는 것이 성능, 데이터 크기에 유리하다)
  • 필드 번호 최소 - 1, 최대 2^29-1(536,870,911)
  • 단, 19000~19999번은 예약된 번호라 사용하면 안된다.
  • singular필드는 0~1, repeated필드는 0~N개가 있다.

* proto3에서 repeated numeric scalar type은 압축된 인코딩을 기본적으로 사용한다. (숫자형 repeated 값은 원래는 key-value쌍으로 와야하는데 그렇지 않고 payload size를 지정해서 값만 여러개 들어오는 식으로 인코딩 된다)

message Test4 {
  repeated int32 d = 4 [packed=true];
}

이런 메세지 타입 예제가 있을 때 지난 포스트의 protocol buffer 인코딩에 따르면,

4번 필드에 int32가 type값이 2니까 key는 16진수로 22고, 값이 만약 3, 270, 86942가 반복되어 들어온다고 치면,

"22" , "03"(3), "22", "8E 02"(270), "22" , "9E A7 05"(86942)

"22 03 22 8E 02 22 9E A7 05"이렇게 들어와야 한다.

그러나 payload size를 통한 인코딩으로 다음과 같이 표현된다.

"22"(key) "06"(6byte) "03"(3) "8E 02"(270) "9E A7 05"(86942)

"22 06 03 8E 02 9E A7 05" 이렇게 들어온다.

반복되는 값의 수가 많을 수록 데이터 사이즈는 상대적으로 더 작아져서 효율적이다.

- 같은 proto파일에 정의된 message가 아닐 때 import를 사용하면 된다.(import "myproject/other_protos.proto"

- 위와 같이 직접 경로를 import할 수 있지만 때로는 proto파일의 위치를 변경해야할 때가 있다. 그럴 때는 proto파일의 위치를 옮기고, 기존 위치에 dummy proto파일을 만들어서 그 파일안에 새로 위치를 옮긴 proto를 import하는 방식으로 한다.

- proto2파일을 proto3에서 import할 수 있으나 enum 타입에서 호환성이 떨어지므로 권장하지 않는다.

- message Type 업데이트 (끼존 코드를 손상시키지 않고 message type을 업데이트하는 방법/규칙

  • 기존 필드의 번호를 절대 변경하지 않아야 한다.
  • 필드를 제거할 경우 다음 사용자가 같은 필드 번호를 사용하지 못하게 "OBSOLETE_" prefix를 붙이거나 reserved를 통해 예약해야 한다.
  • int32, uint32, int64, uint64, bool 타입은 서로 호환된다. 따라서 각 타입으로 변경은 상관없다.(단, 64비트 숫자를 32비트로 바꾸면, 32비트로 잘린다.)
  • sint32, sint64는 서로 호환된다.
  • string과 bytes는 bytes가 UTF-8형식이면 호환된다.
  • sfixed32와 fixed32, sfixed64와 fixed64는 호환된다.
  • enum은 int32, uint32, int64, uint64와 호환된다. (역시 비트에 따라 값이 잘린다)
  • enum값 중 UNRECOGNIZED 값은 프로그래밍 언어에 따라 다르게 표시될 것이다. 

- 정의하지 않은 필드 값은 proto3에서 deserialize할 때 항상 버렸지만, 3.5버전 이상부터는 proto3도 proto2와 동작을 일치시키기 위해 UNKNOWN 필드를 다시 적용했다. 직렬화된 출력에 포함된다.

- Any Type(필드에 쓰고, 어떤 Message type이 올지 모르는 상황에서 정의할 수 있는 타입이다, 자바에서 Object가 모든 클래스의 부모 클래스인 것 처럼 Any type에 넣고 뺄 수 있다.

- Any Type을 쓰고 싶으면 import "google/protobuf/any.proto"; 라고 import해야한다.

import "google/protobuf/any.proto";

message ParentMessage {
  string text = 1;
  repeated google.protobuf.Any childMessage = 2;
}

message ChildMessage {
  string text = 1;
}

위의 예제로 자바에서 사용법은 아래와 같다.

- packing

public ParentMessage createMessage() {
    // Create child message
    ChildMessage.Builder childMessageBuilder = ChildMessage.newBuilder();
    childMessageBuilder.setText("Child Text");
    // Create parent message
    ParentMessage.Builder parentMessageBuilder = ParentMessage.newBuilder();
    parentMessageBuilder.setText("Parent Text");
    parentMessageBuilder.setChildMessage(Any.pack(childMessageBuilder.build()));
    // Return message
    return parentMessageBuilder.build();
}

- unpacking

public ChildMessage readChildMessage(ParentMessage parentMessage) {
    try {
        return parentMessage.getChildMessage().unpack(ChildMessage.class);
    } catch (InvalidProtocolBufferException e) {
        e.printStackTrace();
        return null;
    }
}

- one of

만약 message에 여러개의 필드를 정의했는데, 이 message는 동시에 하나의 필드만 설정되는 것일 때는 어떻게 할까

one of 뜻대로 그중에 하나를 사용할 수 있는 것이다.

실제로 코드에서 어떤 값이 사용되었는지는 case() 또는 whichOneof() 메서드를 통해 알 수 있다. (oneof 필드는 repeated를 사용할 수 없다.)

message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

- oneof의 특징

  • 하나의 필드를 설정하면 그 외의 필드는 자동으로 제거된다. (코드에서 여러번 설정한다면 마지막으로 설정한 값이 들어간다)
  • 파싱할 때 유효한 여러 필드가 오면 제일 마지막에 있는 필드로 파싱한다.
  • 리플렉션이 적용된다(무슨말인지...)
  • C++에서는 oneof를 주의해야한다. oneof로 설정된 필드에 두 번 쓰이게 되면 첫번 째 쓰인 값이 날아간다.
  • oneof 필드를 삭제하거나 추가했을 때 oneof 값이 리턴되는 것은 None이나 NOT_SET일 것이다. (이렇게 되면 모르는 값이 왔을 때 이 값이 설정이 안된건지 다른 버전의 필드인지 구분할 수 없다)
  • oneof에서 태그 값을 재사용했을 때 문제점이 많으므로 사용하지 않도록 한다.

- Maps : map<key, value> map_field = N; 이런식으로 map을 만들 수 있다.

  • key값은 int와 string만 사용 가능하다. (부동소수점, 바이트, enum도 안된다.)
  • value는 다른 map을 제외하고 전부다 가능하다.
  • map 필드는 repeated될 수 없다
  • 어떤 순서를 가질 수 없다
  • proto파일을 텍스트 형식으로 generate할 때 map은 key를 기준으로 정렬된다.
  • 파싱할 때 같은 key값이 있으면 마지막으로 온 key와 value가 사용된다.
  • 텍스트 포맷에서 맵으로 파싱할 때 중복이 있으면 파싱에 실패할 수 있다.
  • key는 제공하지만 value는 제공하지 않을 때, C++/Java 같은 경우 value의 기본값이 serialize되지만 다른 언어에서는 안된다.
  • map이 지원되지 않는 프로토콜 버퍼의 버전일 때는 아래와 같이 사용해도 똑같다.
message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

- package : message 충돌방지를 위해서 설정이 필요하고, 프로그래밍 언어마다 generate할 때 어떻게 적용하는지가 다르다. (자바의 경우 option java_package를 지정하지 않으면 자바패키지로 사용한다.)

- Json Mapping

  • proto3는 JSON인코딩을 지원한다. 따라서 다른 시스템간의 공유가 쉽다
  • JSON인코딩 데이터가 없거나 null이면 프로토콜 버퍼로 파싱될 때 default value가 적용된다.
  • 프로토콜 버퍼에서 기본값을 가지면 공간 절약을 위해 json인코딩에서 해당 필드를 제거한다.
  • JSON으로 변환될 때 기본 값을 사용하여 필드를 나타내는 옵션을 제공한다.

- Json mapping rule

  • message(object)
  • enum(string) - enum과 같은 이름이나 정수 값
  • map(object) - 모든 키는 string으로 변환
  • repeated(array) - null은 빈 배열
  • bool(true/false)
  • string(string)
  • bytes(base64 string)
  • int32, fixed32, uint32(number)
  • int64, fixed64, uint64(string)
  • float, double(number)
  • Any(Object)
  • Timestamp(string)
  • Duration(string)
  • Struct(object)
  • ...

- json option

- lowerCamelCase이름 대신 필드 이름 사용 (보통은 필드 이름을 lowerCamelCase로 변환하고 이를 JSON이름으로 사용해야된다.)

- enum값을 문자열 대신 정수로 보낸다.

- proto option

  • proto 파일에 쓸 수 있는 option이 다양하다.(google/protobuf/descriptor.proto에 사용할 수 있는 옵션이 정의되어 있음.
  • 많이 쓰이는 옵션 몇개만 정리한다.
  • java_package(file option) : 자바 패키지 명시, 없으면 proto 패키지명으로 대신한다. (단, 도메인 역방향으로 자동으로 처리되지 않기 때문에 명시를 잘해야함)
  • java_multiple_files(file option) : message, enum, service가 최상위 패키지 수준에서 정의된다.
  • java_outer_classname : generate된 소스의 클래스명을 지정할 수 있다. 별도로 지정하지 않으면 .proto파일의 이름을 camelCase로 변환해서 생된다. java 코드로 generate하지 않으면 해당 옵션이 표기되어 있어도 적용되지 않는다.
  • optimize_for : 컴파일러가 코드를 생성할 때 기준으로 SPEED, CODE_SIZE, LITE_RUNTIME이 존재한다.
  • -> SPEED : default설정으로 직렬화, 파싱에 가장 최적화된 상태로 만들어준다
  • -> CODE_SIZE : 최소한의 클래스를 생성하고 코드 사이즈도 최대한 작게 한다. 대신 작업 속도(직렬화/역직렬화)가 훨씬 느려진다.
  • -> LITE_RUNTIME : libprotobuf대신 libprotobuf-lite에만 의존하는 클래스를 생성한다. 속도는 SPEED모드와 비슷하게 구현하지만 Descriptor, Reflection같은 기능이 사라진다.
  • (option optimize_for = CODE_SIZE;)
  • deprecated : 필드에 주는 옵션으로 새 코드에서 사용하지 않아야함을 나타내준다. 자바의 @Deprecated 주석과 같다.
  • (int32 old_field = 3 [deprecated=true])

이상으로 문법과 주의해야하는 사항을 대략적으로 공부했다.

참고 사이트

https://developers.google.com/protocol-buffers/docs/proto3

반응형