Café & Tapioca

Entendendo protocolos TCP em Java.

Prefácio

Entender como funcionam as redes de computadores é entender toda a base que rege o funcionamento do mundo contemporâneo. As redes de internet fazem parte de um componente motor essencial para quase todas as operações nas quais são realizadas em computadores; desde usar um gerenciador de pacotes dentro do seu SO, ou baixar uma imagem Docker do Docker Hub, tudo isso depende da conexão entre um cliente e um servidor.

Universidades com seus sistemas acadêmicos que lidam com milhares de requisições simultâneas de alunos, sistemas de chats como o WhatsApp que permitem a comunicação entre pessoas ou comércios, redes sociais como o Reddit que funcionam em uma dinâmica de comunidades que se retroalimentam, e por aí vai…

Redes de computadores

Primeiramente precisamos definir alguns termos, para assim começarmos a prosseguir com alguns entendimento sobre como redes funcionam. Aqui vai um breve dicionário de termos que são bastante usados no estudo de redes:

Protocolos de rede

Todas as atividades dentro da internet envolvem pelo menos a comunicação entre dois pontos, que devem seguir um protocolo para se comunicarem. Por exemplo: ao acessar o site “google.com” você está fazendo uma requisição GET dentro do protocolo HTTP, onde o servidor é o responsável por devolver ao usuário a página que foi requisitada na URL.

Arquiteturas de aplicação

A arquitetura de aplicação é uma forma arbitrária, decidida pelo programador, de compôr um sistema final. Temos alguns tipos bem conhecidos de arquiteturas de aplicação:

TCP

Dentro deste post falarei mais sobre o protocolo TCP, pois envolve a dinâmica que rege o funcionamento atual da web, o TCP/IP. O protocolo TCP é resumido em uma forma confiável e íntegra de transferência de dados via pacotes.

Além de depender de sockets para haver uma comunicação, o TCP controla dentro do próprio protocolo a segmentação, fluxo e garante que tudo foi entregue na ordem como deveria.

TCP/IP

Como foi posto anteriormente, o TCP/IP estabelece uma junção dos dois protocolos, sendo o IP o responsável por definir o endereço no qual o pacote deve ser enviado, enquanto o TCP fica responsável por enviar e garantir a integridade desses pacotes.

Esse modelo representa a forma na qual os dados são passados entre redes, podendo essas serem de longa distância, ele é dividido em 4 camadas que explicam mais a fundo o funcionamento dessa comunicação:

  1. datalink: essa define como os dados serão enviados, de forma chula, é a camada de link, camada física e camada de acesso a rede.
  2. internet: responsável por enviar e controlar os pacotes, para que cheguem de forma correta ao seu destinatário, essa é a camada que divide e segmenta os dados em pacotes.
  3. transporte: fornece uma conexão sólida e confiável de dados entre quem envia e o destinatário, ela define quem envia, para onde, quantos dados devem ser enviados e em qual taxa devem ser mandados.
  4. aplicativo: refere-se a programas que precisam desse protocolo para a comunicação, normalmente o usuário interage com essa parte.

Dentro do TCP/IP os pacotes não são privados, ou seja, podem ser interceptados por um terceiro com acesso a rede, portanto é muito necessário uma camada de criptografia para proteger a mensagem em translado.

Exemplo de Echo em Java

Aqui coloco um projeto desenvolvido em Java para colocar em práticas conceitos de TCP, basicamente um servidor que ecoa a mensagem entre um cliente e servidor. Essa implementação é multi-thread, porém síncrona, portanto, suporte múltiplas conexões e responde apenas uma por vez, em fila.

Obs: optei por manter a documentação que foi feita pro javadoc, para evitar reescrever o que cada classe faz.

Cliente

/**
 * Implementação de um cliente TCP Echo.
 * <p>
 * Esta classe permite que o usuário se conecte a um servidor, envie mensagens
 * do console ({@code stdin}) e exiba as mensagens de eco recebidas do servidor.
 * Ela também mede e exibe a latência de cada requisição.
 * </p>
 */
public class TcpEchoClientImpl implements TcpEchoClient {

    /**
     * Inicia o cliente, conecta-se ao servidor e gerencia a comunicação de eco.
     *
     * @param ip   O endereço IP do servidor para se conectar.
     * @param port A porta do servidor.
     * @throws ConnectionException se ocorrer um erro durante a conexão ou comunicação.
     */
    @Override
    public void start(String ip, int port) {
        try (
                var clientSocket = new Socket(ip, port);
                var in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8));
                var out = new PrintWriter(clientSocket.getOutputStream(), true, StandardCharsets.UTF_8);
                var stdin = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))
        ) {

            System.out.println("\nConectado ao host: " + ip + ":" + port);

            System.out.println("Servidor: " + in.readLine());
            System.out.println("Servidor: " + in.readLine());

            String line;

            while ((line = stdin.readLine()) != null) {
                var ini = System.nanoTime();

                /*
                 * --> Instruções do projeto
                 * Protocolo de comunicação: cada mensagem deve ser finalizada com um '\n'
                 */
                out.write(line + "\n");
                out.flush();

                var resp = in.readLine();
                if(resp == null || resp.contains("Tempo limite de inatividade atingido")) {
                    System.out.println("\nServidor: " + resp);
                    System.out.println("Servidor encerrou conexão.");
                    break;
                }

                var fim = System.nanoTime();

                if("quit".equalsIgnoreCase(line)) {
                    System.out.println("Fechando conexão.");
                    break;
                }
                System.out.println("\nServidor: " + resp);

                double latenciaMs = (double) (fim - ini) / 1_000_000.0;
                System.out.printf("Latência: %.3f ms\n\n", latenciaMs);
            }
        } catch (IOException e) {
            throw new ConnectionException("Erro ao realizar conexão", e);
        } catch (Exception e) {
            throw new ConnectionException("Erro inesperado ao realizar conexão", e);
        }
    }
}

Servidor

/**
 * Implementação de um servidor TCP Echo multithreaded.
 * <p>
 * Este servidor aceita múltiplas conexões de clientes. Ele usa uma {@link java.util.concurrent.BlockingQueue}
 * para gerenciar as conexões recebidas e as processa em uma thread separada,
 * permitindo que o servidor continue aceitando novas conexões rapidamente.
 * </p>
 */
public class TcpEchoServerImpl implements TcpEchoServer {

    private final BlockingQueue<Socket> connectionQueue = new LinkedBlockingQueue<>();
    private volatile boolean running = true;

    /**
     * Inicia o servidor em uma porta especificada.
     * O servidor aceita novas conexões em um loop e as adiciona à fila de processamento.
     * Uma thread separada é responsável por processar as conexões da fila.
     *
     * @param port A porta na qual o servidor irá escutar.
     * @throws ConnectionException se ocorrer um erro ao iniciar o servidor ou aceitar uma conexão.
     */
    @Override
    public void start(int port) {
        try (var serverSocket = new ServerSocket(port)) {
            System.out.println("\nServidor conectado na porta: " + port);

            new Thread(this::processConnections, "ConnectionProcessor").start();

            while (running) {
                var socket = serverSocket.accept();
                connectionQueue.put(socket);

                System.out.println("\nNova conexão aceita: " + socket.getRemoteSocketAddress());

                sendQueuedMessage(socket);
            }
        } catch (IOException e) {
            throw new ConnectionException("Erro ao aceitar nova conexão", e);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Servidor interrompido.");
        }
    }

    /**
     * Envia uma mensagem inicial ao cliente informando que a conexão foi enfileirada.
     *
     * @param socket O socket do cliente para o qual a mensagem será enviada.
     * @throws ConnectionException se ocorrer um erro de I/O ao enviar a mensagem.
     */
    private void sendQueuedMessage(Socket socket) {
        try {
            var out = new PrintWriter(socket.getOutputStream(), true, StandardCharsets.UTF_8);
            out.println("Sua conexão foi enfileirada. Aguarde para ser atendido.");

        } catch (IOException e) {
            throw new ConnectionException("Erro ao enviar mensagem inicial: ", e);
        }
    }

    /**
     * Método executado em uma thread separada para processar as conexões da fila.
     * Este método pega uma conexão da fila, atende o cliente e o ecoa as mensagens.
     * Além disso, trata de tempos de inatividade (com um timeout de 10 segundos),
     * encerrando a conexão se o cliente não enviar dados dentro desse intervalo.
     */
    private void processConnections() {
        while (running) {
            try (var socket = connectionQueue.take();
                 var in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
                 var out = new PrintWriter(socket.getOutputStream(), true, StandardCharsets.UTF_8)) {
                socket.setSoTimeout(10_000);
                System.out.println("\nAtendendo cliente: " + socket.getRemoteSocketAddress() + "\n");
                out.println("O servidor está pronto para processar sua conexão.");

                String line;
                while (true) {
                    try {
                        line = in.readLine();
                        if (line == null) {
                            System.out.println("\nConexão encerrada pelo cliente: " + socket.getRemoteSocketAddress());
                            break;
                        }
                        if("quit".equalsIgnoreCase(line)) {
                            System.out.println("\nConexão finalizada pelo cliente.");
                            break;
                        }
                        System.out.println("Mensagem recebida: " + line);

                        /*
                         * --> Instruções do projeto
                         * Protocolo de comunicação: cada mensagem deve ser finalizada com um '\n'
                         */
                        out.write(line + "\n");
                        out.flush();
                    } catch (SocketTimeoutException ste) {
                        out.write("Tempo limite de inatividade atingido (10s). Encerrando conexão." + "\n");
                        out.flush();
                        System.out.println("Conexão encerrada: cliente inativo -> " + socket.getRemoteSocketAddress());
                        break;
                    }
                }
            } catch (IOException e) {
                throw new ConnectionException("Erro ao aceitar nova conexão", e);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new ConnectionException("Processamento interrompido", e);
            }
        }
    }

    /**
     * Sinaliza para o servidor que ele deve parar a execução.
     */
    public void stop() {
        running = false;
        System.out.println("Servidor será finalizado...");
    }
}

Application starter

/**
 * Orquestra o início da aplicação, validando os argumentos
 * da linha de comando e iniciando o modo de execução correto (cliente ou servidor).
 * <p>
 * Esta classe centraliza a lógica de validação de flags, IP e porta,
 * garantindo que a aplicação seja executada de forma segura e com os
 * parâmetros esperados.
 * </p>
 */
public class ApplicationStarter {
    private final FlagParser parser;
    private final TcpEchoServer server;
    private final TcpEchoClient client;

    /**
     * Constrói uma nova instância do {@code ApplicationStarter}.
     *
     * @param parser O analisador de flags para processar os argumentos da linha de comando.
     * @param client O cliente TCP a ser iniciado, se o modo cliente for selecionado.
     * @param server O servidor TCP a ser iniciado, se o modo servidor for selecionado.
     */
    public ApplicationStarter(FlagParser parser, TcpEchoClient client, TcpEchoServer server) {
        this.client = client;
        this.server = server;
        this.parser = parser;
    }
    
    /**
     * Executa a lógica de inicialização da aplicação.
     * <p>
     * Este método valida os argumentos fornecidos e, em seguida, inicia
     * o servidor ou o cliente com base nas flags `--server` ou `--client`.
     * </p>
     *
     * @throws ConnectionException se as flags de modo estiverem ausentes ou duplicadas,
     * ou se os argumentos de IP ou porta forem inválidos.
     */
    public void execute() {
        validateFlags();

        if(parser.has("server")) {
            int port = validatePort();
            server.start(port);
        }

        if(parser.has("client")) {
            String ip = validateIp();
            int port = validatePort();
            client.start(ip, port);
        }
    }

    /**
     * Valida as flags de modo de execução (`--server` e `--client`).
     * <p>
     * Garante que exatamente uma dessas flags esteja presente nos argumentos.
     * </p>
     *
     * @throws ConnectionException se ambas as flags estiverem presentes, ou se nenhuma
     * delas for encontrada.
     */
    private void validateFlags() {
        if(parser.has("server") && parser.has("client")) {
            throw new ConnectionException("Cliente e servidor devem ser executados separadamente: ", new RuntimeException());
        }

        if(!parser.has("server") && !parser.has("client")) {
            throw new ConnectionException("Servidor ou cliente devem ser declarados: ", new RuntimeException());
        }
    }

    /**
     * Valida a porta fornecida nos argumentos.
     *
     * @return O número da porta validado.
     * @throws ConnectionException se o valor da porta não for um número inteiro válido
     * ou estiver fora do intervalo de 1 a 65536.
     */
    private int validatePort() {
        int port = Integer.valueOf(parser.get("port"));
        if(1 > port || port > 65536) {
            throw new ConnectionException("A porta deve ser entre 1 e 65.536: ", new RuntimeException());
        }
        return port;
    }

    /**
     * Valida o endereço IP fornecido nos argumentos.
     *
     * @return O endereço IP validado.
     * @throws ConnectionException se o IP for nulo ou estiver vazio.
     */
    private String validateIp() {
        String ip = parser.get("ip");
        if(ip == null || ip.trim().isEmpty()) {
            throw new ConnectionException("O IP deve ser preenchido: ", new RuntimeException());
        }
        return ip;
    }
}

Flag parser

/**
 * Analisa argumentos da linha de comando formatados como flags.
 * <p>
 * Esta classe processa um array de strings, identificando argumentos
 * que começam com "--" e os armazena em um mapa. As flags podem ser
 * booleanas (ex: {@code --help}) ou ter um valor (ex: {@code --port=8080}).
 * </p>
 * <b>Formato Esperado:</b>
 * <ul>
 * <li>{@code --flag} (valor padrão "true")</li>
 * <li>{@code --flag=valor}</li>
 * </ul>
 */
public class FlagParser {
    private final Map<String, String> flags = new HashMap<>();

    /**
     * Construtor que processa os argumentos da linha de comando.
     *
     * @param args O array de strings de argumentos da linha de comando.
     */
    public FlagParser(String[] args) {
        for(String arg: args) {
            if(arg.startsWith("--")) {
                String[] part = arg.substring(2).split("=",2);

                if(part.length == 2) {
                    flags.put(part[0], part[1]);
                } else {
                    flags.put(part[0], "true");
                }
            }
        }
    }

    /**
     * Obtém o valor de uma flag específica.
     *
     * @param key A chave (nome) da flag.
     * @return O valor da flag como uma string, ou {@code null} se a flag não existir.
     */
    public String get(String key) {
        return flags.get(key);
    }

    /**
     * Verifica se uma flag específica está presente nos argumentos.
     *
     * @param key A chave (nome) da flag a ser verificada.
     * @return {@code true} se a flag estiver presente, caso contrário {@code false}.
     */
    public boolean has(String key) {
        return flags.containsKey(key);
    }

}

Connection exception

/**
 * Exceção de tempo de execução personalizada para erros de conexão.
 * <p>
 * Esta exceção é usada para encapsular e propagar erros que ocorrem durante
 * as operações de rede, como falha ao iniciar o servidor, aceitar conexões
 * ou conectar a um host remoto.
 * </p>
 */
public class ConnectionException extends RuntimeException {
    /**
     * Construtor da exceção com uma mensagem detalhada e a causa original.
     *
     * @param msg   A mensagem detalhada.
     * @param cause A exceção original que causou o erro.
     */
    public ConnectionException(String msg, Throwable cause) {
        super(msg, cause);
    }
}

Main

/**
 * Ponto de entrada da aplicação TCP Echo.
 * <p>
 * A classe {@code Main} é responsável por inicializar a aplicação,
 * determinando se ela deve ser executada como um cliente ou um servidor
 * com base nos argumentos da linha de comando.
 * </p>
 */
public class Main {

    /**
     * @param args Argumentos da linha de comando fornecidos no início da aplicação.
     */
    public static void main(String[] args) {

        var parser = new FlagParser(args);
        var server = new TcpEchoServerImpl();
        var client = new TcpEchoClientImpl();

        var starter = new ApplicationStarter(parser, client, server);
        starter.execute();
    }
}
#Acadêmico #Java #Redes