-
Notifications
You must be signed in to change notification settings - Fork 0
JSend Data Model 에서 Depth 지옥에서 벗어나보기
Sieun Ju edited this page Jan 28, 2023
·
2 revisions
- JSend 규칙으로 받는 Data Object 에서 실제로 필요한 “payload” 안에 있는 데이터를 원뎁스로 처리할수 있는 방법을 고민해 봅니다.
- JSend Format 으로 데이터를 주고 받다 보면 제가 생각했을때 가장 Best 데이터 모델은 아래라고 생각합니다.
{
status : "success", "fail", "error",
message: "Error Message",
data : {
payload : Array or Object,
meta : MetaEntity
}
}
@Serializable
data class JSendBaseResponse<T : Any>(
@SerialName("status")
val isSuccess: Boolean = true,
@SerialName("message")
val message: String? = null,
@SerialName("data")
val data: T? = null
)
// 예시기 때문에 Payload 가 List 형식만 있습니다. Object 형식으로도 만들수 있습니다.
@Serializable
data class JSendListWithMeta<T : Any, M : MetaEntity>(
@SerialName("payload")
val list: List<T> = listOf(),
@SerialName("meta")
val meta: M? = null
)
RepositoryImpl.kt
// Repository 패턴 혹은 UseCase 패턴에서 status or message 에 대한 값은
// 에러인경우에만 필요하기 때문에 보통 아래와 같이 map 확장함수를 통해 실제로 쓰일 데이터를
// 리턴해서 사용합니다.
override fun fetchJSendListMeta(): Single<JSendListWithMeta<String, MetaEntity>> {
return apiService.fetchJSendListWithMeta()
.map { it.data ?: throw NullPointerException("Data is Null") }
}
- 위와 같은 구조를 하는 경우 한가지 딜레마에 빠지게 됩니다. API 를 추가하는 경우
- API 추가
- Repository 추가
- RepositoryImpl 추가
- Impl 클래스 안에 map 함수를 사용하여 처리
- 이러한 구조를 하게 되면 map 함수는 꾸준히 추가 해야 하는 함수가 됩니다. 그렇게 되면 보일러코드가 생성이 되고 많은 귀차니즘이 발생합니다. 또한, 불필요한 확장 함수는 제가 좋아하는 스타일이 아니라고 생각합니다.
- 불필요한 Map 함수를 사용하지 않고 UseCase 패턴에서 ‘알맹이’ 데이터를 바로 가져다 사용할수 있는 방법에 대해서 생각 하도록 합니다.
- 이러한 과정을 없애기 위해 Retrofit 에서 제공하는 몇가지 클래스에 대해서 심도있게 관찰을 했습니다.
-
CallAdapter
,Converter
에 대해서 공부를 했고, 몇가지 솔루션을 생각해 냈습니다.
- Converter 를 Custom 하게 사용시 기존에 사용하고 있는 로직에 대해서 이슈가 생기지 않아야 하므로 Converter 할때 특정 Annotation Class 체크 하여 재 가공 할수 있는 방법입니다.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class JSendSimple
Converter.kt
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *> {
val loader = format.serializersModule.serializer(type)
val rawType = getRawType(type)
Timber.d("RawType $rawType")
return DeserializationConverter(rawType, loader, format)
}
/**
* Response 역 직렬화 컨버터
*/
class DeserializationConverter<T>(
private val rawType: Class<*>,
private val loader: DeserializationStrategy<T>,
private val format: Json
) : Converter<ResponseBody, T> {
override fun convert(value: ResponseBody): T? {
val string = value.string()
if (rawType.isAnnotationPresent(JSendSimple::class.java)) {
Timber.d("JSendSimpleResponse 가 있습니다. ")
val jsonElement = format.decodeFromString<JsonElement>(string)
val dataBody = jsonElement.jsonObject["data"]
?: return format.decodeFromString(loader, string)
val status = jsonElement.jsonObject["status"]
val message = jsonElement.jsonObject["message"]
val payload = dataBody.jsonObject["payload"]
val meta = dataBody.jsonObject["meta"]
val map = ConcurrentHashMap<String, JsonElement>()
if (status != null) {
map["status"] = status
}
if (message != null) {
map["message"] = message
}
if (payload != null) {
map["payload"] = payload
}
if (meta != null) {
map["meta"] = meta
}
val json = JsonObject(map)
Timber.d("재 가공 데이터 $json")
return format.decodeFromString(loader, json.toString())
} else {
Timber.d("JSendSimpleResponse 가 없습니다. ")
return format.decodeFromString(loader, string)
}
}
}
-
위 방법 처럼 ‘data’ Depth 를 앞으로 이동후 재 가공해서 처리할수 있게 합니다. 혹시나 message or status 를 UseCase 에서 사용할수 있다고 생각이 들어 해당 값도 넣어서 처리 했습니다.
-
```kotlin override fun fetchJSendListWithMeta(): Single<JSendListWithMeta<String, CustomMetaEntity>> { return jsendApiService.fetchJSendListWithMetaTest() } ```
- 위와 같이 따로 JSendResponse 를 안해도 앞단에서 json string 에서 data 를 꺼내서 변환 하기때문에 따로 map 함수를 사용안하고 바로 사용할수 있다는 장점이 있습니다.
- data 가 null 인경우 Error 로 빠지는 이슈가 있습니다. 하지만, data 가 null 인경우는 에러가 맞다고 생각이 들었고 Response 가 없는 경우는 Void 로 풀어 낼수 있겠습니다.
-
이슈 사항
- 재 가공하면서 불필요한 JsonObject 와 HashMap 을 생성 하는 이슈가 있습니다. map 확장함수를 안하겠다고 저런 과정을 하기에는 더 불필요해보였습니다.
- 위 방법은 너무 어렵게만 생각했던거 같아 위에 방법을 처리하다가 갑자기 쉽게 풀어 낼수 있겠다는 생각이 들었습니다.
- Data Class 로 쉽게 해결할수 있을거 같다는 생각이 들어서 Data Class 구조를 변경했습니다. 그리고 Data 가 Non Null 를 보장하기 위해 CallAdapter 에서 풀어낼수 있을거 같습니다.
- data 가 null 인경우 → Error 로 판단 Nothing 이 아닌이상 성공하면 무조건 data 라는 키값 안에 데이터가 존재 해야 한다는 가정
- CallAdapter 를 통해 data 가 Null 인경우 Error 로 리턴한다.
DataModel.kt
@Serializable
data class JSendListWithMeta<T : Any, M : MetaEntity>(
@SerialName("status")
val isSuccess: Boolean = true,
@SerialName("message")
val message: String? = null,
@SerialName("data")
private val depthData: Payload<T, M>? = null
) {
@Serializable
data class Payload<T : Any, M : MetaEntity>(
@SerialName("payload")
val list: List<T> = listOf(),
@SerialName("meta")
val meta: M? = null
)
val payload: List<T>
get() = depthData?.list ?: listOf()
val meta: M?
get() = depthData?.meta
}
Repository.kt
fun fetchJSendListWithMeta(): Single<JSendListWithMeta<String, MetaEntity>>
- 위에 코드 처럼 Repository 에서 여러 Depth 를 처리 하지 않고 한개의 Depth 로 간단히 처리 할수 있겠습니다.
참고 클래스
class RxErrorHandlingCallAdapter : CallAdapter.Factory() {
private val original = RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io())
companion object {
fun create(): CallAdapter.Factory {
return RxErrorHandlingCallAdapter()
}
}
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
val adapter = original.get(returnType, annotations, retrofit)
return if (adapter != null) {
RxJavaCallAdapterWrapper(adapter)
} else {
null
}
}
class RxJavaCallAdapterWrapper<R>(
private val original: CallAdapter<R, *>
) : CallAdapter<R, Any> {
override fun responseType(): Type = original.responseType()
override fun adapt(call: Call<R>): Any {
return when (val res = original.adapt(call)) {
is Single<*> -> {
res.map { it.performErrorHandling() }
}
is Flowable<*> -> {
res.map { it.performErrorHandling() }
}
else -> {
throw IllegalArgumentException("Not Invalid Type")
}
}
}
@Throws(JSendInvalidPayloadException::class, JSendEmptyDataException::class)
private fun Any.performErrorHandling(): Any {
return if (checkDataPayload()) {
this
} else {
if (this is BaseJSend) {
throw JSendInvalidPayloadException(message)
} else {
throw JSendInvalidPayloadException("Invalid Exception")
}
}
}
/**
* data 가 없거나 안에 payload 가 유효하지 않는 경우
*/
@Throws(JSendEmptyDataException::class)
private fun Any.checkDataPayload(): Boolean {
return when (this) {
is JSendObj<*> -> {
if (this.isValid) {
true
} else if (isSuccess) {
throw JSendEmptyDataException(message)
} else {
false
}
}
is JSendObjWithMeta<*, *> -> {
if (this.isValid) {
true
} else if (isSuccess) {
throw JSendEmptyDataException(message)
} else {
false
}
}
is JSendList<*> -> {
if (this.isValid) {
true
} else if (isSuccess) {
throw JSendEmptyDataException(message)
} else {
false
}
}
is JSendListWithMeta<*, *> -> {
if (this.isValid) {
true
} else if (isSuccess) {
throw JSendEmptyDataException(message)
} else {
false
}
}
// 규격화된 방식이 아닌경우 true 리턴
else -> true
}
}
}
}
- 위에 클래스대로 하게 되면 더이상 불필요하게 Repository 패턴에서 map( data ?: throw NPR) 없이 바로 UseCase 에서 처리 할수 있습니다.
- 이미 CallAdapter.Factory 에서 JSend Format 규칙을 확인 해서 UseCase 에서 페이로드값에 대한 보장을 확실하게 받을수 있습니다. :)
class GetGoodsUseCase @Inject constructor(
private val repository: GoodsRepository
) {
operator fun invoke(params: GoodsParamMap): Single<List<GoodsEntity>> {
return repository.fetchGoods(params)
.map { it.payload }
}
}