Repository navigation
[gateway] 서비스 응답과 게이트웨이 오류의 공통 응답 형식 래핑 #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jyx-07
wants to merge
3
commits into
feat/gateway-circuit-breaker-status
from
feat/gateway-response-wrapper
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseBodyWrapper.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| package com.rush.gateway.response | ||
|
|
||
| import com.fasterxml.jackson.databind.DeserializationFeature | ||
| import com.fasterxml.jackson.databind.JsonNode | ||
| import com.fasterxml.jackson.databind.ObjectMapper | ||
| import com.fasterxml.jackson.databind.cfg.JsonNodeFeature | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** | ||
| * 서비스가 돌려준 JSON body를 공통 형식으로 바꿉니다. | ||
| */ | ||
| @Component | ||
| class ApiResponseBodyWrapper( | ||
| private val objectMapper: ObjectMapper, | ||
| ) { | ||
| // 소수를 double로 읽으면 금액 같은 값의 자릿수가 바뀌므로 BigDecimal로 읽고, 끝자리 0도 원문대로 둡니다. | ||
| private val reader = | ||
| objectMapper | ||
| .reader() | ||
| .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) | ||
| .without(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES) | ||
|
|
||
| /** 감싼 body를 돌려줍니다. 이미 공통 형식이거나 JSON으로 읽을 수 없으면 null입니다. */ | ||
| fun wrap( | ||
| status: HttpStatus, | ||
| body: ByteArray, | ||
| ): ByteArray? { | ||
| val node = if (body.isEmpty()) null else runCatching { reader.readTree(body) }.getOrElse { return null } | ||
| if (node != null && isAlreadyWrapped(node)) return null | ||
|
|
||
| val wrapped = | ||
| if (status.isError) { | ||
| CommonApiResponse.error( | ||
| status = status.value(), | ||
| code = node.textField("code") ?: status.name, | ||
| message = node.textField("message") ?: status.reasonPhrase, | ||
| ) | ||
| } else { | ||
| CommonApiResponse.success(status, node) | ||
| } | ||
| return objectMapper.writeValueAsBytes(wrapped) | ||
| } | ||
|
|
||
| private fun isAlreadyWrapped(node: JsonNode) = node.isObject && WRAPPED_KEYS.all(node::has) | ||
|
|
||
| private fun JsonNode?.textField(name: String): String? = this?.get(name)?.takeIf { it.isTextual }?.asText() | ||
|
|
||
| companion object { | ||
| private val WRAPPED_KEYS = listOf("status", "code", "message") | ||
| } | ||
| } |
88 changes: 88 additions & 0 deletions
88
rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperFilter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package com.rush.gateway.response | ||
|
|
||
| import org.reactivestreams.Publisher | ||
| import org.springframework.cloud.gateway.filter.GatewayFilterChain | ||
| import org.springframework.cloud.gateway.filter.GlobalFilter | ||
| import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter | ||
| import org.springframework.core.Ordered | ||
| import org.springframework.core.io.buffer.DataBuffer | ||
| import org.springframework.core.io.buffer.DataBufferUtils | ||
| import org.springframework.http.HttpHeaders | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.http.server.reactive.ServerHttpResponseDecorator | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.server.ServerWebExchange | ||
| import reactor.core.publisher.Flux | ||
| import reactor.core.publisher.Mono | ||
|
|
||
| /** | ||
| * 서비스 응답을 CommonApiResponse로 감쌉니다. | ||
| * | ||
| * NettyWriteResponseFilter 바로 앞에서 응답을 가로채, 쓰기 직전에 [ApiResponseWrappingPolicy]로 감쌀지 정합니다. | ||
| * 감쌀 때는 body를 최대 크기까지 모았다가 바꾸고, 크기를 넘으면 원문을 그대로 흘려보냅니다. | ||
| * SSE처럼 writeAndFlushWith로 나가는 스트리밍 응답은 감싸지 않습니다. | ||
| */ | ||
| @Component | ||
| class ApiResponseWrapperFilter( | ||
| private val policy: ApiResponseWrappingPolicy, | ||
| private val bodyWrapper: ApiResponseBodyWrapper, | ||
| properties: ApiResponseWrapperProperties, | ||
| ) : GlobalFilter, | ||
| Ordered { | ||
| private val maxBytes = properties.maxSize.toBytes() | ||
|
|
||
| override fun filter( | ||
| exchange: ServerWebExchange, | ||
| chain: GatewayFilterChain, | ||
| ): Mono<Void> = chain.filter(exchange.mutate().response(WrappingResponse(exchange)).build()) | ||
|
|
||
| private inner class WrappingResponse( | ||
| private val exchange: ServerWebExchange, | ||
| ) : ServerHttpResponseDecorator(exchange.response) { | ||
| override fun writeWith(body: Publisher<out DataBuffer>): Mono<Void> { | ||
| val request = exchange.request | ||
| val decision = policy.decide(request.path.value(), request.method, request.headers, httpStatus(), headers) | ||
| if (decision != ApiResponseWrappingPolicy.Decision.WRAP) return super.writeWith(body) | ||
|
|
||
| val collector = BoundedBodyCollector(maxBytes) | ||
| val relayed = Flux.from(body).concatMapIterable(collector::accept) | ||
| val wrapped = Mono.defer { if (collector.relaying) Mono.empty() else Mono.justOrEmpty(wrappedBody(collector.drain())) } | ||
| return super.writeWith( | ||
| relayed | ||
| .concatWith(wrapped) | ||
| .doOnCancel(collector::release) | ||
| .doOnError { collector.release() }, | ||
| ) | ||
| } | ||
|
|
||
| private fun httpStatus(): HttpStatus? = statusCode?.let { HttpStatus.resolve(it.value()) } | ||
|
|
||
| // 모은 버퍼를 하나로 합쳐 감싸고, 감쌀 수 없으면 원문 그대로 돌려줍니다. | ||
| private fun wrappedBody(buffers: List<DataBuffer>): DataBuffer? { | ||
| if (buffers.isEmpty()) return null | ||
| val original = | ||
| bufferFactory().join(buffers).let { joined -> | ||
| ByteArray(joined.readableByteCount()).also { | ||
| joined.read(it) | ||
| DataBufferUtils.release(joined) | ||
| } | ||
| } | ||
| val status = httpStatus() ?: HttpStatus.OK | ||
| val bytes = bodyWrapper.wrap(status, original) ?: return bufferFactory().wrap(original) | ||
|
|
||
| headers.remove(HttpHeaders.TRANSFER_ENCODING) | ||
| HEADERS_INVALIDATED_BY_WRAPPING.forEach(headers::remove) | ||
| headers.contentType = MediaType.APPLICATION_JSON | ||
| headers.contentLength = bytes.size.toLong() | ||
| return bufferFactory().wrap(bytes) | ||
| } | ||
| } | ||
|
|
||
| override fun getOrder() = NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1 | ||
|
|
||
| companion object { | ||
| // body가 바뀌면 의미가 없어지는 헤더 | ||
| private val HEADERS_INVALIDATED_BY_WRAPPING = listOf(HttpHeaders.ETAG, "Content-MD5", "Digest") | ||
| } | ||
| } |
15 changes: 15 additions & 0 deletions
15
rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperProperties.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.rush.gateway.response | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties | ||
| import org.springframework.util.unit.DataSize | ||
|
|
||
| /** | ||
| * 서비스 응답 래핑 설정입니다. | ||
| */ | ||
| @ConfigurationProperties("gateway.response-wrapper") | ||
| data class ApiResponseWrapperProperties( | ||
| // 이보다 큰 응답은 감싸지 않고 원문을 그대로 보냅니다. | ||
| val maxSize: DataSize = DataSize.ofMegabytes(1), | ||
| // 기본 제외 경로에 더해 감싸지 않을 경로 패턴 | ||
| val excludedPaths: List<String> = emptyList(), | ||
| ) |
75 changes: 75 additions & 0 deletions
75
rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.rush.gateway.response | ||
|
|
||
| import org.springframework.http.HttpHeaders | ||
| import org.springframework.http.HttpMethod | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.http.server.PathContainer | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.util.pattern.PathPatternParser | ||
|
|
||
| /** | ||
| * 서비스 응답을 공통 형식으로 감쌀지 정합니다. 응답 상태와 헤더가 정해진 뒤, body를 쓰기 직전에 판단합니다. | ||
| */ | ||
| @Component | ||
| class ApiResponseWrappingPolicy( | ||
| properties: ApiResponseWrapperProperties, | ||
| ) { | ||
| private val maxBytes = properties.maxSize.toBytes() | ||
| private val excludedPatterns = (DEFAULT_EXCLUDED_PATHS + properties.excludedPaths).map(PathPatternParser.defaultInstance::parse) | ||
|
|
||
| fun decide( | ||
| path: String, | ||
| method: HttpMethod, | ||
| requestHeaders: HttpHeaders, | ||
| status: HttpStatus?, | ||
| responseHeaders: HttpHeaders, | ||
| ): Decision { | ||
| val pathContainer = PathContainer.parsePath(path) | ||
| return when { | ||
| excludedPatterns.any { it.matches(pathContainer) } -> Decision.SKIP_EXCLUDED_PATH | ||
| requestHeaders.upgrade.equals("websocket", ignoreCase = true) -> Decision.SKIP_WEBSOCKET | ||
| method == HttpMethod.HEAD -> Decision.SKIP_NO_BODY | ||
| status == HttpStatus.NO_CONTENT || status == HttpStatus.NOT_MODIFIED -> Decision.SKIP_NO_BODY | ||
| // Content-Range는 원본 본문 기준이라 감싸면 범위가 맞지 않습니다. | ||
| status == HttpStatus.PARTIAL_CONTENT || responseHeaders.containsKey(HttpHeaders.CONTENT_RANGE) -> Decision.SKIP_PARTIAL | ||
| isEncoded(responseHeaders) -> Decision.SKIP_ENCODED | ||
| !isJson(responseHeaders.contentType) -> Decision.SKIP_NOT_JSON | ||
| responseHeaders.contentLength > maxBytes -> Decision.SKIP_TOO_LARGE | ||
| else -> Decision.WRAP | ||
| } | ||
| } | ||
|
|
||
| private fun isEncoded(headers: HttpHeaders): Boolean { | ||
| val encoding = headers.getFirst(HttpHeaders.CONTENT_ENCODING) ?: return false | ||
| return !encoding.equals("identity", ignoreCase = true) | ||
| } | ||
|
|
||
| private fun isJson(contentType: MediaType?): Boolean = | ||
| contentType != null && | ||
| (contentType.isCompatibleWith(MediaType.APPLICATION_JSON) || contentType.subtype.endsWith("+json")) | ||
|
|
||
| enum class Decision { | ||
| WRAP, | ||
| SKIP_EXCLUDED_PATH, | ||
| SKIP_WEBSOCKET, | ||
| SKIP_NO_BODY, | ||
| SKIP_PARTIAL, | ||
| SKIP_ENCODED, | ||
| SKIP_NOT_JSON, | ||
| SKIP_TOO_LARGE, | ||
| } | ||
|
|
||
| companion object { | ||
| val DEFAULT_EXCLUDED_PATHS = | ||
| listOf( | ||
| "/actuator/**", | ||
| "/fallback", | ||
| "/v3/api-docs/**", | ||
| "/*/v3/api-docs/**", | ||
| "/swagger-ui.html", | ||
| "/swagger-ui/**", | ||
| "/webjars/**", | ||
| ) | ||
| } | ||
| } | ||
39 changes: 39 additions & 0 deletions
39
rush-gateway/src/main/kotlin/com/rush/gateway/response/BoundedBodyCollector.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.rush.gateway.response | ||
|
|
||
| import org.springframework.core.io.buffer.DataBuffer | ||
| import org.springframework.core.io.buffer.DataBufferUtils | ||
|
|
||
| /** | ||
| * 응답 body를 최대 [maxBytes]까지 모읍니다. 넘는 순간 모은 버퍼를 한꺼번에 내보내고, | ||
| * 이후 버퍼는 모으지 않고 그대로 통과시킵니다. 요청 하나에서만 쓰는 상태 객체입니다. | ||
| */ | ||
| class BoundedBodyCollector( | ||
| private val maxBytes: Long, | ||
| ) { | ||
| private val collected = mutableListOf<DataBuffer>() | ||
| private var size = 0L | ||
|
|
||
| /** 크기를 넘어 원문을 그대로 흘려보내는 중인지 여부입니다. */ | ||
| var relaying = false | ||
| private set | ||
|
|
||
| /** 버퍼 하나를 받아, 지금 내보낼 버퍼 목록을 돌려줍니다. 모으는 중이면 빈 목록입니다. */ | ||
| fun accept(buffer: DataBuffer): List<DataBuffer> { | ||
| if (relaying) return listOf(buffer) | ||
|
|
||
| collected += buffer | ||
| size += buffer.readableByteCount() | ||
| if (size <= maxBytes) return emptyList() | ||
|
|
||
| relaying = true | ||
| return drain() | ||
| } | ||
|
|
||
| /** 모은 버퍼를 넘겨주고 비웁니다. 넘겨받은 쪽이 해제합니다. */ | ||
| fun drain(): List<DataBuffer> = collected.toList().also { collected.clear() } | ||
|
|
||
| /** 취소나 오류로 끝났을 때 아직 들고 있는 버퍼를 해제합니다. */ | ||
| fun release() { | ||
| drain().forEach(DataBufferUtils::release) | ||
| } | ||
| } |
27 changes: 24 additions & 3 deletions
27
...n/com/rush/gateway/error/ErrorResponse.kt → ...ush/gateway/response/CommonApiResponse.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
206 부분 응답도 여기서 제외하는 게 좋겠습니다. 7바이트짜리 JSON 응답으로 확인해 보니 래핑 후 본문은 96바이트가 됐는데
Content-Range: bytes 0-6/20은 그대로 남았습니다. 범위 정보가 원본 본문 기준이라, 206이나Content-Range가 있는 응답은 그대로 전달하는 쪽이 맞을 것 같습니다.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cfcromn b007549 에서 반영했습니다. (근거: RFC 9110 §14.4, Content-Range는 원본 표현 기준) 206 응답이나
Content-Range가 있는 응답은 감싸지 않고 그대로 전달합니다. 정책 단위 테스트와 통합 테스트를 추가했습니다.