Protobuf - com.squareup.wire

Protobuf - com.squareup.wire

2024년 12월 9일

Protobuf #

구글에서 개발한 직렬화/역직렬화 포맷으로 JSON, XML 대비 더 빠르고 효율적으로 네트워크 전송이나 디스크에 저장하는 바이트 스트림을 말한다.

구조 #

[Tag][Value] 구조가 이어진 형태로 데이터가 저장된다.

Tag #

(Field Number << 3) | Field Type 값으로 구성된 1~4?byte 크기. 5byte일수도 있다.

  • Field Number : 1 ~ 2^29-1 값을 갖는 필드의 고유 ID 값. Varint 로 저장된다.
  • Filed Type : 3bit
    • Varint(0) : 정수형 데이터 (부호없는 정수, ZigZag 부호화된 정수)
    • Fixed64(1) : 64bit 고정크기 데이터
    • Length-Delimited(2) : 길이를 포함한 데이터 (문자열, 바이트 배열, 메시지)
    • Start Group(3) / End Group(4) : deprecated
    • Fixed32(5) : 32bit 고정크기 데이터

Value #

  • Varint : 1~10바이트 범위에서 데이터를 쓰는데, 최상위 비트에 따라 다음 바이트가 Varint의 일부인지 알 수 있고, 나머지 7bit는 데이터를 저장한다
  • Fixed64 : 고정크기 64bit 데이터를 LE로 저장한다.
  • Length-Delimeted : 데이터 길이를 Varint 형식으로 넣고 데이터 내용을 직렬화한다.
  • Fixed32 : 고정크기 4byte LE
  • 중첩메시지 : 이것도 Length-Delimeted 형식이다.

사용법 #

1. 플러그인 세팅 #

https://github.com/google/protobuf-gradle-plugin

2bdd4b3b-f5fe-49d5-ba77-b35446bccbbb

버전 관리가 쉽게 libs.versions.toml 를 사용

 1[versions]
 2protobuf = "0.9.4"
 3protobuf-java = "3.24.0"
 4protoc = "3.24.0"
 5
 6[libraries] // 프로젝트에서 사용할 라이브러리 등록
 7protobuf-java = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf-java" }
 8protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" }
 9
10[plugins]   // Gradle 플러그인 등록 
11protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }

프로젝트의 build.gradle.kts (프로젝트 루트 빌드스크립트)

1plugins {
2    ...
3    // libs.versions.toml에 등록한 protobuf 플러그인을 빌드 스크립트에서 사용할 수 있도록 선언
4    // apply false 는 프로젝트에 로드만 하고 즉시 적용하지는 않겠다는 의미
5    // 사실 alias때문에 필요 없는 선언이지만, 원래는 프로젝트 루트에서 관리하기 버전을 관리하기 위함이다. 
6    // 의존성때문에 가장 아래에 세팅하는게 좋음. 
7    alias(libs.plugins.protobuf) apply false    
8}

앱 모듈의 build.gradle.kts

 1plugins {
 2    ...
 3    // 이 하위모듈에서는 이 플러그인을 사용하겠다는 의미
 4    alias(libs.plugins.protobuf)
 5}
 6...
 7
 8// 맨 아래에 protobuf 블록 추가 
 9// protobuf 플러그인으로 .proto 파일을 처리할때 사용되는 빌드 설정을 정의
10protobuf {
11    // libs.protoc을 이용해서 처리하겠다는 의미 (com.google.protobuf:protoc:3.24.0)
12    protoc {
13        // libs.versions.toml에서 [libraries]에 protoc 이름으로 세팅해서 libs.protoc 로 접근할 수 있다. 
14        artifact = libs.protoc.get().toString()    
15    }
16    // 모든 .proto 파일을 처리해서 Java, Kotlin 코드를 생성
17    generateProtoTasks {
18        all().forEach { task ->
19            task.builtins {
20                create("java") {
21                    option("lite")
22                }
23                // create("kotlin") {
24                //     option("lite")
25                // }
26            }
27        }
28    }
29}

2. .proto 파일 작성 #

1syntax = "proto3";
2
3package example;
4
5message User {
6  int32 id = 1;
7  string name = 2;
8  string email = 3;
9}

3. 한번 빌드를 실행해서 protoc가 만들어준 java 코드를 확인한다. #

app/build/generated/source/proto/...

d7446a32-32cf-40bf-99b0-918e7821411e


4. 원하는 코드에서 import 해서 사용 #

현재는 protobuf java 클래스만 사용했지만, 조금 더 세팅해서 코틀린 클래스를 사용하는 것도 가능하다.

 1import example.Example
 2
 3class MainActivity : AppCompatActivity() {
 4    override fun onCreate(savedInstanceState: Bundle?) {
 5        ...
 6        val person = Example.Person.newBuilder()
 7            .setId(1)
 8            .setName("John Doe")
 9            .setEmail("john.doe@example.com")
10            .build()
11
12        // 직렬화
13        val serializedData = person.toByteArray()
14        // 역직렬화
15        val deserializedPerson = Example.Person.parseFrom(serializedData)
16
17        // 출력
18        println("ID: ${deserializedPerson.id}")
19        println("Name: ${deserializedPerson.name}")
20        println("Email: ${deserializedPerson.email}")
21   }

com.squareup.wire #

Squre에서 Protobuf를 구현한 라이브러리 패키지이며, .proto로 protobuf의 구조를 설계해두고 추상 클래스인 Message를 상속받아서 개발자가 원하는 Protobuf 메시지를 구현한다.

구조 #

Message 클래스 #

각 타입에 맞는 ProtoAdapter를 등록하고, encode 할 때 adapter의 encode를 호출해준다.

 1package com.squareup.wire;
 2
 3public abstract class Message<M extends Message<M, B>, B extends Builder<M, B>> implements Serializable {
 4    private static final long serialVersionUID = 0;
 5    private final transient ProtoAdapter<M> adapter;
 6
 7    public Message(ProtoAdapter<M> adapter, ByteString unknownFields) {
 8        Intrinsics.checkNotNullParameter(adapter, "adapter");
 9        Intrinsics.checkNotNullParameter(unknownFields, "unknownFields");
10        this.adapter = adapter;
11        this.unknownFields = unknownFields;
12    }
13
14    public final byte[] encode() {
15        return this.adapter.encode(this);
16    }
17    ...
18}

ProtoAdapter 클래스 #

제네릭 형식 추상클래스이며, 각 ProtoAdapter 타입에 맞는 encode, decode 함수가 구현돼야 한다.

ReverseProtoWriter는 임시적으로 역순으로 저장해두고 마지막에 정방향으로 변경하는 방식으로 결과는 정방향으로 출력되기 때문에 ProtoReader는 그냥 정방향으로만 읽으면 된다.

ReverseProtoWriter는 내부메시지를 먼저 직렬화 하는 등의 경우에 유연하게 대처할 수 있기 때문에 사용된다.

1public abstract class ProtoAdapter<E> {
2    public abstract E decode(ProtoReader reader) throws IOException;
3    public abstract void encode(ProtoWriter writer, E value) throws IOException;
4    public abstract void encode(ReverseProtoWriter writer, E value) throws IOException;
5    ...
6}

실제 구현된 커스텀 Protobuf #

Message를 상속받아서 MessageExtras를 만들고 있고, ProtoAdapter도 익명 클래스로 정의해서 ADAPTER에 초기화한다.

 1public final class MessageExtras extends Message<MessageExtras, Builder> {
 2    public static final ProtoAdapter<MessageExtras> ADAPTER;
 3
 4    @WireField(adapter = "org.whispersystems.signalservice.internal.push.GroupContext#ADAPTER", oneofName = "extra", tag = 2)
 5    public final GroupContext gv1Context;
 6
 7    @WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription#ADAPTER", oneofName = "extra", tag = 1)
 8    public final GV2UpdateDescription gv2UpdateDescription;
 9
10    static {
11        final FieldEncoding fieldEncoding = FieldEncoding.LENGTH_DELIMITED;
12        final KClass orCreateKotlinClass = Reflection.getOrCreateKotlinClass(MessageExtras.class);
13        final Syntax syntax = Syntax.PROTO_3;
14
15        // ProtoAdapter<MessageExtras>를 익명 클래스로 구현하고 encode, decode 오버라이딩
16        ADAPTER = new ProtoAdapter<MessageExtras>(fieldEncoding, orCreateKotlinClass, syntax) {
17            @Override // com.squareup.wire.ProtoAdapter
18            public void encode(ProtoWriter writer, MessageExtras value) {
19                Intrinsics.checkNotNullParameter(writer, "writer");
20                Intrinsics.checkNotNullParameter(value, "value");
21                GV2UpdateDescription.ADAPTER.encodeWithTag(writer, 1, (int) value.gv2UpdateDescription);
22                GroupContext.ADAPTER.encodeWithTag(writer, 2, (int) value.gv1Context);
23                ProfileChangeDetails.ADAPTER.encodeWithTag(writer, 3, (int) value.profileChangeDetails);
24                PaymentTombstone.ADAPTER.encodeWithTag(writer, 4, (int) value.paymentTombstone);
25                writer.writeBytes(value.unknownFields());
26            }
27
28            @Override // com.squareup.wire.ProtoAdapter
29            public MessageExtras decode(ProtoReader reader) {
30                Intrinsics.checkNotNullParameter(reader, "reader");
31                long beginMessage = reader.beginMessage();
32                GV2UpdateDescription gV2UpdateDescription = null;
33                GroupContext groupContext = null;
34                ProfileChangeDetails profileChangeDetails = null;
35                PaymentTombstone paymentTombstone = null;
36                while (true) {
37                    int nextTag = reader.nextTag();
38                    if (nextTag == -1) {
39                        return new MessageExtras(gV2UpdateDescription, groupContext, profileChangeDetails, paymentTombstone, reader.endMessageAndGetUnknownFields(beginMessage));
40                    }
41                    if (nextTag == 1) {
42                        gV2UpdateDescription = GV2UpdateDescription.ADAPTER.decode(reader);
43                    } else if (nextTag == 2) {
44                        groupContext = GroupContext.ADAPTER.decode(reader);
45                    } else if (nextTag == 3) {
46                        profileChangeDetails = ProfileChangeDetails.ADAPTER.decode(reader);
47                    } else if (nextTag == 4) {
48                        paymentTombstone = PaymentTombstone.ADAPTER.decode(reader);
49                    } else {
50                        reader.readUnknownField(nextTag);
51                    }
52                }
53            }
54       ...
55    }   // static
56} // MessageExtras

분석 #

앱에 포함된 .proto 검색 #

앱에서 삭제되지 않은 proto 파일이 남아있는 경우가 있다.
앱의 압축을 풀고 findstr /S /I /C:"GroupContext" .\*.proto 명령으로 메시지 타입이 검색될 수 있다.
이 타입들은 굳이 분석하지 않아도된다.

4188b8c3-f5f6-40ab-b000-29ec5bbba9f4


필드와 속성 #

  • @WireField : Square 의 Wire 라이브러리에서 사용하는 어노테이션으로, protobuf 메시지의 필드를 의미한다.
  • adapter : protobuf 메시지를 Java 객체와 직렬화/역직렬화 하는 방식을 지정하는 ProtoAdapter이다.
  • oneofName : 하나의 메시지 안에서 같은 name으로 지정된 필드 중 하나만 활성화되는 그룹을 의미한다.
  • tag : 필드이름을 식별하는 고유번호
 1public final class MessageExtras extends Message<MessageExtras, Builder> {
 2    public static final ProtoAdapter<MessageExtras> ADAPTER;
 3    private static final long serialVersionUID = 0;
 4
 5    @WireField(adapter = "org.whispersystems.signalservice.internal.push.GroupContext#ADAPTER", oneofName = "extra", tag = 2)
 6    public final GroupContext gv1Context;
 7
 8    @WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription#ADAPTER", oneofName = "extra", tag = 1)
 9    public final GV2UpdateDescription gv2UpdateDescription;
10
11    @WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone#ADAPTER", oneofName = "extra", tag = 4)
12    public final PaymentTombstone paymentTombstone;
13
14    @WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails#ADAPTER", oneofName = "extra", tag = 3)
15    public final ProfileChangeDetails profileChangeDetails;

이 필드를 .proto 파일로 변경하면 아래와 같이 나오며, 각각의 필드는 기본 필드가 아니기 때문에 각자의 ADAPTER로 디코드 되어야 한다.

1message MessageExtras {
2  oneof extra {
3    GV2UpdateDescription gv2UpdateDescription = 1;
4    GroupContext gv1Context = 2;
5    ProfileChangeDetails profileChangeDetails = 3;
6    PaymentTombstone paymentTombstone = 4;
7  }
8}

Enum 값과 기본자료형 #

  • WireEnum : 이 클래스를 상속받아서 구현하게 되면, Enum 타입을 구현할 수 있다. Flags 타입은 아마 flags에 세팅하기 위한 Enum 타입이겠지만 거기에 대한 정보는 없다.
  • 기본 자료형 : 기본 자료형들은 adapter = "com.squareup.wire.ProtoAdapter#STRING" 형식으로 이미 구현된 어댑터를 사용한다.
  • optional 필드 : nullable 타입같은 경우 .proto에서는 optional로 지정하며, 값이 null이라면 직렬화할 때 필드 자체가 데이터에서 제외된다.
 1public final class AttachmentPointer extends Message<AttachmentPointer, Builder> {
 2    public static final ProtoAdapter<AttachmentPointer> ADAPTER;
 3    private static final long serialVersionUID = 0;
 4
 5    @WireField(adapter = "com.squareup.wire.ProtoAdapter#STRING", tag = 2)
 6    public final String contentType;
 7
 8    @WireField(adapter = "com.squareup.wire.ProtoAdapter#BYTES", tag = 3)
 9    public final ByteString key;
10
11    @WireField(adapter = "com.squareup.wire.ProtoAdapter#UINT32", tag = 4)
12    public final Integer size;
13
14    @WireField(adapter = "com.squareup.wire.ProtoAdapter#FIXED64", oneofName = "attachment_identifier", tag = 1)
15    public final Long cdnId;
16
17    @WireField(adapter = "com.squareup.wire.ProtoAdapter#STRING", oneofName = "attachment_identifier", tag = 15)
18    public final String cdnKey;
19
20    @WireField(adapter = "com.squareup.wire.ProtoAdapter#UINT32", tag = 8)
21    public final Integer flags;
22
23    // 생성자에서 nullable을 확인할 수 있어서 optional 필드를 확인할 수 있다. 
24    public /* synthetic */ AttachmentPointer(Long l, String str, String str2, ByteString byteString, ...) {
25        this((i & 1) != 0 ? null : l, (i & 2) != 0 ? null : str, (i & 4) != 0 ? null : str2, (i & 8) != 0 ? null : byteString, (i & 16) != 0 ? null : num, (i & 32) != 0 ? null : byteString2, ...);
26    }
27
28    ...
29
30    public static final class Flags implements WireEnum {
31        private static final /* synthetic */ EnumEntries $ENTRIES;
32        private static final /* synthetic */ Flags[] $VALUES;
33        public static final ProtoAdapter<Flags> ADAPTER;
34
35        /* renamed from: Companion, reason: from kotlin metadata */
36        public static final Companion INSTANCE;
37        private final int value;
38        public static final Flags VOICE_MESSAGE = new Flags("VOICE_MESSAGE", 0, 1);
39        public static final Flags BORDERLESS = new Flags("BORDERLESS", 1, 2);
40        public static final Flags GIF = new Flags("GIF", 2, 4);
41
42        private static final /* synthetic */ Flags[] $values() {
43            return new Flags[]{VOICE_MESSAGE, BORDERLESS, GIF};
44        }
45        ...
46        public static final class Companion {
47            @JvmStatic
48            public final Flags fromValue(int value) {
49                if (value == 1) return Flags.VOICE_MESSAGE;
50                if (value == 2) return Flags.BORDERLESS;
51                if (value != 4) return null;
52                return Flags.GIF;
53            }
54        }
55    }
56}

.proto로 변경

 1syntax = "proto2";
 2
 3package org.whispersystems.signalservice.internal.push;
 4
 5message AttachmentPointer {
 6  oneof attachment_identifier {
 7    fixed64 cdnId = 1;
 8    string cdnKey = 15;
 9  }
10  optional string contentType = 2;
11  optional bytes key = 3;
12  optional uint32 size = 4;
13  optional uint32 flags = 8;
14  ...
15}
16
17enum Flags {
18  VOICE_MESSAGE = 1;
19  BORDERLESS = 2;
20  GIF = 4;
21}

.proto 컴파일 #

컴파일 방법 #

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

코드를 분석해서 .proto 파일 작성이 완료되면 위의 AndroidStudio의 gradle에서 했던 것 처럼 원하는 언어로 컴파일 해야한다.

protoc --csharp_out=. --python_out=. .\MessageExtras.proto 명령으로 컴파일이 가능하다.

하나의 proto 파일에서 다른 파일을 import 할 수 있는데 자동으로 import한 파일까지 컴파일되지는 않는다.
protoc --python_out=. .\MessageExtras.proto .\GV2UpdateDescription.proto ...

 1// MessageExtras.proto
 2syntax = "proto3";
 3
 4package org.thoughtcrime.securesms.database.model.databaseprotos;
 5
 6import "GV2UpdateDescription.proto";
 7import "GroupContext.proto";
 8import "ProfileChangeDetails.proto";
 9import "PaymentTombstone.proto";
10
11message MessageExtras {
12  oneof extra {
13    GV2UpdateDescription gv2UpdateDescription = 1;
14    GroupContext gv1Context = 2;
15    ProfileChangeDetails profileChangeDetails = 3;
16    PaymentTombstone paymentTombstone = 4;
17  }
18}
19
20// GV2UpdateDescription.proto
21syntax = "proto3";
22
23package org.thoughtcrime.securesms.database.model.databaseprotos;
24
25message GV2UpdateDescription {
26  string test_field = 1;
27}

다른 패키지의 .proto 파일 사용 #

.proto 파일이 지워져 있지 않고 앱에 포함되어 있어서 그걸 그대로 사용하려 하는데 타입을 찾아오지 못한다고 에러가 발생했다.
패키지가 동일하면 위의 방법 그대로 사용할 수 있지만, 패키지가 다르면 메시지 타입을 찾아오지 못한다.

 1// MessageExtras.proto
 2syntax = "proto3";
 3
 4package org.thoughtcrime.securesms.database.model.databaseprotos;
 5
 6import "GV2UpdateDescription.proto";
 7import "SignalService.proto";
 8// 모든 proto 파일이 proto3 이라면 import public 키워드로 임포트해서 패키지명을 명시하지 않아도된다. 
 9// import public "SignalService.proto";
10import "ProfileChangeDetails.proto";
11import "PaymentTombstone.proto";
12
13message MessageExtras {
14  oneof extra {
15    GV2UpdateDescription gv2UpdateDescription = 1;
16    // 여기에서 [package].[message_type] 형태로 접근해야한다.
17    signalservice.GroupContext gv1Context = 2;
18    ProfileChangeDetails profileChangeDetails = 3;
19    PaymentTombstone paymentTombstone = 4;
20  }
21}
22
23// SignalService.proto
24syntax = "proto2";
25
26package signalservice;
27
28message GroupContext {
29  optional bytes id = 1;
30  ...
31}

컴파일 결과로 나온 파일들 #

da73db35-bad0-46f4-aae3-45216587c6f6


컴파일된 파일 사용법 #

protobuf 모듈 설치 pip install protobuf

 1import MessageExtras_pb2
 2
 3# MessageExtras 메시지 생성
 4message = MessageExtras_pb2.MessageExtras()
 5
 6message.gv2UpdateDescription.test_field = "1234"
 7
 8# 직렬화
 9serialized_data = message.SerializeToString()
10print("serialized_data:", serialized_data)
11
12# 저장
13with open("message.bin", "wb") as f:
14    f.write(serialized_data)
15
16# 읽기
17with open("message.bin", "rb") as f:
18    serialized_data2 = f.read()
19
20message2 = MessageExtras_pb2.MessageExtras()
21message2.ParseFromString(serialized_data2)
22
23print(message2)

b28e99ce-410f-4442-870b-9bfaf6d01b58

comments powered by Disqus