Avro

Avro 시작하기 - Java, Gradle

소농배 2022. 2. 17. 10:31

Download

Avro library Dependency

<dependency>
  <groupId>org.apache.avro</groupId>
  <artifactId>avro</artifactId>
  <version>1.11.0</version>
</dependency>

Avro Plugin

<plugin>
  <groupId>org.apache.avro</groupId>
  <artifactId>avro-maven-plugin</artifactId>
  <version>1.11.0</version>
  <executions>
    <execution>
      <phase>generate-sources</phase>
      <goals>
        <goal>schema</goal>
      </goals>
      <configuration>
        <sourceDirectory>${project.basedir}/src/main/avro/</sourceDirectory>
        <outputDirectory>${project.basedir}/src/main/java/</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <source>1.8</source>
    <target>1.8</target>
  </configuration>
</plugin>

 

Defining a schema

Avro 의 Schema 는 JSON 으로 정의되어있다. Schema 는 Primitive type 과 Complext Type 으로 구성되어있다.

{"namespace": "example.avro",
 "type": "record",
 "name": "User",
 "fields": [
     {"name": "name", "type": "string"},
     {"name": "favorite_number",  "type": ["int", "null"]},
     {"name": "favorite_color", "type": ["string", "null"]}
 ]
}

 이 Schema 는 가상의 User 를 나타내는 Record 를 정의한다. (Schema 파일 하나는 오로지 하나의 Schema 정의만 포함할 수 있다) Record 정의는 최소한 Type, Name, Field 를 포함해야한다. namespace 또한 정의되어있는데, 이것은 name 필드와 합쳐져서 Schema 의 "Full Name" 을 나타내는 요소이다.

 

 필드는 name 과 type 으로 정의되는 객체의 배열로 정의된다. field 의 type 요소는 Schema 객체와는 또 다른 것이고 이는 primitive 타입이거나 Complext 타입이다. 예를들어, User Schema 의 name 필드는 primitive 타입의 string 이고 반면에 favorite_number, favorite_color 필드들은 JSON 배열로 표현되는 union 이다. union 은 list 안에 어떠한 타입으로 맵핑될 수 있는 Complext 타입이다. 따라서 favorite_number 필드는 int 가 될 수도 있고 null 이 될수도 있기 때문에 optional 필드로 만들때 적합하다.

 

Serializing and deserializing with code generation

Compiling Schema

plugins {
    id 'java'
    id "com.commercehub.gradle.plugin.avro" version "0.9.1"
}

위와 같이 plugin 을 추가한 후에 Gradle refresh 를 해보면 Avro 관련 Task 들이 추가된것을 확인할 수 있다. 

위에서 정의한 Schema 를 User.avsc 파일로 저장한 후에 generateAvroJava Task 를 실행하면 아래와 같이 Class 가 생성되는 것을 확인할 수 있다.

Creating Users

 Code Generation 까지 마쳤다면, User 객체를 생성하고 File 로 디스크에 저장한 후에 역직렬화를 통해 User 객체를 불러와보자.

 

 첫번째로 User 객체를 생성하고 각 필드를 세팅한다.

User user1 = new User();
user1.setName("Alyssa");
user1.setFavoriteNumber(256);
// Leave favorite color null

// Alternate constructor
User user2 = new User("Ben", 7, "red");

// Construct via builder
User user3 = User.newBuilder()
             .setName("Charlie")
             .setFavoriteColor("blue")
             .setFavoriteNumber(null)
             .build();

 예제에서 볼 수 있듯이, Avro 객체는 Builder 를 사용하거나 생성자를 호출해서 생성할 수 있다. 생성자와는 달리 Builder 는 Schema 에 정의되어있는 Default 값을 세팅한다. 게다가 Builder 는 값이 세팅될때 유효성검사를 하는 반면에 생성자를 이용해서 객체를 생성할 경우에는 직렬화가 되기 전까지는 에러가 발생하지 않는다. 그러나 생성자를 사용하는것이 성능이 낫고 Builder 는 이것이 실제로 쓰여지기 전까지는 복사본을 만든다.

 

 user1 에는 favoriteColor 를 지정하지 않은 것을 기억하자. Record 의 타입이 ["string", "null"] 이기 때문에, string 을 지정할 수도 있고 null 로 남겨둘 수도 있는 것이다. 비슷하게 user3 의 favoriteNumber 를 null 로 세팅하였다. 

Serializing

 위에서 만든 User 객체를 직렬화하여 저장해보자

// Serialize user1, user2 and user3 to disk
DatumWriter<User> userDatumWriter = new SpecificDatumWriter<User>(User.class);
DataFileWriter<User> dataFileWriter = new DataFileWriter<User>(userDatumWriter);
dataFileWriter.create(user1.getSchema(), new File("users.avro"));
dataFileWriter.append(user1);
dataFileWriter.append(user2);
dataFileWriter.append(user3);
dataFileWriter.close();

 Java 객체를 in-memory 직렬화 포맷으로 변경해주는 DatumWriter 를 생성하였다. SpecificDatumWriter 클래스는 Generated 클래스와 주로 사용되고 특별히 생성된 타입으로부터 Schema 를 추출한다.

 

 다음으로 직렬화된 Record 를  write 하는 역할을 하는 DataFileWriter 를 생성하고 dataFileWriter.create() 를 호출하여 어느 File 에 데이터를 쓸지 특정하였다. dataFileWriter.append() 함수를 호출하여 User 객체를 File 에 썼다. 쓰기 작업이 모두 완료된 후에 Data File 을 닫았다.

Deserializing

위에서 생성한 직렬화 파일을 역직렬화 해보자

// Deserialize Users from disk
DatumReader<User> userDatumReader = new SpecificDatumReader<User>(User.class);
DataFileReader<User> dataFileReader = new DataFileReader<User>(file, userDatumReader);
User user = null;
while (dataFileReader.hasNext()) {
// Reuse user object by passing it to next(). This saves us from
// allocating and garbage collecting many objects for files with
// many items.
user = dataFileReader.next(user);
System.out.println(user);
}

위 예제 코드의 결과는 아래와 같다.

{"name": "Alyssa", "favorite_number": 256, "favorite_color": null}
{"name": "Ben", "favorite_number": 7, "favorite_color": "red"}
{"name": "Charlie", "favorite_number": null, "favorite_color": "blue"}

역직렬화는 직렬화와 비슷하다. 직렬화에 사용한 SpecificDatumWriter 와 비슷한 SpecificDatumReader 를 생성하였다. SpecificDatumReader 는 in-memory 직렬화 데이터를 Generated Class 의 객체로 변환해준다. 

 

 DatafileReader 에게 앞서 만들었던 File 객체와 DatumReader 를 전달하였다. DataFilerReader 는 데이터가 쓰여질 당시의 Schema 와 data 모두 불러오게 된다. Data 는 Write 할 당시의 Schema 와 Read 할 당시의 Schema 둘 다 이용하여 불러와지게 된다. 이 예제에서는 User.class 가 Read Schema 로 사용되었다. Write Schema 는 어떤 Field 들이 쓰여졌는지 알아야 하고, 반면에 Read Schema 는 어떤 필드들이 예상되는지, Read Schema 에서 추가된 Field 들의 Default 값은 무엇으로 채워야 하는지를 알야아 한다. 두 Schema 의 차이가 있다면, Schema Resolution 을 통해 해결한다.

 

 다음으로 DataFileReader 를 사용하여 직렬화된 User 객체를 순회하고 역직렬화된 객체를 출력하였다. 기억해야할 것은 Iteration 을 어떻게 수행하였는지이다. 현재 역직렬화된 객체를 저장하기 위해 User 객체를 하나 생성하였고 dataFileReader.next 를 호출할때마다 이 객체를 전달하였다. 이 방법은 DataFileReader 가 동일한 User 객체를 Iteration 할때마다 새로 생성해서 GC 를 일으키지 않도록 하기 위해 동일한 객체를 재활용하도록 최적화한 것이다.