Protobuf - com.squareup.wire
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 를 사용
[versions]
protobuf = "0.9.4"
protobuf-java = "3.24.0"
protoc = "3.24.0"
[libraries] // 프로젝트에서 사용할 라이브러리 등록
protobuf-java = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf-java" }
protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" }
[plugins] // Gradle 플러그인 등록
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
프로젝트의 build.gradle.kts (프로젝트 루트 빌드스크립트)
plugins {
...
// libs.versions.toml에 등록한 protobuf 플러그인을 빌드 스크립트에서 사용할 수 있도록 선언
// apply false 는 프로젝트에 로드만 하고 즉시 적용하지는 않겠다는 의미
// 사실 alias때문에 필요 없는 선언이지만, 원래는 프로젝트 루트에서 관리하기 버전을 관리하기 위함이다.
// 의존성때문에 가장 아래에 세팅하는게 좋음.
alias(libs.plugins.protobuf) apply false
}
앱 모듈의 build.gradle.kts
plugins {
...
// 이 하위모듈에서는 이 플러그인을 사용하겠다는 의미
alias(libs.plugins.protobuf)
}
...
// 맨 아래에 protobuf 블록 추가
// protobuf 플러그인으로 .proto 파일을 처리할때 사용되는 빌드 설정을 정의
protobuf {
// libs.protoc을 이용해서 처리하겠다는 의미 (com.google.protobuf:protoc:3.24.0)
protoc {
// libs.versions.toml에서 [libraries]에 protoc 이름으로 세팅해서 libs.protoc 로 접근할 수 있다.
artifact = libs.protoc.get().toString()
}
// 모든 .proto 파일을 처리해서 Java, Kotlin 코드를 생성
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
// create("kotlin") {
// option("lite")
// }
}
}
}
}
2. .proto 파일 작성
syntax = "proto3";
package example;
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
3. 한번 빌드를 실행해서 protoc가 만들어준 java 코드를 확인한다.
app/build/generated/source/proto/...
4. 원하는 코드에서 import 해서 사용
현재는 protobuf java 클래스만 사용했지만, 조금 더 세팅해서 코틀린 클래스를 사용하는 것도 가능하다.
import example.Example
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val person = Example.Person.newBuilder()
.setId(1)
.setName("John Doe")
.setEmail("john.doe@example.com")
.build()
// 직렬화
val serializedData = person.toByteArray()
// 역직렬화
val deserializedPerson = Example.Person.parseFrom(serializedData)
// 출력
println("ID: ${deserializedPerson.id}")
println("Name: ${deserializedPerson.name}")
println("Email: ${deserializedPerson.email}")
}
com.squareup.wire
Squre에서 Protobuf를 구현한 라이브러리 패키지이며, .proto로 protobuf의 구조를 설계해두고 추상 클래스인 Message를 상속받아서 개발자가 원하는 Protobuf 메시지를 구현한다.
구조
Message 클래스
각 타입에 맞는 ProtoAdapter를 등록하고, encode 할 때 adapter의 encode를 호출해준다.
package com.squareup.wire;
public abstract class Message<M extends Message<M, B>, B extends Builder<M, B>> implements Serializable {
private static final long serialVersionUID = 0;
private final transient ProtoAdapter<M> adapter;
public Message(ProtoAdapter<M> adapter, ByteString unknownFields) {
Intrinsics.checkNotNullParameter(adapter, "adapter");
Intrinsics.checkNotNullParameter(unknownFields, "unknownFields");
this.adapter = adapter;
this.unknownFields = unknownFields;
}
public final byte[] encode() {
return this.adapter.encode(this);
}
...
}
ProtoAdapter 클래스
제네릭 형식 추상클래스이며, 각 ProtoAdapter
ReverseProtoWriter는 임시적으로 역순으로 저장해두고 마지막에 정방향으로 변경하는 방식으로 결과는 정방향으로 출력되기 때문에 ProtoReader는 그냥 정방향으로만 읽으면 된다.
ReverseProtoWriter는 내부메시지를 먼저 직렬화 하는 등의 경우에 유연하게 대처할 수 있기 때문에 사용된다.
public abstract class ProtoAdapter<E> {
public abstract E decode(ProtoReader reader) throws IOException;
public abstract void encode(ProtoWriter writer, E value) throws IOException;
public abstract void encode(ReverseProtoWriter writer, E value) throws IOException;
...
}
실제 구현된 커스텀 Protobuf
Message를 상속받아서 MessageExtras를 만들고 있고, ProtoAdapter도 익명 클래스로 정의해서 ADAPTER에 초기화한다.
public final class MessageExtras extends Message<MessageExtras, Builder> {
public static final ProtoAdapter<MessageExtras> ADAPTER;
@WireField(adapter = "org.whispersystems.signalservice.internal.push.GroupContext#ADAPTER", oneofName = "extra", tag = 2)
public final GroupContext gv1Context;
@WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription#ADAPTER", oneofName = "extra", tag = 1)
public final GV2UpdateDescription gv2UpdateDescription;
static {
final FieldEncoding fieldEncoding = FieldEncoding.LENGTH_DELIMITED;
final KClass orCreateKotlinClass = Reflection.getOrCreateKotlinClass(MessageExtras.class);
final Syntax syntax = Syntax.PROTO_3;
// ProtoAdapter<MessageExtras>를 익명 클래스로 구현하고 encode, decode 오버라이딩
ADAPTER = new ProtoAdapter<MessageExtras>(fieldEncoding, orCreateKotlinClass, syntax) {
@Override // com.squareup.wire.ProtoAdapter
public void encode(ProtoWriter writer, MessageExtras value) {
Intrinsics.checkNotNullParameter(writer, "writer");
Intrinsics.checkNotNullParameter(value, "value");
GV2UpdateDescription.ADAPTER.encodeWithTag(writer, 1, (int) value.gv2UpdateDescription);
GroupContext.ADAPTER.encodeWithTag(writer, 2, (int) value.gv1Context);
ProfileChangeDetails.ADAPTER.encodeWithTag(writer, 3, (int) value.profileChangeDetails);
PaymentTombstone.ADAPTER.encodeWithTag(writer, 4, (int) value.paymentTombstone);
writer.writeBytes(value.unknownFields());
}
@Override // com.squareup.wire.ProtoAdapter
public MessageExtras decode(ProtoReader reader) {
Intrinsics.checkNotNullParameter(reader, "reader");
long beginMessage = reader.beginMessage();
GV2UpdateDescription gV2UpdateDescription = null;
GroupContext groupContext = null;
ProfileChangeDetails profileChangeDetails = null;
PaymentTombstone paymentTombstone = null;
while (true) {
int nextTag = reader.nextTag();
if (nextTag == -1) {
return new MessageExtras(gV2UpdateDescription, groupContext, profileChangeDetails, paymentTombstone, reader.endMessageAndGetUnknownFields(beginMessage));
}
if (nextTag == 1) {
gV2UpdateDescription = GV2UpdateDescription.ADAPTER.decode(reader);
} else if (nextTag == 2) {
groupContext = GroupContext.ADAPTER.decode(reader);
} else if (nextTag == 3) {
profileChangeDetails = ProfileChangeDetails.ADAPTER.decode(reader);
} else if (nextTag == 4) {
paymentTombstone = PaymentTombstone.ADAPTER.decode(reader);
} else {
reader.readUnknownField(nextTag);
}
}
}
...
} // static
} // MessageExtras
분석
앱에 포함된 .proto 검색
앱에서 삭제되지 않은 proto 파일이 남아있는 경우가 있다.
앱의 압축을 풀고 findstr /S /I /C:"GroupContext" .\*.proto 명령으로 메시지 타입이 검색될 수 있다.
이 타입들은 굳이 분석하지 않아도된다.
필드와 속성
- @WireField : Square 의 Wire 라이브러리에서 사용하는 어노테이션으로, protobuf 메시지의 필드를 의미한다.
- adapter : protobuf 메시지를 Java 객체와 직렬화/역직렬화 하는 방식을 지정하는 ProtoAdapter이다.
- oneofName : 하나의 메시지 안에서 같은 name으로 지정된 필드 중 하나만 활성화되는 그룹을 의미한다.
- tag : 필드이름을 식별하는 고유번호
public final class MessageExtras extends Message<MessageExtras, Builder> {
public static final ProtoAdapter<MessageExtras> ADAPTER;
private static final long serialVersionUID = 0;
@WireField(adapter = "org.whispersystems.signalservice.internal.push.GroupContext#ADAPTER", oneofName = "extra", tag = 2)
public final GroupContext gv1Context;
@WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription#ADAPTER", oneofName = "extra", tag = 1)
public final GV2UpdateDescription gv2UpdateDescription;
@WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone#ADAPTER", oneofName = "extra", tag = 4)
public final PaymentTombstone paymentTombstone;
@WireField(adapter = "org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails#ADAPTER", oneofName = "extra", tag = 3)
public final ProfileChangeDetails profileChangeDetails;
이 필드를 .proto 파일로 변경하면 아래와 같이 나오며, 각각의 필드는 기본 필드가 아니기 때문에 각자의 ADAPTER로 디코드 되어야 한다.
message MessageExtras {
oneof extra {
GV2UpdateDescription gv2UpdateDescription = 1;
GroupContext gv1Context = 2;
ProfileChangeDetails profileChangeDetails = 3;
PaymentTombstone paymentTombstone = 4;
}
}
Enum 값과 기본자료형
- WireEnum : 이 클래스를 상속받아서 구현하게 되면, Enum 타입을 구현할 수 있다. Flags 타입은 아마 flags에 세팅하기 위한 Enum 타입이겠지만 거기에 대한 정보는 없다.
- 기본 자료형 : 기본 자료형들은
adapter = "com.squareup.wire.ProtoAdapter#STRING"형식으로 이미 구현된 어댑터를 사용한다. - optional 필드 : nullable 타입같은 경우 .proto에서는 optional로 지정하며, 값이 null이라면 직렬화할 때 필드 자체가 데이터에서 제외된다.
public final class AttachmentPointer extends Message<AttachmentPointer, Builder> {
public static final ProtoAdapter<AttachmentPointer> ADAPTER;
private static final long serialVersionUID = 0;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#STRING", tag = 2)
public final String contentType;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#BYTES", tag = 3)
public final ByteString key;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#UINT32", tag = 4)
public final Integer size;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#FIXED64", oneofName = "attachment_identifier", tag = 1)
public final Long cdnId;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#STRING", oneofName = "attachment_identifier", tag = 15)
public final String cdnKey;
@WireField(adapter = "com.squareup.wire.ProtoAdapter#UINT32", tag = 8)
public final Integer flags;
// 생성자에서 nullable을 확인할 수 있어서 optional 필드를 확인할 수 있다.
public /* synthetic */ AttachmentPointer(Long l, String str, String str2, ByteString byteString, ...) {
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, ...);
}
...
public static final class Flags implements WireEnum {
private static final /* synthetic */ EnumEntries $ENTRIES;
private static final /* synthetic */ Flags[] $VALUES;
public static final ProtoAdapter<Flags> ADAPTER;
/* renamed from: Companion, reason: from kotlin metadata */
public static final Companion INSTANCE;
private final int value;
public static final Flags VOICE_MESSAGE = new Flags("VOICE_MESSAGE", 0, 1);
public static final Flags BORDERLESS = new Flags("BORDERLESS", 1, 2);
public static final Flags GIF = new Flags("GIF", 2, 4);
private static final /* synthetic */ Flags[] $values() {
return new Flags[]{VOICE_MESSAGE, BORDERLESS, GIF};
}
...
public static final class Companion {
@JvmStatic
public final Flags fromValue(int value) {
if (value == 1) return Flags.VOICE_MESSAGE;
if (value == 2) return Flags.BORDERLESS;
if (value != 4) return null;
return Flags.GIF;
}
}
}
}
.proto로 변경
syntax = "proto2";
package org.whispersystems.signalservice.internal.push;
message AttachmentPointer {
oneof attachment_identifier {
fixed64 cdnId = 1;
string cdnKey = 15;
}
optional string contentType = 2;
optional bytes key = 3;
optional uint32 size = 4;
optional uint32 flags = 8;
...
}
enum Flags {
VOICE_MESSAGE = 1;
BORDERLESS = 2;
GIF = 4;
}
.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 ...
// MessageExtras.proto
syntax = "proto3";
package org.thoughtcrime.securesms.database.model.databaseprotos;
import "GV2UpdateDescription.proto";
import "GroupContext.proto";
import "ProfileChangeDetails.proto";
import "PaymentTombstone.proto";
message MessageExtras {
oneof extra {
GV2UpdateDescription gv2UpdateDescription = 1;
GroupContext gv1Context = 2;
ProfileChangeDetails profileChangeDetails = 3;
PaymentTombstone paymentTombstone = 4;
}
}
// GV2UpdateDescription.proto
syntax = "proto3";
package org.thoughtcrime.securesms.database.model.databaseprotos;
message GV2UpdateDescription {
string test_field = 1;
}
다른 패키지의 .proto 파일 사용
.proto 파일이 지워져 있지 않고 앱에 포함되어 있어서 그걸 그대로 사용하려 하는데 타입을 찾아오지 못한다고 에러가 발생했다.
패키지가 동일하면 위의 방법 그대로 사용할 수 있지만, 패키지가 다르면 메시지 타입을 찾아오지 못한다.
// MessageExtras.proto
syntax = "proto3";
package org.thoughtcrime.securesms.database.model.databaseprotos;
import "GV2UpdateDescription.proto";
import "SignalService.proto";
// 모든 proto 파일이 proto3 이라면 import public 키워드로 임포트해서 패키지명을 명시하지 않아도된다.
// import public "SignalService.proto";
import "ProfileChangeDetails.proto";
import "PaymentTombstone.proto";
message MessageExtras {
oneof extra {
GV2UpdateDescription gv2UpdateDescription = 1;
// 여기에서 [package].[message_type] 형태로 접근해야한다.
signalservice.GroupContext gv1Context = 2;
ProfileChangeDetails profileChangeDetails = 3;
PaymentTombstone paymentTombstone = 4;
}
}
// SignalService.proto
syntax = "proto2";
package signalservice;
message GroupContext {
optional bytes id = 1;
...
}
컴파일 결과로 나온 파일들
컴파일된 파일 사용법
protobuf 모듈 설치 pip install protobuf
import MessageExtras_pb2
# MessageExtras 메시지 생성
message = MessageExtras_pb2.MessageExtras()
message.gv2UpdateDescription.test_field = "1234"
# 직렬화
serialized_data = message.SerializeToString()
print("serialized_data:", serialized_data)
# 저장
with open("message.bin", "wb") as f:
f.write(serialized_data)
# 읽기
with open("message.bin", "rb") as f:
serialized_data2 = f.read()
message2 = MessageExtras_pb2.MessageExtras()
message2.ParseFromString(serialized_data2)
print(message2)
Comments