Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

열람용기록

Encoding 본문

Read the F. Manual/Protocol Buffer

Encoding

OptiFree 2021. 5. 19. 23:12
 

Encoding  |  Protocol Buffers  |  Google Developers

This document describes the binary wire format for protocol buffer messages. You don't need to understand this to use protocol buffers in your applications, but it can be very useful to know how different protocol buffer formats affect the size of your enc

developers.google.com

 

이 문서는 protocol buffer의 이진 wire 포맷에 대하여 설명합니다. 어플리케이션에 protocol buffer를 사용하기 위해서 이것을 이해해야 할 필요는 없습니다. 그러나 어떻게 다른 protocol buffer 포맷들이 인코딩된 메시지 사이즈에 영향을 미치는지 아는 것은 매우 유용합니다.

 

간단한 메시지

아래의 아주 간단한 메시지 정의를 볼까요.

message Test1 {
  optional int32 a = 1;
}

어플리케이션에서 Test1 메시지를 만들어서 여기에 150을 할당하고 이를 출력 스트림에 직렬화했다고 해보죠. 만약 인코딩된 메시지를 살펴볼 수 있다면 아래의 3개의 바이트를 볼 수 있을것입니다.

08 96 01

이게 무슨 뜻일까요?

128 기반의 변수 표기법(?) (Base 128 Varints)

간단한 protocol buffer의 인코딩을 이해하기 위해서는 varints에 대하여 이해해야 합니다. Varint는 하나 이상의 바이트를 이용하여 정수를 직렬와하는 방법입니다. 작은 숫자는 작은 수의 바이트를 사용합니다.

Varint의 각 바이트는 마지막 바이트를 제외하고 최상위 비트 (most significant big)가 1로 설정되는데, 이는 이후에 추가로 바이트가 이어진다는 것을 표시합니다. 하위 7바이트는 2의 보수 표현법으로 7비트 그룹에 있는 숫자를 저장하고, 각 그룹은 하위 그룹부터 읽습니다. (lest significant group first)

예를 들어 숫자 1은 아래와 같이 하나의 바이트로 최상위비트가 0으로 표현됩니다.

0000 0001

300를 표현하려면 약간 복잡해집니다.

1010 1100 0000 0010

이것이 300 인것을 어떻게 알 수 있을까요? 먼저 최상위비트를 각 바이트에서 떼어냅니다. 최상위비트는 그저 이 바이트가 숫자를 표현하기 위한 마지막 바이트인지 아닌지를 알려주기 때문입니다. (두개 이상의 바이트가 있으므로 첫번째 바이트의 최상위비트를 1로 설정합니다.)

 1010 1100 0000 0010
-> 010 1100  000 0010

Varint는 숫자를 하위 그룹부터 읽기 때문에 7비트의 두 그룹을 뒤집습니다. 이후 이를 이어붙여 최종 값을 얻을 수 있습니다.

000 0010  010 1100
-> 000 0010 ++ 010 1100
-> 100101100
-> 256 + 32 + 8 + 4 = 300

메시지 구조

이미 알고 있듯 protocol buffer 메시지는 키-값 쌍의 나열입니다. 이진화된 메시지는 그저 필드 숫자를 키로 사용합니다. 각 필드의 이름과 타입은 메시지 타입의 정의 (.proto file 같이)를 참조하여 디코딩이 끝나야 확인할 수 있습니다.

메시지가 인코딩되면 키와 값들은 바이트 스트림에 연결됩니다. 메시지가 디코딩 되었을 때 파서는 인식되지 않는 필드에 대하여 건너 뛸 수 있어야 합니다. 이 방법으로 메시지의 새로운 필드는 이 사실을 모르는 이전의 프로그램을 멈추지 않고 추가될 수 있습니다. 이를 위해 wire 형식 메시지에 있는 “키” 는 실제로 2개의 값을 갖습니다. 하나는 .proto 파일에 있는 필드 숫자이고, 하나는 wire 타입으로 이어지는 값의 길이를 알아내기에 충분한 정보만을 제공합니다. 대부분 언어는 이 키를 태그라고 합니다.

 

가능한 wire 타입은 아래와 같습니다.

타입 사용처
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64 비트 fixed64, sfixed64, double
2   string, bytes, embedded messages, packed repeated fields
3 시작 그룹 groups (삭제예정)
4 끝 그룹 groups (삭제예정)
5 32비트 fixed32, sfixed32, float

 

스트림 메시지에 있는 각 키는 값 (field_number << 3 | wire_type)이 있는 varint 입니다. 다시 말해 숫자의 마지막 3개의 비트에 wire 타입을 저장합니다.

 

이제 간단한 예제를 다시 볼까요. 이제 스트림의 가장 첫번째 숫자는 항상 varint 키라는 것을 알고 있습니다. 그리고 아래는 08 입니다. (최상위 비트는 제외하였습니다.)

000 1000

마지막 3개의 통해 wire 타입이 0인 것을 알 수 있고, 오른쪽으로 3번 시프트 연산 하여 필드 숫자 1을 얻어냅니다. 따라서 이제 필드 숫자가 1이라는 것과 뒤따라 오는 값이 varint 라는 것을 알게 되었습니다. 이전 섹션에서 배운 varint 디코딩 방법을 이용하여 다음 두개의 바이트가 150을 저장하고 있는 것을 확인할 수 있습니다.

96 01 = 1001 0110  0000 0001
       -> 000 0001 ++ 001 0110 (최상위 비트 제거 및 그룹 순서 재배치)
       -> 10010110
       -> 128 + 16 + 4 + 2 = 150

또 다른 Value 타입

부호가 있는 정수

이전 섹션에서 보았듯이, wire 타입이 0인 모든 protocol buffer의 타입은 varint로 인코딩 됩니다. 하지만 부호가 있는 정수 타입과 (sint32, sint64)와 일반적인 정수 타입 (int32, int64)는 음수를 인코딩 할 때 중요한 차이점을 가집니다. 만약 int32나 int64 로 음수를 표현하면 varint는 항상 10바이트의 길이를 가집니다. 사실상 굉장히 큰 부호가 없는 정수 취급을 합니다. 만약 부호가 있는 타입을 사용한다면 (sint32,sint64) varint는 보다 효율적인 지그재그(ZigZag) 인코딩을 사용합니다.

지그재그 인코딩은 부호가 있는 정수를 부호가 없는 정수로 대응시키는데, 절대값이 작은 숫자(가령 -1과 같이)를 작은 varint 인코딩 값을 갖게 하기 위함입니다. 양수와 음수를 넘나들며 뒤와 앞으로 지그재그 하는 방법으로 -1은 1로, 1은 2로, -2는 3으로 인코딩 합니다. 아래의 표에서 확인 할 수 있습니다.

 

부호 있는 원본 변환값
0 0
-1 1
1 2
-2 3
2147483647 4294967294
-2147483648 4294967295

다시 말해서 n의값은 sint32 타입에 대해 아래와 같이 인코딩 됩니다.

(n << 1) ^ (n >> 31)

그리고 sint64 타입에 대해서는 아래와 같이 인코딩 됩니다.

(n << 1) ^ (n >> 63)

두번째 시프트 연산 (n >> 31 부분) 은 대수적 시프트 연산입니다. 다시 말해서 시프트의 결과는 모두 0의 값이거나 (n이 양수인 경우) 모두 1입니다. (n이 음수인 경우)

sint32나 sint64가 파싱되면 이 값은 원래의 부호 있는 버전으로 디코딩 됩니다.

Varint가 아닌 숫자

Varint가 아닌 숫자는 간단합니다. double과 fixed64 는 wire 타입 1번인데, 파서에게 고정된 64 비트의 데이터를 확인하게 합니다. 비슷하게 float 와 fixed32 는 wire 타입 5번이며 32비트임을 이야기합니다. 이 두 케이스 모두 값을 리틀 앤디안 순서로 저장합니다.

문자열

Wire 타입 2는 앞으로 올 데이터의 길이를 지칭하는 varint 값이 온다는 것을 뜻합니다.

message Test2 {
	optional string b = 2;
}

b 값을 “testing” 이라고 설정하면 아래와 같은 인코딩 값을 얻게 됩니다.

12 07 [74 65 73 74 69 6e 67]

대괄호 안에 있는 바이트는 UTF8로 “testing” 입니다. 키는 0x12인데 아래와 같이 파싱됩니다.

0x12
-> 0001 0010 (이진표현)
-> 00010 010 (비트를 다시 그루핑)
-> 필드 숫자 = 2, Wire 타입 = 2

길이를 나타내는 varint 값은 7이고 이후 우리의 문자열인 7바이트를 찾습니다.

포함된 메시지

위에서 예시로 들었던 Test1를 포함하는 메시지 정의를 봅시다.

message Test3 {
	optional Test1 c = 3;
}

Test1의 a 필드를 150으로 설정하였을 때 인코딩 결과는 아래와 같습니다.

1a 03 08 96 01

마지막 3개의 비트는 첫번째 예시(08 96 01)와 정확히 동일하고 그 앞에 있는 숫자 3 뒤에 따라 옵니다. 포함된 메시지는 문자열과 정확히 동일한 방법으로 다루어 집니다. (wire 타입 2)

선택적인 (Optional) 요소와 반복되는 (Repeated) 요소

만약 proto2 메시지 정의가 repeated 요소를 자기고 있다면 ([packed = true] 옵션 없이), 인코딩된 메시지는 없거나 그보다 많은 같은 필드 숫자를 가지는 키-값의 쌍을 가집니다. 이 반복되는 값은 반복적으로 나타날 필요가 없습니다. 다른 필드에 끼워질 수 있습니다. 각기 다른 요소들에 대하여 요소의 순서는 파싱될 때 보존됩니다. 다른 필드에 대한 순서가 손실 되더라도 말이죠. proto3 에서는 반복되는 필드는 packed 인코딩을 사용합니다. 이에 대해서 아래에 설명합니다.

proto3의 반복되지 않는 모든 필드 또는 proto2의 optional 필드에 대하여 인코딩된 메시지는 그 필드 숫자의 키-값의 쌍을 가지거나 가지지 않을 수 있습니다.

일반적으로 인코딩된 메시지는 반복되지 않는 필드의 인스턴스를 2개 이상 절대 가지지 않습니다. 하지만 파서가 이 케이스를 처리하는 것이 기대됩니다.(?) 숫자와 문자열 타입에 대하여 만약 같은 필드가 여러번 나타난다면 파서는 마지막 값을 받아들일 것 입니다. 포함된 메시지 필드의 경우 파서는 같은 필드의 여러 인스턴스를 Message::MergeFrom 메소드로 병합합니다. 이는 뒤의 인스턴스의 모든 단일 스칼라 필드는 이전의 것을 대체하고, 단일 포함된 메시지의 경우는 병합하고, 반복되는 필드는 이어 붙입니다. 이 규칙의 효과는 두개의 메시지의 연결을 파싱하는 것이 각각을 파싱하고 병합하는 것과 동일한 결과를 내어줍니다. 아래의 예시를 볼까요?

MyMessagee message;
message.ParseFromString(str1 + str2);

위의 예시는 아래와 정확히 같습니다.

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

이 속성은 메시지의 타입을 모르더라도 두 개의 메시지를 병합 할 수 있기 때문에 때때로 유용합니다.

Packed 반복된 필드

2.1.0 버전에서 packed 반복된 필드를 소개했습니다. proto2에서 반복 필드를 선언할 때 특별한 [packed=true] 옵션을 사용하는 것입니다. proto3 에서 스칼라 숫자 타입의 반복되는 필드는 packed가 기본입니다. 이 기능은 반복된 필드와 비슷하지만 인코딩 방식이 다릅니다. 요소가 없는 packed 반복된 필드는 인코딩된 메시지에 나오지 않습니다. 반대로 필드의 모든 요소는 wire 타입 2의 하나의 키-값의 쌍으로 packed 됩니다. 각 요소는 앞에 나오는 키를 제외하고 일반적인 방법과 동일하게 인코딩 됩니다.

예를 들어서 아래와 같은 메시지 타입이 있다고 합시다.

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

이제 Test4를만들었다고 하고 값을 3, 270, 86942 를 d에넣었다고 합시다. 인코딩된 메시지는 아래와 같을 것입니다.

22 // 키 (필드 숫자 4, wire 타입 2)
06 // 뒤따라오는 사이즈 (6 바이트)
03 // 첫번째 요소 값 (varint 3)
8E 02 // 두번째 요소 값 (varint 270)
9E A7 05 // 세번째 요소 값 (varint 86942)

기본적인 숫자 타입(varint, 32-bit, 64-bit wire 타입)의 필드만 packed 로 선언될 수 있습니다.

대게 packed 반복 필드에 대하여 2개 이상의 키-값의 쌍으로 인코딩 할 이유가 없음에도 불구하고 인코더는 여러 키-값의 쌍을 받아들일 준비가 되어 있어야 합니다. 이 경우에 뒤따라 오는 사이즈를 나타내는 숫자가 이어져야 합니다. 각 쌍은 요소의 총 숫자를 담고 있어야만 합니다.

Protocol buffer 파서는 마치 packed 되지 않은 것 처럼 packed로 반복된 필드를 파싱 할 수 있어야 하고 그 반대로도 가능해야 합니다. [packed=true] 를 필드에 추가하여 서로 호환 가능하게 할 수 있습니다.

필드 순서

필드 숫자는 .proto 파일의 어느 순서에도 사용할 수 있습니다. 순서는 메시지가 어떻게 직렬화 되는지와 관련이 없습니다.

메시지가 직렬화 될 때, 어떻게 알고 있는 필드와 알고 있지 않은 필드가 어떤 순서로 적혀야 하는지 보증하지 않습니다. 직렬화 하는 순서는 구현의 아주 세부적인 이용이며 향우에 변경 될 수도 있습니다. 따라서 protocol buffer 파서는 어떤 순서로 필드가 구성되어 있어도 파싱할 수 있어야 합니다.

암시(?, Implications)

  • 직렬화된 메시지의 바이트 출력이 안정적이라고 가정하지 마세요. 특히 다른 직렬화된 protocol buffer메시지를 표현하는 전이(transitive?) 바이트 필드의 경우 더욱 그렇습니다.
  • 기본적으로 같은 protocol buffer 메시지 객체에 직렬화를 여러번 적용한 경우 서로 다른 바이트 출력을 얻을 수 있습니다. 즉 기본적인 직렬화는 비결정적입니다.
    • 결정적 직렬화는 특정한 바이너리에 대한 같은 바이트 출력만을 보장합니다. 바이트 출력은 바이너리의 여러 버전에 따라 바뀔 수 있습니다.
  • 객체 foo에 대하여 아래의 체크는 실패할 수 있습니다.
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • 아래는 논리적으로 동일한 protocol buffer 메시지 foo와 bar가 서로 다른 바이트 출력으로 직렬화 되는 몇가지 예시입니다.
    • bar가 몇몇 필드를 알지 못하는 예전 서버에 의해서 직렬화되었다.
    • bar가 다른 프로그래밍 언어로 구현된 서버에서 직렬화 되고 필드의 순서가 다르다.
    • bar가 비결정적인 방법으로 직렬회되는 필드를 가지고 있다.
    • bar가 다르게 직렬화된 protocol buffer 메시지의 바이트 출력을 저장하는 필드를 가지고 있다.
    • bar가 새로운 서버에서 직렬화 될 때 구현의 변경으로 인하여 필드의 순서가 변경되었다.
    • foo와 bar 모두 각각의 메시지의 연결인데, 서로 다른 순서이다.

'Read the F. Manual > Protocol Buffer' 카테고리의 다른 글

Style Guide  (0) 2021.05.19
Techniques  (0) 2021.05.19