Chilpost Spring Boot Kotlin 后端实现
Warning本文发布于 2023/12/12,内容可能已过时。
咱还是因为 Web 的大作业要求 Spring Boot,有些看不起 Node 后端,那还是写一份 SB 的实现吧 😹 当然,得是 Kotlin 的 😇
咱放在了 chilpost-sb 这个 repo 里
咱写着写着 Kotlin 版本的后端成了主线,Nuxt 得拖拖了,找个较好的 Node 后端技术栈 实在是因为 Kotlin 太甜了,让人很难推掉它 hhh
工具选择
除了 Spring Boot 外,还是用 Kotlin 友好的 JetBrains/Exposed 作为 SQL 框架,org.bitbucket.b_c:jose4j 作为 JWT/JWS 的支持,和要求的 MySQL。依赖如下
val exposedVersion = "0.44.0"
dependencies { implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") implementation("org.jetbrains.exposed:exposed-json:$exposedVersion") implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion") implementation("org.jetbrains.exposed:exposed-spring-boot-starter:$exposedVersion")
implementation("org.bitbucket.b_c:jose4j:0.9.3") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test")}
开始
因为之前有写过 Nest.js (反过来了 hh),所以还算是比较熟练 hhhh 且算是在 Nuxt 中写了还算好的后端最佳实践,现在要做的只是迁移到 SB 而已
例如抛弃 Restful Api,改用 all 200 api、统一的异常处理、JWS 等等
比较惊艳的还是 Exposed 写 DAO,以及 Kotlin 语法的优雅。例如:
fun getUserByEmail(email: String) = (UserTable innerJoin UserStatusTable) .select { UserTable.email eq email } .map(::toUserDetail) .firstOrNull() ?: throw newError(ErrorCode.NOT_FOUND_USER)
虽然它不直接支持视图,但也能通过 Kotlin 来间接实现
fun postWithOwner() = PostTable .innerJoin(PostStatusTable) .join( UserTable, JoinType.INNER, PostTable.ownerId, UserTable.id )
fun postQuery() = postWithOwner().selectAll() .orderBy(PostTable.createdAt to SortOrder.DESC)
这些函数都是返回 Query
,可以继续链式调用,最后再 map
到 DTO
不该返回自增 id 给前端
一直都是下意识地将主键 id 作为返回的 id,但其实这有很多的弊端,和不安全的地方。b 站将自增 AV 号换为了较为随机的 BV 号,uid 也改为固定长度的随机数字。这部分也是属于老生常谈的问题了,后续我都改为了 uuid 字符串
携用户信息的接口
有些接口对于登录用户是另一种表现,例如该推文是否点赞,这部分应该交由后端来查好了再返回给前端,而不是返回一个点赞的 id 数组。且不说实际上这个点赞数组在内部使用主键的自增 id 来存的,把超大的数组传过去似乎不是很合理 hhhh
于是也还是使用携带的 token 中解析出用户信息,那这样就要改一下后端中间件的逻辑了,不在白名单列表 (如搜索、推文详情这些不强求登录后才能请求的路径) 并出错时才报错
同时,为了更易读,应该将这个解析出来的用户命名为像是 ctxUser,表示这个接口 context 的 user,就不会和像是要关注别人的另一个 user 弄混了
val token = req.getHeader("Authorization")?.trim()?.split(" ")if ( !isInWhiteList(path) && (token.isNullOrEmpty() || token.size != 2 || token[0] != "Bearer")) throw newError(ErrorCode.INVALID_TOKEN)
val userInfo = verifyToken<TokenData>(token?.lastOrNull())
if (userInfo == null && !isInWhiteList(path)) throw newError(ErrorCode.INVALID_TOKEN)
req.setAttribute("ctxUser", userInfo)
chain.doFilter(request, response)
外部文件支持
用户上传的文件应该放在专门的 OSS 服务器,或是本机中的某个地方,总之不是 resources,这是会被打包进 jar 中的
于是为了 serve 外部文件,就得专门写一下文件路由:
@Controller@RequestMapping("/files")class FileController { fun emptyFile(): File { val ba = ByteArray(0) val file = File.createTempFile("empty", "file") file.writeBytes(ba) return file }
@GetMapping("/**") fun getFile(): ResponseEntity<FileSystemResource> { val req = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request val filePath = req.servletPath.substringAfter("/files/")
var file = File("files/$filePath") if (!file.exists()) { file = emptyFile() } val headers = HttpHeaders() headers.contentType = MediaType.APPLICATION_OCTET_STREAM headers.contentDisposition = ContentDisposition.builder("inline") .filename(file.name) .build()
val resource = FileSystemResource(file) return ResponseEntity(resource, headers, HttpStatus.OK) }}
文件将保存在 $pwd/files
下,同时它支持深度路径,即 /files/a/b/c/d.png