https://protobuf.dev/programming-guides/encoding/
Protobuf가 message를 버퍼로 인코딩하는 방법에 대해 설명합니다.
이 문서에서는 메시지가 wire format으로 변환되는 방식과 차지하는 공간에 대한 세부 사항을 정의하는 프로토콜 버퍼 wire format에 대해 설명합니다. 애플리케이션에서 프로토콜 버퍼를 사용하기 위해 이를 이해할 필요는 없지만 최적화를 작업을 위해 유용한 정보입니다.
Protoscope는 low-level wire format을 설명하기 위한 매우 간단한 언어로, 다양한 메시지 인코딩을 위한 시각적 참조를 제공하는 데 사용됩니다. Protoscope의 구문은 각각 특정 바이트 시퀀스로 인코딩되는 토큰 시퀀스로 구성됩니다.
예를 들어, 물결표시 아래 작은 따옴표 `70726f746f6275660a`와 같은 원시 16진수 리터럴을 나타냅니다. 이는 리터럴에서 16진수로 표시된 정확한 바이트로 인코딩됩니다. 따옴표는 "Hello, Protobuf!"와 같이 UTF-8 문자열을 나타냅니다. 이 리터럴은 `48656c6c6f2c2050726f746f62756621`과 동의어입니다(자세히 보면 ASCII 바이트로 구성됨). wire format에 대해 소개하면서 Protoscope 언어에 대해 더 자세히 소개하겠습니다.
Protoscope 도구는 인코딩된 프로토콜 버퍼를 텍스트로 덤프할 수도 있습니다. 예제는 https://github.com/protocolbuffers/protoscope/tree/main/testdata 을 참조하세요.
다음과 같은 매우 간단한 메시지 정의가 있다고 가정해 봅시다:
message Test1 {
optional int32 a = 1;
}
애플리케이션에서 Test1 메시지를 생성하고 a를 150으로 설정합니다. 그런 다음 메시지를 출력 스트림으로 직렬화합니다. 인코딩된 메시지를 보면 3바이트가 표시됩니다:
08 96 01
이 byte들은 무슨 뜻일까요? Protoscope를 사용해 이 byte를 덤프하면 1:150
가 출력됩니다. 이것이 메시지 내용이라는 것을 어떻게 알 수 있을까요?
가변 폭 정수, 즉 varint는 wire format의 핵심입니다. varint를 사용하면 1바이트에서 10바이트 사이의 부호 없는 64비트 정수를 인코딩할 수 있으며, 작은 값은 더 적은 바이트를 사용합니다.
varint의 각 바이트에는 그 뒤에 오는 바이트가 varint의 일부인지 여부를 나타내는 연속 비트가 있습니다. 이것은 바이트의 가장 중요한 비트(MSB)입니다(부호 비트라고도 함). 하위 7비트는 페이로드이며, 결과 정수는 구성 바이트의 7비트 페이로드를 합산하여 만들어집니다.
예를 들어 `01`로 인코딩된 숫자 1은 단일 바이트이므로 MSB가 설정되지 않습니다:
0000 0001
^ msb
그리고 여기 `9601`로 인코딩된 150은 좀 더 복잡합니다:
10010110 00000001
MSB ^ MSB
이것이 150이라는 것을 어떻게 알 수 있을까요? 먼저 각 바이트에서 MSB를 삭제하면 되는데, 이는 숫자의 끝에 도달했는지 여부를 알려주기 위한 것입니다(보시다시피 변수에 바이트가 하나 이상 있기 때문에 첫 번째 바이트에 설정되어 있습니다). 이 7비트 페이로드는 리틀 엔디안으로 되어 있습니다. 빅 엔디안으로 변환하고 연결한 다음 부호 없는 64비트 정수로 해석합니다:
10010110 00000001 // 원본.
0010110 0000001 // 연결 비트를 삭제합니다.
0000001 0010110 // 빅엔디안으로 변환합니다.
00000010010110 // 연결합니다.
128 + 16 + 4 + 2 = 150 // 부호 없는 64비트 정수로 해석합니다.
varint는 프로토콜 버퍼에서 매우 중요하기 때문에 프로토콜 구문에서는 이를 일반 정수라고 부릅니다. 150은 `9601`과 동일합니다.
프로토콜 버퍼 메시지는 일련의 키-값 쌍입니다. 메시지의 바이너리 버전은 필드 번호만 키로 사용하며, 각 필드의 이름과 선언된 유형은 메시지 유형의 정의(즉, .proto 파일)를 참조하여 디코딩 끝에서만 확인할 수 있습니다. Protoscope는 이 정보에는 접근할 수 없으므로 필드 번호만 제공할 수 있습니다.
메시지가 인코딩되면 각 키-값 쌍은 필드 번호, wire type 및 페이로드로 구성된 레코드로 바뀝니다. wire type은 구문 분석기에 뒤에 오는 페이로드의 크기를 알려줍니다. 이를 통해 구식 구문 분석기는 이해하지 못하는 새로운 필드를 건너뛸 수 있습니다. 이러한 유형의 체계를 Tag-Length-Value 또는 TLV라고도 합니다.
6가지 wire type이 있습니다: VARINT
, I64
, LEN
, SGROUP
, EGROUP
, I32
ID Name
Used For
0 VARINT
int32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64
fixed64, sfixed64, double
2 LEN
string, bytes, embedded messages, packed repeated fields
3 SGROUP
그룹 시작(deprecated)
4 EGROUP
그룹 종료(deprecated)
5 I32
fixed32, sfixed32, float
레코드의 '태그'는 필드 번호와 wire type으로 구성된 varint로 인코딩되며, (field_number << 3) | wire_type
수식을 통해 인코딩됩니다. 즉, 필드를 나타내는 varint를 디코딩한 후 하위 3비트는 wire type을, 나머지 정수는 필드 번호를 알려줍니다.
이제 간단한 예제를 다시 살펴보겠습니다. 이제 스트림의 첫 번째 숫자는 항상 varint 키이며, 여기서는 08
또는 (MSB를 삭제)라는 것을 알 수 있습니다:
000 1000
마지막 세 비트를 가져와 wire type(0)을 구한 다음 오른쪽으로 3씩 시프트하여 필드 번호(1)를 구합니다. Protoscope는 태그를 정수 다음에 콜론과 wire type으로 표시하므로 위의 바이트는 1:VARINT
로 쓸 수 있습니다.
wire type이 0, 즉 VARINT이므로 페이로드를 얻으려면 varint를 디코딩해야 한다는 것을 알 수 있습니다. 위에서 보았듯이 9601
바이트는 150으로 varint-디코딩되어 레코드를 제공합니다. 이를 Protoscope에서 1:VARINT 150
으로 출력합니다.
Protoscope는 :
뒤에 공백이 있는 경우 태그의 유형을 유추할 수 있습니다. 이는 다음 토큰을 미리 보고 사용자가 무엇을 의미하는지 추측하는 방식으로 이루어집니다(규칙은 Protoscope의 language.txt에 자세히 문서화되어 있습니다). 예를 들어, 1: 150
에서 유형이 지정되지 않은 태그 바로 뒤에 varint가 있으므로 Protoscope는 그 유형을 VARINT로 유추합니다. 2: {}
라고 쓰면 {를 보고 LEN을 추측하고, 3: 5i32
라고 쓰면 I32를 추측하는 식입니다.
Bool과 enum은 모두 int32처럼 인코딩됩니다. 특히 Bool은 항상 `00` 또는 `01`로 인코딩됩니다. Protoscope에서 false와 true는 이러한 바이트 문자열의 aliase입니다.
이전 섹션에서 살펴본 것처럼 wire type은 0과 관련된 모든 프로토콜 버퍼 유형은 varint로 인코딩됩니다. 그러나 varints는 부호가 없으므로 부호가 있는 다른 유형인 sint32 및 sint64와 int32 또는 int64는 음수 정수를 다르게 인코딩합니다.
intN
유형은 음수를 2의 보수로 인코딩하므로 부호가 없는 64비트 정수로써 가장 높은 비트 집합을 갖습니다. 결과적으로 이는 10바이트를 모두 사용해야 함을 의미합니다. 예를 들어, -2는 Protoscope에 의해 다음과 같이 변환됩니다.
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
This is the two’s complement of 2, defined in unsigned arithmetic as ~0 - 2 + 1, where ~0 is the all-ones 64-bit integer. It is a useful exercise to understand why this produces so many ones.
sintN
은 음의 정수를 인코딩할 때 2의 보수 대신 "지그재그" 인코딩을 사용합니다. 양의 정수 p는 2 p(짝수)로 인코딩되고, 음의 정수 n은 2 |n| - 1(홀수)로 인코딩됩니다. 따라서 인코딩은 양수와 음수 사이를 "지그재그로" 인코딩합니다. 예를 들어
Signed Original|Encoded As
0 0
-1 1
1 2
-2 3
... ...
0x7FFFFFFF 0xFFFFFFFE
-0x80000000 0xffffffff
즉, 각 값 n은 다음을 사용하여 인코딩됩니다.
(n << 1) ^ (n >> 31)
를 사용하거나
(n << 1) ^ (n >> 63)
를 사용하여 인코딩합니다.
sint32 또는 sint64가 구문 분석되면 해당 값은 원래의 서명된 버전으로 다시 디코딩됩니다.
Protoscope에서 정수 뒤에 z를 붙이면 지그재그로 인코딩됩니다. 예를 들어 -500z
는 varint 999
와 동일합니다.
varint가 아닌 숫자 유형은 간단합니다. double과 fixed64는 와이어 유형이 I64로, 구문 분석기에 8바이트의 고정된 데이터 덩어리를 예상하도록 지시합니다. 5: 25.4
를 작성하여 이중 레코드를 지정하거나 6: 200i64
를 사용하여 fixed64
레코드를 지정할 수 있습니다. 두 경우 모두 명시적인 wire type을 생략하면 I64 wire type을 의미합니다.
마찬가지로 float
와 fixed32
는 wire type이 I32
로, 대신 4바이트를 예상하도록 지시합니다. 이 구문은 i32
접두사를 추가하는 것으로 구성됩니다. 25.4i32
는 200i32
와 마찬가지로 4바이트를 출력합니다. 태그 유형은 I32로 유추됩니다.
length prefix는 wire format의 또 다른 주요 개념입니다. LEN wire type은 동적 길이를 가지며, 태그 바로 뒤에 varint로 지정되고, 그 뒤에 보통 페이로드가 이어집니다.
다음 메시지 스키마를 살펴보세요:
message Test2 {
optional string b = 2;
}
필드 b
에 대한 레코드는 문자열이며 문자열은 LEN 인코딩됩니다. b를 "testing"으로 설정하면 필드 번호 2에 ASCII 문자열 "testing"이 포함된 LEN 레코드로 인코딩됩니다. 결과는 `120774657374696e67`입니다. 바이트 분할하기,
12 07 [74 65 73 74 69 6e 67]
태그 12
가 00010 010 또는 2:LEN임을 알 수 있습니다. 그 뒤에 오는 바이트는 int32 varint 7이고, 다음 7바이트는 'testing'의 UTF-8 인코딩입니다. int32 varint는 문자열의 최대 길이가 2GB임을 의미합니다.
Protoscope에서는 2:LEN 7 "testing"
으로 작성됩니다. 그러나 문자열의 길이를 반복하는 것은 불편할 수 있습니다(Protoscope 텍스트에서는 이미 따옴표로 구분되어 있음). Protoscope 콘텐츠를 중괄호로 묶으면 길이 접두사가 생성됩니다: {"testing"}
는 7 "testing"
의 약어입니다. {}는 항상 필드에 의해 LEN 레코드로 추론되므로 이 레코드를 2: {"testing"}
로 간단히 작성할 수 있습니다.
bytes
필드도 같은 방식으로 인코딩됩니다.
Submessage 필드도 LEN 와이어 유형을 사용합니다. 다음은 원래 예제 메시지인 Test1의 임베드된 메시지가 포함된 메시지 정의입니다:
message Test3 {
optional Test1 c = 3;
}
Test1의 필드(즉, Test3의 c.a 필드)가 150으로 설정되어 있으면 1a03089601
이 됩니다. 나눠보면
1a 03 [08 96 01]
마지막 세 바이트([])는 첫 번째 예제에서와 완전히 동일한 바이트입니다. 이 바이트 앞에는 문자열이 인코딩되는 것과 똑같은 방식으로 LEN 형식의 태그와 길이 3이 붙습니다.
Protoscope에서 submessage는 매우 간결합니다. ``1a03089601``은 3: {1: 150}
으로 작성할 수 있습니다.
Missing optional 필드는 인코딩하기 쉽습니다. 없는 경우 해당 레코드를 생략하기만 하면 됩니다. 즉, 몇 개의 필드만 설정된 "거대한" 프로토는 매우 드물게 인코딩할 수 있습니다.
repeated 필드는 조금 더 복잡합니다. 일반(패킹되지 않은) repeated 필드는 필드의 모든 요소에 대해 하나의 레코드를 생성합니다. 따라서 다음과 같은 경우
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
가 있고 d가 "hello"로 설정되고 e가 1, 2, 3으로 설정된 Test4 메시지를 구성한다면 이는 `220568656c6c6f280128022803`으로 인코딩하거나 Protoscope로 작성할 수 있습니다,
4: {"hello"}
5: 1
5: 2
5: 3
그러나 e에 대한 레코드는 연속적으로 나타날 필요가 없어서 다른 필드 사이에 끼워넣을수 있으며 서로에 대해 동일한 필드에 대한 레코드 순서만 유지됩니다. 따라서 다음과 같이 인코딩할 수도 있습니다.
5: 1
5: 2
4: {"hello"}
5: 3
Oneof
필드는 Oneof
에 속하지 않은 필드와 동일하게 인코딩됩니다. Oneof
에 적용되는 규칙은 wire에 표시되는 방식과 무관합니다.
일반적으로 인코딩된 메시지에는 non-repeated 필드의 인스턴스가 두 개 이상 존재하지 않습니다. 그러나 구문 분석기는 이러한 경우를 처리해야 합니다. 숫자 유형과 문자열의 경우, 동일한 필드가 여러 번 나타나면 구문 분석기는 마지막으로 본 값을 받아들입니다. 포함된 메시지 필드의 경우, 구문 분석기는 Message::MergeFrom
메서드를 사용하는 것처럼 동일한 필드의 여러 인스턴스를 병합합니다. 즉, 후자의 모든 단수 스칼라 필드가 전자의 필드를 대체하고, 단수 포함된 메시지가 병합되고, repeated 필드가 연결됩니다. 이러한 규칙의 효과는 인코딩된 두 메시지의 연결을 구문 분석하면 두 메시지를 개별적으로 구문 분석하고 결과 개체를 병합한 것과 정확히 동일한 결과를 생성한다는 것입니다. 즉, 아래의 구문은
MyMessage message;
message.ParseFromString(str1 + str2);
다음과 동일합니다.
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
이 속성은 두 메시지의 유형을 모르더라도 (연결을 통해) 병합할 수 있으므로 때때로 유용합니다.
v2.1.0부터 기본 유형(문자열이나 바이트가 아닌 모든 스칼라 type)의 반복 필드를 "packed"로 선언할 수 있습니다. proto2에서는 필드 옵션 [packed=true]를 사용하여 이 작업을 수행했습니다. proto3에서는 이것이 기본값입니다.
항목당 하나의 레코드로 인코딩되는 대신, 각 요소가 연결된 단일 LEN 레코드로 인코딩됩니다. 디코딩을 위해 페이로드가 소진될 때까지 LEN 레코드에서 요소를 하나씩 디코딩합니다. 다음 요소의 시작은 이전 요소의 길이에 따라 결정되며, 이는 필드 유형에 따라 달라집니다.
예를 들어 다음과 같은 메시지 유형이 있다고 가정해 보겠습니다:
message Test5 {
repeated int32 f = 6 [packed=true];
}
이제 repeated 필드 f에 3, 270, 86942 값을 제공하는 Test5를 구성한다고 가정해 보겠습니다. 인코딩하면 3206038e029ea705
, 즉 Protoscope 텍스트로 표시됩니다,
6: {3 270 86942}
기본형 숫자 repeated 필드만 "packed"로 선언할 수 있습니다. 이러한 유형은 일반적으로 VARINT, I32 또는 I64 wire type을 사용하는 유형입니다.
일반적으로 packed repeated 필드에 대해 하나 이상의 키-값 쌍을 인코딩할 이유는 없지만 parser는 여러 개의 키-값 쌍을 받아들일 준비가 되어 있어야 합니다. 이 경우 페이로드는 연결되어야 합니다. 각 쌍에는 전체 수의 요소가 포함되어야 합니다. 다음은 parser가 수락해야 하는 위의 동일한 메시지의 유효한 인코딩입니다:
6: {3 270}
6: {86942}
프로토콜 버퍼 parser는 패킹된 것처럼 컴파일된 repeaded 필드를 packed되지 않은 것처럼 구문 분석할 수 있어야 하며, 그 반대의 경우도 마찬가지입니다. 이렇게 하면 정방향 및 역방향 호환 방식으로 기존 필드에 [packed=true]
를 추가할 수 있습니다.
Map 필드는 특수한 종류의 반복 필드를 줄여 부르는 말입니다. 다음과 같은 경우
message Test6 {
map<string, int32> g = 7;
}
이 있다면 이는 실제로
message Test6 {
message g_Entry {
optional string key = 1;
optional int32 value = 2;
}
repeated g_Entry g = 7;
}
따라서 map은 repeated 메시지 필드와 똑같이 인코딩됩니다. 즉, 각각 두 개의 필드가 있는 LEN 타입 레코드의 시퀀스로 인코딩됩니다.
Group은 더 이상 사용되지 않는 기능이지만, wire format에 남아 있으므로 잠깐 언급할 가치가 있습니다.
그룹은 submessage와 비슷하지만 LEN 접두사가 아닌 특수 태그로 구분됩니다. 메시지의 각 group에는 이러한 특수 태그에 사용되는 필드 번호가 있습니다.
필드 번호가 8인 group은 8:SGROUP
태그로 시작합니다. SGROUP 레코드에는 페이로드가 비어 있으므로 이 태그는 group의 시작을 나타낼 뿐입니다. 그룹의 모든 필드가 나열되면 해당 8:EGROUP
태그는 group의 끝을 나타냅니다. EGROUP 레코드에는 페이로드가 없으므로 8:EGROUP
이 전체 레코드입니다. 그룹 필드 번호는 일치해야 합니다. 8:EGROUP
이 예상되는 곳에서 7:EGROUP
이 발견되면 메시지가 잘못된 형식입니다.
Protoscope는 그룹을 작성할 때 편리한 구문을 제공합니다. 다음과 같이 작성하는 대신
8:SGROUP
1: 2
3: {"foo"}
8:EGROUP
Protoscope는 다음을 허용합니다.
8: !{
1: 2
3: {"foo"}
}
이렇게 하면 적절한 시작 및 종료 그룹 마커가 생성됩니다. !{} 구문은 8:과 같이 입력되지 않은 태그 표현식 바로 뒤에만 사용할 수 있습니다.
필드 번호는 .proto 파일에서 어떤 순서로든 선언할 수 있습니다. 선택한 순서는 메시지가 직렬화되는 방식에 영향을 미치지 않습니다.
메시지가 직렬화될 때 known 또는 unknown 필드가 어떻게 기록될지에 대한 순서는 보장되지 않습니다. 직렬화 순서는 구현 세부 사항이며, 특정 구현의 세부 사항은 향후 변경될 수 있습니다. 따라서 프로토콜 버퍼 parser는 어떤 순서로든 필드를 구문 분석할 수 있어야 합니다.
foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
Proto는 직렬화할 때 2기가바이트보다 작아야 합니다. 많은 프로토 구현은 이 제한을 초과하는 메시지를 직렬화하거나 구문 분석하는 것을 거부합니다.
다음은 wire format의 가장 중요한 부분을 찾아보기 쉬운 형식으로 제공합니다.
message := (tag value)*
tag := (field << 3) bit-or wire_type;
encoded as uint32 varint
value := varint for wire_type == VARINT,
i32 for wire_type == I32,
i64 for wire_type == I64,
len-prefix for wire_type == LEN,
<empty> for wire_type == SGROUP or EGROUP
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
encoded as varints (sintN are ZigZag-encoded first)
i32 := sfixed32 | fixed32 | float;
encoded as 4-byte little-endian;
memcpy of the equivalent C types (u?int32_t, float)
i64 := sfixed64 | fixed64 | double;
encoded as 8-byte little-endian;
memcpy of the equivalent C types (u?int64_t, double)
len-prefix := size (message | string | bytes | packed);
size encoded as int32 varint
string := valid UTF-8 string (e.g. ASCII);
max 2GB of bytes
bytes := any sequence of 8-bit bytes;
max 2GB of bytes
packed := varint* | i32* | i64*,
consecutive values of the type specified in `.proto`
Protoscope 언어 레퍼런스도 참조하세요.
message := (tag value)*
메시지는 0개 이상의 tag와 value
쌍으로 순서대로 인코딩됩니다.
tag := (field << 3) bit-or wire_type
tag는 최하위 3비트로 저장되는 wire_type과 .proto 파일에 정의된 필드 번호의 조합입니다.
value := wire_type == VARINT, ...
value은 tag에 지정된 wire_type에 따라 다르게 저장됩니다.
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
varint를 사용하여 다양한 data type을 저장할 수 있습니다.
i32 := sfixed32 | fixed32 | float
fixed32를 사용하여 나열된 data type을 저장할 수 있습니다.
i64 := sfixed64 | fixed64 | double
고정64를 사용하여 나열된 data type을 저장할 수 있습니다.
len-prefix := size (message | string | bytes | packed)
길이 접두사가 붙은 값은 길이로 저장된 다음(varint로 인코딩됨) 나열된 data type 중 하나로 저장됩니다.
string := valid UTF-8 string (e.g. ASCII)
설명한 대로 문자열은 UTF-8 문자 인코딩을 사용해야 합니다. 문자열은 2GB를 초과할 수 없습니다.
bytes := any sequence of 8-bit bytes
설명한 대로 바이트는 최대 2GB 크기의 사용자 지정 data type을 저장할 수 있습니다.
packed := varint* | i32* | i64*
프로토콜 정의에 설명된 유형의 연속된 값을 저장할 때는 packed
data type을 사용합니다. 첫 번째 값 이후의 값에 대해서는 tag가 삭제되므로 태그 비용이 요소가 아닌 필드당 1개로 줄어듭니다.