Skip to content
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

Feature/doubleb 2 #207

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docker/.DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions docker/testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Execute

- `docker compose up --scale worker={worker_number}`
14 changes: 14 additions & 0 deletions docker/testing/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
master:
image: locustio/locust
ports:
- "8089:8089"
volumes:
- ./:/mnt/locust
command: -f /mnt/locust/locust-boot.py --master -H http://host.docker.internal:8080

worker:
image: locustio/locust
volumes:
- ./:/mnt/locust
command: -f /mnt/locust/locust-boot.py --worker --master-host master
20 changes: 20 additions & 0 deletions docker/testing/locust-boot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from locust import HttpUser, task, between
import uuid

class APIUser(HttpUser):
wait_time = between(1, 3) # 각 태스크 사이의 대기 시간을 1~3초로 설정

# @task
# def test_entity_random_endpoint(self):
# # UUID 생성
# uid = str(uuid.uuid4())

# # API 엔드포인트에 POST 요청 보내기
# self.client.post(f"/api/v1/test/entity/{uid}")
@task
def test_entity_fix_endpoint(self):
# 고정 UUID
uid = "7afcc705-8ce8-4bde-9904-9cca8e57a1a1"

# API 엔드포인트에 POST 요청 보내기
self.client.post(f"/api/v1/test/entity/{uid}")
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test-runtime = ["junit"]
bootstarp = ["spring-boot-starter-web", "spring-boot-starter-actuator", "opentelemetry-starter"]
bootstarp-runtime = ["micrometer-prometheus"]
domain-application = ["spring-boot-docs", "spring-transaction"]
adaptor-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation"]
adaptor-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation", "micrometer-prometheus"]
adaptor-persistence-postgresql = ["spring-boot-starter-jpa", "postgresql"]
adaptor-storage = ["spring-web", "aws-sdk-s3", "jaxb-api", "jaxb-runtime"]
adaptor-cache-redis = ["spring-boot-starter-cache", "spring-boot-starter-redis"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum class ExceptionCode(
VOTE_PLACE_ID_INVALID(400, "투표 항목 데이터(Place Id)이 올바르지 않습니다."),
NOT_SUPPORT_AUTO_COMPLETE_URL(400, "자동입력 지원을 하지 않는 주소형식 입니다."),
DUPLICATED_REQUEST(400, "같은 리소스에 대한 중복요청입니다."),
REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW(400, "타임 윈도우 내 한정된 요청 수 초과"),

UNAUTHORIZED(401, "인증된 토큰으로부터의 요청이 아닙니다."),
ROOM_PASSWORD_INVALID(401, "방 패스워드가 틀립니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.piikii.common.exception

class ServiceException : RuntimeException {
var statusCode: Int
var defaultMessage: String
var detailMessage: String? = null

constructor(
exceptionCode: ExceptionCode,
detailMessage: String,
) : super("[$exceptionCode] $detailMessage") {
this.statusCode = exceptionCode.statusCode
this.defaultMessage = exceptionCode.defaultMessage
this.detailMessage = detailMessage
}

constructor(exceptionCode: ExceptionCode) :
super("[$exceptionCode] ${exceptionCode.defaultMessage}") {
this.statusCode = exceptionCode.statusCode
this.defaultMessage = exceptionCode.defaultMessage
}
}
1 change: 1 addition & 0 deletions piikii-input-http/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ plugins {

dependencies {
implementation(project(":piikii-application"))
implementation(project(":piikii-output-cache:redis"))
implementation(libs.bundles.adaptor.input.http)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.ServiceException
import com.piikii.output.redis.RedisLockRepository
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LimitConcurrentRequest(
val key: String,
val maxRequestCount: Long = 1,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class LimitConcurrentRequestAspect(
private val redisLockRepository: RedisLockRepository,
) {
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()

@Around("@annotation(RateLimiter)")
fun limitConcurrentRequest(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(LimitConcurrentRequest::class.java)

val key = "req_concurrent_limit:${method.name}"
val epochSecond = Instant.now().epochSecond.toDouble()

val member = generateMember(
memberSuffix = epochSecond.toString(),
joinPoint = joinPoint,
signature = signature,
annotation = annotation
)

val ttl = Duration.of(annotation.amount, annotation.timeUnit)
redisLockRepository.removeOutOfWindow(key, epochSecond, ttl)
if (redisLockRepository.getCountRequestsInWindow(key) >= annotation.maxRequestCount) {
throw ServiceException(ExceptionCode.REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW)
}

redisLockRepository.increaseRequestsInWindow(key, member, epochSecond, ttl)
return joinPoint.proceed()
}

/**
* 요청 인식용 Member parsing
* - using SpEL parsed value
*/
private fun generateMember(
memberSuffix: String,
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: LimitConcurrentRequest,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val member = expression.getValue(context, String::class.java)
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"LimitConcurrentRequest member 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "$member:$memberSuffix"
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.PiikiiException
import com.piikii.common.exception.ServiceException
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
Expand All @@ -10,6 +10,7 @@ import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.temporal.ChronoUnit
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
Expand All @@ -19,18 +20,20 @@ import java.util.concurrent.TimeoutException
* 중복요청 방지 제어 Annotation
*
* @property key key to determine duplicate requests (support SpEL)
* @property timeoutMillis default 7 seconds
* @property timeUnit time unit of timeout
* @property amount time amount of timeout
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PreventDuplicateRequest(
annotation class PreventDuplicateAspectWithConcurrent(
val key: String,
val timeoutMillis: Long = 7_000,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class PreventDuplicateAspect {
class PreventDuplicateAspectWithConcurrentAspect {
private val processingRequests = ConcurrentHashMap.newKeySet<String>()
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()
Expand All @@ -45,15 +48,15 @@ class PreventDuplicateAspect {

if (!processingRequests.add(key)) {
// if exists, throw Duplicated Request Exception
throw PiikiiException(ExceptionCode.DUPLICATED_REQUEST)
throw ServiceException(ExceptionCode.DUPLICATED_REQUEST)
}

val future = executor.submit<Any> { joinPoint.proceed() }
try {
return future.get(annotation.timeoutMillis, TimeUnit.MILLISECONDS)
return future.get(annotation.amount, TimeUnit.of(annotation.timeUnit))
} catch (e: TimeoutException) {
future.cancel(true)
throw PiikiiException(ExceptionCode.REQUEST_TIMEOUT)
throw ServiceException(ExceptionCode.REQUEST_TIMEOUT)
} finally {
processingRequests.remove(key)
}
Expand Down Expand Up @@ -84,10 +87,10 @@ class PreventDuplicateAspect {
)
val parsedValue =
expression.getValue(context, String::class.java)
?: throw PiikiiException(
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "${method.name}_$parsedValue"
return "${method.name}:$parsedValue"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.ServiceException
import com.piikii.output.redis.RedisLockRepository
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.temporal.ChronoUnit

/**
* 중복요청 방지 제어 Annotation
*
* @property key key to determine duplicate requests (support SpEL)
* @property timeUnit time unit of timeout
* @property amount time amount of timeout
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PreventDuplicateRequest(
val key: String,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class PreventDuplicateAspect(
private val redisLockRepository: RedisLockRepository,
) {
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()

@Around("@annotation(PreventDuplicateRequest)")
fun preventDuplicateRequest(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(PreventDuplicateRequest::class.java)
val key = generateKey(joinPoint, signature, annotation)

if (!redisLockRepository.lock(key, Duration.of(annotation.amount, annotation.timeUnit))) {
// if exists, throw Duplicated Request Exception
throw ServiceException(ExceptionCode.DUPLICATED_REQUEST)
}

try {
return joinPoint.proceed()
} finally {
redisLockRepository.unlock(key)
}
}

/**
* 중복 요청으로 판단할 key 생성
* - req_duplicate + method name + SpEL parsed value
*
* @param joinPoint
* @param signature
* @param annotation
* @return Key to determine duplicate requests
*/
private fun generateKey(
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: PreventDuplicateRequest,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val parsedValue =
expression.getValue(context, String::class.java)
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"PreventDuplicateRequest key 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "req_duplicate:${method.name}:$parsedValue"
}
}
Loading
Loading