[Flatbuffers] 플랫버퍼란?
Development/Netty & FlatBuffers

[Flatbuffers] 플랫버퍼란?

반응형

두괄식으로, 플랫버퍼는 메세지 송/수신에 사용되는 플랫폼 종속성 없이 사용가능한 "직/역직렬화 라이브러리" 이다.

 

플랫버퍼를 쓰는이유?

  • 데이터 송/수신 시 파싱/언패킹을 안해도 된다.
  • 메모리 효율성이 높고, 빠른 속도를 보장한다
  • 유연성(사용하는 데이터타입에 대한)
  • 적은 량의 코드로 작성 가능(이건 좀 거짓이 있다. 코드가 간단하진 않다)
  • 사용하기 편리하다
  • 크로스 플랫폼, 종속성 없이 사용이 가능
프로토콜 버퍼(Protocol Buffer)라고 기존에 구글이 만들어 놓은 직렬화 라이브러리가 존재했었는데, 이 FlatBuffer는 완전히 "게임"에 초점을 맞춘 라이브러리 라고 할 수 있겠다.
 
비교 표를 보면 ProtoBuf를 팀킬했다 능가하는 성능을 자랑한다.

 

사실 이게 꼭 사용 안해도 되는 라이브러리인데, 게임서버라던지, 채팅서버에서 고 성능의 효율성을 부여하기 위해 사용한다고 생각하면 된다.

 

소켓통신을 예로 들면, 데이터 스트림을 주고 받을때 언어에 관계없이 low단에서는 byte array로 송 수신을 하게 되는데, 그 때 메모리 참조 및 복사를 최소화 해서 높은 성능을 제공하는 라이브러리이다.

 

그래서.. 어떻게 쓰는지를 일단 알아봐야 한다.

 

1. IDL을 작성한다

인터페이스를 정의하는(Interface Description Language) IDL을 먼저 작성해야 한다.

이는, 크로스 플랫폼을 지원하며 어떤 방식으로 컴파일 할 것인지만 결정하면 해당 플랫폼에 맞게 라이브러리가 생성된다.

 

만약 Object를 송/수신하고 싶다. 할때 플랫버퍼의 사용성이 진가를 발휘한다.

 

table Monster {
  pos:Vec3;
  mana:short = 150;
  hp:short = 100;
  name:string;
  friendly:bool = false (deprecated, priority: 1);
  inventory:[ubyte];
  color:Color = Blue;
  test:Any;
}
 

위는 게임에서 사용되는 "몬스터" 객체의 인터페이스를 정의한 예제인데, 보시다시피 한 테이블에 여러개의 변수가 들어가 있다.

HP, Mana, 이름, 인벤토리, 컬러 등등

 

이런식으로 정의를 하고 컴파일을 하면 C++, JAVA, C# ... 에 맞게 알아서 클래스가 생성된다.

 

그 클래스를 import해서 쓰기만 하면 byte[] <-> Monster 간의 변환이 알아서 적절하게 완료가 된다.

 

상당히 편리한 인터페이스라고 생각한다.

 

자 그럼 인터페이스를 작성했으니, 이를 각 플랫폼에 맞게 변환을 해야 한다.

 

2. 컴파일을 하자

여기서 좀 헷갈릴 수 있는데, 일단 FlatBuffer의 버전에 따라 동작이 다르므로, idl을 작성해서 나온 컴파일 결과와, 종속성으로 넣어주는 라이브러리 파일의 버전이 같아야 한다.

 

애초에 라이브러리를 받을 때 동일 버전으로 받으면 된다.

 

github에 해당 라이브러리가 등록되어 있는데, release라고 적힌놈을 받아서 사용하면 된다.

 

최신 Release Version : https://github.com/google/flatbuffers/releases

 

 

접속해보면 위와같이 친절하게 버전별로 달라진 점이나 바뀐점을 말해주는데,

일단 컴파일을 하려면 flatc.exe 가 있어야 한다.

그렇기에, 위에 flatc_windows_exe를 받아서 압축을 푼다 -> flatc.exe가 나온다

 

소스코드 또한 받아서 압축을 풀면 각 플랫폼 별로 추가해줘야 할 라이브러리 클래스들이 나온다.

 

컴파일 방법은, 다음과 같다

 

커맨드창에서 flatc.exe를 실행시키는데

 

flatc [ GENERATOR OPTIONS ] [ -o PATH ] [ -I PATH ] [ -S ] FILES...
      [ -- FILES...]

 

와 같은 인자를 넣어서 실행시키면 된다.

 

flatc Arguments 설명
  • --cpp-c : Generate a C++ header for all definitions in this file (as filename_generated.h).
  • --java-j : Generate Java code.
  • --csharp-n : Generate C# code.
  • --go-g : Generate Go code.
  • --python-p: Generate Python code.
  • --javascript-s: Generate JavaScript code.
  • --php: Generate PHP code.
  • --grpc: Generate RPC stub code for GRPC.
 
  • --binary-b : If data is contained in this file, generate a filename.bin containing the binary flatbuffer (or a different extension if one is specified in the schema).
  • --json-t : If data is contained in this file, generate a filename.json representing the data in the flatbuffer.
 
  • -o PATH : Output all generated files to PATH (either absolute, or relative to the current directory). If omitted, PATH will be the current directory. PATH should end in your systems path separator, e.g. / or \.
  • -I PATH : when encountering include statements, attempt to load the files from this path. Paths will be tried in the order given, and if all fail (or none are specified) it will try to load relative to the path of the schema file being parsed.
  • -M : Print make rules for generated files.
  • --strict-json : Require & generate strict JSON (field names are enclosed in quotes, no trailing commas in tables/vectors). By default, no quotes are required/generated, and trailing commas are allowed.
  • --defaults-json : Output fields whose value is equal to the default value when writing JSON text.
  • --no-prefix : Don't prefix enum values in generated C++ by their enum type.
  • --scoped-enums : Use C++11 style scoped and strongly typed enums in generated C++. This also implies --no-prefix.
  • --gen-includes : (deprecated), this is the default behavior. If the original behavior is required (no include statements) use --no-includes.
  • --no-includes : Don't generate include statements for included schemas the generated file depends on (C++).
  • --gen-mutable : Generate additional non-const accessors for mutating FlatBuffers in-place.
  • --gen-object-api : Generate an additional object-based API. This API is more convenient for object construction and mutation than the base API, at the cost of efficiency (object allocation). Recommended only to be used if other options are insufficient.
  • --gen-onefile : Generate single output file (useful for C#)
  • --gen-all: Generate not just code for the current schema files, but for all files it includes as well. If the language uses a single file for output (by default the case for C++ and JS), all code will end up in this one file.
  • --raw-binary : Allow binaries without a file_indentifier to be read. This may crash flatc given a mismatched schema.
  • --proto: Expect input files to be .proto files (protocol buffers). Output the corresponding .fbs file. Currently supports: packagemessageenum, nested declarations, import (use -I for paths), extendoneofgroup. Does not support, but will skip without error: optionserviceextensions, and most everything else.
  • --schema: Serialize schemas instead of JSON (use with -b). This will output a binary version of the specified schema that itself corresponds to the reflection/reflection.fbs schema. Loading this binary file is the basis for reflection functionality.
  • --conform FILE : Specify a schema the following schemas should be an evolution of. Gives errors if not. Useful to check if schema modifications don't break schema evolution rules.


 

 

ex 1) 해당 ldl파일을 JAVA로 만들거야!

.fbs 파일은 그냥 임의로 만든 거고, 위의 idl파일을 어떤 형식으로 저장해도 무관하다(txt도 가능)

flatc --java -o ./src/main/java/ ./protocol_idl/javaGompangProtocol.fbs

 

ex 2) 해당 idl을 C#으로 만들거야!

flatc --csharp-o ./src/main/java/ ./protocol_idl/csGompangProtocol.fbs

 

 

컴파일이 성공했을 경우 해당 폴더에 플랫폼 별 클래스가 생성된다.

 

C#으로 생성된 클래스의 예는 아래와 같다

 

 


// automatically generated by the FlatBuffers compiler, do not modify

using System;
using FlatBuffers;

public sealed class EchoMsg : Table {
  public static EchoMsg GetRootAsEchoMsg(ByteBuffer _bb) { return GetRootAsEchoMsg(_bb, new EchoMsg()); }
  public static EchoMsg GetRootAsEchoMsg(ByteBuffer _bb, EchoMsg obj) { return (obj.__init(_bb.GetInt(_bb.Position) + _bb.Position, _bb)); }
  public EchoMsg __init(int _i, ByteBuffer _bb) { bb_pos = _i; bb = _bb; return this; }

  public short ProtocolId { get { int o = __offset(4); return o != 0 ? bb.GetShort(o + bb_pos) : (short)0; } }
  public string Msg { get { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } }
  public ArraySegment<byte>? GetMsgBytes() { return __vector_as_arraysegment(6); }

  public static Offset<EchoMsg> CreateEchoMsg(FlatBufferBuilder builder,
      short ProtocolId = 0,
      StringOffset MsgOffset = default(StringOffset)) {
    builder.StartObject(2);
    EchoMsg.AddMsg(builder, MsgOffset);
    EchoMsg.AddProtocolId(builder, ProtocolId);
    return EchoMsg.EndEchoMsg(builder);
  }

  public static void StartEchoMsg(FlatBufferBuilder builder) { builder.StartObject(2); }
  public static void AddProtocolId(FlatBufferBuilder builder, short ProtocolId) { builder.AddShort(0, ProtocolId, 0); }
  public static void AddMsg(FlatBufferBuilder builder, StringOffset MsgOffset) { builder.AddOffset(1, MsgOffset.Value, 0); }
  public static Offset<EchoMsg> EndEchoMsg(FlatBufferBuilder builder) {
    int o = builder.EndObject();
    return new Offset<EchoMsg>(o);
  }
};

public sealed class TestMsg : Table {
  public static TestMsg GetRootAsTestMsg(ByteBuffer _bb) { return GetRootAsTestMsg(_bb, new TestMsg()); }
  public static TestMsg GetRootAsTestMsg(ByteBuffer _bb, TestMsg obj) { return (obj.__init(_bb.GetInt(_bb.Position) + _bb.Position, _bb)); }
  public TestMsg __init(int _i, ByteBuffer _bb) { bb_pos = _i; bb = _bb; return this; }

  public string Msg { get { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } }
  public ArraySegment<byte>? GetMsgBytes() { return __vector_as_arraysegment(4); }

  public static Offset<TestMsg> CreateTestMsg(FlatBufferBuilder builder,
      StringOffset MsgOffset = default(StringOffset)) {
    builder.StartObject(1);
    TestMsg.AddMsg(builder, MsgOffset);
    return TestMsg.EndTestMsg(builder);
  }

  public static void StartTestMsg(FlatBufferBuilder builder) { builder.StartObject(1); }
  public static void AddMsg(FlatBufferBuilder builder, StringOffset MsgOffset) { builder.AddOffset(0, MsgOffset.Value, 0); }
  public static Offset<TestMsg> EndTestMsg(FlatBufferBuilder builder) {
    int o = builder.EndObject();
    return new Offset<TestMsg>(o);
  }
};

 

알아보기 힘들게 컴파일이 되었는데 상관쓰지 말고 다음 단계로 가자

 

3. FlatBuffer의 라이브러리 파일을 추가하자

 

아까 다운받은 source_code를 압축해제해보면 

 

 

이런식으로 디렉토리가 생성되는데, 각 디렉토리에 적힌 이름이 "플랫폼"의 라이브러리가 된다.

 

java를 기준으로 한다면 java폴더 내의 파일들이 된다. (다른 플랫폼을 쓴다면 해당 플랫폼 언어에 맞는 파일을 가져가면 된다)

 

java 폴더를 들어가보면

 

com/google/flatbuffers 디렉토리에 

 

이런식으로 플랫버퍼를 사용하기 위한 파일들이 존재한다.

 

com파일 통째로 프로젝트에 import 시켜도 무방하다.

 

 

위는 java 프로젝트에 flatbuffer를 추가한 모습인데 위와같이 추가되면 된다.

 

여기 까지 되면 플랫버퍼를 사용할 준비가 되어 있는것이다.

 

그럼 아까 컴파일 한 플랫버퍼가 만들어준 클래스를 역시 프로젝트에 추가해주자

 

 

잘 추가가 되었다면 위와 같이 오류 없이, 컴파일한 클래스를 사용할 수 있는 상태가 된 것이다

 

4. 이제 사용해보자

프로젝트에 flatbuffer 라이브러리 추가, 직접 만든 idl파일을 컴파일 한 클래스를 추가했다면 이제 사용하는 일만 남았다.

 

다만, 좀 순서가 헷갈릴 수 있는데 "순서" 에 따라 오류가 날 수도 있다 잘 지키도록 하자.

 

직렬화 순서는 다음과 같다(EchoMsg 기준)

  • FlatBufferBuilder 생성
  • String이 존재한다면 CreateString으로 문자열을 생성해두고 offset을 저장(중요)
  • EchoMsg.StartEchoMsg();
  • EchoMsg.AddMsg();
  • EchoMsg.EndEchoMsg(fbb);
  • fbb.Finish();
  • fbb.SizedByteArray() // 이게 최종 byte[] 이다. 반드시 SizedByteArray() 를 사용해야 함. fbb에 담긴 메모리를 직접 접근해서 쓰지 말자.

C# 기준으로 객체를 만들고 byte[]로 바꾸는 코드이다(언어는 별 상관이 없다. 어짜피 비슷한 함수로 구현이 되서 순서만 보면 된다)

 


FlatBufferBuilder fbb = new FlatBufferBuilder(1);
var offset = fbb.CreateString(msg);
EchoMsg.StartEchoMsg(fbb);
EchoMsg.AddProtocolId(fbb, (short)1);
EchoMsg.AddMsg(fbb, offset);
var endOffset = EchoMsg.EndEchoMsg(fbb);
fbb.Finish(endOffset.Value);
byte[] packet = fbb.SizedByteArray();

 

마지막에 FlatBufferBuilder의 SizedByteArray() 메소드로 byte[] 배열을 받게 된다.

  • fbb.ByteBuffer.Data로 접근할 수 있지만 offset이 증가된 상태이므로, 온전히 데이터를 전부 받기 위해서는 SizedByteArray()를 호출해야 한다

이 byte[]배열을 서버나 클라이언트로 보내면 된다.

 

역직렬화 순서는 다음과 같다(EchoMsg 기준)

 

// ... 패킷 수신으로 byte[] 를 얻었다고 치자

 

byte[] data = socket.read();

 

EchoMsg echoMsg = EchoMsg.getRootAsEchoMsg(data);

 

echoMsg.Msg(); // String의 Msg를 받을 수 있다


 

정말 간단하다.

 

byte[]배열을 getRootAsEchoMsg()에 인자로 넘겨주게 되면, 해당 byte[]에서 원하는 데이터들을 메소드로 접근 가능하다.

 

이와 같이 데이터 송/수신 시 객체를 만들어서 사용하면 성능좋은 동작을 보장할 수 있다고 한다.

 

필자도 현재는 TCP GameServer 에 메세지 송/수신에 FlatBuffer를 사용하고 있다.

사용법에 있어서 좀 까다로워서 그렇지, 한번 환경이 구축되면 여간 편한게 아니다.

 

* FlatBuffers의 성능이 좋은 이유에 대한 그림

필자는 이 그림을 보면서 아.. 저렇게 하면 되는구나 라고 생각이 들었다.

 

사실, 직/역직렬화 라이브러리가 하는것은 개발자가 집어넣은 데이터를 잘 포맷팅해서 송/수신측에서 열어보는 그런 목적으로 제공이 되는데

 

FlatBuffers의 경우는 JSON처럼 { }(Bracket)이 있는것도 아니고 raw data를 직접 byte[]로 쓰기/읽기를 하지만,

 

해당 offset이 어디서부터 시작되고 끝나는지를 명시함으로써 사실 저 byte배열에서 따로 Parsing에 걸리는 시간 및 encode/decode하는 시간이

 

없어지게 됨에 따라 다른 라이브러리보다 빠른 성능을 자랑하는 것이다.

 

Protobuf의 경우는 어떤 데이터를 읽기 위해서는 모든 byte[]배열을 순회해서 테이블 명세를 얻은 후에나 데이터를 얻어올 수 있다.

 

 

table을 구분하는 방식 예

 

 

* 정리

 

FlatBuffer Github : https://github.com/google/flatbuffers

FlatBuffer Release Page : https://github.com/google/flatbuffers/releases

FlatBuffer Guide : https://google.github.io/flatbuffers/

반응형

'Development > Netty & FlatBuffers' 카테고리의 다른 글

FlatBuffers 빌드 자동 구성  (0) 2018.04.15
Netty 통신 서버 개발 관련 주저리  (1) 2018.04.05