Skip to content

Commit

Permalink
feat(image): add image task abstraction and executor
Browse files Browse the repository at this point in the history
- create ImageTask subclasses for thumbnail and image operations
- implement ImageExecutor for concurrent task execution
  • Loading branch information
ShiinaKin committed Dec 18, 2024
1 parent b0d9f94 commit 22fc24c
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 111 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.sakurasou.exception.service.image.io

import io.sakurasou.exception.ServiceThrowable

/**
* @author Shiina Kin
* 2024/9/12 12:57
*/
class ImageThumbnailNotFoundException : ServiceThrowable() {
override val code: Int
get() = 4004
override val message: String
get() = "Image thumbnail not found, will be generated later"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.sakurasou.execute.executor.image

import io.github.oshai.kotlinlogging.KotlinLogging
import io.sakurasou.execute.task.image.DeleteImageTask
import io.sakurasou.execute.task.image.DeleteThumbnailTask
import io.sakurasou.execute.task.image.ImageTask
import io.sakurasou.execute.task.image.PersistImageThumbnailTask
import io.sakurasou.execute.task.image.RePersistImageThumbnailTask
import io.sakurasou.model.entity.Strategy
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.awt.image.BufferedImage

/**
* @author Shiina Kin
* 2024/12/18 18:31
*/
object ImageExecutor {
private val logger = KotlinLogging.logger {}

private val imageExecuteScope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + CoroutineName("ImageExecutor")
)

private val taskChannel = Channel<ImageTask>(Channel.UNLIMITED)

init {
imageExecuteScope.launch {
logger.debug { "image executor started" }
for (task in taskChannel) {
launch {
try {
task.execute()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}

suspend fun persistThumbnail(strategy: Strategy, subFolder: String, fileName: String, image: BufferedImage) {
taskChannel.send(PersistImageThumbnailTask(strategy, subFolder, fileName, image))
}

suspend fun rePersistThumbnail(strategy: Strategy, relativePath: String) {
taskChannel.send(RePersistImageThumbnailTask(strategy, relativePath))
}

suspend fun deleteImage(strategy: Strategy, relativePath: String) {
taskChannel.send(DeleteImageTask(strategy, relativePath))
}

suspend fun deleteThumbnail(strategy: Strategy, relativePath: String) {
taskChannel.send(DeleteThumbnailTask(strategy, relativePath))
}

fun shutdown() {
taskChannel.close()
imageExecuteScope.cancel()
}
}
123 changes: 123 additions & 0 deletions app/src/main/kotlin/io/sakurasou/execute/task/image/ImageTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package io.sakurasou.execute.task.image

import io.github.oshai.kotlinlogging.KotlinLogging
import io.sakurasou.exception.service.image.io.ImageFileDeleteFailedException
import io.sakurasou.exception.service.image.io.ImageFileNotFoundException
import io.sakurasou.model.entity.Strategy
import io.sakurasou.model.group.ImageType
import io.sakurasou.model.strategy.LocalStrategy
import io.sakurasou.model.strategy.S3Strategy
import io.sakurasou.util.ImageUtils
import io.sakurasou.util.S3Utils
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

/**
* @author Shiina Kin
* 2024/12/18 18:38
*/

private const val THUMBNAIL_HEIGHT = 256
private const val THUMBNAIL_QUALITY = 0.9

sealed class ImageTask() {
val logger = KotlinLogging.logger {}
abstract fun execute()
}

class PersistImageThumbnailTask(
private val strategy: Strategy,
private val subFolder: String,
private val fileName: String,
private val image: BufferedImage
) : ImageTask() {
override fun execute() {
val relativePath = "$subFolder/$fileName"
val imageType = ImageType.valueOf(fileName.substringAfterLast('.').uppercase())
val thumbnailBytes = ImageUtils.transformImageByHeight(image, imageType, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY)
ImageUtils.saveThumbnail(strategy, subFolder, fileName, thumbnailBytes, relativePath)
logger.debug { "persist thumbnail $fileName in strategy(id ${strategy.id})" }
}
}

class RePersistImageThumbnailTask(
private val strategy: Strategy,
private val relativePath: String
) : ImageTask() {
override fun execute() {
val fileName = relativePath.substringAfterLast('/')
when (val strategyConfig = strategy.config) {
is LocalStrategy -> {
val uploadFolderStr = strategyConfig.uploadFolder
val uploadFile = File(uploadFolderStr, relativePath)
if (!uploadFile.exists()) throw ImageFileNotFoundException()
uploadFile.inputStream()
}

is S3Strategy -> {
val filePath = "${strategyConfig.uploadFolder}/$relativePath"
S3Utils.fetchImage(filePath, strategy)
}
}.use { rawImageInputStream ->
val rawImage = ImageIO.read(rawImageInputStream)
val subFolder = relativePath.substringBeforeLast('/')
val imageType = ImageType.valueOf(fileName.substringAfterLast('.').uppercase())
val thumbnailBytes =
ImageUtils.transformImageByHeight(rawImage, imageType, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY)
ImageUtils.saveThumbnail(strategy, subFolder, fileName, thumbnailBytes, relativePath)
}
logger.debug { "re-persist thumbnail $fileName in strategy(id ${strategy.id})" }
}
}

class DeleteImageTask(
private val strategy: Strategy,
private val relativePath: String
) : ImageTask() {
override fun execute() {
when (val strategyConfig = strategy.config) {
is LocalStrategy -> {
val uploadFolderStr = strategyConfig.uploadFolder
val uploadFile = File(uploadFolderStr, relativePath)

if (uploadFile.exists() && !uploadFile.delete()) {
logger.error { "Failed to delete image at $uploadFolderStr/$relativePath" }
throw ImageFileDeleteFailedException()
}
}

is S3Strategy -> {
val filePath = "${strategyConfig.uploadFolder}/$relativePath"
S3Utils.deleteImage(filePath, strategy)
}
}
val fileName = relativePath.substringAfterLast('/')
logger.debug { "delete image $fileName from strategy(id ${strategy.id})" }
}
}

class DeleteThumbnailTask(
private val strategy: Strategy,
private val relativePath: String
) : ImageTask() {
override fun execute() {
when (val strategyConfig = strategy.config) {
is LocalStrategy -> {
val thumbnailFolder = strategyConfig.thumbnailFolder
val thumbnailFile = File(thumbnailFolder, relativePath)

if (thumbnailFile.exists() && !thumbnailFile.delete()) {
logger.error { "Failed to delete thumbnail at $thumbnailFolder/$relativePath" }
}
}

is S3Strategy -> {
val filePath = "${strategyConfig.thumbnailFolder}/$relativePath"
S3Utils.deleteImage(filePath, strategy)
}
}
val fileName = relativePath.substringAfterLast('/')
logger.debug { "delete thumbnail $fileName from strategy(id ${strategy.id})" }
}
}
27 changes: 16 additions & 11 deletions app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.sakurasou.service.image

import io.github.oshai.kotlinlogging.KotlinLogging
import io.sakurasou.controller.request.ImageManagePatchRequest
import io.sakurasou.controller.request.ImagePatchRequest
import io.sakurasou.controller.request.ImageRawFile
Expand All @@ -13,8 +14,10 @@ import io.sakurasou.exception.service.album.AlbumAccessDeniedException
import io.sakurasou.exception.service.album.AlbumNotFoundException
import io.sakurasou.exception.service.group.GroupNotFoundException
import io.sakurasou.exception.service.image.*
import io.sakurasou.exception.service.image.io.ImageThumbnailNotFoundException
import io.sakurasou.exception.service.strategy.StrategyNotFoundException
import io.sakurasou.exception.service.user.UserNotFoundException
import io.sakurasou.execute.executor.image.ImageExecutor
import io.sakurasou.model.DatabaseSingleton.dbQuery
import io.sakurasou.model.dao.album.AlbumDao
import io.sakurasou.model.dao.group.GroupDao
Expand All @@ -30,9 +33,6 @@ import io.sakurasou.model.strategy.S3Strategy
import io.sakurasou.service.setting.SettingService
import io.sakurasou.util.ImageUtils
import io.sakurasou.util.PlaceholderUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
Expand All @@ -52,6 +52,8 @@ class ImageServiceImpl(
private val strategyDao: StrategyDao,
private val settingService: SettingService
) : ImageService {
private val logger = KotlinLogging.logger {}

override suspend fun saveImage(userId: Long, imageRawFile: ImageRawFile): String {
val siteSetting = settingService.getSiteSetting()
return runCatching {
Expand Down Expand Up @@ -143,10 +145,7 @@ class ImageServiceImpl(

imageDao.saveImage(imageInsertDTO)

// TODO use a mq to do this
CoroutineScope(Dispatchers.IO).launch {
ImageUtils.createAndUploadThumbnail(strategy, subFolder, storageFileName, image)
}
ImageExecutor.persistThumbnail(strategy, subFolder, storageFileName, image)

if (user.isDefaultImagePrivate) ""
else "${siteSetting.siteExternalUrl}/s/$uniqueName"
Expand All @@ -167,8 +166,8 @@ class ImageServiceImpl(
val strategy = strategyDao.findStrategyById(image.strategyId) ?: throw StrategyNotFoundException()

imageDao.deleteImageById(imageId)
ImageUtils.deleteImage(strategy, image.path)
ImageUtils.deleteThumbnail(strategy, image.path)
ImageExecutor.deleteImage(strategy, image.path)
ImageExecutor.deleteThumbnail(strategy, image.path)
}
}.onFailure {
if (it is ServiceThrowable) throw ImageDeleteFailedException(it)
Expand All @@ -183,8 +182,8 @@ class ImageServiceImpl(
val strategy = strategyDao.findStrategyById(image.strategyId) ?: throw StrategyNotFoundException()

imageDao.deleteImageById(imageId)
ImageUtils.deleteImage(strategy, image.path)
ImageUtils.deleteThumbnail(strategy, image.path)
ImageExecutor.deleteImage(strategy, image.path)
ImageExecutor.deleteThumbnail(strategy, image.path)
}
}.onFailure {
if (it is ServiceThrowable) throw ImageDeleteFailedException(it)
Expand Down Expand Up @@ -333,6 +332,12 @@ class ImageServiceImpl(
when (strategy.config) {
is LocalStrategy -> ImageFileDTO(bytes = ImageUtils.fetchLocalImage(strategy, image.path, true))
is S3Strategy -> ImageFileDTO(url = ImageUtils.fetchS3Image(strategy, image.path, true))
}.also {
if (it.bytes == null && it.url.isNullOrBlank()) {
logger.debug { "thumbnail of image $imageId doesn't exist, will be generate later." }
ImageExecutor.rePersistThumbnail(strategy, image.path)
throw ImageThumbnailNotFoundException()
}
}
}
}
Expand Down
Loading

0 comments on commit 22fc24c

Please sign in to comment.