NATS
This project includes integration between Micronaut and nats.io. The standard Java Client is used to do the actual publishing and consuming.
For this project, you can find a list of releases (with release notes) here:
3 Using the Micronaut CLI
To create a project with NATS support using the Micronaut CLI, supply the nats feature to the features flag.
$ mn create-app my-nats-app --features nats
This will create a project with the minimum necessary configuration for NATS.
Messaging Application
The Micronaut CLI can generate messaging applications. This will create a Micronaut app with NATS support, and without an HTTP server (although you can add one if you desire). The profile also provides a couple commands for generating NATS consumers and producers.
To create a NATS messaging application, use the the following command:
$ mn create-messaging-app my-nats-service --features nats
As you’d expect, you can start the application with ./gradlew run (for Gradle) or ./mvnw compile exec:exec (Maven). The application will (with the default config) attempt to connect to NATS at nats://localhost:4222, and will continue to run without starting up an HTTP server. All communication to/from the service will take place via NATS producers and/or consumers.
Within the new project, you can now run the NATS specific code generation commands:
$ mn create-nats-producer Message
| Rendered template Producer.java to destination src/main/java/my/nats/app/MessageProducer.java
$ mn create-nats-listener Message
| Rendered template Listener.java to destination src/main/java/my/nats/app/MessageListener.java
To add support for NATS.io to an existing project, you should first add the Micronaut NATS configuration to your build configuration. For example:
implementation ("io.micronaut.nats:micronaut-nats" )<dependency >
<groupId >io.micronaut.nats</groupId >
<artifactId >micronaut-nats</artifactId >
</dependency >
Creating a NATS Producer with @NatsClient
To create a NATS Producer that sends messages you can simply define an interface that is annotated with @NatsClient .
For example the following is a trivial @NatsClient interface:
imports import io.micronaut.nats.annotation.Subject;
import io.micronaut.nats.annotation.NatsClient;@NatsClient //
public interface ProductClient {
@Subject ("product" ) //
void send (byte [] data ); //
} imports from micronaut.nats.annotation import NatsClient, Subject@NatsClient #
class ProductClient (ABC ):
@Subject ("product" ) #
@abstractmethod
def send (self, data: bytes ) -> None : #
... imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject @NatsClient //
interface ProductClient {
@Subject ("product" ) //
fun send (data : ByteArray ) //
} imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject@NatsClient //
interface ProductClient {
@Subject ("product" ) //
void send (byte [] data ) //
}
At compile time Micronaut will produce an implementation of the above interface. You can retrieve an instance of ProductClient either by looking up the bean from the ApplicationContext or by injecting the bean with @Inject:
productClient.send ("quickstart" .getBytes ()); self .product_client.send("quickstart" .encode())productClient.send ("quickstart" .toByteArray ()) productClient. send("quickstart" . bytes)
Creating a NATS Consumer with @NatsListener
To listen to NATS messages you can use the @NatsListener annotation to define a message listener.
The following example will listen for messages published by the ProductClient in the previous section:
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener //
public class ProductListener {
List< String > messageLengths = Collections.synchronizedList (new ArrayList<>());
@Subject ("product" ) //
public void receive (byte [] data ) { //
messageLengths.add (new String (data));
System.out.println ("Java received " + data.length + " bytes from Nats" );
}
} imports from micronaut.nats.annotation import NatsListener, Subject@NatsListener #
class ProductListener :
def __init__ (self):
self .message_lengths: list[str ] = []
@Subject ("product" ) #
def receive (self, data: bytes ) -> None : #
self .message_lengths.append(bytes (data).decode())
print (f "Python received { len (data)} bytes from Nats" )imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.Collections @NatsListener //
class ProductListener {
val messageLengths: MutableList <String > = Collections.synchronizedList (ArrayList ())
@Subject ("product" ) //
fun receive (data : ByteArray ) { //
val string = String (data )
messageLengths.add (string)
println ("Kotlin received ${ data .size} bytes from Nats: ${string}" )
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.concurrent.CopyOnWriteArrayList@NatsListener //
class ProductListener {
CopyOnWriteArrayList<String> messageLengths = []
@Subject ("product" ) //
void receive (byte [] data ) { //
messageLengths << new String (data)
}
}
5 Configuring the connection
The properties that can be converted from the string values in a configuration file can be configured directly.
Note
Without any configuration the defaults in the Options will be used.
Tip
It is also possible to disable the integration entirely with nats.enabled: false
Connections
It is possible to configure multiple connections to the same server, different servers, or a single connection to one of a list of servers.
nats.server1.addresses[0] =nats://localhost:4222
nats.server1.username =guest
nats.server1.password =guest
nats.server2.addresses[0] =nats://randomServer:4222
nats.server2.username =guest
nats.server2.password =guestnats :
server1 :
addresses :
- "nats://localhost:4222"
username : guest
password : guest
server2 :
addresses :
- "nats://randomServer:4222"
username : guest
password : guest [nats .server1 ]
addresses = [ "nats://localhost:4222" ]
username = "guest"
password = "guest"
[nats .server2 ]
addresses = [ "nats://randomServer:4222" ]
username = "guest"
password = "guest" nats {
server1 {
addresses = ['nats://localhost:4222' ]
username = 'guest'
password = 'guest'
}
server2 {
addresses = ['nats://randomServer:4222' ]
username = 'guest'
password = 'guest'
}
} {
nats = {
server1 = {
addresses = ["nats://localhost:4222"]
username = "guest"
password = "guest"
}
server2 = {
addresses = ["nats://randomServer:4222"]
username = "guest"
password = "guest"
}
}
} {
"nats" : {
"server1" : {
"addresses" : [
"nats://localhost:4222"
],
"username" : "guest" ,
"password" : "guest"
},
"server2" : {
"addresses" : [
"nats://randomServer:4222"
],
"username" : "guest" ,
"password" : "guest"
}
}
}
NATS also supports a fail over connection strategy where the first server that connects successfully will be used among a list of servers. To use this option in Micronaut, simply supply a list of host:port addresses.
nats.default.addresses[0] =nats://localhost:4222
nats.default.addresses[1] =nats://randomServer:4222
nats.default.username =guest
nats.default.password =guestnats :
default :
addresses :
- "nats://localhost:4222"
- "nats://randomServer:4222"
username : guest
password : guest [nats .default ]
addresses = [ "nats://localhost:4222" , "nats://randomServer:4222" ]
username = "guest"
password = "guest" nats {
'default' {
addresses = ['nats://localhost:4222' , 'nats://randomServer:4222' ]
username = 'guest'
password = 'guest'
}
} {
nats = {
default = {
addresses = ["nats://localhost:4222", "nats://randomServer:4222"]
username = "guest"
password = "guest"
}
}
} {
"nats" : {
"default" : {
"addresses" : [
"nats://localhost:4222" ,
"nats://randomServer:4222"
],
"username" : "guest" ,
"password" : "guest"
}
}
}
Important
When the configuration option nats.servers is used, no other options underneath nats are read; for example nats.username.
If you need to setup TLS, it can be configured this way:
nats.default.addresses[0] =nats://localhost:4222
nats.default.tls.trust-store-path =/path/to/client.truststore.jks
nats.default.tls.trust-store-password =secret
nats.default.tls.certificate-path =/path/to/certificate.crtnats :
default :
addresses :
- "nats://localhost:4222" #
tls :
trust-store-path : /path/to/client.truststore.jks #
trust-store-password : secret
certificate-path : /path/to/certificate.crt # [nats .default ]
addresses = [ "nats://localhost:4222" ]
[nats .default .tls ]
trust-store-path = "/path/to/client.truststore.jks"
trust-store-password = "secret"
certificate-path = "/path/to/certificate.crt" nats {
'default' {
addresses = ['nats://localhost:4222' ]
tls {
trustStorePath = '/path/to/client.truststore.jks'
trustStorePassword = 'secret'
certificatePath = '/path/to/certificate.crt'
}
}
} {
nats = {
default = {
addresses = ["nats://localhost:4222"]
tls = {
trust-store-path = "/path/to/client.truststore.jks"
trust-store-password = "secret"
certificate-path = "/path/to/certificate.crt"
}
}
}
} {
"nats" : {
"default" : {
"addresses" : [
"nats://localhost:4222"
],
"tls" : {
"trust-store-path" : "/path/to/client.truststore.jks" ,
"trust-store-password" : "secret" ,
"certificate-path" : "/path/to/certificate.crt"
}
}
}
}
If the server requires a client certificate (mutual TLS), configure a key store holding the client certificate and its private key in addition:
nats.default.addresses[0] =tls://localhost:4222
nats.default.tls.trust-store-path =/path/to/client.truststore.jks
nats.default.tls.trust-store-password =secret
nats.default.tls.key-store-path =/path/to/client.p12
nats.default.tls.key-store-password =secret
nats.default.tls.key-store-type =PKCS12nats :
default :
addresses :
- "tls://localhost:4222"
tls :
trust-store-path : /path/to/client.truststore.jks
trust-store-password : secret
key-store-path : /path/to/client.p12 #
key-store-password : secret #
key-store-type : PKCS12 # [nats .default ]
addresses = [ "tls://localhost:4222" ]
[nats .default .tls ]
trust-store-path = "/path/to/client.truststore.jks"
trust-store-password = "secret"
key-store-path = "/path/to/client.p12"
key-store-password = "secret"
key-store-type = "PKCS12" nats {
'default' {
addresses = ['tls://localhost:4222' ]
tls {
trustStorePath = '/path/to/client.truststore.jks'
trustStorePassword = 'secret'
keyStorePath = '/path/to/client.p12'
keyStorePassword = 'secret'
keyStoreType = 'PKCS12'
}
}
} {
nats = {
default = {
addresses = ["tls://localhost:4222"]
tls = {
trust-store-path = "/path/to/client.truststore.jks"
trust-store-password = "secret"
key-store-path = "/path/to/client.p12"
key-store-password = "secret"
key-store-type = "PKCS12"
}
}
}
} {
"nats" : {
"default" : {
"addresses" : [
"tls://localhost:4222"
],
"tls" : {
"trust-store-path" : "/path/to/client.truststore.jks" ,
"trust-store-password" : "secret" ,
"key-store-path" : "/path/to/client.p12" ,
"key-store-password" : "secret" ,
"key-store-type" : "PKCS12"
}
}
}
}
Further connection options
Besides the options above, all options of the Options.Builder which can be converted from a configuration value can be set directly underneath the connection. Options without an argument, such as noRandomize(), are enabled by setting them to true.
nats.default.addresses[0] =nats://localhost:4222
nats.default.max-pings-out =5
nats.default.no-randomize =true
nats.default.request-cleanup-interval =10s
nats.default.reconnect-jitter =200ms
nats.default.max-messages-in-outgoing-queue =10000
nats.default.discard-messages-when-outgoing-queue-full =truenats :
default :
addresses :
- "nats://localhost:4222"
max-pings-out : 5
no-randomize : true
request-cleanup-interval : 10s
reconnect-jitter : 200ms
max-messages-in-outgoing-queue : 10000
discard-messages-when-outgoing-queue-full : true [nats .default ]
addresses = [ "nats://localhost:4222" ]
max-pings-out = 5
no-randomize = true
request-cleanup-interval = "10s"
reconnect-jitter = "200ms"
max-messages-in-outgoing-queue = 10000
discard-messages-when-outgoing-queue-full = true nats {
'default' {
addresses = ['nats://localhost:4222' ]
maxPingsOut = 5
noRandomize = true
requestCleanupInterval = '10s'
reconnectJitter = '200ms'
maxMessagesInOutgoingQueue = 10000
discardMessagesWhenOutgoingQueueFull = true
}
} {
nats = {
default = {
addresses = ["nats://localhost:4222"]
max-pings-out = 5
no-randomize = true
request-cleanup-interval = "10s"
reconnect-jitter = "200ms"
max-messages-in-outgoing-queue = 10000
discard-messages-when-outgoing-queue-full = true
}
}
} {
"nats" : {
"default" : {
"addresses" : [
"nats://localhost:4222"
],
"max-pings-out" : 5 ,
"no-randomize" : true ,
"request-cleanup-interval" : "10s" ,
"reconnect-jitter" : "200ms" ,
"max-messages-in-outgoing-queue" : 10000 ,
"discard-messages-when-outgoing-queue-full" : true
}
}
}
Note
Options which require objects at runtime (for example executors, listeners or handlers) can only be set through a BeanCreatedEventListener for the io.micronaut.nats.connect.NatsConnectionFactoryConfig, using getBuilder().
The example in the quick start presented a trivial definition of an interface that be implemented automatically for you using the @NatsClient annotation.
The implementation that powers @NatsClient (defined by the NatsIntroductionAdvice class) is, however, very flexible and offers a range of options for defining NATS clients.
6.1 Defining @NatsClient Methods
All methods that publish messages to NATS must meet the following conditions:
The method must reside in an interface annotated with @NatsClient .
The method or a method parameter must be annotated with @Subject .
The method must contain an argument representing the body of the message.
Important
If a body argument cannot be found, an exception will be thrown.
Note
In order for all of the functionality to work as designed in this guide your classes must be compiled with the parameters flag set to true. If your application was created with the Micronaut CLI, then that has already been configured for you.
Important
Unless a reactive type is returned from the publishing method, the action is blocking.
6.1.1 Publishing Parameters
All options are available to be set for publishing messages. The publish method is used by the NatsIntroductionAdvice to publish messages and all arguments can be set through annotations or method arguments.
If you need to specify the subject of the message, apply the @Subject annotation to the method or an argument of the method. Apply the annotation to the method itself if the value is static for every execution. Apply the annotation to an argument of the method if the value should be set per execution.
imports import io.micronaut.nats.annotation.NatsClient;
import io.micronaut.nats.annotation.Subject;@NatsClient
public interface ProductClient {
@Subject ("product" ) //
void send (byte [] data );
void send (@Subject String subject , byte [] data ); //
} imports from micronaut.nats.annotation import NatsClient, Subject@NatsClient
class ProductClient (ABC ):
@Subject ("product" ) #
@abstractmethod
def send (self, data: bytes ) -> None :
...
@abstractmethod
def send_to_subject (self, subject: Annotated[str , Subject], data: bytes ) -> None : #
... imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject @NatsClient
interface ProductClient {
@Subject ("product" ) //
fun send (data : ByteArray )
fun send (@Subject subject: String , data :ByteArray ) //
} imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject@NatsClient
interface ProductClient {
@Subject ("product" ) //
void send (byte [] data )
void send (@Subject String subject , byte [] data ) //
}
Producer Connection
If multiple Nats servers have been configured, the name of the server can be set in the @Subject annotation to designate which connection should be used to publish messages.
imports import io.micronaut.nats.annotation.NatsClient;
import io.micronaut.nats.annotation.Subject;@NatsClient
public interface ProductClient {
@Subject (value = "product" , connection = "product-cluster" ) //
void send (byte [] data );
} imports from micronaut.nats.annotation import NatsClient, Subject@NatsClient
class ProductClient (ABC ):
@Subject (value = "product" , connection = "product-cluster" ) #
@abstractmethod
def send (self, data: bytes ) -> None :
... imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject @NatsClient
interface ProductClient {
@Subject (value = "product" , connection = "product-cluster" ) //
fun send (data : ByteArray )
} imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject@NatsClient
interface ProductClient {
@Subject (value = "product" , connection = "product-cluster" ) //
void send (byte [] data )
}
Note
The connection option is also available to be set on the @NatsClient annotation.
Queues
Tip
The NATS server will route the message to the queue and select a message receiver.
Headers can be set on the message with the @MessageHeader annotation applied to the method or an argument of the method. Apply the annotation to the method itself if the value is static for every execution. Apply the annotation to an argument of the method if the value should be set per execution.
imports import io.micronaut.messaging.annotation.MessageBody;
import io.micronaut.messaging.annotation.MessageHeader;
import io.micronaut.nats.annotation.NatsClient;
import io.micronaut.nats.annotation.Subject;
import io.nats.client.impl.Headers;@NatsClient
@MessageHeader (name = "x-product-sealed" , value = "true" ) //
@MessageHeader (name = "productSize" , value = "large" )
public interface ProductClient {
@Subject ("product" )
@MessageHeader (name = "x-product-count" , value = "10" ) //
@MessageHeader (name = "productSize" , value = "small" )
void send (byte [] data );
@Subject ("product" )
void send (@MessageHeader String productSize , //
@MessageHeader ("x-product-count" ) Long count ,
byte [] data );
@Subject ("products" )
@MessageHeader (name = "x-product-count" , value = "20" )
void send (@MessageBody byte [] data , @MessageHeader List<String > productSizes );//
@Subject ("productHeader" )
void send (@MessageBody byte [] data , Headers headers );//
} imports from micronaut.messaging.annotation import MessageBody, MessageHeader
from micronaut.nats.annotation import NatsClient, Subject
from io.nats.client.impl import Headers@NatsClient
@MessageHeader (name = "x-product-sealed" , value = "true" ) #
@MessageHeader (name = "productSize" , value = "large" )
class ProductClient (ABC ):
@Subject ("product" )
@MessageHeader (name = "x-product-count" , value = "10" ) #
@MessageHeader (name = "productSize" , value = "small" )
@abstractmethod
def send (self, data: bytes ) -> None :
...
@Subject ("product" )
@abstractmethod
def send_with_headers (self, product_size: Annotated[str | None , MessageHeader("productSize" )], #
count: Annotated[Long, MessageHeader("x-product-count" )],
data: bytes ) -> None :
...
@Subject ("products" )
@MessageHeader (name = "x-product-count" , value = "20" )
@abstractmethod
def send_with_product_sizes (self, data: Annotated[bytes , MessageBody],
product_sizes: Annotated[list[str ], MessageHeader("productSizes" )]) -> None : #
...
@Subject ("productHeader" )
@abstractmethod
def send_with_nats_headers (self, data: Annotated[bytes , MessageBody], headers: Headers) -> None : #
... imports import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.messaging.annotation.MessageHeaders
import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers @NatsClient
@MessageHeaders (
MessageHeader (name = "x-product-sealed" , value = "true" ), //
MessageHeader (name = "productSize" , value = "large" )
)
interface ProductClient {
@Subject ("product" )
@MessageHeaders (
MessageHeader (name = "x-product-count" , value = "10" ), //
MessageHeader (name = "productSize" , value = "small" )
)
fun send (data : ByteArray )
@Subject ("product" )
fun send (@MessageHeader productSize: String ?, //
@MessageHeader ("x-product-count" ) count: Long ,
data : ByteArray )
@Subject ("products" )
@MessageHeader (name = "x-product-count" , value = "20" )
fun send (@MessageBody data :ByteArray , @MessageHeader productSizes: List <String >) //
@Subject ("productHeader" )
fun send (@MessageBody data : ByteArray , headers: Headers ) //
} imports import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers@NatsClient
@MessageHeader (name = "x-product-sealed" , value = "true" ) //
@MessageHeader (name = "productSize" , value = "large" )
interface ProductClient {
@Subject ("product" )
@MessageHeader (name = "x-product-count" , value = "10" ) //
@MessageHeader (name = "productSize" , value = "small" )
void send (byte [] data )
@Subject ("product" )
void send (@MessageHeader String productSize , //
@MessageHeader ("x-product-count" ) Long count ,
byte [] data )
@Subject ("products" )
@MessageHeader (name = "x-product-count" , value = "20" )
void send (@MessageBody byte [] data , @MessageHeader List<String> productSizes ) //
@Subject ("productHeader" )
void send (@MessageBody byte [] data , Headers headers ) //
}
Note
Python methods cannot be overloaded, so the Python client declares one method per signature (send, send_with_headers, send_with_product_sizes, send_with_nats_headers).
Most examples up to this point have been using a byte[] as the body type for simplicity. This library supports most standard Java types and JSON serialization (using Jackson) by default. The functionality is extensible and it is possible to add support for additional types and serialization strategies. See the section on Message Serialization/Deserialization for more information.
The quick start section presented a trivial example of what is possible with the @NatsListener annotation.
The implementation that powers @NatsListener (defined by the NatsConsumerAdvice class) is, however, very flexible and offers a range of options for consuming NATS message.
7.1 Defining @NatsListener Methods
All methods that consume messages from NATS must meet the following conditions:
Note
In order for all of the functionality to work as designed in this guide your classes must be compiled with the parameters flag set to true.
If your application was created with the Micronaut CLI, then that has already been configured for you.
7.1.1 Consumer Parameters
Important
In order for the consumer method to be invoked, all arguments must be satisfied. To allow execution of the method with a null value, the argument must be declared as nullable. If the arguments cannot be satisfied, the message will be rejected.
A @Subject annotation is required for a method to be a consumer of messages from Nats. Simply apply the annotation to the method and supply the name of the subject you would like to listen to.
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener
public class ProductListener {
List< Integer > messageLengths = Collections.synchronizedList (new ArrayList<>());
@Subject ("product" ) //
public void receive (byte [] data ) {
messageLengths.add (data.length);
}
} imports from micronaut.nats.annotation import NatsListener, Subject@NatsListener
class ProductListener :
def __init__ (self):
self .message_lengths: list[int ] = []
@Subject ("product" ) #
def receive (self, data: bytes ) -> None :
self .message_lengths.append(len (data))imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.Collections @NatsListener
class ProductListener {
val messageLengths: MutableList <Int > = Collections.synchronizedList (ArrayList ())
@Subject ("product" ) //
fun receive (data : ByteArray ) {
messageLengths.add (data .size)
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.concurrent.CopyOnWriteArrayList@NatsListener
class ProductListener {
CopyOnWriteArrayList<Integer> messageLengths = []
@Subject ("product" ) //
void receive (byte [] data ) {
messageLengths << data. length
}
}
Queue Support
Subscribers may specify queue groups at subscription time. When a message is published to the group, NATS will deliver it to a one-and-only-one subscriber.
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener
public class ProductListener {
List< String > messageLengths = Collections.synchronizedList (new ArrayList<>());
@Subject (value = "product" , queue = "product-queue" ) //
public void receiveByQueue1 (byte [] data ) {
messageLengths.add (new String (data));
System.out.println ("Java received " + data.length + " bytes from Nats" );
}
@Subject (value = "product" , queue = "product-queue" )
public void receiveByQueue2 (byte [] data ) {
messageLengths.add (new String (data));
System.out.println ("Java received " + data.length + " bytes from Nats" );
}
} imports from micronaut.nats.annotation import NatsListener, Subject@NatsListener
class ProductListener :
def __init__ (self):
self .message_lengths: list[str ] = []
@Subject (value = "product" , queue = "product-queue" ) #
def receive_by_queue1 (self, data: bytes ) -> None :
self .message_lengths.append(bytes (data).decode())
print (f "Python received { len (data)} bytes from Nats" )
@Subject (value = "product" , queue = "product-queue" )
def receive_by_queue2 (self, data: bytes ) -> None :
self .message_lengths.append(bytes (data).decode())
print (f "Python received { len (data)} bytes from Nats" )imports import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers
import java.util.Collections @NatsListener
class ProductListener {
var messageLengths: MutableList <String > = Collections.synchronizedList (ArrayList ())
@Subject (value = "product" , queue = "product-queue" ) //
fun receiveByQueue1 (data : ByteArray ) {
messageLengths.add (String (data ))
}
@Subject (value = "product" , queue = "product-queue" )
fun receiveByQueue2 (data : ByteArray ) {
messageLengths.add (String (data ))
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.concurrent.CopyOnWriteArrayList@NatsListener
class ProductListener {
CopyOnWriteArrayList<String> messageLengths = []
@Subject (value = "product" , queue = "product-queue" ) //
void receiveByQueue1 (byte [] data ) {
messageLengths << new String (data)
}
@Subject (value = "product" , queue = "product-queue" )
public void receiveByQueue2 (byte [] data ) {
messageLengths << new String (data)
}
}
Important
Queue groups do not persist messages. If no listeners are available, the message is discarded.
Other Options
If multiple Nats servers have been configured, the name of the server can be set in the @Subject annotation to designate which connection should be used to listen for messages.
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener
public class ProductListener {
List< String > messageLengths = Collections.synchronizedList (new ArrayList<>());
@Subject (value = "product" , connection = "product-cluster" ) //
public void receive (byte [] data ) {
messageLengths.add (new String (data));
System.out.println ("Java received " + data.length + " bytes from Nats" );
}
} imports from micronaut.nats.annotation import NatsListener, Subject@NatsListener
class ProductListener :
def __init__ (self):
self .message_lengths: list[str ] = []
@Subject (value = "product" , connection = "product-cluster" ) #
def receive (self, data: bytes ) -> None :
self .message_lengths.append(bytes (data).decode())
print (f "Python received { len (data)} bytes from Nats" )imports import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers
import java.util.Collections @NatsListener
class ProductListener {
var messageLengths: MutableList <String > = Collections.synchronizedList (ArrayList ())
@Subject (value = "product" , connection = "product-cluster" ) //
fun receive (data : ByteArray ) {
messageLengths.add (String (data ))
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject@NatsListener
class ProductListener {
List<String> messageLengths = Collections. synchronizedList([])
@Subject (value = "product" , connection = "product-cluster" ) //
void receive (byte [] data ) {
messageLengths << new String (data)
}
}
Note
The connection option is also available to be set on the @NatsListener annotation.
Headers can be retrieved with the @MessageHeader annotation applied to the arguments of the method.
imports import io.micronaut.messaging.annotation.MessageBody;
import io.micronaut.messaging.annotation.MessageHeader;
import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;
import io.nats.client.impl.Headers;@NatsListener
public class ProductListener {
Set< String > messageProperties = Collections.synchronizedSet (new HashSet<>());
@Subject ("product" )
public void receive (byte [] data ,
@MessageHeader ("x-product-sealed" ) Boolean sealed , //
@MessageHeader ("x-product-count" ) Long count , //
@Nullable @MessageHeader String productSize ) { //
messageProperties.add (sealed + "|" + count + "|" + productSize);
}
@Subject ("products" )
public void receive (@MessageBody byte [] data , @MessageHeader ("x-product-sealed" ) Boolean sealed ,
@MessageHeader ("x-product-count" ) Long count , @MessageHeader List<String > productSizes ) { //
for (String productSize : productSizes) {
messageProperties.add (sealed + "|" + count + "|" + productSize);
}
}
@Subject ("productHeader" )
public void receive (@MessageBody byte [] data , Headers headers ) { //
String productSize = headers.get ("productSize" ).get (0 );
messageProperties.add (
headers.get ("x-product-sealed" ).get (0 ) + "|" +
headers.get ("x-product-count" ).get (0 ) + "|" +
productSize);
}
} imports from micronaut.messaging.annotation import MessageBody, MessageHeader
from micronaut.nats.annotation import NatsListener, Subject
from io.nats.client.impl import Headers@NatsListener
class ProductListener :
def __init__ (self):
self .message_properties: set[str ] = set ()
@Subject ("product" )
def receive (self, data: bytes ,
sealed: Annotated[bool , MessageHeader("x-product-sealed" )], #
count: Annotated[Long, MessageHeader("x-product-count" )], #
product_size: Annotated[str | None , MessageHeader("productSize" )]) -> None : #
self .message_properties.add(f " { str (sealed).lower()} | { count} | { product_size if product_size is not None else 'null' } " )
@Subject ("products" )
def receive_product_sizes (self, data: Annotated[bytes , MessageBody],
sealed: Annotated[bool , MessageHeader("x-product-sealed" )],
count: Annotated[Long, MessageHeader("x-product-count" )],
product_sizes: Annotated[list[str ], MessageHeader("productSizes" )]) -> None : #
for product_size in product_sizes:
self .message_properties.add(f " { str (sealed).lower()} | { count} | { product_size} " )
@Subject ("productHeader" )
def receive_headers (self, data: Annotated[bytes , MessageBody], headers: Headers) -> None : #
product_size = headers.get("productSize" ).get(0 )
self .message_properties.add(
f " { headers.get('x-product-sealed' ).get(0 )} | { headers.get('x-product-count' ).get(0 )} | { product_size} " )imports import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers
import java.util.Collections @NatsListener
class ProductListener {
var messageProperties: MutableList <String > = Collections.synchronizedList (ArrayList ())
private var datas: MutableList <ByteArray > = Collections.synchronizedList (ArrayList ())
@Subject ("product" )
fun receive (data : ByteArray ,
@MessageHeader ("x-product-sealed" ) sealed : Boolean , //
@MessageHeader ("x-product-count" ) count: Long , //
@MessageHeader productSize: String ?) { //
messageProperties.add (sealed .toString () + "|" + count + "|" + productSize)
datas.add (data )
}
@Subject ("products" )
fun receive (@MessageBody data : ByteArray ,
@MessageHeader ("x-product-sealed" ) sealed : Boolean ,
@MessageHeader ("x-product-count" ) count: Long ,
@MessageHeader productSizes: List <String >?) { //
productSizes?.forEach {
messageProperties.add ("${ sealed }|${count}|${it}" )
}
datas.add (data )
}
@Subject ("productHeader" )
fun receive (@MessageBody data : ByteArray , headers: Headers ) { //
messageProperties.add ("${headers["x-product-sealed"] !! [ 0 ]}|${headers["x-product-count"] !! [ 0 ]}|${headers["productSize"] !! [ 0 ]}" )
datas.add (data )
}
} imports import io.micronaut.core.annotation.Nullable
import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.messaging.annotation.MessageHeader
import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.impl.Headers
import java.util.concurrent.CopyOnWriteArrayList@NatsListener
class ProductListener {
CopyOnWriteArrayList<String> messageProperties = []
@Subject ("product" )
void receive (byte [] data ,
@MessageHeader ("x-product-sealed" ) Boolean sealed , //
@MessageHeader ("x-product-count" ) Long count , //
@Nullable @MessageHeader String productSize ) { //
messageProperties << sealed. toString() + "|" + count + "|" + productSize
}
@Subject ("products" )
void receive (@MessageBody byte [] data , @MessageHeader ("x-product-sealed" ) Boolean sealed ,
@MessageHeader ("x-product-count" ) Long count , @MessageHeader List<String> productSizes ) { //
productSizes. forEach {
messageProperties << sealed. toString() + "|" + count + "|" + it
}
}
@Subject ("productHeader" )
void receive (@MessageBody byte [] data , Headers headers ) { //
String productSize = headers. get("productSize" ). get(0 )
messageProperties << headers. get("x-product-sealed" ). get(0 ) + "|" +
headers. get("x-product-count" ). get(0 ) + "|" +
productSize
}
}
Note
Python methods cannot be overloaded, so the Python listener declares one method per subject (receive, receive_product_sizes, receive_headers).
Arguments can also be bound based on their type. Several types are supported by default and each type has a corresponding NatsTypeArgumentBinder . The argument binders are covered in detail in the section on Custom Parameter Binding .
There is only type that is supported for retrieving data about the Message .
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;
import io.nats.client.Connection;
import io.nats.client.Message;
import io.nats.client.Subscription;
import io.nats.client.impl.Headers;@NatsListener
public class ProductListener {
List< String > messages = Collections.synchronizedList (new ArrayList<>());
@Subject ("product" )
public void receive (byte [] data ,
Message message ,
Connection connection ,
Subscription subscription ,
Headers headers ) { //
messages.add (String.format ("subject: [%s], maxPayload: [%s], subscriptionSubject: [%s], x-productCount: [%s]" ,
message.getSubject (),
connection.getMaxPayload (), subscription.getSubject (),
headers.get ("x-product-count" ).get (0 )));
}
} imports from micronaut.nats.annotation import NatsListener, Subject
from io.nats.client import Connection, Message, Subscription
from io.nats.client.impl import Headers@NatsListener
class ProductListener :
def __init__ (self):
self .messages: list[str ] = []
@Subject ("product" )
def receive (self, data: bytes , message: Message, connection: Connection, subscription: Subscription, headers: Headers) -> None : #
self .messages.append(
f "subject: [ { message.getSubject()} ], maxPayload: [ { connection.getMaxPayload()} ], "
f "subscriptionSubject: [ { subscription.getSubject()} ], x-productCount: [ { headers.get('x-product-count' ).get(0 )} ]"
) imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.Connection
import io.nats.client.Message
import io.nats.client.Subscription
import io.nats.client.impl.Headers
import java.util.Collections @NatsListener
class ProductListener {
var messages: MutableList <String > = Collections.synchronizedList (ArrayList ())
private var datas: MutableList <ByteArray > = Collections.synchronizedList (ArrayList ())
@Subject (value = "product" )
fun receive (message: Message ,
connection: Connection ,
subscription: Subscription ,
headers: Headers ) { //
messages.add ("subject: [${message.subject}], maxPayload: [${connection.maxPayload}], subscriptionSubject: [${subscription.subject}], x-productCount: [${headers["x-product-count"] !! [ 0 ]}]" )
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.nats.client.Connection
import io.nats.client.Message
import io.nats.client.Subscription
import io.nats.client.impl.Headers
import java.util.concurrent.CopyOnWriteArrayList@NatsListener
class ProductListener {
CopyOnWriteArrayList<String> messages = []
@Subject ("product" )
void receive (byte [] data ,
Message message ,
Connection connection ,
Subscription subscription ,
Headers headers ) { //
def count = headers. get("x-product-count" ). get(0 )
messages << "subject: [ $message. subject], maxPayload: [ $connection. maxPayload], subscriptionSubject: [ $subscription. subject], x-productCount: [ $count]" . toString()
}
}
Most examples up to this point have been using a byte[] as the body type for simplicity. This library supports most standard Java types and JSON deserialization (using Jackson) by default. The functionality is extensible and it is possible to add support for additional types and deserialization strategies. See the section on Message Serialization/Deserialization for more information.
7.1.1.5 Custom Parameter Binding
Default Binding Functionality
The registry supports argument binders that are used based on an annotation applied to the argument or the argument type. All argument binders must implement either NatsAnnotatedArgumentBinder or NatsTypeArgumentBinder . The exception to that rule is the NatsDefaultBinder which is used when no other binders support a given argument.
When an argument needs bound, the Message is used as the source of all of the available data. The binder registry follows a small sequence of steps to attempt to find a binder that supports the argument.
Search the annotation based binders for one that matches any annotation on the argument that is annotated with @Bindable .
Search the type based binders for one that matches or is a subclass of the argument type.
Return the default binder.
The default binder binds the body of the message to the argument.
Custom Binding
To inject your own argument binding behavior, it is as simple as registering a bean. The existing binder registry will inject it and include it in the normal processing.
Annotation Binding
A custom annotation can be created to bind consumer arguments. A custom binder can then be created to use that annotation and the Message to supply a value for the argument. The value may in fact come from anywhere, however for the purposes of this documentation, the replyTo in the message is used.
imports 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 io.micronaut.core.bind.annotation.Bindable;@Documented
@Retention (RetentionPolicy.RUNTIME)
@Target ({ElementType.PARAMETER})
@Bindable //
public @interface SID {
} imports from micronaut.core.bind.annotation import Bindable@Bindable #
def SID ():
def decorator (target):
return target
return decoratorimports import io.micronaut.core.bind.annotation.Bindable @MustBeDocumented
@Retention (AnnotationRetention.RUNTIME)
@Target (AnnotationTarget.VALUE_PARAMETER)
@Bindable //
annotation class SID imports import io.micronaut.core.bind.annotation.Bindable
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@Documented
@Retention (RetentionPolicy .RUNTIME )
@Target ([ElementType. PARAMETER ])
@Bindable //
@interface SID {
}
imports import io.micronaut.core.convert.ArgumentConversionContext;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.nats.bind.NatsAnnotatedArgumentBinder;
import io.nats.client.Message;
import jakarta.inject.Singleton;@Singleton //
public class SIDAnnotationBinder implements NatsAnnotatedArgumentBinder <SID > { //
private final ConversionService conversionService;
public SIDAnnotationBinder (ConversionService conversionService ) { //
this .conversionService = conversionService;
}
@Override
public Class<SID > getAnnotationType () {
return SID.class;
}
@Override
public BindingResult<Object > bind (ArgumentConversionContext<Object > context , Message source ) {
String sid = source.getSID (); //
return () -> conversionService.convert (sid, context); //
}
} imports from jakarta.inject import Singleton
from micronaut.core.bind.ArgumentBinder import BindingResult
from micronaut.core.convert import ArgumentConversionContext, ConversionService
from micronaut.nats.bind import NatsAnnotatedArgumentBinder
from .SID import SID
from io.nats.client import Message@Singleton #
class SIDAnnotationBinder (NatsAnnotatedArgumentBinder[SID ]): #
def __init__ (self, conversion_service: ConversionService): #
self .conversion_service = conversion_service
def getAnnotationType (self) -> type[SID ]:
return SIDClass
# TODO (python): the return annotation mirrors the Java @Override; without it the returned lambda is not
# converted to the BindingResult functional interface (see DISABLED_TESTS.md)
def bind (self, context: ArgumentConversionContext[object ], source: Message) -> BindingResult[object ]:
sid = source.getSID() #
return lambda : self .conversion_service.convert(sid, context) # imports import io.micronaut.core.bind.ArgumentBinder
import io.micronaut.core.convert.ArgumentConversionContext
import io.micronaut.core.convert.ConversionService
import io.micronaut.nats.bind.NatsAnnotatedArgumentBinder
import io.nats.client.Message
import jakarta.inject.Singleton @Singleton //
class SIDAnnotationBinder (private val conversionService: ConversionService ) //
: NatsAnnotatedArgumentBinder <SID > { //
override fun getAnnotationType (): Class <SID > {
return SID::class .java
}
override fun bind (context: ArgumentConversionContext <Any >, source: Message ): ArgumentBinder .BindingResult <Any > {
val sid = source.sid //
return ArgumentBinder.BindingResult { conversionService.convert (sid, context) } //
}
} imports import io.micronaut.core.convert.ArgumentConversionContext
import io.micronaut.core.convert.ConversionService
import io.micronaut.nats.bind.NatsAnnotatedArgumentBinder
import io.nats.client.Message
import jakarta.inject.Singleton@Singleton //
class SIDAnnotationBinder implements NatsAnnotatedArgumentBinder<SID> { //
private final ConversionService conversionService
SIDAnnotationBinder (ConversionService conversionService ) { //
this . conversionService = conversionService
}
@Override
Class<SID> getAnnotationType () {
SID
}
@Override
BindingResult<Object> bind (ArgumentConversionContext<Object> context , Message source ) {
String sid = source. getSID() //
return { -> conversionService. convert(sid, context) } //
}
}
The annotation can now be used on the argument in a consumer method.
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener
public class ProductListener {
List< String > messages = Collections.synchronizedList (new ArrayList<>());
@Subject ("product" )
public void receive (byte [] data , @SID String sid ) { //
messages.add (sid);
}
} imports from micronaut.nats.annotation import NatsListener, Subject
from .SID import SID @NatsListener
class ProductListener :
def __init__ (self):
self .messages: list[str ] = []
@Subject ("product" )
def receive (self, data: bytes , sid: Annotated[str , SID ]) -> None : #
self .messages.append(sid)imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.micronaut.nats.docs.consumer.custom.type.ProductInfo
import java.util.Collections @NatsListener
class ProductListener {
var messages: MutableList <String > = Collections.synchronizedList (ArrayList ())
private var datas: MutableList <ByteArray > = Collections.synchronizedList (ArrayList ())
@Subject ("product" )
fun receive (data : ByteArray , @SID sid: String ) {//
messages.add (sid)
datas.add (data )
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import io.micronaut.nats.docs.consumer.custom.type.ProductInfo
import java.util.concurrent.CopyOnWriteArrayList@NatsListener
class ProductListener {
CopyOnWriteArrayList<ProductInfo> messages = []
@Subject ("product" )
void receive (byte [] data , @SID String sid ) { //
messages << sid
}
}
Type Binding
A custom binder can be created to support any argument type. For example the following class could be created to bind values from the headers. This functionality could allow the work of retrieving and converting the headers to occur in a single place instead of multiple times in your code.
imports import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;public class ProductInfo {
private String size;
private Long count;
private Boolean sealed;
public ProductInfo (@Nullable String size , //
@NonNull Long count , //
@NonNull Boolean sealed ) { //
this .size = size;
this .count = count;
this .sealed = sealed ;
}
public String getSize () {
return size;
}
public Long getCount () {
return count;
}
public Boolean getSealed () {
return sealed ;
}
} imports from dataclasses import dataclass@dataclass
class ProductInfo :
size: str | None #
count: int #
sealed: bool # class ProductInfo (val size: String ?, //
val count: Long , //
val sealed : Boolean )// imports import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullableclass ProductInfo {
private String size
private Long count
private Boolean sealed
ProductInfo (@Nullable String size , //
@NonNull Long count , //
@NonNull Boolean sealed ) { //
this . size = size
this . count = count
this . sealed = sealed
}
String getSize () {
size
}
Long getCount () {
count
}
Boolean getSealed () {
sealed
}
}
A type argument binder can then be created to create the ProductInfo instance to bind to your consumer method argument.
imports import io.micronaut.core.convert.ArgumentConversionContext;
import io.micronaut.core.convert.ConversionError;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.core.type.Argument;
import io.micronaut.nats.bind.NatsHeaderConvertibleValues;
import io.micronaut.nats.bind.NatsTypeArgumentBinder;
import io.nats.client.Message;
import io.nats.client.impl.Headers;
import jakarta.inject.Singleton;@Singleton //
public class ProductInfoTypeBinder implements NatsTypeArgumentBinder <ProductInfo > { //
private final ConversionService conversionService;
ProductInfoTypeBinder (ConversionService conversionService ) { //
this .conversionService = conversionService;
}
@Override
public Argument<ProductInfo > argumentType () {
return Argument.of (ProductInfo.class);
}
@Override
public BindingResult<ProductInfo > bind (ArgumentConversionContext<ProductInfo > context , Message source ) {
Headers rawHeaders = source.getHeaders (); //
if (rawHeaders == null ) {
return BindingResult.EMPTY;
}
NatsHeaderConvertibleValues headers = new NatsHeaderConvertibleValues (rawHeaders, conversionService);
String size = headers.get ("productSize" , String.class).orElse (null ); //
Optional< Long > count = headers.get ("x-product-count" , Long.class); //
Optional< Boolean > sealed = headers.get ("x-product-sealed" , Boolean.class); //
if (headers.getConversionErrors ().isEmpty () && count.isPresent () && sealed .isPresent ()) {
return () -> Optional.of (new ProductInfo (size, count.get (), sealed .get ())); //
} else {
return new BindingResult<ProductInfo >() {
@Override
public Optional<ProductInfo > getValue () {
return Optional.empty ();
}
@Override
public List<ConversionError > getConversionErrors () {
return headers.getConversionErrors (); //
}
};
}
}
} imports from jakarta.inject import Singleton
from micronaut.core.bind import ArgumentBinder
from micronaut.core.convert import ArgumentConversionContext, ConversionService
from micronaut.core.type import Argument
from micronaut.nats.bind import NatsHeaderConvertibleValues, NatsTypeArgumentBinder
from java.lang import Boolean, Long, String
from java.util import Optional
from .ProductInfo import ProductInfo
from io.nats.client import Message@Singleton #
class ProductInfoTypeBinder (NatsTypeArgumentBinder[ProductInfo]): #
def __init__ (self, conversion_service: ConversionService): #
self .conversion_service = conversion_service
def argumentType (self) -> Argument[ProductInfo]:
return Argument.of(ProductInfo)
def bind (self, context: ArgumentConversionContext[ProductInfo], source: Message) -> ArgumentBinder.BindingResult[ProductInfo]:
raw_headers = source.getHeaders() #
if raw_headers is None :
return ArgumentBinder.BindingResult.empty()
headers = NatsHeaderConvertibleValues(raw_headers, self .conversion_service)
size = headers.get("productSize" , String).orElse(None ) #
count = headers.get("x-product-count" , Long) #
sealed = headers.get("x-product-sealed" , Boolean) #
if headers.getConversionErrors().isEmpty() and count.isPresent() and sealed.isPresent():
return lambda : Optional.of(ProductInfo(size, count.get(), sealed.get())) #
else :
class ConversionErrorsBindingResult :
def getValue (self):
return Optional.empty()
def getConversionErrors (self):
return headers.getConversionErrors() #
return ConversionErrorsBindingResult()imports import io.micronaut.core.bind.ArgumentBinder.BindingResult
import io.micronaut.core.convert.ArgumentConversionContext
import io.micronaut.core.convert.ConversionError
import io.micronaut.core.convert.ConversionService
import io.micronaut.core.type.Argument
import io.micronaut.nats.bind.NatsHeaderConvertibleValues
import io.micronaut.nats.bind.NatsTypeArgumentBinder
import io.micronaut.nats.docs.consumer.custom.type.ProductInfo
import io.nats.client.Message
import jakarta.inject.Singleton
import java.util.Optional @Singleton //
class ProductInfoTypeBinder constructor (private val conversionService: ConversionService ) //
: NatsTypeArgumentBinder <ProductInfo > { //
override fun argumentType (): Argument <ProductInfo > {
return Argument.of (ProductInfo::class .java)
}
override fun bind (context: ArgumentConversionContext <ProductInfo >, source: Message ): BindingResult <ProductInfo > {
val rawHeaders = source.headers ?: return BindingResult { Optional.empty <ProductInfo >() } //
val headers = NatsHeaderConvertibleValues (rawHeaders, conversionService)
val size = headers.get ("productSize" , String::class .java).orElse (null ) //
val count = headers.get ("x-product-count" , Long::class .java) //
val sealed = headers.get ("x-product-sealed" , Boolean::class .java) //
if (headers.conversionErrors.isEmpty () && count.isPresent && sealed .isPresent) {
return BindingResult <ProductInfo > { Optional.of (ProductInfo (size, count.get (), sealed .get ())) } //
} else {
return object : BindingResult <ProductInfo > {
override fun getValue (): Optional <ProductInfo > {
return Optional.empty ()
}
override fun getConversionErrors (): List <ConversionError > {
return headers.conversionErrors //
}
}
}
}
} imports import io.micronaut.core.convert.ArgumentConversionContext
import io.micronaut.core.convert.ConversionError
import io.micronaut.core.convert.ConversionService
import io.micronaut.core.type.Argument
import io.micronaut.nats.bind.NatsHeaderConvertibleValues
import io.micronaut.nats.bind.NatsTypeArgumentBinder
import io.nats.client.Message
import io.nats.client.impl.Headers
import jakarta.inject.Singleton@Singleton //
class ProductInfoTypeBinder implements NatsTypeArgumentBinder<ProductInfo> { //
private final ConversionService conversionService
ProductInfoTypeBinder (ConversionService conversionService ) { //
this . conversionService = conversionService
}
@Override
Argument<ProductInfo> argumentType () {
return Argument. of(ProductInfo )
}
@Override
BindingResult<ProductInfo> bind (ArgumentConversionContext<ProductInfo> context , Message source ) {
Headers rawHeaders = source. headers //
if (rawHeaders == null ) {
return BindingResult. EMPTY
}
def headers = new NatsHeaderConvertibleValues (rawHeaders, conversionService)
String size = headers. get("productSize" , String ). orElse(null ) //
Optional<Long> count = headers. get("x-product-count" , Long ) //
Optional<Boolean> sealed = headers. get("x-product-sealed" , Boolean ) //
if (headers. conversionErrors. isEmpty() && count. isPresent() && sealed. isPresent()) {
{ -> Optional. of(new ProductInfo (size, count. get(), sealed. get())) } //
} else {
new BindingResult<ProductInfo> () {
@Override
Optional<ProductInfo> getValue () {
Optional. empty()
}
@Override
List<ConversionError> getConversionErrors () {
headers. conversionErrors //
}
}
}
}
}
This library supports RPC through the usage of Request-Reply . Both blocking and non blocking variations are supported.
The following is an example direct reply to where the consumer is converting the body to upper case and replying with the converted string.
Client Side
The "client side" in this case starts by publishing a message. A consumer somewhere will then receive the message and reply with a new value.
imports import io.micronaut.nats.annotation.NatsClient;
import io.micronaut.nats.annotation.Subject;
import org.reactivestreams.Publisher;@NatsClient
public interface ProductClient {
@Subject ("product" )
String send (String data ); //
@Subject ("product" )
Publisher<String > sendReactive (String data ); //
} imports from micronaut.nats.annotation import NatsClient, Subject
from org.reactivestreams import Publisher@NatsClient
class ProductClient (ABC ):
@Subject ("product" )
@abstractmethod
def send (self, data: str ) -> str : #
...
@Subject ("product" )
@abstractmethod
def send_reactive (self, data: str ) -> Publisher[str ]: #
... imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject
import org.reactivestreams.Publisher @NatsClient
interface ProductClient {
@Subject ("product" )
fun send (data : String ): String //
@Subject ("product" )
fun sendReactive (data : String ): Publisher <String > //
} imports import io.micronaut.nats.annotation.NatsClient
import io.micronaut.nats.annotation.Subject
import org.reactivestreams.Publisher@NatsClient
interface ProductClient {
@Subject ("product" )
String send (String data ) //
@Subject ("product" )
Publisher<String> sendReactive (String data ) //
}
Important
In order for the publisher to assume RPC should be used instead of just completing when the publish is confirmed, the data type must not be Void. In both cases above, the data type is String.
Server Side
The "server side" in this case starts with the consumption of a message, and then a new message is published by returning the result
imports import io.micronaut.nats.annotation.NatsListener;
import io.micronaut.nats.annotation.Subject;@NatsListener
public class ProductListener {
@Subject ("product" )
public String toUpperCase (String data ) { //
return data.toUpperCase (); //
}
} imports from micronaut.nats.annotation import NatsListener, Subject@NatsListener
class ProductListener :
@Subject ("product" )
def to_upper_case (self, data: str ) -> str : #
return data.upper() # imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject
import java.util.Collections @NatsListener
class ProductListener {
@Subject ("product" )
fun receive (data : String ): String { //
return data .uppercase () //
}
} imports import io.micronaut.nats.annotation.NatsListener
import io.micronaut.nats.annotation.Subject@NatsListener
class ProductListener {
@Subject ("product" )
String toUpperCase (String data ) { //
data. toUpperCase() //
}
}
Note
If the reply publish fails for any reason, the original message will be rejected.
Important
RPC consumer methods must never return a reactive type. Because the resulting publish needs to occur on the same thread and only a single item can be emitted, there is no value in doing so.
9 Message Serialization/Deserialization (SerDes)
The serialization and deserialization of message bodies is handled through instances of NatsMessageSerDes . The ser-des (Serializer/Deserializer) is responsible for both serialization and deserialization of Nats message bodies into the message body types defined in your clients and consumers methods.
The ser-des are managed by a NatsMessageSerDesRegistry . All ser-des beans are injected in order into the registry and then searched for when serialization or deserialization is needed. The first ser-des that returns true for supports-java.lang.Class- is returned and used.
By default, standard Java lang types and JSON format (with Jackson) are supported. You can supply your own ser-des by simply registering a bean of type NatsMessageSerDes . All ser-des implement the Ordered interface, so custom implementations can come before, after, or in between the default implementations.
A custom serializer/deserializer would be necessary to support custom data formats. In the section on Custom Consumer Binding an example was demonstrated that allowed binding a ProductInfo type from the headers of the message. If instead that object should represent the body of the message with a custom data format, you could register your own serializer/deserializer to do so.
In this example a simple data format of the string representation of the fields are concatenated together with a pipe character.
imports import io.micronaut.core.convert.ConversionService;
import io.micronaut.core.type.Argument;
import io.micronaut.nats.serdes.NatsMessageSerDes;
import io.nats.client.Message;
import jakarta.inject.Singleton;
import java.nio.charset.StandardCharsets;
import java.util.Optional;@Singleton //
public class ProductInfoSerDes implements NatsMessageSerDes <ProductInfo > { //
private final ConversionService conversionService;
public ProductInfoSerDes (ConversionService conversionService ) { //
this .conversionService = conversionService;
}
@Override
public ProductInfo deserialize (Message message , Argument<ProductInfo > argument ) { //
String body = new String (message.getData (), StandardCharsets.UTF_8);
String [] parts = body.split (" \\ |" );
if (parts.length == 3 ) {
String size = parts[0 ];
if (size.equals ("null" )) {
size = null ;
}
Optional< Long > count = conversionService.convert (parts[1 ], Long.class);
Optional< Boolean > sealed = conversionService.convert (parts[2 ], Boolean.class);
if (count.isPresent () && sealed .isPresent ()) {
return new ProductInfo (size, count.get (), sealed .get ());
}
}
return null ;
}
@Override
public byte [] serialize (ProductInfo data ) { //
if (data == null ) {
return null ;
}
return (data.getSize () + "|" + data.getCount () + "|" + data.getSealed ()).getBytes (StandardCharsets.UTF_8);
}
@Override
public boolean supports (Argument<ProductInfo > argument ) { //
return argument.getType ().isAssignableFrom (ProductInfo.class);
}
} imports from jakarta.inject import Singleton
from micronaut.core.convert import ConversionService
from micronaut.core.type import Argument
from micronaut.nats.serdes import NatsMessageSerDes
from java.lang import Boolean, Long
from .ProductInfo import ProductInfo
from io.nats.client import Message@Singleton #
class ProductInfoSerDes (NatsMessageSerDes ): #
def __init__ (self, conversion_service: ConversionService): #
self .conversion_service = conversion_service
def deserialize (self, message: Message, argument: Argument) -> ProductInfo | None : #
body = bytes (message.getData()).decode("utf-8" )
parts = body.split("|" )
if len (parts) == 3 :
size = parts[0 ]
if size == "null" :
size = None
count = self .conversion_service.convert(parts[1 ], Long)
sealed = self .conversion_service.convert(parts[2 ], Boolean)
if count.isPresent() and sealed.isPresent():
return ProductInfo(size, count.get(), sealed.get())
return None
def serialize (self, data: ProductInfo | None ) -> bytes | None : #
if data is None :
return None
return f " { data.size if data.size is not None else 'null' } | { data.count} | { str (data.sealed).lower()} " .encode("utf-8" )
def supports (self, argument: Argument) -> bool : #
return argument.getType().isAssignableFrom(ProductInfo)imports import io.micronaut.core.convert.ConversionService
import io.micronaut.core.type.Argument
import io.micronaut.nats.serdes.NatsMessageSerDes
import io.nats.client.Message
import jakarta.inject.Singleton
import java.nio.charset.Charset @Singleton //
class ProductInfoSerDes (private val conversionService: ConversionService )//
: NatsMessageSerDes <ProductInfo > { //
override fun deserialize (message: Message , argument: Argument <ProductInfo >): ProductInfo ? { //
val body = String (message.data , CHARSET)
val parts = body.split (" \\ |" .toRegex ())
if (parts.size == 3 ) {
var size: String ? = parts[0 ]
if (size == "null" ) {
size = null
}
val count = conversionService.convert (parts[1 ], Long::class .java)
val sealed = conversionService.convert (parts[2 ], Boolean::class .java)
if (count.isPresent && sealed .isPresent) {
return ProductInfo (size, count.get (), sealed .get ())
}
}
return null
}
override fun serialize (data : ProductInfo ?): ByteArray { //
return (data ?.size + "|" + data ?.count + "|" + data ?.sealed ).toByteArray (CHARSET)
}
override fun supports (argument: Argument <ProductInfo >): Boolean { //
return argument.type.isAssignableFrom (ProductInfo::class .java)
}
companion object {
private val CHARSET = Charset.forName ("UTF-8" )
}
} imports import io.micronaut.core.convert.ConversionService
import io.micronaut.core.type.Argument
import io.micronaut.nats.serdes.NatsMessageSerDes
import io.nats.client.Message
import jakarta.inject.Singleton@Singleton //
class ProductInfoSerDes implements NatsMessageSerDes<ProductInfo> { //
private static final Charset UTF8 = Charset. forName( "UTF-8" )
private final ConversionService conversionService
ProductInfoSerDes (ConversionService conversionService ) { //
this . conversionService = conversionService
}
@Override
ProductInfo deserialize (Message message , Argument<ProductInfo> argument ) { //
String body = new String (message. data, UTF8 )
String [] parts = body. split(" \\ |" )
if (parts. length == 3 ) {
String size = parts[0 ]
if (size == "null" ) {
size = null
}
Optional<Long> count = conversionService. convert(parts[1 ], Long )
Optional<Boolean> sealed = conversionService. convert(parts[2 ], Boolean )
if (count. isPresent() && sealed. isPresent()) {
return new ProductInfo (size, count. get(), sealed. get())
}
}
null
}
@Override
byte [] serialize (ProductInfo data ) { //
if (data == null ) {
return null
}
(data. size + "|" + data. count + "|" + data. sealed). getBytes(UTF8 )
}
@Override
boolean supports (Argument<ProductInfo> argument ) { //
argument. type. isAssignableFrom(ProductInfo )
}
}
Tip
Because the getOrder method was not overridden, the default order of 0 is used. All default ser-des have a lower precedent than the default order which means this ser-des will be checked before the others.
This library comes with a health indicator for applications that are using the management module in Micronaut. See the Health Endpoint documentation for more information about the endpoint itself.
The information reported from the health indicator is under the nats key.
"nats" : {
"status" : "UP",
"details" : {
"servers" : ["nats://localhost:4222"]
}
} "nats" : {
"status" : "UP" ,
"details" : {
"servers" : ["nats://localhost:4222" ]
}
} "nats": {
"status": "UP",
"details": {
"servers": ["nats://localhost:4222"]
}
} "nats" : {
"status" : "UP" ,
"details" : {
"servers" : ["nats://localhost:4222" ]
}
} "nats" : {
"status" : "UP",
"details" : {
"servers" : ["nats://localhost:4222"]
}
} "nats" : {
"status" : "UP" ,
"details" : {
"servers" : ["nats://localhost:4222" ]
}
}
Tip
To disable the NATS health indicator entirely, add endpoints.health.nats.enabled: false.
Jetstream is built-in distributed persistence system built into Nats.io which enables new functionalities like
Streams are 'message stores', each stream defines how messages are stored and what the limits (duration, size, interest) of the retention are.
Streams consume normal NATS subjects, any message published on those subjects will be captured in the defined storage system.
You can do a normal publish to the subject for unacknowledged delivery, though it’s better to use the JetStream publish calls instead as the JetStream server will reply with an acknowledgement that it was successfully stored.
The following properties are available for a stream configuration:
A simple configuration for jetstream and a single stream can look like:
nats.default.jetstream.streams.events.storage-type =Memory
nats.default.jetstream.streams.events.subjects[0] =events.>nats :
default :
jetstream :
streams :
events :
storage-type : Memory
subjects :
- events.> [nats .default .jetstream .streams .events ]
storage-type = "Memory"
subjects = [ "events.>" ] nats {
'default' {
jetstream {
streams {
events {
storageType = 'Memory'
subjects = ['events.>' ]
}
}
}
}
} {
nats = {
default = {
jetstream = {
streams = {
events = {
storage-type = "Memory"
subjects = ["events.>"]
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"streams" : {
"events" : {
"storage-type" : "Memory" ,
"subjects" : [
"events.>"
]
}
}
}
}
}
}
nats.default.jetstream.streams.events.subjects[0] =events.>
nats.default.jetstream.streams.events.compression-option =s2
nats.default.jetstream.streams.events.metadata.owner =team-a
nats.default.jetstream.streams.events.subject-transform.source =events.*.*
nats.default.jetstream.streams.events.subject-transform.destination =events.{{wildcard(2)}}.{{wildcard(1)}}nats :
default :
jetstream :
streams :
events :
subjects :
- events.>
compression-option : s2 #
metadata : #
owner : team-a
subject-transform : #
source : "events.*.*"
destination : "events.{{wildcard(2)}}.{{wildcard(1)}}" [nats .default .jetstream .streams .events ]
subjects = [ "events.>" ]
compression-option = "s2"
[nats .default .jetstream .streams .events .metadata ]
owner = "team-a"
[nats .default .jetstream .streams .events .subject-transform ]
source = "events.*.*"
destination = "events.{{wildcard(2)}}.{{wildcard(1)}}" nats {
'default' {
jetstream {
streams {
events {
subjects = ['events.>' ]
compressionOption = 's2'
metadata {
owner = 'team-a'
}
subjectTransform {
source = 'events.*.*'
destination = 'events.{{wildcard(2)}}.{{wildcard(1)}}'
}
}
}
}
}
} {
nats = {
default = {
jetstream = {
streams = {
events = {
subjects = ["events.>"]
compression-option = "s2"
metadata = {
owner = "team-a"
}
subject-transform = {
source = "events.*.*"
destination = "events.{{wildcard(2)}}.{{wildcard(1)}}"
}
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"streams" : {
"events" : {
"subjects" : [
"events.>"
],
"compression-option" : "s2" ,
"metadata" : {
"owner" : "team-a"
},
"subject-transform" : {
"source" : "events.*.*" ,
"destination" : "events.{{wildcard(2)}}.{{wildcard(1)}}"
}
}
}
}
}
}
}
Placement, republish and consumer limits
nats.default.jetstream.streams.events.subjects[0] =events.>
nats.default.jetstream.streams.events.placement.cluster =east
nats.default.jetstream.streams.events.placement.tags[0] =ssd
nats.default.jetstream.streams.events.republish.source =events.>
nats.default.jetstream.streams.events.republish.destination =republished.>
nats.default.jetstream.streams.events.republish.headers-only =false
nats.default.jetstream.streams.events.consumer-limits.inactive-threshold =10m
nats.default.jetstream.streams.events.consumer-limits.max-ack-pending =1000nats :
default :
jetstream :
streams :
events :
subjects :
- events.>
placement : #
cluster : east
tags :
- ssd
republish : #
source : "events.>"
destination : "republished.>"
headers-only : false
consumer-limits : #
inactive-threshold : 10m
max-ack-pending : 1000 [nats .default .jetstream .streams .events ]
subjects = [ "events.>" ]
[nats .default .jetstream .streams .events .placement ]
cluster = "east"
tags = [ "ssd" ]
[nats .default .jetstream .streams .events .republish ]
source = "events.>"
destination = "republished.>"
headers-only = false
[nats .default .jetstream .streams .events .consumer-limits ]
inactive-threshold = "10m"
max-ack-pending = 1000 nats {
'default' {
jetstream {
streams {
events {
subjects = ['events.>' ]
placement {
cluster = 'east'
tags = ['ssd' ]
}
republish {
source = 'events.>'
destination = 'republished.>'
headersOnly = false
}
consumerLimits {
inactiveThreshold = '10m'
maxAckPending = 1000
}
}
}
}
}
} {
nats = {
default = {
jetstream = {
streams = {
events = {
subjects = ["events.>"]
placement = {
cluster = "east"
tags = ["ssd"]
}
republish = {
source = "events.>"
destination = "republished.>"
headers-only = false
}
consumer-limits = {
inactive-threshold = "10m"
max-ack-pending = 1000
}
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"streams" : {
"events" : {
"subjects" : [
"events.>"
],
"placement" : {
"cluster" : "east" ,
"tags" : [
"ssd"
]
},
"republish" : {
"source" : "events.>" ,
"destination" : "republished.>" ,
"headers-only" : false
},
"consumer-limits" : {
"inactive-threshold" : "10m" ,
"max-ack-pending" : 1000
}
}
}
}
}
}
}
Mirror
A stream can mirror another stream. A mirror can not have subjects of its own.
nats.default.jetstream.streams.events-mirror.mirror.name =events
nats.default.jetstream.streams.events-mirror.mirror.filter-subject =events.orders.>
nats.default.jetstream.streams.events-mirror.mirror.start-seq =1
nats.default.jetstream.streams.events-mirror.mirror.domain =hub
nats.default.jetstream.streams.events-mirror.mirror.subject-transforms[0].source =events.orders.>
nats.default.jetstream.streams.events-mirror.mirror.subject-transforms[0].destination =mirror.orders.>nats :
default :
jetstream :
streams :
events-mirror :
mirror :
name : events #
filter-subject : "events.orders.>" #
start-seq : 1
domain : hub #
subject-transforms : #
- source : "events.orders.>"
destination : "mirror.orders.>" [nats .default .jetstream .streams .events-mirror .mirror ]
name = "events"
filter-subject = "events.orders.>"
start-seq = 1
domain = "hub"
[[nats .default .jetstream .streams .events-mirror .mirror .subject-transforms ]]
source = "events.orders.>"
destination = "mirror.orders.>" nats {
'default' {
jetstream {
streams {
eventsMirror {
mirror {
name = 'events'
filterSubject = 'events.orders.>'
startSeq = 1
domain = 'hub'
subjectTransforms = ['[object Object]' ]
}
}
}
}
}
} {
nats = {
default = {
jetstream = {
streams = {
events-mirror = {
mirror = {
name = "events"
filter-subject = "events.orders.>"
start-seq = 1
domain = "hub"
subject-transforms = [{
source = "events.orders.>"
destination = "mirror.orders.>"
}]
}
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"streams" : {
"events-mirror" : {
"mirror" : {
"name" : "events" ,
"filter-subject" : "events.orders.>" ,
"start-seq" : 1 ,
"domain" : "hub" ,
"subject-transforms" : [
{
"source" : "events.orders.>" ,
"destination" : "mirror.orders.>"
}
]
}
}
}
}
}
}
}
Sources
A stream can be sourced from one or more other streams, in addition to its own subjects.
nats.default.jetstream.streams.all-events.sources[0].name =events
nats.default.jetstream.streams.all-events.sources[0].subject-transforms[0].source =events.*
nats.default.jetstream.streams.all-events.sources[0].subject-transforms[0].destination =all.events.$1
nats.default.jetstream.streams.all-events.sources[1].name =remote-events
nats.default.jetstream.streams.all-events.sources[1].filter-subject =remote.>
nats.default.jetstream.streams.all-events.sources[1].external.api =$JS.remote.API
nats.default.jetstream.streams.all-events.sources[1].external.deliver =deliver.remotenats :
default :
jetstream :
streams :
all-events :
sources :
- name : events #
subject-transforms : #
- source : "events.*"
destination : "all.events.$1"
- name : remote-events
filter-subject : "remote.>"
external : #
api : "$JS.remote.API"
deliver : "deliver.remote" [[nats .default .jetstream .streams .all-events .sources ]]
name = "events"
[[nats .default .jetstream .streams .all-events .sources .subject-transforms ]]
source = "events.*"
destination = "all.events.$1"
[[nats .default .jetstream .streams .all-events .sources ]]
name = "remote-events"
filter-subject = "remote.>"
[nats .default .jetstream .streams .all-events .sources .external ]
api = "$JS.remote.API"
deliver = "deliver.remote" nats {
'default' {
jetstream {
streams {
allEvents {
sources = ['[object Object]' , '[object Object]' ]
}
}
}
}
} {
nats = {
default = {
jetstream = {
streams = {
all-events = {
sources = [{
name = "events"
subject-transforms = [{
source = "events.*"
destination = "all.events.$1"
}]
}, {
name = "remote-events"
filter-subject = "remote.>"
external = {
api = "$JS.remote.API"
deliver = "deliver.remote"
}
}]
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"streams" : {
"all-events" : {
"sources" : [
{
"name" : "events" ,
"subject-transforms" : [
{
"source" : "events.*" ,
"destination" : "all.events.$1"
}
]
},
{
"name" : "remote-events" ,
"filter-subject" : "remote.>" ,
"external" : {
"api" : "$JS.remote.API" ,
"deliver" : "deliver.remote"
}
}
]
}
}
}
}
}
}
The example in the quick start presented a trivial definition of an interface that be implemented automatically for you using the @JetstreamClient annotation.
The implementation that powers @JetstreamClient (defined by the JetStreamIntroductionAdvice class) is, however, very flexible and offers a range of options for defining Jetstream clients.
The @JetstreamClient extends the default @NatsClient and is based on the same methods.
So you can still use all header and subject functionalities as you already know.
@JetstreamClient have a special extension for the options you want to publish.
imports import io.micronaut.messaging.annotation.MessageBody;
import io.micronaut.nats.annotation.Subject;
import io.micronaut.nats.jetstream.annotation.JetStreamClient;
import io.nats.client.PublishOptions;
import io.nats.client.api.PublishAck;@JetStreamClient
public interface ProductClient {
PublishAck send (@Subject String subject , @MessageBody byte [] data , PublishOptions publishOptions ); //
} imports from micronaut.messaging.annotation import MessageBody
from micronaut.nats.annotation import Subject
from micronaut.nats.jetstream.annotation import JetStreamClient
from io.nats.client import PublishOptions
from io.nats.client.api import PublishAck@JetStreamClient
class ProductClient (ABC ):
@abstractmethod
def send (self, subject: Annotated[str , Subject], data: Annotated[bytes , MessageBody], publish_options: PublishOptions) -> PublishAck: #
... imports import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.nats.annotation.Subject
import io.micronaut.nats.jetstream.annotation.JetStreamClient
import io.nats.client.PublishOptions
import io.nats.client.api.PublishAck @JetStreamClient
interface ProductClient {
fun send (@Subject subject: String , @MessageBody data : ByteArray , publishOptions: PublishOptions ): PublishAck //
} imports import io.micronaut.messaging.annotation.MessageBody
import io.micronaut.nats.annotation.Subject
import io.micronaut.nats.jetstream.annotation.JetStreamClient
import io.nats.client.PublishOptions
import io.nats.client.api.PublishAck@JetStreamClient
interface ProductClient {
PublishAck send (@Subject String subject , @MessageBody byte [] data , PublishOptions publishOptions ); //
}
A consumer is a stateful view of a stream. It acts as interface for clients to consume a subset of messages stored in a stream and will keep track of which messages were delivered and acknowledged by clients.
Unlike with core NATS which provides an at most once delivery guarantee of a message, a consumer can provide an at least once delivery guarantee. This is achieved by the combination of published messages being persisted to the stream as well as the consumer tracking delivery and acknowledgement of each individual message as clients receive and process them. JetStream consumers support multiple kinds of acknowledgements and multiple acknowledgement policies. They will take care of automatically re-deliver un-acked (or 'nacked') messages up to a user specified maximum number of delivery attempts (there is an advisory being emitted when a message reaches this limit).
Consumers can be push-based where messages will be delivered to a specified subject or pull-based which allows clients to request batches of messages on demand. The choice of what kind of consumer to use depends on the use-case but typically in the case of a client application that needs to get their own individual replay of messages from a stream you would use an 'ordered push consumer'. If there is a need to process messages and easily scale horizontally, you would use a 'pull consumer'.
In addition to the choice of being push or pull, a consumer can also be ephemeral or durable. A consumer is considered durable when an explicit name is set on the Durable field when creating the consumer, otherwise it is considered ephemeral. Durables and ephemeral behave exactly the same except that an ephemeral will be automatically cleaned up (deleted) after a period of inactivity, specifically when there are no subscriptions bound to the consumer. By default, durables will remain even when there are periods of inactivity (unless InactiveThreshold is set explicitly).
A push consumer is where the server is in control and sends messages to the client. It can be made durable or ephemeral based on your use case.
Push consumers are very similiar to the already known @NatsListener. Let’s look at a quick example.
imports import io.micronaut.nats.jetstream.annotation.JetStreamListener;
import io.micronaut.nats.jetstream.annotation.PushConsumer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;@JetStreamListener //
public class ProductListener {
List< byte []> messageLengths = Collections.synchronizedList (new ArrayList<>());
@PushConsumer (value = "myevents" , subject = "myevents.>" , durable = "test" ) //
public void receive (byte [] data ) {
messageLengths.add (data);
}
} imports from micronaut.nats.jetstream.annotation import JetStreamListener, PushConsumer@JetStreamListener #
class ProductListener :
def __init__ (self):
self .message_lengths: list[bytes ] = []
@PushConsumer (value = "myevents" , subject = "myevents.>" , durable = "test" ) #
def receive (self, data: bytes ) -> None :
self .message_lengths.append(bytes (data))imports import io.micronaut.nats.jetstream.annotation.JetStreamListener
import io.micronaut.nats.jetstream.annotation.PushConsumer @JetStreamListener //
class ProductListener {
val messageLengths: MutableList <ByteArray > = Collections.synchronizedList (ArrayList ())
@PushConsumer (value = "myevents" , subject = "myevents.>" , durable = "test" ) //
fun receive (data : ByteArray ) {
messageLengths.add (data )
}
} imports import io.micronaut.nats.jetstream.annotation.JetStreamListener
import io.micronaut.nats.jetstream.annotation.PushConsumer@JetStreamListener //
class ProductListener {
CopyOnWriteArrayList<byte[]> messageLengths = []
@PushConsumer (value = "myevents" , subject = "myevents.>" , durable = "test" ) //
void receive (byte [] data ) {
messageLengths << data
}
}
Tip
To consume multiple subjects of a stream with a single consumer, use filterSubjects, for example @PushConsumer(value = "events", durable = "orders-and-payments", filterSubjects = {"events.orders.>", "events.payments.>"}). With filterSubjects the subject is optional; if it is set, it has to be one of the filter subjects. The single valued filterSubject is deprecated.
A pull consumer allows you to control when the server sends the client messages.
Let’s look at a quick example.
imports import io.micronaut.nats.jetstream.PullConsumerRegistry;
import io.nats.client.JetStreamApiException;
import io.nats.client.JetStreamSubscription;
import io.nats.client.Message;
import io.nats.client.PullSubscribeOptions;
import io.nats.client.api.ConsumerConfiguration;@Singleton
public class PullConsumerHelper {
private final PullConsumerRegistry pullConsumerRegistry;
public PullConsumerHelper (PullConsumerRegistry pullConsumerRegistry ) { //
this .pullConsumerRegistry = pullConsumerRegistry;
}
public List<Message > pullMessages () throws JetStreamApiException, IOException {
PullSubscribeOptions pullSubscribeOptions =
PullSubscribeOptions.builder ()
.stream ("events" )
.configuration (
ConsumerConfiguration
.builder ()
.ackWait (
Duration.ofMillis (
2500 ))
.build ())
.build ();
JetStreamSubscription jetStreamSubscription =
pullConsumerRegistry.newPullConsumer ("events.>" , pullSubscribeOptions); //
List< Message > messages = jetStreamSubscription.fetch (2 , Duration.ofSeconds (2L )); //
messages.forEach (Message:: ack); //
return messages;
}
} imports from micronaut.nats.jetstream import PullConsumerRegistry
from java.time import Duration
from io.nats.client import JetStreamSubscription, Message, PullSubscribeOptions
from io.nats.client.api import ConsumerConfiguration@Singleton
class PullConsumerHelper :
def __init__ (self, pull_consumer_registry: PullConsumerRegistry): #
self .pull_consumer_registry = pull_consumer_registry
def pull_messages (self) -> list[Message]:
pull_subscribe_options = (
PullSubscribeOptions.builder()
.stream("events" )
.configuration(
ConsumerConfiguration.builder()
.ackWait(Duration.ofMillis(2500 ))
.build())
.build())
jet_stream_subscription: JetStreamSubscription = \
self .pull_consumer_registry.newPullConsumer("events.>" , pull_subscribe_options) #
messages = jet_stream_subscription.fetch(2 , Duration.ofSeconds(2 )) #
for message in messages:
message.ack() #
return messagesimports import io.micronaut.nats.jetstream.PullConsumerRegistry
import io.nats.client.JetStreamSubscription
import io.nats.client.Message
import io.nats.client.PullSubscribeOptions
import io.nats.client.api.ConsumerConfiguration @Singleton
class PullConsumerHelper (private val pullConsumerRegistry: PullConsumerRegistry ) { //
fun pullMessages (): List <Message > {
val pullSubscribeOptions: PullSubscribeOptions =
PullSubscribeOptions.builder ()
.stream ("events" )
.configuration (
ConsumerConfiguration
.builder ()
.ackWait (
Duration.ofMillis (
2500
)
)
.build ()
)
.build ()
val jetStreamSubscription: JetStreamSubscription =
pullConsumerRegistry.newPullConsumer ("events.>" , pullSubscribeOptions) //
val messages: List <Message > = jetStreamSubscription.fetch (2 , Duration.ofSeconds (2L )) //
messages.forEach (Message::ack ) //
return messages
}
} imports import io.micronaut.nats.jetstream.PullConsumerRegistry
import io.nats.client.JetStreamApiException
import io.nats.client.JetStreamSubscription
import io.nats.client.Message
import io.nats.client.PullSubscribeOptions
import io.nats.client.api.ConsumerConfiguration@Singleton
class PullConsumerHelper {
private final PullConsumerRegistry pullConsumerRegistry;
PullConsumerHelper (PullConsumerRegistry pullConsumerRegistry ) { //
this . pullConsumerRegistry = pullConsumerRegistry
}
List<Message> pullMessages () throws JetStreamApiException , IOException {
JetStreamSubscription jetStreamSubscription =
pullConsumerRegistry. newPullConsumer("events.>" ,
PullSubscribeOptions. builder()
.stream("events" )
.configuration(
ConsumerConfiguration. builder(). ackWait(Duration. ofMillis(2500 )). build())
.build()) //
List<Message> messages = jetStreamSubscription. fetch(2 , Duration. ofSeconds(2L )) //
messages. forEach(Message ::ack) //
return messages
}
}
JetSteam, the persistence layer of NATS, doesn’t just allow for higher qualities of service and features associated with 'streaming', but it also enables some functionalities not found in messaging systems.
One such feature is the Key/Value store functionality, which allows client applications to create 'buckets' and use them as immediately consistent, persistent associative arrays.
You can use KV buckets to perform the typical operations you would expect from an immediately consistent key/value store:
put: associate a value with a key
get: retrieve the value associated with a key
delete: clear any value associated with a key
purge: clear all the values associated with all keys
create: associate the value with a key only if there is currently no value associated with that key (i.e. compare to null and set)
update: compare and set (aka compare and swap) the value for a key
keys: get a copy of all the keys (with a value or operation associated to it)
You can set limits for your buckets, such as:
- the maximum size of the bucket
- the maximum size for any single value
- a TTL: how long the store will keep values for
Finally, you can even do things that typically can not be done with a Key/Value Store:
watch: watch for changes happening for a key, which is similar to subscribing (in the publish/subscribe sense) to the key: the watcher receives updates due to put or delete operations on the key pushed to it in real-time as they happen
watch all: watch for all the changes happening on all the keys in the bucket
history: retrieve a history of the values (and delete operations) associated with each key over time (by default the history of buckets is set to 1, meaning that only the latest value/operation is stored)
The following properties are available for a key value configuration:
A simple configuration for key value can look like:
nats.default.jetstream.keyvalue.examplebucket.storage-type =Memory
nats.default.jetstream.keyvalue.examplebucket.max-history-per-key =5nats :
default :
jetstream :
keyvalue :
examplebucket :
storage-type : Memory
max-history-per-key : 5 [nats .default .jetstream .keyvalue .examplebucket ]
storage-type = "Memory"
max-history-per-key = 5 nats {
'default' {
jetstream {
keyvalue {
examplebucket {
storageType = 'Memory'
maxHistoryPerKey = 5
}
}
}
}
} {
nats = {
default = {
jetstream = {
keyvalue = {
examplebucket = {
storage-type = "Memory"
max-history-per-key = 5
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"keyvalue" : {
"examplebucket" : {
"storage-type" : "Memory" ,
"max-history-per-key" : 5
}
}
}
}
}
}
Compression, metadata, placement and republish can be configured as for streams:
nats.default.jetstream.keyvalue.examplebucket.compression =true
nats.default.jetstream.keyvalue.examplebucket.metadata.owner =team-a
nats.default.jetstream.keyvalue.examplebucket.placement.cluster =east
nats.default.jetstream.keyvalue.examplebucket.republish.source =$KV.examplebucket.>
nats.default.jetstream.keyvalue.examplebucket.republish.destination =changes.examplebucket.>nats :
default :
jetstream :
keyvalue :
examplebucket :
compression : true
metadata :
owner : team-a
placement :
cluster : east
republish :
source : "$KV.examplebucket.>"
destination : "changes.examplebucket.>" [nats .default .jetstream .keyvalue .examplebucket ]
compression = true
[nats .default .jetstream .keyvalue .examplebucket .metadata ]
owner = "team-a"
[nats .default .jetstream .keyvalue .examplebucket .placement ]
cluster = "east"
[nats .default .jetstream .keyvalue .examplebucket .republish ]
source = "$KV.examplebucket.>"
destination = "changes.examplebucket.>" nats {
'default' {
jetstream {
keyvalue {
examplebucket {
compression = true
metadata {
owner = 'team-a'
}
placement {
cluster = 'east'
}
republish {
source = '$KV.examplebucket.>'
destination = 'changes.examplebucket.>'
}
}
}
}
}
} {
nats = {
default = {
jetstream = {
keyvalue = {
examplebucket = {
compression = true
metadata = {
owner = "team-a"
}
placement = {
cluster = "east"
}
republish = {
source = "$KV.examplebucket.>"
destination = "changes.examplebucket.>"
}
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"keyvalue" : {
"examplebucket" : {
"compression" : true ,
"metadata" : {
"owner" : "team-a"
},
"placement" : {
"cluster" : "east"
},
"republish" : {
"source" : "$KV.examplebucket.>" ,
"destination" : "changes.examplebucket.>"
}
}
}
}
}
}
}
A bucket can also mirror another bucket, or be sourced from other buckets. The names refer to the buckets, the KV_ prefix of the underlying streams is added automatically.
nats.default.jetstream.keyvalue.examplebucket-mirror.mirror.name =examplebucket
nats.default.jetstream.keyvalue.aggregated-bucket.sources[0].name =examplebucket
nats.default.jetstream.keyvalue.aggregated-bucket.sources[1].name =otherbucketnats :
default :
jetstream :
keyvalue :
examplebucket-mirror :
mirror :
name : examplebucket
aggregated-bucket :
sources :
- name : examplebucket
- name : otherbucket [nats .default .jetstream .keyvalue .examplebucket-mirror .mirror ]
name = "examplebucket"
[[nats .default .jetstream .keyvalue .aggregated-bucket .sources ]]
name = "examplebucket"
[[nats .default .jetstream .keyvalue .aggregated-bucket .sources ]]
name = "otherbucket" nats {
'default' {
jetstream {
keyvalue {
examplebucketMirror {
mirror {
name = 'examplebucket'
}
}
aggregatedBucket {
sources = ['[object Object]' , '[object Object]' ]
}
}
}
}
} {
nats = {
default = {
jetstream = {
keyvalue = {
examplebucket-mirror = {
mirror = {
name = "examplebucket"
}
}
aggregated-bucket = {
sources = [{
name = "examplebucket"
}, {
name = "otherbucket"
}]
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"keyvalue" : {
"examplebucket-mirror" : {
"mirror" : {
"name" : "examplebucket"
}
},
"aggregated-bucket" : {
"sources" : [
{
"name" : "examplebucket"
},
{
"name" : "otherbucket"
}
]
}
}
}
}
}
}
Note
The entries are mirrored into the stream KV_<mirror bucket> keeping the subjects of the origin bucket, so reading them through the mirror bucket name does not return them. Read through the origin bucket instead; the mirror is allowed to answer those reads (mirror direct).
Nats.io provides an KeyValue interface for the usage of Key/Value Stores.
To use it, just inject your Key/Value Store as follows:
imports import io.micronaut.nats.jetstream.annotation.KeyValueStore;
import io.nats.client.JetStreamApiException;
import io.nats.client.KeyValue;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.io.IOException;@Singleton
public class KeyValueStoreHolder {
@Inject
@KeyValueStore ("examplebucket" )
KeyValue store; //
public void put (String key , String value ) throws JetStreamApiException, IOException {
store.put (key, value);
}
} imports from jakarta.inject import Inject, Singleton
from micronaut.nats.jetstream.annotation import KeyValueStore
from io.nats.client import KeyValue@Singleton
class KeyValueStoreHolder :
store: Annotated[KeyValue, Inject, KeyValueStore("examplebucket" )] #
def put (self, key: str , value: str ) -> None :
self .store.put(key, value)imports import io.micronaut.nats.jetstream.annotation.KeyValueStore
import io.nats.client.KeyValue
import jakarta.inject.Inject
import jakarta.inject.Singleton @Singleton
class KeyValueStoreHolder {
@Inject
@field :KeyValueStore ("examplebucket ")
lateinit var store: KeyValue //
fun put (key: String , value :String ) {
store.put (key, value )
}
} imports import io.micronaut.nats.jetstream.annotation.KeyValueStore
import io.nats.client.KeyValue
import jakarta.inject.Inject
import jakarta.inject.Singleton@Singleton
class KeyValueStoreHolder {
@Inject
@KeyValueStore ("examplebucket" )
KeyValue store //
void put (String key , String value ) {
store. put(key, value)
}
}
11.3 Object Store (Experimental)
Warning
Experimental Preview
The Object Store allows you to store data of any (i.e. large) size by implementing a chunking mechanism, allowing you to for example store and retrieve files (i.e. the object) of any size by associating them with a path and a file name (i.e. the key). You obtain a ObjectStoreManager object from your JetStream context.
A simple configuration for an object store can look like:
nats.default.jetstream.objectstore.examplebucket.storage-type =Memorynats :
default :
jetstream :
objectstore :
examplebucket :
storage-type : Memory [nats .default .jetstream .objectstore .examplebucket ]
storage-type = "Memory" nats {
'default' {
jetstream {
objectstore {
examplebucket {
storageType = 'Memory'
}
}
}
}
} {
nats = {
default = {
jetstream = {
objectstore = {
examplebucket = {
storage-type = "Memory"
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"objectstore" : {
"examplebucket" : {
"storage-type" : "Memory"
}
}
}
}
}
}
Compression, metadata and placement can be configured as well:
nats.default.jetstream.objectstore.examplebucket.storage-type =Memory
nats.default.jetstream.objectstore.examplebucket.compression =true
nats.default.jetstream.objectstore.examplebucket.metadata.owner =team-a
nats.default.jetstream.objectstore.examplebucket.placement.cluster =east
nats.default.jetstream.objectstore.examplebucket.placement.tags[0] =ssdnats :
default :
jetstream :
objectstore :
examplebucket :
storage-type : Memory
compression : true
metadata :
owner : team-a
placement :
cluster : east
tags :
- ssd [nats .default .jetstream .objectstore .examplebucket ]
storage-type = "Memory"
compression = true
[nats .default .jetstream .objectstore .examplebucket .metadata ]
owner = "team-a"
[nats .default .jetstream .objectstore .examplebucket .placement ]
cluster = "east"
tags = [ "ssd" ] nats {
'default' {
jetstream {
objectstore {
examplebucket {
storageType = 'Memory'
compression = true
metadata {
owner = 'team-a'
}
placement {
cluster = 'east'
tags = ['ssd' ]
}
}
}
}
}
} {
nats = {
default = {
jetstream = {
objectstore = {
examplebucket = {
storage-type = "Memory"
compression = true
metadata = {
owner = "team-a"
}
placement = {
cluster = "east"
tags = ["ssd"]
}
}
}
}
}
}
} {
"nats" : {
"default" : {
"jetstream" : {
"objectstore" : {
"examplebucket" : {
"storage-type" : "Memory" ,
"compression" : true ,
"metadata" : {
"owner" : "team-a"
},
"placement" : {
"cluster" : "east" ,
"tags" : [
"ssd"
]
}
}
}
}
}
}
}
Nats.io provides an ObjectStore interface for the usage of Object Stores.
To use it, just inject your Object Store as follows:
imports import io.micronaut.nats.jetstream.annotation.ObjectStore;
import io.nats.client.JetStreamApiException;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.io.IOException;
import java.io.InputStream;
import java.security.NoSuchAlgorithmException;@Singleton
public class ObjectStoreHolder {
@Inject
@ObjectStore ("examplebucket" )
io. nats. client. ObjectStore store; //
public void put (String key , InputStream inputStream ) throws JetStreamApiException, IOException, NoSuchAlgorithmException {
store.put (key, inputStream);
}
} imports from jakarta.inject import Inject, Singleton
from java.io import InputStream
from micronaut.nats.jetstream.annotation import ObjectStore
from io.nats.client import ObjectStore as NatsObjectStore@Singleton
class ObjectStoreHolder :
store: Annotated[NatsObjectStore, Inject, ObjectStore("examplebucket" )] #
def put (self, key: str , input_stream: InputStream) -> None :
self .store.put(key, input_stream)imports import io.micronaut.nats.jetstream.annotation.ObjectStore
import jakarta.inject.Inject
import jakarta.inject.Singleton
import java.io.InputStream @Singleton
class ObjectStoreHolder {
@Inject
@field :ObjectStore ("examplebucket ")
lateinit var store: io .nats.client.ObjectStore //
fun put (key: String , value :InputStream ) {
store.put (key, value )
}
} imports import io.micronaut.nats.jetstream.annotation.ObjectStore
import jakarta.inject.Inject
import jakarta.inject.Singleton@Singleton
class ObjectStoreHolder {
@Inject
@ObjectStore ("examplebucket" )
io.nats.client.ObjectStore store //
void put (String key , InputStream value ) {
store. put(key, value)
}
}
You can find the source code of this project in this repository: