ブログ・ア・ラ・クレーム

技術的なメモとかライフログとか。

Apache Avro について知っていることを書いていく その2

Apache Avro について書き下していく記事その 2 です。 本記事では Avro で表現されるデータのプログラミング言語上の表現、特に Java を想定して SpecificDataGenericData について触れていきます。

Avro のデータ型とコード生成

前回の記事で触れた通り、 Avro では様々なリッチなデータ型を表現できます。 多くの場合一番外側のスコープでは record type を使って構造体を定義することになるかなと思います。 Avro スキーマは以下のように事前に JSON 文字列を埋め込むか .avsc ファイルで保存しておくのもアリですが、実行時に動的生成することもできます。

{
  "name": "Hello",
  "type": "record",
  "fields": [
    {
      "name": "id",
      "type": "long"
    },
    {
      "name": "name",
      "type": "string"
    }
  ]
}

ここで定義したデータ型を実際にどう扱いましょうか? 例えば Protocol Buffers ではちょうど Avro で .avsc ファイルに記述するように、以下のようにおおむね .proto ファイルにメッセージ型を定義していくことになると思います。

syntax = "proto3";

package com.syucream.example;

message Hello {
  uint64 id = 1;
  string name = 2;
}

その後典型的には protoc と各言語に対応する protoc plugin を用いることで、定義したメッセージ型に従ったコードを生成することになります。

$ protoc --java_out=./ hello.proto
// Generated by the protocol buffer compiler.  DO NOT EDIT!
// source: hello.proto

package com.syucream.example;

public final class HelloOuterClass {
  private HelloOuterClass() {}
  public static void registerAllExtensions(
      com.google.protobuf.ExtensionRegistryLite registry) {
  }

  public static void registerAllExtensions(
      com.google.protobuf.ExtensionRegistry registry) {
    registerAllExtensions(
        (com.google.protobuf.ExtensionRegistryLite) registry);
  }
  public interface HelloOrBuilder extends
      // @@protoc_insertion_point(interface_extends:com.syucream.example.Hello)
      com.google.protobuf.MessageOrBuilder {

    /**
     * <code>uint64 id = 1;</code>
     */
    long getId();

    /**
     * <code>string name = 2;</code>
     */
    java.lang.String getName();
   ...

つまりアプリケーションに組み込んで用いる際に事前にコード生成を求められます。

Avro ではコード生成はオプションです。 事前に生成したコードを用いることも、そうでない選択肢を取ることもできます。

基本的なデータ型表現セット GenericData

org.apache.avro.generic.GenericData は Avro のデータ型の Java での表現の基礎となるクラスなどが詰まったユーティリティです。 GenericData 以下に具体的な型に対応したクラスが存在します。 例えば record なら org.apache.avro.generic.GenericData.Record といった具合です。 以下では特にこの GenericData.Record を掘り下げましょう。

まずこのクラスは org.apache.avro.generic.GenericRecord interface を実装しているので、まぁ名前の通りなのですがつまり値を get したり put したりできます。 一見あまり情報量が無さそうなこの interface は、結構色々な Javaミドルウェアで頻出します。 またこの get() の戻り値は Object 型であり、実際どんな型なのか意識するのは get() を呼び出すコードを書く人の責務になります。

GenericData.Record オブジェクトを作るには、 Schema オブジェクトを渡して new します。 Schema オブジェクトは Schema.Parser を使って事前定義した JSON 文字列をパースして得るのもよし、動的に Schema.createRecord() などから生成してもよし、です。

この GenericData.Record ですが、フィールドの具体的な値は Object[] 型のメンバとして持ち、 get/put などをする時は Schema オブジェクトの情報からフィールドのインデックスを得てアクセスします。 これはちょうど、 Avro の仕様としてシリアライズした後のバイナリのレイアウトがどうなっているかがスキーマによって決定づけられる(他にレイアウト情報を持たずスキーマを信じる)のに対応しそうです。

GenericData を読み書きする GenericDatumReader/GenericDatumWriter

org.apache.avro.generic.GenericDatumReader を用いることでシリアライズされた状態のバイナリから GenericData 以下の Java のオブジェクトを取り出すことができます。 ジェネリクスのパラメータは、例えば record 型の値を参照するだけでいいなら前述の GenericRecord を指定するので良いでしょう。 逆に Java のオブジェクトをシリアライズしたい際は org.apache.avro.generic.GenericDatumWriter を使うことができます。 より具体的な使い方は公式のテストコードを見ると参考になります。

github.com

Avro のシリアライズ方法とバイナリレイアウトを決めているのはスキーマです。 というわけでもちろん、GenericDatumReader / GenericDatumWriter を用いる際にはスキーマ情報が求められます。

さて実は GenericDatumReader には二種類のスキーマ、引数名でいうと readerwriter を与えることができます。 名前の通り writerGenericDatumWriter を使ってシリアライズする側で用いられた側のスキーマreaderGenericDatumReader で読み出す側のスキーマです。 具体的で実用しそうなシナリオとしては、 writer が古いスキーマシリアライズする可能性のある環境下で reader としては新しいスキーマ(古いスキーマからの後方互換性がある)に従う型のオブジェクトとして扱いたい際に二種類のスキーマを与える場合がありそうです。

より安全で高速な表現セット SpecificData

さてこの GenericData 関連のツールですが、値の取り回しは Object 型ですることになるので逐一キャストしなければならず、安全な実装をできるかやキャストするコストが発生する点が気になります。 悪い言い方をすれば、シリアライズ・デシリアライズの操作を含めてスキーマに違反していないことをは保証された値でしかなく、 Java のコード上は例えば record 型なら単なる Map<String, Object> 型オブジェクトと言えそうです。

幸いなことに Avro では GenericData を使うのとは別に SpecificData サブクラスを使う道も存在します。 org.apache.avro.specific.SpecificDataGenericData のサブクラスです。 と言ってもこのクラス自体にはさほど機能は追加されていません。 重要なのはこれを継承した、自動生成された schema specific なクラスです。

SpecificData なクラスのコード生成

最初に述べた通り Avro には事前に .java ファイルのコードを生成しておく道もあります。 コード生成は avro-tools を通して行えます。試しにこの記事の冒頭に出てきた .avsc からコード生成してみます。

$ java -jar ~/tools/avro-tools-1.8.2.jar compile schema hello.avsc ./
Input files to compile:
  hello.avsc
log4j:WARN No appenders could be found for logger (AvroVelocityLogChute).
log4j:WARN Please initialize the log4j system properly.
log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info.

得られる Java のコードは以下のようになります(一部抜粋)

/**
 * Autogenerated by Avro
 *
 * DO NOT EDIT DIRECTLY
 */

import org.apache.avro.specific.SpecificData;
import org.apache.avro.message.BinaryMessageEncoder;
import org.apache.avro.message.BinaryMessageDecoder;
import org.apache.avro.message.SchemaStore;

@SuppressWarnings("all")
@org.apache.avro.specific.AvroGenerated
public class Hello extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord {
  private static final long serialVersionUID = -8266440640401408575L;
  public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse("{\"type\":\"record\",\"name\":\"Hello\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},{\"name\":\"name\",\"type\":\"string\"}]}");
  public static org.apache.avro.Schema getClassSchema() { return SCHEMA$; }

  ...

  @Deprecated public long id;
  @Deprecated public java.lang.CharSequence name;

  /**
   * Default constructor.  Note that this does not initialize fields
   * to their default values from the schema.  If that is desired then
   * one should use <code>newBuilder()</code>.
   */
  public Hello() {}

  ...

  /**
   * Gets the value of the 'id' field.
   * @return The value of the 'id' field.
   */
  public java.lang.Long getId() {
    return id;
  }

  /**
   * Sets the value of the 'id' field.
   * @param value the value to set.
   */
  public void setId(java.lang.Long value) {
    this.id = value;
  }

  /**
   * Gets the value of the 'name' field.
   * @return The value of the 'name' field.
   */
  public java.lang.CharSequence getName() {
    return name;
  }

  /**
   * Sets the value of the 'name' field.
   * @param value the value to set.
   */
  public void setName(java.lang.CharSequence value) {
    this.name = value;
  }

  ...

嬉しいことにフィールドの型に対応した getter/setter が手に入ります! GenericRecord interface のメソッドを使うよりこちらの方が安全で若干高速になるはずです。

見てきた通り SpecificData 系サブクラスはコード生成する都合コンパイル時にスキーマが決まっている必要があります。 またスキーマ定義が頻繁に変更され、その度にコード生成し直しまくるのもやや煩雑かと思われます。 あまり変わらない・変えたくないデータ型を定義する時は SpecificData からなるクラスを生成、動的に・頻繁に変わる可能性があるデータ型は GenericData として扱うのが良いかもしれません。

Java 意外の言語ではどうなる?

Java の例を見てきた通り Specific なテータ表現は Generic なもののサブセットとなっています。 そんな訳で多くのプログラミング言語では Generic なデータ表現はサポートされています。

が、 Specific によってはサポート状況がまちまちなようです。 筆者が認識した限りでは、 C++ で experimental でサポート? されていたり、 Go では gogen-avro を作っている人がいます。

飽きてきたので

本記事はこのあたりで終わります。 あとは筆者がよくハマる、複雑なスキーマを定義してみる話やシリアライズされた後のバイナリのレイアウトの話とか別記事で書くかもしれないです。