- Uma mensagem simples
- Variedades da Base 128
- Estrutura da mensagem
- Mais tipos de números inteiros
- Mensagens incorporadas
- Elementos opcionais e repetidos
- Ordem de campo
- Card de referência condensado
Este documento descreve o formato de transmissão do buffer de protocolo, que define os detalhes de como sua mensagem é enviada em trânsito e quanto espaço ela consome no disco. Você provavelmente não precisa entender isso para usar buffers de protocolo no seu aplicativo, mas são informações úteis para fazer otimizações.
Se você já conhece os conceitos, mas quer uma referência, pule para a seção Card de referência condensado.
O protoscópio é uma linguagem muito simples para descrever snippets do formato de transmissão de baixo nível, que usaremos para fornecer uma referência visual para a codificação de várias mensagens. A sintaxe do protoscope consiste em uma sequência de tokens que cada um codifica para uma sequência de bytes específica.
Por exemplo, crases indicam um literal hexadecimal bruto, como `70726f746f6275660a`
. Isso é codificado nos bytes exatos marcados como hexadecimais no literal. As aspas denotam strings UTF-8, como "Hello, Protobuf!"
. Esse literal é sinônimo de
`48656c6c6f2c2050726f746f62756621`
, que, se você observar de perto, é
composto por bytes ASCII. Vamos falar mais sobre a linguagem de protótipo ao
discutir aspectos do formato de transmissão.
A ferramenta Protoscope também pode despejar buffers de protocolo codificados como texto. Consulte testdata para ver exemplos.
Uma mensagem simples
Digamos que você tenha a seguinte definição de mensagem muito simples:
message Test1 { optional int32 a = 1; }
Em um aplicativo, crie uma mensagem Test1
e defina a
como 150. Em seguida, serializa a mensagem para um stream de saída. Se você pudesse examinar a mensagem codificada, veria três bytes:
08 96 01
Até aqui, tão pequeno e numérico, mas o que isso significa? Se você usar a ferramenta Protoscope
para despejar esses bytes, vai receber algo como 1: 150
. Como ele sabe que esse é o conteúdo da mensagem?
128 variedades de base
Números inteiros de largura variável, ou varints, são a base do formato de transmissão. Eles permitem codificar números inteiros de 64 bits não assinados usando qualquer lugar entre um e dez bytes, com valores pequenos usando menos bytes.
Cada byte na varint tem um bit de continuação que indica se o byte que a segue faz parte da varint. Esse é o bit mais significativo (MSB, na sigla em inglês) do byte (às vezes, também chamado de bit bit de sinal). Os 7 bits mais baixos são um payload. O número inteiro resultante é anexado aos payloads de 7 bits dos bytes constituintes dele.
Por exemplo, este é o número 1, codificado como `01`
. Ele é um único byte, portanto, o MSB não está definido:
0000 0001
^ msb
E aqui está 150, codificado como `9601`
. Isso é um pouco mais complicado:
10010110 00000001
^ msb ^ msb
Como você descobre que é 150? Primeiro, elimine o MSB de cada byte, já que ele serve apenas para nos informar se chegamos ao fim do número (como você pode ver, ele é definido no primeiro byte, já que há mais de um byte no varint). Em seguida, concatenamos os payloads de 7 bits e o interpretamos como um número inteiro não assinado de 64 bits:
10010110 00000001 // Original inputs.
0010110 0000001 // Drop continuation bits.
0000001 0010110 // Put into little-endian order.
10010110 // Concatenate.
128 + 16 + 4 + 2 = 150 // Interpret as integer.
Como os variedades são cruciais para os buffers de protocolo, nos referimos a eles como números inteiros. 150
é igual a `9601`
.
Estrutura da mensagem
Uma mensagem do buffer de protocolo é uma série de pares de chave-valor. A versão binária de uma mensagem usa apenas o número do campo como chave. O nome e o tipo declarado para cada campo só podem ser determinados no final da decodificação ao referenciar a definição do tipo de mensagem (ou seja, o arquivo .proto
). O protótipo não tem
acesso a essas informações, portanto, ele só pode fornecer os números de campo.
Quando uma mensagem é codificada, cada par de chave-valor é transformado em um registro com o número do campo, um tipo de transferência e um payload. O tipo de transmissão informa ao analisador quanto o payload é necessário. Isso permite que os analisadores antigos ignorem novos campos que não entendem. Esse tipo de esquema às vezes é chamado de Tag-Length-Value, ou TLV.
Há seis tipos de fios: VARINT
, I64
, LEN
, SGROUP
, EGROUP
e I32
ID | Nome | Usado para |
---|---|---|
0 | VÁRIAS | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64. | fixar64, fixo64, duplo |
2 | LEN | string, bytes, mensagens incorporadas, campos repetidos compactados |
3 | GRUPO | início do grupo (descontinuado) |
4 | EGROUP | fim do grupo (descontinuado) |
5 | P32 | fixed32, sFixed32, flutuante |
A "tag" de um registro é codificada como um varint formado pelo número do campo e
pelo tipo de fio pela fórmula (field_number << 3) | wire_type
. Em outras palavras,
após a decodificação do varint que representa um campo, os três bits mais baixos informam o tipo de
fio, e o restante do número inteiro informa o número do campo.
Agora vamos ver novamente nosso exemplo simples. Agora, você sabe que o primeiro número
no stream é sempre uma chave varint e, aqui, é `08`
, ou (descartando o
MSB):
000 1000
Use os três últimos bits para chegar ao tipo de transmissão (0) e mude a posição em três para a direita para descobrir o número do campo (1). O protótipo representa uma tag como um número inteiro
seguido de dois-pontos e o tipo de fio. Assim, podemos gravar os bytes acima como
1:VARINT
.
Como o tipo de transmissão é 0, ou VARINT
, sabemos que precisamos decodificar um varint
para receber o payload. Como vimos acima, os bytes `9601`
varint-decode para
150, proporcionando nosso registro. Podemos gravá-lo no protótipo como 1:VARINT 150
.
O protótipo poderá inferir o tipo de uma tag se houver espaços em branco depois do :
. Isso
é feito considerando o próximo token e adivinhando o que você quer dizer. As
regras estão documentadas em detalhes no
language.txt do Protoscope.
Por exemplo, em 1: 150
, há uma variedade imediatamente após a tag sem tipo. Portanto, o protoscópio infere o tipo como VARINT
. Se você tivesse escrito 2: {}
, ele veria
o {
e adivinharia LEN
. Se você tivesse escrito 3: 5i32
, ele adivinharia I32
e assim por diante.
Mais tipos de números inteiros
Bool e Enums
Booleanos e enumerações são codificados como se fossem int32
s. Os booleanos, em particular, sempre codificam como `00`
ou `01`
. No Protoscope, false
e true
são aliases dessas strings de bytes.
Números inteiros assinados
Como você viu na seção anterior, todos os tipos de buffer de protocolo associados
ao tipo de fio 0 são codificados como varints. No entanto, os variedades não têm assinatura, então os
diferentes tipos assinados, sint32
e sint64
, em comparação com int32
ou int64
, codificam
números inteiros negativos de maneiras diferentes.
Os tipos intN
codificam números negativos como complemento de dois, o que significa que,
como números inteiros de 64 bits não assinados, eles têm o maior conjunto de bits. Como resultado, isso
significa que todos os 10 bytes precisam ser usados. Por exemplo, -2
é convertido por
protoscope em
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
Esse é o complemento de dois de 2, definido em aritmética não assinada como ~0 - 2 +
1
, em que ~0
é o número inteiro de 64 bits. É um exercício útil
para entender por que isso produz tantos.
Por outro lado, sintN
usa a codificação "ZigZag" em vez do complemento
de dois para codificar números inteiros negativos. Os números inteiros positivos n
são codificados como 2
* n
(os números pares), enquanto os números inteiros negativos -n
são codificados como 2 * n + 1
(os números ímpares). Assim, a codificação "zig-zags" entre números positivos e negativos. Exemplo:
Original assinado | Codificado como |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
… | … |
0x7fffffff | 0xfffffffe |
-0x80000000 | 0xffffffff |
Usando alguns truques, é barato converter n
em sua representação ZigZag:
n + n + (n < 0)
Aqui, presumimos que o booleano n < 0
é convertido em um número inteiro 1 se for verdadeiro
ou um número inteiro 0 se for falso.
Quando o sint32
ou sint64
é analisado, o valor dele é decodificado de volta para a
versão original assinada.
No protótipo, a adição de um número inteiro com um z
como sufixo fará com que ele seja codificado como ZigZag.
Por exemplo, -500z
é igual à variável 1001
.
Números não variados
Os tipos numéricos não diversos são simples: double
e fixed64
têm o tipo de fio
I64
, que informa ao analisador que é esperado um volume fixo de dados de oito bytes. É possível
especificar um registro double
gravando 5: 25.4
ou um registro fixed64
com 6:
200i64
. Em ambos os casos, a omissão de um tipo de fiação explícito infere o tipo de fiação
I64
.
Da mesma forma, float
e fixed32
têm o tipo de transmissão I32
, que informa que é preciso esperar
quatro bytes no lugar. A sintaxe deles consiste na adição de um prefixo i32
.
A 25.4i32
emite quatro bytes, assim como a 200i32
. Os tipos de tags são inferidos como I32
.
Registros com duração ilimitada
Os prefixos de comprimento são outro conceito importante no formato de transmissão. O tipo de fiação LEN
tem um comprimento dinâmico, especificado por uma variedade imediatamente após a tag, que é seguida pelo payload normalmente.
Considere este esquema de mensagens:
message Test2 {
optional string b = 2;
}
Um registro para o campo b
é uma string que tem codificação LEN
. Se definirmos
b
como "testing"
, codificamos como um registro LEN
com o campo número 2 contendo
a string ASCII "testing"
. O resultado é `120774657374696e67`
. Dividindo os bytes,
12 07 [74 65 73 74 69 6e 67]
vemos que a tag `12`
é 00010 010
ou 2:LEN
. O byte seguinte é a variedade 7
e os próximos sete bytes são a codificação UTF-8 de "testing"
.
No Protoscope, isso é escrito como 2:LEN 7 "testing"
. No entanto, pode ser
incorreto repetir o comprimento da string (que, no texto do protótipo, já
está delimitado por aspas). Unir o conteúdo do protótipo entre chaves vai gerar um
prefixo de comprimento: {"testing"}
é uma abreviação de 7 "testing"
. O {}
é
sempre inferido por campos para ser um registro LEN
. Portanto, podemos gravar esse registro
simplesmente como 2: {"testing"}
.
Os campos bytes
são codificados da mesma maneira.
Submensagens
Os campos de submensagem também usam o tipo de transferência LEN
. Veja uma definição de mensagem com
uma mensagem incorporada da mensagem de exemplo original, Test1
:
message Test3 {
optional Test1 c = 3;
}
Se o campo a
de Test1
(por exemplo, c.a
do Test3
) estiver definido como 150, o resultado será 1a03089601
. Divisão:
1a 03 [08 96 01]
Os últimos três bytes (em []
) são exatamente os mesmos do nosso
primeiro exemplo. Esses bytes são precedidos por uma tag do tipo LEN
e um comprimento de três, exatamente da mesma forma que as strings são codificadas.
No Protoscope, as submensagens são bem sucintas. 1a03089601
pode ser escrito como
3: {1: 150}
.
Elementos opcionais e repetidos
Os campos optional
ausentes são fáceis de codificar: basta não incluir o registro se ele não estiver presente. Isso significa que os protos "enormes" com apenas alguns campos definidos são esparsos.
Os campos repeated
são um pouco mais complicados. Os campos repetidos (não empacotados) emitem um registro para cada elemento do campo. Assim, se tivermos
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
e construímos uma mensagem Test4
com d
definido como "hello"
e e
definido como
1
, 2
e 3
, que pode ser codificado como `220568656c6c6f280128022803`
ou escrito como Protoscope,
4: {"hello"}
5: 1
5: 2
5: 3
No entanto, os registros de e
não precisam aparecer consecutivamente e podem ser intercalados com outros campos. Apenas a ordem dos registros para o mesmo campo em relação a eles é preservada. Portanto, isso também poderia ter sido codificado como
5: 1
5: 2
4: {"hello"}
5: 3
Não há tratamento especial para oneof
s no formato de transmissão.
O último vence
Normalmente, uma mensagem codificada nunca teria mais de uma instância de um
campo que não seja repeated
. No entanto, os analisadores precisam processar o
caso em que fazem isso. Para tipos e strings numéricos, se o mesmo campo aparecer várias
vezes, o analisador aceitará o último valor mostrado. Para campos de mensagens incorporados,
o analisador mescla várias instâncias do mesmo campo, como se você estivesse usando o método
Message::MergeFrom
. Ou seja, todos os campos escalares singulares da última
instância substituem os da anterior, as mensagens incorporadas singulares são mescladas e
os campos repeated
são concatenados. O efeito dessas regras é que analisar
a concatenação de duas mensagens codificadas produz exatamente o mesmo resultado que
se você tivesse analisado as duas mensagens separadamente e mesclado os objetos resultantes.
Ou seja:
MyMessage message;
message.ParseFromString(str1 + str2);
é equivalente a:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
Essa propriedade é útil às vezes porque permite mesclar duas mensagens (por concatenação), mesmo que você não saiba os tipos delas.
Campos repetidos em pacotes
A partir da versão 2.1.0, os campos repeated
do tipo escalar podem ser declarados como "empacotados". No proto2, isso é feito com o [packed=true]
, mas no proto3,
é o padrão.
Em vez de serem codificados como um registro por entrada, eles são codificados como um único
registro LEN
que contém cada elemento concatenado. Para decodificar, os elementos são
decodificados do registro LEN
um por um até o payload ser esgotado. O
início do próximo elemento é determinado pelo comprimento do anterior, que
depende do tipo do campo.
Por exemplo, imagine que você tem o tipo de mensagem:
message Test5 {
repeated int32 f = 6 [packed=true];
}
Agora, digamos que você construa um Test5
, fornecendo os valores 3, 270 e 86942
para o campo repetido f
. Codificado, isso nos dá `3206038e029ea705`
, ou texto de protótipo,
6: {3 270 86942}
Somente campos repetidos de tipos numéricos primitivos podem ser declarados como "empacotados". Esses
são os tipos que normalmente usam os tipos de fio VARINT
, I32
ou I64
.
Embora não haja motivo para codificar mais de um par de chave-valor em um campo repetido, os analisadores precisam estar preparados para aceitar vários pares de chave-valor. Nesse caso, os payloads precisam ser concatenados. Cada par precisa conter um número inteiro de elementos. Veja a seguir uma codificação válida da mesma mensagem acima que os analisadores precisam aceitar:
6: {3 270}
6: {86942}
Os analisadores de buffer de protocolo precisam ser capazes de analisar campos repetidos que foram compilados como packed
como se não fossem compactados e vice-versa. Isso permite adicionar
[packed=true]
a campos existentes de maneira compatível com versões futuras e versões anteriores.
Mapas
Os campos do mapa são apenas uma forma abreviada de um tipo especial de campo repetido. Se
message Test6 {
map<string, int32> g = 7;
}
este é o mesmo que
message Test6 {
message g_Entry {
optional string key = 1;
optional int32 value = 2;
}
repeated g_Entry g = 7;
}
Assim, os mapas são codificados exatamente como um campo de mensagem repeated
: como uma sequência de registros do tipo LEN
, com dois campos cada.
Grupos
Os grupos são um recurso descontinuado que não deve ser usado, mas permanecem no formato de transmissão e merecem ser mencionados.
Um grupo é um pouco como uma submensagem, mas é delimitado por tags especiais, em vez de um prefixo LEN
. Cada grupo em uma mensagem tem um número de campo, que é usado nessas tags especiais.
Um grupo com o campo de número 8
começa com uma tag 8:SGROUP
. Os registros SGROUP
têm payloads vazios, então isso indica o início do grupo. Depois que todos os campos do grupo forem listados, uma tag 8:EGROUP
correspondente vai indicar o fim. Como os registros EGROUP
também não têm payload, o 8:EGROUP
é todo o registro.
Os números de campo do grupo precisam ser correspondentes. Se encontrarmos 7:EGROUP
onde esperamos
8:EGROUP
, a mensagem estará mal formada.
O protótipo oferece uma sintaxe conveniente para criar grupos. Em vez de escrever
8:SGROUP
1: 2
3: {"foo"}
8:EGROUP
O protótipo permite
8: !{
1: 2
3: {"foo"}
}
Isso vai gerar os marcadores de grupo inicial e final adequados. A sintaxe !{}
só pode ocorrer imediatamente após uma expressão de tag sem tipo, como 8:
.
Ordem do campo
Os números de campo podem ser declarados em qualquer ordem em um arquivo .proto
. A ordem escolhida
não afeta a maneira como as mensagens são serializadas.
Quando uma mensagem é serializada, não há uma garantia de como os campos desconhecidos ou conhecidos serão gravados. A ordem de serialização é um detalhe da implementação, e os detalhes de qualquer implementação específica podem mudar no futuro. Portanto, os analisadores de buffer de protocolo precisam ser capazes de analisar campos em qualquer ordem.
Implicações
- Não suponha que a saída de bytes de uma mensagem serializada seja estável. Isso é especialmente verdadeiro para mensagens com campos de bytes transitivos que representam outras mensagens de buffer de protocolo serializadas.
- Por padrão, invocações repetidas de métodos de serialização na mesma instância de mensagem de buffer de protocolo podem não produzir a mesma saída de byte. Ou seja, a serialização padrão não é determinista.
- A serialização determinística garante apenas a mesma saída de byte em um binário específico. A saída de bytes pode mudar em diferentes versões do binário.
- As seguintes verificações podem falhar para uma instância da mensagem de buffer de protocolo
foo
:foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
- Veja alguns exemplos de situações em que as mensagens de buffer de protocolo logicamente equivalentes
foo
ebar
podem ser serializadas em diferentes saídas de bytes:bar
é serializado por um servidor antigo que trata alguns campos como desconhecidos.bar
é serializado por um servidor que é implementado em uma linguagem de programação diferente e serializa campos em ordem diferente.bar
tem um campo que é serializado de maneira não determinista.bar
tem um campo que armazena uma saída de bytes serializada de uma mensagem de buffer de protocolo, que é serializada de maneira diferente.bar
é serializado por um novo servidor que serializa campos em uma ordem diferente devido a uma mudança de implementação.foo
ebar
são concatenações das mesmas mensagens individuais em uma ordem diferente.
Card de referência condensado
Veja a seguir as partes mais proeminentes do formato de transmissão em um formato fácil de referência.
message := (tag value)*
tag := (field << 3) bit-or wire_type;
encoded as 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?int32_t, float)
len-prefix := size (message | string | bytes | packed);
size encoded as 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`
Consulte também a Referência da linguagem do protótipo.
Chave
message := (tag value)*
- Uma mensagem é codificada como uma sequência de zero ou mais pares de tags e valores.
tag := (field << 3) bit-or wire_type
- Uma tag é uma combinação de um
wire_type
, armazenado nos três bits menos significativos, e no número de campo definido no arquivo.proto
. value := varint for wire_type == VARINT, ...
- Um valor é armazenado de forma diferente dependendo do
wire_type
especificado na tag. varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
- É possível usar o varint para armazenar qualquer um dos tipos de dados listados.
i32 := sfixed32 | fixed32 | float
- É possível usar fixa32 para armazenar qualquer um dos tipos de dados listados.
i64 := sfixed64 | fixed64 | double
- É possível usar fixa64 para armazenar qualquer um dos tipos de dados listados.
len-prefix := size (message | string | bytes | packed)
- Um valor com prefixo de tamanho é armazenado como um comprimento (codificado como um varint) e um dos tipos de dados listados.
string := valid UTF-8 string (e.g. ASCII)
- Como descrito, uma string deve usar a codificação de caracteres UTF-8. Uma string não pode exceder 2 GB.
bytes := any sequence of 8-bit bytes
- Como descrito, os bytes podem armazenar tipos de dados personalizados de até 2 GB.
packed := varint* | i32* | i64*
- Use o tipo de dados
packed
ao armazenar valores consecutivos do tipo descrito na definição do protocolo. A tag é descartada para valores após o primeiro, o que amortiza os custos de tags em uma por campo, em vez de por elemento.