Construyendo una aplicación de chat en Dart

Habiendo cubierto exhaustivamente los conceptos básicos de los paquetes "built", ahora estoy listo para subir el nivel mostrándolos en acción.

Escribí este código en un día y medio para usarlo como ejemplo en mi charla en el Dart summit (video). Fue diseñado para mostrar built_value y source_gen, pero también muestra algo sobre Dart: que puedes hacer mucho con solo el SDK y los paquetes principales de dart-lang.

Ejemplo de app de chat construida con el paquete built_value ejecutándose en un navegador.
El ejemplo de chat de built_value en acción.

Obtener el código

#

Puedes leer el código aquí pero será más divertido si lo descargas y lo ejecutas. Para eso necesitarás tener dart y git instalados. Luego:

git clone [https://github.com/google/built_value.dart.git](https://github.com/google/built_value.dart.git)
cd built_value.dart
git checkout tags/v0.4.3-article
cd chat_example
pub get

# Run these three in separate terminal windows.
pub serve
dart bin/main.dart
dart tool/watch.dart

Ahora puedes ir a localhost:26199 en Dartium, Chrome o cualquier navegador moderno. Verás algo como esto:

Bienvenido al ejemplo de chat de built_value. Para ayuda, escribe /help. Estás conectado como anon9531.

No es particularmente bonito — pero si te conectas en unas cuantas pestañas más, verás que es una aplicación de chat completamente funcional. Tiene login, mensajes de away/estado, y mensajes públicos y privados.

Si cambias el código verás que los cambios del cliente se reflechan inmediatamente al refrescar. Para cambios del servidor, mata y relanza “dart bin/main.dart”.

Qué hay en una app de chat

#

Nuestro código del servidor va a necesitar:

  • servir HTML y CSS;

  • trackear el estado del usuario;

  • enviar y recibir mensajes.

El cliente va a necesitar:

  • proporcionar una UI que muestre texto y acepte entrada;

  • enviar y recibir mensajes.

Vamos a ver esto pieza por pieza.

Servir HTML y CSS

#

Estoy usando package:shelf de dart-lang. Para un servicio en producción querría pre-construir los recursos a servir, pero por ahora me centraré en el desarrollo, así que HTML y CSS pueden ir directamente a pub serve. Aquí hay un Cascade que hace eso:

var cascade = new Cascade()
  // Try sending requests to pub serve.
  .add(proxyHandler(Uri.parse(‘[http://localhost:8080’](http://localhost:8080'))))
  // If that didn't work, must be a problem with pub serve.
  .add((_) {
    print(‘Request failed. Check pub serve output for errors.’);
    return new Response.notFound(‘’);
  });

Y un servidor:

await io.serve(cascade.handler, 'localhost', 26199);

Modificaremos esto un poco más adelante para añadir el manejo de mensajes.

UI del cliente

#

Un cliente de chat no necesita mucha UI, y el SDK está más que a la altura de la tarea. Primero, el campo de texto. Necesitamos querySelector, HtmlEscape y Window:

void addLocal(String text) {
  querySelector('#text').innerHtml +=
    '<div class="local">${_htmlEscape.convert(text)}</div>';
  window.scrollTo(0, document.body.scrollHeight);
}

Y segundo, el campo de entrada. Aquí hay una clase que convierte la entrada de texto en un Stream<String> para que cualquiera lo consuma:

class Input {
  final StreamController<String> _streamController =
      new StreamController<String>();

  Input() {
    final input = querySelector('#input') as InputElement;

    input.onKeyPress.listen((keyEvent) {
      if (keyEvent.keyCode == KeyCode.ENTER) {
        _streamController.add(input.value);
        input.value = '';
       }
    });
  }

  Stream<String> get keyboardInput => _streamController.stream;
}

Esto se sitúa sobre algo mínimo de HTML y CSS. Luego necesitamos un poco de código para asegurar que el elemento de entrada comience con el foco y lo mantenga:

var screen = querySelector('#screen');
var text = querySelector('#text');
var input = querySelector('#input');

input.focus();

for (var element in [screen, text]) {
  element.onClick.listen((e) {
    input.focus();
  });
}

Y eso es todo. No se necesita ningún tipo de framework. Obviamente, la UI es muy mínima — si quisiera hacer más cambiaría a Angular.

Comunicación cliente-servidor

#

Las aplicaciones de chat necesitan poder enviar mensajes del servidor al cliente, así que los POST simples no son suficientes. Opté por usar WebSockets, que proporcionan canales de comunicación bidireccional persistente.

Con shelf podemos simplemente añadir esto al inicio de nuestro Cascade:

var cascade = new Cascade()
 .add(webSocketHandler(socketReceiver))

Donde “socketReceiver” es una función que acepta los WebSocketChannel recién creadas. “webSocketHandler” rechazará las peticiones HTTP simples, así que esas continuarán por el cascade.

Conectar desde el cliente es igualmente simple:

var socket = new WebSocket('ws://${window.location.host}/ws');
socket.onMessage.listen((message) => <handle message>);
socket.send('Hello server!');

Por supuesto necesitamos un formato de comunicación; como escribí este código para mostrar built_value, por supuesto uso su serialización. JSON simple con conversión escrita a mano sería directo — pero todo el punto de built_value es que obtienes eso gratis.

Web Sockets y SSL

#

Una nota al margen: actualmente, el ejemplo usa conexiones no cifradas. Desafortunadamente, las conexiones de web socket no cifradas a veces pueden ser bloqueadas por proxies con mal comportamiento. La forma de solucionarlo es cambiar a cifrar todo tu sitio, es decir, hacerlo “https” en lugar de “http”. Esto es un fastidio, pero podría decirse que es una buena idea por otras razones.

Modelo de objetos

#

En el corazón del ejemplo de chat hay un modelo de objetos que se comparte entre el servidor y el cliente. Como usamos built_value podemos enviar estas clases a través de la red. Por ejemplo, esta clase se usa para enviar un chat:

abstract class Chat implements Command {
  String get text;
  BuiltSet<String> get targets;
}

El login del servidor, la lógica del cliente y el renderizado del cliente están todos construidos alrededor de estas clases del modelo de objetos.

Lógica del cliente

#

Cuando escribes algo, el código del cliente necesita emparejar y manejar los posibles comandos. Es cuestión de simple emparejamiento de strings seguido de construir y enviar clases del modelo de objetos según corresponda. Código completo.

Por ejemplo, si escribes algo que empieza con “/away”, se convierte en una actualización de estado:

_send(new Status((b) => b
  ..message = command.substring(‘/away ‘.length)
  ..type = StatusType.away));

Renderizado del cliente

#

Todos los mensajes que el servidor puede enviar implementan esta interfaz:

abstract class Response {
  String render();
}

Y así, al recibir un mensaje, el cliente simplemente llama “render” y muestra el resultado. Por ejemplo, la clase ShowChat, que puede contener mensajes públicos o privados:

class ShowChat implements Response {
  String get username;
  bool get private;
  String get text;

  @override
  String render() =>
    '$username${private ? "(private)" : ""}: $text';
}

Por cierto, este patrón de encapsular lógica de renderizado simple dentro de clases de datos también funciona bien para la internacionalización.

Lógica del servidor

#

En el servidor mantenemos el estado: el registro de chat, nombres de usuario y contraseñas, y estados de usuario. Cuando se recibe un mensaje, actualizamos el estado y enviamos mensajes según sea necesario. Código completo.

Por ejemplo, aquí “command” es un mensaje recibido de un cliente. Si es un mensaje de “chat”, comprobamos si está destinado a ser privado — si especifica destinatarios — y lo enviamos a todos o solo a los destinatarios, según corresponda.

if (command is Chat) {
  _chat(connection, command);
}

...

void _chat(ServerConnection connection, Chat chat) {
  if (chat.targets.isEmpty) {
    _sendToAll(new ShowChat((b) => b
      ..username = connection.username
      ..private = false
      ..text = chat.text));
  } else {
    _sendTo(
      chat.targets,
      new ShowChat((b) => b
        ..username = connection.username
        ..private = true
        ..text = chat.text));
  }
}

Eso es todo, amigos

#

Y ahí lo tenemos — un servidor y cliente de chat en 820 líneas de código. Para los que llevan la cuenta, otras 1120 líneas son generadas por built_value, y el testeo end to end tomó 155 líneas.

Espera — ¿testeo end to end?

Dije que tomó un día y medio escribirlo; a mitad de camino me molesté porque cada vez que añadía una funcionalidad tenía que testear manualmente para asegurar que no había roto nada más. Así que di un paso atrás, refactoricé para testabilidad, y añadí cobertura de tests para las funcionalidades principales.

La próxima semana explicaré exactamente cómo se testea el ejemplo de chat, y lo genial que es testear con Dart tanto en el servidor como en el cliente.

Edición: siguiente artículo.

Más de Dart