backend scaffold
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
dependencies {
|
||||
api project(':platform:platform-integration')
|
||||
implementation project(':platform:platform-web')
|
||||
|
||||
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
|
||||
testImplementation 'com.ninja-squad:springmockk:5.0.1'
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import io.github.resilience4j.bulkhead.annotation.Bulkhead
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
|
||||
import io.github.resilience4j.retry.annotation.Retry
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* F6 采购接口,见 05-integration-layer.md。
|
||||
*
|
||||
* 三个注解的叠加顺序由 `application.yml` 里的 `*-aspect-order` 决定,
|
||||
* 配的是 bulkhead(1) < circuitbreaker(2) < retry(3),也就是
|
||||
* **Retry 在最外层**:每次重试都会被熔断器单独统计,连续失败能更快打开熔断。
|
||||
* 方向容易记反,所以有一条 `ResilienceAspectOrderTest` 实测它(见 10-testing.md)。
|
||||
*
|
||||
* 没有 `@TimeLimiter`:同步栈下超时由 HTTP 客户端本身控制(见 RestClientFactory),
|
||||
* 加 TimeLimiter 反而会引入一个额外线程池。
|
||||
*/
|
||||
@Component
|
||||
class F6ApiClient(private val f6RestClient: RestClient) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Bulkhead(name = "f6-api")
|
||||
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
|
||||
@Retry(name = "f6-api")
|
||||
fun fetchProcurementList(storeId: Long): ProcurementList =
|
||||
f6RestClient.get()
|
||||
.uri("/f6/procurement/list?storeId={storeId}", storeId)
|
||||
.retrieve()
|
||||
.body(ProcurementList::class.java)
|
||||
?: ProcurementList.degraded(storeId)
|
||||
|
||||
/** fallback 的签名必须是"原方法参数 + Throwable",少一个参数 Resilience4j 就找不到它。 */
|
||||
@Suppress("unused")
|
||||
private fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementList {
|
||||
log.warn("F6 采购列表降级 storeId={} cause={}", storeId, ex.javaClass.simpleName)
|
||||
return ProcurementList.degraded(storeId)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import com.continental.retailapp.platform.integration.IntegrationException
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
|
||||
/**
|
||||
* F6 侧的失败分两类,见 05-integration-layer.md——这个区分直接决定了要不要重试:
|
||||
*
|
||||
* - [F6ServerException](5xx / 网络层):对方的问题,**可以重试**,计入熔断统计;
|
||||
* - [F6ClientException](4xx):我们请求本身有问题,重试一百次也是同样结果,
|
||||
* 所以它在 `retry-exceptions` 之外、在 `ignore-exceptions` 之内。
|
||||
*
|
||||
* 构造参数里的 message 只进日志,对外文案是父类里那句用户能看懂的话。
|
||||
*/
|
||||
class F6ServerException(detail: String) :
|
||||
IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试") {
|
||||
init {
|
||||
// detail 只在日志里出现,不进响应体
|
||||
F6_LOG.warn("F6 服务端错误: {}", detail)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ServerException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
class F6ClientException(detail: String) :
|
||||
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝") {
|
||||
init {
|
||||
F6_LOG.warn("F6 客户端错误: {}", detail)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ClientException::class.java)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import com.continental.retailapp.platform.integration.IntegrationClientProperties
|
||||
import com.continental.retailapp.platform.integration.RestClientFactory
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* F6 的 [RestClient],见 05-integration-layer.md(以及那里对文档偏离 #3 的说明)。
|
||||
*
|
||||
* statusHandler 在这里而不是 platform-integration:只有 f6-adapter 知道 F6 的错误该映射成什么异常,
|
||||
* 平台层不应该反过来 import 适配层的类型。
|
||||
*/
|
||||
@Configuration
|
||||
class F6RestClientConfig {
|
||||
|
||||
@Bean
|
||||
fun f6RestClient(
|
||||
factory: RestClientFactory,
|
||||
props: IntegrationClientProperties,
|
||||
): RestClient = factory.build(props.require("f6")) { builder ->
|
||||
builder
|
||||
.defaultStatusHandler({ it.is5xxServerError }) { _, response ->
|
||||
throw F6ServerException("status=${response.statusCode}")
|
||||
}
|
||||
.defaultStatusHandler({ it.is4xxClientError }) { _, response ->
|
||||
throw F6ClientException("status=${response.statusCode}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
/**
|
||||
* F6 采购列表的传输模型。`degraded` 让调用方能区分"真的没数据"和"没拉到",
|
||||
* 见 11-cross-domain-collaboration.md 的局部降级约定。
|
||||
*/
|
||||
data class ProcurementList(
|
||||
val storeId: Long,
|
||||
val pendingCount: Int,
|
||||
val degraded: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun degraded(storeId: Long) = ProcurementList(storeId, pendingCount = 0, degraded = true)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.get
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Tag
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
|
||||
/**
|
||||
* 降级路径的行为测试,见 10-testing.md:**外部依赖挂掉时本服务不能跟着挂**。
|
||||
*
|
||||
* 用 WireMock 而不是 mock 掉 `RestClient`:要验的是超时、状态码映射、fallback 这条真实链路,
|
||||
* 把 RestClient mock 掉就等于把要测的东西测掉了。
|
||||
*/
|
||||
@Tag("integration")
|
||||
@SpringBootTest(
|
||||
classes = [F6TestApplication::class],
|
||||
properties = [
|
||||
"integration.clients.f6.connect-timeout-ms=300",
|
||||
"integration.clients.f6.read-timeout-ms=500",
|
||||
"integration.clients.f6.connection-request-timeout-ms=200",
|
||||
"resilience4j.retry.retry-aspect-order=3",
|
||||
"resilience4j.retry.instances.f6-api.max-attempts=2",
|
||||
"resilience4j.retry.instances.f6-api.wait-duration=10ms",
|
||||
"resilience4j.retry.instances.f6-api.retry-exceptions[0]=" +
|
||||
"org.springframework.web.client.ResourceAccessException",
|
||||
"resilience4j.retry.instances.f6-api.retry-exceptions[1]=" +
|
||||
"com.continental.retailapp.integration.f6.F6ServerException",
|
||||
"resilience4j.retry.instances.f6-api.ignore-exceptions[0]=" +
|
||||
"com.continental.retailapp.integration.f6.F6ClientException",
|
||||
"resilience4j.circuitbreaker.circuit-breaker-aspect-order=2",
|
||||
// 样本门槛调高,避免熔断在测试中途打开,把"降级"和"熔断"两件事混在一起
|
||||
"resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100",
|
||||
"resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" +
|
||||
"com.continental.retailapp.integration.f6.F6ServerException",
|
||||
"resilience4j.bulkhead.bulkhead-aspect-order=1",
|
||||
"resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20",
|
||||
"resilience4j.bulkhead.instances.f6-api.max-wait-duration=0",
|
||||
],
|
||||
)
|
||||
class F6ApiClientResilienceTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var client: F6ApiClient
|
||||
|
||||
@BeforeEach
|
||||
fun resetStubs() {
|
||||
wireMock.resetAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `F6 返回 5xx 时降级而不是抛给调用方`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withStatus(500)),
|
||||
)
|
||||
|
||||
val result = client.fetchProcurementList(storeId = 100L)
|
||||
|
||||
assertTrue(result.degraded)
|
||||
assertEquals(100L, result.storeId)
|
||||
assertEquals(0, result.pendingCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `F6 响应超时时降级`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withStatus(200).withFixedDelay(2_000)),
|
||||
)
|
||||
|
||||
val result = client.fetchProcurementList(storeId = 100L)
|
||||
|
||||
// read-timeout 500ms < 固定延迟 2000ms,超时由 HTTP 客户端本身兜住,
|
||||
// 这也是不需要 @TimeLimiter 的原因(见 05-integration-layer.md)
|
||||
assertTrue(result.degraded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `F6 正常返回时不降级`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(
|
||||
aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("""{"storeId":100,"pendingCount":7}"""),
|
||||
),
|
||||
)
|
||||
|
||||
val result = client.fetchProcurementList(storeId = 100L)
|
||||
|
||||
assertFalse(result.degraded)
|
||||
assertEquals(7, result.pendingCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `4xx 不重试`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withStatus(400)),
|
||||
)
|
||||
|
||||
val result = client.fetchProcurementList(storeId = 100L)
|
||||
|
||||
assertTrue(result.degraded)
|
||||
// 参数错了再打一次还是错,重试只会放大下游压力:F6ClientException 在 ignore-exceptions 里
|
||||
wireMock.verify(1, getRequestedFor(urlPathEqualTo("/f6/procurement/list")))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort())
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun registerBaseUrl(registry: DynamicPropertyRegistry) {
|
||||
if (!wireMock.isRunning) wireMock.start()
|
||||
registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@AfterAll
|
||||
fun stopWireMock() {
|
||||
if (wireMock.isRunning) wireMock.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
|
||||
/**
|
||||
* 只给 f6-adapter 的集成测试用的最小上下文。
|
||||
*
|
||||
* 扫描范围刻意收窄到 f6-adapter + platform-integration 两个包:
|
||||
* 真实的 `BootstrapApplication` 会把 JPA、Security、Flyway 全拉起来,
|
||||
* 而这里要测的只是 RestClient 加三层韧性注解之后的行为。
|
||||
*/
|
||||
@SpringBootApplication(
|
||||
scanBasePackages = [
|
||||
"com.continental.retailapp.integration.f6",
|
||||
"com.continental.retailapp.platform.integration",
|
||||
],
|
||||
)
|
||||
class F6TestApplication
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.continental.retailapp.integration.f6
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.get
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Tag
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
|
||||
/**
|
||||
* 三个韧性注解的叠加顺序是**配出来的**,不是写注解的先后顺序决定的,
|
||||
* 而且方向极容易记反——所以 10-testing.md 要求实测它。
|
||||
*
|
||||
* 当前配置是 bulkhead(1) < circuitbreaker(2) < retry(3),即 Retry 在最外层:
|
||||
*
|
||||
* ```
|
||||
* Retry( CircuitBreaker( Bulkhead( 真实调用 )))
|
||||
* ```
|
||||
*
|
||||
* 可观察的差别就是本测试断言的那一条:**每一次重试都会被熔断器单独记一笔失败**。
|
||||
* 如果哪天有人把 order 改反了(Retry 在熔断器内层),一次业务调用只会给熔断器留下 1 笔失败,
|
||||
* 连续故障时熔断打开会慢得多——这条测试会立刻变红。
|
||||
*/
|
||||
@Tag("integration")
|
||||
@SpringBootTest(
|
||||
classes = [F6TestApplication::class],
|
||||
properties = [
|
||||
"integration.clients.f6.connect-timeout-ms=300",
|
||||
"integration.clients.f6.read-timeout-ms=500",
|
||||
"integration.clients.f6.connection-request-timeout-ms=200",
|
||||
"resilience4j.retry.retry-aspect-order=3",
|
||||
"resilience4j.retry.instances.f6-api.max-attempts=2",
|
||||
"resilience4j.retry.instances.f6-api.wait-duration=10ms",
|
||||
"resilience4j.retry.instances.f6-api.retry-exceptions[0]=" +
|
||||
"com.continental.retailapp.integration.f6.F6ServerException",
|
||||
"resilience4j.circuitbreaker.circuit-breaker-aspect-order=2",
|
||||
"resilience4j.circuitbreaker.instances.f6-api.sliding-window-type=COUNT_BASED",
|
||||
"resilience4j.circuitbreaker.instances.f6-api.sliding-window-size=20",
|
||||
// 门槛抬高到测试内不可能触发,否则熔断打开后调用不再落到 WireMock,计数就没意义了
|
||||
"resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100",
|
||||
"resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" +
|
||||
"com.continental.retailapp.integration.f6.F6ServerException",
|
||||
"resilience4j.bulkhead.bulkhead-aspect-order=1",
|
||||
"resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20",
|
||||
"resilience4j.bulkhead.instances.f6-api.max-wait-duration=0",
|
||||
],
|
||||
)
|
||||
class ResilienceAspectOrderTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var client: F6ApiClient
|
||||
|
||||
@Autowired
|
||||
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
||||
|
||||
@BeforeEach
|
||||
fun reset() {
|
||||
wireMock.resetAll()
|
||||
circuitBreakerRegistry.circuitBreaker("f6-api").reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Retry 在熔断器外层,每次重试都被单独统计`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withStatus(500)),
|
||||
)
|
||||
|
||||
client.fetchProcurementList(storeId = 100L)
|
||||
|
||||
// max-attempts=2 → 一次业务调用打了 2 个 HTTP 请求
|
||||
wireMock.verify(2, getRequestedFor(urlPathEqualTo("/f6/procurement/list")))
|
||||
|
||||
// 关键断言:熔断器看到的是 2 笔失败而不是 1 笔。
|
||||
// Retry 若在熔断器内层,这里会是 1。
|
||||
val metrics = circuitBreakerRegistry.circuitBreaker("f6-api").metrics
|
||||
assertEquals(2, metrics.numberOfFailedCalls)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort())
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun registerBaseUrl(registry: DynamicPropertyRegistry) {
|
||||
if (!wireMock.isRunning) wireMock.start()
|
||||
registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@AfterAll
|
||||
fun stopWireMock() {
|
||||
if (wireMock.isRunning) wireMock.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user