Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
13 changes: 11 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,25 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-security")


// DB
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")

// mockk
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("io.mockk:mockk:1.13.8")

// kotest
val kotestVersion = "5.5.4"
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("io.rest-assured:rest-assured:5.4.0")

// Jasypt
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")
Expand Down Expand Up @@ -99,6 +108,6 @@ tasks.named("clean") {
}
}

tasks.withType<Test> {
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
1 change: 0 additions & 1 deletion src/main/kotlin/com/study/core/DokiApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.study.core

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.core.env.get

@SpringBootApplication
class DokiApplication
Expand Down
23 changes: 23 additions & 0 deletions src/main/kotlin/com/study/core/global/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.study.core.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.Executor

@Configuration
@EnableAsync
class AsyncConfig {

@Bean(name = ["imageStorageExecutor"])
fun imageStorageExecutor(): Executor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 2
executor.maxPoolSize = 4
executor.queueCapacity = 50
executor.setThreadNamePrefix("image-storage-")
executor.initialize()
return executor
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.study.core.image.application

import com.study.core.image.application.event.ImageCreateEvent
import com.study.core.image.domain.domainservice.ImageStorage
import com.study.core.image.domain.Image
import com.study.core.image.domain.ImageRepository
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component

@Component
class ImageEventListener(
private val imageRepository: ImageRepository,
private val imageStorage: ImageStorage
) {

@Async("imageStorageExecutor")
@EventListener
fun handleImageCreate(event: ImageCreateEvent) {
require(event.content.isNotEmpty()) { "content must not be empty." }

val image = Image.createAndStore(
aggregateType = event.aggregateType,
aggregateId = event.aggregateId,
originalFilename = event.originalFilename,
content = event.content,
sortOrder = event.sortOrder,
imageStorage = imageStorage,
)
imageRepository.save(image)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.study.core.image.application

import com.study.core.image.application.event.ImageCreateEvent
import com.study.core.image.domain.vo.ImageAggregateType
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Component

@Component
class ImageEventPublisher(
private val applicationEventPublisher: ApplicationEventPublisher
) {

fun publishCreate(event: ImageCreateEvent) {
applicationEventPublisher.publishEvent(event)
}

fun publishCreate(
aggregateType: ImageAggregateType,
aggregateId: Long,
originalFilename: String,
content: ByteArray,
sortOrder: Int = 0
) {
publishCreate(
ImageCreateEvent(
aggregateType = aggregateType,
aggregateId = aggregateId,
originalFilename = originalFilename,
content = content,
sortOrder = sortOrder
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.study.core.image.application.event

import com.study.core.image.domain.vo.ImageAggregateType

data class ImageCreateEvent(
val aggregateType: ImageAggregateType,
val aggregateId: Long,
val originalFilename: String,
val content: ByteArray,
val sortOrder: Int = 0,
)
110 changes: 110 additions & 0 deletions src/main/kotlin/com/study/core/image/domain/Image.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.study.core.image.domain

import com.study.core.image.domain.domainservice.ImageStorage
import com.study.core.global.BaseEntity
import com.study.core.image.domain.vo.ImageAggregateType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID

@Entity
@Table(name = "images")
class Image(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,

@Enumerated(EnumType.STRING)
@Column(name = "aggregate_type", nullable = false, length = 30)
val aggregateType: ImageAggregateType,

@Column(name = "aggregate_id", nullable = false)
val aggregateId: Long,

@Column(name = "original_filename", nullable = false, length = 255)
val originalFilename: String,

@Column(name = "unique_filename", nullable = false, length = 255)
val uniqueFilename: String,
Comment on lines 33 to 34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지를 식별하기 위한 파일명인가요?? Id, Uniquefilename 둘 다 필요한 건지 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ex. originalFileName이 이미지.jpg인데, 중복된 파일명이 또 저장되는 경우를 예방하기 위해서 uuid기반의 uniqueName을 추가해습니다! db에서 식별은 id만 있어도 되지만 물리 환경에선 클라우드냐 로컬이냐에 따라서 uniqueName도 필요할 수 있어서요

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스토리지 식별자로 사용되는 부분 이해했어요 감사합니다! 그럼 혹시 url은 가변적이어서 식별자로 사용하지 않는 걸까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 스토리지가 구글클라우드, aws, 클라우드플레어 등등 변경될 수 있고 가변적이라 생각해서 식별자로 사용하지 않았습니다~


@Column(name = "extension", nullable = false, length = 20)
val extension: String,

@Column(name = "file_size", nullable = false)
val fileSize: Long,

@Column(name = "url", nullable = false, length = 500)
val url: String,

@Column(name = "sort_order", nullable = false)
var sortOrder: Int = 0,
) : BaseEntity() {

fun changeSortOrder(targetSortOrder: Int): Image {
require(targetSortOrder >= 0) { "sortOrder must be zero or positive." }
sortOrder = targetSortOrder
return this
}

companion object {
fun createAndStore(
aggregateType: ImageAggregateType,
aggregateId: Long,
originalFilename: String,
content: ByteArray,
sortOrder: Int = 0,
imageStorage: ImageStorage,
): Image {
require(aggregateId > 0) { "aggregateId must be positive." }
require(content.isNotEmpty()) { "content must not be empty." }
require(sortOrder >= 0) { "sortOrder must be zero or positive." }

val normalizedOriginal = originalFilename.trim()
require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." }

val generatedFilename = generateUniqueFilename(normalizedOriginal)
val storedImage = imageStorage.store(generatedFilename.uniqueFilename, content)

require(storedImage.url.isNotBlank()) { "url must not be blank." }
require(storedImage.fileSize > 0) { "fileSize must be greater than zero." }

return Image(
aggregateType = aggregateType,
aggregateId = aggregateId,
originalFilename = normalizedOriginal,
uniqueFilename = generatedFilename.uniqueFilename,
extension = generatedFilename.extension,
fileSize = storedImage.fileSize,
url = storedImage.url,
sortOrder = sortOrder,
)
}

fun generateUniqueFilename(originalFilename: String): GeneratedFilename {
val normalizedOriginal = originalFilename.trim()
require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." }
val extension = extractExtension(normalizedOriginal)
return GeneratedFilename(
uniqueFilename = "${UUID.randomUUID()}.$extension",
extension = extension
)
}

private fun extractExtension(filename: String): String {
val extension = filename.substringAfterLast('.', "").lowercase()
require(extension.isNotEmpty()) { "filename must contain an extension." }
return extension
}
}
}

data class GeneratedFilename(
val uniqueFilename: String,
val extension: String
)
14 changes: 14 additions & 0 deletions src/main/kotlin/com/study/core/image/domain/ImageRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.study.core.image.domain

import com.study.core.image.domain.vo.ImageAggregateType

interface ImageRepository {

fun save(image: Image): Image

fun saveAll(images: Collection<Image>): List<Image>

fun findByAggregate(aggregateType: ImageAggregateType, aggregateId: Long): List<Image>

fun deleteAllByAggregate(aggregateType: ImageAggregateType, aggregateId: Long)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.study.core.image.domain.domainservice

interface ImageStorage {
fun store(uniqueFilename: String, content: ByteArray): StoredImage
}

data class StoredImage(
val url: String,
val fileSize: Long
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.study.core.image.domain.vo

enum class ImageAggregateType {
PACKAGE,
PRODUCT,
USER,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.study.core.image.infrastructure.persistence

import com.study.core.image.domain.Image
import com.study.core.image.domain.vo.ImageAggregateType
import org.springframework.data.jpa.repository.JpaRepository

interface ImageJpaRepository : JpaRepository<Image, Long> {

fun findByAggregateTypeAndAggregateIdOrderBySortOrderAsc(
aggregateType: ImageAggregateType,
aggregateId: Long
): List<Image>

fun deleteByAggregateTypeAndAggregateId(aggregateType: ImageAggregateType, aggregateId: Long)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.study.core.image.infrastructure.persistence

import com.study.core.image.domain.Image
import com.study.core.image.domain.vo.ImageAggregateType
import com.study.core.image.domain.ImageRepository
import org.springframework.stereotype.Repository

@Repository
class ImageRepositoryImpl(
private val imageJpaRepository: ImageJpaRepository
) : ImageRepository {

override fun save(image: Image): Image =
imageJpaRepository.save(image)

override fun saveAll(images: Collection<Image>): List<Image> =
imageJpaRepository.saveAll(images)

override fun findByAggregate(aggregateType: ImageAggregateType, aggregateId: Long): List<Image> =
imageJpaRepository.findByAggregateTypeAndAggregateIdOrderBySortOrderAsc(aggregateType, aggregateId)

override fun deleteAllByAggregate(aggregateType: ImageAggregateType, aggregateId: Long) {
imageJpaRepository.deleteByAggregateTypeAndAggregateId(aggregateType, aggregateId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.study.core.image.infrastructure.storage

import com.study.core.image.domain.domainservice.ImageStorage
import com.study.core.image.domain.domainservice.StoredImage
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption

@Primary
@Profile("local", "test")
@Component
class LocalImageStorage(
@Value("\${app.storage.local.base-path:}") basePath: String? = null
) : ImageStorage {

// 자신 로컬 환경에 맞게 이미지 저장 경로 설정
private val rootPath: Path = basePath
?.takeIf { it.isNotBlank() }
?.let { Paths.get(it) }
?: Paths.get(System.getProperty("user.home"), "Desktop", "image")

override fun store(uniqueFilename: String, content: ByteArray): StoredImage {
require(uniqueFilename.isNotBlank()) { "uniqueFilename must not be blank." }
require(content.isNotEmpty()) { "content must not be empty." }

Files.createDirectories(rootPath)
val targetPath = rootPath.resolve(uniqueFilename)
Files.write(
targetPath,
content,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
)
return StoredImage(
url = targetPath.toUri().toString(),
fileSize = content.size.toLong()
)
}
}
Loading