본문 바로가기
Programming

protobuf 사용하기

by 드로니뚜벅이 2022. 5. 9.

Protocol Buffer구글에서 개발하고 오픈소스로 공개한 직렬화 데이터 구조입니다. C++, Java, Python, C#, Go, Object-C, JavaScript, Ruby 등 다양한 언어를 지원하며 특히 직렬화 속도가 빠르고 직렬화된 파일의 크기도 작아서 Apache Avro 파일 포맷과 함께 많이 사용되고 있습니다.

(직렬화데이터를 파일로 저장하거나 또는 네트워크로 전송하기 위해 바이너리 스트림 형태로 저장하는 행위입니다)

 

특히 gRPC라는 네트워크 프로토콜의 경우 HTTP 2.0을 기반으로 하면서 메시지를 이 프로토콜 버퍼를 이용하여 직렬화하기 때문에 프로토콜 버퍼를 이해해 놓으면 gRPC를 습득하는 것이 상대적으로 쉽습니다.

프로토콜 버퍼는 하나의 파일에 최대 64MB까지 지원할 수 있으며 재미있는 기능 중 하나는 JSON 파일을 프로토콜 버퍼 파일 포맷으로 전환이 가능하고, 반대로 프로토콜 버퍼 파일도 JSON으로 전환이 가능합니다.

 

Why Use Protocol Buffers?

우리가 사용하려고 하는 예제는 사람들의 주소 정보를 파일로 쓰거나 읽어오는 매우 간단한 address book 어플리케이션입니다. 주소록에 있는 각각의 사람들은 이름, ID, 이메일 주소, 전화번호를 가지고 있습니다.

그러면 이와 같은 구조화된 데이터를 어떻게 직렬화하고 데이터를 얻어올까요? 이를 풀 수 있는 방법이 몇 가지 있습니다.

  • 메모리의 raw 데이터들은 바이너리 형태로 저장되거나 보내질 수 있습니다. 시간이 지나면서, 이러한 형태는 받거나 읽는 코드가 정확히 동일한 메모리 레이아웃으로 컴파일되어야 하기 때문에 깨지기 쉬운 형태가 됩니다. 그리고, raw 포맷으로 데이터가 축적된 파일들과 그러한 포맷으로 연결된 어플리케이션들이 퍼지게 되면, 데이터 포맷을 확장하는 것은 매우 어려운 일이 됩니다.
  • 애드훅 형태로 데이터를 단일 스트링으로 인코딩하는 것을 사용할 수 있습니다("12:3-23:67" 처럼 4개의 정수로 인코딩된 형태처럼). 이러한 형태는 간단하고 확장이 용이하지만, 인코딩과 파싱하는 코드를 작성해야 하고, 파싱은 약간의 런타임 비용을 부과합니다. 매우 간단한 데이터를 인코딩하는데는 이 방법이 최선입니다.
  • XML로 데이터를 직렬화하는 방법이 있습니다. 이 방법은 매우 매력적인 방법입니다. 왜냐하면, XML은 가독성이 높고 많은 언어들로 바인딩된 라이브러리들이 있다. 다른 어플리케이션이나 프로젝트와 데이터를 공유하려면 이 방법은 좋은 선택이 될 수 있습니다. 하지만, XML은 공간 집약적이고 어플리케이션에서 많은 퍼포먼스 패널티를 가져오는 인코딩과 디코딩으로 악명이 자자합니다. 그리고 XML DOM 트리를 탐색하는 것은 클래스에서 일반적으로 필드들을 탐색하는 것 보다 복잡합니다.

프로토콜 버퍼는 이러한 문제점들을 해결할 수 있는 유연하고, 효율적이고 자동화된 솔루션입니다. 프로토콜 버퍼로, 저장하려고 하는 데이터 구조에 대한 .proto 파일을 작성합니다. 그 파일로부터, 프로토콜 버퍼 컴파일러(protoc)는 효율적인 바이너리 포맷의 프로토콜 버퍼 데이터를 자동으로 인코딩하고 파싱하는 것을 구현하는 클래스를 생성합니다. 생성된 클래스는 프로토콜 버퍼로 만들어진 필드들에 대한 getter와 setter를 제공하고 하나의 유닛으로서 프로토콜 버퍼를 읽거나 쓰는 디테일한 부분을 책임집니다. 더욱 중요한 것은, 프로토콜 버퍼 포맷은 이전 포맷으로 인코딩된 데이터를 여전히 읽을 수 있게 하면서 포맷을 확장하는 방법을 지원합니다.

 

Where to Find the Example Code

참고할 만한 예제 코드는 소스 코드 패키지의 example 폴더 아래에 포함되어 있습니다.

이 튜토리얼은 프로토콜 버퍼로 작업을 하는 C++ 프로그래머들을 위한 입문입니다. 간단한 예제 어플리케이션을 단계적으로 제작하면서 방법을 보여줄 것입니다.

이 튜토리얼은 C++로 프로토콜 버퍼를 사용하는 방법에 대한 전반적인 가이드는 아닙니다. 좀더 자세한 정보는 Protocol Buffer Language Guide, C++ API Reference, C++ Generated Code Guide, Encoding Reference를 보세요.

 

Defining Your Protocol Format

address book 어플리케이션을 제작하려면, .proto 파일로부터 시작해야 합니다. .proto 파일에 있는 정의들은 간단합니다 : 직렬화하려는 각각의 데이터 구조들에 대한 메시지를 추가하고, 메시지에서 각각의 필드들에 대한 이름과 타입을 지정합니다. 여기에 이러한 메시지들을 정의한 addressbook.proto 파일이 있습니다.

syntax = "proto2"

package tutorial;

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phone = 4;
}

message AddressBook {
  repeated Person person = 1;
}

위에서 보듯이, 문법은 C++나 자바와 유사합니다. 파일의 각 부분들로 들어가서 내용을 살펴보도록 하겠습니다.

.proto 파일은 패키지 선언으로 시작합니다. 패키지 선언은 서로 다른 프로젝트들 간의 이름의 혼선을 방지해 줍니다. C++에서는, 생성된 클래스들은 패키지 이름과 동일한 네임스페이스 안에 위치하게 될 것입니다.

다음으로, 자신의 메시지 정의들을 가지고 있다. 메시지는 타입 형태의 필드들을 포함하는 집합체일 뿐입니다. 많은 간단한 스탠다드 데이터 타입들이 필드 타입으로 사용될 수 있습니다(bool, int32, float, double, string들이 포함됩니다). 필드 타입으로 그 외의 메시지 타입들을 이용해서 자신의 메시지들에 그 이상의 구조를 추가할 수 있습니다. 위의 예에서는 Person 메시지가 PhoneNumber 메시지들을 포함하고 있고 AddressBook 메시지는 Person 메시지들을 포함하고 있습니다. 그리고 다른 메시지들 안쪽에서 메시지 타입들을 정의할 수도 있습니다. 위에서 보았듯이, PhoneNumber 타입은 Person 안에서 정의되었습니다. 그리고 자신의 필드들에 enum 같은 상수를 사용하고 싶다면, enum을 정의할 수도 있습니다. 여기서는 전화번호가 MOBILE, HOME, WORK 중에 하나가 될 수 있도록 지정해줄 수 있습니다.

각각의 요소들에 있는 "= 1", "= 2" 마커들은 필드가 바이너리 인코딩에서 사용하는 유니크한 태그를 식별하도록 해줍니다. 태그 넘버 1~15는 그보다 높은 숫자들보다 인코딩하는데 1 이하의 바이트를 요구합니다. 그래서 최적화 방법으로 일반적으로 사용되거나 반복적인 요소들에 대해서는 그러한 태그들을 사용하도록 할 수 있습니다. 반복적인 필드에 있는 각각의 요소들은 태그 넘버들을 다시 인코딩할 필요가 있습니다. 그래서 반복적인 필드들은 최적화에서 좋은 선택입니다.

각각의 필드는 반드시 다음의 모디파이어들 중에 하나가 달려 있어야 한다.

  • required : 이 필드의 값은 반드시 제공되어야 합니다. 그렇지 않으면 메시지는 초기화 되지 않은 것으로 간주될 것입니다. libprotobuf가 디버그 모드에서 컴파일되면, 초기화되지 않은 메시지를 직렬화하는 것은 실패를 유발할 것입니다. 최적화 빌드에서는, 이러한 체크가 스킵되고 메시지는 그냥 작성될 것입니다. 하지만, 초기화되지 않은 메시지를 파싱하는 것은 항상 실패할 것입니다(파싱 매소드에서 false를 리턴함으로서). 이 외에는, required 필드는 정확히 optional 필드처럼 행동합니다.
  • optional : 이 필드는 셋팅되도 되고 안되도 됩니다. 이 필드가 셋팅되지 않으면, 기본값이 사용됩니다. 간단한 타입들에게는, 우리가 위의 예제에서 전화번호 type에서 했던 것처럼, 제작자가 자신의 기본값을 지정해 줄 수 있습니다. 그렇지 않으면, 시스템의 기본값이 사용됩니다.: 숫자 타입에 대해서는 0, 스트링 타입에는 빈 스트링이, bool에는 false가 사용된다. 임베디드된 메시지들에 대해서는 기본값은 항상 필드에 셋팅된 값이 없다는 메시지의 "default instance"이거나 "prototype"입니다. 명시적으로 값이 셋팅되지 않은 optional(이나 required) 필드의 값을 가져오는 접근자를 호출하는 것은 항상 그 필드의 기본값을 리턴합니다.
  • repeated : 이 필드는 여러번 반복될 것입니다(0번을 포함해서). 반복되는 값들의 순서는 프로토콜 버퍼에 보존될 것입니다. 반복적인 필드들은 다이나믹하게 사이즈가 변하는 배열들로 생각할 수 있습니다.
Required Is Forever
필드들을 required로 표시하는 것에 대해서 세심한 주의를 요합니다. 어느 시점에서, required 필드를 쓰거나 전송하는 것을 멈추고 싶을 때, required 필드를 optional 필드로 바꾸는 것이 문제가 될 수 있습니다. 이전 리더들은 이 필드가 없는 메시지들을 미완성된 것으로 판단할 것이고, 본의 아니게 해당 메시지를 드랍시키거나 거부하게 될 수 있습니다. 대신에 어플리케이션에서 지정하는 유효성 루틴을 커스터마이징 하는 것을 생각해 볼 수 있습니다. 구글의 어떤 엔지니어들은 required 를 사용하는 것은 좋은 쪽보다 해로운 쪽이 더 많다고 결론을 내고 있습니다. 그들은 optional
과 repeated 만을 사용하는 것을 선호합니다. 하지만, 이러한 관점이 전체를 대변하는 것은 아닙니다.

.proto 파일을 작성하는 완벽한 가이드(모든 가능한 필드 타입들을 포함해서)는 Protocol Buffer Language Guide에서 찾을 수 있습니다. 클래스 상속과 유사한 기능을 찾지는 마세요. 프로토콜 버퍼는 그러한 것을 하지 않습니다.

 

Compiling Your Protocol Buffers

이제 .proto 파일을 갖게 되었고, 다음 해야할 일은 AddressBook 메시지들을 쓰고 읽을 클래스들을 생성하는 것입니다. 그렇게 하려면, 프로토콜 버퍼 컴파일러인 자신의 .proto 파일에 protoc를 실행해야 합니다.

  1. 컴파일러를 설치하지 않았다면, 패키지를 다운로드 하고 README 에 있는 지침을 따라 설치합니다.
  2. 이제 소스 디렉토리(자신의 어플리케이션의 소스가 있는 곳 : 이 값을 지정해 주지 않으면 현재 디렉토리가 사용됩니다)와 목적 디렉토리(코드를 생성하려는 디렉토리. 보통 소스 디렉토리와 동일하게 지정합니다) 그리고 .proto 파일에 대한 경로를 지정해주고 컴파일러를 실행합니다. 우리의 경우에는 다음과 같습니다.
      protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto

      C++ 클래스가 필요하기 때문에 --cpp_out 옵션을 사용합니다. 그 외 지원 언어들에도 유사한 옵션들이 제공됩니다.

이렇게 하면, 지정한 목적 디렉토리에 다음의 파일들을 생성합니다.

  • addressbook.pb.h : 생성된 클래스들을 정의하는 헤더 파일
  • addressbook.pb.cc : 클래스들의 구현부분을 포함하는 파일

 

The Protocol Buffer API

이제 생성된 코드들을 좀 보고 컴파일러가 어떤 클래스들과 함수들을 생성했는지 살펴보겠습니다. tutorial.pb.h를 보셨다면, tutorial.proto 파일에서 지정한 각각의 메시지들에 대한 클래스들이 있음을 볼 수 있었을 것입니다. Person 클래스를 좀더 자세히 들여다 보면, 컴파일러가 각각의 필드에 대한 접근자들을 생성해 놓은 것을 볼 수 있을 것입니다. 예를 들면, name, id, email, phone필드들에 대해서 다음의 메소드들을 가지게 되었을 것입니다.

// name
  inline bool has_name() const;
  inline void clear_name();
  inline const ::std::string& name() const;
  inline void set_name(const ::std::string& value);
  inline void set_name(const char* value);
  inline ::std::string* mutable_name();

  // id
  inline bool has_id() const;
  inline void clear_id();
  inline int32_t id() const;
  inline void set_id(int32_t value);

  // email
  inline bool has_email() const;
  inline void clear_email();
  inline const ::std::string& email() const;
  inline void set_email(const ::std::string& value);
  inline void set_email(const char* value);
  inline ::std::string* mutable_email();

  // phone
  inline int phone_size() const;
  inline void clear_phone();
  inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phone() const;
  inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phone();
  inline const ::tutorial::Person_PhoneNumber& phone(int index) const;
  inline ::tutorial::Person_PhoneNumber* mutable_phone(int index);
  inline ::tutorial::Person_PhoneNumber* add_phone();

위에서 보듯이, getter들은 정확히 소문자로 필드와 같은 이름을 가지고 있고, setter 메소드들은 set_으로 시작합니다. 그리고 각각의 단일 필드(required나 optional)들에 대해서 값이 셋팅되었으면 true를 리턴하는 has_소드들도 있다. 마지막으로, 각각의 필드는 필드의 값을 비우는 clear_ 메소드를 가지고 있습니다.

위에서 보면, 숫자인 id 필드는 기본 접근자인 set을 가지고 있는 반면, name과 email 필드들은 문자열이기 때문에 몇 개의 추가 메소드들을 가지고 있습니다. 스트링에 대한 포인터를 바로 가져오는 mutable_ getter와 추가 setter들입니다. email이 아직 값이 셋팅되어 있지 않더라도 mutable_email()을 호출 할 수 있습니다. 스트링은 자동으로 빈 스트링으로 초기화될 것입니다. 이 예제에서 단일의 메시지 필드를 가지고 있다면, mutalble_ 메소드를 갖게 되지만 set_메소드는 없을 것입니다.

그리고 repeated 필드들은 몇 가지 특별한 메소드들을 가지고 있습니다. repeated phone 필드에 대한 매소드들을 모았다면, 다음을 볼 수 있을 것입니다.

  • 필드의 _size 함수(Person에 할당된 전화번호가 몇 개인지)
  • 인덱스를 이용해서 지정된 전화번호를 얻어오는 것
  • 지정된 인덱스의 전화번호를 업데이트하는 것
  • 메시지에 다른 전화번호를 추가하고 편집할 수 있는 것(새로운 값을 전달할 수 있는 add_ 함수)

특정 필드 정의에 대한 프로토콜 컴파일러가 생성한 맴버들이 무엇인지 알고 싶으시면, C++ generated code reference를 참고하세요.

 

Enum and Nested Classes

생성된 코드는 .proto 파일에 있는 enum과 대응되는 PhoneType enum을 포함하고 있습니다. 이 타입들은 Person:PhoneType에서 조회해 볼 수 있고, 각각의 값들은 Person::MOBILE, Person::HOME, Person::WORK 입니다(세부 구현 내용은 좀더 복잡하지만, 사용하는데 있어서는 크게 신경 쓸 필요는 없습니다).

그리고 컴파일러는 Person::PhoneNumber라는 중첩된 클래스도 생성했습니다. 코드를 보면, 실제 클래스가 Person_PhoneNumber 로 되어 있는 것을 볼 수 있을 것입니다. 하지만 Person 안에서 정의된 typedef는 중첩된 클래스처럼 다룰 수 있도록 해 줍니다. 중첩된 클래스와 다르게 하는 단 하나의 경우는 다른 파일에서 전방 선언을 할 경우입니다. C++에서 중첩된 타입을 전방 선언할 수는 없습니다. 대신에 Person_PhoneNumber 를 전방 선언할 수 있습니다.

 

Standard Message Methods

각각의 메시지 클래스들은 전체 메시지를 체크하고 조작할 수 있는 몇 가지 서로 다른 메소드들을 가지고 있습니다.

  • bool IsInitialized() const; : 모든 required 필드들이 셋팅되어 있는지 체크합니다.
  • string DebugString() const; : 읽을 수 있는 형태로 메시지를 리턴합니다. 특히 디버깅 시에 유용합니다.
  • void CopyFrom(const Person& from); : 전달해 준 값으로 메시지를 덮어 씁니다.
  • void Clear(); : 모든 요소들을 빈 상태로 지웁니다.

이 메소드들은 모든 C++ 프로토콜 버퍼 클래스들에 의해서 공유되는 Message 인터페이스 구현 섹션에서 설명합니다. 자세한 설명은 complete API documentation for Message를 참고하세요.

 

Parsing and Serialization

마지막으로, 각각의 프로토콜 버퍼 클래스는 프로토콜 버퍼 바이너리 포맷을 사용하는 타입의 메시지를 읽고 쓰는 메소드들을 가지고 있습니다. 다음의 메소드들을 포함하고 있습니다.

  • bool SerializeToString(string* output) const; : 메시지를 직렬화하고 파라미터로 전달한 스트링에 저장합니다. 저장된 데이터들은 텍스트가 아닌 바이너리입니다. 우리는 단지 편리한 컨테이너 사용을 위해서 스트링 클래스를 사용할 뿐입니다.
  • bool ParseFromString(const string& data); : 전달받은 스트링으로부터 메시지를 파싱합니다.
  • bool SerializeToOstream(ostream* output) const; : 전달해 준 C++ ostream 객체로 메시지를 씁니다.
  • bool ParseFromIstream(istream* input); : 전달해 준 C++ istream에서 메시지를 파싱합니다.

이 메소드들은 파싱과 직렬화에 제공되는 두 가지 옵션들일 뿐입니다. 모든 메소드 리스트를 보려면 Message API reference를 참고하세요.

Protocol Buffers and O-O Design
프로토콜 버퍼 클래스들은 기본적으로 dumb 데이터 저장소입니다(C++에서 struct 처럼). 프로토콜 버퍼 클래스들은 오브젝트 모델에서 좋은 일급 객체(first citizen class)들은 아닙니다. 생성된 클래스에 좀 더 좋은 behaviour를 추가하기 위해 가장 좋은 방법은 생성된 클래스를 어플리케이션 클래스로 래핑하는 것입니다. 그리고 .proto 파일의 디자인 전체를 컨트롤하는 것이 없다면, 프로토콜 버퍼를 래핑하는 것은 좋은 생각입니다. 이 경우, 자신의 어플리케이션의 유니크한 환경에 적절한 인터페이스를 구현한 래퍼 클래스를 사용할 수 있습니다(몇몇 데이터들과 메소드들은 숨기고 편리한 함수들을 노출시키는 등). 
하지만 절대로 프로토콜 버퍼 클래스들을 상속해서 메소드들을 추가해서는 안 된다. 이는 내부 메카니즘을 깨는 것이고 좋은 방법이 아닙니다.

 

Writing A Message

이제 프로토콜 버퍼 클래스들을 사용해 보겠습니다.

첫 번째로, address book 어플리케이션에서 할 수 있는 것은 개인 세부 항목들을 address book 파일로 쓰는 것입니다. 이를 하려면, 프로토콜 버퍼 클래스의 인스턴스를 생성하고 아웃풋 스트림에 써야 합니다.

여기에 파일로부터 AddressBook을 읽어와서 유저의 입력을 받아서 새로운 Person을 추가하는 프로그램이 있습니다. 그리고 파일로 새로운 AddressBook을 씁니다. 호출되고 참조되는 코드들 중에서 프로토콜 컴파일러에 의해서 생성된 코드들은 하이라이트되어 있다.

#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
  cout << "Enter person ID number: ";
  int id;
  cin >> id;
  person->set_id(id);
  cin.ignore(256, '\n');

  cout << "Enter name: ";
  getline(cin, *person->mutable_name());

  cout << "Enter email address (blank for none): ";
  string email;
  getline(cin, email);
  if (!email.empty()) {
    person->set_email(email);
  }

  while (true) {
    cout << "Enter a phone number (or leave blank to finish): ";
    string number;
    getline(cin, number);
    if (number.empty()) {
      break;
    }

    tutorial::Person::PhoneNumber* phone_number = person->add_phone();
    phone_number->set_number(number);

    cout << "Is this a mobile, home, or work phone? ";
    string type;
    getline(cin, type);
    if (type == "mobile") {
      phone_number->set_type(tutorial::Person::MOBILE);
    } else if (type == "home") {
      phone_number->set_type(tutorial::Person::HOME);
    } else if (type == "work") {
      phone_number->set_type(tutorial::Person::WORK);
    } else {
      cout << "Unknown phone type.  Using default." << endl;
    }
  }
}

// Main function:  Reads the entire address book from a file,
//   adds one person based on user input, then writes it back out to the same
//   file.
int main(int argc, char* argv[]) {
  // Verify that the version of the library that we linked against is
  // compatible with the version of the headers we compiled against.
  GOOGLE_PROTOBUF_VERIFY_VERSION;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // Read the existing address book.
    fstream input(argv[1], ios::in | ios::binary);
    if (!input) {
      cout << argv[1] << ": File not found.  Creating a new file." << endl;
    } else if (!address_book.ParseFromIstream(&input)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  // Add an address.
  PromptForAddress(address_book.add_person());

  {
    // Write the new address book back to disk.
    fstream output(argv[1], ios::out | ios::trunc | ios::binary);
    if (!address_book.SerializeToOstream(&output)) {
      cerr << "Failed to write address book." << endl;
      return -1;
    }
  }

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

GOOGLE_PROTOBUF_VERIFY_VERSION 매크로를 주의해서 보자. C++ 프로토콜 버퍼 라이브러리를 사용하기 전에 이 매크로를 사용하는 것이 좋다(반드시 필요한 것은 아니다). 이 매크로는 우연히 컴파일된 헤더의 버전과 호환이 되지 않는 버전의 라이브러리를 링크했는지를 확인한다. 버전이 맞지 않는 것이 발견되면, 프로그램은 실패할 것이다. 모든 .pb.cc 파일은 시작할 때 자동으로 이 매크로를 실행한다.

그리고 프로그램의 마지막에 ShutdownProtoBufLibrary() 호출을 주의해서 보자. 이 함수는 프로토콜 버퍼 라이브러리에 의해서 할당된 전역 오브젝트들을 제거한다. 이는 대부분의 프로그램에서 필요한 것은 아니다. 프로세스는 어찌되었든 종료될 것이고 OS는 프로그램에서 사용하는 모든 메모리들을 관리하고 있을 것이기 때문이다. 하지만, 만약에 모든 오브젝트들이 해제되어야 하는 메모리릭 체커를 사용하고 있거나, 하나의 프로세스에서 여러번 로드하고 해제되는 라이브러리를 사용하고 있다면, 프로토콜 버퍼를 강제로 해제하길 원할 수도 있을 것이다.

 

Reading A Message

물론, address book에서 어떤 정보도 꺼낼 수 없다면, 그다지 쓸모는 없다. 이 예제는 위의 예제에서 생성된 파일을 읽어서 모든 정보들을 프린트한다.

#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
  for (int i = 0; i < address_book.person_size(); i++) {
    const tutorial::Person& person = address_book.person(i);

    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (person.has_email()) {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (int j = 0; j < person.phone_size(); j++) {
      const tutorial::Person::PhoneNumber& phone_number = person.phone(j);

      switch (phone_number.type()) {
        case tutorial::Person::MOBILE:
          cout << "  Mobile phone #: ";
          break;
        case tutorial::Person::HOME:
          cout << "  Home phone #: ";
          break;
        case tutorial::Person::WORK:
          cout << "  Work phone #: ";
          break;
      }
      cout << phone_number.number() << endl;
    }
  }
}

// Main function:  Reads the entire address book from a file and prints all
//   the information inside.
int main(int argc, char* argv[]) {
  // Verify that the version of the library that we linked against is
  // compatible with the version of the headers we compiled against.
  GOOGLE_PROTOBUF_VERIFY_VERSION;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // Read the existing address book.
    fstream input(argv[1], ios::in | ios::binary);
    if (!address_book.ParseFromIstream(&input)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  ListPeople(address_book);

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

 

Extending a Protocol Buffer

프로토콜 버퍼를 사용하는 코드를 릴리스 한 후에 지금이나 나중에, 반드시 프로토콜 버퍼의 정의를 향상시키길 원하게 될 것이다. 하위 호환이 되는 새로운 버퍼를 원한다면, 몇가지 따라야 할 규칙이 있다.새로운 버전의 프로토콜 버퍼는 :

  • 존재하고 있는 어떤 필드라도 태그 넘버를 변경해서는 안 된다.
  • required 필드들은 추가하거나 제거해서는 안 된다.
  • optional이나 repeated 필드들은 제거해도 괜찮다.
  • optional이나 repeated 필드들은 추가되어도 괜찮지만, 반드시 새로운 태그 넘버들을 사용해야 한다(즉, 이 프로토콜 버퍼에서 절대 사용되지 않았던 태그 넘버 - 지워진 필드의 것도 안 된다)

(이 규칙들에는 몇가지 예외가 있지만 드물게 사용된다.)

이 규칙들을 따른다면, 이전 코드는 새로운 메시지들을 읽을수 있을 것이고 간단하게 새로운 필드들을 무시하게 될 것이다. 이전 코드에는, 제거된 optional 필드들은 간단하게 기본 값들을 갖게 될 것이고, 제거된 repeated 필드들은 빈 상태가 되어 있을 것이다. 그리고 새로운 코드는 이전 메시지들을 투명하게 읽을 것이다. 하지만, 새로운 optional 필드들은 이전 메시지들에서는 존재하지 않는다. 그래서 has_ 로 셋팅이 되어 있는지 명시적으로 체크를 하거나 .proto 파일에서 태그 넘버 다음에 [default = value] 를 이용해서 정당한 기본 값을 제공해 주어야 한다. optional 필드에 기본값이 지정되어 있지 않다면, 대신에 타입 지정 기본값이 사용된다. 스트링의 경우 기본값은 빈 스트링이다. boolean은 false이고 숫자 타입들은 0이다. 새로운 repeated 필드를 추가한다면, 새로 추가된 필드에는 has_ 플래그가 없기 때문에, 새로운 코드는 그 값이 비어있는 상태로 남아 있는지를 알려줄 수 없거나(새로운 코드에 의해서) 값을 셋팅할 수 없게 될 것이다(이전 코드에서).

 

Optimization Tips

C++ 프로토콜 버퍼 라이브러리는 매우 많이 최적화되어 있다. 그렇더라도 알맞은 사용법이 좀더 퍼포먼스를 향상시킬 수 있다. 여기에 라이브러리의 스피드를 떨어뜨리는 것에 대응할 수 있는 몇 가지 팁들이 있다.

  • 가능하다면 메시지 오브젝트들을 재사용한다. 메시지들은 삭제되었더라도 재사용에 할당된 메모리 주변에 있으려 할 것이다. 그러므로, 연속으로 동일한 타입과 유사한 구조를 사용하는 많은 메시지들을 다룬다면, 메모리 할당자의 부담을 줄여주기 위해서 매번 같은 메시지 오브젝트를 재사용하는 것이 좋다. 하지만, 메시지들이 모양을 바꾸거나 가끔 일반적인 경우보다 큰 메시지를 만들면 오랜 시간동안 오브젝트가 확장될 수 있다. SpaceUsed 매소드를 사용해서 메시지 오브젝트들의 크기를 감시하고 오브젝트들이 너무 커졌을 경우에는 지워주는 것이 좋다.
  • 시스템의 메모리 할당자는 여러 쓰레드로부터 많은 수의 작은 크기의 오브젝트들을 할당하는 것에 대해서 최적화가 잘 되어 있지 않을 것이다. 대신에 Google's tcmalloc를 사용해 보자.

Advanced Usage

프로토콜 버퍼들은 단순한 접근자나 직렬화를 넘어서 사용되어 왔다. 지금까지 본 것 외에 할 수 있는 것들에 대해서는 C++ API reference를 보도록 하자.

프로토콜 메시지 클래스들에 의해서 제공되는 하나의 키 요소는 reflection 이다. 메시지의 필드들을 반복해서 볼 수 있고 특정 메시지 타입에 대한 코드를 작성하지 않고서 필드의 값들을 조작할 수도 있다. reflection을 사용하는 가장 효과적인 방법은 프로토콜 메시지를 다른 인코딩으로 컨버팅하거나 다른 인코딩에서 컨버팅 해오는 것이다(XML이나 JSON 같은). reflection의 고급 사용법은 동일한 타입의 두 메시지들 사이의 차이점을 발견하거나 프로토콜 메시지들에 대한 정규 표현식 같은 것을 개발하는 것이다.

Reflection은 Message::Reflection 인터페이스에 의해서 제공된다.

 

설치 및 구성

프로토콜 버퍼 개발툴킷은 크게 두 가지 부분이 있습니다. 데이터 포맷 파일을 컴파일 해 주는 protoc와 각 프로그래밍 언어에서 프로토콜 버퍼를 사용하게 해주는 라이브러리 SDK가 있습니다.

protoc 컴파일러와 각 프로그래밍 언어별 SDK는 GitHub에서 다운로드 받으시면 됩니다.

protoc 는 C++ 소스 코드를 직접 다운로드 받아서 컴파일하여 설치할 수 있고 아니면 OS별로 미리 컴파일된 바이너리를 다운로드 받아서 설치할 수 있습니다.

각 프로그래밍 언어용 프로토콜 버퍼 SDK는 해당하는 버전을 다운로드 받아서 사용하시면 됩니다.

소스(code)를 빌드해서 설치하려면 다음 툴들이 필요합니다

  • autoconf
  • automake
  • libtool
  • make
  • g++
  • unzip
  • git

우분투에서 위에서 나열한 툴을 설치하려면 다음 명령어를 실행합니다.

$ sudo apt install autoconf automake libtool curl make g++ unzip git

소스를 얻기 위해서는 두가지 방법이 있습니다.

첫 번째 방법은 아래 릴리즈 페이지에서 tar.gz 파일이나 .zip 패키지를 다운로드 받습니다.

https://github.com/protocolbuffers/protobuf/releases/latest

예를 들어, C++ 로 빌드 하기 위해서는 릴리즈 페이지에서 protobuf-cpp-[VERSION].tar.gz 파일을 다운로드 받습니다.

두번째 방법은 "git clone" 명령어를 통해 구할 수 있습니다.

$ git clone https://github.com/protocolbuffers/protobuf.git
$ cd protobuf
$ git submodule update --init --recursive
$ ./autogen.sh

C++ 프로토콜 버퍼 런타임과 컴파일러(protoc)를 빌드하고 시스템에 설치하기 위해서는 다음 명령어를 실행합니다.

$ ./configure
$ make -j$(nproc)      # $(nproc) ensures it uses all cores for compilation
$ make check
$ sudo make install
$ sudo ldconfig        # refresh shared library cache

 

구조

프로토콜 버퍼를 사용하기 위해서는 저장하기 위한 데이터형을 proto file 이라는 형태로 정의합니다. 프로토콜 버퍼는 하나의 프로그래밍 언어가 아니라 여러 프로그래밍 언어를 지원하기 때문에 특정 언어에 종속성이 없는 형태로 데이터 타입을 정의하게 되는데  이 파일을 proto file 이라고 합니다.

이렇게 정의된 데이터 타입을 프로그래밍 언어에서 사용하려면, 해당 언어에 맞는 형태의 데이터 클래스로 생성을 해야 하는데, protoc 컴파일러로 proto file을 컴파일하면, 각 언어에 맞는 형태의 데이터 클래스 파일을 생성해 줍니다.

 

참고 사이트

 

'Programming' 카테고리의 다른 글

Julia 프로그래밍 언어 소개  (0) 2023.06.16
프로그래밍 언어 순위  (0) 2023.06.02
Lua 기본 문법 익히기 (1)  (0) 2022.11.29
YAML & yaml-cpp 라이브러리 설치  (0) 2022.09.05
LLVM  (0) 2022.04.11