Ejemplo Sencillo de TCP/IP con netty

De ChuWiki
Saltar a: navegación, buscar

Netty es una librería Java que nos facilita todo el uso de sockets. Está pensada para ser eficiente y consumir pocos recursos, además de tener ya implementados bastantes protocolos estándar, ahorrándonos el trabajo de hacerlo a nosotros o buscar otras librerías.

Veamos aquí un ejemplo básico de cómo hacer un socket TCP/IP. Haremos un servidor que admitirá varios clientes, cada cliente enviará un texto periodicamente y el servidor se lo reenviará al resto de clientes. Lo haremos con lo básico de Netty, sin utilizar ninguno de los encoder o decoder que netty nos proporiciona.

Aquí tienes el código de ejemplo de socket tcp/ip con netty

El Servidor

En netty el código para abrir un socket servidor TCP/IP es más o menos siempre el mismo, copy-paste y lo explicamos un poco

int port = 8080; // El puerto que queramos

EventLoopGroup bossGroup = new NioEventLoopGroup(); // (1)
EventLoopGroup workerGroup = new NioEventLoopGroup(); // (2)

try {
   ServerBootstrap b = new ServerBootstrap(); // (3)
   b.group(bossGroup, workerGroup)  // (4)
      .channel(NioServerSocketChannel.class) // (5)
      .childHandler(new ChannelInitializer<SocketChannel>() { // (6)
          @Override
          public void initChannel(SocketChannel ch) throws Exception { // (7)
             ch.pipeline().addLast(serverHandler); // (8)
          }
      })
      .option(ChannelOption.SO_BACKLOG, 128)          // (9)
      .childOption(ChannelOption.SO_KEEPALIVE, true); // (10)
       
   ChannelFuture f = b.bind(port).sync(); // (11)

   f.channel().closeFuture().sync(); // (12)

Veamos todo esto

  • (1) Netty está orientado a eventos. Se producen eventos y se propagan cuando se abre una conexión, cuando se acepta un cliente, cuando se cierra una conexión, cuando llega un mensaje, etc, etc, etc. Todos estos eventos se tratan en un bucle de eventos en una serie de hilos que maneja netty. Netty tiene varias clases que implementan estos bucles de eventos con hilos, entre ellas NioEventLoopGroup. Así que lo primero que hacemos es crear una de estas para los eventos del socket servidor. Cada vez que se acepte una conexión de un cliente o se cierre, será este bucle de eventos en el encargado de detectarlo y avisar a todos los que tengan interés en ello.
  • (2) Idem a (1), pero es el bucle de eventos para los eventos de clientes ya conectados, cuando se envían o reciben mensajes de ellos, también cuando se desconecta un cliente concreto, etc. Resumiendo, (1) es para los eventos del socket servidor y (2) para los eventos en cada uno de los socket con los clientes.
  • (3) ServerBootstrap es una clase que nos abre un socket servidor de forma fácil. Podríamos hacerlo usando clases de más bajo nivel de netty (ServerSocketChannel) y así tendriamos más control y libertad para crearlo como queramos, pero en general no es necesario. Basta con instanciar esta clase.
  • (4) Le pasamos los dos bucles de eventos que creamos antes, el del servidor para que acepte conexiones y el de los eventos de cada uno de los clientes ya conectados.
  • (5) Estamos haciendo un Socket Servidor que acepte conexiones de clientes. Netty tiene varias clases que permiten hacer esto, aquí decimos la que queremos usar, NioServerSocketChannel. Esta usa la función select de java.nio. Hay otras implementaciones que usan entradas/salidas bloqueantes, o que usan epoll de linux y que sólo funcionan en linux. Para un java moderno, NioServerSocketChannel es una buena elección.
  • (6) Llamamos al método childHandler pasándole un ChannelInitializer. Cada vez que un cliente se conecte, se llamará a (7) ChannelInitializer.initChannel() pasándole el SocketChannel con el cliente que se acaba de conectar, es decir, la conexión con dicho cliente. Aquí es donde tenemos que hacer cosas de nuestra cosecha para tratar los mensajes que nos envíe el cliente o para enviarle mensajes al cliente.
  • (8) Cada vez que sucede algo con el cliente (llega un mensaje, le enviamos un mensaje, se conecta, se desconecta, etc), netty provoca eventos a los que nos avisa si nos suscribimos. en la línea (8) es donde lo hacemos. En la llamada a ch.pipeline().addLast() pasamos una clase nuestra, serverHandler, que implementa la interfaz de netty correspondiente para recibir todos los eventos que nos interesen en la conexión con el cliente. Veremos esto con más detalle más adelante.
  • (9) Con el método option() podemos pasar todas las opciones que queramos típicas de un socket servidor TCP/IP. El método option() afecta al socket servidor.
  • (10) con el método childOption() podemos pasar todas las opciones que queramos típicas de un socket servidor TCP/IP. El método childOption() afecta a los socket que se abran con el cliente, no al socket servidor.
  • (11) Hacemos el típico bind() del socket servidor, indicando el puerto que queremos. La llamada a bind() vuelve inmediatamente, con la llamada a sync() el código se queda aquí bloqueado hasta que el servidor está en marcha.
  • (12) f.channel() nos devuelve el canal (conexión) que hay en el socket servidor. closeFuture() nos devuelve una "cosa" (un future) que es algo que sucederá pero todavía no ha sucedido y ese algo, en este caso, es que se cierre el socket servidor. Si llamamos al método sync() de esa cosa futura, el método sync() se quedará bloqueado hasta que esa cosa futura (el cierre del socket) sea realidad. Como nadie cierra el socket (en este ejemplo), este sync() se quedará aquí para siempre. Si en algún sitio llamaramos a f.channel().close(), entonces se cerraría el socket y el sync() acabaría saliendo.

El cliente

Para abrir un socket cliente en otro PC que se conecte a nuestro servidor, el código es como este pero algo más sencillo.

int port = 8080;

EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
   Bootstrap b = new Bootstrap(); // (1)
   b.group(workerGroup)
      .channel(NioSocketChannel.class) // (2)
      .handler(new ChannelInitializer<SocketChannel>() { // (3)
          @Override
          public void initChannel(SocketChannel ch) throws Exception {
             ch.pipeline().addLast(clientHandler);  // (4)
          }
      })
     .option(ChannelOption.SO_KEEPALIVE, true); // (5)

     // connect.
     ChannelFuture f = b.connect("localhost", port).sync(); // (6)

     // Wait until the server socket is closed.
     // In this example, this does not happen, but you can do that to gracefully
     // shut down your server.
     f.channel().closeFuture().sync();

Es algo más sencillo, comentamos sólo las diferencias

  • (1) Como es un cliente, usamos la clase de utilidad Bootstrap en vez de ServerBootstrap. Como vimos antes, se podría hacer usando clases de más bajo nivel de Netty, pero esta nos ayuda y es más fácil.
  • (2) Como es cliente, usamos la clase NioSocketChannel en vez de NioServerSocketChannel. Al igual que en el caso del servidor, netty nos ofrece varias implementaciones usando diferentes técnicas, pero NioSocketChannel es una opción adecuada para una versión de java moderna con la api nio.
  • (3) Aquí usamos el método handler() para pasarle el ChannelInitializer. En el servidor usabamos childHandler() porque eran handler para los sockets con los clientes y no para el socker servidor en sí mismo.
  • (4) Como en el caso anterior, aquí es donde debemos pasar un handler encargado de enviar o tratar mensajes del servidor. clientHandler es una instancia de una clase de nuestro código que veremos más adelante.
  • (5) Con sucesivas llamadas a option() podemos pasar todas las opciones relativas a socket tcp/ip que queramos.
  • (6) Llamada a connect() para establecer la conexión. Debemos indicar IP del servidor (localhost en el ejemplo) y puerto de escucha del servidor (8080 en el ejemplo). La llamada a connect() vuelve inmediatamente, con sync() esperamos a que la conexión esté realmente establecida.

Eventos

Hasta ahora lo necesario para abrir los sockets y conectarlos. Como se comentó, el código es más o menos siempre el mismo, con pequeños ajustes como pueden ser las opciones que deseemos para los sockets, puerto o ip. Por supuesto, si tenemos una aplicación más elaborada o exigente, se pueden ajustar más cosas, como el número de hilos en los bucles de eventos, el tipo de clase a usar para el socket servidor o cliente, etc.

Pero vamos ahora a la "chicha", como enterarnos de los mensajes que llegan por el socket y como enviarlos. Dijimos antes que esto era algo que hacian las clases serverHandler y clientHandler. Vamos a ver aquí los coneptos básicos y hacer un ejemplo muy simple.

Cada vez que se produzca algún tipo de evento en el socket, netty avisará a los handler que se le pasen. Los handler deben implementar determinada interface. Hay dos posibles : ChannelInboundHandler y ChannelOutboundHandler.

ChannelInboundHandler

Esta interface deben implementarla aquellas clases que estén interesadas en eventos que se producen en nuestro socket provocados desde fuera. Hay muchos eventos pero los más interesantes puede ser :

  • Se conecta o desconecta un cliente o se abre o cierra nuestro socket.
  • Llega un mensaje (bytes) del socket.

ChannelOutboundHandler

Esta interfaz deben implementarla aquellas clases que estén interesadas en eventos que se producen en nuestro socket desde nuestro de nuestra aplicación. Con este handler podemos enterarnos de :

  • Cuando se hace el bind(), el connect() o el close()
  • Los write() o read() de nuestra aplicación (habitualmente nos intersa el write()).

Lectura de mensajes

Para leer mensajes, debemos hacer un handler que implemente la interfaz ChannelInboundHandler e implementar su método channelRead(). Netty llamará a este método cada vez que tenga bytes leídos por el socket y nos los pasará para que los tratemos. Como la interface ChannelInboundHandler tiene muchos métodos y somos vagos y no queremos implementarlos todos, heredaremos de ChannelInboundHandlerAdapter, y solo sobreescribiremos el método que hemos dicho

public class ServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {      // (1)

        ByteBuf buf =(ByteBuf)msg;    // (2)
        String text = buf.toString(Charset.defaultCharset());   // (3)
        System.out.print(text);
        
        buf.release(); // (4)
    }
}

Vamos con los detalles

  • (1) En el método recibimos un contexto de la conexión (ChannelHandlerContext) que tiene su utilidad, pero no para este caso simple. También recibmos los bytes leídos como Object msg.
  • (2) En este ejemplo sencillo, lo recibido en un ByteBuf de netty, con los bytes leídos en el socket, así que hacemos el cast. En ejemplos más complejos de netty, este Object puede ser cualquier cosa, incluso clases nuestras de datos que hemos obtenido en pasos previos a partir de los bytes leídos.
  • (3) Como en el ejemplo el cliente enviará texto, convertimos estos bytes a texto y los sacamos por pantalla.
  • (4) Netty, para ser eficiente, no crea ByteBuf alegremente, sino que tiene unos cuantos creados y los reutiliza. Cuando ya no nos haga falta, debemos decirle que lo puede liberar para usarlo en otro sitio. El responsable de liberar estos buffer es el que los recibe y no los retransmite a otro sitio. Ese es nuestro caso, convertimos al buffer a String, lo sacamos por pantalla y no llamamos a nadie pasandole al buffer, así que nos toca liberarlo. No nos tocaría si después de sacar por pantalla hicieramos una llamada a alguien pasándole este buffer, por ejemplo tomaTuElBuffferTodoParaTi(buf). En esta caso sería responsabilidad del que lo recibe el liberarlo si no pasa la pelota a otro.

Escribir mensajes

Caso simple, socket cliente

Vamos primero con el caso del cliente, que es más sencillo. El cliente sólo envía el mensaje al servidor. El servidor tendría una lógica más complicada, porque debe enviar el mensaje a uno o más clientes y tendría que tener alguna lógica para decidir a cuales.

Para el envío, nos vale nuevamente con implementar la interface ChannelInboundHandler (o heredar de ChannelInboundHandlerAdapter mejor) y tenemos que hacer algo como esto

public class ClientHandler extends ChannelInboundHandlerAdapter{
    private ChannelHandlerContext ctx;   // (1)

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {  // (2)
        this.ctx=ctx;
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {  // (3)
        ctx=null;
    }

    public void sendMessage(String msg){  // (4)
        if (null==ctx)
            return;
        ByteBuf buf = ctx.alloc().buffer();  // (5)
        buf.writeCharSequence(msg,Charset.defaultCharset());
        ctx.write(buf);
        ctx.flush();
    }
}

Vamos con los detalles:

  • (1) ChannelHandlerContext nos servirá para enviar mensajes, así que como nos lo tendremos que guardar, creamos un atributo de la clase para ello.
  • (2) Sobreescribimos el método channelActive(). Netty llamará a este método cuando nuestro socket cliente se haya conectado al servidor y el canal de comunicación esté listo para ser usado. Nos pasará un ChannelContextHandler como parámetro, nos lo guardamos en el atributo del punto anterior para poder usarlo más adelante.
  • (3) Sobreescribimos el método channelInactive(). Netty llamará a este método cuando nuestro socket cliente se desconecte del servidor y el canal de comunicación deje de estar disponible. Borramos el ChannelContextHandler que nos habíamos guardaddo en el punto anterior.
  • (4) Creamos un método público para el envío de mensajes. En él usamos el método write() de ChannelContextHandler y el método flush() para forzar el envío. El método write() admite cualquier objeto, pero en este caso simple, el objeto debe ser obligatoriamente un ByteBuf de netty, si no, netty no sabe cómo convertirlo a bytes para enviarlo por el socket.
  • (5) Para la creación del ByteBuf, netty aconseja que usemos clases de netty preparadas para ello. El mismo ChannelContextHandler tiene un método alloc() que nos devuelve una clase que permite crear ByteBuf y el método buffer() de esa clase crea un ByteBuf. Sólo nos queda rellenarlo con los bytes que queramos enviar por el socket, en nuestro ejemplo, el texto que nos pasen como parámetro. Usamos el método writeCharSequence() de ByteBuf. Un detalle a tener en cuenta es que hemos creado este ByteBuf y se lo hemos pasado a ChannelHandlerContext, por lo que ya no es responsabilidad nuestra hacer el bytebuf.release(), así que no lo hacemos.

Caso algo más complejo, lado del servidor

El lado del servidor es un poco más complejo porque tiene varios clientes conectados y tenemos que poner algún tipo de lógica para decidir qué enviamos a quién. También debemos guardarnos los canales de conexión con todos ellos. El código puede ser como este

@ChannelHandler.Sharable  // (1)
public class ServerHandler extends ChannelInboundHandlerAdapter {
    ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);  // (2)

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {

        ByteBuf buf =(ByteBuf)msg;
        String text = buf.toString(Charset.defaultCharset()); // (3)
        System.out.print(text);
        for (Channel channel : clients){  // (4)
            if (!ctx.channel().equals(channel)) {  // (5)
                ByteBuf bufToSend = ctx.alloc().buffer();
                bufToSend.writeCharSequence(text, Charset.defaultCharset());
                channel.writeAndFlush(bufToSend);
            }
        };
        buf.release(); // (6)
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        clients.add(ctx.channel()); // (7)
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        clients.remove(ctx.channel()); // (8)
    }
}

Vamos a los detalles

  • (1) Esta clase es el handler que añadimos en childHandler() del servidor, cada vez que se crea un canal con el cliente. Netty por defecto quiere que pasemos una instancia de esta clase nueva para cada canal cliente (un new ServerHandler() cada vez que se conecte un cliente). Esto nos nos permitiría fácilmente guardar todos los canales en un solo sitio (la variable ChannelGroup del siguiente punto) ya que cada instancia de ServerHandler tendría su propia copia de ChannelGroup y cada una de estas variables sólo guardaría la conexión con su cliente. Así que debemos indicar a Netty que una sola instancia de esta clase se puede usar para inicializar distintos canales, sin necesidad de hacer un new cada vez. Haremos un solo new en algún sitio y pasaremos esa instancia a todas las inicializaciones de los canales de cliente.
  • (2) Nos tenemos que guardar todos los canales de comunicación con los clientes, así que podemos hacer una List de canales de comunicación, pero netty tiene la clase ChannelGroup que sirve para lo mismo (una lista de Channel), pero nos da alguna utilidad más, como por ejemplo, un método para envío a todos los canales sin necesidad de que hagamos el bucle. Así que creamos una variable de ese tipo para guardar todos los canales de comunicación con todos los clientes.
  • (3) Sobreescribimos el método channelRead(). Netty llamará a este método cada vez que un cliente nos envíe una ristra de bytes. Lo que recibmos en este método será en ByteBuf, así que lo traducimos a texto y lo sacamos por pantalla.
  • (4) Un bucle para todos los channel que tenemos.
  • (5) Enviamos el texto a todos, excepto al que nos lo ha enviado, es lo que pretende el if. Como tenemos que enviar un ByteBuf, lo creamos al igual que antes, y lo enviamos con channel.write(). Debemos enviar un ByteBuf distinto, ya que cada channel hará un release() del ByteBuf que le pasemos.
  • (6) Como el ByteBuf original que recibimos como parámetro no se lo hemos pasado a nadie, es nuestra responsabilidad liberarlo.
  • (7) Sobreescribimos channelActive(). Cada vez que un cliente se conecte y su canal esté listo para usar, netty llamará a este método. El ChannelHandlerContext recibido siempre será el mismo, aunque el ChannelHandlerContext.channel() será en cada caso el del cliente que se acaba de conectar. Es por este motivo que debemos guardarnos los channel, y no el ChannelHandlerContext, para tener así la conexión con cada cliente. En el caso del lado del cliente, podiamos guardar ChannelHandlerContext, puesto en en el lado del ciente, sólo hay un channel : la comunicación con el servidor.
  • (8) Cuando un channel deja de estar activo, lo borramos de la lista.

Y esto es todo. En este ejemplo tanto en servidor como en cliente, sólo hemos creado un ChannelInboundHandler para cada uno y lo enviado y recibido debe ser ByteBuf. Sin embargo, hay posibilidad de mejorar todo esto. Cuando se envía un mensaje o se recibe, normalmente en un caso real suele haber que hacer varias cosas :

  • Cuando se envían varios mensajes, se envían varios grupos de bytes, uno por mensaje. Para que cuando al receptor le llegue una ristra seguida de bytes sea capaz de separar que bytes son de qué mensaje, es habitual enviar bytes de alguna manera que ayuden a esta tarea. Por ejemplo, un protocolo muy común es enviar mensajes de texto separador con un retorno de carro. El que lee, debe buscar retornos de carro en los bytes para poder separar los textos de cada mensaje (las líneas).
  • Una vez se tienen los bytes de un mensaje, hay que convertirlo a algo útil desde java. En el caso simple, en un String. En casos más complejos, puede ser una clase con atributos y más cosas.
  • Finalmente, una vez si tiene la clase con sus datos rellenos, habrá que hacer algo con ella y posiblemente responder por el socket.

Netty es consciente de todo esto y permite que añadamos una lista de handlers, en vez de uno solo, de forma que cada Handler hace su parte (separar los bytes que forman parte de un mensaje, construir la clase con sus datos a partir de los bytes recibidos, etc, etc. Vemos todos los detalles de esto en pipeline con netty