I/O Architecture
The Jetty client libraries provide the basic components and APIs to implement a network client.
They build on the common Jetty I/O Architecture and provide client specific concepts (such as establishing a connection to a server).
There are conceptually two layers that compose the Jetty client libraries:
-
The network layer, that handles the low level I/O and deals with buffers, threads, etc.
-
The protocol layer, that handles the parsing of bytes read from the network and the generation of bytes to write to the network.
Network Layer
The Jetty client libraries use the common I/O design described in this section.
The main client-side component is the ClientConnector
.
The ClientConnector
primarily wraps the SelectorManager
and aggregates other four components:
-
a thread pool (in form of an
java.util.concurrent.Executor
) -
a scheduler (in form of
org.eclipse.jetty.util.thread.Scheduler
) -
a byte buffer pool (in form of
org.eclipse.jetty.io.ByteBufferPool
) -
a TLS factory (in form of
org.eclipse.jetty.util.ssl.SslContextFactory.Client
)
The ClientConnector
is where you want to set those components after you have configured them.
If you don’t explicitly set those components on the ClientConnector
, then appropriate defaults will be chosen when the ClientConnector
starts.
The simplest example that creates and starts a ClientConnector
is the following:
ClientConnector clientConnector = new ClientConnector();
clientConnector.start();
A more typical example:
// Create and configure the SslContextFactory.
SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
sslContextFactory.addExcludeProtocols("TLSv1", "TLSv1.1");
// Create and configure the thread pool.
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setName("client");
// Create and configure the ClientConnector.
ClientConnector clientConnector = new ClientConnector();
clientConnector.setSslContextFactory(sslContextFactory);
clientConnector.setExecutor(threadPool);
clientConnector.start();
A more advanced example that customizes the ClientConnector
by overriding some of its methods:
class CustomClientConnector extends ClientConnector
{
@Override
protected SelectorManager newSelectorManager()
{
return new ClientSelectorManager(getExecutor(), getScheduler(), getSelectors())
{
@Override
protected void endPointOpened(EndPoint endpoint)
{
System.getLogger("endpoint").log(INFO, "opened %s", endpoint);
}
@Override
protected void endPointClosed(EndPoint endpoint)
{
System.getLogger("endpoint").log(INFO, "closed %s", endpoint);
}
};
}
}
// Create and configure the thread pool.
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setName("client");
// Create and configure the scheduler.
Scheduler scheduler = new ScheduledExecutorScheduler("scheduler-client", false);
// Create and configure the custom ClientConnector.
CustomClientConnector clientConnector = new CustomClientConnector();
clientConnector.setExecutor(threadPool);
clientConnector.setScheduler(scheduler);
clientConnector.start();
Since ClientConnector
is the component that handles the low-level network, it is also the component where you want to configure the low-level network configuration.
The most common parameters are:
-
ClientConnector.selectors
: the number ofjava.nio.Selector
s components (defaults to1
) that are present to handle theSocketChannel
s opened by theClientConnector
. You typically want to increase the number of selectors only for those use cases where each selector should handle more than few hundreds concurrent socket events. For example, one selector typically runs well for250
concurrent socket events; as a rule of thumb, you can multiply that number by10
to obtain the number of opened sockets a selector can handle (2500
), based on the assumption that not all the2500
sockets will be active at the same time. -
ClientConnector.idleTimeout
: the duration of time after whichClientConnector
closes a socket due to inactivity (defaults to30
seconds). This is an important parameter to configure, and you typically want the client idle timeout to be shorter than the server idle timeout, to avoid race conditions where the client attempts to use a socket just before the client-side idle timeout expires, but the server-side idle timeout has already expired and the is already closing the socket. -
ClientConnector.connectBlocking
: whether the operation of connecting a socket to the server (i.e.SocketChannel.connect(SocketAddress)
) must be a blocking or a non-blocking operation (defaults tofalse
). Forlocalhost
or same datacenter hosts you want to set this parameter totrue
because DNS resolution will be immediate (and likely never fail). For generic Internet hosts (e.g. when you are implementing a web spider) you want to set this parameter tofalse
. -
ClientConnector.connectTimeout
: the duration of time after whichClientConnector
aborts a connection attempt to the server (defaults to5
seconds). This time includes the DNS lookup time and the TCP connect time.
Please refer to the ClientConnector
javadocs for the complete list of configurable parameters.
Unix-Domain Support
JEP 380 introduced Unix-Domain sockets support in Java 16, on all operative systems.
ClientConnector
can be configured to support Unix-Domain sockets in the following way:
// This is the path where the server "listens" on.
Path unixDomainPath = Path.of("/path/to/server.sock");
// Creates a ClientConnector that uses Unix-Domain
// sockets, not the network, to connect to the server.
ClientConnector clientConnector = ClientConnector.forUnixDomain(unixDomainPath);
clientConnector.start();
You can use Unix-Domain sockets support only when you run your client application with Java 16 or later. |
Protocol Layer
The protocol layer builds on top of the network layer to generate the bytes to be written to the network and to parse the bytes read from the network.
Recall from this section that Jetty uses the Connection
abstraction to produce and interpret the network bytes.
On the client side, a ClientConnectionFactory
implementation is the component that creates Connection
instances based on the protocol that the client wants to "speak" with the server.
Applications use ClientConnector.connect(SocketAddress, Map<String, Object>)
to establish a TCP connection to the server, and must tell ClientConnector
how to create the Connection
for that particular TCP connection, and how to notify back the application when the connection creation succeeds or fails.
This is done by passing a ClientConnectionFactory
(that creates Connection
instances) and a Promise
(that is notified of connection creation success or failure) in the context Map
as follows:
class CustomConnection extends AbstractConnection
{
public CustomConnection(EndPoint endPoint, Executor executor)
{
super(endPoint, executor);
}
@Override
public void onOpen()
{
super.onOpen();
System.getLogger("connection").log(INFO, "Opened connection {0}", this);
}
@Override
public void onFillable()
{
}
}
ClientConnector clientConnector = new ClientConnector();
clientConnector.start();
String host = "serverHost";
int port = 8080;
SocketAddress address = new InetSocketAddress(host, port);
// The ClientConnectionFactory that creates CustomConnection instances.
ClientConnectionFactory connectionFactory = (endPoint, context) ->
{
System.getLogger("connection").log(INFO, "Creating connection for {0}", endPoint);
return new CustomConnection(endPoint, clientConnector.getExecutor());
};
// The Promise to notify of connection creation success or failure.
CompletableFuture<CustomConnection> connectionPromise = new Promise.Completable<>();
// Populate the context with the mandatory keys to create and obtain connections.
Map<String, Object> context = new HashMap<>();
context.put(ClientConnector.CLIENT_CONNECTION_FACTORY_CONTEXT_KEY, connectionFactory);
context.put(ClientConnector.CONNECTION_PROMISE_CONTEXT_KEY, connectionPromise);
clientConnector.connect(address, context);
// Use the Connection when it's available.
// Use it in a non-blocking way via CompletableFuture APIs.
connectionPromise.whenComplete((connection, failure) ->
{
System.getLogger("connection").log(INFO, "Created connection for {0}", connection);
});
// Alternatively, you can block waiting for the connection (or a failure).
// CustomConnection connection = connectionPromise.get();
When a Connection
is created successfully, its onOpen()
method is invoked, and then the promise is completed successfully.
It is now possible to write a super-simple telnet
client that reads and writes string lines:
class TelnetConnection extends AbstractConnection
{
private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
private Consumer<String> consumer;
public TelnetConnection(EndPoint endPoint, Executor executor)
{
super(endPoint, executor);
}
@Override
public void onOpen()
{
super.onOpen();
// Declare interest for fill events.
fillInterested();
}
@Override
public void onFillable()
{
try
{
ByteBuffer buffer = BufferUtil.allocate(1024);
while (true)
{
int filled = getEndPoint().fill(buffer);
if (filled > 0)
{
while (buffer.hasRemaining())
{
// Search for newline.
byte read = buffer.get();
if (read == '\n')
{
// Notify the consumer of the line.
consumer.accept(bytes.toString(StandardCharsets.UTF_8));
bytes.reset();
}
else
{
bytes.write(read);
}
}
}
else if (filled == 0)
{
// No more bytes to fill, declare
// again interest for fill events.
fillInterested();
return;
}
else
{
// The other peer closed the
// connection, close it back.
getEndPoint().close();
return;
}
}
}
catch (Exception x)
{
getEndPoint().close(x);
}
}
public void onLine(Consumer<String> consumer)
{
this.consumer = consumer;
}
public void writeLine(String line, Callback callback)
{
line = line + "\r\n";
getEndPoint().write(callback, ByteBuffer.wrap(line.getBytes(StandardCharsets.UTF_8)));
}
}
ClientConnector clientConnector = new ClientConnector();
clientConnector.start();
String host = "wikipedia.org";
int port = 80;
SocketAddress address = new InetSocketAddress(host, port);
ClientConnectionFactory connectionFactory = (endPoint, context) ->
new TelnetConnection(endPoint, clientConnector.getExecutor());
CompletableFuture<TelnetConnection> connectionPromise = new Promise.Completable<>();
Map<String, Object> context = new HashMap<>();
context.put(ClientConnector.CLIENT_CONNECTION_FACTORY_CONTEXT_KEY, connectionFactory);
context.put(ClientConnector.CONNECTION_PROMISE_CONTEXT_KEY, connectionPromise);
clientConnector.connect(address, context);
connectionPromise.whenComplete((connection, failure) ->
{
if (failure == null)
{
// Register a listener that receives string lines.
connection.onLine(line -> System.getLogger("app").log(INFO, "line: {0}", line));
// Write a line.
connection.writeLine("" +
"GET / HTTP/1.0\r\n" +
"", Callback.NOOP);
}
else
{
failure.printStackTrace();
}
});
Note how a very basic "telnet" API that applications could use is implemented in the form of the onLine(Consumer<String>)
for the non-blocking receiving side and writeLine(String, Callback)
for the non-blocking sending side.
Note also how the onFillable()
method implements some basic "parsing" by looking up the \n
character in the buffer.
The "telnet" client above looks like a super-simple HTTP client because HTTP/1.0 can be seen as a line-based protocol. HTTP/1.0 was used just as an example, but we could have used any other line-based protocol such as SMTP, provided that the server was able to understand it. |
This is very similar to what the Jetty client implementation does for real network protocols. Real network protocols are of course more complicated and so is the implementation code that handles them, but the general ideas are similar.
The Jetty client implementation provides a number of ClientConnectionFactory
implementations that can be composed to produce and interpret the network bytes.
For example, it is simple to modify the above example to use the TLS protocol so that you will be able to connect to the server on port 443
, typically reserved for the encrypted HTTP protocol.
The differences between the clear-text version and the TLS encrypted version are minimal:
class TelnetConnection extends AbstractConnection
{
private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
private Consumer<String> consumer;
public TelnetConnection(EndPoint endPoint, Executor executor)
{
super(endPoint, executor);
}
@Override
public void onOpen()
{
super.onOpen();
// Declare interest for fill events.
fillInterested();
}
@Override
public void onFillable()
{
try
{
ByteBuffer buffer = BufferUtil.allocate(1024);
while (true)
{
int filled = getEndPoint().fill(buffer);
if (filled > 0)
{
while (buffer.hasRemaining())
{
// Search for newline.
byte read = buffer.get();
if (read == '\n')
{
// Notify the consumer of the line.
consumer.accept(bytes.toString(StandardCharsets.UTF_8));
bytes.reset();
}
else
{
bytes.write(read);
}
}
}
else if (filled == 0)
{
// No more bytes to fill, declare
// again interest for fill events.
fillInterested();
return;
}
else
{
// The other peer closed the
// connection, close it back.
getEndPoint().close();
return;
}
}
}
catch (Exception x)
{
getEndPoint().close(x);
}
}
public void onLine(Consumer<String> consumer)
{
this.consumer = consumer;
}
public void writeLine(String line, Callback callback)
{
line = line + "\r\n";
getEndPoint().write(callback, ByteBuffer.wrap(line.getBytes(StandardCharsets.UTF_8)));
}
}
ClientConnector clientConnector = new ClientConnector();
clientConnector.start();
// Use port 443 to contact the server using encrypted HTTP.
String host = "wikipedia.org";
int port = 443;
SocketAddress address = new InetSocketAddress(host, port);
ClientConnectionFactory connectionFactory = (endPoint, context) ->
new TelnetConnection(endPoint, clientConnector.getExecutor());
// Wrap the "telnet" ClientConnectionFactory with the SslClientConnectionFactory.
connectionFactory = new SslClientConnectionFactory(clientConnector.getSslContextFactory(),
clientConnector.getByteBufferPool(), clientConnector.getExecutor(), connectionFactory);
// We will obtain a SslConnection now.
CompletableFuture<SslConnection> connectionPromise = new Promise.Completable<>();
Map<String, Object> context = new HashMap<>();
context.put(ClientConnector.CLIENT_CONNECTION_FACTORY_CONTEXT_KEY, connectionFactory);
context.put(ClientConnector.CONNECTION_PROMISE_CONTEXT_KEY, connectionPromise);
clientConnector.connect(address, context);
connectionPromise.whenComplete((sslConnection, failure) ->
{
if (failure == null)
{
// Unwrap the SslConnection to access the "line" APIs in TelnetConnection.
TelnetConnection connection = (TelnetConnection)sslConnection.getDecryptedEndPoint().getConnection();
// Register a listener that receives string lines.
connection.onLine(line -> System.getLogger("app").log(INFO, "line: {0}", line));
// Write a line.
connection.writeLine("" +
"GET / HTTP/1.0\r\n" +
"", Callback.NOOP);
}
else
{
failure.printStackTrace();
}
});
The differences with the clear-text version are only:
-
Change the port from
80
to443
. -
Wrap the
ClientConnectionFactory
withSslClientConnectionFactory
. -
Unwrap the
SslConnection
to accessTelnetConnection
.