mirror of
https://github.moeyy.xyz/https://github.com/GeWuYou/forgeboot
synced 2025-10-27 12:52:07 +08:00
Compare commits
6 Commits
401b9cf065
...
2c6a721176
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6a721176 | |||
| 40f58efd82 | |||
| d71262b550 | |||
| be03204a73 | |||
| 2880eafc03 | |||
| e92a53b194 |
@ -6,55 +6,100 @@ stages:
|
||||
|
||||
variables:
|
||||
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
|
||||
GIT_STRATEGY: fetch
|
||||
GIT_DEPTH: "0"
|
||||
|
||||
before_script:
|
||||
- rm -rf $GRADLE_USER_HOME/.tmp || true
|
||||
- rm -rf "$GRADLE_USER_HOME/.tmp" || true
|
||||
|
||||
# -------- 复用片段(anchors) ------------------------------------
|
||||
|
||||
# 统一镜像:alpine/git + 清空 ENTRYPOINT,避免 "git sh" 问题
|
||||
.alpine_git_image: &alpine_git_image
|
||||
image:
|
||||
name: alpine/git:latest
|
||||
entrypoint: [ "" ]
|
||||
|
||||
# 统一 Git 身份配置
|
||||
.git_identity: &git_identity
|
||||
- git config --global user.email "pipeline@${GITLAB_URL}"
|
||||
- git config --global user.name "Project Pipeline Bot"
|
||||
|
||||
# 统一远端地址变量(GitLab & GitHub)
|
||||
.remotes: &remotes
|
||||
- export GL_REPO_URL="https://oauth2:${PIPELINE_BOT_TOKEN}@${GITLAB_URL}/${CI_PROJECT_PATH}.git"
|
||||
- export GH_REPO_URL="https://x-access-token:${GITHUB_PUSH_TOKEN}@github.com/GeWuYou/forgeboot.git"
|
||||
|
||||
# tag 任务常用准备:清理本地 tag、拉取远端 tag 和 main
|
||||
.tag_prepare: &tag_prepare
|
||||
- echo "🧹 删除所有本地 tag..."
|
||||
- git tag -l | xargs -r git tag -d || true
|
||||
- echo "🔍 拉取远程 tag 和 main"
|
||||
- git fetch --tags --force --prune
|
||||
- git fetch origin main --force
|
||||
- MAIN_COMMIT=$(git rev-parse origin/main)
|
||||
- echo "🔗 当前 main commit -> $MAIN_COMMIT"
|
||||
|
||||
# 🏷️ 自动打标签
|
||||
# tag job: 在main分支提交时自动创建并推送新的语义化版本标签
|
||||
# 参数:
|
||||
# stage: 指定该job属于tag阶段
|
||||
# image: 指定使用alpine/git镜像,并清空entrypoint避免git sh问题
|
||||
# rules: 定义触发规则,仅当提交到main分支且提交信息不包含"ci"(忽略大小写)时触发
|
||||
# script: 执行标签创建和推送的具体脚本
|
||||
# tags: 指定运行该job的runner标签为java
|
||||
tag:
|
||||
stage: tag
|
||||
image: alpine:latest
|
||||
<<: *alpine_git_image
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE !~ /ci/i'
|
||||
tags: [ java ]
|
||||
before_script:
|
||||
- *git_identity
|
||||
- *remotes
|
||||
- *tag_prepare
|
||||
script:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache git
|
||||
- git config --global user.email "pipeline@${GITLAB_URL}"
|
||||
- git config --global user.name "Project Pipeline Bot"
|
||||
- git fetch --tags
|
||||
- git fetch origin main
|
||||
- MAIN_COMMIT=$(git rev-parse origin/main)
|
||||
- echo "🔗 main commit -> $MAIN_COMMIT"
|
||||
|
||||
- echo "📦 获取最新 tag..."
|
||||
# 使用 git 自带排序(无需 coreutils 的 sort -V)
|
||||
- LATEST_TAG=$(git tag --list '*' --sort=-v:refname | head -n1 || true)
|
||||
- if [ -z "$LATEST_TAG" ]; then LATEST_TAG="0.0.0"; fi
|
||||
- echo "🔖 最新 tag -> $LATEST_TAG"
|
||||
|
||||
# 解析 semver(不带 v 前缀)
|
||||
- VERSION=${LATEST_TAG#v}
|
||||
- MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
- MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
- PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
|
||||
# 规则:patch +1,满 10 进位到 minor
|
||||
- PATCH=$((PATCH + 1))
|
||||
- if [ "$PATCH" -ge 10 ]; then PATCH=0; MINOR=$((MINOR+1)); echo "🔁 patch 达 10 进位:MINOR=$MINOR, PATCH=$PATCH"; fi
|
||||
- NEW_TAG="${MAJOR}.${MINOR}.${PATCH}"
|
||||
- echo "🏷️ 新 tag -> $NEW_TAG"
|
||||
|
||||
- if git tag --points-at "$MAIN_COMMIT" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' > /dev/null; then
|
||||
echo "⏭️ 已存在 tag,跳过创建";
|
||||
# 同一 commit 不重复打 tag
|
||||
- if git tag --points-at "$MAIN_COMMIT" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' >/dev/null; then
|
||||
echo "⏭️ 当前 commit 已有 semver tag,跳过";
|
||||
exit 0;
|
||||
fi
|
||||
|
||||
- git tag $NEW_TAG $MAIN_COMMIT
|
||||
- git push https://oauth2:${PIPELINE_BOT_TOKEN}@${GITLAB_URL}/${CI_PROJECT_PATH}.git $NEW_TAG
|
||||
- echo "✅ tag $NEW_TAG 已推送"
|
||||
# 添加 GitHub 远程并推送 tag
|
||||
- git remote add github https://x-access-token:${GITHUB_PUSH_TOKEN}@github.com/GeWuYou/forgeboot.git
|
||||
- git push github $NEW_TAG
|
||||
- git tag "$NEW_TAG" "$MAIN_COMMIT"
|
||||
- git push "$GL_REPO_URL" "$NEW_TAG"
|
||||
- echo "✅ tag $NEW_TAG 已推送到 GitLab"
|
||||
|
||||
- git remote add github "$GH_REPO_URL" || git remote set-url github "$GH_REPO_URL"
|
||||
- git push github "$NEW_TAG"
|
||||
- echo "✅ tag $NEW_TAG 已同步至 GitHub"
|
||||
tags:
|
||||
- java
|
||||
|
||||
# 📦 发布至 GitLab 与 GitHub Maven 仓库
|
||||
# publish job: 将构建产物发布到GitLab和GitHub的Maven仓库
|
||||
# 参数:
|
||||
# stage: 指定该job属于publish阶段
|
||||
# needs: 指定依赖tag job完成后再执行
|
||||
# rules: 定义触发规则,仅当提交到main分支且提交信息不包含"ci"(忽略大小写)时触发
|
||||
# cache: 定义缓存策略,缓存Gradle相关目录以加速构建
|
||||
# script: 执行发布到Maven仓库的具体脚本
|
||||
# tags: 指定运行该job的runner标签为java
|
||||
publish:
|
||||
stage: publish
|
||||
needs: [ "tag" ]
|
||||
@ -72,53 +117,63 @@ publish:
|
||||
- .gradle/kotlin-profile/
|
||||
- .kotlin/
|
||||
policy: pull-push
|
||||
tags: [ java ]
|
||||
script:
|
||||
- echo "🔧 授予 gradlew 执行权限..."
|
||||
- chmod +x gradlew
|
||||
- ./gradlew publishMavenJavaPublicationToGitLabRepository
|
||||
- ./gradlew publishMavenJavaPublicationToGitHubRepository --continue
|
||||
tags:
|
||||
- java
|
||||
|
||||
# 🔄 重建 test 分支
|
||||
# reset job: 基于main分支重建test分支
|
||||
# 参数:
|
||||
# stage: 指定该job属于reset阶段
|
||||
# image: 指定使用alpine/git镜像,并清空entrypoint避免git sh问题
|
||||
# rules: 定义触发规则,仅当提交到main分支时触发
|
||||
# script: 执行重建test分支的具体脚本
|
||||
# tags: 指定运行该job的runner标签为java
|
||||
reset:
|
||||
stage: reset
|
||||
image: alpine:latest
|
||||
<<: *alpine_git_image
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
tags: [ java ]
|
||||
before_script:
|
||||
- *git_identity
|
||||
- *remotes
|
||||
script:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache git
|
||||
- git config --global user.email "pipeline@${GITLAB_URL}"
|
||||
- git config --global user.name "Project Pipeline Bot"
|
||||
- git clone --branch main https://oauth2:${PIPELINE_BOT_TOKEN}@${GITLAB_URL}/${CI_PROJECT_PATH}.git repo
|
||||
- echo "🔄 重建 test 分支..."
|
||||
- git clone --branch main "$GL_REPO_URL" repo
|
||||
- cd repo
|
||||
- git checkout -B test
|
||||
- git push origin test --force
|
||||
- echo "✅ test 分支已重建完成"
|
||||
tags:
|
||||
- java
|
||||
# Mirror to GitHub
|
||||
|
||||
# 🔁 同步到 GitHub
|
||||
# mirror-to-github job: 将GitLab的main分支同步到GitHub
|
||||
# 参数:
|
||||
# stage: 指定该job属于mirror阶段
|
||||
# image: 指定使用alpine/git镜像,并清空entrypoint避免git sh问题
|
||||
# rules: 定义触发规则,仅当提交到main分支时触发
|
||||
# script: 执行同步到GitHub的具体脚本
|
||||
# tags: 指定运行该job的runner标签为java
|
||||
mirror-to-github:
|
||||
stage: mirror
|
||||
image: alpine:latest
|
||||
<<: *alpine_git_image
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
tags: [ java ]
|
||||
before_script:
|
||||
- *git_identity
|
||||
- *remotes
|
||||
script:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache git openssh
|
||||
- git config --global user.name "Project Pipeline Bot"
|
||||
- git config --global user.email "pipeline@${GITLAB_URL}"
|
||||
|
||||
- echo "🔄 正在 clone 当前 GitLab 仓库..."
|
||||
- git clone --branch main https://oauth2:${PIPELINE_BOT_TOKEN}@${GITLAB_URL}/${CI_PROJECT_PATH}.git repo
|
||||
- echo "🔄 clone GitLab 仓库..."
|
||||
- git clone --branch main "$GL_REPO_URL" repo
|
||||
- cd repo
|
||||
|
||||
- echo "🔗 添加 GitHub 远程地址..."
|
||||
- git remote add github https://x-access-token:${GITHUB_PUSH_TOKEN}@github.com/GeWuYou/forgeboot.git
|
||||
|
||||
- echo "🚀 推送 main 分支到 GitHub..."
|
||||
- echo "🔗 添加/更新 GitHub 远程..."
|
||||
- git remote add github "$GH_REPO_URL" || git remote set-url github "$GH_REPO_URL"
|
||||
- echo "🚀 推送 main 到 GitHub..."
|
||||
- git push github main --force
|
||||
- echo "✅ GitHub 同步完成"
|
||||
tags:
|
||||
- java
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
// This file is used to define the repositories used by the project.
|
||||
repositories {
|
||||
mavenLocal()
|
||||
val host = System.getenv("GITEA_HOST")
|
||||
// host?.let {
|
||||
// maven{
|
||||
// url = uri("${host}/api/packages/gewuyou/maven")
|
||||
// }
|
||||
// }
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/public/")
|
||||
content {
|
||||
excludeModule("io.ktor", "ktor-client-mock")
|
||||
excludeModule("io.ktor", "ktor-client-mock-jvm") // 如果你之前用的是 jvm 变体,也一并排除
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/spring/")
|
||||
|
||||
3
forgeboot-http/.gitattributes
vendored
Normal file
3
forgeboot-http/.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
40
forgeboot-http/.gitignore
vendored
Normal file
40
forgeboot-http/.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Kotlin ###
|
||||
.kotlin
|
||||
4
forgeboot-http/build.gradle.kts
Normal file
4
forgeboot-http/build.gradle.kts
Normal file
@ -0,0 +1,4 @@
|
||||
dependencies {
|
||||
|
||||
}
|
||||
|
||||
3
forgeboot-http/forgeboot-http-ktor/.gitattributes
vendored
Normal file
3
forgeboot-http/forgeboot-http-ktor/.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
40
forgeboot-http/forgeboot-http-ktor/.gitignore
vendored
Normal file
40
forgeboot-http/forgeboot-http-ktor/.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Kotlin ###
|
||||
.kotlin
|
||||
14
forgeboot-http/forgeboot-http-ktor/build.gradle.kts
Normal file
14
forgeboot-http/forgeboot-http-ktor/build.gradle.kts
Normal file
@ -0,0 +1,14 @@
|
||||
dependencies {
|
||||
api(libs.io.ktor.clientCore)
|
||||
implementation(libs.io.ktor.clientContentNegotiation)
|
||||
implementation(libs.io.ktor.serializationKotlinxJson)
|
||||
implementation(libs.io.ktor.clientLogging)
|
||||
|
||||
// test
|
||||
testImplementation(libs.org.junit.jupiter.api)
|
||||
testImplementation(libs.io.ktor.clientCio)
|
||||
testImplementation(libs.io.ktor.clientMock)
|
||||
testImplementation(libs.kotlinxCorountinesTest)
|
||||
testRuntimeOnly(libs.org.junit.jupiter.engine)
|
||||
testRuntimeOnly(libs.org.junit.platform)
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.gewuyou.forgeboot.http.ktor
|
||||
|
||||
import com.gewuyou.forgeboot.http.ktor.entities.KtorHttpClientConfig
|
||||
import io.ktor.client.statement.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* 带重试机制的执行函数
|
||||
*
|
||||
* 根据配置的重试策略,对给定的代码块进行执行,如果执行失败或返回指定的状态码,
|
||||
* 则按照配置的重试策略进行重试。
|
||||
*
|
||||
* @param conf Ktor客户端配置,包含重试相关配置
|
||||
* @param block 需要执行的代码块
|
||||
* @return 执行结果
|
||||
* @throws Exception 当达到最大重试次数或不满足重试条件时抛出异常
|
||||
*/
|
||||
suspend fun <T> withRetry(conf: KtorHttpClientConfig, block: suspend () -> T): T {
|
||||
// 如果未启用重试机制,则直接执行代码块并返回结果
|
||||
if (!conf.retry.enabled) return block()
|
||||
|
||||
var attempt = 0
|
||||
var backoff = conf.retry.initialBackoffMillis.coerceAtLeast(1)
|
||||
|
||||
while (true) {
|
||||
attempt++
|
||||
try {
|
||||
val res = block()
|
||||
// 如果结果是HttpResponse且状态码在重试列表中,并且未达到最大重试次数,则进行重试
|
||||
if (res is HttpResponse && res.status.value in conf.retry.retryOnStatus && attempt < conf.retry.maxAttempts) {
|
||||
delay(jitter(backoff, conf.retry.jitterMillis)); backoff *= 2; continue
|
||||
}
|
||||
return res
|
||||
} catch (e: Exception) {
|
||||
// 如果不满足重试条件或已达到最大重试次数,则抛出异常
|
||||
if (!conf.retry.retryOnNetworkError || attempt >= conf.retry.maxAttempts) throw e
|
||||
delay(jitter(backoff, conf.retry.jitterMillis)); backoff *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加抖动的延迟计算函数
|
||||
*
|
||||
* 为避免惊群效应,在基础延迟时间上添加随机抖动
|
||||
*
|
||||
* @param base 基础延迟时间(毫秒)
|
||||
* @param j 抖动范围(毫秒)
|
||||
* @return 添加抖动后的延迟时间,最小为1毫秒
|
||||
*/
|
||||
private fun jitter(base: Long, j: Long) = (base + Random.nextLong(-j, j + 1)).coerceAtLeast(1)
|
||||
@ -0,0 +1,67 @@
|
||||
package com.gewuyou.forgeboot.http.ktor.client
|
||||
|
||||
import com.gewuyou.forgeboot.http.ktor.entities.KtorHttpClientConfig
|
||||
import com.gewuyou.forgeboot.http.ktor.withRetry
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
/**
|
||||
* 简单的HTTP客户端
|
||||
*
|
||||
* 提供了基本的HTTP操作方法,包括GET、POST、PUT和DELETE,并内置重试机制。
|
||||
* 所有请求都会根据配置的重试策略在失败时自动重试。
|
||||
*
|
||||
* @property client Ktor HTTP客户端实例
|
||||
* @property conf Ktor客户端配置,包含重试等相关配置
|
||||
* @since 2025-08-13 15:14:50
|
||||
* @author gewuyou
|
||||
*/
|
||||
class SimpleHttpClient(
|
||||
val client: HttpClient,
|
||||
val conf: KtorHttpClientConfig,
|
||||
) {
|
||||
/**
|
||||
* 执行GET请求
|
||||
*
|
||||
* @param path 请求路径
|
||||
* @return 响应结果,类型为R
|
||||
*/
|
||||
suspend inline fun <reified R> get(path: String): R =
|
||||
withRetry(conf) { client.get(path) }.body()
|
||||
|
||||
/**
|
||||
* 执行POST请求
|
||||
*
|
||||
* @param path 请求路径
|
||||
* @param body 请求体数据
|
||||
* @return 响应结果,类型为Res
|
||||
*/
|
||||
suspend inline fun <reified Req : Any, reified Res> post(path: String, body: Req): Res =
|
||||
withRetry(conf) { client.post(path) { contentType(ContentType.Application.Json); setBody(body) } }.body()
|
||||
|
||||
/**
|
||||
* 执行PUT请求
|
||||
*
|
||||
* @param path 请求路径
|
||||
* @param body 请求体数据
|
||||
* @return 响应结果,类型为Res
|
||||
*/
|
||||
suspend inline fun <reified Req : Any, reified Res> put(path: String, body: Req): Res =
|
||||
withRetry(conf) { client.put(path) { contentType(ContentType.Application.Json); setBody(body) } }.body()
|
||||
|
||||
/**
|
||||
* 执行DELETE请求
|
||||
*
|
||||
* @param path 请求路径
|
||||
* @return 响应结果,类型为R
|
||||
*/
|
||||
suspend inline fun <reified R> delete(path: String): R =
|
||||
withRetry(conf) { client.delete(path) }.body()
|
||||
|
||||
/**
|
||||
* 关闭HTTP客户端,释放资源
|
||||
*/
|
||||
fun close() = client.close()
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package com.gewuyou.forgeboot.http.ktor.entities
|
||||
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* HTTP客户端配置
|
||||
*
|
||||
* 用于配置HTTP客户端的各种参数,包括基础URL、超时设置、连接数限制、认证信息、
|
||||
* JSON处理配置、日志配置和重试策略等。
|
||||
*
|
||||
* @property baseUrl 基础URL地址,所有请求将基于此URL进行构建
|
||||
* @property connectTimeout 连接超时时间,默认5秒
|
||||
* @property requestTimeout 请求超时时间,默认30秒
|
||||
* @property socketTimeout Socket超时时间,默认30秒
|
||||
* @property maxConnections 最大连接数,默认100
|
||||
* @property auth 认证配置,支持无认证、API Key、Bearer Token和Basic认证
|
||||
* @property json JSON序列化配置
|
||||
* @property logging 日志配置
|
||||
* @property retry 重试策略配置
|
||||
* @since 2025-08-13 14:54:44
|
||||
* @author gewuyou
|
||||
*/
|
||||
data class KtorHttpClientConfig(
|
||||
val baseUrl: String? = null,
|
||||
val connectTimeout: Duration = Duration.ofSeconds(5),
|
||||
val requestTimeout: Duration = Duration.ofSeconds(30),
|
||||
val socketTimeout: Duration = Duration.ofSeconds(30),
|
||||
val maxConnections: Int = 100,
|
||||
val auth: Auth = Auth.None,
|
||||
val json: Json = Json(),
|
||||
val logging: Logging = Logging(),
|
||||
val retry: Retry = Retry(),
|
||||
) {
|
||||
/**
|
||||
* 认证接口,定义了不同类型的认证方式
|
||||
*/
|
||||
sealed interface Auth {
|
||||
/**
|
||||
* 无认证
|
||||
*/
|
||||
data object None : Auth
|
||||
|
||||
/**
|
||||
* API Key认证
|
||||
* @property header 认证头名称
|
||||
* @property value 认证值
|
||||
*/
|
||||
data class ApiKey(val header: String, val value: String) : Auth
|
||||
|
||||
/**
|
||||
* Bearer Token认证
|
||||
* @property token 认证令牌
|
||||
*/
|
||||
data class Bearer(val token: String) : Auth
|
||||
|
||||
/**
|
||||
* Basic认证
|
||||
* @property username 用户名
|
||||
* @property password 密码
|
||||
*/
|
||||
data class Basic(val username: String, val password: String) : Auth
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON配置
|
||||
*
|
||||
* @property ignoreUnknownKeys 是否忽略未知的JSON字段,默认为true
|
||||
* @property prettyPrint 是否格式化输出JSON,默认为false
|
||||
* @property explicitNulls 是否显式输出null值,默认为false
|
||||
*/
|
||||
data class Json(
|
||||
val ignoreUnknownKeys: Boolean = true,
|
||||
val prettyPrint: Boolean = false,
|
||||
val explicitNulls: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 日志配置
|
||||
*
|
||||
* @property enabled 是否启用日志,默认为true
|
||||
* @property level 日志级别,默认为"INFO"
|
||||
*/
|
||||
data class Logging(val enabled: Boolean = true, val level: String = "INFO")
|
||||
|
||||
/**
|
||||
* 重试策略配置
|
||||
*
|
||||
* @property enabled 是否启用重试机制,默认为false
|
||||
* @property maxAttempts 最大重试次数,默认为3次
|
||||
* @property initialBackoffMillis 初始退避时间(毫秒),默认为200毫秒
|
||||
* @property jitterMillis 抖动时间(毫秒),默认为50毫秒
|
||||
* @property retryOnStatus 需要重试的HTTP状态码集合,默认包含429, 500, 502, 503, 504
|
||||
* @property retryOnNetworkError 是否在网络错误时重试,默认为true
|
||||
*/
|
||||
data class Retry(
|
||||
val enabled: Boolean = false,
|
||||
val maxAttempts: Int = 3,
|
||||
val initialBackoffMillis: Long = 200,
|
||||
val jitterMillis: Long = 50,
|
||||
val retryOnStatus: Set<Int> = setOf(429, 500, 502, 503, 504),
|
||||
val retryOnNetworkError: Boolean = true,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package com.gewuyou.forgeboot.http.ktor.factory
|
||||
|
||||
import com.gewuyou.forgeboot.http.ktor.entities.KtorHttpClientConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
/**
|
||||
* HTTP客户工厂
|
||||
*
|
||||
* 用于创建和配置Ktor HTTP客户端的工厂对象。提供了多种创建方式,
|
||||
* 支持使用已实例化的引擎或引擎工厂来创建客户端,并统一配置插件和默认请求设置。
|
||||
*
|
||||
* @since 2025-08-13 14:56:45
|
||||
* @author gewuyou
|
||||
*/
|
||||
object HttpClientFactory {
|
||||
|
||||
/**
|
||||
* 使用已实例化的引擎创建HTTP客户端
|
||||
*
|
||||
* 由调用方完全掌控引擎的配置与生命周期
|
||||
*
|
||||
* @param engine 已实例化的HTTP客户端引擎
|
||||
* @param conf Ktor客户端配置
|
||||
* @return 配置好的HttpClient实例
|
||||
*/
|
||||
fun create(engine: HttpClientEngine, conf: KtorHttpClientConfig): HttpClient =
|
||||
build(HttpClient(engine), conf)
|
||||
|
||||
/**
|
||||
* 使用引擎工厂创建HTTP客户端
|
||||
*
|
||||
* 由Ktor负责创建和管理引擎,适用于简单场景
|
||||
*
|
||||
* @param engineFactory HTTP客户端引擎工厂
|
||||
* @param conf Ktor客户端配置
|
||||
* @param configureEngine 引擎配置函数,可选
|
||||
* @return 配置好的HttpClient实例
|
||||
*/
|
||||
fun <TConfig : HttpClientEngineConfig> create(
|
||||
engineFactory: HttpClientEngineFactory<TConfig>,
|
||||
conf: KtorHttpClientConfig,
|
||||
configureEngine: (TConfig.() -> Unit)? = null,
|
||||
): HttpClient = build(HttpClient(engineFactory) {
|
||||
configureEngine?.let { engine(it) }
|
||||
}, conf)
|
||||
|
||||
/**
|
||||
* 统一安装插件和配置默认请求
|
||||
*
|
||||
* 配置内容包括内容协商、日志记录、认证头、默认内容类型和基础URL等
|
||||
*
|
||||
* @param client HTTP客户端实例
|
||||
* @param conf Ktor客户端配置
|
||||
* @return 配置好的HttpClient实例
|
||||
*/
|
||||
private fun build(client: HttpClient, conf: KtorHttpClientConfig): HttpClient = client.config {
|
||||
// 安装内容协商插件并配置JSON序列化
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = conf.json.ignoreUnknownKeys
|
||||
prettyPrint = conf.json.prettyPrint
|
||||
explicitNulls = conf.json.explicitNulls
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 根据配置决定是否安装日志插件
|
||||
if (conf.logging.enabled) {
|
||||
install(Logging) {
|
||||
logger = Logger.SIMPLE
|
||||
level = when (conf.logging.level.uppercase()) {
|
||||
"NONE" -> LogLevel.NONE
|
||||
"HEADERS" -> LogLevel.HEADERS
|
||||
"BODY" -> LogLevel.BODY
|
||||
else -> LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配置默认请求设置
|
||||
defaultRequest {
|
||||
// 根据认证配置添加相应的认证头
|
||||
when (val a = conf.auth) {
|
||||
is KtorHttpClientConfig.Auth.ApiKey -> headers.append(a.header, a.value)
|
||||
is KtorHttpClientConfig.Auth.Bearer -> headers.append("Authorization", "Bearer ${a.token}")
|
||||
is KtorHttpClientConfig.Auth.Basic -> {
|
||||
val raw = "${a.username}:${a.password}"
|
||||
val basic = java.util.Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||
headers.append("Authorization", "Basic $basic")
|
||||
}
|
||||
|
||||
KtorHttpClientConfig.Auth.None -> {
|
||||
// not required
|
||||
}
|
||||
}
|
||||
// 设置默认内容类型为JSON
|
||||
if (contentType() == null) contentType(ContentType.Application.Json)
|
||||
// 设置基础URL(当使用相对路径时生效)
|
||||
conf.baseUrl?.let { url(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package com.gewuyou.forgeboot.http.ktor.client
|
||||
|
||||
import com.gewuyou.forgeboot.http.ktor.entities.KtorHttpClientConfig
|
||||
import com.gewuyou.forgeboot.http.ktor.factory.HttpClientFactory
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
*HttpClientFactoryTest
|
||||
*
|
||||
* @since 2025-08-13 15:45:50
|
||||
* @author gewuyou
|
||||
*/
|
||||
class HttpClientFactoryTest {
|
||||
@Test
|
||||
fun `factory should apply baseUrl, auth header and default content-type`() = runTest {
|
||||
var capturedUrl: String? = null
|
||||
var capturedAuth: String? = null
|
||||
var capturedContentType: String? = null
|
||||
|
||||
val engine = MockEngine { request ->
|
||||
capturedUrl = request.url.toString()
|
||||
capturedAuth = request.headers[HttpHeaders.Authorization]
|
||||
capturedContentType = request.headers[HttpHeaders.ContentType] // 来自 defaultRequest 的默认 JSON
|
||||
respond(
|
||||
content = """{"ok":true}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
|
||||
val conf = KtorHttpClientConfig(
|
||||
baseUrl = "https://example.com",
|
||||
auth = KtorHttpClientConfig.Auth.Bearer("TOKEN-XYZ"),
|
||||
// 打开日志不会影响请求,但要保证装配不报错
|
||||
logging = KtorHttpClientConfig.Logging(enabled = true, level = "INFO")
|
||||
)
|
||||
|
||||
val client: HttpClient = HttpClientFactory.create(engine, conf)
|
||||
|
||||
// 不通过 SimpleHttpClient,直接用 HttpClient 也应该生效
|
||||
client.get("/echo")
|
||||
|
||||
assertEquals("https://example.com/echo", capturedUrl)
|
||||
assertEquals("Bearer TOKEN-XYZ", capturedAuth)
|
||||
assertEquals(ContentType.Application.Json.toString(), capturedContentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `factory create with engineFactory should accept engine configure lambda`() = runTest {
|
||||
// 这里用 MockEngineFactory + MockEngineConfig 验证“引擎工厂”重载能正常工作
|
||||
val engineFactory = MockEngine
|
||||
var seen = false
|
||||
|
||||
val conf = KtorHttpClientConfig(baseUrl = "https://e.com")
|
||||
|
||||
val client = HttpClientFactory.create(engineFactory, conf) {
|
||||
seen = true
|
||||
addHandler { request ->
|
||||
// 命中即证明客户端按 baseUrl 拼接了路径
|
||||
if (request.url.toString() == "https://e.com/ping") {
|
||||
respond(
|
||||
content = """{"ok":true}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
} else {
|
||||
respond(
|
||||
content = """{"ok":false}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发起请求,触发我们在 configureEngine 中注册的 handler
|
||||
client.get("/ping")
|
||||
|
||||
assertTrue(seen, "engine configure lambda should be executed")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package com.gewuyou.forgeboot.http.ktor.client
|
||||
|
||||
import com.gewuyou.forgeboot.http.ktor.entities.KtorHttpClientConfig
|
||||
import com.gewuyou.forgeboot.http.ktor.factory.HttpClientFactory
|
||||
import com.gewuyou.forgeboot.http.ktor.withRetry
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
||||
class SimpleHttpClientTest {
|
||||
@Test
|
||||
fun `GET should retry on 503 and succeed on second attempt`() = runTest {
|
||||
var attempts = 0
|
||||
|
||||
val engine = MockEngine { request ->
|
||||
attempts++
|
||||
if (request.url.fullPath == "/v1/ping") {
|
||||
if (attempts == 1) {
|
||||
respondError(HttpStatusCode.ServiceUnavailable)
|
||||
} else {
|
||||
respond(
|
||||
content = """{"ok":true}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
respondError(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
val conf = KtorHttpClientConfig(
|
||||
baseUrl = "https://api.example.com",
|
||||
retry = KtorHttpClientConfig.Retry(
|
||||
enabled = true, maxAttempts = 3, initialBackoffMillis = 1, jitterMillis = 0
|
||||
)
|
||||
)
|
||||
|
||||
val client = HttpClientFactory.create(engine, conf)
|
||||
val http = SimpleHttpClient(client, conf)
|
||||
|
||||
val res: Map<String, Boolean> = http.get("/v1/ping")
|
||||
|
||||
assertTrue(res["ok"] == true, "should parse JSON body to Map and get ok=true")
|
||||
assertEquals(2, attempts, "should retry exactly once (503 -> 200)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POST should send JSON and parse response`() = runTest {
|
||||
val engine = MockEngine { request ->
|
||||
val bodyCt = request.body.contentType
|
||||
assertEquals(ContentType.Application.Json, bodyCt?.withoutParameters())
|
||||
assertEquals("https://api.example.com/v1/echo", request.url.toString())
|
||||
|
||||
respond(
|
||||
content = """{"echo":true}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
|
||||
val conf = KtorHttpClientConfig(baseUrl = "https://api.example.com")
|
||||
val client = HttpClientFactory.create(engine, conf)
|
||||
|
||||
val res: Map<String, Boolean> = withRetry(conf) {
|
||||
client.post("/v1/echo") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"x":1}""")
|
||||
}
|
||||
}.body()
|
||||
|
||||
assertTrue(res["echo"] == true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -18,11 +18,14 @@ redisson-version = "3.50.0"
|
||||
org-pf4j-version = "3.13.0"
|
||||
org-pf4j-spring-version = "0.10.0"
|
||||
org-yaml-snakeyaml-version = "2.4"
|
||||
jimmer-version = "0.9.101"
|
||||
jimmer-version = "0.9.105"
|
||||
ktor-version = "3.2.3"
|
||||
junit-jupiter-version = "5.13.4"
|
||||
[libraries]
|
||||
org-babyfish-jimmer-springBootStarter = { group = "org.babyfish.jimmer", name = "jimmer-spring-boot-starter", version.ref = "jimmer-version" }
|
||||
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-version" }
|
||||
kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime-version" }
|
||||
kotlinxCorountinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
|
||||
kotlinxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJSON-version" }
|
||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-version" }
|
||||
#kotlinxCoroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines-version" }
|
||||
@ -39,6 +42,7 @@ springBootStarter-webflux = { group = "org.springframework.boot", name = "spring
|
||||
springBootStarter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
|
||||
springBootStarter-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" }
|
||||
springBootStarter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis" }
|
||||
springBootStarter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" }
|
||||
|
||||
redisson-springBootStarter = { group = "org.redisson", name = "redisson-spring-boot-starter", version.ref = "redisson-version" }
|
||||
springBoot-configuration-processor = { group = "org.springframework.boot", name = "spring-boot-configuration-processor", version.ref = "spring-boot-version" }
|
||||
@ -57,6 +61,9 @@ jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackso
|
||||
|
||||
reactor-core = { group = "io.projectreactor", name = "reactor-core" }
|
||||
#org
|
||||
org-junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit-jupiter-version" }
|
||||
org-junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit-jupiter-version" }
|
||||
org-junit-platform = { group = "org.junit.platform", name = "junit-platform-launcher", version = "1.13.4" }
|
||||
org-yaml-snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "org-yaml-snakeyaml-version" }
|
||||
org-pf4j = { group = "org.pf4j", name = "pf4j", version.ref = "org-pf4j-version" }
|
||||
org-pf4jSpring = { group = "org.pf4j", name = "pf4j-spring", version.ref = "org-pf4j-spring-version" }
|
||||
@ -68,6 +75,12 @@ jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwt-ve
|
||||
# com
|
||||
com-github-benManes-caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine-version" }
|
||||
# io
|
||||
io-ktor-clientCore = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor-version" }
|
||||
io-ktor-clientContentNegotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor-version" }
|
||||
io-ktor-serializationKotlinxJson = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor-version" }
|
||||
io-ktor-clientLogging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor-version" }
|
||||
io-ktor-clientCio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor-version" }
|
||||
io-ktor-clientMock = { group = "io.ktor", name = "ktor-client-mock-jvm", version.ref = "ktor-version" }
|
||||
|
||||
[bundles]
|
||||
kotlinxEcosystem = ["kotlinxDatetime", "kotlinxSerialization", "kotlinxCoroutines-core"]
|
||||
|
||||
@ -154,6 +154,7 @@ include(
|
||||
":forgeboot-demo:forgeboot-plugin-demo:forgeboot-plugin-demo-server",
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region module plugin
|
||||
include(
|
||||
"forgeboot-plugin",
|
||||
@ -164,7 +165,8 @@ project(":forgeboot-plugin").name = "forgeboot-plugin-spring-boot-starter"
|
||||
project(":forgeboot-plugin:forgeboot-plugin-core").name = "forgeboot-plugin-core"
|
||||
project(":forgeboot-plugin:forgeboot-plugin-spring").name = "forgeboot-plugin-spring"
|
||||
//endregion
|
||||
//region module cache
|
||||
|
||||
//region module cache
|
||||
include(
|
||||
"forgeboot-cache",
|
||||
":forgeboot-cache:forgeboot-cache-api",
|
||||
@ -176,3 +178,12 @@ project(":forgeboot-cache:forgeboot-cache-api").name = "forgeboot-cache-api"
|
||||
project(":forgeboot-cache:forgeboot-cache-impl").name = "forgeboot-cache-impl"
|
||||
project(":forgeboot-cache:forgeboot-cache-autoconfigure").name = "forgeboot-cache-autoconfigure"
|
||||
//endregion
|
||||
|
||||
//region module http
|
||||
include(
|
||||
"forgeboot-http",
|
||||
":forgeboot-http:forgeboot-http-ktor",
|
||||
)
|
||||
project(":forgeboot-http").name = "forgeboot-http"
|
||||
project(":forgeboot-http:forgeboot-http-ktor").name = "forgeboot-http-ktor"
|
||||
//endregion
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user