All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.rouplex.platform.tcp.RouplexTcpClient Maven / Gradle / Ivy

package org.rouplex.platform.tcp;

import org.rouplex.commons.annotations.NotThreadSafe;
import org.rouplex.commons.annotations.Nullable;
import org.rouplex.nio.channels.SSLSocketChannel;
import org.rouplex.platform.io.Receiver;
import org.rouplex.platform.io.Sender;
import org.rouplex.platform.io.Throttle;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

/**
 * A class representing a TCP client and which provides access to the related output and input streams via
 * {@link Sender} and {@link Receiver} interfaces.
 *
 * Instances of this class are obtained via the inner builder, which in turn is instantiated via the
 * {@link RouplexTcpClient#newBuilder()} static method.
 *
 * @author Andi Mullaraj (andimullaraj at gmail.com)
 */
public class RouplexTcpClient extends RouplexTcpEndPoint {
    protected final static byte[] EOS_BA = new byte[0];
    private static final ByteBuffer EOS_BB = ByteBuffer.allocate(0);

    /**
     * A RouplexTcpClient builder. The builder can only build one client and once done, any future calls to alter the
     * builder or try to rebuild will fail with {@link IllegalStateException}. The builder is not thread safe.
     */
    @NotThreadSafe
    public static class Builder extends RouplexTcpEndPoint.Builder {
        protected SocketAddress remoteAddress;
        protected RouplexTcpClientListener rouplexTcpClientListener;

        protected void checkCanBuild() {
            if (remoteAddress == null) {
                throw new IllegalStateException("Missing value for remoteAddress");
            }
        }

        /**
         * An optional {@link SocketChannel}. May be connected, in which case the localAddress, remoteAddress and the
         * eventual {@link SSLContext} are ignored.
         *
         * @param socketChannel
         *          a socket channel, in connected or just in open (and not connected) state
         * @return
         *          the reference to this builder for chaining calls
         */
        public Builder withSocketChannel(SocketChannel socketChannel) {
            checkNotBuilt();

            this.selectableChannel = socketChannel;
            return builder;
        }

        /**
         * A remote {@link SocketAddress} where to connect to.
         *
         * @param remoteAddress
         *          the remote address to connect to
         * @return
         *          the reference to this builder for chaining calls
         */
        public Builder withRemoteAddress(SocketAddress remoteAddress) {
            checkNotBuilt();

            this.remoteAddress = remoteAddress;
            return builder;
        }

        /**
         * A remote host name and port, representing a remote address to connect to.
         *
         * @param hostname
         *          the name of the host to connect to
         * @param port
         *          the remote port to connect to
         * @return
         *          the reference to this builder for chaining calls
         */
        public Builder withRemoteAddress(String hostname, int port) {
            checkNotBuilt();

            this.remoteAddress = new InetSocketAddress(hostname, port);
            return builder;
        }

        /**
         * Weather the client should connect in secure mode or not. If secure, the sslContext provides the means to
         * access the key and trust stores; if sslContext is null then a relaxed SSLContext, providing no client
         * identity and accepting any server identity will be used. If not secure, the {@link SSLContext} should be
         * null and will be ignored.
         *
         * @param secure
         *          true if the client should connect securely to the remote endpoint
         * @param sslContext
         *          the sslContext to use, or null if an allow-all is preferred
         * @return
         *          the reference to this builder for chaining calls
         */
        public Builder withSecure(boolean secure, @Nullable SSLContext sslContext) {
            checkNotBuilt();

            this.sslContext = secure ? sslContext != null ? sslContext : buildRelaxedSSLContext() : null;
            return builder;
        }

        /**
         * Set the client lifecycle event listener.
         *
         * @param rouplexTcpClientListener
         *          the event listener
         * @return
         *          the reference to this builder for chaining calls
         */
        public Builder withRouplexTcpClientListener(RouplexTcpClientListener rouplexTcpClientListener) {
            checkNotBuilt();

            this.rouplexTcpClientListener = rouplexTcpClientListener;
            return builder;
        }

        /**
         * Build the client, start its connection and return immediately.
         *
         * @return
         *          the built but not necessarily connected client
         * @throws IOException
         *          if any problems arise during the client creation and connection initialization
         */
        @Override
        public RouplexTcpClient buildAsync() throws IOException {
            checkNotBuilt();
            checkCanBuild();

            if (selectableChannel == null) {
                selectableChannel = sslContext == null
                        ? SocketChannel.open() : SSLSocketChannel.open(sslContext);
            }

            RouplexTcpClient result = new RouplexTcpClient(this);
            builder = null;
            result.connectAsync();
            return result;
        }
    }

    /**
     * Create a new builder to be used to build a RouplexTcpClient.
     *
     * @return
     *          the new builder
     */
    public static Builder newBuilder() {
        return new Builder();
    }

    /**
     * Internal class for throttling the incoming traffic.
     */
    class ThrottledReceiver extends Throttle {
        private final SelectionKey selectionKey;
        @Nullable
        private Receiver receiver;
        boolean eosReceived;

        private long rateLimitCurrentTimestamp;
        private long rateLimitCurrentBytes;
        private long rateLimitBytes;
        private long rateLimitMillis;

        private ThrottledReceiver(SelectionKey selectionKey) {
            this.selectionKey = selectionKey;
        }

        @Override
        public boolean setMaxRate(long maxRate, long duration, TimeUnit timeUnit) {
            rateLimitBytes = maxRate;
            rateLimitMillis = timeUnit.toMillis(duration);
            resume(); // so the new values get computed
            return true;
        }

        @Override
        public boolean pause() {
            rouplexTcpSelector.asyncPauseRead(selectionKey, Long.MAX_VALUE); // 295 million years
            return true;
        }

        @Override
        public void resume() {
            rouplexTcpSelector.asyncResumeRead(selectionKey);
        }

        /**
         * Handle the payload which was read from the network by first forwarding it to the receiver (if existent),
         * then calculating the input rate and possibly pausing the producer via the throttle mechanism. If there is
         * no receiver set up, then the payload is simply discarded.
         *
         * @param payload
         *          the payload read from the channels input stream
         * @return
         *          true if there is no receiver or if the receiver was able to receive the payload, false otherwise
         */
        boolean consumeSocketInput(byte[] payload) {
            boolean consumed = receiver == null || receiver.receive(payload);

            if (payload == null) {
                return true;
            }

            if (rateLimitCurrentTimestamp != 0) {
                if (System.currentTimeMillis() > rateLimitCurrentTimestamp) {
                    rateLimitCurrentTimestamp = System.currentTimeMillis() + rateLimitMillis;
                    rateLimitBytes = 0;
                } else {
                    rateLimitCurrentBytes += payload.length;
                    if (rateLimitCurrentBytes > rateLimitBytes) {
                        rouplexTcpSelector.asyncPauseRead(selectionKey, rateLimitCurrentTimestamp);
                    }
                }
            }

            if (eosReceived = payload == EOS_BA) {
                handleEos();
            }

            return consumed;
        }
    }

    /**
     * Internal class for throttling the outgoing traffic.
     */
    class ThrottledSender implements Sender {
        private final LinkedList writeBuffers = new LinkedList();
        private final SelectionKey selectionKey;

        private long remaining;
        private Throttle throttle;
        boolean paused;
        boolean eosReceived;
        boolean eosApplied;

        private ThrottledSender(SelectionKey selectionKey) {
            this.selectionKey = selectionKey;

            try {
                remaining = ((SocketChannel) selectableChannel).socket().getSendBufferSize();
            } catch (IOException ioe) {
                throw new RuntimeException(ioe);
            }
        }

        /**
         * Copy bytes from source to destination, adjusting their positions accordingly. This call does not fail if
         * destination cannot accommodate all the bytes available in source, but copies as many as possible.
         *
         * @param source
         *          source buffer
         * @param destination
         *          destination buffer
         * @return
         *          the number of bytes copied
         */
        private int transfer(ByteBuffer source, ByteBuffer destination) {
            int srcRemaining;
            int destRemaining;

            if ((srcRemaining = source.remaining()) > (destRemaining = destination.remaining())) {
                int limit = source.limit();
                source.limit(source.position() + destRemaining);
                destination.put(source);
                source.limit(limit);
                return destRemaining;
            } else {
                if (source.hasRemaining()) {
                    destination.put(source);
                }

                return srcRemaining;
            }
        }

        /**
         * Send a payload to the remote endpoint.
         *
         * A payload with no remaining bytes is interpreted as an End-Of-Stream, in which case:
         * 1. Any future sends will fail with "Sender closed" IOException
         * 2. The sender will make sure all the write buffers are flushed, then shutdown the channel's output stream,
         * resulting in End-Of-Stream being transmitted to the remote endpoint
         * 3. Whenever the RouplexTcpClient receives an End-Of-Stream from the remote endpoint, it will close itself
         * and will notify the listener of the graceful close by passing null in the exception parameter.
         *
         * A null payload will be interpreted as an abrupt Sender close, and it will close the sender and the
         * RouplexTcpClient.
         *
         * @param payload
         *          the payload to be sent
         * @throws IOException
         *          if the Sender or the RouplexTcpClient is already closed, or any other issue sending the payload
         */
        @Override
        public void send(ByteBuffer payload) throws IOException {
            int writeSize;

            synchronized (lock) {
                if (eosReceived) {
                    throw new IOException("Sender is closed");
                }

                if (isClosed()) {
                    throw new IOException("TcpClient is closed");
                }

                if (payload == null) { // null is marker for client (abrupt) close
                    close();
                    return;
                }

                if (!payload.hasRemaining()) { // empty buffer is marker for channel (graceful) close
                    payload = EOS_BB;
                    eosReceived = true;
                } else if (paused) { // don't remove else keyword since we must always accept the EOS for delivery
                    return;
                }

                paused = remaining < payload.remaining();
                writeSize = (int) (paused ? remaining : payload.remaining());
                remaining -= writeSize;
            }

            ByteBuffer writeBuffer;
            if (payload != EOS_BB) {
                // potentially costly operations in this block so we must perform outside the lock space especially
                // since the removeWriteBuffer is performed from the RouplexTcpBinder's single thread responsible to go
                // over all the channels monitored!!!
                writeBuffer = ByteBuffer.allocate(writeSize);
                transfer(payload, writeBuffer);
                writeBuffer.flip();
            } else {
                writeBuffer = EOS_BB;
            }

            Throttle throttle;
            synchronized (lock) {
                writeBuffers.add(writeBuffer);
                throttle = paused ? this.throttle : null;
            }

            rouplexTcpSelector.asyncResumeWrite(selectionKey);

            if (throttle != null) {
                try {
                    // todo, pause / resume order not guaranteed here, options include (in order of preference)
                    // (1) fire it off the binder's thread, (2) remove the pause() since the caller's hasRemaining
                    // would imply the pause, or (3) fire them from within the locked space (at risk of deadlocks)
                    throttle.pause();
                } catch (RuntimeException re) {
                    closeSilently(re);
                }
            }
        }

        ByteBuffer pollFirstWriteBuffer() {
            synchronized (lock) {
                return writeBuffers.isEmpty() ? null : writeBuffers.iterator().next();
            }
        }

        /**
         * Called after all the content of a {@link ByteBuffer} has been written to the network. Eventually resume
         * the writes.
         *
         * @param writeBuffer
         *          the writeBuffer in the internal que, and to be removed
         */
        void removeWriteBuffer(ByteBuffer writeBuffer) {
            Throttle throttle;

            synchronized (lock) {
                writeBuffers.remove(writeBuffer);
                remaining += writeBuffer.limit();

                if (paused) {
                    paused = false;
                    throttle = this.throttle;
                } else {
                    throttle = null;
                }
            }

            if (throttle != null) {
                throttle.resume();
            }

            if (writeBuffer == EOS_BB) {
                eosApplied = true;
                handleEos();
            }
        }
    }

    /**
     * Update the RouplexTcpClient taking into consideration the state of its sender and receiver.
     */
    private void handleEos() {
        if (throttledSender.eosApplied && throttledReceiver.eosReceived) {
            closeSilently(null); // a successful close
        }
    }

    /**
     * Called by {@link RouplexTcpSelector} when the underlying channel just got connected to the remote endpoint.
     */
    void handleConnected() {
        handleOpen(null);

        if (rouplexTcpClientListener != null) {
            rouplexTcpClientListener.onConnected(this);
        }
    }

    /**
     * Called by {@link RouplexTcpSelector} when the underlying channel just failed connection to the remote endpoint.
     */
    void handleConnectionFailed(@Nullable Exception optionalReason) {
        if (rouplexTcpClientListener != null) {
            rouplexTcpClientListener.onConnectionFailed(this, optionalReason);
        }
    }

    /**
     * Called by {@link RouplexTcpSelector} when the underlying channel just got disconnected from the remote endpoint.
     */
    boolean handleDisconnected(@Nullable Exception optionalReason) {
        boolean drainedChannels = throttledReceiver.eosReceived && throttledSender.eosApplied;

        if (rouplexTcpClientListener != null) {
            rouplexTcpClientListener.onDisconnected(this, optionalReason, drainedChannels);
        }

        return drainedChannels;
    }

    protected final RouplexTcpServer rouplexTcpServer; // not null if this channel was created by a rouplexTcpServer
    protected final RouplexTcpClientListener rouplexTcpClientListener;

    protected ThrottledSender throttledSender;
    protected ThrottledReceiver throttledReceiver;

    /**
     * Construct an instance using a prepared {@link Builder} instance.
     *
     * @param builder
     *          the builder providing all needed info for creation of the client
     */
    RouplexTcpClient(Builder builder) {
        super(builder);

        this.rouplexTcpServer = null;
        this.rouplexTcpClientListener = builder.rouplexTcpClientListener;
    }

    /**
     * Construct an instance by wrapping a {@link SocketChannel} obtained via a {@link ServerSocketChannel#accept()}.
     *
     * @param socketChannel
     *          the underlying channel to use for reading and writing to the network
     * @param rouplexTcpSelector
     *          the rouplexTcpSelector wrapping the {@link Selector} used to register the channel
     * @param rouplexTcpServer
     *          the rouplexTcpServer which accepted the underlying channel
     */
    RouplexTcpClient(SocketChannel socketChannel, RouplexTcpSelector rouplexTcpSelector, RouplexTcpServer rouplexTcpServer) {
        super(socketChannel, rouplexTcpSelector);

        this.rouplexTcpServer = rouplexTcpServer;
        this.rouplexTcpClientListener = null;
    }

    /**
     * Start the connection sequence if the underlying channel is not already connected.
     *
     * @throws IOException
     *          if any problem arises
     */
    private void connectAsync() throws IOException {
        SocketChannel socketChannel = (SocketChannel) selectableChannel;

        socketChannel.configureBlocking(false);
        if (!socketChannel.isConnectionPending() && !socketChannel.isConnected()) {
            socketChannel.connect(((Builder) builder).remoteAddress);
        }

        rouplexTcpSelector.asyncRegisterTcpEndPoint(this);
    }

    /**
     * Get the remote endpoint address where this instance is connected, or is connecting to.
     *
     * @return
     *          the remote endpoint address
     * @throws IOException
     *          if the instance is already closed or any other problem retrieving the remote address
     */
    public SocketAddress getRemoteAddress() throws IOException {
        synchronized (lock) {
            if (isClosed()) {
                throw new IOException("Already closed");
            }

            return ((SocketChannel) selectableChannel).getRemoteAddress();
        }
    }

    /**
     * This method is called from {@link RouplexTcpSelector} when it registers this endpoint.
     *
     * @param selectionKey
     *          the selection key, result of registering this channel with the RouplexTcpSelector's selector
     */
    void setSelectionKey(SelectionKey selectionKey) {
        throttledSender = new ThrottledSender(selectionKey);
        throttledReceiver = new ThrottledReceiver(selectionKey);
    }

    /**
     * Hook (rather get) the sender to be used for sending bytes to the remote endpoint. The provided throttle will be
     * used by this instance to notify when to resume after a pause.
     *
     * A {@link ByteBuffer} of 0 remaining bytes is considered as End-Of-Stream and will be handled by shutting down
     * the underlying socketChannel's output stream (after the underlying buffers are completely flushed out). As a
     * result, the remote endpoint will receive an empty byte array, indicating the End-Of-Stream. When subsequently,
     * an End-Of-Stream marker is received from the remote endpoint, then the RouplexTcpClient is closed gracefully,
     * and the listener will be notified of the disconnected RouplexTcpClient with a null exception (graceful).
     *
     * Sending a null payload is considered as an abrupt disconnect and will close the underlying socket channel
     * without bothering to flush any remaining data first. The remote endpoint will receive a null payload, in turn.
     *
     * @param throttle
     *          a throttle instance to be used to control the traffic flow (via calls to pause / resume)
     * @return
     *          the sender to be used to send payloads.
     */
    public Sender hookSendChannel(Throttle throttle) {
        synchronized (lock) {
            if (throttledSender.throttle != null) {
                throw new IllegalStateException("Send channel already hooked.");
            }

            throttledSender.throttle = throttle;
            return throttledSender;
        }
    }

    /**
     * Hook (rather pass) the {@link Receiver} which will be receiving the read bytes from the remote endpoint.
     * Normally, the receiver will be called with non-empty byte buffers. A zero length byte buffer must be interpreted
     * as an End-Of-Stream. A null byte buffer must be interpreted as an abrupt disconnect and the RouplexTcpClient
     * will be closed automatically, and the listener will be notified along wth the respective exception.
     *
     * @param receiver
     *          the receiver instance for receiving the payloads
     * @param started
     *          true if the caller is prepared to receive payloads, false to postpone the firing of incoming payloads
     * @return
     *          the throttle construct which the receiver can use to control the flow of receiving bytes
     */
    public Throttle hookReceiveChannel(@Nullable Receiver receiver, boolean started) {
        synchronized (lock) {
            if (throttledReceiver.receiver != null) {
                throw new IllegalStateException("Receive channel already hooked.");
            }

            throttledReceiver.receiver = receiver;
            if (started) {
                throttledReceiver.resume();
            }

            return throttledReceiver;
        }
    }

    /**
     * Get the local server this client belongs to, or null if this client in not related to a local server.
     *
     * @return
     *          the RouplexTcpServer this client belongs to, if any
     */
    public RouplexTcpServer getRouplexTcpServer() {
        return rouplexTcpServer;
    }

    private static final TrustManager trustAll = new X509TrustManager() {
        @Override
        public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
        }

        @Override
        public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return new X509Certificate[0];
        }
    };

    /**
     * Convenience method for building client side {@link SSLContext} instances that do not provide a client identity
     * and accept any server identity.
     *
     * @return
     *          a relaxed sslContext
     */
    public static SSLContext buildRelaxedSSLContext() {
        try {
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, new TrustManager[] {trustAll}, null);
            return sslContext;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy