horiga blog

とあるエンジニアのメモ

spring-boot で FlatBuffers を試してみた

FlatBuffersが気になってspring-bootを使って試してみたので忘れないうちに残します。

FlatBuffersGoogleが作成したクロスプラットフォームのメッセージシリアライゼーションライブラリですが、同様なものにProtocolBuffersや、MessagePackなども有名だと思います。特に性能比較などは多くの記事でも照会されているので特に書かないですが、FlatBuffersは比較的新しいものでゲーム向けへの利用を考えて作成されたとあり、以下の特徴があるようです。また公式のサイトにbenchmarkの結果がありましたがとても性能が良さそうです。

  • パース処理がなく高速にアクセスできる
  • バッファメモリだけを利用することでデータアクセスを行うことができるため、省メモリに抑えられる

なんだか、モバイル環境のゲームなどには適していそうです。今回試した環境は以下です。 * Mac OS X 10.10 * JDK 1.8.0 u45

FlatBuffersを使えるようにする

flatbuffersをcloneして、flatcをビルドする

# git clone https://github.com/google/flatbuffers.git
// XCode で開く
# open flatbuffers/build/XCode/FlatBuffers.xcodeproj

f:id:horiga:20150921022611p:plain

flatc バイナリがビルドされているので、PATHに追加する。今回は、/usr/local/binに追加した

# flatc -help                                                                                                         1 ↵
flatc: unknown commandline argument-help
usage: flatc [OPTION]... FILE... [-- FILE...]
  -b              Generate wire format binaries for any data definitions.
  -t              Generate text output for any data definitions.
  -c              Generate C++ headers for tables/structs.
  -g              Generate Go files for tables/structs.
  -j              Generate Java classes for tables/structs.
  -n              Generate C# classes for tables/structs.
  -p              Generate Python files for tables/structs.
  -o PATH         Prefix PATH to all generated files.
  -I PATH         Search for includes in the specified path.
  -M              Print make rules for generated files.
  --strict-json   Strict JSON: field names must be / will be quoted,
                  no trailing commas in tables/vectors.
  --defaults-json Output fields whose value is the default when
                  writing JSON
  --no-prefix     Don't prefix enum values with the enum type in C++.
  --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 accessors that can mutate buffers in-place.
  --gen-onefile   Generate single output file for C#
  --raw-binary    Allow binaries without file_indentifier to be read.
                  This may crash flatc given a mismatched schema.
  --proto         Input is a .proto, translate to .fbs.
  --schema        Serialize schemas instead of JSON (use with -b)
FILEs may depend on declarations in earlier files.
FILEs after the -- must be binary flatbuffer format files.
Output files are named using the base file name of the input,
and written to the current directory or the path given by -o.
example: flatc -c -b schema1.fbs schema2.fbs data.json

今回は、Javaで利用したいので、Java クライアントライブラリも準備する

# cd flatbuffers/java
# mvn install

spring-boot で使ってみる

今回は、spring-bootで試したかったので以下のようなプロジェクトを作成した。

https://github.com/horiga/springboot-flatbuffers

...

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
      <exclusion>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-tomcat</artifactId>
      </exclusion>
    </exclusions>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>

  <dependency>
    <groupId>com.google.flatbuffers</groupId>
    <artifactId>flatbuffers-java</artifactId>
    <version>1.2.0-SNAPSHOT</version>
  </dependency>

  <dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-afterburner</artifactId>
    <version>2.4.0</version>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.4.5</version>
  </dependency>

  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
  </dependency>

  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.14.8</version>
    <scope>provided</scope>
  </dependency>

  <dependency>
    <groupId>com.ning</groupId>
    <artifactId>async-http-client</artifactId>
    <version>1.9.31</version>
  </dependency>

</dependencies>
...

以下のflatbuffersのIDLを簡単に作成してみた。

namespace org.horiga.study.springboot.flatbuffers.protocol.messages;

table Token {
    id: int;
    accessToken: string;
    created: long;
}

table Me {
    token: Token;
}

table UserAnswer {
    displayName: string;
    mid: string;
    pictureUrl: string;
}

IDLをflatcでコンパイルすると、IDLのnamespaceに定義した場所に-oオプションで指定した場所からの位置にjavaのファイルが作成される。

# flatc -j -o src/main/java src/main/idl/fbs/v1.0.fbs

今回作成したFlatBuffersのためのspring-bootの処理は以下のようなクラスを追加してみた。

public class FlatBuffersHttpMessageConverter extends AbstractHttpMessageConverter<Table> {

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    public static final MediaType X_FLATBUFFERS = new MediaType("application", "x-fb", DEFAULT_CHARSET);

    public static final String X_FLATBUFFERS_MESSAGE_ID = "X-FBS-MessageId";

    protected final Map<String, FlatBuffersMessage> messageRepository;

    public FlatBuffersHttpMessageConverter(Map<String, FlatBuffersMessage> messageRepository) {
        super(X_FLATBUFFERS);
        this.messageRepository = messageRepository;
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return Table.class.isAssignableFrom(clazz);
    }

    @Override
    protected Table readInternal(Class<? extends Table> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {

        final String messageId = inputMessage.getHeaders().getFirst(X_FLATBUFFERS_MESSAGE_ID);
        log.debug("Request.messageId: {}", messageId);

        if(Objects.isNull(messageId) ||
                !messageRepository.containsKey(messageId)) {
            throw new HttpMessageNotReadableException("Unknown message protocol identifier");
        }

        final long contentLength = inputMessage.getHeaders().getContentLength();
        final ByteArrayOutputStream out =
                new ByteArrayOutputStream(contentLength >= 0 ? (int) contentLength : StreamUtils.BUFFER_SIZE);
        StreamUtils.copy(inputMessage.getBody(), out);

        return messageRepository.get(messageId).build(ByteBuffer.wrap(out.toByteArray()));
    }

    @Override
    protected void writeInternal(Table message, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        setFlatBuffersResponseHeaders(message, outputMessage);

        final byte[] dst = new byte[getContentLength(message, X_FLATBUFFERS).intValue()];
        message.getByteBuffer().get(dst);

        StreamUtils.copy(dst, outputMessage.getBody());
    }

    @Override
    protected Long getContentLength(Table table, MediaType contentType) throws IOException {
        final ByteBuffer buf = table.getByteBuffer();
        return (long) (buf.limit() - buf.position());
    }

    private void setFlatBuffersResponseHeaders(final Table message, final HttpOutputMessage outputMessage) {
        // debug
    }
}
public class FlatBuffersMessage {

    private final String id;

    private final Class<?> klass;

    private final Method buildMethod;

    public FlatBuffersMessage(String id, Class<? extends Table> klass) throws Exception {
        this.id = id;
        this.klass = klass;
        final Method m = klass.getMethod("getRootAs" + klass.getSimpleName(), ByteBuffer.class);
        Preconditions.checkArgument(m != null, "This message is not FlatBuffers message");
        this.buildMethod = m;
    }

    public Table build(final ByteBuffer bytes)
            throws FlatBuffersMessageProtocolException {
        try {
            return (Table) this.buildMethod.invoke(klass, bytes);
        } catch (Exception e) {
            throw new FlatBuffersMessageProtocolException("Unavailable flatbuffers message.", e);
        }
    }
}
public class APIController {

    @RequestMapping(value = "/api", method = RequestMethod.POST)
    public Callable<Table> onMessage(
            @RequestBody Table message
    ) {

        // test
        if (message instanceof Me) {
            log.info("accessToken:{}", ((Me) message).token().accessToken());
            log.info("id         :{}", ((Me) message).token().id());
            log.info("created    :{}", ((Me) message).token().created());
        }

        return () -> {
            FlatBufferBuilder fbb = new FlatBufferBuilder(0);
            fbb.finish(UserAnswer.createUserAnswer(fbb,
                    fbb.createString("Hiroyuki Horigami"),
                    fbb.createString("12345"),
                    fbb.createString("//scontent-nrt1-1.xx.fbcdn.net/hphotos-xpa1/t31.0-8/891598_504258659637103_960802615_o.jpg")));
            UserAnswer resultMessage = UserAnswer.getRootAsUserAnswer(UserAnswer.getRootAsUserAnswer(fbb.dataBuffer()).getByteBuffer());
            return resultMessage;
        };
    }
}

簡単に説明すると、X-FB-MessageIdというリクエストのヘッダでなんのメッセージなのかを教えてもらって、それを元に受信したメッセージを読み取る。そして、MediaTypeはapplication/x-fbにしておいた。 もう少し改良するとすれば、X-FB-MessageIdにメッセージのIDを定義するのではなく、FlatBuffersの受信・応答メッセージの先頭部分にメッセージのIDを表す識別子(例えばshort typeの数字など)を定義したりJWTのように何か改ざんできないような仕組みを考えたかったけど、今回は簡単に実装しました。 ひとまず、ベンチマークとかとってないけど、簡単に使えそうだし良さそうな印象です。

さいごに

まだ詳しく調べてないので間違った部分もあると思います。指摘があればよろしくお願いいたします。