diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c35b6693 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM openjdk:17-jdk +LABEL maintainer="Yosongsong" + +ARG JAR_FILE=build/libs/springboot-url-shortener-0.0.1-SNAPSHOT.jar +ADD ${JAR_FILE} url-shortener-springboot.jar + +ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/url-shortener-springboot.jar"] diff --git a/README.md b/README.md index a557279f..df00d01c 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,79 @@ -# springboot-url-shortener -SprintBoot URL Shortener 구현 미션 Repository 입니다. - -## 요구사항 -각 요구사항을 모두 충족할 수 있도록 노력해봅시다. -- [ ] URL 입력폼 제공 및 결과 출력 -- [ ] URL Shortening Key는 8 Character 이내로 생성 -- [ ] 단축된 URL 요청시 원래 URL로 리다이렉트 -- [ ] 단축된 URL에 대한 요청 수 정보저장 (optional) -- [ ] Shortening Key를 생성하는 알고리즘 2개 이상 제공하며 애플리케이션 실행중 동적으로 변경 가능 (optional) - - -## Short URL Service -### 읽으면 좋은 레퍼런스 -- [Naver 단축 URL API](https://developers.naver.com/docs/utils/shortenurl/) -- [짧게 줄인 URL의 실제 URL 확인 원리 및 방법](https://metalkin.tistory.com/50) -- [짧게 줄인 URL 알고리즘 고찰](https://metalkin.tistory.com/53) -- [단축 URL 원리 및 개발](https://blog.siyeol.com/26) - -### Short URL의 동작 과정 -예시로 bitly를 봅시다 -![image1](./image1.png) -![image2](./image2.png) -1. 원본 URL을 입력하고 Shorten 버튼을 클릭합니다. -2. Unique Key를 7문자 생성합니다. -3. Unique Key와 원본 URL을 DB에 저장합니다. -4. bitly.com/{Unique Key} 로 접근하면, DB를 조회하여 원본 URL로 redirect합니다. - -### Short URL의 특징 -단축 URL서비스는 간편하지만, 단점(위험성)이 있습니다. -링크를 클릭하는 사용자는 단축된 URL만 보고 클릭하기 때문에 어떤 곳으로 이동할지 알 수 없습니다. - -- Short URL 서비스는 주로 요청을 Redirect 시킵니다. (Redirect와 Forward의 차이점에 대해 검색해보세요.) -- 긴 URL을 짧은 URL로 압축할 수 있다. -- short url만으로는 어디에 연결되어있는 지 알 수 없다. 때문에 피싱 사이트 등의 보안에 취약하다. -- 광고를 본 뒤에 원본url로 넘겨주기도 한다. 이 과정에서 악성 광고가 나올 수 있다. -- 당연하지만 이미 존재하는 키를 입력하여 들어오는 사람이 존재할 수 있다. -- 기존의 원본 URL 변경되었더라도 단축 URL을 유지하여, 혼란을 방지할 수 있다. - -### 예시 사이트 -[https://url.kr/](https://url.kr/) +## 📌 설명 +>시연 동영상입니다. https://vimeo.com/manage/videos/873085666 + +- URL Shortner 서비스를 구현했습니다. +- 프론트엔드와 백엔드 모두 구현했습니다. +- Shortening key 알고리즘은 Base62, Short UUID, Adler Hashing을 사용했습니다. +- 추가적인 팀 미션으로 배포를 진행했습니다. +- 개인 미션으로 Redis를 활용한 캐싱을 구현했습니다. +- 개인 미션으로 Docker를 이용한 배포를 진행했습니다.(도커 네트워크 활용) + +~~[Url Shortener 서비스 링크](http://ec2-3-35-240-254.ap-northeast-2.compute.amazonaws.com:3000)~~ + +## 👩‍💻 요구 사항과 구현 내용 + +- [x] URL 입력폼 제공 및 결과 출력 + > 리액트로 구현했습니다. [(링크)](https://github.com/Dev-Yesung/react-url-shortener) +- [x] URL Shortening Key는 8 Character 이내로 생성 + > base62 방식으로 인코딩 완료 +- [x] 단축된 URL 요청시 원래 URL로 리다이렉트 + > 상태코드 301(MOVE_PERMANENTLY) +- [x] 단축된 URL에 대한 요청 수 정보저장 + > MySQL 이외에 Redis를 활용하여 캐싱했습니다. +- [x] Shortening Key를 생성하는 알고리즘 2개 이상 제공하며 애플리케이션 실행중 동적으로 변경 가능 + > 추가적으로 8글자 이내의 UUID, Adler 알고리즘을 사용하였습니다. + +## 📝 Redis를 활용한 캐싱과 MySQL과의 데이터 일치전략 + +Redis는 서버가 다운될 것에 대비해 어느 정도 데이터를 백업해두는 기능을 갖고 있습니다.
+하지만 완벽한 백업이 아니기 때문에 Redis에서 사용하는 캐싱 데이터는
+다음의 조건을 만족하는 데이터에 사용하면 좋다고 생각합니다.
+
+1) 캐싱 했을 때의 성능(속도) 향상 +2) 손실되어도 괜찮은 데이터 + +URL Shortener서버는 Redis를 두 가지 용도로 사용 중 입니다. +1) 리다이렉션으로 보낼 원본 url을 빠르게 찾기 위해 +2) 인코딩된 url의 총 click 수를 빠르게 저장하고 조회하기 위해 + +----- +1️⃣
+인코딩된 shortening key에 매핑되는 원본 URL은 자주 변경되지 않습니다.
+그래서 RDB 저장소까지 가서 읽기를 수행할 필요가 없고 캐시 저장소를 통해
+빠르게 요청을 처리하면 좋을 거 같다고 생각했습니다.
+ +----- +2️⃣
+클릭 수(API요청 횟수)에 관한 업데이트는 1차적으로 Redis에만 진행되도록 했습니다.
+그 이유는 클릭 수는 손실되어도 타격이 큰 데이터가 아니라는 생각을 했습니다.
+물론 선착순 당첨 이벤트와 같이 특수한 상황에서 클릭수의 경우 정확도가 중요하겠고
+마케팅 데이터로 활용할 클릭수는 어느 정도 의미가 있겠지만,
+현재는 그런 특수한 상황이 아니라 배제했습니다.
+ +----- +3️⃣
+속도나 동시성을 어느 정도 고려해주는게 좋다는 생각을 해서 Redis를 활용했습니다.
+Redis는 싱글 스레드 방식으로 작동하기 때문에 동시에 여러 스레드가 접근할 경우
+순차적으로 요청을 처리하게 되어 데이터 정합성을 보장하고
+인메모리 데이터베이스라 속도 또한 보장하기 때문입니다.
+ +----- +4️⃣
+1차적으로 Redis에서 업데이트된 클릭수는
+매일 새벽 3시(트래픽이 가장 적게 몰릴것 같읕 시간)에
+MySQL로 데이터를 업데이트 합니다. 이때 처리하는 방법은
+@Scheduled(스프링 스케줄러)를 사용하였습니다.
+ +----- +5️⃣
+클릭수에 관한 데이터를 다루는 방법으로,
+Redis 서버가 다운 될 것을 고려해 MySQL에도 클릭수를 저장할까 생각했지만,
+클릭수가 크게 중요한 데이터가 아니고 서비스의 본질은
+긴 URL을 줄이는 것과 빠르게 원본 URL을 찾아주는 거라 생각해 배제했습니다.
+ +----- +6️⃣
+Redis에 계속해서 캐시 데이터를 두게 되면 메모리 낭비가 심할거라 생각해
+최근에 클릭한 데이터들에는 만료시간을 연장하는 알고리즘을 적용할까 생각했지만,
+구현할 시간이 없어 패스했습니다!
+ diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..9e67466e --- /dev/null +++ b/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.4' + id 'io.spring.dependency-management' version '1.1.3' +} + +group = 'kr.co.programmers' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-web' + + compileOnly 'org.projectlombok:lombok:1.18.30' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + annotationProcessor 'org.projectlombok:lombok:1.18.30' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..033e24c4 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9f4197d5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..e5ddb406 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$(ls -ld "$app_path") + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$(cd "${APP_HOME:-./}" && pwd -P) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn() { + echo "$*" +} >&2 + +die() { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$(uname)" in #( +CYGWIN*) cygwin=true ;; #( +Darwin*) darwin=true ;; #( +MSYS* | MINGW*) msys=true ;; #( +NONSTOP*) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ]; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop"; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$(ulimit -H -n) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) : ;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys"; then + APP_HOME=$(cygpath --path --mixed "$APP_HOME") + CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") + + JAVACMD=$(cygpath --unix "$JAVACMD") + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg; do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) + t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] + ;; #( + *) false ;; + esac + then + arg=$(cygpath --path --ignore --mixed "$arg") + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1; then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..6be9ab47 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'springboot-url-shortener' diff --git a/src/main/java/shortener/SpringbootUrlShortenerApplication.java b/src/main/java/shortener/SpringbootUrlShortenerApplication.java new file mode 100644 index 00000000..8c21f3d2 --- /dev/null +++ b/src/main/java/shortener/SpringbootUrlShortenerApplication.java @@ -0,0 +1,13 @@ +package shortener; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringbootUrlShortenerApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringbootUrlShortenerApplication.class, args); + } + +} diff --git a/src/main/java/shortener/application/CacheMigrationScheduler.java b/src/main/java/shortener/application/CacheMigrationScheduler.java new file mode 100644 index 00000000..9bc0ceca --- /dev/null +++ b/src/main/java/shortener/application/CacheMigrationScheduler.java @@ -0,0 +1,44 @@ +package shortener.application; + +import java.util.List; +import java.util.Map; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; +import shortener.domain.ClicksCacheRepository; +import shortener.domain.ShortUrl; +import shortener.infrastructure.ShortUrlJpaRepository; + +@Slf4j +@Transactional +@Service +public class CacheMigrationScheduler { + + private final ShortUrlJpaRepository shortUrlRepository; + private final ClicksCacheRepository clicksCacheRepository; + + public CacheMigrationScheduler( + ShortUrlJpaRepository shortUrlRepository, + ClicksCacheRepository clicksCacheRepository + ) { + this.shortUrlRepository = shortUrlRepository; + this.clicksCacheRepository = clicksCacheRepository; + } + + @Scheduled(cron = "0 0 3 * * *") + public void migrateClicksCacheDataToMasterDatabase() { + log.info("Run scheduler to migrate clicks from cache to master database..."); + log.info("Find all shortUrl from master database..."); + List savedShortUrls = shortUrlRepository.findAll(); + log.info("Success to find all shortUrl."); + Map clicksForMigration = clicksCacheRepository.findAll(savedShortUrls); + clicksForMigration.forEach((id, clicks) -> { + log.info("Trying to update id({}) clicks({})...", id, clicks); + shortUrlRepository.updateClicks(id, clicks); + log.info("Success to update"); + }); + } +} diff --git a/src/main/java/shortener/application/ShortenerService.java b/src/main/java/shortener/application/ShortenerService.java new file mode 100644 index 00000000..c0a86fcb --- /dev/null +++ b/src/main/java/shortener/application/ShortenerService.java @@ -0,0 +1,133 @@ +package shortener.application; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; +import shortener.application.dto.response.ClicksResponse; +import shortener.application.dto.response.ShortUrlCreateResponse; +import shortener.domain.ClicksCacheRepository; +import shortener.domain.OriginalUrlCacheRepository; +import shortener.domain.ShortUrl; +import shortener.global.error.ErrorCode; +import shortener.global.error.exception.EntityNotFoundException; +import shortener.infrastructure.ShortUrlJpaRepository; +import shortener.domain.urlencoder.UrlEncoder; + +@Slf4j +@Transactional +@Service +public class ShortenerService { + + private final ShortUrlJpaRepository shortUrlRepository; + private final OriginalUrlCacheRepository originalUrlCacheRepository; + private final ClicksCacheRepository clicksCacheRepository; + + public ShortenerService( + ShortUrlJpaRepository shortUrlRepository, + @Qualifier("originalUrlRedisCacheRepository") + OriginalUrlCacheRepository originalUrlCacheRepository, + @Qualifier("clicksRedisCacheRepository") + ClicksCacheRepository clicksCacheRepository + ) { + this.shortUrlRepository = shortUrlRepository; + this.originalUrlCacheRepository = originalUrlCacheRepository; + this.clicksCacheRepository = clicksCacheRepository; + } + + public ShortUrlCreateResponse saveNewShortUrl(String originalUrl, int algorithmId) { + ShortUrl newShortUrl = saveShortUrlInMaster(originalUrl, algorithmId); + saveOriginalUrlAndClicksInCache(newShortUrl); + + return ShortUrlCreateResponse.of(newShortUrl); + } + + public String findOriginalUrl(String encodedUrl) { + log.info("Trying to find originalUrl from cache..."); + Optional cachedOriginalUrl = originalUrlCacheRepository.findOriginalUrlByEncodedUrl(encodedUrl); + if (cachedOriginalUrl.isPresent()) { + String originalUrl = cachedOriginalUrl.get(); + log.info("Success to find originalUrl({}) in cache.", originalUrl); + updateClicksInCache(encodedUrl); + + return originalUrl; + } + log.warn("Fail to find originalUrl from cache!!!"); + + log.info("Switch to master database system"); + String originalUrl = findOriginalUrlByNoCache(encodedUrl); + + log.info("Trying to save shortUrl(key({}), value({})) in cache...", encodedUrl, originalUrl); + ShortUrl shortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL)); + saveOriginalUrlAndClicksInCache(shortUrl); + updateClicksInCache(encodedUrl); + + return originalUrl; + } + + public ClicksResponse findClicks(String encodedUrl) { + log.info("Trying to find clicks for encodedUrl({}) from cache...", encodedUrl); + Optional clicksFromCache = clicksCacheRepository.findClicksByEncodedUrl(encodedUrl); + if (clicksFromCache.isPresent()) { + Long clicks = clicksFromCache.get(); + log.info("Success to find clicks(encodedUrl({}), clicks({})) from cache.", encodedUrl, clicks); + + return ClicksResponse.of(encodedUrl, clicks); + } + log.warn("Fail to find clicks from cache!!!"); + + log.info("Switch to master database system"); + log.info("Trying to find clicks for encodedUrl({}) from master database...", encodedUrl); + ShortUrl shortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL)); + long clicks = shortUrl.getClicks(); + log.info("Success to find clicks(encodedUrl({}), clicks({})) from master database.", encodedUrl, clicks); + + saveOriginalUrlAndClicksInCache(shortUrl); + + return ClicksResponse.of(encodedUrl, clicks); + } + + private ShortUrl saveShortUrlInMaster(String originalUrl, int algorithmId) { + log.info("Start creating new shortUrl Entity..."); + ShortUrl newShortUrl = new ShortUrl(originalUrl); + ShortUrl savedShortUrl = shortUrlRepository.save(newShortUrl); + log.info("Success to create new Entity(id({}))", savedShortUrl.getId()); + + log.info("Start encoding new shortUrl..."); + String encodedUrl = UrlEncoder.getShortUrl(savedShortUrl, algorithmId); + + log.info("Start saving new shortUrl({}) in master database...", encodedUrl); + savedShortUrl.updateEncodedUrl(encodedUrl); + log.info("Success to save new shortUrl"); + + return savedShortUrl; + } + + private void saveOriginalUrlAndClicksInCache(ShortUrl shortUrl) { + log.info("Trying to save shortUrl in cache..."); + originalUrlCacheRepository.save(shortUrl); + clicksCacheRepository.save(shortUrl); + log.info("Success to save shortUrl in cache."); + } + + private String findOriginalUrlByNoCache(String encodedUrl) { + log.info("Trying to find originalUrl by encodedUrl({})...", encodedUrl); + ShortUrl foundShortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL)); + String originalUrl = foundShortUrl.getOriginalUrl(); + log.info("Success to find originalUrl({})", originalUrl); + + return originalUrl; + } + + private void updateClicksInCache(String encodedUrl) { + log.info("Update clicks in cache..."); + clicksCacheRepository.updateClicks(encodedUrl); + log.info("Success to update clicks in cache."); + } +} diff --git a/src/main/java/shortener/application/dto/response/ClicksResponse.java b/src/main/java/shortener/application/dto/response/ClicksResponse.java new file mode 100644 index 00000000..8e0ce980 --- /dev/null +++ b/src/main/java/shortener/application/dto/response/ClicksResponse.java @@ -0,0 +1,11 @@ +package shortener.application.dto.response; + +public record ClicksResponse( + String encodedUrl, + Long clicks +) { + + public static ClicksResponse of(String encodedUrl, Long clicks) { + return new ClicksResponse(encodedUrl, clicks); + } +} diff --git a/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java b/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java new file mode 100644 index 00000000..039738a7 --- /dev/null +++ b/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java @@ -0,0 +1,18 @@ +package shortener.application.dto.response; + +import shortener.domain.ShortUrl; + +public record ShortUrlCreateResponse( + Long id, + String shortUrl, + String originalUrl +) { + + public static ShortUrlCreateResponse of(ShortUrl newShortUrl) { + Long shortUrlId = newShortUrl.getId(); + String shortUrl = newShortUrl.getEncodedUrl(); + String originalUrl = newShortUrl.getOriginalUrl(); + + return new ShortUrlCreateResponse(shortUrlId, shortUrl, originalUrl); + } +} diff --git a/src/main/java/shortener/config/ClicksCacheRedisConfig.java b/src/main/java/shortener/config/ClicksCacheRedisConfig.java new file mode 100644 index 00000000..c2da708d --- /dev/null +++ b/src/main/java/shortener/config/ClicksCacheRedisConfig.java @@ -0,0 +1,40 @@ +package shortener.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories(redisTemplateRef = "redisTemplateForClicks") +public class ClicksCacheRedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory clicksCacheRedisConnectionFactory() { + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort); + lettuceConnectionFactory.setDatabase(1); + + return lettuceConnectionFactory; + } + + @Bean + public RedisTemplate redisTemplateForClicks() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(clicksCacheRedisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + + return redisTemplate; + } +} diff --git a/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java b/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java new file mode 100644 index 00000000..092562b1 --- /dev/null +++ b/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java @@ -0,0 +1,39 @@ +package shortener.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories(redisTemplateRef = "redisTemplateForOriginalUrl") +public class OriginalUrlCacheRedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory originalUrlCacheRedisConnectionFactory() { + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort); + lettuceConnectionFactory.setDatabase(0); + + return lettuceConnectionFactory; + } + + @Bean + public RedisTemplate redisTemplateForOriginalUrl() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(originalUrlCacheRedisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/shortener/config/SchedulerConfig.java b/src/main/java/shortener/config/SchedulerConfig.java new file mode 100644 index 00000000..feae8001 --- /dev/null +++ b/src/main/java/shortener/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package shortener.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/src/main/java/shortener/config/WebConfig.java b/src/main/java/shortener/config/WebConfig.java new file mode 100644 index 00000000..3b0628b2 --- /dev/null +++ b/src/main/java/shortener/config/WebConfig.java @@ -0,0 +1,20 @@ +package shortener.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@EnableWebMvc +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://url-shortener-react:3000", "http://localhost:3000", + "http://ec2-3-35-240-254.ap-northeast-2.compute.amazonaws.com:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedHeaders("*"); + } +} diff --git a/src/main/java/shortener/domain/ClicksCacheRepository.java b/src/main/java/shortener/domain/ClicksCacheRepository.java new file mode 100644 index 00000000..66008301 --- /dev/null +++ b/src/main/java/shortener/domain/ClicksCacheRepository.java @@ -0,0 +1,15 @@ +package shortener.domain; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface ClicksCacheRepository { + ShortUrl save(ShortUrl shortUrl); + + void updateClicks(String encodedUrl); + + Optional findClicksByEncodedUrl(String encodedUrl); + + Map findAll(List shortUrls); +} diff --git a/src/main/java/shortener/domain/OriginalUrlCacheRepository.java b/src/main/java/shortener/domain/OriginalUrlCacheRepository.java new file mode 100644 index 00000000..e9a16011 --- /dev/null +++ b/src/main/java/shortener/domain/OriginalUrlCacheRepository.java @@ -0,0 +1,9 @@ +package shortener.domain; + +import java.util.Optional; + +public interface OriginalUrlCacheRepository { + ShortUrl save(ShortUrl shortUrl); + + Optional findOriginalUrlByEncodedUrl(String encodedUrl); +} diff --git a/src/main/java/shortener/domain/ShortUrl.java b/src/main/java/shortener/domain/ShortUrl.java new file mode 100644 index 00000000..e3709bb7 --- /dev/null +++ b/src/main/java/shortener/domain/ShortUrl.java @@ -0,0 +1,59 @@ +package shortener.domain; + +import org.springframework.data.redis.core.index.Indexed; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.extern.slf4j.Slf4j; +import shortener.global.error.ErrorCode; +import shortener.global.error.exception.BusinessException; + +@Slf4j +@Entity +@Table(name = "short_urls") +public class ShortUrl { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + @Indexed + @Column(name = "encoded_url", unique = true) + String encodedUrl; + @Column(name = "original_url", nullable = false, length = 2000) + String originalUrl; + @Column(name = "clicks", nullable = false) + long clicks = 0L; + + protected ShortUrl() { + } + + public ShortUrl(String originalUrl) { + log.info("Create ShortUrl Entity..."); + this.originalUrl = originalUrl; + } + + public void updateEncodedUrl(String encodedUrl) { + log.info("Update shortUrl({}) to Entity(id({}))", encodedUrl, this.id); + this.encodedUrl = encodedUrl; + } + + public Long getId() { + return id; + } + + public String getEncodedUrl() { + return encodedUrl; + } + + public String getOriginalUrl() { + return originalUrl; + } + + public long getClicks() { + return clicks; + } +} diff --git a/src/main/java/shortener/domain/urlencoder/UrlEncoder.java b/src/main/java/shortener/domain/urlencoder/UrlEncoder.java new file mode 100644 index 00000000..e2744378 --- /dev/null +++ b/src/main/java/shortener/domain/urlencoder/UrlEncoder.java @@ -0,0 +1,32 @@ +package shortener.domain.urlencoder; + +import shortener.domain.ShortUrl; +import shortener.domain.urlencoder.algorithm.AdlerHash; +import shortener.global.error.ErrorCode; +import shortener.global.error.exception.BusinessException; +import shortener.domain.urlencoder.algorithm.AlgorithmType; +import shortener.domain.urlencoder.algorithm.Base62Hash; +import shortener.domain.urlencoder.algorithm.ShortUuidHash; + +public class UrlEncoder { + + private static final Base62Hash base62Hash = new Base62Hash(); + private static final ShortUuidHash shortUuidHash = new ShortUuidHash(); + private static final AdlerHash adlerHash = new AdlerHash(); + + public static String getShortUrl(ShortUrl shortUrl, int algorithmId) { + if (AlgorithmType.BASE62.getId() == algorithmId) { + Long id = shortUrl.getId(); + + return base62Hash.encode(id); + } else if (AlgorithmType.SHORT_UUID.getId() == algorithmId) { + return shortUuidHash.encode(); + } else if (AlgorithmType.ADLER.getId() == algorithmId) { + String originalUrl = shortUrl.getOriginalUrl(); + + return adlerHash.encode(originalUrl); + } else { + throw new BusinessException(ErrorCode.INVALID_ENCODING_ALGORITHM); + } + } +} diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java b/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java new file mode 100644 index 00000000..fe7e76c9 --- /dev/null +++ b/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java @@ -0,0 +1,18 @@ +package shortener.domain.urlencoder.algorithm; + +public class AdlerHash { + + private static final int MOD_ADLER = 65521; + private static int dataSum = 1; + private static int rollingSum = 0; + + public String encode(String originalUrl) { + for (char urlElement : originalUrl.toCharArray()) { + dataSum = (dataSum + urlElement) % MOD_ADLER; + rollingSum = (rollingSum + dataSum) % MOD_ADLER; + } + int key = (rollingSum << 16) | dataSum; + + return Integer.toHexString(key); + } +} diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java b/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java new file mode 100644 index 00000000..0b17a28d --- /dev/null +++ b/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java @@ -0,0 +1,23 @@ +package shortener.domain.urlencoder.algorithm; + +public enum AlgorithmType { + BASE62(1, "Base62"), + SHORT_UUID(2, "ShortUuid"), + ADLER(3, "Adler"); + + private final int id; + private final String name; + + AlgorithmType(int id, String name) { + this.id = id; + this.name = name; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java b/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java new file mode 100644 index 00000000..8b627362 --- /dev/null +++ b/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java @@ -0,0 +1,30 @@ +package shortener.domain.urlencoder.algorithm; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class Base62Hash { + + private static final char[] base62CryptoWords = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z' + }; + + public String encode(long index) { + StringBuilder encodedUrlBuilder = new StringBuilder(); + log.info("Start encoding shortUrl by id({})", index); + do { + int cryptoIndex = (int)(index % 62); + encodedUrlBuilder.append(base62CryptoWords[cryptoIndex]); + index /= 62; + } while (index % 62 > 0); + log.info("Success to encode to shortUrl({})", encodedUrlBuilder); + + return encodedUrlBuilder.toString(); + } +} diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java b/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java new file mode 100644 index 00000000..6b18a940 --- /dev/null +++ b/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java @@ -0,0 +1,12 @@ +package shortener.domain.urlencoder.algorithm; + +import java.util.UUID; + +public class ShortUuidHash { + + public String encode() { + return UUID.randomUUID() + .toString() + .substring(0, 7); + } +} diff --git a/src/main/java/shortener/global/error/ErrorCode.java b/src/main/java/shortener/global/error/ErrorCode.java new file mode 100644 index 00000000..107e3306 --- /dev/null +++ b/src/main/java/shortener/global/error/ErrorCode.java @@ -0,0 +1,24 @@ +package shortener.global.error; + +public enum ErrorCode { + INVALID_REQUEST_NUMBERS("U001", "API의 총 요청 횟수가 잘 못 되었습니다."), + NOT_FOUND_MAPPED_URL("U002", "요청받은 URL을 찾을 수 없습니다."), + INVALID_ENCODING_ALGORITHM("U003", "올바르지 않은 인코딩 요청입니다."), + INVALID_INPUT_VALUE("U004", "올바르지 않은 입력입니다."); + + private final String code; + private final String message; + + ErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/shortener/global/error/ErrorResponse.java b/src/main/java/shortener/global/error/ErrorResponse.java new file mode 100644 index 00000000..6a35ab92 --- /dev/null +++ b/src/main/java/shortener/global/error/ErrorResponse.java @@ -0,0 +1,80 @@ +package shortener.global.error; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.validation.BindingResult; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public record ErrorResponse( + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss") + LocalDateTime timestamp, + String code, + List errors, + String message +) { + + private ErrorResponse(ErrorCode code, List errors) { + this(LocalDateTime.now(), code.getCode(), errors, code.getMessage()); + } + + private ErrorResponse(ErrorCode code) { + this(LocalDateTime.now(), code.getCode(), new ArrayList<>(), code.getMessage()); + } + + public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) { + return new ErrorResponse(errorCode, FieldError.of(bindingResult)); + } + + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode); + } + + public static ErrorResponse of(final ErrorCode code, final List errors) { + return new ErrorResponse(code, errors); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class FieldError { + + private String field; + private String value; + private String reason; + + private FieldError(final String field, final String value, final String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + + public static List of(final String field, final String value, final String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of(final BindingResult bindingResult) { + final List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/shortener/global/error/GlobalExceptionHandler.java b/src/main/java/shortener/global/error/GlobalExceptionHandler.java new file mode 100644 index 00000000..7a165198 --- /dev/null +++ b/src/main/java/shortener/global/error/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package shortener.global.error; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import shortener.global.error.exception.BusinessException; +import shortener.global.error.exception.EntityNotFoundException; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e) { + ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode()); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode()); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult()); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/shortener/global/error/exception/BusinessException.java b/src/main/java/shortener/global/error/exception/BusinessException.java new file mode 100644 index 00000000..cfce3f53 --- /dev/null +++ b/src/main/java/shortener/global/error/exception/BusinessException.java @@ -0,0 +1,15 @@ +package shortener.global.error.exception; + +import shortener.global.error.ErrorCode; + +public class BusinessException extends RuntimeException { + ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/shortener/global/error/exception/CacheNotFoundException.java b/src/main/java/shortener/global/error/exception/CacheNotFoundException.java new file mode 100644 index 00000000..ef54ad09 --- /dev/null +++ b/src/main/java/shortener/global/error/exception/CacheNotFoundException.java @@ -0,0 +1,16 @@ +package shortener.global.error.exception; + +import shortener.global.error.ErrorCode; + +public class CacheNotFoundException extends RuntimeException { + + ErrorCode errorCode; + + public CacheNotFoundException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/shortener/global/error/exception/EntityNotFoundException.java b/src/main/java/shortener/global/error/exception/EntityNotFoundException.java new file mode 100644 index 00000000..311bac45 --- /dev/null +++ b/src/main/java/shortener/global/error/exception/EntityNotFoundException.java @@ -0,0 +1,15 @@ +package shortener.global.error.exception; + +import shortener.global.error.ErrorCode; + +public class EntityNotFoundException extends RuntimeException { + ErrorCode errorCode; + + public EntityNotFoundException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java b/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java new file mode 100644 index 00000000..76838fc3 --- /dev/null +++ b/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java @@ -0,0 +1,81 @@ +package shortener.infrastructure; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; +import shortener.domain.ClicksCacheRepository; +import shortener.domain.ShortUrl; +import shortener.global.error.ErrorCode; +import shortener.global.error.exception.CacheNotFoundException; + +@Slf4j +@Repository +public class ClicksRedisCacheRepository implements ClicksCacheRepository { + + private final ValueOperations valueOperations; + + public ClicksRedisCacheRepository(RedisTemplate redisTemplateForClicks) { + this.valueOperations = redisTemplateForClicks.opsForValue(); + } + + @Override + public ShortUrl save(ShortUrl shortUrl) { + String encodedUrl = shortUrl.getEncodedUrl(); + long clicks = shortUrl.getClicks(); + + log.info("Trying to save clicks(key({}), value({}) into cache repository...", encodedUrl, clicks); + valueOperations.set(encodedUrl, clicks); + log.info("Success to save into cache."); + + return shortUrl; + } + + @Override + public void updateClicks(String encodedUrl) { + log.info("Trying to find clicks in cache..."); + Long clicks = Optional.ofNullable(valueOperations.get(encodedUrl)) + .orElseThrow(() -> new CacheNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL)); + log.info("Success to find clicks(value({})) from cache.", clicks); + + log.info("Trying to update clicks({}) in cache...", clicks); + valueOperations.set(encodedUrl, clicks + 1); + log.info("Success to update clicks(value({})) in cache.", clicks + 1); + } + + @Override + public Optional findClicksByEncodedUrl(String encodedUrl) { + log.info("Get clicks from cache..."); + Long clicks = valueOperations.get(encodedUrl); + + return Optional.ofNullable(clicks); + } + + @Override + public Map findAll(List shortUrls) { + log.info("Trying to map shortUrl id to clicks from cache..."); + return shortUrls.stream() + .collect(Collectors.toConcurrentMap(shortUrl -> { + Long id = shortUrl.getId(); + log.info("key(id): {}", id); + + return id; + }, shortUrl -> { + String encodedUrl = shortUrl.getEncodedUrl(); + Long clicks = Optional.ofNullable(valueOperations.get(encodedUrl)) + .orElseThrow(() -> new RuntimeException( + MessageFormat.format("Can not find clicks for key({0})", encodedUrl) + )); + log.info("value(clicks): {}", clicks); + + return clicks; + })); + } +} diff --git a/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java b/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java new file mode 100644 index 00000000..b965daab --- /dev/null +++ b/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java @@ -0,0 +1,42 @@ +package shortener.infrastructure; + +import java.util.Optional; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; +import shortener.domain.OriginalUrlCacheRepository; +import shortener.domain.ShortUrl; + +@Slf4j +@Repository +public class OriginalUrlRedisCacheRepository implements OriginalUrlCacheRepository { + + private final ValueOperations valueOperations; + + public OriginalUrlRedisCacheRepository(RedisTemplate redisTemplateForOriginalUrl) { + this.valueOperations = redisTemplateForOriginalUrl.opsForValue(); + } + + @Override + public ShortUrl save(ShortUrl shortUrl) { + String encodedUrl = shortUrl.getEncodedUrl(); + String originalUrl = shortUrl.getOriginalUrl(); + + log.info("Trying to save originalUrl(key({}), value({}) into cache...", encodedUrl, originalUrl); + valueOperations.set(encodedUrl, originalUrl); + log.info("Success to save into cache."); + + return shortUrl; + } + + @Override + public Optional findOriginalUrlByEncodedUrl(String encodedUrl) { + log.info("Get original url from cache..."); + String originalUrl = valueOperations.get(encodedUrl); + + return Optional.ofNullable(originalUrl); + } +} diff --git a/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java b/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java new file mode 100644 index 00000000..85063436 --- /dev/null +++ b/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java @@ -0,0 +1,19 @@ +package shortener.infrastructure; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import shortener.domain.ShortUrl; + +public interface ShortUrlJpaRepository extends JpaRepository { + + Optional findShortUrlByEncodedUrl(String encodedUrl); + + @Modifying + @Query("UPDATE ShortUrl su SET su.clicks = :clicks WHERE su.id = :id") + void updateClicks(@Param("id") Long id, @Param("clicks") Long clicks); +} diff --git a/src/main/java/shortener/presentation/ShortenerController.java b/src/main/java/shortener/presentation/ShortenerController.java new file mode 100644 index 00000000..46cae68d --- /dev/null +++ b/src/main/java/shortener/presentation/ShortenerController.java @@ -0,0 +1,91 @@ +package shortener.presentation; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.extern.slf4j.Slf4j; +import shortener.application.ShortenerService; +import shortener.application.dto.response.ClicksResponse; +import shortener.application.dto.response.ShortUrlCreateResponse; + +@Slf4j +@RestController +public class ShortenerController { + + private final ShortenerService shortenerService; + + public ShortenerController(ShortenerService shortenerService) { + this.shortenerService = shortenerService; + } + + @PostMapping("/v1/util/short-url/{algorithmId}") + public ResponseEntity createShortUrl( + String originalUrl, + @PathVariable int algorithmId + ) { + log.info("Receive request to create originalUrl({}) to shortURL...", originalUrl); + ShortUrlCreateResponse response = shortenerService.saveNewShortUrl(originalUrl, algorithmId); + log.info("Success to create shortURL : {}", response.shortUrl()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); + } + + @GetMapping("/{encodedUrl}") + public ResponseEntity getOriginalUrl(@PathVariable String encodedUrl) { + String originalUrl = shortenerService.findOriginalUrl(encodedUrl); + HttpHeaders headers = createRedirectionHeader(originalUrl); + + return ResponseEntity + .status(HttpStatus.MOVED_PERMANENTLY) + .headers(headers) + .build(); + } + + @GetMapping("/{encodedUrl}/clicks") + public ResponseEntity getClicks(@PathVariable String encodedUrl) { + log.info("Receive request to get clicks for encodedUrl({})...", encodedUrl); + ClicksResponse response = shortenerService.findClicks(encodedUrl); + log.info("Success to get clicks({}) for encodedUrl({})", + response.clicks(), response.encodedUrl()); + + return ResponseEntity.ok(response); + } + + private HttpHeaders createRedirectionHeader(String originalUrl) { + String httpAppendedOriginalUrl = appendHttpToUrlIfAbsent(originalUrl); + + log.info("Create redirection Http headers..."); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.HOST, "http://yosongsong.shortener.co.kr"); + headers.add(HttpHeaders.SERVER, "Tomcat"); + headers.add(HttpHeaders.LOCATION, httpAppendedOriginalUrl); + headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0"); + log.info("Success to create headers"); + + return headers; + } + + private String appendHttpToUrlIfAbsent(String originalUrl) { + log.info("Trying to append \"http://\" to url if absent..."); + boolean hasHttp = originalUrl.startsWith("http://"); + boolean hasHttps = originalUrl.startsWith("https://"); + + if (hasHttp || hasHttps) { + log.info("Already exist \"http://\" or \"https://\" => {}", originalUrl); + + return originalUrl; + } + + String httpAppended = "http://" + originalUrl; + log.info("append \"http://\" => {}", httpAppended); + + return httpAppended; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..3296a2ec --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: 8081 + +spring: + + datasource: + url: jdbc:mysql://url-shortener-mysql:3306/url_shortener + username: root + password: 12345 + driver-class-name: com.mysql.cj.jdbc.Driver + + h2: + console: + enabled: true + + jpa: + open-in-view: true + hibernate: + ddl-auto: create + naming: + implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: url-shortener-redis + port: 6379