Frontend System Design: Aplicación de chat

Frontend System Design: Aplicación de chat

Introducción

Vamos a hacer juntos un ejercicio de diseño del sistema de chat frontend.

Esto es realmente bueno porque aborda muchos matices del desarrollo de frontend.

El objetivo es pasar por todo el proceso de diseño de esto y pensar en todas las compensaciones y consideraciones.

Estructura de componentes: Esbozar los componentes principales de la arquitectura frontend para una aplicación de chat en tiempo real. Identifique los componentes clave y sus relaciones.

Administración de estados: analice cómo manejaría la administración de estados dentro de la aplicación frontend. ¿Qué datos hay que almacenar y gestionar?

Actualizaciones en tiempo real: Explique cómo lograría actualizaciones en tiempo real en la interfaz de chat. ¿Qué tecnologías o técnicas utilizarías para la comunicación en tiempo real entre usuarios?

Autenticación de usuario: describa cómo manejaría la autenticación de usuario en el frontend para garantizar un acceso seguro al chat.

Consideraciones de UI/UX: Analice las consideraciones sobre la interfaz de usuario y la experiencia del usuario, como la representación de mensajes, el historial de chat y el diseño receptivo.

Al final, discutiremos cómo podríamos llevar esto a producción.

Debes ser minucioso, pero mantenlo en un nivel alto.

Esto significa centrarse en lo que importa. Esto también es lo que muestra la antigüedad en las entrevistas de diseño de sistemas frontend.

1. Aclaración de los requisitos

Algunas preguntas inmediatas con las que podría comenzar para aclarar los requisitos funcionales:

  • «¿Qué plataformas necesitan ser compatibles? (web/móvil/escritorio)» -> Concéntrese en la web por ahora.

  • «¿Es esto para chats 1:1, chats grupales o ambos?» -> Podemos ceñirnos a los chats 1:1.

  • «¿Necesitamos admitir archivos adjuntos o solo mensajes de texto?» -> Solo texto está bien.

Algunos más que muestran pensamientos a UX:

  • «¿Queremos mostrar indicadores de mecanografía?» -> Sí.

  • «¿Queremos mostrar el estado de los mensajes, por ejemplo, leídos, entregados, enviados?» -> Sí.

Los requisitos no funcionales son cosas como el rendimiento, la escalabilidad, la seguridad, etc.

Cuando se piensa en sistemas de chat, hay que tener en cuenta varias cosas importantes:

  • Seguridad: Los mensajes deben estar encriptados.

  • Rendimiento: La latencia debe ser baja, el chat de ida y vuelta debe ser agradable y rápido.

  • Fiabilidad: Si envías un mensaje y te desconectas, no debería perderse.

2. Arquitectura de alto nivel

Puntos sobre nuestra arquitectura de alto nivel:

  • Necesita REST y WebSocket para esto.

  • En el caso de los sockets, tendríamos que encargarnos de la reconexión.

  • Tienda offline para no perder mensajes.

  • Almacén en memoria para lo que se necesita actualmente.

  • Para la autenticación, usaremos JWT. Tendríamos un token de actualización y acceso. La actualización es de larga duración y el acceso es de corta duración.

  • Esto tiene que ser seguro. La comunicación entre dos personas debe estar encriptada. Usaremos Signal Protocol para manejar esto por nosotros. Para ser claros, usaríamos una biblioteca para manejar esto. También necesitamos cifrar los mensajes cuando los almacenamos en la tienda fuera de línea.

3. Modelo de datos

Pensemos en el modelo de datos.

Si has usado WhatsApp u otras aplicaciones de chat, piensa en tu experiencia allí.

  • Ver cuándo alguien está en línea o fuera de línea.

  • Ver cuando alguien está escribiendo.

  • Ver mensajes que aún no has leído.

  • Ver el estado de los mensajes. En WhatsApp, tienen marcas de verificación para los mensajes, por ejemplo, uno gris simple significa enviado, los grises dobles significa entregado, los azules dobles significan leído.

La lista de chat es una lista de conversaciones:

				
					interface User {
  id: string;
  name: string;
  image?: string;
  status: "online" | "offline"; // Whether user is online or offline
  lastSeen?: Date; // Last time user was seen online
}

interface Conversation {
  id: string;
  participants: User[]; // For 1:1 chats, there's only two participants.
  lastMessage?: Message; // Last message in the conversation
  lastMessageAt?: Date;
}

// State for each user's conversation
// unread messages is different per user
// this way you can show a number of unread messages in the chat list
interface UserConversationState {
  conversationId: string;
  userId: string;
  unreadCount: number;
  lastReadMessageId?: string;
  isTyping?: boolean; // Works for 1:1 chats
  lastTypingAt?: Date;
}

interface Message {
  id: string;
  clientId: string; // client side generated id to help with idempotency/deduplication
  conversationId: string;
  content: string;
  sentAt: Date;
  sender: User["id"];
  status: "sending" | "sent" | "delivered" | "read" | "failed";
  retryCount?: number; // Number of times the message has been retried
}

// For our offline storage
interface OfflineMessage extends Message {
  syncStatus: "pending" | "synced" | "failed";
  attempts: number;
  lastAttempt?: Date;
}

				
			

Algunos puntos interesantes:

  • El usuario tiene un estado: fuera de línea o en línea. Visto por última vez nos ayuda a comunicarnos cuándo fue la última vez que estuvieron en línea.

  • Tenemos un estado de conversación del usuario. Esto es diferente según el usuario. Están en la misma conversación, pero esto no significa, por ejemplo, que todos hayan leído el mismo mensaje.

    • isTyping nos dice si el usuario está escribiendo.

    • lastTypingAt se usa para borrar el estado de escritura después de un tiempo de espera.

    • unreadCount es el número de mensajes no leídos en la conversación.

  • El mensaje tiene un estado. Dependiendo del estado mostramos diferentes indicadores.

  • Mensaje fuera de línea es el mensaje en nuestra tienda fuera de línea. Al enviar, estaría pendiente, cuando se enviara, se sincronizaría.

  • clientId se utiliza para la idempotencia/deduplicación. No queremos añadir el mismo mensaje dos veces. Por lo tanto, verificaríamos tanto el ID del servidor como el del cliente para ver si un mensaje ya está en la tienda.

4. Diseño de API

				
					interface ChatAPI {
  // GET /conversations
  getConversations(): Promise<Conversation[]>;

  // GET /conversations/:id/messages?cursor=xyz&limit=50
  getMessages(
    conversationId: string,
    cursor?: string,
    limit?: number
  ): Promise<{
    messages: Message[];
    nextCursor?: string;
  }>;

  // POST /conversations/:id/messages
  sendMessage(conversationId: string, content: string): Promise<Message>;
}

				
			

Tenemos un cursor para paginar los mensajes. No sería eficiente recuperar todos los mensajes a la vez. Piensa en un mensaje con una larga historia.getMessages

También podríamos usar la paginación para . Depende de la escala de la aplicación, supongo. Pero hay que tener en cuenta algo. Pero una conversación no es demasiado pesada. Obtener todas las conversaciones y luego usar la virtualización debería ser genial para la mayoría de las personas.getConversations

La API de WebSocket es el puente para la comunicación en tiempo real. Por lo tanto, usaríamos REST para la solicitud inicial y para obtener mensajes más antiguos a medida que avanza en el chat.

Pero sockets es lo que necesitamos para saber lo que está sucediendo en tiempo real.

				
					// Events we LISTEN to
interface WebSocketEvents {
  "message.new": Message; // New message received
  "message.status": MessageStatus; // Status updates (read/delivered)
  "user.typing": { chatId: string }; // User is typing
  "user.online": { userId: string }; // User came online
}

// Events we SEND
interface WebSocketEmits {
  "message.received": { messageId: string }; // Acknowledge message receipt
  "message.read": { messageId: string }; // Mark message as read
  "user.typing": { chatId: string }; // User started typing
}

				
			

message.new: Nuevo mensaje recibido. Actualice el almacén en memoria y envíe el mensaje de que se recibió a través de .message.received

message.status: El estado de un mensaje ha cambiado. Aquí, una advertencia es que debemos realizar un seguimiento del orden del estado. Si un mensaje está en estado y obtenemos un estado, no debemos actualizar el estado. Porque es más temprano en la cadena. Así que esto sería un error cuando piensas en el pedido.readdelivereddelivered

user.typing: El usuario está escribiendo, podemos escuchar y enviar esto cuando estamos escribiendo nosotros mismos.

5. Optimizaciones y casos extremos

Mencioné brevemente la idempotencia/deduplicación. Este es un problema común en los sistemas de chat. Tenemos que asegurarnos de que no añadimos el mismo mensaje dos veces. Dado que mostramos el mensaje enviado de forma optimista, debemos generar un identificador del lado del cliente.

Optaríamos por indexedDB para el almacenamiento sin conexión. Lo bueno de indexedDB es que puede almacenar objetos grandes y también sincronizarse entre pestañas. Entonces, puede imaginar que el usuario abre varias pestañas y desea sincronizar los mensajes entre pestañas, esto es posible.

La red del usuario no es confiable. Si están desconectados, debemos decírselo.

Si necesitamos enviar mensajes que están sin conexión, debemos ponerlos en cola e intentar enviarlos de nuevo cuando vuelvan a estar en línea. Lo importante aquí es cómo los enviamos. El orden importa. Una solución aquí sería enviarlos en un lote (por conversación) y dejar que el servidor maneje el pedido en función de la marca de tiempo del lado del cliente.

Ahora, la marca de tiempo del lado del cliente se puede usar inicialmente para ordenar los mensajes por remitente, pero luego el servidor la actualiza a una marca de tiempo adecuada. Si tienes discordia por un tiempo, sabes que los mensajes cuando estás desconectado se envían y pueden estar desordenados (por ejemplo, el segundo mensaje aparece después del 3er mensaje). Es una compensación.

Una cosa que olvidé mencionar: Vamos a necesitar algún tipo de administrador de sincronización para lidiar con esto. Debería ocuparse de poner en cola esos lotes. Un lote por conversación. Para la cola, podemos usar algo tan simple como Array. El administrador de sincronización que puedes considerar como su propia clase. Esto no tiene por qué ser nada lujoso. Pero para ser justos, sería bueno si lo hubiera incluido en la arquitectura para mayor claridad.

  1. ¿Volvemos a estar en línea? Podemos comprobarlo navigator.onLine

  2. Obtener todos los mensajes con estado pendiente de IndexedDB (recordar desde el modelo de datos)OfflineMessage

  3. Agrupar por conversationId

  4. Enviar en lotes

  • Código de carga diferida que no es necesario.

  • Utilice la virtualización para los mensajes de chat. A medida que el usuario se desplaza hacia arriba, es posible que terminemos con una gran cantidad de mensajes en el DOM.

  • Captura previamente los siguientes mensajes cuando el usuario comienza a desplazarse. De esta manera, no parece que necesiten esperar a que se cargue el lote de mensajes.

6. Llevarlo a producción

Esto se construiría detrás de una marca de característica.

Podemos implementarlo gradualmente para los usuarios. Esto significa no permitir que todos lo usen a la vez, sino un subconjunto de usuarios. De esta manera, podemos obtener comentarios y asegurarnos de que funciona como se espera con las cosas importantes que salen mal.

En el caso de crear un producto completo, tal vez desee hacerlo de forma incremental por característica. No tendrías todo el producto detrás de una marca de características.

Tenemos que asegurarnos de que esto funcione como se espera. Necesitamos pruebas que se asemejen al mundo real desde la perspectiva del usuario tanto como sea posible.

Sería bueno tener pruebas E2E aquí, donde probamos con múltiples usuarios y navegadores. Puedes lograr esto con Playwright.

Necesitamos saber cuándo las cosas van mal. Debemos realizar un seguimiento de los errores que se producen en producción. Sentry es increíble para esto. También podemos ver todo el seguimiento de la pila y lo que está sucediendo con Sentry para comprender qué condujo al error.

Sería bueno supervisar el rendimiento. Aquí es importante usar percentiles y no solo promedios.

Me imagino que nos interesan algunas cosas:

				
					interface MessageMetrics {
  // Time from clicking send to server acknowledgment
  sendLatencyMs: number;

  // Time from send to delivery confirmation
  deliveryLatencyMs: number;

  // WebSocket health
  wsConnectionTime: number;
  wsReconnectionCount: number;

  // API latencies
  getMessagesLatencyMs: number;
  getConversationsLatencyMs: number;
}

				
			

Vamos a querer analíticas para entender el comportamiento de los usuarios.

Podemos usar herramientas como PostHog para esto.

Comportamientos de los que podríamos querer hacer un seguimiento:

				
					interface UserEvent {
  // When user sends a message and we got a response from server
  "message.sent": {
    conversationId: string;
    success: boolean;
  };

  // When user opens a conversation
  "conversation.opened": {
    conversationId: string;
    // How user found the conversation
    source: "notification" | "list" | "direct";
  };

  // When user scrolls through messages
  "messages.scrolled": {
    conversationId: string;
    direction: "up" | "down";
    // How far they scrolled back in history
    oldestMessageTimestamp: number;
  };
}

				
			
Facebook
X
LinkedIn
Reddit
Pinterest
Threads

Post relacionados

Post recientes

Search