commit b1e44a9e1a2fef466fbe04b4827288b89ae81ce7 Author: Soby Chacko Date: Wed Jun 22 19:03:04 2022 -0400 Initial commit Introducing spring-pulsar. This commit introduces the Spring Pulsar project that gives Spring friendly API's for Apache Pulsar. Currently, it has the following components. 1. PulsarTemplate for publishing to Pulsar 2. Pulsar producer factory 3. PulsarListener annotation 4. Pulsar consumer factory 5. Pulsar message listener infrastructure that encapsulates the consumer 6. Pulsar client factory bean 7. Basic Spring Boot auto configuration 8. Testcontainer based Pulsar integration tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5532994a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +*.ipr +*.iws +.classpath +.gradle +.idea +.project +.settings +.sts4-cache +.checkstyle +bin +build +out +target +.DS_Store diff --git a/README.adoc b/README.adoc new file mode 100644 index 00000000..a768e345 --- /dev/null +++ b/README.adoc @@ -0,0 +1,66 @@ +# Spring for Apache Pulsar + +This project provides a basic Spring friendly API for developing https://pulsar.apache.org/[Apache Pulsar] applications. + +Most of the ideas in this project are borrowed from the Spring for Apache Kafka project, thus a familiarity with the Spring support available in Spring for Apache Kafka would help a lot. + + +** #IMPORTANT#: This is a WIP project. Many of the support available at the moment are prototypes and experimental. + +## Quick Introduction + +The current prototype supports a basic `PulsarTemplate` for publishing to a Pulsar topic, `PulsarListner` annotation for consuming from a topic. +It also provides Spring Boot auto-configuration for these components. + +### Writing a quick Spring Boot based Pulsar application. + +Let's take a look at the application below. +This captures the current support available for publishing to and consuming from a Pulsar topic. + +``` +@SpringBootApplication +public class PulsarBootApp { + + public static void main(String[] args) { + SpringApplication.run(PulsarBootApp.class, args); + } + + @Bean + public ApplicationRunner runner(PulsarTemplate pulsarTemplate) { + pulsarTemplate.setDefaultTopicName("hello-pulsar-exclusive"); + return args -> { + for (int i = 0; i < 100; i ++) { + pulsarTemplate.send("This is message " + (i + 1)); + } + + }; + } + + @PulsarListener(subscriptionName = "test-exclusive-sub", topics = "hello-pulsar-exclusive") + public void listen(String foo) { + System.out.println("Message Received: " + foo); + } +} +``` + +This is a complete Spring Boot application that is sending messages to a topic in Apache Pulsar and consuming from that topic. + +`PulsarTemplate` is the template mechanism available in Spring for Apache Pulsar that enables you to publish messages to a topic. +The project provides Spring Boot auto-configuration for this `PulsarTemplate` which we inject in the application above. + +We use `PulsarListener` annotation to consume from the same topic where the template is publishing to. + +At the moment, we only support the `Exclusive` subscription mode. + +### Building the project + +``` +./gradlew clean build +``` + +### More support to come -- stay tuned... + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..6385a6fd --- /dev/null +++ b/build.gradle @@ -0,0 +1,271 @@ +buildscript { +// repositories { +// mavenCentral() +// maven { url 'https://plugins.gradle.org/m2' } +// maven { url 'https://repo.spring.io/plugins-release' } +// mavenLocal() +// } + repositories { + mavenCentral() + gradlePluginPortal() + maven { url 'https://repo.spring.io/plugins-release' } + } +} + +plugins { + id 'base' + id 'project-report' + id 'idea' + id 'org.sonarqube' version '2.8' +// id 'org.ajoberstar.grgit' version '4.0.1' apply false + id 'io.spring.nohttp' version '0.0.5.RELEASE' + id 'io.spring.dependency-management' version '1.0.10.RELEASE' apply false + id 'com.jfrog.artifactory' version '4.18.2' apply false + id 'org.asciidoctor.jvm.pdf' version '3.3.2' + id 'org.asciidoctor.jvm.gems' version '3.3.2' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +apply plugin: 'io.spring.nohttp' + +//def gitPresent = new File('.git').exists() + +//if(gitPresent) { +// apply plugin: 'org.ajoberstar.grgit' +//} + +description = 'Spring for Apache Pulsar' + +ext { +// if (gitPresent) { +// modifiedFiles = +// files(grgit.status().unstaged.modified).filter{ f -> f.name.endsWith('.java') || f.name.endsWith('.kt') } +// } + + assertjVersion = '3.21.0' + awaitilityVersion = '4.1.1' + googleJsr305Version = '3.0.2' + hamcrestVersion = '2.2' + hibernateValidationVersion = '6.2.3.Final' + jacksonBomVersion = '2.13.2.20220328' + jaywayJsonPathVersion = '2.6.0' + junit4Version = '4.13.2' + junitJupiterVersion = '5.8.2' + pulsarVersion = '2.10.0' + log4jVersion = '2.17.2' +// micrometerVersion = '2.0.0-SNAPSHOT' + mockitoVersion = '4.0.0' + reactorVersion = '2020.0.17' + springBootVersion = '3.0.0-SNAPSHOT' // docs module + springRetryVersion = '1.3.2' + springVersion = '6.0.0-SNAPSHOT' + + idPrefix = 'pulsar' +} + +nohttp { + source.include '**/src/**' + source.exclude '**/*.gif', '**/*.ks' +} + +allprojects { + group = 'org.springframework.pulsar' + + apply plugin: 'io.spring.dependency-management' + + dependencyManagement { + resolutionStrategy { + cacheChangingModulesFor 0, 'seconds' + } + applyMavenExclusions = false + generatedPomCustomization { + enabled = false + } + + imports { + mavenBom "com.fasterxml.jackson:jackson-bom:$jacksonBomVersion" + mavenBom "org.junit:junit-bom:$junitJupiterVersion" + mavenBom "org.springframework:spring-framework-bom:$springVersion" + mavenBom "io.projectreactor:reactor-bom:$reactorVersion" + } + } + + repositories { + mavenCentral() + maven { url 'https://repo.spring.io/release' } + maven { url 'https://repo.spring.io/milestone' } + if (version.endsWith('SNAPSHOT')) { + maven { url 'https://repo.spring.io/snapshot' } + } +// maven { url 'https://repository.apache.org/content/groups/staging/' } + } + +} + +subprojects { subproject -> + apply plugin: 'java-library' + apply plugin: 'java' +// apply from: "${rootProject.projectDir}/publish-maven.gradle" + apply plugin: 'eclipse' + apply plugin: 'idea' + apply plugin: 'jacoco' +// apply plugin: 'checkstyle' + + java { + withJavadocJar() + withSourcesJar() + registerFeature('optional') { + usingSourceSet(sourceSets.main) + } + registerFeature('provided') { + usingSourceSet(sourceSets.main) + } + } + + compileJava { + sourceCompatibility = 17 + targetCompatibility = 17 + } + + compileTestJava { + sourceCompatibility = 17 + options.encoding = 'UTF-8' + } + + eclipse.project.natures += 'org.springframework.ide.eclipse.core.springnature' + + jacoco { + toolVersion = '0.8.6' + } + + configurations { + all { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + } + + // dependencies that are common across all java projects + dependencies { + implementation "com.google.code.findbugs:jsr305:$googleJsr305Version" + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // To avoid compiler warnings about @API annotations in JUnit code + testCompileOnly 'org.apiguardian:apiguardian-api:1.0.0' + + testRuntimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:$log4jVersion" + + testImplementation("org.awaitility:awaitility:$awaitilityVersion") { + exclude group: 'org.hamcrest' + } + testImplementation "org.hamcrest:hamcrest-core:$hamcrestVersion" + optionalApi "org.assertj:assertj-core:$assertjVersion" + + + + testImplementation("org.testcontainers:pulsar:1.17.2") { + exclude module: 'log4j-to-slf4j' + } + + } + + // enable all compiler warnings; individual projects may customize further + [compileJava, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options'] + + test { + testLogging { + events "skipped", "failed" + showStandardStreams = project.hasProperty("showStandardStreams") ?: false + showExceptions = true + showStackTraces = true + exceptionFormat = 'full' + } + + maxHeapSize = '1536m' +// jvmArgs '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:8111' + jacoco { + destinationFile = file("$buildDir/jacoco.exec") + } + useJUnitPlatform() + + if (System.properties['sonar.host.url']) { + finalizedBy jacocoTestReport + } + } + + checkstyle { + configDirectory.set(rootProject.file("src/checkstyle")) + toolVersion = '9.0' + } + + jacocoTestReport { + reports { + xml.enabled true + csv.enabled false + html.enabled false + xml.destination file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml") + } + } + + jar { + manifest { + attributes( + 'Implementation-Version': archiveVersion, +// 'Created-By': "JDK ${System.properties['java.version']} (${System.properties['java.specification.vendor']})", + 'Implementation-Title': subproject.name, + 'Implementation-Vendor-Id': subproject.group, + 'Implementation-Vendor': 'Pivotal Software, Inc.', +// 'Implementation-URL': linkHomepage, + 'Automatic-Module-Name': subproject.name.replace('-', '.') // for Jigsaw + ) + } + + from("${rootProject.projectDir}/src/dist") { + include 'license.txt' + include 'notice.txt' + into 'META-INF' + expand(copyright: new Date().format('yyyy'), version: project.version) + } + } + +} + +project ('spring-pulsar') { + description = 'Spring Pulsar Support' + + dependencies { + api "org.springframework.boot:spring-boot:$springBootVersion" + api "org.springframework.boot:spring-boot-autoconfigure:$springBootVersion" + api "org.springframework.boot:spring-boot-starter:$springBootVersion" + api "org.springframework.boot:spring-boot-starter-logging:$springBootVersion" + api "org.springframework.boot:spring-boot-starter-validation:$springBootVersion" + api 'org.springframework:spring-context' + api 'org.springframework:spring-messaging' + api 'org.springframework:spring-tx' + api ("org.springframework.retry:spring-retry:$springRetryVersion") { + exclude group: 'org.springframework' + } + api "org.apache.pulsar:pulsar-client:$pulsarVersion" + api "org.apache.pulsar:pulsar-client-admin:$pulsarVersion" + api "org.apache.pulsar:pulsar-client-admin-api:$pulsarVersion" + + optionalApi 'com.fasterxml.jackson.core:jackson-core' + optionalApi 'com.fasterxml.jackson.core:jackson-databind' + optionalApi 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8' + optionalApi 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + optionalApi 'com.fasterxml.jackson.datatype:jackson-datatype-joda' + optionalApi ('com.fasterxml.jackson.module:jackson-module-kotlin') { + exclude group: 'org.jetbrains.kotlin' + } + + optionalApi "com.jayway.jsonpath:json-path:$jaywayJsonPathVersion" + + optionalApi 'io.projectreactor:reactor-core' +// optionalApi "io.micrometer:micrometer-core:$micrometerVersion" + + testImplementation 'io.projectreactor:reactor-test' + testImplementation "org.mockito:mockito-junit-jupiter:$mockitoVersion" + testImplementation "org.hibernate.validator:hibernate-validator:$hibernateValidationVersion" + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..aa4e8956 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +version=0.1.0-SNAPSHOT +org.gradle.caching=true +org.gradle.parallel=true diff --git a/gradle/build-scan-user-data.gradle b/gradle/build-scan-user-data.gradle new file mode 100644 index 00000000..ef813dfb --- /dev/null +++ b/gradle/build-scan-user-data.gradle @@ -0,0 +1,90 @@ +tagOs() +tagIde() +tagCiOrLocal() +addCiMetadata() +addGitMetadata() +//addTestTaskMetadata() + +void tagOs() { + gradleEnterprise.buildScan.tag System.getProperty('os.name') +} + +void tagIde() { + if (System.getProperty('idea.version')) { + gradleEnterprise.buildScan.tag 'IntelliJ IDEA' + } else if (System.getProperty('eclipse.buildId')) { + gradleEnterprise.buildScan.tag 'Eclipse' + } +} + +void tagCiOrLocal() { + gradleEnterprise.buildScan.tag(isCi() ? 'CI' : 'LOCAL') +} + +void addGitMetadata() { + gradleEnterprise.buildScan.background { + def gitCommitId = execAndGetStdout('git', 'rev-parse', '--short=8', '--verify', 'HEAD') + def gitBranchName = execAndGetStdout('git', 'rev-parse', '--abbrev-ref', 'HEAD') + def gitStatus = execAndGetStdout('git', 'status', '--porcelain') + + if(gitCommitId) { + def commitIdLabel = 'Git Commit ID' + value commitIdLabel, gitCommitId + link 'Git commit build scans', customValueSearchUrl([(commitIdLabel): gitCommitId]) + } + if (gitBranchName) { + tag gitBranchName + value 'Git branch', gitBranchName + } + if (gitStatus) { + tag 'dirty' + value 'Git status', gitStatus + } + } +} + +void addCiMetadata() { + def ciBuild = 'CI BUILD' + if (isBamboo()) { + gradleEnterprise.buildScan.link ciBuild, System.getenv('bamboo_resultsUrl') + } +} + +void addTestTaskMetadata() { + allprojects { + tasks.withType(Test) { test -> + doFirst { + gradleEnterprise.buildScan.value "Test#maxParallelForks[${test.path}]", test.maxParallelForks.toString() + } + } + } +} + +boolean isCi() { + isBamboo() +} + +boolean isBamboo() { + System.getenv('bamboo_resultsUrl') +} + +String execAndGetStdout(String... args) { + def stdout = new ByteArrayOutputStream() + exec { + commandLine(args) + standardOutput = stdout + } + return stdout.toString().trim() +} + +String customValueSearchUrl(Map search) { + def query = search.collect { name, value -> + "search.names=${encodeURL(name)}&search.values=${encodeURL(value)}" + }.join('&') + + "$gradleEnterprise.buildScan.server/scans?$query" +} + +String encodeURL(String url) { + URLEncoder.encode(url, 'UTF-8') +} diff --git a/gradle/docs.gradle b/gradle/docs.gradle new file mode 100644 index 00000000..727b3545 --- /dev/null +++ b/gradle/docs.gradle @@ -0,0 +1,16 @@ +configurations { + asciidoctorExt +} + +dependencies { + asciidoctorExt("io.spring.asciidoctor:spring-asciidoctor-extensions-block-switch:0.5.0") +} + +repositories { + maven { + url "https://repo.spring.io/release" + mavenContent { + includeGroup "io.spring.asciidoctor" + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..41d9927a Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..5b6fb15e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionSha256Sum=e5444a57cda4a95f90b0c9446a9e1b47d3d7f69057765bfb54bd4f482542d548 diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..54c92747 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { url 'https://repo.spring.io/plugins-release' } + } +} + +plugins { + id 'com.gradle.enterprise' version '3.5' + id "io.spring.ge.conventions" version "0.0.7" +} + +gradleEnterprise { + buildScan { + publishOnFailure() + } +} + +rootProject.name = 'spring-pulsar-dist' + +include 'spring-pulsar' diff --git a/spring-pulsar/src/main/java/experiments/basic/PulsarAppTry.java b/spring-pulsar/src/main/java/experiments/basic/PulsarAppTry.java new file mode 100644 index 00000000..7a9f44c1 --- /dev/null +++ b/spring-pulsar/src/main/java/experiments/basic/PulsarAppTry.java @@ -0,0 +1,66 @@ +package experiments.basic; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PulsarAppTry { + + public static void main(String[] args) { + + SpringApplication.run(PulsarAppTry.class, args); + } + +// @Bean +// public PulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient) { +// Map config = new HashMap<>(); +// config.put("topicName", "foo-1"); +// return new DefaultPulsarProducerFactory<>(pulsarClient, config); +// } +// +// @Bean +// public PulsarClientFactoryBean pulsarClientFactoryBean(PulsarClientConfiguration pulsarClientConfiguration) { +// return new PulsarClientFactoryBean(pulsarClientConfiguration); +// } +// +// @Bean +// public PulsarClientConfiguration pulsarClientConfiguration() { +// return new PulsarClientConfiguration(); +// } + +// @Bean +// public PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory) { +// return new PulsarTemplate<>(pulsarProducerFactory); +// } +// +// @Bean +// public PulsarConsumerFactory pulsarConsumerFactory(PulsarClient pulsarClient) { +// +// Map config = new HashMap<>(); +//// final HashSet strings = new HashSet<>(); +//// strings.add("foobar-012"); +//// config.put("topicNames", strings); +//// config.put("subscriptionName", "foobar-sb-012"); +// +// return new DefaultPulsarConsumerFactory<>(pulsarClient, config); +// } +// +// @Bean +// PulsarListenerContainerFactory pulsarListenerContainerFactory(PulsarConsumerFactory pulsarConsumerFactory) { +// final PulsarListenerContainerFactoryImpl pulsarListenerContainerFactory = new PulsarListenerContainerFactoryImpl<>(); +// pulsarListenerContainerFactory.setPulsarConsumerFactory(pulsarConsumerFactory); +// return pulsarListenerContainerFactory; +// } + +// @PulsarListener(subscriptionName = "hello-pulsar-listener", topics = "foo-1") +// public void listen(String foo) { +// System.out.println("Message Received: " + foo); +// } + +// @Configuration(proxyBeanMethods = false) +// @EnablePulsar +// static class EnablePulsarConfiguration { +// +// } + +} diff --git a/spring-pulsar/src/main/java/experiments/basic/PulsarBootApp.java b/spring-pulsar/src/main/java/experiments/basic/PulsarBootApp.java new file mode 100644 index 00000000..45f0373d --- /dev/null +++ b/spring-pulsar/src/main/java/experiments/basic/PulsarBootApp.java @@ -0,0 +1,36 @@ +package experiments.basic; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; + +@SpringBootApplication +public class PulsarBootApp { + + public static void main(String[] args) { + SpringApplication.run(PulsarBootApp.class, args); + } + + @Bean + public ApplicationRunner runner(PulsarTemplate pulsarTemplate) { + pulsarTemplate.setDefaultTopicName("hello-pulsar-exclusive"); + return args -> { +// for (int i = 0; i < 100; i ++) { +// pulsarTemplate.send("This is message " + (i + 1)); +// } + + pulsarTemplate.send("This is message "); + + }; + } + + @PulsarListener(subscriptionName = "test-exclusive-sub", topics = "hello-pulsar-exclusive") + public void listen(String foo) { + System.out.println("Message Received: " + foo); + } + + +} diff --git a/spring-pulsar/src/main/java/experiments/failover/consumer/FailoverConsumerApp.java b/spring-pulsar/src/main/java/experiments/failover/consumer/FailoverConsumerApp.java new file mode 100644 index 00000000..991a7416 --- /dev/null +++ b/spring-pulsar/src/main/java/experiments/failover/consumer/FailoverConsumerApp.java @@ -0,0 +1,78 @@ +package experiments.failover.consumer; + +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageRouter; +import org.apache.pulsar.client.api.TopicMetadata; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; + +@SpringBootApplication +public class FailoverConsumerApp { + + + public static void main(String[] args) { + String[] args1 = new String[]{"--spring.pulsar.consumer.subscription-type=Failover", + "--spring.pulsar.producer.messageRoutingMode=CustomPartition"}; + SpringApplication.run(FailoverConsumerApp.class, args1); + } + + @Bean + public ApplicationRunner runner(PulsarTemplate pulsarTemplate) { + pulsarTemplate.setDefaultTopicName("failover-demo-topic"); + return args -> { + for (int i = 0; i < 10; i++) { + pulsarTemplate.sendAsync("hello john doe 0 ", new FooRouter()); + pulsarTemplate.sendAsync("hello alice doe 1", new BarRouter()); + pulsarTemplate.sendAsync("hello buzz doe 2", new BuzzRouter()); + Thread.sleep(1_000); + System.out.println("------------------------"); + } + System.exit(0); + }; + } + + @PulsarListener(subscriptionName = "failover-subscription-demo", topics = "failover-demo-topic") + public void listen1(String foo) { + System.out.println("Message Received 1: " + foo); + } + + @PulsarListener(subscriptionName = "failover-subscription-demo", topics = "failover-demo-topic") + public void listen2(String foo) { + System.out.println("Message Received 2: " + foo); + } + + @PulsarListener(subscriptionName = "failover-subscription-demo", topics = "failover-demo-topic") + public void listen(String foo) { + System.out.println("Message Received 3: " + foo); + } + + static class FooRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 0; + } + } + + static class BarRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 1; + } + } + + static class BuzzRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 2; + } + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/PulsarException.java b/spring-pulsar/src/main/java/org/springframework/pulsar/PulsarException.java new file mode 100644 index 00000000..f8f73134 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/PulsarException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar; + +import org.springframework.core.NestedRuntimeException; + +/** + * @author Soby Chacko + */ +public class PulsarException extends NestedRuntimeException { + + public PulsarException(String msg) { + super(msg); + } + + public PulsarException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/EnablePulsar.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/EnablePulsar.java new file mode 100644 index 00000000..724ec06a --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/EnablePulsar.java @@ -0,0 +1,39 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; + +/** + * Enable Pulsar listener annotated endpoints that are created under the covers by a + * {@link org.springframework.pulsar.config.AbstractPulsarListenerContainerFactory}. + * + * @author Soby Chacko + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(PulsarListenerConfigurationSelector.class) +public @interface EnablePulsar { +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarBootstrapConfiguration.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarBootstrapConfiguration.java new file mode 100644 index 00000000..0ed4e146 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarBootstrapConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.pulsar.annotation.PulsarListenerAnnotationBeanPostProcessor; +import org.springframework.pulsar.config.PulsarListenerConfigUtils; +import org.springframework.pulsar.config.PulsarListenerEndpointRegistry; + +/** + * An {@link ImportBeanDefinitionRegistrar} class that registers a {@link PulsarListenerAnnotationBeanPostProcessor} + * bean capable of processing Spring's @{@link PulsarListener} annotation. Also register + * a default {@link PulsarListenerEndpointRegistry}. + * + *

This configuration class is automatically imported when using the @{@link EnablePulsar} + * annotation. + * + * @author Soby Chacko + * + * @see PulsarListenerAnnotationBeanPostProcessor + * @see PulsarListenerEndpointRegistry + * @see EnablePulsar + */ +public class PulsarBootstrapConfiguration implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + if (!registry.containsBeanDefinition( + PulsarListenerConfigUtils.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME)) { + + registry.registerBeanDefinition(PulsarListenerConfigUtils.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME, + new RootBeanDefinition(PulsarListenerAnnotationBeanPostProcessor.class)); + } + + if (!registry.containsBeanDefinition(PulsarListenerConfigUtils.PULSAR_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME)) { + registry.registerBeanDefinition(PulsarListenerConfigUtils.PULSAR_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME, + new RootBeanDefinition(PulsarListenerEndpointRegistry.class)); + } + } + +} \ No newline at end of file diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListener.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListener.java new file mode 100644 index 00000000..00ff32ef --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListener.java @@ -0,0 +1,121 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.messaging.handler.annotation.MessageMapping; + +/** + * Annotation that marks a method to be the target of a Pulsar message listener on the + * specified topics. + * + * The {@link #containerFactory()} identifies the + * {@link org.springframework.pulsar.config.PulsarListenerContainerFactory} to use to build the Pulsar listener container. + * If not set, a default container factory is assumed to be available with a bean name + * of {@code pulsarListenerContainerFactory} unless an explicit default has been provided + * through configuration. + * + *

+ * Processing of {@code @PulsarListener} annotations is performed by registering a + * {@link PulsarListenerAnnotationBeanPostProcessor}. This can be done manually or, more + * conveniently, through {@link EnablePulsar} annotation. + * + *

+ * + * @author Soby Chacko + */ +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@MessageMapping +@Documented +public @interface PulsarListener { + + /** + * The unique identifier of the container for this listener. + *

If none is specified an auto-generated id is used. + *

SpEL {@code #{...}} and property place holders {@code ${...}} are supported. + * @return the {@code id} for the container managing for this endpoint. + * @see org.springframework.pulsar.config.PulsarListenerEndpointRegistry#getListenerContainer(String) + */ + String id() default ""; + + /** + * Pulsar subscription name associated with this listener. + * @return the {@code subscriptionName} for this Pulsar listener endpoint. + */ + String subscriptionName() default ""; + + /** + * Pulsar subscription type for this listener. + * @return the {@code subscriptionType} for this listener + */ + String subscriptionType() default ""; + + /** + * Specific container factory to use on this listener. + * @return {@code containerFactory} to use on this Pulsar listener. + */ + String containerFactory() default ""; + + /** + * Topics to listen to. + * + * @return a comma separated list of topics to listen from. + */ + String[] topics() default {}; + + /** + * Topic patten to listen to. + * + * @return topic pattern to listen to. + */ + String topicPattern() default ""; + + /** + * Set to true or false, to override the default setting in the container factory. May + * be a property placeholder or SpEL expression that evaluates to a {@link Boolean} or + * a {@link String}, in which case the {@link Boolean#parseBoolean(String)} is used to + * obtain the value. + *

SpEL {@code #{...}} and property place holders {@code ${...}} are supported. + * @return true to auto start, false to not auto start. + */ + String autoStartup() default ""; + + /** + * Activate batch consumption. + * + * @return whether this listener is in batch mode or not. + */ + String batch() default ""; + + /** + * A pseudo bean name used in SpEL expressions within this annotation to reference + * the current bean within which this listener is defined. This allows access to + * properties and methods within the enclosing bean. + * Default '__listener'. + *

+ * @return the pseudo bean name. + */ + String beanRef() default "__listener"; + + String[] properties() default {}; +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerAnnotationBeanPostProcessor.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerAnnotationBeanPostProcessor.java new file mode 100644 index 00000000..08865b61 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerAnnotationBeanPostProcessor.java @@ -0,0 +1,801 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import java.io.IOException; +import java.io.StringReader; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import org.apache.commons.logging.LogFactory; +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.Scope; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.OrderComparator; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalGenericConverter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.log.LogAccessor; +import org.springframework.format.Formatter; +import org.springframework.format.FormatterRegistry; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.converter.GenericMessageConverter; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.pulsar.config.MethodPulsarListenerEndpoint; +import org.springframework.pulsar.config.PulsarListenerConfigUtils; +import org.springframework.pulsar.config.PulsarListenerContainerFactory; +import org.springframework.pulsar.config.PulsarListenerEndpointRegistrar; +import org.springframework.pulsar.config.PulsarListenerEndpointRegistry; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.Validator; + +/** + * Bean post-processor that registers methods annotated with {@link PulsarListener} + * to be invoked by a Pulsar message listener container created under the covers + * by a {@link org.springframework.pulsar.config.PulsarListenerContainerFactory} + * according to the parameters of the annotation. + * + *

Annotated methods can use flexible arguments as defined by {@link PulsarListener}. + * + *

This post-processor is automatically registered by Spring's {@link EnablePulsar} + * annotation. + * + *

Auto-detect any {@link PulsarListenerConfigurer} instances in the container, + * allowing for customization of the registry to be used, the default container + * factory or for fine-grained control over endpoints registration. See + * {@link EnablePulsar} Javadoc for complete usage details. + * + * @param the key type. + * @param the value type. + * + * @author Soby Chacko + * + * @see PulsarListener + * @see EnablePulsar + * @see PulsarListenerConfigurer + * @see PulsarListenerEndpointRegistrar + * @see PulsarListenerEndpointRegistry + * @see org.springframework.pulsar.config.PulsarListenerEndpoint + * @see MethodPulsarListenerEndpoint + */ +public class PulsarListenerAnnotationBeanPostProcessor implements BeanPostProcessor, Ordered, ApplicationContextAware, InitializingBean, SmartInitializingSingleton { + + private final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass())); + + public static final String DEFAULT_PULSAR_LISTENER_CONTAINER_FACTORY_BEAN_NAME = "pulsarListenerContainerFactory"; + + private static final String THE_LEFT = "The ["; + + private static final String RESOLVED_TO_LEFT = "Resolved to ["; + + private static final String RIGHT_FOR_LEFT = "] for ["; + + private static final String GENERATED_ID_PREFIX = "org.springframework.Pulsar.PulsarListenerEndpointContainer#"; + + private ApplicationContext applicationContext; + private BeanFactory beanFactory; + private BeanExpressionResolver resolver; + private BeanExpressionContext expressionContext; + private PulsarListenerEndpointRegistry endpointRegistry; + + private String defaultContainerFactoryBeanName = DEFAULT_PULSAR_LISTENER_CONTAINER_FACTORY_BEAN_NAME; + + private final PulsarListenerEndpointRegistrar registrar = new PulsarListenerEndpointRegistrar(); + private final PulsarHandlerMethodFactoryAdapter messageHandlerMethodFactory = + new PulsarHandlerMethodFactoryAdapter(); + + + private Charset charset = StandardCharsets.UTF_8; + + private final Set> nonAnnotatedClasses = Collections.newSetFromMap(new ConcurrentHashMap<>(64)); + + private final ListenerScope listenerScope = new ListenerScope(); + + + private AnnotationEnhancer enhancer; + + private final AtomicInteger counter = new AtomicInteger(); + + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE; + } + + public void setEndpointRegistry(PulsarListenerEndpointRegistry endpointRegistry) { + this.endpointRegistry = endpointRegistry; + } + + public void setDefaultContainerFactoryBeanName(String containerFactoryBeanName) { + this.defaultContainerFactoryBeanName = containerFactoryBeanName; + } + + public void setCharset(Charset charset) { + Assert.notNull(charset, "'charset' cannot be null"); + this.charset = charset; + } + + @Override + public void afterPropertiesSet() throws Exception { + buildEnhancer(); + } + + private void buildEnhancer() { + if (this.applicationContext != null) { + Map enhancersMap = + this.applicationContext.getBeansOfType(AnnotationEnhancer.class, false, false); + if (enhancersMap.size() > 0) { + List enhancers = enhancersMap.values() + .stream() + .sorted(new OrderComparator()) + .collect(Collectors.toList()); + this.enhancer = (attrs, element) -> { + Map newAttrs = attrs; + for (AnnotationEnhancer enh : enhancers) { + newAttrs = enh.apply(newAttrs, element); + } + return attrs; + }; + } + } + } + + @Override + public void afterSingletonsInstantiated() { + this.registrar.setBeanFactory(this.beanFactory); + + if (this.beanFactory instanceof ListableBeanFactory) { + Map instances = + ((ListableBeanFactory) this.beanFactory).getBeansOfType(PulsarListenerConfigurer.class); + for (PulsarListenerConfigurer configurer : instances.values()) { + configurer.configurePulsarListeners(this.registrar); + } + } + + if (this.registrar.getEndpointRegistry() == null) { + if (this.endpointRegistry == null) { + Assert.state(this.beanFactory != null, + "BeanFactory must be set to find endpoint registry by bean name"); + this.endpointRegistry = this.beanFactory.getBean( + PulsarListenerConfigUtils.PULSAR_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME, + PulsarListenerEndpointRegistry.class); + } + this.registrar.setEndpointRegistry(this.endpointRegistry); + } + + if (this.defaultContainerFactoryBeanName != null) { + this.registrar.setContainerFactoryBeanName(this.defaultContainerFactoryBeanName); + } + + // Set the custom handler method factory once resolved by the configurer + MessageHandlerMethodFactory handlerMethodFactory = this.registrar.getMessageHandlerMethodFactory(); + if (handlerMethodFactory != null) { + this.messageHandlerMethodFactory.setHandlerMethodFactory(handlerMethodFactory); + } + else { + addFormatters(this.messageHandlerMethodFactory.defaultFormattingConversionService); + } + + // Actually register all listeners + this.registrar.afterPropertiesSet(); + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + return bean; + } + + @Override + public Object postProcessAfterInitialization(final Object bean, final String beanName) throws BeansException { + if (!this.nonAnnotatedClasses.contains(bean.getClass())) { + Class targetClass = AopUtils.getTargetClass(bean); + Collection classLevelListeners = findListenerAnnotations(targetClass); + Map> annotatedMethods = MethodIntrospector.selectMethods(targetClass, + (MethodIntrospector.MetadataLookup>) method -> { + Set listenerMethods = findListenerAnnotations(method); + return (!listenerMethods.isEmpty() ? listenerMethods : null); + }); + if (annotatedMethods.isEmpty()) { + this.nonAnnotatedClasses.add(bean.getClass()); + this.logger.trace(() -> "No @PulsarListener annotations found on bean type: " + bean.getClass()); + } + else { + // Non-empty set of methods + for (Map.Entry> entry : annotatedMethods.entrySet()) { + Method method = entry.getKey(); + for (PulsarListener listener : entry.getValue()) { + processPulsarListener(listener, method, bean, beanName); + } + } + this.logger.debug(() -> annotatedMethods.size() + " @PulsarListener methods processed on bean '" + + beanName + "': " + annotatedMethods); + } + } + return bean; + } + + protected void processPulsarListener(PulsarListener pulsarListener, Method method, Object bean, String beanName) { + Method methodToUse = checkProxy(method, bean); + MethodPulsarListenerEndpoint endpoint = new MethodPulsarListenerEndpoint<>(); + endpoint.setMethod(methodToUse); + + String beanRef = pulsarListener.beanRef(); + this.listenerScope.addListener(beanRef, bean); + String[] topics = resolveTopics(pulsarListener); + processListener(endpoint, pulsarListener, bean, beanName, topics); + this.listenerScope.removeListener(beanRef); + } + + protected void processListener(MethodPulsarListenerEndpoint endpoint, PulsarListener PulsarListener, + Object bean, String beanName, String[] topics) { + + processPulsarListenerAnnotation(endpoint, PulsarListener, bean, topics); + + String containerFactory = resolve(PulsarListener.containerFactory()); + PulsarListenerContainerFactory listenerContainerFactory = resolveContainerFactory(PulsarListener, + containerFactory, beanName); + + this.registrar.registerEndpoint(endpoint, listenerContainerFactory); + } + + @Nullable + private PulsarListenerContainerFactory resolveContainerFactory(PulsarListener PulsarListener, + Object factoryTarget, String beanName) { + + String containerFactory = PulsarListener.containerFactory(); + if (!StringUtils.hasText(containerFactory)) { + return null; + } + + PulsarListenerContainerFactory factory = null; + + Object resolved = resolveExpression(containerFactory); + if (resolved instanceof PulsarListenerContainerFactory) { + return (PulsarListenerContainerFactory) resolved; + } + String containerFactoryBeanName = resolveExpressionAsString(containerFactory, + "containerFactory"); + if (StringUtils.hasText(containerFactoryBeanName)) { + assertBeanFactory(); + try { + factory = this.beanFactory.getBean(containerFactoryBeanName, PulsarListenerContainerFactory.class); + } + catch (NoSuchBeanDefinitionException ex) { + throw new BeanInitializationException( + noBeanFoundMessage(factoryTarget, beanName, containerFactoryBeanName, + PulsarListenerContainerFactory.class), ex); + } + } + return factory; + } + + protected void assertBeanFactory() { + Assert.state(this.beanFactory != null, "BeanFactory must be set to obtain container factory by bean name"); + } + + protected String noBeanFoundMessage(Object target, String listenerBeanName, String requestedBeanName, + Class expectedClass) { + + return "Could not register Pulsar listener endpoint on [" + + target + "] for bean " + listenerBeanName + ", no '" + expectedClass.getSimpleName() + "' with id '" + + requestedBeanName + "' was found in the application context"; + } + + private void processPulsarListenerAnnotation(MethodPulsarListenerEndpoint endpoint, + PulsarListener pulsarListener, Object bean, String[] topics) { + + endpoint.setBean(bean); + endpoint.setMessageHandlerMethodFactory(this.messageHandlerMethodFactory); + endpoint.setSubscriptionName(getEndpointSubscriptionName(pulsarListener)); + endpoint.setId(getEndpointId(pulsarListener)); + endpoint.setTopics(topics); + endpoint.setSubscriptionType(getEndpointSubscriptionType(pulsarListener)); + + + String autoStartup = pulsarListener.autoStartup(); + if (StringUtils.hasText(autoStartup)) { + endpoint.setAutoStartup(resolveExpressionAsBoolean(autoStartup, "autoStartup")); + } + resolvePulsarProperties(endpoint, pulsarListener.properties()); + if (StringUtils.hasText(pulsarListener.batch())) { + endpoint.setBatchListener(Boolean.parseBoolean(pulsarListener.batch())); + } + endpoint.setBeanFactory(this.beanFactory); + } + + private Boolean resolveExpressionAsBoolean(String value, String attribute) { + Object resolved = resolveExpression(value); + Boolean result = null; + if (resolved instanceof Boolean) { + result = (Boolean) resolved; + } + else if (resolved instanceof String) { + result = Boolean.parseBoolean((String) resolved); + } + else if (resolved != null) { + throw new IllegalStateException( + THE_LEFT + attribute + "] must resolve to a Boolean or a String that can be parsed as a Boolean. " + + RESOLVED_TO_LEFT + resolved.getClass() + RIGHT_FOR_LEFT + value + "]"); + } + return result; + } + + private void resolvePulsarProperties(MethodPulsarListenerEndpoint endpoint, String[] propertyStrings) { + if (propertyStrings.length > 0) { + Properties properties = new Properties(); + for (String property : propertyStrings) { + Object value = resolveExpression(property); + if (value instanceof String) { + loadProperty(properties, property, value); + } + else if (value instanceof String[]) { + for (String prop : (String[]) value) { + loadProperty(properties, prop, prop); + } + } + else if (value instanceof Collection) { + Collection values = (Collection) value; + if (values.size() > 0 && values.iterator().next() instanceof String) { + for (String prop : (Collection) value) { + loadProperty(properties, prop, prop); + } + } + } + else { + throw new IllegalStateException("'properties' must resolve to a String, a String[] or " + + "Collection"); + } + } + endpoint.setConsumerProperties(properties); + } + } + + private void loadProperty(Properties properties, String property, Object value) { + try { + properties.load(new StringReader((String) value)); + } + catch (IOException e) { + this.logger.error(e, () -> "Failed to load property " + property + ", continuing..."); + } + } + + private String getEndpointSubscriptionName(PulsarListener pulsarListener) { + if (StringUtils.hasText(pulsarListener.subscriptionName())) { + return resolveExpressionAsString(pulsarListener.subscriptionName(), "subscriptionName"); + } + else { + return GENERATED_ID_PREFIX + this.counter.getAndIncrement(); + } + } + + private SubscriptionType getEndpointSubscriptionType(PulsarListener pulsarListener) { + final String subscriptionType = pulsarListener.subscriptionType().toLowerCase(); + if (StringUtils.hasText(subscriptionType)) { + return switch (subscriptionType) { + case "exclusive" -> SubscriptionType.Exclusive; + case "failover" -> SubscriptionType.Failover; + case "shared" -> SubscriptionType.Shared; + case "key_shared" -> SubscriptionType.Key_Shared; + default -> SubscriptionType.Exclusive; + }; + } + return null; + } + + private String getEndpointId(PulsarListener pulsarListener) { + if (StringUtils.hasText(pulsarListener.id())) { + return resolveExpressionAsString(pulsarListener.id(), "id"); + } + else { + return GENERATED_ID_PREFIX + this.counter.getAndIncrement(); + } + } + + private String resolveExpressionAsString(String value, String attribute) { + Object resolved = resolveExpression(value); + if (resolved instanceof String) { + return (String) resolved; + } + else if (resolved != null) { + throw new IllegalStateException(THE_LEFT + attribute + "] must resolve to a String. " + + RESOLVED_TO_LEFT + resolved.getClass() + RIGHT_FOR_LEFT + value + "]"); + } + return null; + } + + private String[] resolveTopics(PulsarListener PulsarListener) { + String[] topics = PulsarListener.topics(); + List result = new ArrayList<>(); + if (topics.length > 0) { + for (String topic1 : topics) { + Object topic = resolveExpression(topic1); + resolveAsString(topic, result); + } + } + return result.toArray(new String[0]); + } + + private Object resolveExpression(String value) { + return this.resolver.evaluate(resolve(value), this.expressionContext); + } + + private String resolve(String value) { + if (this.beanFactory != null && this.beanFactory instanceof ConfigurableBeanFactory) { + return ((ConfigurableBeanFactory) this.beanFactory).resolveEmbeddedValue(value); + } + return value; + } + + private void resolveAsString(Object resolvedValue, List result) { + if (resolvedValue instanceof String[]) { + for (Object object : (String[]) resolvedValue) { + resolveAsString(object, result); + } + } + else if (resolvedValue instanceof String) { + result.add((String) resolvedValue); + } + else if (resolvedValue instanceof Iterable) { + for (Object object : (Iterable) resolvedValue) { + resolveAsString(object, result); + } + } + else { + throw new IllegalArgumentException(String.format( + "@PulsarListener can't resolve '%s' as a String", resolvedValue)); + } + } + + private Method checkProxy(Method methodArg, Object bean) { + Method method = methodArg; + if (AopUtils.isJdkDynamicProxy(bean)) { + try { + // Found a @PulsarListener method on the target class for this JDK proxy -> + // is it also present on the proxy itself? + method = bean.getClass().getMethod(method.getName(), method.getParameterTypes()); + Class[] proxiedInterfaces = ((Advised) bean).getProxiedInterfaces(); + for (Class iface : proxiedInterfaces) { + try { + method = iface.getMethod(method.getName(), method.getParameterTypes()); + break; + } + catch (@SuppressWarnings("unused") NoSuchMethodException noMethod) { + // NOSONAR + } + } + } + catch (SecurityException ex) { + ReflectionUtils.handleReflectionException(ex); + } + catch (NoSuchMethodException ex) { + throw new IllegalStateException(String.format( + "@PulsarListener method '%s' found on bean target class '%s', " + + "but not found in any interface(s) for bean JDK proxy. Either " + + "pull the method up to an interface or switch to subclass (CGLIB) " + + "proxies by setting proxy-target-class/proxyTargetClass " + + "attribute to 'true'", method.getName(), + method.getDeclaringClass().getSimpleName()), ex); + } + } + return method; + } + + private Collection findListenerAnnotations(Class clazz) { + Set listeners = new HashSet<>(); + PulsarListener ann = AnnotatedElementUtils.findMergedAnnotation(clazz, PulsarListener.class); + if (ann != null) { + ann = enhance(clazz, ann); + listeners.add(ann); + } + PulsarListeners anns = AnnotationUtils.findAnnotation(clazz, PulsarListeners.class); + if (anns != null) { + listeners.addAll(Arrays.stream(anns.value()) + .map(anno -> enhance(clazz, anno)) + .collect(Collectors.toList())); + } + return listeners; + } + + private Set findListenerAnnotations(Method method) { + Set listeners = new HashSet<>(); + PulsarListener ann = AnnotatedElementUtils.findMergedAnnotation(method, PulsarListener.class); + if (ann != null) { + ann = enhance(method, ann); + listeners.add(ann); + } + PulsarListeners anns = AnnotationUtils.findAnnotation(method, PulsarListeners.class); + if (anns != null) { + listeners.addAll(Arrays.stream(anns.value()) + .map(anno -> enhance(method, anno)) + .collect(Collectors.toList())); + } + return listeners; + } + + private PulsarListener enhance(AnnotatedElement element, PulsarListener ann) { + if (this.enhancer == null) { + return ann; + } + else { + return AnnotationUtils.synthesizeAnnotation( + this.enhancer.apply(AnnotationUtils.getAnnotationAttributes(ann), element), PulsarListener.class, null); + } + } + + + private void addFormatters(FormatterRegistry registry) { + for (Converter converter : getBeansOfType(Converter.class)) { + registry.addConverter(converter); + } + for (GenericConverter converter : getBeansOfType(GenericConverter.class)) { + registry.addConverter(converter); + } + for (Formatter formatter : getBeansOfType(Formatter.class)) { + registry.addFormatter(formatter); + } + } + + private Collection getBeansOfType(Class type) { + if (PulsarListenerAnnotationBeanPostProcessor.this.beanFactory instanceof ListableBeanFactory) { + return ((ListableBeanFactory) PulsarListenerAnnotationBeanPostProcessor.this.beanFactory) + .getBeansOfType(type) + .values(); + } + else { + return Collections.emptySet(); + } + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + if (applicationContext instanceof ConfigurableApplicationContext) { + setBeanFactory(((ConfigurableApplicationContext) applicationContext).getBeanFactory()); + } + else { + setBeanFactory(applicationContext); + } + } + + public void setBeanFactory(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + if (beanFactory instanceof ConfigurableListableBeanFactory) { + this.resolver = ((ConfigurableListableBeanFactory) beanFactory).getBeanExpressionResolver(); + this.expressionContext = new BeanExpressionContext((ConfigurableListableBeanFactory) beanFactory, + this.listenerScope); + } + } + + private class PulsarHandlerMethodFactoryAdapter implements MessageHandlerMethodFactory { + + private final DefaultFormattingConversionService defaultFormattingConversionService = + new DefaultFormattingConversionService(); + + private MessageHandlerMethodFactory handlerMethodFactory; + + public void setHandlerMethodFactory(MessageHandlerMethodFactory pulsarHandlerMethodFactory1) { + this.handlerMethodFactory = pulsarHandlerMethodFactory1; + } + + @Override + public InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) { + return getHandlerMethodFactory().createInvocableHandlerMethod(bean, method); + } + + private MessageHandlerMethodFactory getHandlerMethodFactory() { + if (this.handlerMethodFactory == null) { + this.handlerMethodFactory = createDefaultMessageHandlerMethodFactory(); + } + return this.handlerMethodFactory; + } + + private MessageHandlerMethodFactory createDefaultMessageHandlerMethodFactory() { + DefaultMessageHandlerMethodFactory defaultFactory = new DefaultMessageHandlerMethodFactory(); + Validator validator = PulsarListenerAnnotationBeanPostProcessor.this.registrar.getValidator(); + if (validator != null) { + defaultFactory.setValidator(validator); + } + defaultFactory.setBeanFactory(PulsarListenerAnnotationBeanPostProcessor.this.beanFactory); + this.defaultFormattingConversionService.addConverter( + new BytesToStringConverter(PulsarListenerAnnotationBeanPostProcessor.this.charset)); + this.defaultFormattingConversionService.addConverter(new BytesToNumberConverter()); + defaultFactory.setConversionService(this.defaultFormattingConversionService); + GenericMessageConverter messageConverter = new GenericMessageConverter(this.defaultFormattingConversionService); + defaultFactory.setMessageConverter(messageConverter); + + List customArgumentsResolver = + new ArrayList<>(PulsarListenerAnnotationBeanPostProcessor.this.registrar.getCustomMethodArgumentResolvers()); + // Has to be at the end - look at PayloadMethodArgumentResolver documentation + //customArgumentsResolver.add(new PulsarNullAwarePayloadArgumentResolver(messageConverter, validator)); + defaultFactory.setCustomArgumentResolvers(customArgumentsResolver); + + defaultFactory.afterPropertiesSet(); + + return defaultFactory; + } + + } + + private static class BytesToStringConverter implements Converter { + + + private final Charset charset; + + BytesToStringConverter(Charset charset) { + this.charset = charset; + } + + @Override + public String convert(byte[] source) { + return new String(source, this.charset); + } + + } + + private final class BytesToNumberConverter implements ConditionalGenericConverter { + + BytesToNumberConverter() { + } + + @Override + @Nullable + public Set getConvertibleTypes() { + HashSet pairs = new HashSet<>(); + pairs.add(new ConvertiblePair(byte[].class, long.class)); + pairs.add(new ConvertiblePair(byte[].class, int.class)); + pairs.add(new ConvertiblePair(byte[].class, short.class)); + pairs.add(new ConvertiblePair(byte[].class, byte.class)); + pairs.add(new ConvertiblePair(byte[].class, Long.class)); + pairs.add(new ConvertiblePair(byte[].class, Integer.class)); + pairs.add(new ConvertiblePair(byte[].class, Short.class)); + pairs.add(new ConvertiblePair(byte[].class, Byte.class)); + return pairs; + } + + @Override + @Nullable + public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + byte[] bytes = (byte[]) source; + if (targetType.getType().equals(long.class) || targetType.getType().equals(Long.class)) { + Assert.state(bytes.length >= 8, "At least 8 bytes needed to convert a byte[] to a long"); // NOSONAR + return ByteBuffer.wrap(bytes).getLong(); + } + else if (targetType.getType().equals(int.class) || targetType.getType().equals(Integer.class)) { + Assert.state(bytes.length >= 4, "At least 4 bytes needed to convert a byte[] to an integer"); // NOSONAR + return ByteBuffer.wrap(bytes).getInt(); + } + else if (targetType.getType().equals(short.class) || targetType.getType().equals(Short.class)) { + Assert.state(bytes.length >= 2, "At least 2 bytes needed to convert a byte[] to a short"); + return ByteBuffer.wrap(bytes).getShort(); + } + else if (targetType.getType().equals(byte.class) || targetType.getType().equals(Byte.class)) { + Assert.state(bytes.length >= 1, "At least 1 byte needed to convert a byte[] to a byte"); + return ByteBuffer.wrap(bytes).get(); + } + return null; + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + if (sourceType.getType().equals(byte[].class)) { + Class target = targetType.getType(); + return target.equals(long.class) || target.equals(int.class) || target.equals(short.class) // NOSONAR + || target.equals(byte.class) || target.equals(Long.class) || target.equals(Integer.class) + || target.equals(Short.class) || target.equals(Byte.class); + } + else { + return false; + } + } + + } + + static class ListenerScope implements Scope { + + private final Map listeners = new HashMap<>(); + + ListenerScope() { + } + + public void addListener(String key, Object bean) { + this.listeners.put(key, bean); + } + + public void removeListener(String key) { + this.listeners.remove(key); + } + + @Override + public Object get(String name, ObjectFactory objectFactory) { + return this.listeners.get(name); + } + + @Override + public Object remove(String name) { + return null; + } + + @Override + public void registerDestructionCallback(String name, Runnable callback) { + } + + @Override + public Object resolveContextualObject(String key) { + return this.listeners.get(key); + } + + @Override + public String getConversationId() { + return null; + } + + } + + + public interface AnnotationEnhancer extends BiFunction, AnnotatedElement, Map> { + + } + + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurationSelector.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurationSelector.java new file mode 100644 index 00000000..11dd80fa --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurationSelector.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.annotation.Order; +import org.springframework.core.type.AnnotationMetadata; + +/** + * A {@link DeferredImportSelector} implementation with the lowest order to import a + * {@link PulsarBootstrapConfiguration} as late as possible. + * + * @author Soby Chacko + * + */ +@Order +public class PulsarListenerConfigurationSelector implements DeferredImportSelector { + + @Override + public String[] selectImports(AnnotationMetadata importingClassMetadata) { + return new String[] { PulsarBootstrapConfiguration.class.getName() }; + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurer.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurer.java new file mode 100644 index 00000000..59ad2161 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListenerConfigurer.java @@ -0,0 +1,27 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import org.springframework.pulsar.config.PulsarListenerEndpointRegistrar; + +/** + * @author Soby Chacko + */ +public interface PulsarListenerConfigurer { + + void configurePulsarListeners(PulsarListenerEndpointRegistrar registrar); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListeners.java b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListeners.java new file mode 100644 index 00000000..ed9a3883 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/annotation/PulsarListeners.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.pulsar.annotation.PulsarListener; + +/** + * @author Soby Chacko + */ +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface PulsarListeners { + + PulsarListener[] value(); + +} \ No newline at end of file diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAnnotationDrivenConfiguration.java b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAnnotationDrivenConfiguration.java new file mode 100644 index 00000000..69526775 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAnnotationDrivenConfiguration.java @@ -0,0 +1,77 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.autoconfig; + +import org.apache.pulsar.client.api.Schema; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.annotation.EnablePulsar; +import org.springframework.pulsar.config.PulsarListenerConfigUtils; +import org.springframework.pulsar.config.PulsarListenerContainerFactoryImpl; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +/** + * @author Soby Chacko + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(EnablePulsar.class) +public class PulsarAnnotationDrivenConfiguration { + + private final PulsarProperties pulsarProperties; + + public PulsarAnnotationDrivenConfiguration(PulsarProperties pulsarProperties) { + this.pulsarProperties = pulsarProperties; + } + + @Bean + @ConditionalOnMissingBean(name = "pulsarListenerContainerFactory") + PulsarListenerContainerFactoryImpl pulsarListenerContainerFactory( + ObjectProvider> pulsarConsumerFactory) { + PulsarListenerContainerFactoryImpl factory = new PulsarListenerContainerFactoryImpl<>(); + + final PulsarConsumerFactory pulsarConsumerFactory1 = pulsarConsumerFactory.getIfAvailable(); + factory.setPulsarConsumerFactory(pulsarConsumerFactory1); + + final PulsarContainerProperties containerProperties = factory.getContainerProperties(); + + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + PulsarProperties.Listener properties = this.pulsarProperties.getListener(); + + map.from(properties::getSchema).as( + schema1 -> switch (schema1) { + case STRING -> Schema.STRING; + case BYTES -> Schema.BYTES; + case BYTEBUFFER -> Schema.BYTEBUFFER; + }).to(containerProperties::setSchema); + + return factory; + } + + @Configuration(proxyBeanMethods = false) + @EnablePulsar + @ConditionalOnMissingBean(name = PulsarListenerConfigUtils.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME) + static class EnableKafkaConfiguration { + + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAutoConfiguration.java b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAutoConfiguration.java new file mode 100644 index 00000000..ee180900 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.autoconfig; + +import org.apache.pulsar.client.api.PulsarClient; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.core.DefaultPulsarConsumerFactory; +import org.springframework.pulsar.core.DefaultPulsarProducerFactory; +import org.springframework.pulsar.config.PulsarClientConfiguration; +import org.springframework.pulsar.config.PulsarClientFactoryBean; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.core.PulsarProducerFactory; +import org.springframework.pulsar.core.PulsarTemplate; + +/** + * @author Soby Chacko + */ +@AutoConfiguration +@ConditionalOnClass(PulsarTemplate.class) +@EnableConfigurationProperties(PulsarProperties.class) +@Import({ PulsarAnnotationDrivenConfiguration.class }) +public class PulsarAutoConfiguration { + + private final PulsarProperties properties; + + public PulsarAutoConfiguration(PulsarProperties properties) { + this.properties = properties; + } + + @Bean + public PulsarClientFactoryBean pulsarClientFactoryBean(PulsarClientConfiguration pulsarClientConfiguration) { + return new PulsarClientFactoryBean(pulsarClientConfiguration); + } + + @Bean + public PulsarClientConfiguration pulsarClientConfiguration() { + return new PulsarClientConfiguration(this.properties.buildClientProperties()); + } + + @Bean + public PulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient) { + return new DefaultPulsarProducerFactory<>(pulsarClient, this.properties.buildProducerProperties()); + } + + @Bean + public PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory) { + return new PulsarTemplate<>(pulsarProducerFactory); + } + + @Bean + @ConditionalOnMissingBean(PulsarConsumerFactory.class) + public PulsarConsumerFactory pulsarConsumerFactory(PulsarClient pulsarClient) { + DefaultPulsarConsumerFactory factory = new DefaultPulsarConsumerFactory<>(pulsarClient, + this.properties.buildConsumerProperties()); + return factory; + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarProperties.java b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarProperties.java new file mode 100644 index 00000000..060bc4fe --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/autoconfig/PulsarProperties.java @@ -0,0 +1,774 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.autoconfig; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.ConsumerCryptoFailureAction; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerCryptoFailureAction; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; + +/** + * @author Soby Chacko + */ +@ConfigurationProperties(prefix = "spring.pulsar") +public class PulsarProperties { + + private final Consumer consumer = new Consumer(); + private final Client client = new Client(); + + private final Listener listener = new Listener(); + + private final Producer producer = new Producer(); + + public Map buildConsumerProperties() { + Map properties = new HashMap<>(); + properties.putAll(this.consumer.buildProperties()); + return properties; + } + + public Map buildProducerProperties() { + Map properties = new HashMap<>(); + properties.putAll(this.producer.buildProperties()); + return properties; + } + + public enum Schema { + STRING, + BYTES, + BYTEBUFFER; + } + + public Consumer getConsumer() { + return consumer; + } + + public Listener getListener() { + return listener; + } + + public Client getClient() { + return client; + } + + public Producer getProducer() { + return producer; + } + + public Map buildClientProperties() { + return new HashMap<>(this.client.buildProperties()); + } + + public static class Consumer { + + private String[] topics; + + private String topicsPattern; + + private String subscriptionName; + + private SubscriptionType subscriptionType = SubscriptionType.Exclusive; + + private int receiverQueueSize = 1000; + + private long acknowledgementsGroupTimeMicros = TimeUnit.MILLISECONDS.toMicros(100); + + private long negativeAckRedeliveryDelayMicros = TimeUnit.MINUTES.toMicros(1); + + private int maxTotalReceiverQueueSizeAcrossPartitions = 50000; + + private String consumerName; + + private long ackTimeoutMillis = 0; + + private long tickDurationMillis = 1000; + + private int priorityLevel = 0; + + private ConsumerCryptoFailureAction cryptoFailureAction = ConsumerCryptoFailureAction.FAIL; + + private SortedMap properties = new TreeMap<>(); + + private boolean readCompacted = false; + + private SubscriptionInitialPosition subscriptionInitialPosition = SubscriptionInitialPosition.Latest; + + private int patternAutoDiscoveryPeriod = 1; + + private RegexSubscriptionMode regexSubscriptionMode = RegexSubscriptionMode.PersistentOnly; + + private boolean autoUpdatePartitions = true; + + private boolean replicateSubscriptionState = false; + + private boolean autoAckOldestChunkedMessageOnQueueFull = true; + + private int maxPendingChunkedMessage = 10; + + private long expireTimeOfIncompleteChunkedMessageMillis = 60000; + + public String[] getTopics() { + return topics; + } + + public void setTopics(String[] topics) { + this.topics = topics; + } + + public String getTopicsPattern() { + return topicsPattern; + } + + public void setTopicsPattern(String topicsPattern) { + this.topicsPattern = topicsPattern; + } + + public String getSubscriptionName() { + return subscriptionName; + } + + public void setSubscriptionName(String subscriptionName) { + this.subscriptionName = subscriptionName; + } + + public SubscriptionType getSubscriptionType() { + return subscriptionType; + } + + public void setSubscriptionType(SubscriptionType subscriptionType) { + this.subscriptionType = subscriptionType; + } + + public int getReceiverQueueSize() { + return receiverQueueSize; + } + + public void setReceiverQueueSize(int receiverQueueSize) { + this.receiverQueueSize = receiverQueueSize; + } + + public long getAcknowledgementsGroupTimeMicros() { + return acknowledgementsGroupTimeMicros; + } + + public void setAcknowledgementsGroupTimeMicros(long acknowledgementsGroupTimeMicros) { + this.acknowledgementsGroupTimeMicros = acknowledgementsGroupTimeMicros; + } + + public long getNegativeAckRedeliveryDelayMicros() { + return negativeAckRedeliveryDelayMicros; + } + + public void setNegativeAckRedeliveryDelayMicros(long negativeAckRedeliveryDelayMicros) { + this.negativeAckRedeliveryDelayMicros = negativeAckRedeliveryDelayMicros; + } + + public int getMaxTotalReceiverQueueSizeAcrossPartitions() { + return maxTotalReceiverQueueSizeAcrossPartitions; + } + + public void setMaxTotalReceiverQueueSizeAcrossPartitions(int maxTotalReceiverQueueSizeAcrossPartitions) { + this.maxTotalReceiverQueueSizeAcrossPartitions = maxTotalReceiverQueueSizeAcrossPartitions; + } + + public String getConsumerName() { + return consumerName; + } + + public void setConsumerName(String consumerName) { + this.consumerName = consumerName; + } + + public long getAckTimeoutMillis() { + return ackTimeoutMillis; + } + + public void setAckTimeoutMillis(long ackTimeoutMillis) { + this.ackTimeoutMillis = ackTimeoutMillis; + } + + public long getTickDurationMillis() { + return tickDurationMillis; + } + + public void setTickDurationMillis(long tickDurationMillis) { + this.tickDurationMillis = tickDurationMillis; + } + + public int getPriorityLevel() { + return priorityLevel; + } + + public void setPriorityLevel(int priorityLevel) { + this.priorityLevel = priorityLevel; + } + + public ConsumerCryptoFailureAction getCryptoFailureAction() { + return cryptoFailureAction; + } + + public void setCryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) { + this.cryptoFailureAction = cryptoFailureAction; + } + + public SortedMap getProperties() { + return properties; + } + + public void setProperties(SortedMap properties) { + this.properties = properties; + } + + public boolean isReadCompacted() { + return readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + public SubscriptionInitialPosition getSubscriptionInitialPosition() { + return subscriptionInitialPosition; + } + + public void setSubscriptionInitialPosition(SubscriptionInitialPosition subscriptionInitialPosition) { + this.subscriptionInitialPosition = subscriptionInitialPosition; + } + + public int getPatternAutoDiscoveryPeriod() { + return patternAutoDiscoveryPeriod; + } + + public void setPatternAutoDiscoveryPeriod(int patternAutoDiscoveryPeriod) { + this.patternAutoDiscoveryPeriod = patternAutoDiscoveryPeriod; + } + + public RegexSubscriptionMode getRegexSubscriptionMode() { + return regexSubscriptionMode; + } + + public void setRegexSubscriptionMode(RegexSubscriptionMode regexSubscriptionMode) { + this.regexSubscriptionMode = regexSubscriptionMode; + } + + public boolean isAutoUpdatePartitions() { + return autoUpdatePartitions; + } + + public void setAutoUpdatePartitions(boolean autoUpdatePartitions) { + this.autoUpdatePartitions = autoUpdatePartitions; + } + + public boolean isReplicateSubscriptionState() { + return replicateSubscriptionState; + } + + public void setReplicateSubscriptionState(boolean replicateSubscriptionState) { + this.replicateSubscriptionState = replicateSubscriptionState; + } + + public boolean isAutoAckOldestChunkedMessageOnQueueFull() { + return autoAckOldestChunkedMessageOnQueueFull; + } + + public void setAutoAckOldestChunkedMessageOnQueueFull(boolean autoAckOldestChunkedMessageOnQueueFull) { + this.autoAckOldestChunkedMessageOnQueueFull = autoAckOldestChunkedMessageOnQueueFull; + } + + public int getMaxPendingChunkedMessage() { + return maxPendingChunkedMessage; + } + + public void setMaxPendingChunkedMessage(int maxPendingChunkedMessage) { + this.maxPendingChunkedMessage = maxPendingChunkedMessage; + } + + public long getExpireTimeOfIncompleteChunkedMessageMillis() { + return expireTimeOfIncompleteChunkedMessageMillis; + } + + public void setExpireTimeOfIncompleteChunkedMessageMillis(long expireTimeOfIncompleteChunkedMessageMillis) { + this.expireTimeOfIncompleteChunkedMessageMillis = expireTimeOfIncompleteChunkedMessageMillis; + } + + public Map buildProperties() { + PulsarProperties.Properties properties = new Properties(); + + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + + map.from(this::getTopics).as(Set::of).to(properties.in("topicNames")); + map.from(this::getTopicsPattern).as(Pattern::compile).to(properties.in("topicsPattern")); + map.from(this::getSubscriptionName).to(properties.in("subscriptionName")); + map.from(this::getSubscriptionType).to(properties.in("subscriptionType")); + map.from(this::getReceiverQueueSize) + .to(properties.in("receiverQueueSize")); + map.from(this::getAcknowledgementsGroupTimeMicros).to(properties.in("acknowledgementsGroupTimeMicros")); + map.from(this::getNegativeAckRedeliveryDelayMicros).to(properties.in("negativeAckRedeliveryDelayMicros")); + map.from(this::getMaxTotalReceiverQueueSizeAcrossPartitions).to(properties.in("maxTotalReceiverQueueSizeAcrossPartitions")); + map.from(this::getConsumerName).to(properties.in("consumerName")); + map.from(this::getAckTimeoutMillis).to(properties.in("ackTimeoutMillis")); + map.from(this::getTickDurationMillis).to(properties.in("tickDurationMillis")); + map.from(this::getPriorityLevel).to(properties.in("priorityLevel")); + map.from(this::getCryptoFailureAction).to(properties.in("cryptoFailureAction")); + map.from(this::getProperties).to(properties.in("properties")); + map.from(this::isReadCompacted).to(properties.in("readCompacted")); + map.from(this::getSubscriptionInitialPosition).to(properties.in("subscriptionInitialPosition")); + map.from(this::getPatternAutoDiscoveryPeriod).to(properties.in("patternAutoDiscoveryPeriod")); + map.from(this::getRegexSubscriptionMode).to(properties.in("regexSubscriptionMode")); + map.from(this::isAutoUpdatePartitions).to(properties.in("autoUpdatePartitions")); + map.from(this::isReplicateSubscriptionState).to(properties.in("replicateSubscriptionState")); + map.from(this::isAutoAckOldestChunkedMessageOnQueueFull).to(properties.in("autoAckOldestChunkedMessageOnQueueFull")); + map.from(this::getMaxPendingChunkedMessage).to(properties.in("maxPendingChunkedMessage")); + map.from(this::getExpireTimeOfIncompleteChunkedMessageMillis).to(properties.in("expireTimeOfIncompleteChunkedMessageMillis")); + return properties; + } + + } + + public static class Producer { + + private String topicName; + + private String producerName; + + private long sendTimeoutMs = 30000; + + private boolean blockIfQueueFull = false; + + private int maxPendingMessages = 1000; + + private int maxPendingMessagesAcrossPartitions = 50000; + + private MessageRoutingMode messageRoutingMode = MessageRoutingMode.RoundRobinPartition; + + private HashingScheme hashingScheme = HashingScheme.JavaStringHash; + + private ProducerCryptoFailureAction cryptoFailureAction = ProducerCryptoFailureAction.FAIL; + + private long batchingMaxPublishDelayMicros = TimeUnit.MILLISECONDS.toMicros(1); + + private int batchingMaxMessages = 1000; + + private boolean batchingEnabled = false; + + private boolean chunkingEnabled = false; + + private CompressionType compressionType; + + private String initialSubscriptionName; + + public String getTopicName() { + return topicName; + } + + public void setTopicName(String topicName) { + this.topicName = topicName; + } + + public String getProducerName() { + return producerName; + } + + public void setProducerName(String producerName) { + this.producerName = producerName; + } + + public long getSendTimeoutMs() { + return sendTimeoutMs; + } + + public void setSendTimeoutMs(long sendTimeoutMs) { + this.sendTimeoutMs = sendTimeoutMs; + } + + public boolean isBlockIfQueueFull() { + return blockIfQueueFull; + } + + public void setBlockIfQueueFull(boolean blockIfQueueFull) { + this.blockIfQueueFull = blockIfQueueFull; + } + + public int getMaxPendingMessages() { + return maxPendingMessages; + } + + public void setMaxPendingMessages(int maxPendingMessages) { + this.maxPendingMessages = maxPendingMessages; + } + + public int getMaxPendingMessagesAcrossPartitions() { + return maxPendingMessagesAcrossPartitions; + } + + public void setMaxPendingMessagesAcrossPartitions(int maxPendingMessagesAcrossPartitions) { + this.maxPendingMessagesAcrossPartitions = maxPendingMessagesAcrossPartitions; + } + + public MessageRoutingMode getMessageRoutingMode() { + return messageRoutingMode; + } + + public void setMessageRoutingMode(MessageRoutingMode messageRoutingMode) { + this.messageRoutingMode = messageRoutingMode; + } + + public HashingScheme getHashingScheme() { + return hashingScheme; + } + + public void setHashingScheme(HashingScheme hashingScheme) { + this.hashingScheme = hashingScheme; + } + + public ProducerCryptoFailureAction getCryptoFailureAction() { + return cryptoFailureAction; + } + + public void setCryptoFailureAction(ProducerCryptoFailureAction cryptoFailureAction) { + this.cryptoFailureAction = cryptoFailureAction; + } + + public long getBatchingMaxPublishDelayMicros() { + return batchingMaxPublishDelayMicros; + } + + public void setBatchingMaxPublishDelayMicros(long batchingMaxPublishDelayMicros) { + this.batchingMaxPublishDelayMicros = batchingMaxPublishDelayMicros; + } + + public int getBatchingMaxMessages() { + return batchingMaxMessages; + } + + public void setBatchingMaxMessages(int batchingMaxMessages) { + this.batchingMaxMessages = batchingMaxMessages; + } + + public boolean isBatchingEnabled() { + return batchingEnabled; + } + + public void setBatchingEnabled(boolean batchingEnabled) { + this.batchingEnabled = batchingEnabled; + } + + public boolean isChunkingEnabled() { + return chunkingEnabled; + } + + public void setChunkingEnabled(boolean chunkingEnabled) { + this.chunkingEnabled = chunkingEnabled; + } + + public CompressionType getCompressionType() { + return compressionType; + } + + public void setCompressionType(CompressionType compressionType) { + this.compressionType = compressionType; + } + + public String getInitialSubscriptionName() { + return initialSubscriptionName; + } + + public void setInitialSubscriptionName(String initialSubscriptionName) { + this.initialSubscriptionName = initialSubscriptionName; + } + + public Map buildProperties() { + PulsarProperties.Properties properties = new Properties(); + + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + + map.from(this::getTopicName).to(properties.in("topicName")); + map.from(this::getProducerName).to(properties.in("producerName")); + map.from(this::getSendTimeoutMs).to(properties.in("sendTimeoutMs")); + map.from(this::isBlockIfQueueFull).to(properties.in("blockIfQueueFull")); + map.from(this::getMaxPendingMessages).to(properties.in("maxPendingMessages")); + map.from(this::getMaxPendingMessagesAcrossPartitions).to(properties.in("maxPendingMessagesAcrossPartitions")); + map.from(this::getMessageRoutingMode).to(properties.in("messageRoutingMode")); + map.from(this::getHashingScheme).to(properties.in("hashingScheme")); + map.from(this::getCryptoFailureAction).to(properties.in("cryptoFailureAction")); + map.from(this::getBatchingMaxPublishDelayMicros).to(properties.in("batchingMaxPublishDelayMicros")); + map.from(this::getBatchingMaxMessages).to(properties.in("batchingMaxMessages")); + map.from(this::isBatchingEnabled).to(properties.in("batchingEnabled")); + map.from(this::isChunkingEnabled).to(properties.in("chunkingEnabled")); + map.from(this::getCompressionType).to(properties.in("compressionType")); + map.from(this::getInitialSubscriptionName).to(properties.in("initialSubscriptionName")); + + return properties; + } + } + + public static class Client { + + private String serviceUrl; + + private String authPluginClassName; + + private String authParams; + + private long operationTimeoutMs = 30000L; + + private long statsIntervalSeconds = 60; + + private int numIoThreads = 1; + + private boolean useTcpNoDelay = true; + + private boolean useTls = false; + + private String tlsTrustCertsFilePath; + + private boolean tlsAllowInsecureConnection = false; + + private boolean tlsHostnameVerificationEnable = false; + + private int concurrentLookupRequest = 5000; + + private int maxLookupRequest = 50000; + + private int maxNumberOfRejectedRequestPerConnection = 50; + + private int keepAliveIntervalSeconds = 30; + + private int connectionTimeoutMs = 10000; + + private int requestTimeoutMs = 60000; + + private long initialBackoffIntervalNanos = TimeUnit.MILLISECONDS.toNanos(100);; + + private long maxBackoffIntervalNanos = TimeUnit.SECONDS.toNanos(30); + + public String getServiceUrl() { + return serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public String getAuthPluginClassName() { + return authPluginClassName; + } + + public void setAuthPluginClassName(String authPluginClassName) { + this.authPluginClassName = authPluginClassName; + } + + public String getAuthParams() { + return authParams; + } + + public void setAuthParams(String authParams) { + this.authParams = authParams; + } + + public long getOperationTimeoutMs() { + return operationTimeoutMs; + } + + public void setOperationTimeoutMs(long operationTimeoutMs) { + this.operationTimeoutMs = operationTimeoutMs; + } + + public long getStatsIntervalSeconds() { + return statsIntervalSeconds; + } + + public void setStatsIntervalSeconds(long statsIntervalSeconds) { + this.statsIntervalSeconds = statsIntervalSeconds; + } + + public int getNumIoThreads() { + return numIoThreads; + } + + public void setNumIoThreads(int numIoThreads) { + this.numIoThreads = numIoThreads; + } + + public boolean isUseTcpNoDelay() { + return useTcpNoDelay; + } + + public void setUseTcpNoDelay(boolean useTcpNoDelay) { + this.useTcpNoDelay = useTcpNoDelay; + } + + public boolean isUseTls() { + return useTls; + } + + public void setUseTls(boolean useTls) { + this.useTls = useTls; + } + + public String getTlsTrustCertsFilePath() { + return tlsTrustCertsFilePath; + } + + public void setTlsTrustCertsFilePath(String tlsTrustCertsFilePath) { + this.tlsTrustCertsFilePath = tlsTrustCertsFilePath; + } + + public boolean isTlsAllowInsecureConnection() { + return tlsAllowInsecureConnection; + } + + public void setTlsAllowInsecureConnection(boolean tlsAllowInsecureConnection) { + this.tlsAllowInsecureConnection = tlsAllowInsecureConnection; + } + + public boolean isTlsHostnameVerificationEnable() { + return tlsHostnameVerificationEnable; + } + + public void setTlsHostnameVerificationEnable(boolean tlsHostnameVerificationEnable) { + this.tlsHostnameVerificationEnable = tlsHostnameVerificationEnable; + } + + public int getConcurrentLookupRequest() { + return concurrentLookupRequest; + } + + public void setConcurrentLookupRequest(int concurrentLookupRequest) { + this.concurrentLookupRequest = concurrentLookupRequest; + } + + public int getMaxLookupRequest() { + return maxLookupRequest; + } + + public void setMaxLookupRequest(int maxLookupRequest) { + this.maxLookupRequest = maxLookupRequest; + } + + public int getMaxNumberOfRejectedRequestPerConnection() { + return maxNumberOfRejectedRequestPerConnection; + } + + public void setMaxNumberOfRejectedRequestPerConnection(int maxNumberOfRejectedRequestPerConnection) { + this.maxNumberOfRejectedRequestPerConnection = maxNumberOfRejectedRequestPerConnection; + } + + public int getKeepAliveIntervalSeconds() { + return keepAliveIntervalSeconds; + } + + public void setKeepAliveIntervalSeconds(int keepAliveIntervalSeconds) { + this.keepAliveIntervalSeconds = keepAliveIntervalSeconds; + } + + public int getConnectionTimeoutMs() { + return connectionTimeoutMs; + } + + public void setConnectionTimeoutMs(int connectionTimeoutMs) { + this.connectionTimeoutMs = connectionTimeoutMs; + } + + public int getRequestTimeoutMs() { + return requestTimeoutMs; + } + + public void setRequestTimeoutMs(int requestTimeoutMs) { + this.requestTimeoutMs = requestTimeoutMs; + } + + public long getInitialBackoffIntervalNanos() { + return initialBackoffIntervalNanos; + } + + public void setInitialBackoffIntervalNanos(long initialBackoffIntervalNanos) { + this.initialBackoffIntervalNanos = initialBackoffIntervalNanos; + } + + public long getMaxBackoffIntervalNanos() { + return maxBackoffIntervalNanos; + } + + public void setMaxBackoffIntervalNanos(long maxBackoffIntervalNanos) { + this.maxBackoffIntervalNanos = maxBackoffIntervalNanos; + } + + public Map buildProperties() { + PulsarProperties.Properties properties = new Properties(); + + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + + map.from(this::getServiceUrl).to(properties.in("serviceUrl")); + map.from(this::getAuthPluginClassName).to(properties.in("authPluginClassName")); + map.from(this::getAuthParams).to(properties.in("authParams")); + map.from(this::getOperationTimeoutMs).to(properties.in("operationTimeoutMs")); + map.from(this::getStatsIntervalSeconds).to(properties.in("statsIntervalSeconds")); + map.from(this::getNumIoThreads).to(properties.in("numIoThreads")); + map.from(this::isUseTcpNoDelay).to(properties.in("useTcpNoDelay")); + map.from(this::isUseTls).to(properties.in("useTls")); + map.from(this::getTlsTrustCertsFilePath).to(properties.in("tlsTrustCertsFilePath")); + map.from(this::isTlsAllowInsecureConnection).to(properties.in("tlsAllowInsecureConnection")); + map.from(this::isTlsHostnameVerificationEnable).to(properties.in("tlsHostnameVerificationEnable")); + map.from(this::getConcurrentLookupRequest).to(properties.in("concurrentLookupRequest")); + map.from(this::getMaxLookupRequest).to(properties.in("maxLookupRequest")); + map.from(this::getMaxNumberOfRejectedRequestPerConnection).to(properties.in("maxNumberOfRejectedRequestPerConnection")); + map.from(this::getKeepAliveIntervalSeconds).to(properties.in("keepAliveIntervalSeconds")); + map.from(this::getConnectionTimeoutMs).to(properties.in("connectionTimeoutMs")); + map.from(this::getRequestTimeoutMs).to(properties.in("requestTimeoutMs")); + map.from(this::getInitialBackoffIntervalNanos).to(properties.in("initialBackoffIntervalNanos")); + map.from(this::getMaxBackoffIntervalNanos).to(properties.in("maxBackoffIntervalNanos")); + + return properties; + } + } + + public static class Listener { + + private PulsarProperties.Schema schema = Schema.STRING; + + public PulsarProperties.Schema getSchema() { + return schema; + } + + public void setSchema(PulsarProperties.Schema schema) { + this.schema = schema; + } + } + + @SuppressWarnings("serial") + private static class Properties extends HashMap { + + java.util.function.Consumer in(String key) { + return (value) -> put(key, value); + } + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerContainerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerContainerFactory.java new file mode 100644 index 00000000..cda37221 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerContainerFactory.java @@ -0,0 +1,161 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.core.log.LogAccessor; +import org.springframework.pulsar.listener.AbstractPulsarMessageListenerContainer; +import org.springframework.pulsar.support.JavaUtils; +import org.springframework.pulsar.support.MessageConverter; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +/** + * @author Soby Chacko + */ +public abstract class AbstractPulsarListenerContainerFactory, T> + implements PulsarListenerContainerFactory, ApplicationEventPublisherAware, InitializingBean, + ApplicationContextAware { + + protected final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass())); + + private final PulsarContainerProperties containerProperties = new PulsarContainerProperties(); + + private PulsarConsumerFactory consumerFactory; + + private Boolean autoStartup; + + private Integer phase; + + private MessageConverter messageConverter; + + private Boolean batchListener; + + private ApplicationEventPublisher applicationEventPublisher; + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + + public void setPulsarConsumerFactory(PulsarConsumerFactory consumerFactory) { + this.consumerFactory = consumerFactory; + } + + public PulsarConsumerFactory getPulsarConsumerFactory() { + return this.consumerFactory; + } + + + public void setAutoStartup(Boolean autoStartup) { + this.autoStartup = autoStartup; + } + + + public void setPhase(int phase) { + this.phase = phase; + } + + /** + * Set the message converter to use if dynamic argument type matching is needed. + * @param messageConverter the converter. + */ + public void setMessageConverter(MessageConverter messageConverter) { + this.messageConverter = messageConverter; + } + + + public Boolean isBatchListener() { + return this.batchListener; + } + + + public void setBatchListener(Boolean batchListener) { + this.batchListener = batchListener; + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + + public PulsarContainerProperties getContainerProperties() { + return this.containerProperties; + } + + @Override + public void afterPropertiesSet() { + + } + + @SuppressWarnings("unchecked") + @Override + public C createListenerContainer(PulsarListenerEndpoint endpoint) { + C instance = createContainerInstance(endpoint); + JavaUtils.INSTANCE + .acceptIfNotNull(endpoint.getSubscriptionName(), instance::setBeanName); + if (endpoint instanceof AbstractPulsarListenerEndpoint) { + configureEndpoint((AbstractPulsarListenerEndpoint) endpoint); + } + + endpoint.setupListenerContainer(instance, this.messageConverter); + initializeContainer(instance, endpoint); + //customizeContainer(instance); + return instance; + } + + protected abstract C createContainerInstance(PulsarListenerEndpoint endpoint); + + private void configureEndpoint(AbstractPulsarListenerEndpoint aplEndpoint) { + + if (aplEndpoint.getBatchListener() == null) { + JavaUtils.INSTANCE + .acceptIfNotNull(this.batchListener, aplEndpoint::setBatchListener); + } + } + + protected void initializeContainer(C instance, PulsarListenerEndpoint endpoint) { + PulsarContainerProperties properties = instance.getPulsarContainerProperties(); + BeanUtils.copyProperties(this.containerProperties, properties, "topics", "messageListener", "batchReceive", "subscriptionName"); + + Boolean autoStart = endpoint.getAutoStartup(); + if (autoStart != null) { + instance.setAutoStartup(autoStart); + } + else if (this.autoStartup != null) { + instance.setAutoStartup(this.autoStartup); + } + + JavaUtils.INSTANCE + .acceptIfNotNull(this.phase, instance::setPhase) + .acceptIfNotNull(this.applicationContext, instance::setApplicationContext) + .acceptIfNotNull(this.applicationEventPublisher, instance::setApplicationEventPublisher); + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerEndpoint.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerEndpoint.java new file mode 100644 index 00000000..2b4af536 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/AbstractPulsarListenerEndpoint.java @@ -0,0 +1,198 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Properties; + +import org.apache.commons.logging.LogFactory; +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.core.log.LogAccessor; +import org.springframework.expression.BeanResolver; +import org.springframework.lang.Nullable; +import org.springframework.pulsar.support.MessageConverter; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; +import org.springframework.pulsar.listener.adapter.PulsarMessagingMessageListenerAdapter; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public abstract class AbstractPulsarListenerEndpoint implements PulsarListenerEndpoint, BeanFactoryAware, InitializingBean { + + private final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass())); + + private String subscriptionName; + + private SubscriptionType subscriptionType; + + private String id; + + private final Collection topics = new ArrayList<>(); + + private BeanFactory beanFactory; + + private BeanExpressionResolver resolver; + + private BeanExpressionContext expressionContext; + + private BeanResolver beanResolver; + + private Boolean autoStartup; + private Properties consumerProperties; + private Boolean batchListener; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + if (beanFactory instanceof ConfigurableListableBeanFactory) { + this.resolver = ((ConfigurableListableBeanFactory) beanFactory).getBeanExpressionResolver(); + this.expressionContext = new BeanExpressionContext((ConfigurableListableBeanFactory) beanFactory, null); + } + this.beanResolver = new BeanFactoryResolver(beanFactory); + } + + @Nullable + protected BeanFactory getBeanFactory() { + return this.beanFactory; + } + + @Override + public void afterPropertiesSet() { + boolean topicsEmpty = getTopics().isEmpty(); + if (!topicsEmpty) { + throw new IllegalStateException("Topics or topicPartitions must be provided but not both for " + this); + } + } + + @Nullable + protected BeanExpressionResolver getResolver() { + return this.resolver; + } + + @Nullable + protected BeanExpressionContext getBeanExpressionContext() { + return this.expressionContext; + } + + @Nullable + protected BeanResolver getBeanResolver() { + return this.beanResolver; + } + + public void setSubscriptionName(String subscriptionName) { + + this.subscriptionName = subscriptionName; + } + + @Nullable + @Override + public String getSubscriptionName() { + return this.subscriptionName; + } + + public void setId(String id) { + this.id = id; + } + + @Override + public String getId() { + return this.id; + } + + public void setTopics(String... topics) { + Assert.notNull(topics, "'topics' must not be null"); + this.topics.clear(); + this.topics.addAll(Arrays.asList(topics)); + } + + @Override + public Collection getTopics() { + return Collections.unmodifiableCollection(this.topics); + } + + @Override + @Nullable + public Boolean getAutoStartup() { + return this.autoStartup; + } + + public void setAutoStartup(Boolean autoStartup) { + this.autoStartup = autoStartup; + } + + @Override + public void setupListenerContainer(PulsarMessageListenerContainer listenerContainer, + @Nullable MessageConverter messageConverter) { + + setupMessageListener(listenerContainer, messageConverter); + } + + @SuppressWarnings("unchecked") + private void setupMessageListener(PulsarMessageListenerContainer container, + @Nullable MessageConverter messageConverter) { + + PulsarMessagingMessageListenerAdapter adapter = createMessageListener(container, messageConverter); + Object messageListener = adapter; + boolean isBatchListener = isBatchListener(); + Assert.state(messageListener != null, + () -> "Endpoint [" + this + "] must provide a non null message listener"); + container.setupMessageListener(messageListener); + } + + protected abstract PulsarMessagingMessageListenerAdapter createMessageListener(PulsarMessageListenerContainer container, + @Nullable MessageConverter messageConverter); + + public void setConsumerProperties(Properties consumerProperties) { + this.consumerProperties = consumerProperties; + } + + @Nullable + public Boolean getBatchListener() { + return this.batchListener; + } + + + public void setBatchListener(boolean batchListener) { + this.batchListener = batchListener; + } + + public boolean isBatchListener() { + return this.batchListener == null ? false : this.batchListener; + } + + + public SubscriptionType getSubscriptionType() { + return subscriptionType; + } + + public void setSubscriptionType(SubscriptionType subscriptionType) { + this.subscriptionType = subscriptionType; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/MethodPulsarListenerEndpoint.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/MethodPulsarListenerEndpoint.java new file mode 100644 index 00000000..a6a60c9f --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/MethodPulsarListenerEndpoint.java @@ -0,0 +1,134 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.lang.reflect.Method; + +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogAccessor; +import org.springframework.expression.BeanResolver; +import org.springframework.lang.Nullable; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.pulsar.listener.adapter.HandlerAdapter; +import org.springframework.pulsar.support.MessageConverter; +import org.springframework.pulsar.support.converter.PulsarBatchMessageConverter; +import org.springframework.pulsar.listener.adapter.PulsarBatchMessagingMessageListenerAdapter; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; +import org.springframework.pulsar.listener.adapter.PulsarMessagingMessageListenerAdapter; +import org.springframework.pulsar.support.converter.PulsarRecordMessageConverter; +import org.springframework.pulsar.listener.adapter.PulsarRecordMessagingMessageListenerAdapter; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class MethodPulsarListenerEndpoint extends AbstractPulsarListenerEndpoint { + + + private final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass())); + + private Object bean; + + private Method method; + private MessageHandlerMethodFactory messageHandlerMethodFactory; + + private SmartMessageConverter messagingConverter; + + public void setBean(Object bean) { + this.bean = bean; + } + + public Object getBean() { + return this.bean; + } + + /** + * Set the method to invoke to process a message managed by this endpoint. + * + * @param method the target method for the {@link #bean}. + */ + public void setMethod(Method method) { + this.method = method; + } + + public Method getMethod() { + return this.method; + } + + public void setMessageHandlerMethodFactory(MessageHandlerMethodFactory messageHandlerMethodFactory) { + this.messageHandlerMethodFactory = messageHandlerMethodFactory; + } + + @Override + protected PulsarMessagingMessageListenerAdapter createMessageListener(PulsarMessageListenerContainer container, + @Nullable MessageConverter messageConverter) { + + Assert.state(this.messageHandlerMethodFactory != null, + "Could not create message listener - MessageHandlerMethodFactory not set"); + PulsarMessagingMessageListenerAdapter messageListener = createMessageListenerInstance(messageConverter); + messageListener.setHandlerMethod(configureListenerAdapter(messageListener)); + + return messageListener; + + } + + protected HandlerAdapter configureListenerAdapter(PulsarMessagingMessageListenerAdapter messageListener) { + InvocableHandlerMethod invocableHandlerMethod = + this.messageHandlerMethodFactory.createInvocableHandlerMethod(getBean(), getMethod()); + return new HandlerAdapter(invocableHandlerMethod); + } + + protected PulsarMessagingMessageListenerAdapter createMessageListenerInstance( + @Nullable MessageConverter messageConverter) { + + PulsarMessagingMessageListenerAdapter listener; + if (isBatchListener()) { + PulsarBatchMessagingMessageListenerAdapter messageListener = new PulsarBatchMessagingMessageListenerAdapter( + this.bean, this.method); + if (messageConverter instanceof PulsarBatchMessageConverter) { + messageListener.setBatchMessageConverter((PulsarBatchMessageConverter) messageConverter); + } + listener = messageListener; + } + else { + PulsarRecordMessagingMessageListenerAdapter messageListener = new PulsarRecordMessagingMessageListenerAdapter( + this.bean, this.method); + if (messageConverter instanceof PulsarRecordMessageConverter) { + messageListener.setMessageConverter((PulsarRecordMessageConverter) messageConverter); + } + listener = messageListener; + } + if (this.messagingConverter != null) { + listener.setMessagingConverter(this.messagingConverter); + } + BeanResolver resolver = getBeanResolver(); + if (resolver != null) { + listener.setBeanResolver(resolver); + } + return listener; + } + + public void setMessagingConverter(SmartMessageConverter messagingConverter) { + this.messagingConverter = messagingConverter; + } + + + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientConfiguration.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientConfiguration.java new file mode 100644 index 00000000..4251e29c --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class PulsarClientConfiguration { + + private final Map configs = new HashMap<>(); + + public PulsarClientConfiguration() { + this(Map.of("serviceUrl", "pulsar://localhost:6650")); + } + + public PulsarClientConfiguration(Map configs) { + Assert.notNull(configs, "Configuration map cannot be null"); + this.configs.putAll(configs); + this.configs.putIfAbsent("serviceUrl", "pulsar://localhost:6650"); + } + + public Map getConfigs() { + return configs; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientFactoryBean.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientFactoryBean.java new file mode 100644 index 00000000..20006f6c --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarClientFactoryBean.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import org.apache.pulsar.client.api.PulsarClient; + +import org.springframework.beans.factory.config.AbstractFactoryBean; +import org.springframework.lang.Nullable; +import org.springframework.pulsar.config.PulsarClientConfiguration; + +/** + * @author Soby Chacko + */ +public class PulsarClientFactoryBean extends AbstractFactoryBean { + + private final PulsarClientConfiguration pulsarClientConfiguration; + + public PulsarClientFactoryBean(PulsarClientConfiguration pulsarClientConfiguration) { + this.pulsarClientConfiguration = pulsarClientConfiguration; + } + + @Override + public Class getObjectType() { + return PulsarClient.class; + } + + @Override + protected PulsarClient createInstance() throws Exception { + return PulsarClient.builder() + .loadConf(this.pulsarClientConfiguration.getConfigs()) + .build(); + } + + @Override + protected void destroyInstance(PulsarClient instance) throws Exception { + if (instance != null) { + System.out.printf("CLOSING THE CLIENT"); + instance.close(); + } + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerConfigUtils.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerConfigUtils.java new file mode 100644 index 00000000..3acaec4f --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerConfigUtils.java @@ -0,0 +1,43 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +/** + * @author Soby Chacko + */ +public abstract class PulsarListenerConfigUtils { + + /** + * The bean name of the internally managed Kafka listener annotation processor. + */ + public static final String PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME = + "org.springframework.pulsar.config.internalKafkaListenerAnnotationProcessor"; + + /** + * The bean name of the internally managed Kafka listener endpoint registry. + */ + public static final String PULSAR_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME = + "org.springframework.pulsar.config.internalKafkaListenerEndpointRegistry"; + + /** + * The bean name of the internally managed Kafka consumer back off manager. + */ + public static final String PULSAR_CONSUMER_BACK_OFF_MANAGER_BEAN_NAME = + "org.springframework.pulsar.config.internalKafkaConsumerBackOffManager"; + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactory.java new file mode 100644 index 00000000..a6b594e0 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactory.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; + +/** + * @author Soby Chacko + */ +public interface PulsarListenerContainerFactory { + + C createListenerContainer(PulsarListenerEndpoint endpoint); + + C createContainer(String... topics); + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactoryImpl.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactoryImpl.java new file mode 100644 index 00000000..788af3d8 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerContainerFactoryImpl.java @@ -0,0 +1,82 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.Arrays; +import java.util.Collection; + +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.pulsar.listener.DefaultPulsarMessageListenerContainer; +import org.springframework.pulsar.listener.PulsarContainerProperties; +import org.springframework.util.StringUtils; + +/** + * @author Soby Chacko + */ +public class PulsarListenerContainerFactoryImpl extends AbstractPulsarListenerContainerFactory, T> { + + @Override + protected DefaultPulsarMessageListenerContainer createContainerInstance(PulsarListenerEndpoint endpoint) { + + PulsarContainerProperties properties = new PulsarContainerProperties(); + Collection topics = endpoint.getTopics(); + + if (!topics.isEmpty()) { + final String[] topics1 = topics.toArray(new String[0]); + properties.setTopics(topics1); + } + + final String subscriptionName = endpoint.getSubscriptionName(); + + if (StringUtils.hasText(subscriptionName)) { + properties.setSubscriptionName(endpoint.getSubscriptionName()); + } + if (endpoint.isBatchListener()) { + properties.setBatchReceive(endpoint.isBatchListener()); + } + final SubscriptionType subscriptionType = endpoint.getSubscriptionType(); + if (subscriptionType != null) { + properties.setSubscriptionType(subscriptionType); + } + + return new DefaultPulsarMessageListenerContainer(getPulsarConsumerFactory(), properties); + } + + @Override + protected void initializeContainer(DefaultPulsarMessageListenerContainer instance, + PulsarListenerEndpoint endpoint) { + + super.initializeContainer(instance, endpoint); + } + + @Override + public DefaultPulsarMessageListenerContainer createContainer(String... topics) { + PulsarListenerEndpoint endpoint = new PulsarListenerEndpointAdapter() { + + @Override + public Collection getTopics() { + return Arrays.asList(topics); + } + + }; + DefaultPulsarMessageListenerContainer container = createContainerInstance(endpoint); + initializeContainer(container, endpoint); + //customizeContainer(container); + return container; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpoint.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpoint.java new file mode 100644 index 00000000..3468c585 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpoint.java @@ -0,0 +1,50 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.Collection; + +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.lang.Nullable; +import org.springframework.pulsar.support.MessageConverter; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; + +/** + * @author Soby Chacko + */ +public interface PulsarListenerEndpoint { + + @Nullable + String getId(); + + @Nullable + String getSubscriptionName(); + + @Nullable + SubscriptionType getSubscriptionType(); + + Collection getTopics(); + + @Nullable + Boolean getAutoStartup(); + + void setupListenerContainer(PulsarMessageListenerContainer listenerContainer, + @Nullable MessageConverter messageConverter); + + boolean isBatchListener(); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointAdapter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointAdapter.java new file mode 100644 index 00000000..fac0ace1 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointAdapter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.Collection; +import java.util.Collections; + +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.pulsar.support.MessageConverter; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; + +/** + * @author Soby Chacko + */ +public class PulsarListenerEndpointAdapter implements PulsarListenerEndpoint { + + @Override + public String getId() { + return null; + } + + @Override + public String getSubscriptionName() { + return null; + } + + @Override + public SubscriptionType getSubscriptionType() { + return SubscriptionType.Exclusive; + } + + @Override + public Collection getTopics() { + return Collections.emptyList(); + } + + @Override + public Boolean getAutoStartup() { + return null; + } + + @Override + public void setupListenerContainer(PulsarMessageListenerContainer listenerContainer, MessageConverter messageConverter) { + + } + + @Override + public boolean isBatchListener() { + return false; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistrar.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistrar.java new file mode 100644 index 00000000..abee3b9e --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistrar.java @@ -0,0 +1,175 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.lang.Nullable; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.validation.Validator; + +/** + * @author Soby Chacko + */ +public class PulsarListenerEndpointRegistrar implements BeanFactoryAware, InitializingBean { + + private final List endpointDescriptors = new ArrayList<>(); + private PulsarListenerEndpointRegistry endpointRegistry; + + + private List customMethodArgumentResolvers = new ArrayList<>(); + + private Validator validator; + + private MessageHandlerMethodFactory messageHandlerMethodFactory; + private PulsarListenerContainerFactory containerFactory; + private String containerFactoryBeanName; + private BeanFactory beanFactory; + + + private boolean startImmediately; + + + public void setEndpointRegistry(PulsarListenerEndpointRegistry endpointRegistry) { + this.endpointRegistry = endpointRegistry; + } + + @Nullable + public PulsarListenerEndpointRegistry getEndpointRegistry() { + return this.endpointRegistry; + } + + public List getCustomMethodArgumentResolvers() { + return Collections.unmodifiableList(this.customMethodArgumentResolvers); + } + + public void setCustomMethodArgumentResolvers(HandlerMethodArgumentResolver... methodArgumentResolvers) { + this.customMethodArgumentResolvers = Arrays.asList(methodArgumentResolvers); + } + + public void setMessageHandlerMethodFactory(MessageHandlerMethodFactory PulsarHandlerMethodFactory) { + Assert.isNull(this.validator, + "A validator cannot be provided with a custom message handler factory"); + this.messageHandlerMethodFactory = PulsarHandlerMethodFactory; + } + + @Nullable + public MessageHandlerMethodFactory getMessageHandlerMethodFactory() { + return this.messageHandlerMethodFactory; + } + + + public void setContainerFactory(PulsarListenerContainerFactory containerFactory) { + this.containerFactory = containerFactory; + } + + public void setContainerFactoryBeanName(String containerFactoryBeanName) { + this.containerFactoryBeanName = containerFactoryBeanName; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Nullable + public Validator getValidator() { + return this.validator; + } + + public void setValidator(Validator validator) { + Assert.isNull(this.messageHandlerMethodFactory, + "A validator cannot be provided with a custom message handler factory"); + this.validator = validator; + } + + @Override + public void afterPropertiesSet() { + registerAllEndpoints(); + } + + protected void registerAllEndpoints() { + synchronized (this.endpointDescriptors) { + for (PulsarListenerEndpointDescriptor descriptor : this.endpointDescriptors) { + this.endpointRegistry.registerListenerContainer( + descriptor.endpoint, resolveContainerFactory(descriptor)); + } + this.startImmediately = true; // trigger immediate startup + } + } + + + private PulsarListenerContainerFactory resolveContainerFactory(PulsarListenerEndpointDescriptor descriptor) { + if (descriptor.containerFactory != null) { + return descriptor.containerFactory; + } + else if (this.containerFactory != null) { + return this.containerFactory; + } + else if (this.containerFactoryBeanName != null) { + Assert.state(this.beanFactory != null, "BeanFactory must be set to obtain container factory by bean name"); + this.containerFactory = this.beanFactory.getBean( + this.containerFactoryBeanName, PulsarListenerContainerFactory.class); + return this.containerFactory; // Consider changing this if live change of the factory is required + } + else { + throw new IllegalStateException("Could not resolve the " + + PulsarListenerContainerFactory.class.getSimpleName() + " to use for [" + + descriptor.endpoint + "] no factory was given and no default is set."); + } + } + + public void registerEndpoint(PulsarListenerEndpoint endpoint, @Nullable PulsarListenerContainerFactory factory) { + Assert.notNull(endpoint, "Endpoint must be set"); + Assert.hasText(endpoint.getSubscriptionName(), "Endpoint id must be set"); + // Factory may be null, we defer the resolution right before actually creating the container + PulsarListenerEndpointDescriptor descriptor = new PulsarListenerEndpointDescriptor(endpoint, factory); + synchronized (this.endpointDescriptors) { + if (this.startImmediately) { // Register and start immediately + this.endpointRegistry.registerListenerContainer(descriptor.endpoint, + resolveContainerFactory(descriptor), true); + } + else { + this.endpointDescriptors.add(descriptor); + } + } + } + + + private static final class PulsarListenerEndpointDescriptor { + + private final PulsarListenerEndpoint endpoint; + + private final PulsarListenerContainerFactory containerFactory; + + private PulsarListenerEndpointDescriptor(PulsarListenerEndpoint endpoint, + @Nullable PulsarListenerContainerFactory containerFactory) { + + this.endpoint = endpoint; + this.containerFactory = containerFactory; + } + + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistry.java b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistry.java new file mode 100644 index 00000000..844bd3eb --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/config/PulsarListenerEndpointRegistry.java @@ -0,0 +1,252 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.config; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.lang.Nullable; +import org.springframework.pulsar.listener.AbstractPulsarMessageListenerContainer; +import org.springframework.pulsar.listener.PulsarListenerContainerRegistry; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; +import org.springframework.pulsar.support.EndpointHandlerMethod; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class PulsarListenerEndpointRegistry implements PulsarListenerContainerRegistry, DisposableBean, SmartLifecycle, + ApplicationContextAware, ApplicationListener { + + private final Map listenerContainers = new ConcurrentHashMap<>(); + + private ConfigurableApplicationContext applicationContext; + + private int phase = AbstractPulsarMessageListenerContainer.DEFAULT_PHASE; + + private boolean contextRefreshed; + + + private volatile boolean running; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + if (applicationContext instanceof ConfigurableApplicationContext) { + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + } + } + + @Override + @Nullable + public PulsarMessageListenerContainer getListenerContainer(String id) { + Assert.hasText(id, "Container identifier must not be empty"); + return this.listenerContainers.get(id); + } + + @Override + public Set getListenerContainerIds() { + return Collections.unmodifiableSet(this.listenerContainers.keySet()); + } + + @Override + public Collection getListenerContainers() { + return Collections.unmodifiableCollection(this.listenerContainers.values()); + } + + @Override + public Collection getAllListenerContainers() { + List containers = new ArrayList<>(); + containers.addAll(getListenerContainers()); + containers.addAll(this.applicationContext.getBeansOfType(PulsarMessageListenerContainer.class, true, false).values()); + return containers; + } + + public void registerListenerContainer(PulsarListenerEndpoint endpoint, PulsarListenerContainerFactory factory) { + registerListenerContainer(endpoint, factory, false); + } + + public void registerListenerContainer(PulsarListenerEndpoint endpoint, PulsarListenerContainerFactory factory, + boolean startImmediately) { + Assert.notNull(endpoint, "Endpoint must not be null"); + Assert.notNull(factory, "Factory must not be null"); + + String subscriptionName = endpoint.getSubscriptionName(); + String id = endpoint.getId(); + + Assert.hasText(subscriptionName, "Endpoint id must not be empty"); + + synchronized (this.listenerContainers) { + Assert.state(!this.listenerContainers.containsKey(id), + "Another endpoint is already registered with id '" + subscriptionName + "'"); + PulsarMessageListenerContainer container = createListenerContainer(endpoint, factory); + this.listenerContainers.put(id, container); + ConfigurableApplicationContext appContext = this.applicationContext; + } + } + + protected PulsarMessageListenerContainer createListenerContainer(PulsarListenerEndpoint endpoint, + PulsarListenerContainerFactory factory) { + + if (endpoint instanceof MethodPulsarListenerEndpoint) { + MethodPulsarListenerEndpoint mkle = (MethodPulsarListenerEndpoint) endpoint; + Object bean = mkle.getBean(); + if (bean instanceof EndpointHandlerMethod) { + EndpointHandlerMethod ehm = (EndpointHandlerMethod) bean; + ehm = new EndpointHandlerMethod(ehm.resolveBean(this.applicationContext), ehm.getMethodName()); + mkle.setBean(ehm.resolveBean(this.applicationContext)); + mkle.setMethod(ehm.getMethod()); + } + } + PulsarMessageListenerContainer listenerContainer = factory.createListenerContainer(endpoint); + + if (listenerContainer instanceof InitializingBean) { + try { + ((InitializingBean) listenerContainer).afterPropertiesSet(); + } + catch (Exception ex) { + throw new BeanInitializationException("Failed to initialize message listener container", ex); + } + } + + int containerPhase = listenerContainer.getPhase(); + if (listenerContainer.isAutoStartup() && + containerPhase != AbstractPulsarMessageListenerContainer.DEFAULT_PHASE) { // a custom phase value + if (this.phase != AbstractPulsarMessageListenerContainer.DEFAULT_PHASE && this.phase != containerPhase) { + throw new IllegalStateException("Encountered phase mismatch between container " + + "factory definitions: " + this.phase + " vs " + containerPhase); + } + this.phase = listenerContainer.getPhase(); + } + + return listenerContainer; + } + + @Override + public void destroy() { + for (PulsarMessageListenerContainer listenerContainer : getListenerContainers()) { + listenerContainer.destroy(); + } + } + + + // Delegating implementation of SmartLifecycle + + @Override + public int getPhase() { + return this.phase; + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void start() { + for (PulsarMessageListenerContainer listenerContainer : getListenerContainers()) { + startIfNecessary(listenerContainer); + } + this.running = true; + } + + @Override + public void stop() { + this.running = false; + for (PulsarMessageListenerContainer listenerContainer : getListenerContainers()) { + listenerContainer.stop(); + } + } + + @Override + public void stop(Runnable callback) { + this.running = false; + Collection listenerContainersToStop = getListenerContainers(); + if (listenerContainersToStop.size() > 0) { + AggregatingCallback aggregatingCallback = new AggregatingCallback(listenerContainersToStop.size(), + callback); + for (PulsarMessageListenerContainer listenerContainer : listenerContainersToStop) { + if (listenerContainer.isRunning()) { + listenerContainer.stop(aggregatingCallback); + } + else { + aggregatingCallback.run(); + } + } + } + else { + callback.run(); + } + } + + @Override + public boolean isRunning() { + return this.running; + } + + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (event.getApplicationContext().equals(this.applicationContext)) { + this.contextRefreshed = true; + } + } + + + private void startIfNecessary(PulsarMessageListenerContainer listenerContainer) { + if (this.contextRefreshed || listenerContainer.isAutoStartup()) { + listenerContainer.start(); + } + } + + private static final class AggregatingCallback implements Runnable { + + private final AtomicInteger count; + + private final Runnable finishCallback; + + private AggregatingCallback(int count, Runnable finishCallback) { + this.count = new AtomicInteger(count); + this.finishCallback = finishCallback; + } + + @Override + public void run() { + if (this.count.decrementAndGet() <= 0) { + this.finishCallback.run(); + } + } + + } + + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarConsumerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarConsumerFactory.java new file mode 100644 index 00000000..d65727ab --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarConsumerFactory.java @@ -0,0 +1,88 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pulsar.client.api.BatchReceivePolicy; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; + +import org.springframework.util.CollectionUtils; + +/** + * @author Soby Chacko + */ +public class DefaultPulsarConsumerFactory implements PulsarConsumerFactory { + + private final Map consumerConfig = new HashMap<>(); + + private final List> consumers = new ArrayList<>(); + + private PulsarClient pulsarClient; + + public DefaultPulsarConsumerFactory(PulsarClient pulsarClient, Map consumerConfig) { + this.pulsarClient = pulsarClient; + if (!CollectionUtils.isEmpty(consumerConfig)) { + this.consumerConfig.putAll(consumerConfig); + } + } + + @Override + public Consumer createConsumer(Schema schema, Map propertiesToOverride) throws PulsarClientException { + + final ConsumerBuilder consumerBuilder = this.pulsarClient.newConsumer(schema); + + final Map properties = new HashMap<>(this.consumerConfig); + properties.putAll(propertiesToOverride); + + if (!CollectionUtils.isEmpty(properties)) { + consumerBuilder.loadConf(properties); + } + Consumer consumer = consumerBuilder.subscribe(); + consumers.add(consumer); + return consumer; + } + + @Override + public Consumer createConsumer(Schema schema, BatchReceivePolicy batchReceivePolicy, Map propertiesToOverride) throws PulsarClientException { + + final ConsumerBuilder consumerBuilder = this.pulsarClient.newConsumer(schema); + final Map properties = new HashMap<>(this.consumerConfig); + properties.putAll(propertiesToOverride); + + if (!CollectionUtils.isEmpty(properties)) { + consumerBuilder.loadConf(properties); + } + + consumerBuilder.batchReceivePolicy(batchReceivePolicy); + Consumer consumer = consumerBuilder.subscribe(); + consumers.add(consumer); + return consumer; + } + + public Map getConsumerConfig() { + return consumerConfig; + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarProducerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarProducerFactory.java new file mode 100644 index 00000000..218e9291 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/DefaultPulsarProducerFactory.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.logging.LogFactory; +import org.apache.pulsar.client.api.MessageRouter; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.core.log.LogAccessor; +import org.springframework.util.CollectionUtils; + +/** + * @author Soby Chacko + */ +public class DefaultPulsarProducerFactory implements PulsarProducerFactory, DisposableBean { + + protected final LogAccessor logger = new LogAccessor(LogFactory.getLog(this.getClass())); + + private final Map producerConfig = new HashMap<>(); + + private Producer producer; + + private final PulsarClient pulsarClient; + + public DefaultPulsarProducerFactory(PulsarClient pulsarClient, Map config) { + this.pulsarClient = pulsarClient; + if (!CollectionUtils.isEmpty(config)) { + this.producerConfig.putAll(config); + } + } + + @Override + public Producer createProducer(Schema schema) throws PulsarClientException { + + final ProducerBuilder producerBuilder = this.pulsarClient.newProducer(schema); + + if (!CollectionUtils.isEmpty(this.producerConfig)) { + producerBuilder.loadConf(this.producerConfig); + } + this.producer = producerBuilder.create(); + return producer; + } + + @Override + public Producer createProducer(Schema schema, MessageRouter messageRouter) throws PulsarClientException { + + final ProducerBuilder producerBuilder = this.pulsarClient.newProducer(schema); + + if (!CollectionUtils.isEmpty(this.producerConfig)) { + producerBuilder.loadConf(this.producerConfig); + } + producerBuilder.messageRouter(messageRouter); + this.producer = producerBuilder.create(); + return producer; + } + + @Override + public Map getProducerConfig() { + return producerConfig; + } + + @Override + public void destroy() throws Exception { + this.logger.info("Closing the producer"); + this.producer.close(); + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarClientProperties.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarClientProperties.java new file mode 100644 index 00000000..3d0024fb --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarClientProperties.java @@ -0,0 +1,245 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.net.SocketAddress; + +/** + * @author Soby Chacko + */ +public class PulsarClientProperties { + + private String serviceUrl; + + private String authPluginClassName; + + private String authParams; + + private long operationTimeoutMs; + + private long statsIntervalSeconds; + + private int numIoThreads; + + private boolean useTcpNoDelay; + + private boolean useTls; + + private String tlsTrustCertsFilePath; + + private boolean tlsAllowInsecureConnection; + + private boolean tlsHostnameVerificationEnable; + + private int concurrentLookupRequest; + + private int maxLookupRequest; + + private int maxNumberOfRejectedRequestPerConnection; + + private int keepAliveIntervalSeconds; + + private int connectionTimeoutMs; + + private int requestTimeoutMs; + + private int defaultBackoffIntervalNanos; + + private long maxBackoffIntervalNanos; + + private SocketAddress socks5ProxyAddress; + + private String socks5ProxyUsername; + + private String socks5ProxyPassword; + + public String getServiceUrl() { + return serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public String getAuthPluginClassName() { + return authPluginClassName; + } + + public void setAuthPluginClassName(String authPluginClassName) { + this.authPluginClassName = authPluginClassName; + } + + public String getAuthParams() { + return authParams; + } + + public void setAuthParams(String authParams) { + this.authParams = authParams; + } + + public long getOperationTimeoutMs() { + return operationTimeoutMs; + } + + public void setOperationTimeoutMs(long operationTimeoutMs) { + this.operationTimeoutMs = operationTimeoutMs; + } + + public long getStatsIntervalSeconds() { + return statsIntervalSeconds; + } + + public void setStatsIntervalSeconds(long statsIntervalSeconds) { + this.statsIntervalSeconds = statsIntervalSeconds; + } + + public int getNumIoThreads() { + return numIoThreads; + } + + public void setNumIoThreads(int numIoThreads) { + this.numIoThreads = numIoThreads; + } + + public boolean isUseTcpNoDelay() { + return useTcpNoDelay; + } + + public void setUseTcpNoDelay(boolean useTcpNoDelay) { + this.useTcpNoDelay = useTcpNoDelay; + } + + public boolean isUseTls() { + return useTls; + } + + public void setUseTls(boolean useTls) { + this.useTls = useTls; + } + + public String getTlsTrustCertsFilePath() { + return tlsTrustCertsFilePath; + } + + public void setTlsTrustCertsFilePath(String tlsTrustCertsFilePath) { + this.tlsTrustCertsFilePath = tlsTrustCertsFilePath; + } + + public boolean isTlsAllowInsecureConnection() { + return tlsAllowInsecureConnection; + } + + public void setTlsAllowInsecureConnection(boolean tlsAllowInsecureConnection) { + this.tlsAllowInsecureConnection = tlsAllowInsecureConnection; + } + + public boolean isTlsHostnameVerificationEnable() { + return tlsHostnameVerificationEnable; + } + + public void setTlsHostnameVerificationEnable(boolean tlsHostnameVerificationEnable) { + this.tlsHostnameVerificationEnable = tlsHostnameVerificationEnable; + } + + public int getConcurrentLookupRequest() { + return concurrentLookupRequest; + } + + public void setConcurrentLookupRequest(int concurrentLookupRequest) { + this.concurrentLookupRequest = concurrentLookupRequest; + } + + public int getMaxLookupRequest() { + return maxLookupRequest; + } + + public void setMaxLookupRequest(int maxLookupRequest) { + this.maxLookupRequest = maxLookupRequest; + } + + public int getMaxNumberOfRejectedRequestPerConnection() { + return maxNumberOfRejectedRequestPerConnection; + } + + public void setMaxNumberOfRejectedRequestPerConnection(int maxNumberOfRejectedRequestPerConnection) { + this.maxNumberOfRejectedRequestPerConnection = maxNumberOfRejectedRequestPerConnection; + } + + public int getKeepAliveIntervalSeconds() { + return keepAliveIntervalSeconds; + } + + public void setKeepAliveIntervalSeconds(int keepAliveIntervalSeconds) { + this.keepAliveIntervalSeconds = keepAliveIntervalSeconds; + } + + public int getConnectionTimeoutMs() { + return connectionTimeoutMs; + } + + public void setConnectionTimeoutMs(int connectionTimeoutMs) { + this.connectionTimeoutMs = connectionTimeoutMs; + } + + public int getRequestTimeoutMs() { + return requestTimeoutMs; + } + + public void setRequestTimeoutMs(int requestTimeoutMs) { + this.requestTimeoutMs = requestTimeoutMs; + } + + public int getDefaultBackoffIntervalNanos() { + return defaultBackoffIntervalNanos; + } + + public void setDefaultBackoffIntervalNanos(int defaultBackoffIntervalNanos) { + this.defaultBackoffIntervalNanos = defaultBackoffIntervalNanos; + } + + public long getMaxBackoffIntervalNanos() { + return maxBackoffIntervalNanos; + } + + public void setMaxBackoffIntervalNanos(long maxBackoffIntervalNanos) { + this.maxBackoffIntervalNanos = maxBackoffIntervalNanos; + } + + public SocketAddress getSocks5ProxyAddress() { + return socks5ProxyAddress; + } + + public void setSocks5ProxyAddress(SocketAddress socks5ProxyAddress) { + this.socks5ProxyAddress = socks5ProxyAddress; + } + + public String getSocks5ProxyUsername() { + return socks5ProxyUsername; + } + + public void setSocks5ProxyUsername(String socks5ProxyUsername) { + this.socks5ProxyUsername = socks5ProxyUsername; + } + + public String getSocks5ProxyPassword() { + return socks5ProxyPassword; + } + + public void setSocks5ProxyPassword(String socks5ProxyPassword) { + this.socks5ProxyPassword = socks5ProxyPassword; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarConsumerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarConsumerFactory.java new file mode 100644 index 00000000..8003e18f --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarConsumerFactory.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.Map; + +import org.apache.pulsar.client.api.BatchReceivePolicy; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; + +/** + * @author Soby Chacko + */ +public interface PulsarConsumerFactory { + + Consumer createConsumer(Schema schema, Map propertiesToOverride) throws PulsarClientException; + + Consumer createConsumer(Schema schema, BatchReceivePolicy batchReceivePolicy, Map propertiesToOverride) throws PulsarClientException; + + Map getConsumerConfig(); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarProducerFactory.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarProducerFactory.java new file mode 100644 index 00000000..fc4730ab --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarProducerFactory.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.Map; + +import org.apache.pulsar.client.api.MessageRouter; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; + +/** + * @author Soby Chacko + */ +public interface PulsarProducerFactory { + + Producer createProducer(Schema schema) throws PulsarClientException; + + Producer createProducer(Schema schema, MessageRouter messageRouter) throws PulsarClientException; + + Map getProducerConfig(); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarTemplate.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarTemplate.java new file mode 100644 index 00000000..e5b3859f --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/PulsarTemplate.java @@ -0,0 +1,118 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.MessageRouter; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; + +/** + * @author Soby Chacko + */ +public class PulsarTemplate { + + private final Map> producerCache = new ConcurrentHashMap<>(); + + private final PulsarProducerFactory pulsarProducerFactory; + + private String defaultTopicName; + + public PulsarTemplate(PulsarProducerFactory pulsarProducerFactory) { + this.pulsarProducerFactory = pulsarProducerFactory; + } + + public MessageId send(T message) throws PulsarClientException { + final Schema schema = SchemaUtils.getSchema(message); + final SchemaTopic schemaTopic = getSchemaTopic(schema, this.pulsarProducerFactory); + Producer producer = producerCache.get(schemaTopic); + if (producer == null) { + producer = this.pulsarProducerFactory.createProducer(schema); + producerCache.put(schemaTopic, producer); + } + return producer.send(message); + } + + public CompletableFuture sendAsync(T message) throws PulsarClientException { + final Schema schema = SchemaUtils.getSchema(message); + final SchemaTopic schemaTopic = getSchemaTopic(schema, this.pulsarProducerFactory); + Producer producer = producerCache.get(schemaTopic); + if (producer == null) { + producer = this.pulsarProducerFactory.createProducer(schema); + producerCache.put(schemaTopic, producer); + } + return producer.sendAsync(message); + } + + public CompletableFuture sendAsync(T message, MessageRouter messageRouter) throws PulsarClientException { + final Schema schema = SchemaUtils.getSchema(message); + final SchemaTopic schemaTopic = getSchemaTopic(schema, this.pulsarProducerFactory); + Producer producer = producerCache.get(schemaTopic); + if (producer == null) { + producer = this.pulsarProducerFactory.createProducer(schema, messageRouter); + producerCache.put(schemaTopic, producer); + } + return producer.sendAsync(message); + } + + private SchemaTopic getSchemaTopic(Schema schema, PulsarProducerFactory pulsarProducerFactory) { + return new SchemaTopic(schema, (String) pulsarProducerFactory.getProducerConfig().get("topicName")); + } + + public void setDefaultTopicName(String defaultTopicName) { + this.defaultTopicName = defaultTopicName; + this.pulsarProducerFactory.getProducerConfig().put("topicName", defaultTopicName); + } + + private class SchemaTopic { + + final Schema schema; + final String topicName; + + public SchemaTopic(Schema schema, String topicName) { + this.schema = schema; + this.topicName = topicName; + } + + public Schema getSchema() { + return schema; + } + + public String getTopicName() { + return topicName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SchemaTopic that = (SchemaTopic) o; + return Objects.equals(schema, that.schema) && Objects.equals(topicName, that.topicName); + } + + @Override + public int hashCode() { + return Objects.hash(schema, topicName); + } + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/SchemaUtils.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/SchemaUtils.java new file mode 100644 index 00000000..540f4ed5 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/SchemaUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import org.apache.pulsar.client.api.Schema; + +/** + * @author Soby Chacko + */ +public class SchemaUtils { + + @SuppressWarnings("unchecked") + public static Schema getSchema(T message) { + if (message.getClass() == byte[].class) { + return (Schema) Schema.BYTES; + } + else if (message.getClass() == String.class) { + return (Schema) Schema.STRING; + } + return (Schema) Schema.BYTES; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/Sender.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/Sender.java new file mode 100644 index 00000000..bc0783f1 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/Sender.java @@ -0,0 +1,68 @@ +package org.springframework.pulsar.core; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.config.PulsarClientConfiguration; +import org.springframework.pulsar.config.PulsarClientFactoryBean; + +public class Sender { + + public static void main(String[] args) throws PulsarClientException { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class); + context.getBean(Sender.class).send(); + System.exit(0); + } + + private final PulsarTemplate template; + + public Sender(PulsarTemplate template) { + this.template = template; + } + + public void send() throws PulsarClientException { + this.template.send("foobar-fuzzy".getBytes(StandardCharsets.UTF_8)); + } + +} + + +@Configuration +class Config { + + @Bean + public PulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient) { + Map config = new HashMap<>(); + config.put("topicName", "foo-1"); + return new DefaultPulsarProducerFactory<>(pulsarClient, config); + } + + @Bean + public PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory) { + return new PulsarTemplate<>(pulsarProducerFactory); + } + + @Bean + public Sender sender(PulsarTemplate template) { + return new Sender(template); + } + + @Bean + public PulsarClientFactoryBean pulsarClientFactoryBean(PulsarClientConfiguration pulsarClientConfiguration) { + return new PulsarClientFactoryBean(pulsarClientConfiguration); + } + + @Bean + public PulsarClientConfiguration pulsarClientConfiguration() { + return new PulsarClientConfiguration(); + } + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/core/SenderString.java b/spring-pulsar/src/main/java/org/springframework/pulsar/core/SenderString.java new file mode 100644 index 00000000..7ec21fd1 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/core/SenderString.java @@ -0,0 +1,80 @@ +package org.springframework.pulsar.core; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.config.PulsarClientConfiguration; +import org.springframework.pulsar.config.PulsarClientFactoryBean; + +public class SenderString { + + public static void main(String[] args) throws PulsarClientException { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConfigString.class); + context.getBean(SenderString.class).send(); + System.exit(0); + } + + private final PulsarTemplate template; + + public SenderString(PulsarTemplate template) { + this.template = template; + } + + public void send() throws PulsarClientException { + final CompletableFuture future = this.template.sendAsync("hello john doe"); + future.thenAccept(m -> System.out.println("Got " + m)); + try { + future.get(); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + catch (ExecutionException e) { + e.printStackTrace(); + } + } + +} + + +@Configuration +class ConfigString { + + @Bean + public PulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient) { + Map config = new HashMap<>(); + config.put("topicName", "foo-1"); + return new DefaultPulsarProducerFactory<>(pulsarClient, config); + } + + @Bean + public PulsarClientFactoryBean pulsarClientFactoryBean(PulsarClientConfiguration pulsarClientConfiguration) { + return new PulsarClientFactoryBean(pulsarClientConfiguration); + } + + @Bean + public PulsarClientConfiguration pulsarClientConfiguration() { + return new PulsarClientConfiguration(); + } + + @Bean + public PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory) { + return new PulsarTemplate<>(pulsarProducerFactory); + } + + @Bean + public SenderString senderString(PulsarTemplate template) { + return new SenderString(template); + } + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerFailedToStartEvent.java b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerFailedToStartEvent.java new file mode 100644 index 00000000..a618e150 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerFailedToStartEvent.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.event; + +/** + * @author Soby Chacko + */ +public class ConsumerFailedToStartEvent extends PulsarEvent { + + private static final long serialVersionUID = 1L; + + /** + * Construct an instance with the provided source and container. + * @param source the container instance that generated the event. + * @param container the container or the parent container if the container is a child. + */ + public ConsumerFailedToStartEvent(Object source, Object container) { + super(source, container); + } + + @Override + public String toString() { + return "ConsumerFailedToStartEvent [source=" + getSource() + "]"; + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartedEvent.java b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartedEvent.java new file mode 100644 index 00000000..f6f9c6e0 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartedEvent.java @@ -0,0 +1,41 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.event; + +/** + * @author Soby Chacko + */ +public class ConsumerStartedEvent extends PulsarEvent { + + private static final long serialVersionUID = 1L; + + /** + * Construct an instance with the provided source and container. + * @param source the container instance that generated the event. + * @param container the container or the parent container if the container is a child. + */ + public ConsumerStartedEvent(Object source, Object container) { + super(source, container); + } + + @Override + public String toString() { + return "ConsumerStartedEvent [source=" + getSource() + "]"; + } + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartingEvent.java b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartingEvent.java new file mode 100644 index 00000000..1f4d1e56 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/event/ConsumerStartingEvent.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.event; + +/** + * @author Soby Chacko + */ +public class ConsumerStartingEvent extends PulsarEvent { + + private static final long serialVersionUID = 1L; + + /** + * Construct an instance with the provided source and container. + * @param source the container instance that generated the event. + * @param container the container or the parent container if the container is a child. + */ + public ConsumerStartingEvent(Object source, Object container) { + super(source, container); + } + + @Override + public String toString() { + return "ConsumerStartingEvent [source=" + getSource() + "]"; + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/event/PulsarEvent.java b/spring-pulsar/src/main/java/org/springframework/pulsar/event/PulsarEvent.java new file mode 100644 index 00000000..827b2dcc --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/event/PulsarEvent.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.event; + +import org.springframework.context.ApplicationEvent; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class PulsarEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private final Object container; + + public PulsarEvent(Object source, Object container) { + super(source); + this.container = container; + } + + @SuppressWarnings("unchecked") + public T getContainer(Class type) { + Assert.isInstanceOf(type, this.container); + return (T) this.container; + } + + @SuppressWarnings("unchecked") + public T getSource(Class type) { + Assert.isInstanceOf(type, getSource()); + return (T) getSource(); + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/AbstractPulsarMessageListenerContainer.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/AbstractPulsarMessageListenerContainer.java new file mode 100644 index 00000000..3f9e13fa --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/AbstractPulsarMessageListenerContainer.java @@ -0,0 +1,128 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.core.log.LogAccessor; +import org.springframework.lang.Nullable; +import org.springframework.pulsar.core.PulsarConsumerFactory; + +/** + * @author Soby Chacko + */ +public abstract class AbstractPulsarMessageListenerContainer + implements PulsarMessageListenerContainer, BeanNameAware, ApplicationEventPublisherAware, + ApplicationContextAware { + + protected final LogAccessor logger = new LogAccessor(LogFactory.getLog(this.getClass())); // NOSONAR + + private ApplicationEventPublisher applicationEventPublisher; + private String beanName; + private ApplicationContext applicationContext; + + private final PulsarContainerProperties pulsarContainerProperties; + + private final PulsarConsumerFactory pulsarConsumerFactory; + + private boolean autoStartup = true; + private int phase; + + @SuppressWarnings("unchecked") + protected AbstractPulsarMessageListenerContainer(PulsarConsumerFactory pulsarConsumerFactory,PulsarContainerProperties pulsarContainerProperties) { + this.pulsarContainerProperties = pulsarContainerProperties; + this.pulsarConsumerFactory = (PulsarConsumerFactory)pulsarConsumerFactory; + + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + /** + * Get the event publisher. + * @return the publisher + */ + @Nullable + public ApplicationEventPublisher getApplicationEventPublisher() { + return this.applicationEventPublisher; + } + + @Override + public void setBeanName(String name) { + this.beanName = name; + } + + /** + * Return the bean name. + * @return the bean name. + */ + @Nullable + public String getBeanName() { + return this.beanName; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Nullable + protected ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + @Override + public void setupMessageListener(Object messageListener) { + this.pulsarContainerProperties.setMessageListener(messageListener); + } + + public PulsarContainerProperties getPulsarContainerProperties() { + return pulsarContainerProperties; + } + + public PulsarConsumerFactory getPulsarConsumerFactory() { + return pulsarConsumerFactory; + } + + @Override + public boolean isAutoStartup() { + return this.autoStartup; + } + + @Override + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + + + public void setPhase(int phase) { + this.phase = phase; + } + + @Override + public int getPhase() { + return this.phase; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/DefaultPulsarMessageListenerContainer.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/DefaultPulsarMessageListenerContainer.java new file mode 100644 index 00000000..7a2e440c --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/DefaultPulsarMessageListenerContainer.java @@ -0,0 +1,318 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.api.BatchReceivePolicy; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageListener; +import org.apache.pulsar.client.api.Messages; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.task.AsyncListenableTaskExecutor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.lang.Nullable; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.event.ConsumerFailedToStartEvent; +import org.springframework.pulsar.event.ConsumerStartedEvent; +import org.springframework.pulsar.event.ConsumerStartingEvent; +import org.springframework.scheduling.SchedulingAwareRunnable; +import org.springframework.util.StringUtils; +import org.springframework.util.concurrent.ListenableFuture; + +/** + * @author Soby Chacko + */ +public class DefaultPulsarMessageListenerContainer extends AbstractPulsarMessageListenerContainer { + + private volatile boolean running = false; + + private String beanName; + + private volatile ListenableFuture listenerConsumerFuture; + + private volatile Listener listenerConsumer; + + private volatile CountDownLatch startLatch = new CountDownLatch(1); + + private final AbstractPulsarMessageListenerContainer thisOrParentContainer; + + public DefaultPulsarMessageListenerContainer(PulsarConsumerFactory pulsarConsumerFactory, PulsarContainerProperties pulsarContainerProperties) { + super(pulsarConsumerFactory, pulsarContainerProperties); + this.thisOrParentContainer = this; + } + + @Override + public void start() { + try { + doStart(); + } + catch (PulsarClientException e) { + e.printStackTrace(); //TODO: proper logging + } + } + + private void doStart() throws PulsarClientException { + + PulsarContainerProperties containerProperties = getPulsarContainerProperties(); + + Object messageListenerObject = containerProperties.getMessageListener(); + AsyncListenableTaskExecutor consumerExecutor = containerProperties.getConsumerTaskExecutor(); + + @SuppressWarnings("unchecked") + MessageListener messageListener = (MessageListener) messageListenerObject; + + if (consumerExecutor == null) { + consumerExecutor = new SimpleAsyncTaskExecutor( + (getBeanName() == null ? "" : getBeanName()) + "-C-"); + containerProperties.setConsumerTaskExecutor(consumerExecutor); + } + + this.listenerConsumer = new Listener(messageListener); + setRunning(true); + this.startLatch = new CountDownLatch(1); + this.listenerConsumerFuture = consumerExecutor.submitListenable(this.listenerConsumer); + + try { + if (!this.startLatch.await(containerProperties.getConsumerStartTimeout().toMillis(), TimeUnit.MILLISECONDS)) { + this.logger.error("Consumer thread failed to start - does the configured task executor " + + "have enough threads to support all containers and concurrency?"); + publishConsumerFailedToStart(); + } + } + catch (@SuppressWarnings("UNUSED") InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void stop() { + setRunning(false); + System.out.println("Pausing this consumer."); + this.listenerConsumer.consumer.pause(); + try { + System.out.println("Closing this consumer."); + this.listenerConsumer.consumer.close(); + } + catch (PulsarClientException e) { + e.printStackTrace(); + } + } + + @Override + public boolean isRunning() { + return this.running; + } + + protected void setRunning(boolean running) { + this.running = running; + } + + /** + * Return the bean name. + * + * @return the bean name. + */ + @Nullable + public String getBeanName() { + return this.beanName; + } + + @Override + public void destroy() { + + } + + private void publishConsumerStartingEvent() { + this.startLatch.countDown(); + ApplicationEventPublisher publisher = getApplicationEventPublisher(); + if (publisher != null) { + publisher.publishEvent(new ConsumerStartingEvent(this, this.thisOrParentContainer)); + } + } + + private void publishConsumerStartedEvent() { + ApplicationEventPublisher publisher = getApplicationEventPublisher(); + if (publisher != null) { + publisher.publishEvent(new ConsumerStartedEvent(this, this.thisOrParentContainer)); + } + } + + private void publishConsumerFailedToStart() { + ApplicationEventPublisher publisher = getApplicationEventPublisher(); + if (publisher != null) { + publisher.publishEvent(new ConsumerFailedToStartEvent(this, this.thisOrParentContainer)); + } + } + + private final class Listener implements SchedulingAwareRunnable { + + private final MessageListener listener; + private final PulsarBatchMessageListener batchMessageHandler; + Consumer consumer; + + private final PulsarContainerProperties containerProperties = getPulsarContainerProperties(); + + private volatile Thread consumerThread; + + @SuppressWarnings("unchecked") + Listener(MessageListener messageListener) { + if (messageListener instanceof PulsarBatchMessageListener) { + this.batchMessageHandler = (PulsarBatchMessageListener) messageListener; + this.listener = null; + + } + else if (messageListener != null) { + this.listener = (MessageListener) messageListener; + this.batchMessageHandler = null; + } + else { + this.listener = null; + this.batchMessageHandler = null; + } + + try { + final PulsarContainerProperties pulsarContainerProperties = getPulsarContainerProperties(); + Map propertiesToOverride = extractPropertiesToOverride(pulsarContainerProperties); + if (this.containerProperties.isBatchReceive() || this.containerProperties.isBatchAsyncReceive()) { + final BatchReceivePolicy batchReceivePolicy = BatchReceivePolicy.DEFAULT_POLICY; + this.consumer = getPulsarConsumerFactory().createConsumer( + (Schema) pulsarContainerProperties.getSchema(), + batchReceivePolicy, propertiesToOverride); + } + else if (this.containerProperties.isAsyncReceive()) { + this.consumer = getPulsarConsumerFactory().createConsumer( + (Schema) pulsarContainerProperties.getSchema(), propertiesToOverride); + } + else { + this.consumer = getPulsarConsumerFactory().createConsumer( + (Schema) pulsarContainerProperties.getSchema(), propertiesToOverride); + } + } + catch (PulsarClientException e) { + e.printStackTrace(); //TODO - Proper logging + } + + } + + private Map extractPropertiesToOverride(PulsarContainerProperties pulsarContainerProperties) { + final SubscriptionType subscriptionType = pulsarContainerProperties.getSubscriptionType(); + final Map propertiesToOverride = new HashMap<>(); + if (subscriptionType != null) { + propertiesToOverride.put("subscriptionType", subscriptionType); + } + final String[] topics = pulsarContainerProperties.getTopics(); + final HashSet strings = new HashSet<>(Arrays.stream(topics).toList()); + if (!strings.isEmpty()) { + propertiesToOverride.put("topicNames", strings); + } + if (StringUtils.hasText(pulsarContainerProperties.getSubscriptionName())) { + propertiesToOverride.put("subscriptionName", + pulsarContainerProperties.getSubscriptionName()); + } + return propertiesToOverride; + } + + @Override + public boolean isLongLived() { + return true; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void run() { + publishConsumerStartingEvent(); + this.consumerThread = Thread.currentThread(); + + publishConsumerStartedEvent(); + while (isRunning()) { + Message msg = null; + try { + // Wait for a message + if (this.containerProperties.isBatchReceive()) { + Messages messages = consumer.batchReceive(); + this.batchMessageHandler.received(consumer, messages); + consumer.acknowledge(messages); + } + else if (this.containerProperties.isBatchAsyncReceive()) { + final CompletableFuture> messagesCompletableFuture = consumer.batchReceiveAsync(); + + messagesCompletableFuture.thenAccept(messages -> { + if (messages != null) { + this.batchMessageHandler.received(consumer, messages); + if (this.containerProperties.getAckMode() != PulsarContainerProperties.AckMode.MANUAL) { + try { + consumer.acknowledge(messages); + } + catch (PulsarClientException e) { + consumer.negativeAcknowledge(messages); + } + } + } + }); + //consumer can do other things - but nothing ATM. + } + else if (this.containerProperties.isAsyncReceive()) { + final CompletableFuture> messageCompletableFuture = consumer.receiveAsync(); + messageCompletableFuture.thenAccept(messages1 -> { + if (messages1 != null) { + this.listener.received(consumer, messages1); + if (this.containerProperties.getAckMode() != PulsarContainerProperties.AckMode.MANUAL) { + try { + consumer.acknowledge(messages1); + } + catch (PulsarClientException e) { + consumer.negativeAcknowledge(messages1); + } + } + } + }); + //consumer can do other things - but nothing ATM. + //we may make this mode as the default rather than the sync mode below. + } + else { + try { + msg = consumer.receive(); + this.listener.received(consumer, msg); + if (this.containerProperties.getAckMode() != PulsarContainerProperties.AckMode.MANUAL) { + consumer.acknowledge(msg); + } + } + catch (Exception e) { + consumer.negativeAcknowledge(msg); + } + } + } + catch (Exception e) { + // Message failed to process, redeliver later + consumer.negativeAcknowledge(msg); + } + } + } + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarBatchMessageListener.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarBatchMessageListener.java new file mode 100644 index 00000000..18093b62 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarBatchMessageListener.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @author Soby Chacko + */ +package org.springframework.pulsar.listener; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageListener; +import org.apache.pulsar.client.api.Messages; + +public interface PulsarBatchMessageListener extends MessageListener { + + default void received(Consumer consumer, Message msg) { + throw new UnsupportedOperationException(); + } + + void received(Consumer consumer, Messages msg); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarConsumerProperties.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarConsumerProperties.java new file mode 100644 index 00000000..68bbd90c --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarConsumerProperties.java @@ -0,0 +1,309 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import java.util.SortedMap; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.ConsumerCryptoFailureAction; +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.RedeliveryBackoff; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionType; + +/** + * @author Soby Chacko + */ +public class PulsarConsumerProperties { + + /** + * Topic names. + */ + private String[] topics; + + /** + * Topic pattern. + */ + private Pattern topicsPattern; + + private String subscriptionName; + + private SubscriptionType subscriptionType; + + private int receiveQueueSize; + + private long acknowledgementsGroupTimeMicros; + + private long negativeAckRedeliveryDelayMicros; + + private int maxTotalReceiverQueueSizeAcrossPartitions; + + private String consumerName; + + private long ackTimeoutMillis; + + private long tickDurationMillis; + + private int priorityLevel; + + private ConsumerCryptoFailureAction cryptoFailureAction; + + private SortedMap properties; + + private boolean readCompacted; + + private SubscriptionInitialPosition subscriptionInitialPosition; + + private int patternAutoDiscoveryPeriod; + + private RegexSubscriptionMode regexSubscriptionMode; + + private DeadLetterPolicy deadLetterPolicy; + + private boolean autoUpdatePartitions; + + private boolean replicateSubscriptionState; + + private RedeliveryBackoff negativeAckRedeliveryBackoff; + private RedeliveryBackoff ackTimeoutRedeliveryBackoff; + private boolean autoAckOldestChunkedMessageOnQueueFull; + + private int maxPendingChunkedMessage; + + private long expireTimeOfIncompleteChunkedMessageMillis; + + public PulsarConsumerProperties(String... topics) { + this.topics = topics.clone(); + this.topicsPattern = null; + } + + public PulsarConsumerProperties(Pattern topicPattern) { + this.topicsPattern = topicPattern; + this.topics = null; + } + + public void setTopics(String[] topics) { + this.topics = topics; + } + + public void setTopicsPattern(Pattern topicsPattern) { + this.topicsPattern = topicsPattern; + } + + public String getSubscriptionName() { + return subscriptionName; + } + + public void setSubscriptionName(String subscriptionName) { + this.subscriptionName = subscriptionName; + } + + public int getReceiveQueueSize() { + return receiveQueueSize; + } + + public void setReceiveQueueSize(int receiveQueueSize) { + this.receiveQueueSize = receiveQueueSize; + } + + public SubscriptionType getSubscriptionType() { + return subscriptionType; + } + + public void setSubscriptionType(SubscriptionType subscriptionType) { + this.subscriptionType = subscriptionType; + } + + public long getAcknowledgementsGroupTimeMicros() { + return acknowledgementsGroupTimeMicros; + } + + public void setAcknowledgementsGroupTimeMicros(long acknowledgementsGroupTimeMicros) { + this.acknowledgementsGroupTimeMicros = acknowledgementsGroupTimeMicros; + } + + public long getNegativeAckRedeliveryDelayMicros() { + return negativeAckRedeliveryDelayMicros; + } + + public void setNegativeAckRedeliveryDelayMicros(long negativeAckRedeliveryDelayMicros) { + this.negativeAckRedeliveryDelayMicros = negativeAckRedeliveryDelayMicros; + } + + public int getMaxTotalReceiverQueueSizeAcrossPartitions() { + return maxTotalReceiverQueueSizeAcrossPartitions; + } + + public void setMaxTotalReceiverQueueSizeAcrossPartitions(int maxTotalReceiverQueueSizeAcrossPartitions) { + this.maxTotalReceiverQueueSizeAcrossPartitions = maxTotalReceiverQueueSizeAcrossPartitions; + } + + public String getConsumerName() { + return consumerName; + } + + public void setConsumerName(String consumerName) { + this.consumerName = consumerName; + } + + public long getAckTimeoutMillis() { + return ackTimeoutMillis; + } + + public void setAckTimeoutMillis(long ackTimeoutMillis) { + this.ackTimeoutMillis = ackTimeoutMillis; + } + + public long getTickDurationMillis() { + return tickDurationMillis; + } + + public void setTickDurationMillis(long tickDurationMillis) { + this.tickDurationMillis = tickDurationMillis; + } + + public int getPriorityLevel() { + return priorityLevel; + } + + public void setPriorityLevel(int priorityLevel) { + this.priorityLevel = priorityLevel; + } + + public ConsumerCryptoFailureAction getCryptoFailureAction() { + return cryptoFailureAction; + } + + public void setCryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) { + this.cryptoFailureAction = cryptoFailureAction; + } + + public SortedMap getProperties() { + return properties; + } + + public void setProperties(SortedMap properties) { + this.properties = properties; + } + + public boolean isReadCompacted() { + return readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + public SubscriptionInitialPosition getSubscriptionInitialPosition() { + return subscriptionInitialPosition; + } + + public void setSubscriptionInitialPosition(SubscriptionInitialPosition subscriptionInitialPosition) { + this.subscriptionInitialPosition = subscriptionInitialPosition; + } + + public int getPatternAutoDiscoveryPeriod() { + return patternAutoDiscoveryPeriod; + } + + public void setPatternAutoDiscoveryPeriod(int patternAutoDiscoveryPeriod) { + this.patternAutoDiscoveryPeriod = patternAutoDiscoveryPeriod; + } + + public RegexSubscriptionMode getRegexSubscriptionMode() { + return regexSubscriptionMode; + } + + public void setRegexSubscriptionMode(RegexSubscriptionMode regexSubscriptionMode) { + this.regexSubscriptionMode = regexSubscriptionMode; + } + + public DeadLetterPolicy getDeadLetterPolicy() { + return deadLetterPolicy; + } + + public void setDeadLetterPolicy(DeadLetterPolicy deadLetterPolicy) { + this.deadLetterPolicy = deadLetterPolicy; + } + + public boolean isAutoUpdatePartitions() { + return autoUpdatePartitions; + } + + public void setAutoUpdatePartitions(boolean autoUpdatePartitions) { + this.autoUpdatePartitions = autoUpdatePartitions; + } + + public boolean isReplicateSubscriptionState() { + return replicateSubscriptionState; + } + + public void setReplicateSubscriptionState(boolean replicateSubscriptionState) { + this.replicateSubscriptionState = replicateSubscriptionState; + } + + public RedeliveryBackoff getNegativeAckRedeliveryBackoff() { + return negativeAckRedeliveryBackoff; + } + + public void setNegativeAckRedeliveryBackoff(RedeliveryBackoff negativeAckRedeliveryBackoff) { + this.negativeAckRedeliveryBackoff = negativeAckRedeliveryBackoff; + } + + public RedeliveryBackoff getAckTimeoutRedeliveryBackoff() { + return ackTimeoutRedeliveryBackoff; + } + + public void setAckTimeoutRedeliveryBackoff(RedeliveryBackoff ackTimeoutRedeliveryBackoff) { + this.ackTimeoutRedeliveryBackoff = ackTimeoutRedeliveryBackoff; + } + + public boolean isAutoAckOldestChunkedMessageOnQueueFull() { + return autoAckOldestChunkedMessageOnQueueFull; + } + + public void setAutoAckOldestChunkedMessageOnQueueFull(boolean autoAckOldestChunkedMessageOnQueueFull) { + this.autoAckOldestChunkedMessageOnQueueFull = autoAckOldestChunkedMessageOnQueueFull; + } + + public int getMaxPendingChunkedMessage() { + return maxPendingChunkedMessage; + } + + public void setMaxPendingChunkedMessage(int maxPendingChunkedMessage) { + this.maxPendingChunkedMessage = maxPendingChunkedMessage; + } + + public long getExpireTimeOfIncompleteChunkedMessageMillis() { + return expireTimeOfIncompleteChunkedMessageMillis; + } + + public void setExpireTimeOfIncompleteChunkedMessageMillis(long expireTimeOfIncompleteChunkedMessageMillis) { + this.expireTimeOfIncompleteChunkedMessageMillis = expireTimeOfIncompleteChunkedMessageMillis; + } + + public String[] getTopics() { + return topics; + } + + public Pattern getTopicsPattern() { + return topicsPattern; + } + + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarContainerProperties.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarContainerProperties.java new file mode 100644 index 00000000..31668555 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarContainerProperties.java @@ -0,0 +1,163 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import java.time.Duration; + +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionType; + +import org.springframework.core.task.AsyncListenableTaskExecutor; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class PulsarContainerProperties extends PulsarConsumerProperties { + + private static final Duration DEFAULT_CONSUMER_START_TIMEOUT = Duration.ofSeconds(30); + + private Duration consumerStartTimeout = DEFAULT_CONSUMER_START_TIMEOUT; + + public enum AckMode { + + MANUAL; + } + + private Schema schema = Schema.BYTES; + + private Object messageListener; + private AsyncListenableTaskExecutor consumerTaskExecutor; + private SubscriptionType subscriptionType; + + private int maxNumMessages = -1; + private int maxNumBytes = 10 * 1024 * 1024; + private int batchTimeout = 100; + + private boolean batchReceive; + private boolean batchAsyncReceive; + + private boolean asyncReceive; + + private AckMode ackMode; + + public PulsarContainerProperties(String... topics) { + super(topics); + } + + public Object getMessageListener() { + return messageListener; + } + + public void setMessageListener(Object messageListener) { + this.messageListener = messageListener; + } + + public AsyncListenableTaskExecutor getConsumerTaskExecutor() { + return this.consumerTaskExecutor; + } + + public void setConsumerTaskExecutor(AsyncListenableTaskExecutor consumerExecutor) { + this.consumerTaskExecutor = consumerExecutor; + } + + public SubscriptionType getSubscriptionType() { + return subscriptionType; + } + + public void setSubscriptionType(SubscriptionType subscriptionType) { + this.subscriptionType = subscriptionType; + } + + public int getMaxNumMessages() { + return maxNumMessages; + } + + public void setMaxNumMessages(int maxNumMessages) { + this.maxNumMessages = maxNumMessages; + } + + public int getMaxNumBytes() { + return maxNumBytes; + } + + public void setMaxNumBytes(int maxNumBytes) { + this.maxNumBytes = maxNumBytes; + } + + public int getBatchTimeout() { + return batchTimeout; + } + + public void setBatchTimeout(int batchTimeout) { + this.batchTimeout = batchTimeout; + } + + public boolean isBatchReceive() { + return batchReceive; + } + + public void setBatchReceive(boolean batchReceive) { + this.batchReceive = batchReceive; + } + + public boolean isBatchAsyncReceive() { + return batchAsyncReceive; + } + + public void setBatchAsyncReceive(boolean batchAsyncReceive) { + this.batchAsyncReceive = batchAsyncReceive; + } + + public boolean isAsyncReceive() { + return asyncReceive; + } + + public void setAsyncReceive(boolean asyncReceive) { + this.asyncReceive = asyncReceive; + } + + public AckMode getAckMode() { + return ackMode; + } + + public void setAckMode(AckMode ackMode) { + this.ackMode = ackMode; + } + + public Duration getConsumerStartTimeout() { + return this.consumerStartTimeout; + } + + /** + * Set the timeout to wait for a consumer thread to start before logging + * an error. Default 30 seconds. + * @param consumerStartTimeout the consumer start timeout. + */ + public void setConsumerStartTimeout(Duration consumerStartTimeout) { + Assert.notNull(consumerStartTimeout, "'consumerStartTimout' cannot be null"); + this.consumerStartTimeout = consumerStartTimeout; + } + + public Schema getSchema() { + return schema; + } + + public void setSchema(Schema schema) { + this.schema = schema; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarListenerContainerRegistry.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarListenerContainerRegistry.java new file mode 100644 index 00000000..4b5e8074 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarListenerContainerRegistry.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import java.util.Collection; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.pulsar.listener.PulsarMessageListenerContainer; + +/** + * @author Soby Chacko + */ +public interface PulsarListenerContainerRegistry { + + @Nullable + PulsarMessageListenerContainer getListenerContainer(String id); + + Set getListenerContainerIds(); + + Collection getListenerContainers(); + + Collection getAllListenerContainers(); +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarMessageListenerContainer.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarMessageListenerContainer.java new file mode 100644 index 00000000..70bcef65 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/PulsarMessageListenerContainer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.SmartLifecycle; + +/** + * @author Soby Chacko + */ +public interface PulsarMessageListenerContainer extends SmartLifecycle, DisposableBean { + + void setupMessageListener(Object messageListener); + + @Override + default void destroy() { + stop(); + } + + default void setAutoStartup(boolean autoStartup) { + // empty + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/DelegatingInvocableHandler.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/DelegatingInvocableHandler.java new file mode 100644 index 00000000..b8620e6c --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/DelegatingInvocableHandler.java @@ -0,0 +1,282 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.support.PayloadMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.pulsar.PulsarException; +import org.springframework.pulsar.listener.adapter.InvocationResult; +import org.springframework.validation.Validator; + +/** + * @author Soby Chacko + */ +public class DelegatingInvocableHandler { + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + + private final List handlers; + + private final ConcurrentMap, InvocableHandlerMethod> cachedHandlers = new ConcurrentHashMap<>(); + + private final ConcurrentMap payloadMethodParameters = + new ConcurrentHashMap<>(); + + private final InvocableHandlerMethod defaultHandler; + + private final Map handlerSendTo = new ConcurrentHashMap<>(); + + private final Map handlerReturnsMessage = new ConcurrentHashMap<>(); + + private final Object bean; + + private final BeanExpressionResolver resolver; + + private final BeanExpressionContext beanExpressionContext; + + private final ConfigurableListableBeanFactory beanFactory; + + private final PayloadValidator validator; + + public DelegatingInvocableHandler(List handlers, + @Nullable InvocableHandlerMethod defaultHandler, Object bean, + @Nullable BeanExpressionResolver beanExpressionResolver, + @Nullable BeanExpressionContext beanExpressionContext, + @Nullable BeanFactory beanFactory, @Nullable Validator validator) { + + this.handlers = new ArrayList<>(); + for (InvocableHandlerMethod handler : handlers) { + this.handlers.add(wrapIfNecessary(handler)); + } + this.defaultHandler = wrapIfNecessary(defaultHandler); + this.bean = bean; + this.resolver = beanExpressionResolver; + this.beanExpressionContext = beanExpressionContext; + this.beanFactory = beanFactory instanceof ConfigurableListableBeanFactory + ? (ConfigurableListableBeanFactory) beanFactory + : null; + this.validator = validator == null ? null : new PayloadValidator(validator); + } + + @Nullable + private InvocableHandlerMethod wrapIfNecessary(@Nullable InvocableHandlerMethod handler) { + if (handler == null) { + return null; + } + Parameter[] parameters = handler.getMethod().getParameters(); + for (Parameter parameter : parameters) { +// if (parameter.getType().equals(ConsumerRecordMetadata.class)) { +// return new DelegatingInvocableHandler.MetadataAwareInvocableHandlerMethod(handler); +// } + } + return handler; + } + + /** + * Return the bean for this handler. + * @return the bean. + */ + public Object getBean() { + return this.bean; + } + + /** + * Invoke the method with the given message. + * @param message the message. + * @param providedArgs additional arguments. + * @return the result of the invocation. + * @throws Exception raised if no suitable argument resolver can be found, + * or the method raised an exception. + */ + public Object invoke(Message message, Object... providedArgs) throws Exception { //NOSONAR + Class payloadClass = message.getPayload().getClass(); + InvocableHandlerMethod handler = getHandlerForPayload(payloadClass); + if (this.validator != null && this.defaultHandler != null) { + MethodParameter parameter = this.payloadMethodParameters.get(handler); + if (parameter != null) { + this.validator.validate(message, parameter, message.getPayload()); + } + } + Object result = null; + if (handler instanceof MetadataAwareInvocableHandlerMethod) { +// Object[] args = new Object[providedArgs.length + 1]; +// args[0] = AdapterUtils.buildConsumerRecordMetadataFromArray(providedArgs); +// System.arraycopy(providedArgs, 0, args, 1, providedArgs.length); +// result = handler.invoke(message, args); + } + else { + result = handler.invoke(message, providedArgs); + } + Expression replyTo = this.handlerSendTo.get(handler); + return new InvocationResult(result, replyTo, this.handlerReturnsMessage.get(handler)); + } + + /** + * Determine the {@link InvocableHandlerMethod} for the provided type. + * @param payloadClass the payload class. + * @return the handler. + */ + protected InvocableHandlerMethod getHandlerForPayload(Class payloadClass) { + InvocableHandlerMethod handler = this.cachedHandlers.get(payloadClass); + if (handler == null) { + handler = findHandlerForPayload(payloadClass); + if (handler == null) { + throw new PulsarException("No method found for " + payloadClass); + } + this.cachedHandlers.putIfAbsent(payloadClass, handler); //NOSONAR + //setupReplyTo(handler); + } + return handler; + } + + @Nullable + protected InvocableHandlerMethod findHandlerForPayload(Class payloadClass) { + InvocableHandlerMethod result = null; + for (InvocableHandlerMethod handler : this.handlers) { + if (matchHandlerMethod(payloadClass, handler)) { + if (result != null) { + boolean resultIsDefault = result.equals(this.defaultHandler); + if (!handler.equals(this.defaultHandler) && !resultIsDefault) { + throw new PulsarException("Ambiguous methods for payload type: " + payloadClass + ": " + + result.getMethod().getName() + " and " + handler.getMethod().getName()); + } + if (!resultIsDefault) { + continue; // otherwise replace the result with the actual match + } + } + result = handler; + } + } + return result != null ? result : this.defaultHandler; + } + + protected boolean matchHandlerMethod(Class payloadClass, InvocableHandlerMethod handler) { + Method method = handler.getMethod(); + Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + // Single param; no annotation or not @Header + if (parameterAnnotations.length == 1) { + MethodParameter methodParameter = new MethodParameter(method, 0); + if ((methodParameter.getParameterAnnotations().length == 0 + || !methodParameter.hasParameterAnnotation(Header.class)) + && methodParameter.getParameterType().isAssignableFrom(payloadClass)) { + if (this.validator != null) { + this.payloadMethodParameters.put(handler, methodParameter); + } + return true; + } + } + + MethodParameter foundCandidate = findCandidate(payloadClass, method, parameterAnnotations); + if (foundCandidate != null && this.validator != null) { + this.payloadMethodParameters.put(handler, foundCandidate); + } + return foundCandidate != null; + } + + private MethodParameter findCandidate(Class payloadClass, Method method, + Annotation[][] parameterAnnotations) { + MethodParameter foundCandidate = null; + for (int i = 0; i < parameterAnnotations.length; i++) { + MethodParameter methodParameter = new MethodParameter(method, i); + if ((methodParameter.getParameterAnnotations().length == 0 + || !methodParameter.hasParameterAnnotation(Header.class)) + && methodParameter.getParameterType().isAssignableFrom(payloadClass)) { + if (foundCandidate != null) { + throw new PulsarException("Ambiguous payload parameter for " + method.toGenericString()); + } + foundCandidate = methodParameter; + } + } + return foundCandidate; + } + + /** + * Return a string representation of the method that will be invoked for this payload. + * @param payload the payload. + * @return the method name. + */ + public String getMethodNameFor(Object payload) { + InvocableHandlerMethod handlerForPayload = getHandlerForPayload(payload.getClass()); + return handlerForPayload == null ? "no match" : handlerForPayload.getMethod().toGenericString(); //NOSONAR + } + + public boolean hasDefaultHandler() { + return this.defaultHandler != null; + } + + /** + * A handler method that is aware of metadata. + * + * @since 2.5 + */ + private static final class MetadataAwareInvocableHandlerMethod extends InvocableHandlerMethod { + + MetadataAwareInvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + } + + private static final class PayloadValidator extends PayloadMethodArgumentResolver { + + PayloadValidator(Validator validator) { + super(new MessageConverter() { // Required but never used + + @Override + @Nullable + public Message toMessage(Object payload, @Nullable + MessageHeaders headers) { + return null; + } + + @Override + @Nullable + public Object fromMessage(Message message, Class targetClass) { + return null; + } + + }, validator); + } + + @Override + public void validate(Message message, MethodParameter parameter, Object target) { // NOSONAR - public + super.validate(message, parameter, target); + } + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/HandlerAdapter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/HandlerAdapter.java new file mode 100644 index 00000000..e4d8e171 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/HandlerAdapter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +/** + * @author Soby Chacko + */ +public class HandlerAdapter { + + private final InvocableHandlerMethod invokerHandlerMethod; + + private final DelegatingInvocableHandler delegatingHandler; + + /** + * Construct an instance with the provided method. + * @param invokerHandlerMethod the method. + */ + public HandlerAdapter(InvocableHandlerMethod invokerHandlerMethod) { + this.invokerHandlerMethod = invokerHandlerMethod; + this.delegatingHandler = null; + } + + /** + * Construct an instance with the provided delegating handler. + * @param delegatingHandler the handler. + */ + public HandlerAdapter(DelegatingInvocableHandler delegatingHandler) { + this.invokerHandlerMethod = null; + this.delegatingHandler = delegatingHandler; + } + + public Object invoke(Message message, Object... providedArgs) throws Exception { //NOSONAR + if (this.invokerHandlerMethod != null) { + return this.invokerHandlerMethod.invoke(message, providedArgs); // NOSONAR + } + else if (this.delegatingHandler.hasDefaultHandler()) { + // Needed to avoid returning raw Message which matches Object + Object[] args = new Object[providedArgs.length + 1]; + args[0] = message.getPayload(); + System.arraycopy(providedArgs, 0, args, 1, providedArgs.length); + return this.delegatingHandler.invoke(message, args); + } + else { + return this.delegatingHandler.invoke(message, providedArgs); + } + } + + public String getMethodAsString(Object payload) { + if (this.invokerHandlerMethod != null) { + return this.invokerHandlerMethod.getMethod().toGenericString(); + } + else { + return this.delegatingHandler.getMethodNameFor(payload); + } + } + + public Object getBean() { + if (this.invokerHandlerMethod != null) { + return this.invokerHandlerMethod.getBean(); + } + else { + return this.delegatingHandler.getBean(); + } + } + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/InvocationResult.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/InvocationResult.java new file mode 100644 index 00000000..9b6d3eab --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/InvocationResult.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import org.springframework.expression.Expression; +import org.springframework.lang.Nullable; + +/** + * @author Soby Chacko + */ +public final class InvocationResult { + + @Nullable + private final Object result; + + @Nullable + private final Expression sendTo; + + private final boolean messageReturnType; + + public InvocationResult(@Nullable Object result, @Nullable Expression sendTo, boolean messageReturnType) { + this.result = result; + this.sendTo = sendTo; + this.messageReturnType = messageReturnType; + } + + @Nullable + public Object getResult() { + return this.result; + } + + @Nullable + public Expression getSendTo() { + return this.sendTo; + } + + public boolean isMessageReturnType() { + return this.messageReturnType; + } + + @Override + public String toString() { + return "InvocationResult [result=" + this.result + + ", sendTo=" + (this.sendTo == null ? "null" : this.sendTo.getExpressionString()) + + ", messageReturnType=" + this.messageReturnType + "]"; + } + +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarBatchMessagingMessageListenerAdapter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarBatchMessagingMessageListenerAdapter.java new file mode 100644 index 00000000..5852acf6 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarBatchMessagingMessageListenerAdapter.java @@ -0,0 +1,101 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Messages; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.pulsar.support.converter.PulsarBatchMessageConverter; +import org.springframework.pulsar.listener.PulsarBatchMessageListener; +import org.springframework.pulsar.support.converter.PulsarBatchMessagingMessageConverter; +import org.springframework.pulsar.support.converter.PulsarRecordMessageConverter; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public class PulsarBatchMessagingMessageListenerAdapter extends PulsarMessagingMessageListenerAdapter + implements PulsarBatchMessageListener { + + private PulsarBatchMessageConverter batchMessageConverter = new PulsarBatchMessagingMessageConverter(); + + public PulsarBatchMessagingMessageListenerAdapter(Object bean, Method method) { + super(bean, method); + } + + public void setBatchMessageConverter(PulsarBatchMessageConverter messageConverter) { + Assert.notNull(messageConverter, "'messageConverter' cannot be null"); + this.batchMessageConverter = messageConverter; + PulsarRecordMessageConverter recordMessageConverter = messageConverter.getRecordMessageConverter(); + if (recordMessageConverter != null) { + setMessageConverter(recordMessageConverter); + } + } + + protected final PulsarBatchMessageConverter getBatchMessageConverter() { + return this.batchMessageConverter; + } + + public void received(Consumer consumer, Messages msg) { + Message message; + if (!isConsumerRecordList()) { + if (isMessageList()) { + List> messages = new ArrayList<>(msg.size()); + for (org.apache.pulsar.client.api.Message record : msg) { + messages.add(toMessagingMessage(record, consumer)); + } + message = MessageBuilder.withPayload(messages).build(); + } + else { + message = toMessagingMessage(msg, consumer); + } + } + else { + message = null; // optimization since we won't need any conversion to invoke + } + logger.debug(() -> "Processing [" + message + "]"); + invoke(msg, consumer, message); + } + + protected void invoke(Object records, Consumer consumer, + final Message messageArg) { + + Message message = messageArg; + try { + Object result = invokeHandler(records, message, consumer); +// if (result != null) { +// handleResult(result, records, message); +// } + } + catch (Exception e) { + throw e; + } + } + + + protected Message toMessagingMessage(Messages msg, Consumer consumer) { + + return getBatchMessageConverter().toMessage(msg, consumer, getType()); + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarMessagingMessageListenerAdapter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarMessagingMessageListenerAdapter.java new file mode 100644 index 00000000..a7ba0629 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarMessagingMessageListenerAdapter.java @@ -0,0 +1,288 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.Collection; +import java.util.List; + +import org.apache.commons.logging.LogFactory; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.Messages; + +import org.springframework.context.expression.MapAccessor; +import org.springframework.core.MethodParameter; +import org.springframework.core.log.LogAccessor; +import org.springframework.expression.BeanResolver; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.StandardTypeConverter; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.pulsar.support.converter.PulsarMessagingMessageConverter; +import org.springframework.pulsar.support.converter.PulsarRecordMessageConverter; +import org.springframework.util.Assert; + +/** + * @author Soby Chacko + */ +public abstract class PulsarMessagingMessageListenerAdapter { + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + + private final Object bean; + + protected final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass())); + + private final Type inferredType; + + private final StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + + private HandlerAdapter handlerMethod; + + private boolean conversionNeeded = true; + + private boolean messageReturnType; + + private boolean isConsumerRecordList; + + private boolean isMessageList; + + private boolean isConsumerRecords; + + private boolean converterSet; + + private PulsarRecordMessageConverter messageConverter = new PulsarMessagingMessageConverter(); + + private Type fallbackType = Object.class; + + public PulsarMessagingMessageListenerAdapter(Object bean, Method method) { + this.bean = bean; + this.inferredType = determineInferredType(method); + } + + public void setMessageConverter(PulsarRecordMessageConverter messageConverter) { + this.messageConverter = messageConverter; + this.converterSet = true; + } + + protected final PulsarRecordMessageConverter getMessageConverter() { + return this.messageConverter; + } + + public void setMessagingConverter(SmartMessageConverter messageConverter) { + Assert.isTrue(!this.converterSet, "Cannot set the SmartMessageConverter when setting the messageConverter, " + + "add the SmartConverter to the message converter instead"); + ((PulsarMessagingMessageConverter) this.messageConverter).setMessagingConverter(messageConverter); + } + + protected Type getType() { + return this.inferredType == null ? this.fallbackType : this.inferredType; + } + + public void setFallbackType(Class fallbackType) { + this.fallbackType = fallbackType; + } + + public void setHandlerMethod(HandlerAdapter handlerMethod) { + this.handlerMethod = handlerMethod; + } + + protected boolean isConsumerRecordList() { + return this.isConsumerRecordList; + } + + public boolean isConsumerRecords() { + return this.isConsumerRecords; + } + + public boolean isConversionNeeded() { + return this.conversionNeeded; + } + + public void setBeanResolver(BeanResolver beanResolver) { + this.evaluationContext.setBeanResolver(beanResolver); + this.evaluationContext.setTypeConverter(new StandardTypeConverter()); + this.evaluationContext.addPropertyAccessor(new MapAccessor()); + } + + protected boolean isMessageList() { + return this.isMessageList; + } + + protected org.springframework.messaging.Message toMessagingMessage(Message record, Consumer consumer) { + return getMessageConverter().toMessage(record, consumer, getType()); + } + + protected final Object invokeHandler(Object data, org.springframework.messaging.Message message, + Consumer consumer) { + + try { + if (data instanceof List && !this.isConsumerRecordList) { + return this.handlerMethod.invoke(message, consumer); + } + else { + return this.handlerMethod.invoke(message, data, consumer); + } + } + catch (Exception ex) { + throw new MessageConversionException("Cannot handle message", ex); + } + } + + + protected Type determineInferredType(Method method) { // NOSONAR complexity + if (method == null) { + return null; + } + + Type genericParameterType = null; + int allowedBatchParameters = 1; + int notConvertibleParameters = 0; + + for (int i = 0; i < method.getParameterCount(); i++) { + MethodParameter methodParameter = new MethodParameter(method, i); + /* + * We're looking for a single non-annotated parameter, or one annotated with @Payload. + * We ignore parameters with type Message, Consumer, Ack, ConsumerRecord because they + * are not involved with conversion. + */ + Type parameterType = methodParameter.getGenericParameterType(); + boolean isNotConvertible = parameterIsType(parameterType, Message.class); + boolean isConsumer = parameterIsType(parameterType, Consumer.class); + if (isNotConvertible) { + notConvertibleParameters++; + } + if (!isNotConvertible && !isMessageWithNoTypeInfo(parameterType) + && (methodParameter.getParameterAnnotations().length == 0 + || methodParameter.hasParameterAnnotation(Payload.class))) { + if (genericParameterType == null) { + genericParameterType = extractGenericParameterTypFromMethodParameter(methodParameter); + } + else { + this.logger.debug(() -> "Ambiguous parameters for target payload for method " + method + + "; no inferred type available"); + break; + } + } + else { + if (isConsumer) { + allowedBatchParameters++; + } + else { + if (parameterType instanceof ParameterizedType + && ((ParameterizedType) parameterType).getRawType().equals(Consumer.class)) { + allowedBatchParameters++; + } + } + } + } + + if (notConvertibleParameters == method.getParameterCount() && method.getReturnType().equals(void.class)) { + this.conversionNeeded = false; + } + boolean validParametersForBatch = method.getGenericParameterTypes().length <= allowedBatchParameters; + + if (!validParametersForBatch) { + String stateMessage = "A parameter of type '%s' must be the only parameter " + + "(except for an optional 'Acknowledgment' and/or 'Consumer' " + + "and/or '@Header(KafkaHeaders.GROUP_ID) String groupId'"; + } + this.messageReturnType = returnTypeMessageOrCollectionOf(method); + return genericParameterType; + } + + private Type extractGenericParameterTypFromMethodParameter(MethodParameter methodParameter) { + Type genericParameterType = methodParameter.getGenericParameterType(); + if (genericParameterType instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) genericParameterType; + if (parameterizedType.getRawType().equals(org.springframework.messaging.Message.class)) { + genericParameterType = ((ParameterizedType) genericParameterType).getActualTypeArguments()[0]; + } + else if (parameterizedType.getRawType().equals(List.class) + && parameterizedType.getActualTypeArguments().length == 1) { + + Type paramType = parameterizedType.getActualTypeArguments()[0]; + this.isConsumerRecordList = paramType.equals(Messages.class); + boolean messageHasGeneric = paramType instanceof ParameterizedType + && ((ParameterizedType) paramType).getRawType().equals(org.springframework.messaging.Message.class); + this.isMessageList = paramType.equals(org.springframework.messaging.Message.class) || messageHasGeneric; + if (messageHasGeneric) { + genericParameterType = ((ParameterizedType) paramType).getActualTypeArguments()[0]; + } + } + else { + this.isConsumerRecords = parameterizedType.getRawType().equals(Messages.class); + } + } + return genericParameterType; + } + + public static boolean returnTypeMessageOrCollectionOf(Method method) { + Type returnType = method.getGenericReturnType(); + if (returnType.equals(org.springframework.messaging.Message.class)) { + return true; + } + if (returnType instanceof ParameterizedType) { + ParameterizedType prt = (ParameterizedType) returnType; + Type rawType = prt.getRawType(); + if (rawType.equals(org.springframework.messaging.Message.class)) { + return true; + } + if (rawType.equals(Collection.class)) { + Type collectionType = prt.getActualTypeArguments()[0]; + if (collectionType.equals(org.springframework.messaging.Message.class)) { + return true; + } + return collectionType instanceof ParameterizedType + && ((ParameterizedType) collectionType).getRawType().equals(org.springframework.messaging.Message.class); + } + } + return false; + + } + + private boolean parameterIsType(Type parameterType, Type type) { + if (parameterType instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) parameterType; + Type rawType = parameterizedType.getRawType(); + if (rawType.equals(type)) { + return true; + } + } + return parameterType.equals(type); + } + + private boolean isMessageWithNoTypeInfo(Type parameterType) { + if (parameterType instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) parameterType; + Type rawType = parameterizedType.getRawType(); + if (rawType.equals(org.springframework.messaging.Message.class)) { + return parameterizedType.getActualTypeArguments()[0] instanceof WildcardType; + } + } + return parameterType.equals(org.springframework.messaging.Message.class); // could be Message without a generic type + } + + + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarRecordMessagingMessageListenerAdapter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarRecordMessagingMessageListenerAdapter.java new file mode 100644 index 00000000..8f74eaa9 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/listener/adapter/PulsarRecordMessagingMessageListenerAdapter.java @@ -0,0 +1,58 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.listener.adapter; + +import java.lang.reflect.Method; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageListener; + +/** + * @author Soby Chacko + */ +public class PulsarRecordMessagingMessageListenerAdapter extends PulsarMessagingMessageListenerAdapter + implements MessageListener { + + public PulsarRecordMessagingMessageListenerAdapter(Object bean, Method method) { + super(bean, method); + } + + @Override + public void received(Consumer consumer, Message record) { + org.springframework.messaging.Message message = null; + if (isConversionNeeded()) { + message = toMessagingMessage(record, consumer); + } + else { + //message = NULL_MESSAGE; + } + if (logger.isDebugEnabled()) { + this.logger.debug("Processing [" + message + "]"); + } + try { + Object result = invokeHandler(record, message, consumer); + if (result != null) { + //handleResult(result, record, message); + } + } + catch (Exception e) { // NOSONAR ex flow control + throw e; + } + } + +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/EndpointHandlerMethod.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/EndpointHandlerMethod.java new file mode 100644 index 00000000..fc4b621e --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/EndpointHandlerMethod.java @@ -0,0 +1,123 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.springframework.beans.factory.BeanCurrentlyInCreationException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * @author Soby Chacko + */ +public class EndpointHandlerMethod { + + private final Object beanOrClass; + + private final String methodName; + + private Object bean; + + private Method method; + + public EndpointHandlerMethod(Object beanOrClass, String methodName) { + Assert.notNull(beanOrClass, () -> "No destination bean or class provided!"); + Assert.notNull(methodName, () -> "No method name for destination bean class provided!"); + this.beanOrClass = beanOrClass; + this.methodName = methodName; + } + + public EndpointHandlerMethod(Object bean, Method method) { + Assert.notNull(bean, () -> "No bean for destination provided!"); + Assert.notNull(method, () -> "No method for destination bean class provided!"); + this.method = method; + this.bean = bean; + this.beanOrClass = bean.getClass(); + this.methodName = method.getName(); + } + + /** + * Return the method. + * @return the method. + */ + public Method getMethod() { + if (this.beanOrClass instanceof Class) { + return forClass((Class) this.beanOrClass); + } + Assert.state(this.bean != null, "Bean must be resolved before accessing its method"); + if (this.bean instanceof EndpointHandlerMethod) { + try { + return Object.class.getMethod("toString"); + } + catch (NoSuchMethodException | SecurityException e) { + } + } + return forClass(this.bean.getClass()); + } + + public String getMethodName() { + Assert.state(this.methodName != null, "Unexpected call to getMethodName()"); + return this.methodName; + } + + public Object resolveBean(BeanFactory beanFactory) { + if (this.bean instanceof EndpointHandlerMethod) { + return ((EndpointHandlerMethod) this.bean).beanOrClass; + } + if (this.bean == null) { + try { + if (this.beanOrClass instanceof Class) { + Class clazz = (Class) this.beanOrClass; + try { + this.bean = beanFactory.getBean(clazz); + } + catch (NoSuchBeanDefinitionException e) { + String beanName = clazz.getSimpleName() + "-handlerMethod"; + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition(beanName, + new RootBeanDefinition(clazz)); + this.bean = beanFactory.getBean(beanName); + } + } + else { + String beanName = (String) this.beanOrClass; + this.bean = beanFactory.getBean(beanName); + } + } + catch (BeanCurrentlyInCreationException ex) { + this.bean = this; + } + } + return this.bean; + } + + private Method forClass(Class clazz) { + if (this.method == null) { + this.method = Arrays.stream(ReflectionUtils.getDeclaredMethods(clazz)) + .filter(mthd -> mthd.getName().equals(this.methodName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + String.format("No method %s in class %s", this.methodName, clazz))); + } + return this.method; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/JavaUtils.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/JavaUtils.java new file mode 100644 index 00000000..c73e827b --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/JavaUtils.java @@ -0,0 +1,189 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * @author Soby Chacko + */ +public final class JavaUtils { + + /** + * The singleton instance of this utility class. + */ + public static final JavaUtils INSTANCE = new JavaUtils(); + + private JavaUtils() { + } + + /** + * Invoke {@link Consumer#accept(Object)} with the value if the condition is true. + * + * @param condition the condition. + * @param value the value. + * @param consumer the consumer. + * @param the value type. + * @return this. + */ + public JavaUtils acceptIfCondition(boolean condition, T value, Consumer consumer) { + if (condition) { + consumer.accept(value); + } + return this; + } + + /** + * Invoke {@link Consumer#accept(Object)} with the value if it is not null. + * + * @param value the value. + * @param consumer the consumer. + * @param the value type. + * @return this. + */ + public JavaUtils acceptIfNotNull(@Nullable T value, Consumer consumer) { + if (value != null) { + consumer.accept(value); + } + return this; + } + + /** + * Invoke {@link Consumer#accept(Object)} with the value if it is not null or empty. + * + * @param value the value. + * @param consumer the consumer. + * @return this. + */ + public JavaUtils acceptIfHasText(String value, Consumer consumer) { + if (StringUtils.hasText(value)) { + consumer.accept(value); + } + return this; + } + + /** + * Invoke {@link Consumer#accept(Object)} with the cast value if the object is an + * instance of the provided class. + * + * @param the type of the class to check and cast. + * @param type the type. + * @param value the value to be checked and cast. + * @param consumer the consumer. + * @return this. + * @since 2.9 + */ + @SuppressWarnings("unchecked") + public JavaUtils acceptIfInstanceOf(Class type, Object value, Consumer consumer) { + if (type.isAssignableFrom(value.getClass())) { + consumer.accept((T) value); + } + return this; + } + + /** + * Invoke {@link Consumer#accept(Object)} with the value if it is not null or empty. + * + * @param value the value. + * @param consumer the consumer. + * @param the value type. + * @return this. + */ + public JavaUtils acceptIfNotEmpty(List value, Consumer> consumer) { + if (!CollectionUtils.isEmpty(value)) { + consumer.accept(value); + } + return this; + } + + /** + * Invoke {@link Consumer#accept(Object)} with the value if it is not null or empty. + * + * @param value the value. + * @param consumer the consumer. + * @param the value type. + * @return this. + */ + public JavaUtils acceptIfNotEmpty(T[] value, Consumer consumer) { + if (!ObjectUtils.isEmpty(value)) { + consumer.accept(value); + } + return this; + } + + /** + * Invoke {@link BiConsumer#accept(Object, Object)} with the arguments if the + * condition is true. + * + * @param condition the condition. + * @param t1 the first consumer argument + * @param t2 the second consumer argument + * @param consumer the consumer. + * @param the first argument type. + * @param the second argument type. + * @return this. + */ + public JavaUtils acceptIfCondition(boolean condition, T1 t1, T2 t2, BiConsumer consumer) { + if (condition) { + consumer.accept(t1, t2); + } + return this; + } + + /** + * Invoke {@link BiConsumer#accept(Object, Object)} with the arguments if the t2 + * argument is not null. + * + * @param t1 the first argument + * @param t2 the second consumer argument + * @param consumer the consumer. + * @param the first argument type. + * @param the second argument type. + * @return this. + */ + public JavaUtils acceptIfNotNull(T1 t1, T2 t2, BiConsumer consumer) { + if (t2 != null) { + consumer.accept(t1, t2); + } + return this; + } + + /** + * Invoke {@link BiConsumer#accept(Object, Object)} with the arguments if the value + * argument is not null or empty. + * + * @param t1 the first consumer argument. + * @param value the second consumer argument + * @param the first argument type. + * @param consumer the consumer. + * @return this. + */ + public JavaUtils acceptIfHasText(T t1, String value, BiConsumer consumer) { + if (StringUtils.hasText(value)) { + consumer.accept(t1, value); + } + return this; + } +} + diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/MessageConverter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/MessageConverter.java new file mode 100644 index 00000000..d04340d8 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/MessageConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support; + +/** + * @author Soby Chacko + */ +public interface MessageConverter { +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessageConverter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessageConverter.java new file mode 100644 index 00000000..b93ee670 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessageConverter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support.converter; + +import java.lang.reflect.Type; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Messages; + +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.pulsar.support.MessageConverter; + +/** + * @author Soby Chacko + */ +public interface PulsarBatchMessageConverter extends MessageConverter { + + @NonNull + Message toMessage(Messages records, Consumer consumer, Type payloadType); + + T fromMessage(Messages message, String defaultTopic); + + @Nullable + default PulsarRecordMessageConverter getRecordMessageConverter() { + return null; + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessagingMessageConverter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessagingMessageConverter.java new file mode 100644 index 00000000..22430d29 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarBatchMessagingMessageConverter.java @@ -0,0 +1,93 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support.converter; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Messages; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.pulsar.support.converter.PulsarBatchMessageConverter; +import org.springframework.pulsar.support.converter.PulsarRecordMessageConverter; + +/** + * @author Soby Chacko + */ +public class PulsarBatchMessagingMessageConverter implements PulsarBatchMessageConverter { + + private final PulsarRecordMessageConverter recordConverter; + + + public PulsarBatchMessagingMessageConverter() { + this(null); + } + + public PulsarBatchMessagingMessageConverter(PulsarRecordMessageConverter recordConverter) { + this.recordConverter = recordConverter; + } + + @Override + public Message toMessage(Messages records, Consumer consumer, Type type) { + List payloads = new ArrayList<>(); + List conversionFailures = new ArrayList<>(); + for (org.apache.pulsar.client.api.Message message : records) { + payloads.add(obtainPayload(type, message, conversionFailures)); + } + + return MessageBuilder.createMessage(payloads, new MessageHeaders(Collections.emptyMap())); + } + + private Object obtainPayload(Type type, org.apache.pulsar.client.api.Message record, List conversionFailures) { + return this.recordConverter == null || !containerType(type) + ? extractAndConvertValue(record, type) + : convert(record, type, conversionFailures); + } + + private boolean containerType(Type type) { + return type instanceof ParameterizedType + && ((ParameterizedType) type).getActualTypeArguments().length == 1; + } + + protected Object extractAndConvertValue(org.apache.pulsar.client.api.Message record, Type type) { + return record.getValue(); + } + + protected Object convert(org.apache.pulsar.client.api.Message record, Type type, List conversionFailures) { + try { + Object payload = this.recordConverter + .toMessage(record, null, ((ParameterizedType) type).getActualTypeArguments()[0]).getPayload(); + conversionFailures.add(null); + return payload; + } + catch (Exception ex) { + throw new RuntimeException("The batch converter can only report conversion failures to the listener " + + "if the record.value() is byte[], Bytes, or String", ex); + } + } + + @Override + public T fromMessage(Messages message, String defaultTopic) { + throw new UnsupportedOperationException(); + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarMessagingMessageConverter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarMessagingMessageConverter.java new file mode 100644 index 00000000..f3cac499 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarMessagingMessageConverter.java @@ -0,0 +1,79 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support.converter; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collections; + +import org.apache.pulsar.client.api.Consumer; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Soby Chacko + */ +public class PulsarMessagingMessageConverter implements PulsarRecordMessageConverter { + + private SmartMessageConverter messagingConverter; + + @Override + public Message toMessage(org.apache.pulsar.client.api.Message record, Consumer consumer, Type type) { + + Message message = MessageBuilder.createMessage(extractAndConvertValue(record, type), new MessageHeaders(Collections.emptyMap())); + if (this.messagingConverter != null) { + Class clazz = type instanceof Class ? (Class) type : type instanceof ParameterizedType + ? (Class) ((ParameterizedType) type).getRawType() : Object.class; + Object payload = this.messagingConverter.fromMessage(message, clazz, type); + if (payload != null) { + message = new GenericMessage<>(payload, message.getHeaders()); + } + } + return message; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public V fromMessage(Message messageArg, String defaultTopic) { + Message message = messageArg; + if (this.messagingConverter != null) { + Message converted = this.messagingConverter.toMessage(message.getPayload(), message.getHeaders()); + if (converted != null) { + message = converted; + } + } + return null; //TODO + } + + + + protected org.springframework.messaging.converter.MessageConverter getMessagingConverter() { + return this.messagingConverter; + } + + public void setMessagingConverter(SmartMessageConverter messagingConverter) { + this.messagingConverter = messagingConverter; + } + + protected Object extractAndConvertValue(org.apache.pulsar.client.api.Message record, Type type) { + return record.getValue(); + } +} diff --git a/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarRecordMessageConverter.java b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarRecordMessageConverter.java new file mode 100644 index 00000000..dcdbf3e5 --- /dev/null +++ b/spring-pulsar/src/main/java/org/springframework/pulsar/support/converter/PulsarRecordMessageConverter.java @@ -0,0 +1,39 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.support.converter; + +import java.lang.reflect.Type; + +import org.apache.pulsar.client.api.Consumer; + +import org.springframework.lang.NonNull; +import org.springframework.messaging.Message; +import org.springframework.pulsar.support.MessageConverter; + +/** + * @author Soby Chacko + */ +public interface PulsarRecordMessageConverter extends MessageConverter { + + + @NonNull + Message toMessage(org.apache.pulsar.client.api.Message record, Consumer consumer, + Type payloadType); + + T fromMessage(Message message, String defaultTopic); + +} diff --git a/spring-pulsar/src/main/resources/META-INF/spring.factories b/spring-pulsar/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..3f09129c --- /dev/null +++ b/spring-pulsar/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration:\ +org.springframework.pulsar.autoconfig.PulsarAutoConfiguration \ No newline at end of file diff --git a/spring-pulsar/src/test/java/org/springframework/pulsar/core/AbstractContainerBaseTest.java b/spring-pulsar/src/test/java/org/springframework/pulsar/core/AbstractContainerBaseTest.java new file mode 100644 index 00000000..9ae3c65d --- /dev/null +++ b/spring-pulsar/src/test/java/org/springframework/pulsar/core/AbstractContainerBaseTest.java @@ -0,0 +1,25 @@ +package org.springframework.pulsar.core; + +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.utility.DockerImageName; + +abstract class AbstractContainerBaseTest { + + static final DockerImageName PULSAR_IMAGE = DockerImageName.parse("apachepulsar/pulsar:2.10.0"); + + static PulsarContainer PULSAR_CONTAINER; + + static { + PULSAR_CONTAINER = new PulsarContainer(PULSAR_IMAGE); + PULSAR_CONTAINER.start(); + } + + protected static String getPulsarBrokerUrl() { + return PULSAR_CONTAINER.getPulsarBrokerUrl(); + } + + protected static String getHttpServiceUrl() { + return PULSAR_CONTAINER.getHttpServiceUrl(); + } +} + diff --git a/spring-pulsar/src/test/java/org/springframework/pulsar/core/DefaultConsumerTests.java b/spring-pulsar/src/test/java/org/springframework/pulsar/core/DefaultConsumerTests.java new file mode 100644 index 00000000..1e5bbff6 --- /dev/null +++ b/spring-pulsar/src/test/java/org/springframework/pulsar/core/DefaultConsumerTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.MessageListener; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.pulsar.listener.DefaultPulsarMessageListenerContainer; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +/** + * @author Soby Chacko + */ +public class DefaultConsumerTests extends AbstractContainerBaseTest { + + + @Test + public void testDefaultConsumer() throws Exception { +// try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE)) { +// pulsar.start(); + Map config = new HashMap<>(); + final HashSet strings = new HashSet(); + strings.add("foobar-012"); + config.put("topicNames", strings); + config.put("subscriptionName", "foobar-sb-012"); + final PulsarClient pulsarClient = PulsarClient.builder() + .serviceUrl(getPulsarBrokerUrl()) + .build(); + final DefaultPulsarConsumerFactory pulsarConsumerFactory = new DefaultPulsarConsumerFactory<>(pulsarClient, config); + CountDownLatch latch = new CountDownLatch(1); + PulsarContainerProperties pulsarContainerProperties = new PulsarContainerProperties(); + pulsarContainerProperties.setMessageListener( + (MessageListener) (consumer, msg) -> latch.countDown()); + pulsarContainerProperties.setSchema(Schema.STRING); + DefaultPulsarMessageListenerContainer container = new DefaultPulsarMessageListenerContainer<>( + pulsarConsumerFactory, pulsarContainerProperties); + container.start(); + Map prodConfig = new HashMap<>(); + prodConfig.put("topicName", "foobar-012"); + final DefaultPulsarProducerFactory pulsarProducerFactory = new DefaultPulsarProducerFactory<>(pulsarClient, prodConfig); + final PulsarTemplate pulsarTemplate = new PulsarTemplate<>(pulsarProducerFactory); + final CompletableFuture future = pulsarTemplate.sendAsync("hello john doe"); + latch.await(10, TimeUnit.SECONDS); + pulsarClient.close(); +// } + } + + + + +} diff --git a/spring-pulsar/src/test/java/org/springframework/pulsar/core/FailoverConsumerTests.java b/spring-pulsar/src/test/java/org/springframework/pulsar/core/FailoverConsumerTests.java new file mode 100644 index 00000000..841a266a --- /dev/null +++ b/spring-pulsar/src/test/java/org/springframework/pulsar/core/FailoverConsumerTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageListener; +import org.apache.pulsar.client.api.MessageRouter; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.client.api.TopicMetadata; +import org.junit.jupiter.api.Test; + +import org.springframework.pulsar.listener.DefaultPulsarMessageListenerContainer; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * @author Soby Chacko + */ +public class FailoverConsumerTests extends AbstractContainerBaseTest { + + @Test + public void testFailOverConsumersOnPartitionedTopic() throws Exception { + PulsarAdmin admin = PulsarAdmin.builder() + .serviceHttpUrl(getHttpServiceUrl()) + .build(); + + String topicName = "persistent://public/default/my-part-topic-1"; + int numPartitions = 3; + admin.topics().createPartitionedTopic(topicName, numPartitions); + + Map config = new HashMap<>(); + final HashSet topics = new HashSet<>(); + topics.add("my-part-topic-1"); + config.put("topicNames", topics); + config.put("subscriptionName", "my-part-subscription-1"); + final PulsarClient pulsarClient = PulsarClient.builder() + .serviceUrl(getPulsarBrokerUrl()) + .build(); + final DefaultPulsarConsumerFactory pulsarConsumerFactory = new DefaultPulsarConsumerFactory<>(pulsarClient, config); + CountDownLatch latch = new CountDownLatch(3); + PulsarContainerProperties pulsarContainerProperties = new PulsarContainerProperties(); + pulsarContainerProperties.setMessageListener(new MessageListener() { + @Override + public void received(Consumer consumer, Message msg) { + latch.countDown(); + } + }); + pulsarContainerProperties.setSubscriptionType(SubscriptionType.Failover); + pulsarContainerProperties.setSchema(Schema.STRING); + DefaultPulsarMessageListenerContainer container = new DefaultPulsarMessageListenerContainer( + pulsarConsumerFactory, pulsarContainerProperties); + container.start(); + DefaultPulsarMessageListenerContainer container1 = new DefaultPulsarMessageListenerContainer( + pulsarConsumerFactory, pulsarContainerProperties); + container1.start(); + DefaultPulsarMessageListenerContainer container2 = new DefaultPulsarMessageListenerContainer( + pulsarConsumerFactory, pulsarContainerProperties); + container2.start(); + Map prodConfig = new HashMap<>(); + prodConfig.put("topicName", "my-part-topic-1"); + prodConfig.put("messageRoutingMode", MessageRoutingMode.CustomPartition); + final DefaultPulsarProducerFactory pulsarProducerFactory = new DefaultPulsarProducerFactory<>(pulsarClient, prodConfig); + final PulsarTemplate pulsarTemplate = new PulsarTemplate<>(pulsarProducerFactory); + + pulsarTemplate.sendAsync("hello john doe", new FooRouter()); + pulsarTemplate.sendAsync("hello alice doe", new BarRouter()); + pulsarTemplate.sendAsync("hello buzz doe", new BuzzRouter()); + final boolean await = latch.await(10, TimeUnit.SECONDS); + assertThat(await).isTrue(); + } + + + static class FooRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 0; + } + } + + static class BarRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 1; + } + } + + static class BuzzRouter implements MessageRouter { + + @Override + public int choosePartition(Message msg, TopicMetadata metadata) { + return 2; + } + } +} diff --git a/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarListenerTests.java b/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarListenerTests.java new file mode 100644 index 00000000..ddc488c8 --- /dev/null +++ b/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarListenerTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.pulsar.annotation.PulsarListener; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * @author Soby Chacko + */ +public class PulsarListenerTests extends AbstractContainerBaseTest { + + static CountDownLatch latch1 = new CountDownLatch(1); + static CountDownLatch latch2 = new CountDownLatch(10); + + @Test + void testBasicListener() throws Exception { + SpringApplication app = new SpringApplication(BasicListenerConfig.class); + app.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = app.run("--spring.pulsar.client.serviceUrl=" + getPulsarBrokerUrl())) { + @SuppressWarnings("unchecked") + final PulsarTemplate pulsarTemplate = context.getBean(PulsarTemplate.class); + pulsarTemplate.setDefaultTopicName("hello-pulsar-exclusive"); + pulsarTemplate.send("John Doe"); + System.out.println("waiting at the latch"); + final boolean await = latch1.await(20, TimeUnit.SECONDS); + assertThat(await).isTrue(); + } + } + + @Test + void testBatchListener() throws Exception { + SpringApplication app = new SpringApplication(BatchListenerConfig.class); + app.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = app.run("--spring.pulsar.client.serviceUrl=" + getPulsarBrokerUrl())) { + @SuppressWarnings("unchecked") + final PulsarTemplate pulsarTemplate = context.getBean(PulsarTemplate.class); + pulsarTemplate.setDefaultTopicName("hello-pulsar-exclusive"); + for (int i = 0; i < 10; i++) { + pulsarTemplate.send("John Doe"); + } + final boolean await = latch2.await(10, TimeUnit.SECONDS); + assertThat(await).isTrue(); + } + } + + @EnableAutoConfiguration + public static class BasicListenerConfig { + + @PulsarListener(subscriptionName = "test-exclusive-sub-1", topics = "hello-pulsar-exclusive") + public void listen(String foo) { + System.out.println("Message Received from basic: " + foo); + latch1.countDown(); + } + } + + @EnableAutoConfiguration + public static class BatchListenerConfig { + + @PulsarListener(subscriptionName = "test-exclusive-sub-2", topics = "hello-pulsar-exclusive", batch = "true") + public void listen(List foo) { + System.out.println("Message Received from batch: " + foo); + System.out.println("Message Received from batch: " + foo.size()); + foo.forEach(t -> latch2.countDown()); + } + } +} diff --git a/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarTemplateTests.java b/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarTemplateTests.java new file mode 100644 index 00000000..3823d3f4 --- /dev/null +++ b/spring-pulsar/src/test/java/org/springframework/pulsar/core/PulsarTemplateTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.pulsar.core; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * @author Soby Chacko + */ +public class PulsarTemplateTests extends AbstractContainerBaseTest { + + public static final String TEST_TOPIC = "test_topic"; + + @Test + public void testUsage() throws Exception { + testPulsarFunctionality(getPulsarBrokerUrl()); + } + + @Test + public void testSendAsync() throws Exception { + Map config = new HashMap<>(); + config.put("topicName", "foo-bar-123"); + Map clientConfig = new HashMap<>(); + clientConfig.put("serviceUrl", getPulsarBrokerUrl()); + try ( + PulsarClient client = PulsarClient.builder() + .loadConf(clientConfig) + .build(); + Consumer consumer = client.newConsumer(Schema.STRING) + .topic("foo-bar-123") + .subscriptionName("xyz-test-subs-123") + .subscribe() + ) { + final DefaultPulsarProducerFactory pulsarProducerFactory = new DefaultPulsarProducerFactory<>(client, config); + final PulsarTemplate pulsarTemplate = new PulsarTemplate<>(pulsarProducerFactory); + final CompletableFuture future = pulsarTemplate.sendAsync("hello john doe"); + future.thenAccept(m -> System.out.println("Got " + m)); + try { + Thread.sleep(2000); + final MessageId messageId = future.get(); + System.out.println(); + } + catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + CompletableFuture> future0 = consumer.receiveAsync(); + Message message = future0.get(5, TimeUnit.SECONDS); + assertThat(new String(message.getData())) + .isEqualTo("hello john doe"); + } + } + + @Test + public void testSendSync() throws Exception { + Map config = new HashMap<>(); + config.put("topicName", "foo-bar-123"); + Map clientConfig = new HashMap<>(); + clientConfig.put("serviceUrl", getPulsarBrokerUrl()); + try ( + PulsarClient client = PulsarClient.builder() + .loadConf(clientConfig) + .build(); + Consumer consumer = client.newConsumer(Schema.STRING) + .topic("foo-bar-123") + .subscriptionName("xyz-test-subs-123") + .subscribe(); + ) { + final DefaultPulsarProducerFactory pulsarProducerFactory = new DefaultPulsarProducerFactory<>(client, config); + final PulsarTemplate pulsarTemplate = new PulsarTemplate<>(pulsarProducerFactory); + final MessageId messageId = pulsarTemplate.send("hello john doe"); + CompletableFuture> future0 = consumer.receiveAsync(); + Message message = future0.get(5, TimeUnit.SECONDS); + assertThat(new String(message.getData())) + .isEqualTo("hello john doe"); + } + } + + private void testPulsarFunctionality(String pulsarBrokerUrl) throws Exception { + try ( + PulsarClient client = PulsarClient.builder() + .serviceUrl(pulsarBrokerUrl) + .build(); + Consumer consumer = client.newConsumer() + .topic(TEST_TOPIC) + .subscriptionName("test-subs") + .subscribe(); + Producer producer = client.newProducer() + .topic(TEST_TOPIC) + .create() + ) { + producer.send("test containers".getBytes()); + CompletableFuture future = consumer.receiveAsync(); + Message message = future.get(5, TimeUnit.SECONDS); + + assertThat(new String(message.getData())) + .isEqualTo("test containers"); + } + } +}