2486 字
12 分钟
创建一个kotlin的springboot3的项目

前言#

看完你将学会使用kotlin创建一个springboot3的项目,成品项目,star

项目基础#

  • gradle kotlin 包管理工具
  • spring web
  • spring security 安全工具
  • spring data jpa 数据管理
  • spring validation 验证数据
  • postgresql 数据库
  • jjwt 生成token和校验token
  • openapi 自动生成请求

项目结构#

我的不一定是最好的,可以参考一下

.
├── main
│ ├── kotlin
│ │ └── top
│ │ └── inept
│ │ └── blog
│ │ ├── IneptBlogServerApplication.kt 启动类
│ │ ├── base
│ │ │ ├── ApiResponse.kt Api包装类
│ │ │ ├── BaseQueryDTO.kt 基础查询DTO
│ │ │ ├── PageResponse.kt Page包装类
│ │ │ ├── QueryBuilder.kt 查询构建工具
│ │ │ └── ValidationError.kt validation校验错误格式
│ │ ├── config
│ │ │ ├── MessageConfig.kt i18n配置
│ │ │ ├── OpenApiI18nConfig.kt i18n配置
│ │ │ └── SecurityConfig.kt 管理spring security配置
│ │ ├── constant
│ │ │ └── JwtClaimsConstant.kt tokenClaims字符串
│ │ ├── exception
│ │ │ ├── JwtInvalidException.kt 定义异常
│ │ │ └── NotFoundException.kt 定义异常
│ │ ├── extensions kotlin扩展函数
│ │ │ ├── BaseQyeryDTOExtensions.kt
│ │ │ ├── MessageExtensions.kt
│ │ │ └── PageResponseExtensions.kt
│ │ ├── feature 模块
│ │ │ ├── article 文章
│ │ │ ├── categories 分类
│ │ │ ├── comment 评论
│ │ │ ├── tag 标签
│ │ │ └── user 用户
│ │ │ ├── controller
│ │ │ │ ├── AdminUserController.kt 管理员控制器
│ │ │ │ ├── OpenUserController.kt 公开控制器
│ │ │ │ └── UserUserController.kt 用户控制器
│ │ │ ├── pojo
│ │ │ │ ├── convert
│ │ │ │ │ └── UserConvert.kt 转换DTO VO entity
│ │ │ │ ├── dto
│ │ │ │ │ ├── CreateUserDTO.kt
│ │ │ │ │ ├── LoginUserDTO.kt
│ │ │ │ │ ├── QueryUserDTO.kt
│ │ │ │ │ ├── UpdateUserDTO.kt
│ │ │ │ │ └── UpdateUserProfileDTO.kt
│ │ │ │ ├── entity
│ │ │ │ │ ├── User.kt spring data jpa 的 entity
│ │ │ │ │ └── enums
│ │ │ │ │ └── UserRole.kt
│ │ │ │ ├── validated 校验相关
│ │ │ │ │ ├── ValidateUserNickname.kt
│ │ │ │ │ ├── ValidateUserUsername.kt
│ │ │ │ │ ├── ValidatedUserEmail.kt
│ │ │ │ │ └── ValidatedUserPassword.kt
│ │ │ │ └── vo
│ │ │ │ ├── LoginUserVO.kt
│ │ │ │ ├── UserPublicVO.kt
│ │ │ │ └── UserVO.kt
│ │ │ ├── repository 仓库
│ │ │ │ ├── UserRepository.kt
│ │ │ │ └── UserSpecs.kt 查询相关(Specification)
│ │ │ └── service 服务
│ │ │ ├── UserService.kt
│ │ │ └── impl 服务实现
│ │ │ └── UserServiceImpl.kt
│ │ ├── filter
│ │ │ └── JwtAuthFilter.kt 判断请求头的token有效,在SecurityConfig注册
│ │ ├── handler
│ │ │ └── GlobalExceptionHandler.kt 全局异常拦截器
│ │ ├── properties
│ │ │ └── JwtProperties.kt token配置
│ │ └── utils
│ │ ├── DateUtil.kt
│ │ ├── JwtUtil.kt
│ │ ├── PasswordUtil.kt
│ │ └── SecurityUtil.kt
│ └── resources
│ ├── application-dev.yml 开发配置
│ ├── application-prod.yml 发布配置
│ ├── application.yml 配置
│ ├── data.sql
│ ├── i18n
│ │ ├── messages.properties
│ │ ├── messages_en.properties
│ │ ├── openapi.properties
│ │ ├── openapi_en.properties
│ │ ├── validation.properties
│ │ └── validation_en.properties
│ ├── schema.sql
│ ├── static
│ └── templates
└── test
└── kotlin
└── top
└── inept
└── blog
└── IneptBlogServerApplicationTests.kt

使用idea开始创建#

alt text

NOTE

添加必要的依赖

alt text

1.管理好配置#

和我一样在resources目录下创建yml格式的配置吧

创建开发配置#

src/main/resources/application-dev.yml
top:
inept:
datasource:
ddl-auto: update
host: 127.0.0.1
port: 5432
database: inept_blog
username: user
password: 123456
server:
port: 8080
jwt:
secretKey: 82MjWAO+36AA+XMmx6dCuLZd8Kaa2sL+KxzDVY5Mvm+xXo5ZcUQe5uZpnv+lhhdgTMjgJpfevyxok0NlXCwb8A==
ttlHours: 168
tokenName: token
logging:
level:
org:
springframework:
web:
servlet:
DispatcherServlet: DEBUG
validation: DEBUG
hibernate:
validator: DEBUG

创建发布配置#

src/main/resources/application-prod.yml
top:
inept:
datasource:
ddl-auto: validate
host: 127.0.0.1
port: 5432
database: inept_blog
username: user
password: 123456
server:
port: 8080
jwt:
secretKey: U7lIBboDJW24Ak1ROncsOyuVNuugnZriJWZBY9ULB//4EdZU5AeGCRlVAdXMKxinBynktrcuQgGVCM4Fm54tBg== #openssl rand -base64 64
ttlHours: 168 #7day
tokenName: token

创建主配置#

src/main/resources/application.yml
spring:
application:
name: inept-test
profiles:
active: dev
datasource:
url: jdbc:postgresql://${top.inept.datasource.host}:${top.inept.datasource.port}/${top.inept.datasource.database}
username: ${top.inept.datasource.username}
password: ${top.inept.datasource.password}
jpa:
hibernate:
ddl-auto: ${top.inept.datasource.ddl-auto}
show-sql: true
properties:
hibernate:
format_sql: true
database-platform: org.hibernate.dialect.PostgreSQLDialect
database: postgresql
messages:
basename: i18n/messages,i18n/validation,i18n/openapi
encoding: UTF-8
fallback-to-system-locale: false
server:
port: ${top.inept.server.port}
top:
inept:
jwt:
secretKey: ${top.inept.jwt.secretKey}
ttlHours: ${top.inept.jwt.ttlHours}
tokenName: ${top.inept.jwt.tokenName}

详细讲解配置#

我们只需要修改开发配置和发布配置

CAUTION

不过再次之前我们需要把application-dev.yml添加到.gitignore中,只需要在行末添加

src/main/resources/application-dev.yml
top:
inept:
datasource:
ddl-auto: update
host: 127.0.0.1 #数据库ip或者域名
port: 5432 #数据库端口
database: inept_blog #数据库名称
username: user #数据库账号
password: 123456 #数据库密码
server:
port: 8080 #springboot web 开放端口
jwt:
secretKey: 82MjWAO+36AA+XMmx6dCuLZd8Kaa2sL+KxzDVY5Mvm+xXo5ZcUQe5uZpnv+lhhdgTMjgJpfevyxok0NlXCwb8A== #jwt的secretKey使用openssl rand -base64 64随机生成
ttlHours: 168 #过期时间
tokenName: token
logging:
level:
org:
springframework:
web:
servlet:
DispatcherServlet: DEBUG
validation: DEBUG
hibernate:
validator: DEBUG

2.添加i18n相关#

在项目结构中可以看到在resources下有个i18n文件夹里面就是关于i18n内容,按照下面创建多个i18n文件

├── messages.properties
├── messages_en.properties
├── openapi.properties
├── openapi_en.properties
├── validation.properties
└── validation_en.properties

默认配置是中文的,大概内容为

openapi.query.page=页数
openapi.query.size=大小
openapi.response.code=失败成功数字
....

我来说明messages openapi validation这些文件有什么用

messages#

通常在服务层使用较为通用,例如message.user.username_or_password_error=用户名或者密码错误

openapi#

专为为openapi使用,通常在DTO VO类使用

data class UserVO(
@Schema(description = "openapi.user.id")
val id: Long,
@Schema(description = "openapi.user.nickname") //openapi.user.nickname=昵称
val nickname: String,
@Schema(description = "openapi.user.username") //openapi.user.username=用户名
val username: String,
@Schema(description = "openapi.user.email")
val email: String?,
)

validation#

和名字一样在validation使用,例如校验用户提示的错误

@Size(min = 2, max = 16, message = "valid.user.nickname")
annotation class ValidateUserNickname(
val message: String = "valid.common.unknown_error",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
valid.user.nickname=错误的昵称格式,长度应为2-16
valid.user.username=错误的用户名格式,长度应为6-16
valid.user.password=至少一个字母,至少一个数字,长度应为6-20
valid.user.email=邮箱校验错误
....

3.创建最基础的#

你可以直接在我的成品项目中复制代码,在开头有提到

1.创建JwtProperties#

创建这个发布我们在业务代码方便获取到在配置文件写的内容,JwtProperties获取的是关于Jwt的

top/inept/blog/properties/JwtProperties.kt
@ConfigurationProperties(prefix = "top.inept.jwt")
data class JwtProperties @ConstructorBinding constructor(
val secretKey: String,
val ttlHours: Long,
val tokenName: String,
)

2.创建配置类#

1.MessageConfig#

用于解析i18n内容的

top/inept/blog/config/MessageConfig.kt
@Configuration
class MessageConfig : WebMvcConfigurer {
@Bean
fun messageAccessor(messageSource: MessageSource): MessageSourceAccessor {
// 这个 accessor 会自动从 LocaleContextHolder 取 locale
return MessageSourceAccessor(messageSource)
}
}

创建配套的拓展函数

top/inept/blog/extensions/MessageExtensions.kt
// 单参数,无 args
operator fun MessageSourceAccessor.get(code: String): String =
try {
this.getMessage(code)
} catch (ex: NoSuchMessageException) {
code
}
// 多参数,可传 args
operator fun MessageSourceAccessor.get(code: String, vararg args: Any): String =
try {
this.getMessage(code, args)
} catch (ex: NoSuchMessageException) {
code
}

2.OpenApiI18nConfig#

由于openapi不会自动解析我们的i18n内容我需要自己手动解析

top/inept/blog/config/OpenApiI18nConfig.kt
@Configuration
class OpenApiI18nConfig(private val messages: MessageSourceAccessor) {
@Bean
fun i18nCustomizer(): OpenApiCustomizer {
return OpenApiCustomizer { openApi: OpenAPI ->
openApi.components?.schemas?.forEach { (_, schema) ->
translateSchema(schema, messages)
}
}
}
private fun translateSchema(schema: Schema<*>, source: MessageSourceAccessor) {
schema.description?.takeIf { it.startsWith("openapi.") }?.also {
schema.description = source[it]
}
schema.properties?.values?.forEach { prop ->
prop?.let { p ->
p.description?.takeIf { it.startsWith("openapi.") }?.also {
p.description = source[it]
}
}
}
}
}

3.一些基础的东西#

ApiResponse#

一个Api包装类,并且之类已经用到openapi的i18n注解了

top/inept/blog/base/ApiResponse.kt
data class ApiResponse<T>(
@Schema(description = "openapi.response.code")
val code: Int = 0,
@Schema(description = "openapi.response.msg") //openapi.response.msg=消息
val msg: String? = null,
@Schema(description = "openapi.response.data")
val data: T? = null
) : Serializable {
companion object {
const val SUCCESS = 0
const val FAILURE = 1
/**
* 只返回成功,不带数据
*/
fun <T> success(data: T): ApiResponse<T> = ApiResponse(code = SUCCESS, data = data, msg = "success")
/**
* 只返回错误信息
*/
fun <T> error(msg: String): ApiResponse<T> = ApiResponse(code = FAILURE, msg = msg)
fun <T> error(msg: String, data: T): ApiResponse<T> = ApiResponse(code = FAILURE, msg = msg, data = data)
}
}

ValidationError#

自定义validation返回的错误消息格式,在下面的全局拦截器有提到

top/inept/blog/base/ValidationError.kt
data class ValidationError(val field: String, val message: String)

4.创建全局的异常拦截器#

top/inept/blog/handler/GlobalExceptionHandler.kt
@RestControllerAdvice
class GlobalExceptionHandler(
private val messages: MessageSourceAccessor
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@ExceptionHandler
fun exceptionHandler(ex: Exception): ApiResponse<String> {
logger.error(ex.message, ex)
return ApiResponse.error(ex.message ?: messages["message.common.unknown_error"]) //message.common.unknown_error=未知错误
}
@ExceptionHandler
fun exceptionHandler(ex: HttpMessageNotReadableException): ApiResponse<String> {
logger.error(ex.message, ex)
//json解析时缺少字段报错
if (ex.cause is MissingKotlinParameterException) {
return ApiResponse.error(messages["message.common.missing_json_field", ex.message ?: "Null"])
}
return ApiResponse.error(ex.message ?: messages["message.common.unknown_error"])
}
/**
* Validated的验证错误
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun exceptionHandler(ex: MethodArgumentNotValidException): ApiResponse<List<ValidationError>> {
val errors = ex.bindingResult.fieldErrors.map { fe ->
ValidationError(
field = fe.field,
message = messages[fe.defaultMessage ?: "message.common.illegal_parameters"],
)
}
return ApiResponse.error(messages["message.common.illegal_parameters"], errors)
}
}

4.在启动类中添加需要的注释#

下面这个是默认的启动类,可能你创建项目名字不同和我的不太一样

@SpringBootApplication
@EnableJpaAuditing //JPA 中启用审计
@EnableConfigurationProperties(JwtProperties::class) //给刚刚创建的JwtProperties添加上
class IneptBlogServerApplication
fun main(args: Array<String>) {
runApplication<IneptBlogServerApplication>(*args)
}

5.配置认证过滤器#

1.创建JwtUtil#

由于我这里的JwtUtil没有和UserRole解耦,你们最好看一下成品项目

top/inept/blog/utils/JwtUtil.kt
package top.inept.blog.utils
import io.jsonwebtoken.*
import org.springframework.context.support.MessageSourceAccessor
import org.springframework.stereotype.Component
import top.inept.blog.constant.JwtClaimsConstant
import top.inept.blog.exception.JwtInvalidException
import top.inept.blog.extensions.get
import top.inept.blog.feature.user.pojo.entity.enums.UserRole
import java.util.*
import javax.crypto.spec.SecretKeySpec
@Component
class JwtUtil(private val messages: MessageSourceAccessor) {
private fun createJWT(
secretKey: String,
ttlHours: Long,
claims: Map<String, Any>
): String {
val now = Date(System.currentTimeMillis())
val hmacKey = SecretKeySpec(Base64.getDecoder().decode(secretKey), "HmacSHA512")
return Jwts.builder()
.claims(claims)
.issuedAt(now)
.expiration(DateUtil.addHoursToDate(now, ttlHours))
.signWith(hmacKey)
.compact()
}
fun createJWT(
secretKey: String,
ttlHours: Long,
id: Long,
username: String,
role: UserRole,
): String {
val payload = HashMap<String, Any>()
payload.put(JwtClaimsConstant.ID, id)
payload.put(JwtClaimsConstant.USERNAME, username)
payload.put(JwtClaimsConstant.ROLE, role.toString())
return createJWT(secretKey, ttlHours, payload)
}
fun parseJWT(
secretKey: String,
token: String
): Jws<Claims> {
val hmacKey = SecretKeySpec(Base64.getDecoder().decode(secretKey), "HmacSHA512")
return try {
Jwts.parser()
.verifyWith(hmacKey)
.build()
.parseSignedClaims(token)
} catch (e: ExpiredJwtException) {
throw JwtInvalidException(messages["message.jwt.expired"])
} catch (e: JwtException) {
throw JwtInvalidException(messages["message.jwt.invalid"])
}
}
fun getIdFromClaims(claims: Claims): Long? {
val raw = claims[JwtClaimsConstant.ID]
return when (raw) {
is Number -> raw.toLong()
is String -> raw.toLongOrNull()
else -> null
}
}
fun getUsernameFromClaims(claims: Claims): String? {
val raw = claims[JwtClaimsConstant.USERNAME]
return raw as? String
}
fun getRoleFromClaims(claims: Claims): UserRole? {
val raw = claims[JwtClaimsConstant.ROLE]
if (raw !is String) return null
return try {
UserRole.valueOf(raw)
} catch (e: IllegalArgumentException) {
null
}
}
}
top/inept/blog/exception/JwtInvalidException.kt
class JwtInvalidException(message: String) : RuntimeException(message)
top/inept/blog/utils/DateUtil.kt
object DateUtil {
fun addHoursToDate(date: Date, hours: Long): Date {
val instant = date.toInstant()
val newInstant = instant.plus(Duration.ofHours(hours))
return Date.from(newInstant)
}
}

2.创建JwtAuthFilter#

top/inept/blog/filter/JwtAuthFilter.kt
@Component
class JwtAuthFilter(
private val jwtProperties: JwtProperties,
private val jwtUtil: JwtUtil,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
//判断有没有token
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
//获取token
val token = authHeader.substring(7)
//解析token
val claims = jwtUtil.parseJWT(secretKey = jwtProperties.secretKey, token)
//获取用户名与role
val username = jwtUtil.getUsernameFromClaims(claims.payload)
val role = jwtUtil.getRoleFromClaims(claims.payload)
if (username == null || role == null) {
filterChain.doFilter(request, response)
return
}
// 如果上下文中没有认证信息,才进行设置
if (SecurityContextHolder.getContext().authentication == null) {
val authToken = UsernamePasswordAuthenticationToken(
username,
null,
listOf<GrantedAuthority>(role)
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
// 将认证对象设置到 SecurityContext 中,表示当前用户已认证
SecurityContextHolder.getContext().authentication = authToken
}
filterChain.doFilter(request, response)
}
}

3.创建SecurityConfig配置类#

top/inept/blog/config/SecurityConfig.kt
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtAuthFilter: JwtAuthFilter,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.invoke {
// 禁用 CSRF,因为我们使用无状态的 JWT
csrf { disable() }
authorizeHttpRequests {
// 定义访问权限
authorize("/open/**", permitAll)
authorize("/user/**", hasAuthority(UserRole.USER.authority)) // /user/** 路径需要 USER 角色
authorize("/admin/**", hasAuthority(UserRole.ADMIN.authority)) // /admin/** 路径需要 ADMIN 角色
//TODO 发布时关闭
authorize("/swagger-ui/**", permitAll)
authorize("/v3/api-docs/**", permitAll)
authorize(anyRequest, authenticated) // 其他所有请求都需要认证
}
// 设置 Session 管理策略为 STATELESS (无状态)
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
}
return http.build()
}
}
创建一个kotlin的springboot3的项目
https://blog.inept.top/posts/kotlin-springboot3/start-kotlin-springboot3-01/
作者
无能酱
发布于
2025-08-10
许可协议
CC BY-NC-SA 4.0