피곤해서 그런지 암만 생각해도 좋은 글 제목이 생각나지 않아 부득이하게 어그로성 제목을 달아 죄송합니다. 허허
1.이슈
Firestore에 MyData란 Document가 있고, 좋아요를 눌렀는지에 대한 Boolean 값이 field로 있다고 할 때,
Android에서 이 document data를 toObject()로 우리가 원하는 DTO로 직렬화한다고 해보자.
//Firestore document
MyData
- isLiked = true (boolean)
//Android-Kotlin DTO
data class MyData(
val isLiked: Boolean = false,
}
myDataDocument.toObject<MyData>()
MyData에 isLiked는 true값이 정상적으로 들어가길 기대하겠지만
true값이 들어가지 않는다.
//Firestore document
MyData
- liked = true (boolean)
//Android-Kotlin DTO
data class MyData(
val liked: Boolean = false,
}
myDataDocument.toObject<MyData>()
isLiked가 아닌 liked로 바꾸면 정상적으로 true가 들어간다.
이유가 뭘까?
toObject()의 동작 방식을 알아보자.
2.뜯어보기
//firestore.Firestore.kt
/**
* Returns the contents of the document converted to a POJO or null if the document doesn't exist.
*
* @param T The type of the object to create.
* @return The contents of the document in an object of type T or null if the document doesn't
* ```
* exist.
* ```
*/
inline fun <reified T> DocumentSnapshot.toObject(): T? = toObject(T::class.java)
//firestore.DocumentSnapshot.java
/**
* Returns the contents of the document converted to a POJO or {@code null} if the document
* doesn't exist.
*
* @param valueType The Java class to create
* @return The contents of the document in an object of type T or {@code null} if the document
* doesn't exist.
*/
@Nullable
public <T> T toObject(@NonNull Class<T> valueType) {
return toObject(valueType, ServerTimestampBehavior.DEFAULT);
}
java-Kotlin 모두 지원하니 역시 kotlin으로 래핑만하고 Java기반으로 동작한다.
주석을 보면 Data를 역직렬화할 타입 T는 POJO를 기대하고 있다.
좀 더 들어가보자.
//firestore.util.CustomClassMapper.java
private static <T> T deserializeToClass(Object o, Class<T> clazz, DeserializeContext context) {
if (o == null) {
return null;
} else if (clazz.isPrimitive()
|| Number.class.isAssignableFrom(clazz)
|| Boolean.class.isAssignableFrom(clazz)
|| Character.class.isAssignableFrom(clazz)) {
return deserializeToPrimitive(o, clazz, context);
} else if (String.class.isAssignableFrom(clazz)) {
return (T) convertString(o, context);
} else if (Date.class.isAssignableFrom(clazz)) {
return (T) convertDate(o, context);
} else if (Timestamp.class.isAssignableFrom(clazz)) {
return (T) convertTimestamp(o, context);
} else if (Blob.class.isAssignableFrom(clazz)) {
return (T) convertBlob(o, context);
} else if (GeoPoint.class.isAssignableFrom(clazz)) {
return (T) convertGeoPoint(o, context);
} else if (DocumentReference.class.isAssignableFrom(clazz)) {
return (T) convertDocumentReference(o, context);
} else if (clazz.isArray()) {
throw deserializeError(
context.errorPath, "Converting to Arrays is not supported, please use Lists instead");
} else if (clazz.getTypeParameters().length > 0) {
throw deserializeError(
context.errorPath, "Class " + clazz.getName() + " has generic type parameters");
} else if (clazz.equals(Object.class)) {
return (T) o;
} else if (clazz.isEnum()) {
return deserializeToEnum(o, clazz, context);
} else {
return convertBean(o, clazz, context);
}
}
위 코드를 보면 T가 우리가 정의한 클래스인 경우 convertBean 함수를 타게 된다.
즉, toObject() 함수는 T에 POJO를 기대하고 Java Beans 규칙을 따른다는 것을 알 수 있다.
Java Beans Convention
클래스의 property 들은 get, set 또는 표준 명명법을 따르는 메소드를 사용해 접근 할 수 있어야 한다.
//firestore.util.CustomClassMapper.java
//1
private static <T> T convertBean(Object o, Class<T> clazz, DeserializeContext context) {
//BeanMapper를 이용해 Bean과 data를 매핑
BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz);
if (o instanceof Map) {
return mapper.deserialize(expectMap(o, context), context);
} else {
throw deserializeError(
context.errorPath,
"Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName());
}
}
//2
// Add any public getters to properties (including isXyz())
for (Method method : clazz.getMethods()) {
if (shouldIncludeGetter(method)) {
// Bean의 getter method들을 돌면서 getter의 변수명을 체크
String propertyName = propertyName(method);
addProperty(propertyName);
method.setAccessible(true);
if (getters.containsKey(propertyName)) {
throw new RuntimeException(
"Found conflicting getters for name "
+ method.getName()
+ " on class "
+ clazz.getName());
}
getters.put(propertyName, method);
applyGetterAnnotations(method);
}
}
//3
private static String propertyName(Method method) {
String annotatedName = annotatedName(method);
// @get:PropertyName() 같은 어노테이션을 사용했다면 그 이름을, 아니라면 getther에서 변수명 따오기
return annotatedName != null ? annotatedName : serializedName(method.getName());
}
//firestore.util.CustomClassMapper.java
private static String serializedName(String methodName) {
String[] prefixes = new String[] {"get", "set", "is"};
String methodPrefix = null;
for (String prefix : prefixes) {
if (methodName.startsWith(prefix)) {
methodPrefix = prefix;
}
}
if (methodPrefix == null) {
throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName);
}
//getter/setter에서 prefix인 get/set/is를 제외한 나머지를 변수명으로 채택
String strippedName = methodName.substring(methodPrefix.length());
// Make sure the first word or upper-case prefix is converted to lower-case
char[] chars = strippedName.toCharArray();
int pos = 0;
while (pos < chars.length && Character.isUpperCase(chars[pos])) {
chars[pos] = Character.toLowerCase(chars[pos]);
pos++;
}
return new String(chars);
}
}
여기가 핵심 코드인데, 코드 블럭 내에 주석을 중심으로 보면 좋을 것 같다.
내용인 즉, property를 매핑할 때 getter/setter에서 프로퍼티명을 따오는데,
이때 로직은 get/set/is의 prefix을 substring하고 뒷부분을 프로퍼티명으로 채택하는 것이다.
그래서 뭐가 문제인 것인가??
3.관련 개념
Kotlin의 프로퍼티가 Java에서 동작할 때는 getter/setter가 필요하고, 개발자가 직접 만들지 않아도 된다.
그렇다는 것은 어떠한 규칙에 의해 getter/setter가 생성되는 것이므로 이 규칙을 알아야 할 필요가 있다.
//kotlin
var firstName: String
//java
private String firstName;
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
Calling Kotlin from Java 문서에 따르면 kotlin property는 java로 컴파일되면서
getter에는 get, setter에는 set(only var)이라는 prefix가 붙은 method가 생성된다.
//kotlin
val isLiked: Boolean
//java
private boolean isLiked;
public boolean isLiked(){
return isLiked;
public boolean setLiked(boolean var1){ //매개변수 네이밍은 신경쓰지 맙시다!
this.isLiked = var1
}
하지만 특수한 경우가 있는데,
Boolean 타입의 property면서 변수명에 is라는 prefix가 붙은 경우는
get/set이 붙지 않고 is/set이 붙는다.
4.결론
//Firestore document
MyData
- isLiked = true (boolean)
//Android-Kotlin DTO
data class MyData(
val isLiked: Boolean = false,
}
myDataDocument.toObject<MyData>()
val isLiked: Boolean
getter: isLiked()
setter: setLiked(boolean var1)
val liked: Boolean
getter: getLiked()
setter: setLiked(boolean var1)
다시 이 사례를 보면,
isLiked가 toObject의 BeanMapper에서는 프로퍼티명이 liked로 인식되기 때문에 Data의 field명과 DTO의 property명이 일치하지 않아 true라는 데이터가 들어가지 않고 기본 값인 false가 찍히는 것이다.
5.해결
//Firestore document
MyData
- isLiked = true (boolean)
//Android-Kotlin DTO
data class MyData(
@get:PropertyName("isLiked") val isLiked: Boolean = false,
}
//Firestore document
MyData
- liked = true (boolean)
//Android-Kotlin DTO
data class MyData(
val liked: Boolean = false,
}
따라서 이렇게 두 가지 해결책이 있을 것 같은데, 프로젝트의 컨벤션이나 상황에 맞춰서 선택하여 사용하자.
Before
2022.08.31 - [Android] - [Android/Kotlin] Retrofit2은 어떻게 Interface의 구현 없이 사용할 수 있을까?
2021.12.18 - [언어/Kotlin&Java] - [Kotlin/Java] Kotlin/java의 sort 동작 방식 (2022.10.18 수정)
댓글