Favicon

Protobuf language (with C#)

Peponi11/25/202411m

Protobuf
gRPC.NET.NET framework

1. Introduction

이 문서에서는 proto3 문법 및 C#에서의 사용 방법을 알아본다.

2. Proto definitions

// Protocol version 지정
syntax = "proto3";
 
// Package name 지정
package ProtoTest;
 
// C# namespace 지정 옵션
// 옵션을 지정하지 않은 경우 package name이 namespace로 지정됨
option csharp_namespace = "MyProto";
 
// 다른 proto import
import "ProtoTest.Transaction.proto";

package 또는 csharp_namespace로 namespace를 지정하는 방법은 C#과 동일하다.

2.1. import public

import public을 선언 후 다른 곳에서 해당 proto를 import하면 import public 또한 같이 import 된다.

A.proto
// contents...
B.proto
// All proto files that import this proto will be import under proto also.
import public "A.proto";
C.proto
import "B.proto";
 
// Use definitions from 'B.proto' and 'A.proto'

3. Scalar value types

Protobuf는 자체 type을 가지고 있으며, C# 언어에 대응하는 형식은 아래 표를 참조한다.

ProtobufC#Note
doubledouble
floatfloat
int32intUses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead.
int64longUses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead.
uint32uintUses variable-length encoding.
uint64ulongUses variable-length encoding.
sint32intUses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s.
sint64longUses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s.
fixed32uintAlways four bytes. More efficient than uint32 if values are often greater than 2<sup>28</sup>.
fixed64ulongAlways eight bytes. More efficient than uint64 if values are often greater than 2<sup>56</sup>.
sfixed32intAlways four bytes.
sfixed64longAlways eight bytes.
boolbool
stringstringA string must always contain UTF-8 encoded or 7-bit ASCII text, and cannot be longer than 2<sup>32</sup>.
bytesByteStringMay contain any arbitrary sequence of bytes no longer than 2<sup>32</sup>.

다른 언어에 대한 내용은 Scalar Value Types을 참조한다.

4. Language

4.1. Message

메시지는 필드를 담는 객체이며 gRPC 통신에 사용되기도 한다. C#의 class와 비슷하다. 메시지 안에 들어있는 필드는 name/value pair로 구성되며 Value 값의 범위는 1 ~ 536,870,911 이며 다음 제약을 가진다.

  • Value 값은 한 메시지 내에서 고유하다.
  • 19,000 ~ 19,999 값은 Protocol buffer 정의에 의해 예약되어 있다.
  • Reserved field 또는 Extension에 정의된 값은 사용할 수 없다.
Basic
message Transaction
{
    string SenderAddress = 1;
    int32 SenderPort = 2;
    int32 TransactionID = 3;
}
 
message Message
{
    Transaction Transaction = 1;
    string Message = 2;
}
Nested message
message Outer
{
    message Inner
    {
        int32 IntValue = 1 ;
    }
 
    Inner InnerValue = 1;
}
 
message Other
{
    Outer.Inner InnerValue = 1;
}

필드에는 다음과 같은 레이블을 추가할 수 있다.

message Optional
{
	optional int32 TestInt = 1;
}
 
message Repeated
{
	repeated int32 TestInts = 1;
}
 
message Map
{
	map<string, int32> TestPairs = 1;
}
  • optional
    1. 필드에 값이 할당된 경우 : 일반 필드와 같이 사용한다. wire로 serialize 될 수 있다.
    2. 필드에 값이 할당되지 않은 경우 : 기본값을 반환하며 wire로 serialize 될 수 없다.
  • repeated
    • repeated 레이블이 지정된 필드는 enumerator type으로 동작한다.
    • IList<T>를 구현하여 LINQ 쿼리 사용 및 배열, 리스트로 변환이 가능하다.
  • map
    • map 레이블이 지정된 필드는 key/value pair type으로 동작한다.
    • IDictionary<TKey,TValue>를 구현하여 LINQ 쿼리가 가능하다.

4.2. Message 사용 방법

private static void Main(string[] args)
{
    Optional opt = new Optional();
 
    Console.WriteLine($"Has value : {opt.HasTestInt}, value : {opt.TestInt}");
 
    Repeated rpt = new Repeated();
    rpt.TestInts.Add(1);
    rpt.TestInts.Add(2);
 
    Console.WriteLine(rpt);
 
    Map map = new Map();
    map.TestPairs.Add("A", 1);
    map.TestPairs.Add("B", 2);
 
    Console.WriteLine(map);
}
 
/* output:
Has value : False, value : 0
{ "TestInts": [ 1, 2 ] }
{ "TestPairs": { "A": 1, "B": 2 } }
*/

4.3. Reserved field

Reserved field는 key 또는 value를 점유하여 해당 값을 사용하지 못하게 한다. 향후 추가될 필드를 미리 정의하거나 메시지에서 제거된 필드를 사용하지 못하게 하는 데 활용할 수 있다.

message Reserved
{
    reserved 1, 2, 3 to 5;
    reserved "NameA", "NameB";
 
    // Following lines will be occurring error by protobuf compiler
    int32 TestInt = 2;
    int32 NameA = 6;
}

4.4. Oneof

oneof는 message field를 구성하는 기능 중 하나이다. oneof에 등록된 어떤 형식이라도 멤버들 중 하나만 값을 가질 수 있으며 다음 제약 조건이 있다.

  • oneof 멤버의 형식에 map은 사용이 불가하다.
  • oneofrepeated로 지정할 수 없다.

Protobuf 컴파일러는 컴파일 시 oneof 키워드를 처리하여 enum을 생성해준다. 이를 이용하여 C# 코드에서 설정된 필드가 무엇인지 찾을 수 있다.

message OneOf
{
    oneof Selected{
        string A = 1;
        int32 B = 2;
        bool C = 3;
    }
}
private static void Main(string[] args)
{
    OneOf oneOf = new OneOf();
    oneOf.B = 1010;
    CheckOneof(oneOf);
}
 
private static void CheckOneof(OneOf oneOf)
{
    switch (oneOf.SelectedCase)
    {
        case OneOf.SelectedOneofCase.A:
            Console.WriteLine(oneOf.A);
            break;
 
        case OneOf.SelectedOneofCase.B:
            Console.WriteLine(oneOf.B);
            break;
 
        case OneOf.SelectedOneofCase.C:
            Console.WriteLine(oneOf.C);
            break;
 
        default:
            Console.WriteLine("None");
            break;
    }
}
 
/* output:
1010
*/

4.5. Comments

Protobuf의 주석 작성법은 C#과 동일하다.

// Comments in single line
 
/* Comments in 
   Multiple line */

4.6. Enumerations

enum NoticeType
{
    None = 0;
    UserNotify = 1;
    SystemMessage = 2;
    Warn = 3;
    Error = 4;
    Exception = 5;
}
 
message Notice
{
    NoticeType Type = 1;
    string Message = 2;
}

Protobuf의 enum 작성법은 C#과 동일하다. enum의 제약사항으로 첫번째 항목은 반드시 0으로 지정되어야 한다.

4.6. Reserved value

Reserved value는 key 또는 value를 점유하여 해당 값을 사용하지 못하게 한다. 향후 추가될 값을 미리 정의하거나 enum에서 제거된 값을 사용하지 못하게 하는 데 활용할 수 있다.

enum Reserved
{
    reserved 1, 2, 3 to 5;
    reserved "NameA", "NameB";
}

4.7. Any

Any는 재사용 가능한 형식으로, C#의 object와 비슷한 면이 있다. google/protobuf/any.proto를 import 해주어야 하며 Pack(IMessage), Unpack<T>(), TryUnpack<T>(out T), Is(MessageDescriptor) 등의 메서드를 지원하여 형변환 및 확인이 가능하다.

import "google/protobuf/any.proto";
 
package ProtoTest;
 
message Foo { }
 
message Bar { }
 
message Baz 
{
    google.protobuf.Any Data = 1;
}
using Google.Protobuf.WellKnownTypes;
using ProtoTest;
 
private static void Main(string[] args)
{
    Baz baz = new Baz();
    baz.Data = Any.Pack(new Foo());
    CheckType(baz);
}
 
private static void CheckType(Baz baz)
{
    if (baz.Data.Is(Foo.Descriptor))
    {
        Console.WriteLine("Foo");
    }
    else if (baz.Data.Is(Bar.Descriptor))
    {
        Console.WriteLine("Bar");
    }
    else
    {
        throw new ArgumentException($"{baz.Data} is unknown type");
    }
}
 
/* output:
Foo
*/

4.8. Service

service를 정의하기 위해 Grpc Nuget 패키지를 설치한다. service는 RPC service (WCF, gRPC 등...) 에 message를 사용 가능하게 해준다. Protobuf compiler는 service 선언을 확인하여 interface code 및 stub을 생성한다. 아래는 생성한 service를 이용한 gRPC 통신의 예시다.

package ProtoTest;
 
message Transaction
{
    string SenderAddress = 1;
    int32 SenderPort = 2;
    int32 TransactionID = 3;
}
 
message Message
{
    Transaction Transaction = 1;
    string Message = 2;
}
 
service CoreService
{
    rpc Ping(Message) returns (Message);
}
using ProtoTest;
using Grpc.Core;
 
private static void Main(string[] args)
{
    // RPC channel 생성
    Channel channel = new Channel("localhost", 50001, SslCredentials.Insecure);
 
    // Service client 생성
    var coreService = new CoreService.CoreServiceClient(channel);
 
    Transaction transaction = new Transaction() { SenderAddress = "127.0.0.1", SenderPort = 50001, TransactionID = 0 };
    Message message = new Message() { Transaction = transaction, Message_ = "Hello" };
 
    // Server에 Ping service 요청
    var returnMessage = coreService.Ping(message);
    Console.WriteLine(returnMessage);
}

IMPORTANT

gRPC 통신을 하는 경우 Protocol buffer 요소는 통신을 위한 인터페이스이기 때문에 매우 중요하다. 서버와 클라이언트 개발의 주체가 다른 경우에는 특히 신중하게 다뤄야 한다.

  • Wire 형식이 달라지는 경우, 서버측에서 변경된 *.proto 파일을 제공하거나 알려주지 않으면 클라이언트 측에서는 이를 알기 어렵다.
    외국의 서버와 통신하는 과정에서 이런 일이 발생하였는데, 업데이트 시점마다 달라지는 인터페이스로 인해 고생한 일이 있었다. (연락이 잘 되지 않아 매번 통신 테스트를 하며 변경사항이 있는지 일일이 확인해야 했다.)
    • enum의 key, value의 순서 또는 값을 변경하는 경우도 마찬가지다. enum을 제어 요소로 활용하는 경우 클라이언트 측에서는 변경된 요소에 대해 알지 못해 어리둥절한 상황이 발생할 수 있다.
  • 메시지에 대해 주의해야 하는 사항에 대한 공식 자료는 다음을 참조한다.

5. 참조 자료