diff --git a/.github/workflows/spring-batch-db-example.yml b/.github/workflows/spring-batch-db-example.yml new file mode 100644 index 0000000..8348063 --- /dev/null +++ b/.github/workflows/spring-batch-db-example.yml @@ -0,0 +1,68 @@ +name: spring-batch-db-examples CI Build + +on: + pull_request: + branches: [master] + paths: + - "spring-batch-db-examples/**" + types: + - opened + - synchronize + - reopened + +jobs: + + integration-tests: + name: Run Unit & Integration Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: spring-batch-db-examples + strategy: + matrix: + distribution: [ 'temurin' ] + java: [ '21' ] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5.0.0 + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + cache: 'maven' + - name: Build and analyze + run: ./mvnw clean verify + + health-check: + name: Health Check on Services + runs-on: ubuntu-latest + steps: + - name: Checkout repository and submodules + uses: actions/checkout@v5 + with: + submodules: true + + - name: Extract service names from docker compose + id: services + run: | + echo "services<> $GITHUB_OUTPUT + docker compose -f ./spring-batch-db-examples/compose.yaml config --services >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Start containers with Compose Action + uses: hoverkraft-tech/compose-action@v2.4.0 + with: + compose-file: './spring-batch-db-examples/compose.yaml' + services: ${{ steps.services.outputs.services }} + up-flags: '--build' + down-flags: '--volumes' + + - name: Wait for containers to initialize + run: sleep 10 + + - name: Check container health + run: | + ./.github/scripts/check-container-health.sh "${{ steps.services.outputs.services }}" \ No newline at end of file diff --git a/.github/workflows/spring-batch-file-example.yml b/.github/workflows/spring-batch-file-example.yml new file mode 100644 index 0000000..152bc53 --- /dev/null +++ b/.github/workflows/spring-batch-file-example.yml @@ -0,0 +1,68 @@ +name: spring-batch-file-examples CI Build + +on: + pull_request: + branches: [master] + paths: + - "spring-batch-file-examples/**" + types: + - opened + - synchronize + - reopened + +jobs: + + integration-tests: + name: Run Unit & Integration Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: spring-batch-file-examples + strategy: + matrix: + distribution: [ 'temurin' ] + java: [ '21' ] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5.0.0 + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + cache: 'maven' + - name: Build and analyze + run: ./mvnw clean verify + + health-check: + name: Health Check on Services + runs-on: ubuntu-latest + steps: + - name: Checkout repository and submodules + uses: actions/checkout@v5 + with: + submodules: true + + - name: Extract service names from docker compose + id: services + run: | + echo "services<> $GITHUB_OUTPUT + docker compose -f ./spring-batch-file-examples/compose.yaml config --services >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Start containers with Compose Action + uses: hoverkraft-tech/compose-action@v2.4.0 + with: + compose-file: './spring-batch-file-examples/compose.yaml' + services: ${{ steps.services.outputs.services }} + up-flags: '--build' + down-flags: '--volumes' + + - name: Wait for containers to initialize + run: sleep 10 + + - name: Check container health + run: | + ./.github/scripts/check-container-health.sh "${{ steps.services.outputs.services }}" \ No newline at end of file diff --git a/README.md b/README.md index b252b95..0d75c48 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Below you will find a summary table of each subproject. For more details, please | Name | Description | |---------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| [Spring Boot + Spring Batch + Database](./spring-batch-db-examples) | Demonstrates a basic integration between Spring Batch and Databases | +| [Spring Boot + Spring Batch + File](./spring-batch-file-examples) | Demonstrates a basic integration between Spring Batch and Files | | [Spring Boot + Jasper](./spring-jasper-example) | Demonstrates a basic integration between Spring Boot and Jasper. | | [Spring Boot + Kafka](./spring-kafka-example) | Demonstrates a basic integration between Spring Boot and Apache Kafka. | | [Spring Boot + Keycloak](./spring-keycloak-example) | Demonstrates a basic integration between Spring Boot and Keycloak. | diff --git a/pom.xml b/pom.xml index 9cc5fed..7ed636f 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,8 @@ spring-keycloak-example spring-jasper-example spring-oracle-example + spring-batch-file-examples + spring-batch-db-examples - \ No newline at end of file + diff --git a/spring-batch-db-examples/.dockerignore b/spring-batch-db-examples/.dockerignore new file mode 100644 index 0000000..8893889 --- /dev/null +++ b/spring-batch-db-examples/.dockerignore @@ -0,0 +1,62 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.factorypath +**/.git +**/.gitignore +**/.idea +**/.project +**/.sts4-cache +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.next +**/.cache +**/*.dbmdl +**/*.jfm +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/secrets.dev.yaml +**/values.dev.yaml +**/vendor +LICENSE +README.md +**/*.class +**/*.iml +**/*.ipr +**/*.iws +**/*.log +**/.apt_generated +**/.gradle +**/.gradletasknamecache +**/.nb-gradle +**/.springBeans +**/build +**/dist +**/gradle-app.setting +**/nbbuild +**/nbdist +**/nbproject/private +**/target +*.ctxt +.mtj.tmp +.mvn/timing.properties +buildNumber.properties +dependency-reduced-pom.xml +hs_err_pid* +pom.xml.next +pom.xml.releaseBackup +pom.xml.tag +pom.xml.versionsBackup +release.properties +replay_pid* \ No newline at end of file diff --git a/spring-batch-db-examples/.gitattributes b/spring-batch-db-examples/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/spring-batch-db-examples/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/spring-batch-db-examples/.github/workflows/maven.yml b/spring-batch-db-examples/.github/workflows/maven.yml new file mode 100644 index 0000000..16d1a66 --- /dev/null +++ b/spring-batch-db-examples/.github/workflows/maven.yml @@ -0,0 +1,30 @@ +name: CI Build + +on: + push: + branches: + - "**" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + distribution: [ 'temurin' ] + java: [ '21' ] + steps: + - uses: actions/checkout@v5 + + - name: Setup Java 21 + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + cache: 'maven' + + - name: Grant execute permission for mvnw + run: chmod +x mvnw + + - name: Build with Maven + run: ./mvnw clean verify \ No newline at end of file diff --git a/spring-batch-db-examples/.gitignore b/spring-batch-db-examples/.gitignore new file mode 100644 index 0000000..a71cd2b --- /dev/null +++ b/spring-batch-db-examples/.gitignore @@ -0,0 +1,32 @@ +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/spring-batch-db-examples/.mvn/wrapper/maven-wrapper.properties b/spring-batch-db-examples/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/spring-batch-db-examples/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/spring-batch-db-examples/Dockerfile b/spring-batch-db-examples/Dockerfile new file mode 100644 index 0000000..ed30a9a --- /dev/null +++ b/spring-batch-db-examples/Dockerfile @@ -0,0 +1,47 @@ +FROM eclipse-temurin:21-jdk-jammy as deps + +WORKDIR /build + +COPY --chmod=0755 mvnw mvnw +COPY .mvn/ .mvn/ + +RUN --mount=type=bind,source=pom.xml,target=pom.xml \ + --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests + +FROM deps as package + +WORKDIR /build + +COPY ./src src/ +RUN --mount=type=bind,source=pom.xml,target=pom.xml \ + --mount=type=cache,target=/root/.m2 \ + ./mvnw package -DskipTests && \ + mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar + +FROM package as extract + +WORKDIR /build + +RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted + +FROM eclipse-temurin:21-jre-jammy AS final + +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser +USER appuser + +COPY --from=extract build/target/extracted/dependencies/ ./ +COPY --from=extract build/target/extracted/spring-boot-loader/ ./ +COPY --from=extract build/target/extracted/snapshot-dependencies/ ./ +COPY --from=extract build/target/extracted/application/ ./ + +EXPOSE 8082 + +ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ] diff --git a/spring-batch-db-examples/README.md b/spring-batch-db-examples/README.md new file mode 100644 index 0000000..3f52ae7 --- /dev/null +++ b/spring-batch-db-examples/README.md @@ -0,0 +1,30 @@ +# Spring Batch Examples | DB And Async + +This project is a **Spring Boot** application demonstrating a **fully asynchronous Spring Batch job**, designed with a focus on **performance** and **scalability**. + +--- + +## 🚀 Overview + +The example showcases how to configure and run an **asynchronous Spring Batch job** that processes a large dataset efficiently. +The job reads **10,000 records** from a database table, simulating item processing by printing +`"item processed"` for each entry. + +--- + +## ⚙️ How It Works + +- The job leverages Spring Batch’s asynchronous capabilities to read and process data concurrently. +- An **H2 in-memory database** is used to store the sample data. +- The asynchronous behavior is enabled through a specific Spring profile. + +--- + +## 🧩 Technologies Used + +- **Java 21** +- **Spring Batch** +- **Spring Boot** +- **H2 Database** + +--- diff --git a/spring-batch-db-examples/compose.yaml b/spring-batch-db-examples/compose.yaml new file mode 100644 index 0000000..3dc65db --- /dev/null +++ b/spring-batch-db-examples/compose.yaml @@ -0,0 +1,22 @@ +services: + + app: + container_name: app + build: + context: . + ports: + - "8082:8082" + environment: + SERVER_PORT: "8082" + SPRING_DATASOURCE_URL: "jdbc:h2:mem:db" + SPRING_DATASOURCE_DRIVER_CLASS_NAME: "org.h2.Driver" + SPRING_DATASOURCE_USERNAME: "sa" + SPRING_DATASOURCE_PASSWORD: "" + SPRING_JPA_HIBERNATE_DDL_AUTO: "create-drop" + SPRING_H2_CONSOLE_ENABLED: "true" + SPRING_H2_CONSOLE_PATH: "/h2-console" + SPRING_BATCH_INITIALIZE_SCHEMA: "always" + SPRING_BATCH_JOB_ENABLED: "false" + SPRING_BATCH_CHUNK_SIZE: "1000" + SPRING_BATCH_BATCH_SIZE: "1000" + SPRING_PROFILES_ACTIVE: "prd" \ No newline at end of file diff --git a/spring-batch-db-examples/mvnw b/spring-batch-db-examples/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/spring-batch-db-examples/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/spring-batch-db-examples/mvnw.cmd b/spring-batch-db-examples/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/spring-batch-db-examples/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/spring-batch-db-examples/pom.xml b/spring-batch-db-examples/pom.xml new file mode 100644 index 0000000..0e7e6a6 --- /dev/null +++ b/spring-batch-db-examples/pom.xml @@ -0,0 +1,172 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + + com.io + spring-batch-db-examples + 1.0.0 + spring-batch-db-examples + Demo project for Spring Boot + + + 21 + 1.18.40 + 2.3.232 + 3.5.6 + 5.2.3 + 3.14.1 + 5.5.1 + 0.8.14 + + + + + + org.springframework.boot + spring-boot-starter-web + ${spring.boot.version} + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring.boot.version} + + + + org.springframework.boot + spring-boot-starter-batch + ${spring.boot.version} + + + + org.springframework.batch + spring-batch-integration + ${spring.batch.version} + + + + com.h2database + h2 + runtime + ${h2.version} + + + + org.projectlombok + lombok + true + ${lombok.version} + + + + org.instancio + instancio-core + ${instancio.version} + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + org.springframework.batch + spring-batch-test + test + ${spring.batch.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + + + org.projectlombok + lombok + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + org.projectlombok + lombok + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + + + + + + diff --git a/spring-batch-db-examples/src/main/java/com/io/example/DbReaderExampleApplication.java b/spring-batch-db-examples/src/main/java/com/io/example/DbReaderExampleApplication.java new file mode 100644 index 0000000..954f79e --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/DbReaderExampleApplication.java @@ -0,0 +1,13 @@ +package com.io.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DbReaderExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(DbReaderExampleApplication.class, args); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/config/BatchConfig.java b/spring-batch-db-examples/src/main/java/com/io/example/config/BatchConfig.java new file mode 100644 index 0000000..fce5ef9 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/config/BatchConfig.java @@ -0,0 +1,124 @@ +package com.io.example.config; + +import com.io.example.listener.LoggingStepListener; +import com.io.example.mapper.TestEntityMapper; +import com.io.example.model.dto.TestDto; +import com.io.example.model.entity.TestEntity; +import com.io.example.service.TestService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.launch.support.TaskExecutorJobLauncher; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.integration.async.AsyncItemProcessor; +import org.springframework.batch.integration.async.AsyncItemWriter; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.H2PagingQueryProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Future; + +import static com.io.example.repository.query.TestEntityQuery.*; + +@Configuration +@RequiredArgsConstructor +public class BatchConfig { + + private final TestService testService; + + @Bean + public JobLauncher asyncJobLauncher(JobRepository jobRepository) throws Exception { + TaskExecutorJobLauncher jobLauncher = new TaskExecutorJobLauncher(); + jobLauncher.setJobRepository(jobRepository); + jobLauncher.setTaskExecutor(new SimpleAsyncTaskExecutor()); + jobLauncher.afterPropertiesSet(); + return jobLauncher; + } + + @Bean + public AsyncItemProcessor asyncProcessor(AsyncTaskExecutor asyncTaskExecutor) { + AsyncItemProcessor processor = new AsyncItemProcessor<>(); + processor.setDelegate(new TestEntityMapper()); + processor.setTaskExecutor(asyncTaskExecutor); + return processor; + } + + @Bean + public AsyncItemWriter asyncWriter(ItemWriter itemWriter) { + AsyncItemWriter writer = new AsyncItemWriter<>(); + writer.setDelegate(itemWriter); + return writer; + } + + @Bean + public ItemWriter writerDelegate() { + return items -> items.forEach(testService::print); + } + + @Bean + public AsyncTaskExecutor asyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); + executor.setMaxPoolSize(50); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("batch-async-"); + executor.initialize(); + return executor; + } + + @Bean + public Step asyncStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JdbcPagingItemReader asyncReader, + AsyncItemProcessor asyncProcessor, + AsyncItemWriter asyncWriter, + @Value("${spring.batch.chunk-size}") int chunkSize) { + + return new StepBuilder("asyncStep", jobRepository) + .>chunk(chunkSize, transactionManager) + .reader(asyncReader) + .processor(asyncProcessor) + .writer(asyncWriter) + .listener(new LoggingStepListener()) + .build(); + } + + @Bean + public JdbcPagingItemReader asyncReader( + DataSource dataSource, + @Value("${spring.batch.batch-size}") int batchSize + ) { + return new JdbcPagingItemReaderBuilder() + .name("asyncReader") + .dataSource(dataSource) + .queryProvider(createQueryProvider()) + .pageSize(batchSize) + .rowMapper(new BeanPropertyRowMapper<>(TestEntity.class)) + .build(); + } + + private H2PagingQueryProvider createQueryProvider() { + H2PagingQueryProvider queryProvider = new H2PagingQueryProvider(); + queryProvider.setSelectClause(SELECT_CLAUSE); + queryProvider.setFromClause(FROM_CLAUSE); + Map sortKeys = new HashMap<>(); + sortKeys.put(ORDER_ID, Order.ASCENDING); + queryProvider.setSortKeys(sortKeys); + return queryProvider; + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/controller/TestController.java b/spring-batch-db-examples/src/main/java/com/io/example/controller/TestController.java new file mode 100644 index 0000000..203a487 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/controller/TestController.java @@ -0,0 +1,39 @@ +package com.io.example.controller; + +import com.io.example.exception.BusinessException; +import com.io.example.exception.GlobalHandlerException; +import com.io.example.service.DBBatchService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.BatchStatus; +import org.springframework.context.annotation.Import; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/job") +@RequiredArgsConstructor +@Import(GlobalHandlerException.class) +public class TestController { + + private final DBBatchService DBBatchService; + + @GetMapping("/process") + public ResponseEntity processJob(){ + var response = DBBatchService.runJob(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{jobId}/status") + public ResponseEntity getJobStatus(@PathVariable Long jobId) { + try { + BatchStatus status = DBBatchService.getJobStatus(jobId); + return ResponseEntity.ok(status.name()); + } catch (BusinessException e) { + throw e; + } + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/exception/BusinessException.java b/spring-batch-db-examples/src/main/java/com/io/example/exception/BusinessException.java new file mode 100644 index 0000000..bef631f --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/exception/BusinessException.java @@ -0,0 +1,13 @@ +package com.io.example.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } + + public BusinessException(String message) { + super(message); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java b/spring-batch-db-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java new file mode 100644 index 0000000..d345db5 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java @@ -0,0 +1,19 @@ +package com.io.example.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Slf4j +@ControllerAdvice +public class GlobalHandlerException { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + log.error("BusinessException caught: {}", ex.getMessage()); + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + +} \ No newline at end of file diff --git a/spring-batch-db-examples/src/main/java/com/io/example/init/Init.java b/spring-batch-db-examples/src/main/java/com/io/example/init/Init.java new file mode 100644 index 0000000..1ee1a6b --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/init/Init.java @@ -0,0 +1,44 @@ +package com.io.example.init; + +import com.io.example.model.entity.TestEntity; +import com.io.example.repository.TestEntityRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Random; +import java.util.stream.IntStream; + +@Slf4j +@Component +@RequiredArgsConstructor +public class Init implements CommandLineRunner { + + private final TestEntityRepository repository; + private final Random random = new Random(); + + @Override + public void run(String... args){ + log.info("Generating 10k random entities..."); + log.info("Saving entities in parallel..."); + this.getRandomEntities() + .parallelStream() + .forEach(repository::save); + log.info("Done!"); + } + + private List getRandomEntities(){ + return IntStream.range(0, 10_000) + .mapToObj(i -> TestEntity.builder() + .fieldA("A" + random.nextInt(10000)) + .fieldB("B" + random.nextInt(10000)) + .fieldC(random.nextInt(1000)) + .fieldD(random.nextDouble() * 1000) + .fieldE(random.nextBoolean()) + .build()) + .toList(); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/job/BatchJob.java b/spring-batch-db-examples/src/main/java/com/io/example/job/BatchJob.java new file mode 100644 index 0000000..7e297f3 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/job/BatchJob.java @@ -0,0 +1,22 @@ +package com.io.example.job; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BatchJob { + + @Bean + public Job job(JobRepository jobRepository, Step asyncStep) { + return new JobBuilder("batchJob", jobRepository) + .start(asyncStep) + .build(); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/listener/LoggingStepListener.java b/spring-batch-db-examples/src/main/java/com/io/example/listener/LoggingStepListener.java new file mode 100644 index 0000000..d6364f5 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/listener/LoggingStepListener.java @@ -0,0 +1,27 @@ +package com.io.example.listener; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; + +@Slf4j +public class LoggingStepListener implements StepExecutionListener { + + @Override + public void beforeStep(StepExecution stepExecution) { + log.info("______________ STEP STARTING | StepName: {} | Read count: {} ______________", + stepExecution.getStepName(), + stepExecution.getReadCount()); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("______________ STEP FINISHED | StepName: {} | Read count: {} | Write count: {} ______________", + stepExecution.getStepName(), + stepExecution.getReadCount(), + stepExecution.getWriteCount()); + return stepExecution.getExitStatus(); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/mapper/TestEntityMapper.java b/spring-batch-db-examples/src/main/java/com/io/example/mapper/TestEntityMapper.java new file mode 100644 index 0000000..5c1b051 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/mapper/TestEntityMapper.java @@ -0,0 +1,21 @@ +package com.io.example.mapper; + +import com.io.example.model.dto.TestDto; +import com.io.example.model.entity.TestEntity; +import org.springframework.batch.item.ItemProcessor; + +public class TestEntityMapper implements ItemProcessor { + + @Override + public TestDto process(TestEntity entity) { + return TestDto.builder() + .id(entity.getId()) + .fieldA(entity.getFieldA()) + .fieldB(entity.getFieldB()) + .fieldC(entity.getFieldC()) + .fieldD(entity.getFieldD()) + .fieldE(entity.isFieldE()) + .build(); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/model/dto/TestDto.java b/spring-batch-db-examples/src/main/java/com/io/example/model/dto/TestDto.java new file mode 100644 index 0000000..d2aa953 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/model/dto/TestDto.java @@ -0,0 +1,21 @@ +package com.io.example.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TestDto { + + private Long id; + private String fieldA; + private String fieldB; + private int fieldC; + private double fieldD; + private boolean fieldE; + +} \ No newline at end of file diff --git a/spring-batch-db-examples/src/main/java/com/io/example/model/entity/TestEntity.java b/spring-batch-db-examples/src/main/java/com/io/example/model/entity/TestEntity.java new file mode 100644 index 0000000..acce612 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/model/entity/TestEntity.java @@ -0,0 +1,27 @@ +package com.io.example.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Table(name = "test_entity") +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TestEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String fieldA; + private String fieldB; + private int fieldC; + private double fieldD; + private boolean fieldE; + +} \ No newline at end of file diff --git a/spring-batch-db-examples/src/main/java/com/io/example/repository/TestEntityRepository.java b/spring-batch-db-examples/src/main/java/com/io/example/repository/TestEntityRepository.java new file mode 100644 index 0000000..de20b2f --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/repository/TestEntityRepository.java @@ -0,0 +1,9 @@ +package com.io.example.repository; + +import com.io.example.model.entity.TestEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestEntityRepository extends JpaRepository { +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/repository/query/TestEntityQuery.java b/spring-batch-db-examples/src/main/java/com/io/example/repository/query/TestEntityQuery.java new file mode 100644 index 0000000..6f084b7 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/repository/query/TestEntityQuery.java @@ -0,0 +1,27 @@ +package com.io.example.repository.query; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class TestEntityQuery { + + public static final String SELECT_CLAUSE = """ + entity.id, + entity.fieldA, + entity.fieldB, + entity.fieldC, + entity.fieldD, + entity.fieldE + """; + + public static final String FROM_CLAUSE = """ + test_entity entity + """; + + public static final String WHERE_CLAUSE = """ + """; + + public static final String ORDER_ID = "id"; + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/service/DBBatchService.java b/spring-batch-db-examples/src/main/java/com/io/example/service/DBBatchService.java new file mode 100644 index 0000000..aff7176 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/service/DBBatchService.java @@ -0,0 +1,8 @@ +package com.io.example.service; + +import org.springframework.batch.core.BatchStatus; + +public interface DBBatchService { + Long runJob(); + BatchStatus getJobStatus(Long jobId); +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/service/TestService.java b/spring-batch-db-examples/src/main/java/com/io/example/service/TestService.java new file mode 100644 index 0000000..53e3ebf --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/service/TestService.java @@ -0,0 +1,7 @@ +package com.io.example.service; + +import com.io.example.model.dto.TestDto; + +public interface TestService { + void print(TestDto testDto); +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/service/impl/DBBatchServiceImpl.java b/spring-batch-db-examples/src/main/java/com/io/example/service/impl/DBBatchServiceImpl.java new file mode 100644 index 0000000..50e9b5d --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/service/impl/DBBatchServiceImpl.java @@ -0,0 +1,51 @@ +package com.io.example.service.impl; + +import com.io.example.exception.BusinessException; +import com.io.example.service.DBBatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.*; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DBBatchServiceImpl implements DBBatchService { + + private final JobLauncher asyncJobLauncher; + private final Job batchJob; + private final JobExplorer jobExplorer; + + @Override + public Long runJob() { + String jobName = batchJob.getName(); + var parameters = getParameters(); + try { + log.info("Starting async execution of job: {}", jobName); + JobExecution jobExecution = asyncJobLauncher.run(batchJob, parameters); + log.info("Job {} started with status: {}", jobName, jobExecution.getStatus()); + return jobExecution.getId(); + } catch (Exception e) { + log.error("Error executing job {} asynchronously", jobName, e); + throw new BusinessException(e.getMessage(), e); + } + } + + @Override + public BatchStatus getJobStatus(Long jobId) { + JobExecution jobExecution = jobExplorer.getJobExecution(jobId); + if (jobExecution == null) { + throw new BusinessException("JobExecution not found for this id: " + jobId); + } + return jobExecution.getStatus(); + } + + private JobParameters getParameters(){ + return new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + } + +} diff --git a/spring-batch-db-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java b/spring-batch-db-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java new file mode 100644 index 0000000..4d5cf17 --- /dev/null +++ b/spring-batch-db-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java @@ -0,0 +1,17 @@ +package com.io.example.service.impl; + +import com.io.example.model.dto.TestDto; +import com.io.example.service.TestService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class TestServiceImpl implements TestService { + + @Override + public void print(TestDto dto) { + log.info("Processed: {}", dto); + } + +} diff --git a/spring-batch-db-examples/src/main/resources/application-prd.yml b/spring-batch-db-examples/src/main/resources/application-prd.yml new file mode 100644 index 0000000..ace581b --- /dev/null +++ b/spring-batch-db-examples/src/main/resources/application-prd.yml @@ -0,0 +1,26 @@ +server: + port: ${SERVER_PORT} + +spring: + + datasource: + url: ${SPRING_DATASOURCE_URL} + driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} + + h2: + console: + enabled: ${SPRING_H2_CONSOLE_ENABLED} + path: ${SPRING_H2_CONSOLE_PATH} + + batch: + initialize-schema: ${SPRING_BATCH_INITIALIZE_SCHEMA} + job: + enabled: ${SPRING_BATCH_JOB_ENABLED} + chunk-size: ${SPRING_BATCH_CHUNK_SIZE} + batch-size: ${SPRING_BATCH_BATCH_SIZE} diff --git a/spring-batch-db-examples/src/main/resources/application.yml b/spring-batch-db-examples/src/main/resources/application.yml new file mode 100644 index 0000000..e5e1ec7 --- /dev/null +++ b/spring-batch-db-examples/src/main/resources/application.yml @@ -0,0 +1,26 @@ +server: + port: 8082 + +spring: + + datasource: + url: jdbc:h2:mem:db + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + + h2: + console: + enabled: true + path: /h2-console + + batch: + initialize-schema: always + job: + enabled: false + chunk-size: 1000 + batch-size: 1000 diff --git a/spring-batch-db-examples/src/main/resources/banner.txt b/spring-batch-db-examples/src/main/resources/banner.txt new file mode 100644 index 0000000..112316b --- /dev/null +++ b/spring-batch-db-examples/src/main/resources/banner.txt @@ -0,0 +1,6 @@ +,---. ,------. ,------. ,--.,--. ,--. ,----. ,-----. ,---. ,--------.,-----.,--. ,--. ,------. ,-----. +' .-'| .--. '| .--. '| || ,'.| |' .-./ ,-----.| |) /_ / O \'--. .--' .--./| '--' |,-----.| .-. \ | |) /_ +`. `-.| '--' || '--'.'| || |' ' || | .---.'-----'| .-. \ .-. | | | | | | .--. |'-----'| | \ :| .-. \ +.-' | | --' | |\ \ | || | ` |' '--' | | '--' / | | | | | ' '--'\| | | | | '--' /| '--' / +`-----'`--' `--' '--'`--'`--' `--' `------' `------'`--' `--' `--' `-----'`--' `--' `-------' `------' + SPRING-BATCH-DB-EXAMPLES \ No newline at end of file diff --git a/spring-batch-db-examples/src/test/java/com/io/example/controller/TestControllerTest.java b/spring-batch-db-examples/src/test/java/com/io/example/controller/TestControllerTest.java new file mode 100644 index 0000000..4fd90c8 --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/controller/TestControllerTest.java @@ -0,0 +1,71 @@ +package com.io.example.controller; + +import com.io.example.exception.BusinessException; +import com.io.example.service.DBBatchService; +import org.instancio.Instancio; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.batch.core.BatchStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("TestController - Unit tests with MockMvc") +@WebMvcTest(TestController.class) +class TestControllerTest { + + private static final Long jobId = Instancio.create(Long.class); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DBBatchService DBBatchService; + + @Test + @DisplayName("GET /job/process → should return job ID when service runs successfully") + void shouldReturnJobIdWhenProcessJobIsCalled() throws Exception { + + when(DBBatchService.runJob()).thenReturn(jobId); + + mockMvc.perform(get("/job/process") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(String.valueOf(jobId))); + } + + @ParameterizedTest + @EnumSource(BatchStatus.class) + @DisplayName("GET /job/{jobId}/status → should return correct status for each BatchStatus") + void shouldReturnJobStatusForAllBatchStatuses(BatchStatus status) throws Exception { + + when(DBBatchService.getJobStatus(jobId)).thenReturn(status); + + mockMvc.perform(get("/job/{jobId}/status", jobId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(status.name())); + } + + @Test + @DisplayName("GET /job/{jobId}/status → should return 400 when job ID is invalid") + void shouldReturn400WhenJobIdIsInvalid() throws Exception { + + when(DBBatchService.getJobStatus(jobId)) + .thenThrow(new BusinessException("JobExecution not found for this id: " + jobId)); + + mockMvc.perform(get("/job/{jobId}/status", jobId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest() ); + } + +} diff --git a/spring-batch-db-examples/src/test/java/com/io/example/init/InitIntegrationTest.java b/spring-batch-db-examples/src/test/java/com/io/example/init/InitIntegrationTest.java new file mode 100644 index 0000000..9ca707b --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/init/InitIntegrationTest.java @@ -0,0 +1,29 @@ +package com.io.example.init; + +import com.io.example.repository.TestEntityRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +class InitIntegrationTest { + + @Autowired + private Init init; + + @Autowired + private TestEntityRepository repository; + + @Test + void run_shouldSaveAllRandomEntities() { + repository.deleteAll(); + init.run(); + int size = repository.findAll().size(); + assertEquals(10000, size); + } + +} diff --git a/spring-batch-db-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java b/spring-batch-db-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java new file mode 100644 index 0000000..cab80bd --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java @@ -0,0 +1,86 @@ +package com.io.example.job; + +import com.io.example.model.dto.TestDto; +import com.io.example.model.entity.TestEntity; +import com.io.example.service.TestService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.io.example.util.DataUtils.configJdbcPagingItemReaderMock; +import static com.io.example.util.DataUtils.getParameters; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest +@SpringBatchTest +@ActiveProfiles("test") +@DisplayName("BatchJob - Integration test with real Job execution") +class BatchJobIntegrationTest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job batchJob; + + @MockitoBean("asyncReader") + private JdbcPagingItemReader asyncReader; + + @MockitoBean + private TestService testService; + + @Test + @DisplayName("Should execute batchJob and complete successfully with mocked reader") + void shouldExecuteJobSuccessfully() throws Exception { + + configJdbcPagingItemReaderMock(asyncReader); + + JobExecution execution = jobLauncher.run(batchJob, getParameters()); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + verify(testService, times(5)).print(any(TestDto.class)); + + } + + @Test + @DisplayName("Should fail executing batchJob when reader throws exception") + void shouldFailJobWhenReaderThrowsException() throws Exception { + + when(asyncReader.read()) + .thenThrow(new RuntimeException("Simulated reader failure")); + + JobExecution execution = jobLauncher.run(batchJob, getParameters()); + + assertThat(execution.getStatus()).isNotEqualTo(BatchStatus.COMPLETED); + assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED); + + verify(testService, never()).print(any(TestDto.class)); + + } + + @Test + @DisplayName("Should execute batchJob and complete successfully when reader has no data") + void shouldCompleteJobWithNoData() throws Exception { + + when(asyncReader.read()).thenReturn(null); + + JobExecution execution = jobLauncher.run(batchJob, getParameters()); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + verify(testService, never()).print(any(TestDto.class)); + + } + +} diff --git a/spring-batch-db-examples/src/test/java/com/io/example/mapper/TestEntityMapperTest.java b/spring-batch-db-examples/src/test/java/com/io/example/mapper/TestEntityMapperTest.java new file mode 100644 index 0000000..c44d000 --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/mapper/TestEntityMapperTest.java @@ -0,0 +1,33 @@ +package com.io.example.mapper; + +import com.io.example.model.dto.TestDto; +import com.io.example.model.entity.TestEntity; +import org.instancio.Instancio; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit tests for TestEntityToDtoMapper") +public class TestEntityMapperTest { + + @InjectMocks + private TestEntityMapper mapper; + + @Test + @DisplayName("should correctly map TestEntity to TestDto") + void shouldMapEntityToDtoCorrectly() { + + TestEntity entity = Instancio.create(TestEntity.class); + TestDto dto = mapper.process(entity); + + assertThat(dto).usingRecursiveComparison() + .isEqualTo(entity); + + } + +} \ No newline at end of file diff --git a/spring-batch-db-examples/src/test/java/com/io/example/service/DBBatchServiceImplTest.java b/spring-batch-db-examples/src/test/java/com/io/example/service/DBBatchServiceImplTest.java new file mode 100644 index 0000000..ee5c396 --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/service/DBBatchServiceImplTest.java @@ -0,0 +1,93 @@ +package com.io.example.service; + +import com.io.example.exception.BusinessException; +import com.io.example.service.impl.DBBatchServiceImpl; +import org.instancio.Instancio; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit tests for AsyncBatchServiceImpl") +class DBBatchServiceImplTest { + + private static final Long jobId = Instancio.create(Long.class); + + @Mock + private JobLauncher asyncJobLauncher; + + @Mock + private Job batchJob; + + @Mock + private JobExplorer jobExplorer; + + @InjectMocks + private DBBatchServiceImpl batchService; + + @Test + @DisplayName("Should run job successfully and return job execution id") + void shouldRunJobSuccessfully() throws Exception { + + JobExecution jobExecutionMock = mock(JobExecution.class); + when(jobExecutionMock.getId()).thenReturn(123L); + when(asyncJobLauncher.run(eq(batchJob), any(JobParameters.class))) + .thenReturn(jobExecutionMock); + + Long jobId = batchService.runJob(); + + assertThat(jobId).isEqualTo(123L); + verify(asyncJobLauncher).run(eq(batchJob), any(JobParameters.class)); + } + + @Test + @DisplayName("Should throw BusinessException when job execution fails") + void shouldThrowBusinessExceptionWhenJobFails() throws Exception { + + when(asyncJobLauncher.run(any(Job.class), any(JobParameters.class))) + .thenThrow(new RuntimeException("Simulated error")); + + BusinessException exception = assertThrows(BusinessException.class, + () -> batchService.runJob()); + + assertThat(exception.getMessage()).isEqualTo("Simulated error"); + } + + @Test + @DisplayName("Should return job status when job execution exists") + void shouldReturnJobStatus() { + JobExecution jobExecutionMock = mock(JobExecution.class); + when(jobExecutionMock.getStatus()).thenReturn(BatchStatus.COMPLETED); + when(jobExplorer.getJobExecution(jobId)).thenReturn(jobExecutionMock); + + BatchStatus status = batchService.getJobStatus(jobId); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + verify(jobExplorer).getJobExecution(jobId); + } + + @Test + @DisplayName("Should throw BusinessException when job execution not found") + void shouldThrowBusinessExceptionWhenJobNotFound() { + when(jobExplorer.getJobExecution(jobId)).thenReturn(null); + + BusinessException exception = assertThrows(BusinessException.class, + () -> batchService.getJobStatus(jobId)); + + assertThat(exception.getMessage()) + .isEqualTo("JobExecution not found for this id: " + jobId); + } +} diff --git a/spring-batch-db-examples/src/test/java/com/io/example/service/TestServiceImplTest.java b/spring-batch-db-examples/src/test/java/com/io/example/service/TestServiceImplTest.java new file mode 100644 index 0000000..180d4d8 --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/service/TestServiceImplTest.java @@ -0,0 +1,18 @@ +package com.io.example.service; + +import com.io.example.model.dto.TestDto; +import com.io.example.service.impl.TestServiceImpl; +import org.instancio.Instancio; +import org.junit.jupiter.api.Test; + +class TestServiceImplTest { + + private final TestServiceImpl service = new TestServiceImpl(); + + @Test + void print_shouldLogStudent() { + TestDto testDto = Instancio.create(TestDto.class); + service.print(testDto); + } + +} diff --git a/spring-batch-db-examples/src/test/java/com/io/example/util/DataUtils.java b/spring-batch-db-examples/src/test/java/com/io/example/util/DataUtils.java new file mode 100644 index 0000000..ef02bac --- /dev/null +++ b/spring-batch-db-examples/src/test/java/com/io/example/util/DataUtils.java @@ -0,0 +1,35 @@ +package com.io.example.util; + +import com.io.example.model.entity.TestEntity; +import lombok.SneakyThrows; +import org.instancio.Instancio; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.item.database.JdbcPagingItemReader; + +import static org.mockito.BDDMockito.given; + +public class DataUtils { + + public static JobParameters getParameters(){ + return new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + } + + public static TestEntity createRandomTestEntity(){ + return Instancio.create(TestEntity.class); + } + + @SneakyThrows(Exception.class) + public static void configJdbcPagingItemReaderMock(JdbcPagingItemReader reader){ + given(reader.read()) + .willReturn(createRandomTestEntity()) + .willReturn(createRandomTestEntity()) + .willReturn(createRandomTestEntity()) + .willReturn(createRandomTestEntity()) + .willReturn(createRandomTestEntity()) + .willReturn(null); + } + +} diff --git a/spring-batch-db-examples/src/test/resources/application-test.yml b/spring-batch-db-examples/src/test/resources/application-test.yml new file mode 100644 index 0000000..5909ecc --- /dev/null +++ b/spring-batch-db-examples/src/test/resources/application-test.yml @@ -0,0 +1,26 @@ +server: + port: 8081 + +spring: + + datasource: + url: jdbc:h2:mem:db + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + + h2: + console: + enabled: true + path: /h2-console + + batch: + initialize-schema: always + job: + enabled: false + batch-size: 2 + chunk-size: 1 diff --git a/spring-batch-file-examples/.dockerignore b/spring-batch-file-examples/.dockerignore new file mode 100644 index 0000000..8893889 --- /dev/null +++ b/spring-batch-file-examples/.dockerignore @@ -0,0 +1,62 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.factorypath +**/.git +**/.gitignore +**/.idea +**/.project +**/.sts4-cache +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.next +**/.cache +**/*.dbmdl +**/*.jfm +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/secrets.dev.yaml +**/values.dev.yaml +**/vendor +LICENSE +README.md +**/*.class +**/*.iml +**/*.ipr +**/*.iws +**/*.log +**/.apt_generated +**/.gradle +**/.gradletasknamecache +**/.nb-gradle +**/.springBeans +**/build +**/dist +**/gradle-app.setting +**/nbbuild +**/nbdist +**/nbproject/private +**/target +*.ctxt +.mtj.tmp +.mvn/timing.properties +buildNumber.properties +dependency-reduced-pom.xml +hs_err_pid* +pom.xml.next +pom.xml.releaseBackup +pom.xml.tag +pom.xml.versionsBackup +release.properties +replay_pid* \ No newline at end of file diff --git a/spring-batch-file-examples/.gitattributes b/spring-batch-file-examples/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/spring-batch-file-examples/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/spring-batch-file-examples/.github/workflows/maven.yml b/spring-batch-file-examples/.github/workflows/maven.yml new file mode 100644 index 0000000..16d1a66 --- /dev/null +++ b/spring-batch-file-examples/.github/workflows/maven.yml @@ -0,0 +1,30 @@ +name: CI Build + +on: + push: + branches: + - "**" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + distribution: [ 'temurin' ] + java: [ '21' ] + steps: + - uses: actions/checkout@v5 + + - name: Setup Java 21 + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + cache: 'maven' + + - name: Grant execute permission for mvnw + run: chmod +x mvnw + + - name: Build with Maven + run: ./mvnw clean verify \ No newline at end of file diff --git a/spring-batch-file-examples/.gitignore b/spring-batch-file-examples/.gitignore new file mode 100644 index 0000000..a71cd2b --- /dev/null +++ b/spring-batch-file-examples/.gitignore @@ -0,0 +1,32 @@ +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/spring-batch-file-examples/.mvn/wrapper/maven-wrapper.properties b/spring-batch-file-examples/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/spring-batch-file-examples/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/spring-batch-file-examples/Dockerfile b/spring-batch-file-examples/Dockerfile new file mode 100644 index 0000000..ed30a9a --- /dev/null +++ b/spring-batch-file-examples/Dockerfile @@ -0,0 +1,47 @@ +FROM eclipse-temurin:21-jdk-jammy as deps + +WORKDIR /build + +COPY --chmod=0755 mvnw mvnw +COPY .mvn/ .mvn/ + +RUN --mount=type=bind,source=pom.xml,target=pom.xml \ + --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests + +FROM deps as package + +WORKDIR /build + +COPY ./src src/ +RUN --mount=type=bind,source=pom.xml,target=pom.xml \ + --mount=type=cache,target=/root/.m2 \ + ./mvnw package -DskipTests && \ + mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar + +FROM package as extract + +WORKDIR /build + +RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted + +FROM eclipse-temurin:21-jre-jammy AS final + +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser +USER appuser + +COPY --from=extract build/target/extracted/dependencies/ ./ +COPY --from=extract build/target/extracted/spring-boot-loader/ ./ +COPY --from=extract build/target/extracted/snapshot-dependencies/ ./ +COPY --from=extract build/target/extracted/application/ ./ + +EXPOSE 8082 + +ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ] diff --git a/spring-batch-file-examples/README.md b/spring-batch-file-examples/README.md new file mode 100644 index 0000000..444578a --- /dev/null +++ b/spring-batch-file-examples/README.md @@ -0,0 +1,30 @@ +# Spring Batch Examples | DB And Async + +This project is a **Spring Boot** application demonstrating a **fully asynchronous Spring Batch job**, designed with a focus on **performance** and **scalability**. + +--- + +## 🚀 Overview + +The example showcases how to configure and run an **asynchronous Spring Batch job** that processes a large dataset efficiently. +The job reads **10,000 records** from a database table, simulating item processing by printing +`"item processed"` for each entry. + +--- + +## ⚙️ How It Works + +- The job leverages Spring Batch’s asynchronous capabilities to read and process data concurrently. +- An **H2 in-memory database** is used to store the sample data. +- The asynchronous behavior is enabled through a specific Spring profile. + +--- + +## 🧩 Technologies Used + +- **Java 21** +- **Spring Batch** +- **Spring Boot** +- **H2 Database** + +--- \ No newline at end of file diff --git a/spring-batch-file-examples/compose.yaml b/spring-batch-file-examples/compose.yaml new file mode 100644 index 0000000..7910379 --- /dev/null +++ b/spring-batch-file-examples/compose.yaml @@ -0,0 +1,22 @@ +services: + + app: + container_name: app + build: + context: . + ports: + - "8082:8082" + environment: + SERVER_PORT: "8082" + SPRING_DATASOURCE_URL: "jdbc:h2:mem:db" + SPRING_DATASOURCE_DRIVER_CLASS_NAME: "org.h2.Driver" + SPRING_DATASOURCE_USERNAME: "sa" + SPRING_DATASOURCE_PASSWORD: "" + SPRING_JPA_HIBERNATE_DDL_AUTO: "create-drop" + SPRING_H2_CONSOLE_ENABLED: "true" + SPRING_H2_CONSOLE_PATH: "/h2-console" + SPRING_BATCH_INITIALIZE_SCHEMA: "always" + SPRING_BATCH_JOB_ENABLED: "false" + SPRING_BATCH_BATCH_SIZE: "1000" + SPRING_BATCH_CHUNK_SIZE: "1000" + SPRING_PROFILES_ACTIVE: "prd" \ No newline at end of file diff --git a/spring-batch-file-examples/mvnw b/spring-batch-file-examples/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/spring-batch-file-examples/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/spring-batch-file-examples/mvnw.cmd b/spring-batch-file-examples/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/spring-batch-file-examples/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/spring-batch-file-examples/pom.xml b/spring-batch-file-examples/pom.xml new file mode 100644 index 0000000..0d35d1f --- /dev/null +++ b/spring-batch-file-examples/pom.xml @@ -0,0 +1,190 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + + com.io + spring-batch-file-examples + 1.0.0 + spring-batch-file-examples + Demo project for Spring Boot + + + 21 + 1.18.40 + 3.5.6 + 5.2.3 + 3.14.1 + 5.5.1 + 5.4.1 + 0.1.1 + 2.3.232 + 0.8.14 + + + + + + org.springframework.boot + spring-boot-starter-web + ${spring.boot.version} + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring.boot.version} + + + + org.springframework.boot + spring-boot-starter-batch + ${spring.boot.version} + + + + org.springframework.batch + spring-batch-integration + ${spring.batch.version} + + + + org.projectlombok + lombok + true + ${lombok.version} + + + + org.instancio + instancio-core + ${instancio.version} + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + org.springframework.batch + spring-batch-test + test + ${spring.batch.version} + + + + org.springframework.batch.extensions + spring-batch-excel + ${spring.batch.excel.version} + + + + com.h2database + h2 + runtime + ${h2.version} + + + + org.apache.poi + poi-ooxml + ${poi.ooxml.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + + + org.projectlombok + lombok + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + org.projectlombok + lombok + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + + prepare-agent + + + + + report + test + + report + + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + + + + + + + diff --git a/spring-batch-file-examples/src/main/java/com/io/example/FileReaderExampleApplication.java b/spring-batch-file-examples/src/main/java/com/io/example/FileReaderExampleApplication.java new file mode 100644 index 0000000..ad6cbd4 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/FileReaderExampleApplication.java @@ -0,0 +1,13 @@ +package com.io.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class FileReaderExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(FileReaderExampleApplication.class, args); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/README.md b/spring-batch-file-examples/src/main/java/com/io/example/README.md new file mode 100644 index 0000000..b73f712 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/README.md @@ -0,0 +1,38 @@ +# File Reading Examples \| Multi-format Processing + +This project is a Spring Boot module that demonstrates basic, configurable setups for reading and processing different file formats using Spring batch. The examples are intentionally generic and meant to illustrate common configuration patterns, extension points, and how to plug different file readers into a batch processing pipeline. + +--- + +## Overview + +- Purpose: provide foundational configurations and examples for reading files of various formats. +- Goal: show how to configure readers, processors, and batch steps in a reusable way that can be adapted to different file types and processing requirements. + +--- + +## Scope + +This module focuses on demonstrating basic reader configurations and integration points. Use this section to list which file types are supported by this module: + +- Excel \(.xlsx\) — reading implemented +- CSV \(.csv\) — next/planned + +--- + +## How It Works (Generic) + +- Uses Spring Batch for job and step orchestration. +- Provides example reader beans and mapping strategies for different file formats. +- Demonstrates how to enable/disable features via Spring profiles and configuration properties. +- Designed for extensibility so additional file readers can be added with minimal changes. + +--- + +## Technologies Used + +- Java 21 +- Spring Boot +- Spring Batch + +--- \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java b/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java new file mode 100644 index 0000000..42e582e --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java @@ -0,0 +1,91 @@ +package com.io.example.config; + +import com.io.example.dto.StudentDto; +import com.io.example.mapper.StudentMapper; +import com.io.example.service.TestService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.extensions.excel.poi.PoiItemReader; +import org.springframework.batch.integration.async.AsyncItemProcessor; +import org.springframework.batch.integration.async.AsyncItemWriter; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.Future; + +@Configuration +@RequiredArgsConstructor +public class ExcelBatchConfig { + + private final TestService testService; + + @Bean + @StepScope + public PoiItemReader excelReader( + @Value("#{jobParameters['filePath']}") String filePath + ) throws IOException { + InputStream inputStream = new ClassPathResource(filePath).getInputStream(); + PoiItemReader reader = new PoiItemReader<>(); + reader.setResource(new InputStreamResource(inputStream)); + reader.setLinesToSkip(1); + reader.setRowMapper(new StudentMapper()); + return reader; + } + + @Bean + public AsyncItemProcessor asyncProcessor( + ItemProcessor processor + ) { + AsyncItemProcessor asyncProcessor = new AsyncItemProcessor<>(); + asyncProcessor.setDelegate(processor); + asyncProcessor.setTaskExecutor(new SimpleAsyncTaskExecutor()); + return asyncProcessor; + } + + @Bean + public AsyncItemWriter asyncWriter( + ItemWriter writer + ) { + AsyncItemWriter asyncWriter = new AsyncItemWriter<>(); + asyncWriter.setDelegate(writer); + return asyncWriter; + } + + @Bean + public ItemWriter writer() { + return studentsDto -> studentsDto.forEach(testService::print); + } + + @Bean + public ItemProcessor processor() { + return studentDto -> studentDto; + } + + @Bean + public Step step(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AsyncItemProcessor processor, + AsyncItemWriter writer, + PoiItemReader reader, + @Value("${spring.batch.chunk-size}") int chunkSize) { + return new StepBuilder("step", jobRepository) + .>chunk(chunkSize, transactionManager) + .processor(processor) + .writer(writer) + .reader(reader) + .build(); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java b/spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java new file mode 100644 index 0000000..bdd8db8 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java @@ -0,0 +1,31 @@ +package com.io.example.controller; + +import com.io.example.service.FileBatchService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.BatchStatus; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/job") +@RequiredArgsConstructor +public class TestController { + + private final FileBatchService fileBatchService; + + @GetMapping("/process") + public ResponseEntity processJob(){ + var response = fileBatchService.runJob(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{jobId}/status") + public ResponseEntity getJobStatus(@PathVariable Long jobId) { + BatchStatus status = fileBatchService.getJobStatus(jobId); + return ResponseEntity.ok(status.name()); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/dto/StudentDto.java b/spring-batch-file-examples/src/main/java/com/io/example/dto/StudentDto.java new file mode 100644 index 0000000..9b767cf --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/dto/StudentDto.java @@ -0,0 +1,18 @@ +package com.io.example.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudentDto { + private String name; + private String clasS; + private LocalDate date; +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/exception/BusinessException.java b/spring-batch-file-examples/src/main/java/com/io/example/exception/BusinessException.java new file mode 100644 index 0000000..bef631f --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/exception/BusinessException.java @@ -0,0 +1,13 @@ +package com.io.example.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } + + public BusinessException(String message) { + super(message); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java b/spring-batch-file-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java new file mode 100644 index 0000000..d345db5 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/exception/GlobalHandlerException.java @@ -0,0 +1,19 @@ +package com.io.example.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Slf4j +@ControllerAdvice +public class GlobalHandlerException { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + log.error("BusinessException caught: {}", ex.getMessage()); + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + +} \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java b/spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java new file mode 100644 index 0000000..bdf6e93 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java @@ -0,0 +1,22 @@ +package com.io.example.job; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FileJob { + + @Bean + public Job excelJob(JobRepository jobRepository, Step step) { + return new JobBuilder("excelJob", jobRepository) + .start(step) + .build(); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/mapper/StudentMapper.java b/spring-batch-file-examples/src/main/java/com/io/example/mapper/StudentMapper.java new file mode 100644 index 0000000..f1e254e --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/mapper/StudentMapper.java @@ -0,0 +1,21 @@ +package com.io.example.mapper; + +import com.io.example.dto.StudentDto; +import org.springframework.batch.extensions.excel.RowMapper; +import org.springframework.batch.extensions.excel.support.rowset.RowSet; + +import java.time.LocalDate; + +public class StudentMapper implements RowMapper { + + @Override + public StudentDto mapRow(RowSet rowSet){ + String[] cells = rowSet.getCurrentRow(); + return StudentDto.builder() + .name(cells[0]) + .clasS(cells[1]) + .date(LocalDate.parse(cells[2])) + .build(); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java b/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java new file mode 100644 index 0000000..c8118be --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java @@ -0,0 +1,8 @@ +package com.io.example.service; + +import org.springframework.batch.core.BatchStatus; + +public interface FileBatchService { + Long runJob(); + BatchStatus getJobStatus(Long jobId); +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java b/spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java new file mode 100644 index 0000000..7033c45 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java @@ -0,0 +1,7 @@ +package com.io.example.service; + +import com.io.example.dto.StudentDto; + +public interface TestService { + void print(StudentDto studentDto); +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java new file mode 100644 index 0000000..ddd1d13 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java @@ -0,0 +1,52 @@ +package com.io.example.service.impl; + +import com.io.example.exception.BusinessException; +import com.io.example.service.FileBatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.*; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileBatchServiceImpl implements FileBatchService { + + private final JobLauncher asyncJobLauncher; + private final Job excelJob; + private final JobExplorer jobExplorer; + + @Override + public Long runJob() { + String jobName = excelJob.getName(); + var parameters = getParameters(); + try { + log.info("Starting async execution of job: {}", jobName); + JobExecution jobExecution = asyncJobLauncher.run(excelJob, parameters); + log.info("Job {} started with status: {}", jobName, jobExecution.getStatus()); + return jobExecution.getId(); + } catch (Exception e) { + log.error("Error executing job {} asynchronously", jobName, e); + throw new BusinessException(e.getMessage(), e); + } + } + + @Override + public BatchStatus getJobStatus(Long jobId) { + JobExecution jobExecution = jobExplorer.getJobExecution(jobId); + if (jobExecution == null) { + throw new BusinessException("JobExecution not found for this id: " + jobId); + } + return jobExecution.getStatus(); + } + + private JobParameters getParameters(){ + return new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .addString("filePath", "files/students.xlsx") + .toJobParameters(); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java new file mode 100644 index 0000000..7e5b676 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java @@ -0,0 +1,17 @@ +package com.io.example.service.impl; + +import com.io.example.dto.StudentDto; +import com.io.example.service.TestService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class TestServiceImpl implements TestService { + + @Override + public void print(StudentDto studentDto) { + log.info("Processed: {}", studentDto); + } + +} \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/resources/application-prd.yml b/spring-batch-file-examples/src/main/resources/application-prd.yml new file mode 100644 index 0000000..ace581b --- /dev/null +++ b/spring-batch-file-examples/src/main/resources/application-prd.yml @@ -0,0 +1,26 @@ +server: + port: ${SERVER_PORT} + +spring: + + datasource: + url: ${SPRING_DATASOURCE_URL} + driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} + + h2: + console: + enabled: ${SPRING_H2_CONSOLE_ENABLED} + path: ${SPRING_H2_CONSOLE_PATH} + + batch: + initialize-schema: ${SPRING_BATCH_INITIALIZE_SCHEMA} + job: + enabled: ${SPRING_BATCH_JOB_ENABLED} + chunk-size: ${SPRING_BATCH_CHUNK_SIZE} + batch-size: ${SPRING_BATCH_BATCH_SIZE} diff --git a/spring-batch-file-examples/src/main/resources/application.yml b/spring-batch-file-examples/src/main/resources/application.yml new file mode 100644 index 0000000..e270c61 --- /dev/null +++ b/spring-batch-file-examples/src/main/resources/application.yml @@ -0,0 +1,26 @@ +server: + port: 8082 + +spring: + + datasource: + url: jdbc:h2:mem:db + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + + h2: + console: + enabled: true + path: /h2-console + + batch: + initialize-schema: always + job: + enabled: false + chunk-size: 1000 + batch-size: 1000 diff --git a/spring-batch-file-examples/src/main/resources/banner.txt b/spring-batch-file-examples/src/main/resources/banner.txt new file mode 100644 index 0000000..52c0568 --- /dev/null +++ b/spring-batch-file-examples/src/main/resources/banner.txt @@ -0,0 +1,6 @@ + ,---. ,------. ,------. ,--.,--. ,--. ,----. ,-----. ,---. ,--------.,-----.,--. ,--. ,------.,--.,--. ,------. +' .-'| .--. '| .--. '| || ,'.| |' .-./ ,-----.| |) /_ / O \'--. .--' .--./| '--' |,-----.| .---'| || | | .---' +`. `-.| '--' || '--'.'| || |' ' || | .---.'-----'| .-. \ .-. | | | | | | .--. |'-----'| `--, | || | | `--, +.-' | | --' | |\ \ | || | ` |' '--' | | '--' / | | | | | ' '--'\| | | | | |` | || '--.| `---. +`-----'`--' `--' '--'`--'`--' `--' `------' `------'`--' `--' `--' `-----'`--' `--' `--' `--'`-----'`------' + SPRING-BATCH-FILE-EXAMPLES \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/resources/files/students.xlsx b/spring-batch-file-examples/src/main/resources/files/students.xlsx new file mode 100644 index 0000000..530e974 Binary files /dev/null and b/spring-batch-file-examples/src/main/resources/files/students.xlsx differ diff --git a/spring-batch-file-examples/src/test/java/com/io/example/FileReaderExampleApplicationTest.java b/spring-batch-file-examples/src/test/java/com/io/example/FileReaderExampleApplicationTest.java new file mode 100644 index 0000000..7ef1b2a --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/FileReaderExampleApplicationTest.java @@ -0,0 +1,25 @@ +package com.io.example; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class FileReaderExampleApplicationTest { + + @Test + void contextLoads() { + } + + @Test + void main_shouldCallSpringApplicationRun() { + try (MockedStatic mocked = Mockito.mockStatic(SpringApplication.class)) { + String[] args = {}; + FileReaderExampleApplication.main(args); + mocked.verify(() -> SpringApplication.run(FileReaderExampleApplication.class, args)); + } + } + +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java b/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java new file mode 100644 index 0000000..5e37e47 --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java @@ -0,0 +1,74 @@ +package com.io.example.controller; + +import com.io.example.exception.BusinessException; +import com.io.example.exception.GlobalHandlerException; +import com.io.example.service.FileBatchService; +import org.instancio.Instancio; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.batch.core.BatchStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TestController.class) +@DisplayName("TestController - Unit tests with MockMvc") +@Import(GlobalHandlerException.class) +class TestControllerTest { + + private static final Long jobId = Instancio.create(Long.class); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private FileBatchService fileBatchService; + + @Test + @DisplayName("GET /job/process → should return job ID when service runs successfully") + void shouldReturnJobIdWhenProcessJobIsCalled() throws Exception { + + when(fileBatchService.runJob()).thenReturn(jobId); + + mockMvc.perform(get("/job/process") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(String.valueOf(jobId))); + } + + @ParameterizedTest + @EnumSource(BatchStatus.class) + @DisplayName("GET /job/{jobId}/status → should return correct status for each BatchStatus") + void shouldReturnJobStatusForAllBatchStatuses(BatchStatus status) throws Exception { + + when(fileBatchService.getJobStatus(jobId)).thenReturn(status); + + mockMvc.perform(get("/job/{jobId}/status", jobId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(status.name())); + } + + @Test + @DisplayName("GET /job/{jobId}/status → should return 400 when job ID is invalid") + void shouldReturn400WhenJobIdIsInvalid() throws Exception { + + when(fileBatchService.getJobStatus(jobId)) + .thenThrow(new BusinessException("JobExecution not found for this id: " + jobId)); + + mockMvc.perform(get("/job/{jobId}/status", jobId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java b/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java new file mode 100644 index 0000000..2289946 --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java @@ -0,0 +1,76 @@ +package com.io.example.job; + +import com.io.example.dto.StudentDto; +import com.io.example.service.TestService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.extensions.excel.poi.PoiItemReader; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.io.example.util.DataUtils.getParameters; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest +@SpringBatchTest +@ActiveProfiles("test") +@DisplayName("ExcelJob - Integration test with real Excel reading and job execution") +class BatchJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private PoiItemReader excelReader; + + @MockitoBean + private TestService testService; + + @Test + @DisplayName("Should execute ExcelJob and complete successfully reading real file") + void shouldExecuteJobSuccessfully() throws Exception { + JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students.xlsx")); + + assertThat(execution.getStatus()) + .as("Job should complete successfully") + .isEqualTo(BatchStatus.COMPLETED); + + verify(testService, atLeastOnce()).print(any(StudentDto.class)); + + } + + @Test + @DisplayName("Should fail executing ExcelJob when reader throws exception") + void shouldFailJobWhenReaderThrowsException() throws Exception { + + JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students-error.xlsx")); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED); + + verify(testService, never()).print(any(StudentDto.class)); + + } + + @Test + @DisplayName("Should execute ExcelJob and complete successfully when reader has no data") + void shouldCompleteJobWithNoData() throws Exception { + + JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students-empty.xlsx")); + + assertThat(execution.getStatus()) + .as("Job should complete successfully") + .isEqualTo(BatchStatus.COMPLETED); + + verify(testService, never()).print(any(StudentDto.class)); + + } + +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/mapper/StudentMapperTest.java b/spring-batch-file-examples/src/test/java/com/io/example/mapper/StudentMapperTest.java new file mode 100644 index 0000000..93aca7f --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/mapper/StudentMapperTest.java @@ -0,0 +1,30 @@ +package com.io.example.mapper; + +import com.io.example.dto.StudentDto; +import org.instancio.Instancio; +import org.junit.jupiter.api.Test; +import org.springframework.batch.extensions.excel.support.rowset.RowSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class StudentMapperTest { + + @Test + void shouldMapRowSetUsingInstancio() { + StudentDto sample = Instancio.create(StudentDto.class); + + RowSet rowSet = mock(RowSet.class); + when(rowSet.getCurrentRow()).thenReturn(new String[]{ + sample.getName(), + sample.getClasS(), + sample.getDate().toString() + }); + + StudentMapper mapper = new StudentMapper(); + StudentDto dto = mapper.mapRow(rowSet); + + assertEquals(sample, dto); + } +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java b/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java new file mode 100644 index 0000000..6fd56b3 --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java @@ -0,0 +1,93 @@ +package com.io.example.service; + +import com.io.example.exception.BusinessException; +import com.io.example.service.impl.FileBatchServiceImpl; +import org.instancio.Instancio; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit tests for AsyncBatchServiceImpl") +class FileBatchServiceImplTest { + + private static final Long jobId = Instancio.create(Long.class); + + @Mock + private JobLauncher asyncJobLauncher; + + @Mock + private Job asyncBatchJob; + + @Mock + private JobExplorer jobExplorer; + + @InjectMocks + private FileBatchServiceImpl fileBatchService; + + @Test + @DisplayName("Should run job successfully and return job execution id") + void shouldRunJobSuccessfully() throws Exception { + + JobExecution jobExecutionMock = mock(JobExecution.class); + when(jobExecutionMock.getId()).thenReturn(123L); + when(asyncJobLauncher.run(eq(asyncBatchJob), any(JobParameters.class))) + .thenReturn(jobExecutionMock); + + Long jobId = fileBatchService.runJob(); + + assertThat(jobId).isEqualTo(123L); + verify(asyncJobLauncher).run(eq(asyncBatchJob), any(JobParameters.class)); + } + + @Test + @DisplayName("Should throw BusinessException when job execution fails") + void shouldThrowBusinessExceptionWhenJobFails() throws Exception { + + when(asyncJobLauncher.run(any(Job.class), any(JobParameters.class))) + .thenThrow(new RuntimeException("Simulated error")); + + BusinessException exception = assertThrows(BusinessException.class, + () -> fileBatchService.runJob()); + + assertThat(exception.getMessage()).isEqualTo("Simulated error"); + } + + @Test + @DisplayName("Should return job status when job execution exists") + void shouldReturnJobStatus() { + JobExecution jobExecutionMock = mock(JobExecution.class); + when(jobExecutionMock.getStatus()).thenReturn(BatchStatus.COMPLETED); + when(jobExplorer.getJobExecution(jobId)).thenReturn(jobExecutionMock); + + BatchStatus status = fileBatchService.getJobStatus(jobId); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + verify(jobExplorer).getJobExecution(jobId); + } + + @Test + @DisplayName("Should throw BusinessException when job execution not found") + void shouldThrowBusinessExceptionWhenJobNotFound() { + when(jobExplorer.getJobExecution(jobId)).thenReturn(null); + + BusinessException exception = assertThrows(BusinessException.class, + () -> fileBatchService.getJobStatus(jobId)); + + assertThat(exception.getMessage()) + .isEqualTo("JobExecution not found for this id: " + jobId); + } +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java b/spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java new file mode 100644 index 0000000..bb5a715 --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java @@ -0,0 +1,18 @@ +package com.io.example.service; + +import com.io.example.dto.StudentDto; +import com.io.example.service.impl.TestServiceImpl; +import org.instancio.Instancio; +import org.junit.jupiter.api.Test; + +class TestServiceImplTest { + + private final TestServiceImpl service = new TestServiceImpl(); + + @Test + void print_shouldLogStudent() { + StudentDto student = Instancio.create(StudentDto.class); + service.print(student); + } + +} diff --git a/spring-batch-file-examples/src/test/java/com/io/example/util/DataUtils.java b/spring-batch-file-examples/src/test/java/com/io/example/util/DataUtils.java new file mode 100644 index 0000000..306cde4 --- /dev/null +++ b/spring-batch-file-examples/src/test/java/com/io/example/util/DataUtils.java @@ -0,0 +1,15 @@ +package com.io.example.util; + +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; + +public class DataUtils { + + public static JobParameters getParameters(String filePath) { + return new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .addString("filePath", filePath) + .toJobParameters(); + } + +} \ No newline at end of file diff --git a/spring-batch-file-examples/src/test/resources/application-test.yml b/spring-batch-file-examples/src/test/resources/application-test.yml new file mode 100644 index 0000000..5a5a88f --- /dev/null +++ b/spring-batch-file-examples/src/test/resources/application-test.yml @@ -0,0 +1,26 @@ +server: + port: 8082 + +spring: + + datasource: + url: jdbc:h2:mem:db + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + + h2: + console: + enabled: true + path: /h2-console + + batch: + initialize-schema: always + job: + enabled: false + chunk-size: 5 + batch-size: 5 diff --git a/spring-batch-file-examples/src/test/resources/files/students-empty.xlsx b/spring-batch-file-examples/src/test/resources/files/students-empty.xlsx new file mode 100644 index 0000000..605672b Binary files /dev/null and b/spring-batch-file-examples/src/test/resources/files/students-empty.xlsx differ diff --git a/spring-batch-file-examples/src/test/resources/files/students-error.xlsx b/spring-batch-file-examples/src/test/resources/files/students-error.xlsx new file mode 100644 index 0000000..d36536a --- /dev/null +++ b/spring-batch-file-examples/src/test/resources/files/students-error.xlsx @@ -0,0 +1,3 @@ +this is not a real excel file +line2 +line3 \ No newline at end of file diff --git a/spring-batch-file-examples/src/test/resources/files/students.xlsx b/spring-batch-file-examples/src/test/resources/files/students.xlsx new file mode 100644 index 0000000..530e974 Binary files /dev/null and b/spring-batch-file-examples/src/test/resources/files/students.xlsx differ