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
버전 관리가 쉽게 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/...
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
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 명령으로 메시지 타입이 검색될 수 있다.
이 타입들은 굳이 분석하지 않아도된다.
필드와 속성 #
- @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}
컴파일 결과로 나온 파일들 #
컴파일된 파일 사용법 #
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)