Inicio / Artículos / Una CLI de clima, cinco cadenas de herramientas: Rust, Go, Zig, Bun y Node.js

Una CLI de clima, cinco cadenas de herramientas: Rust, Go, Zig, Bun y Node.js

Cómo se presenta la misma herramienta CLI pequeña basada en HTTP-plus-JSON en Rust, Go, Zig, Bun y Node.js, y qué significan el tamaño del binario, el tiempo de compilación y las dificultades de configuración a la hora de elegir.

3165 palabras

Los micropruebas como un bucle de Fibonacci dicen muy poco sobre el costo real para desarrollar una herramienta de línea de comandos. Una prueba más adecuada es una pequeña utilidad que se comunica con la red, decodifica JSON, realiza algunos cálculos matemáticos y muestra un resultado ordenado, desarrollada de manera idéntica en varios lenguajes. Esta guía sigue exactamente ese experimento con Rust, Go, Zig, Bun y Node.js, para que pueda determinar qué conjunto de herramientas se adapta mejor a su próxima CLI en función del tamaño del binario, el tiempo de compilación, la carga adicional en tiempo de ejecución y, lo más importante, cuántas dificultades hay entre una carpeta vacía y un binario que sus usuarios puedan ejecutar.

Valora la pena mencionar de antemano los resultados principales. Los archivos binarios finales variaron entre 1,2 MB y 45 MB, y omitir por completo la compilación implica pedir a los usuarios que instalen un entorno de ejecución de aproximadamente 100 MB. Los tiempos de compilación sin optimizaciones oscilaron entre 0,8 segundos y 28 segundos. La recomendación más práctica al final es que no sea el lenguaje con la ejecución más rápida.

La herramienta de prueba y por qué representa una carga de trabajo justa

La utilidad se llama wx. Se le proporciona el nombre de una ciudad; esta herramienta convierte ese nombre en coordenadas mediante la API de geocodificación Open-Meteo, solicita las condiciones actuales desde la API de pronósticos de Open-Meteo y muestra el resultado junto con una temperatura percibida calculada. Una llamada típica se ve así:

$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%

La herramienta es deliberadamente pequeña, pero aborda las cuatro áreas en las que la ergonomía de la CLI difiere realmente entre los diferentes ecosistemas:

  • Un cliente HTTP con TLS. Se necesitan dos solicitudes HTTPS, una para el geocodificado y otra para la previsión.
  • Decodificación de JSON. Las respuestas se mapean a estructuras tipadas en lugar de tratarse como objetos sueltos.
  • Computación real. La temperatura aparente se calcula mediante una fórmula con ramificaciones, no por concatenación de cadenas.
  • Salida en la terminal. Colores ANSI y columnas alineadas, lo que realmente ven los usuarios.

Un lenguaje que no pueda manejar cómodamente estas cuatro áreas no es una buena opción para una CLI, sin importar cuán rápido ejecute un bucle numérico simple.

La única pieza de lógica compartida

Cada versión aplica la misma regla de tres ramas. Por debajo de 10 °C se utiliza la fórmula de enfriamiento por viento de Environment Canada. Por encima de 27 °C se emplea la regresión del índice de calor Rothfusz de NOAA, expresada en grados Celsius con la humedad como porcentaje. En el rango intermedio, la temperatura bruta se devuelve sin cambios. Se muestra aquí la versión en TypeScript, utilizada tanto en las compilaciones de Bun como de Node.js; los demás lenguajes implementan la misma lógica aritmética.

function feelsLike(tempC: number, windKmh: number, humidity: number): number {
  if (tempC < 10) {
    // Wind chill (Environment Canada formula)
    const v = windKmh ** 0.16;
    return 13.12 + 0.6215 * tempC - 11.37 * v + 0.3965 * tempC * v;
  }
  if (tempC > 27) {
    // Heat index (NOAA Rothfusz regression, in Celsius)
    const t = tempC;
    const r = humidity;
    return -8.784 + 1.611 * t + 2.338 * r - 0.146 * t * r
      - 0.0123 * t * t - 0.0164 * r * r + 0.00221 * t * t * r
      + 0.000725 * t * r * r - 0.00000358 * t * t * r * r;
  }
  return tempC; // between 10C and 27C, raw temperature
}

Dos detalles merecen atención. Primero, los límites del régimen son la parte que una implementación descuidada suele errorar, por lo que constituyen una buena verificación de corrección al comparar distintas implementaciones. Segundo, ambas fórmulas tienen rangos de validez que esta versión simplificada ignora: la ecuación del enfriamiento por viento está diseñada para velocidades del viento de aproximadamente 5 km/h en adelante, y la regresión de Rothfusz solo es adecuada para condiciones calurosas y bastante húmedas. Para una herramienta meteorológica sencilla esto es aceptable, pero una versión de producción debería limitar los valores o recurrir a la temperatura real fuera de esos rangos.

Impresiones al desarrollar cada implementación

El código completo en los cinco lenguajes suma unas 360 líneas, por lo que solo se muestran los fragmentos más reveladores. Lo importante es determinar dónde cada ecosistema ayudó y dónde obstaculizó el proceso.

Rust con reqwest, serde y un analizador de argumentos basado en derive

La compilación de Rust utiliza un paquete popular basado en derive para el análisis de argumentos, reqwest para operaciones HTTP y serde para la deserialización. El fragmento a continuación muestra el patrón que hace que Rust sea adecuado para este tipo de tareas: las macros derive generan tanto el analizador de línea de comandos como los decodificadores de JSON a partir de definiciones estructurales simples, y los tipos de los campos se verifican en tiempo de compilación.

#[derive(Parser)]
#[command(name = "wx", about = "Weather lookup")]
struct Cli {
    city: String,
}

#[derive(Deserialize)]
struct WeatherResponse {
    current: CurrentWeather,
}

#[derive(Deserialize)]
struct CurrentWeather {
    temperature_2m: f64,
    wind_speed_10m: f64,
    relative_humidity_2m: u8,
    wind_direction_10m: f64,
}

Todo el programa consta de unas 95 líneas que abarcan tanto las llamadas a la API como la lógica relacionada con la temperatura. El entorno de ejecución asíncrono, tokio, es algo que se debe activar explícitamente, mientras que Node.js oculta su bucle de eventos. Esa explicitidad es útil para el control, pero contribuye al tamaño del binario.

Lo sorprendente fue el resultado por defecto: una simple ejecución de cargo build --release generó un binario de 8,4 MB. Al activar la optimización en tiempo de enlace y la eliminación de símbolos en Cargo.toml, el tamaño se redujo a 3,8 MB. Estas configuraciones son bien conocidas entre quienes distribuyen herramientas en Rust, pero un principiante probablemente distribuiría el archivo más grande sin darse cuenta de que existía uno más pequeño a solo dos líneas de distancia.

Utiliza únicamente la biblioteca estándar

La compilación en Go no necesita ningún paquete de terceros. net/http, encoding/json y os.Args son suficientes para todo el proceso. El fragmento muestra la estructura de respuesta, donde las etiquetas relacionan las claves JSON con los nombres de campos típicos en Go, así como el inicio del archivo main con una verificación mínima de uso.

type WeatherResponse struct {
    Current struct {
        Temperature float64 `json:"temperature_2m"`
        WindSpeed   float64 `json:"wind_speed_10m"`
        Humidity    int     `json:"relative_humidity_2m"`
        WindDir     float64 `json:"wind_direction_10m"`
    } `json:"current"`
}

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: wx <city>")
        os.Exit(1)
    }
    city := os.Args[1]
    // geocode, fetch weather, compute, print
}

El programa final consta de 68 líneas. Un patrón predomina en él: la comprobación if err != nil { log.Fatal(err) } aparece cinco veces, una por cada solicitud de geocodificación, su cuerpo, su decodificación, la solicitud de pronóstico y el cuerpo del pronóstico. Esa repetición no constituye un problema real, pero es la línea más común en prácticamente cualquier CLI de Go.

Se logró crear un binario funcional en aproximadamente diez minutos. La rapidez no se debió a que Go sea un lenguaje mínimo (tiene opiniones firmes que no todos comparten), sino a que no había nada que decidir: sin comparación de bibliotecas, sin descarga de dependencias y sin configuración.

Zig 0.16 con std.http.Client y std.json

Zig, probado en la versión 0.16, fue la compilación más educativa, pero también la más lenta de completar. El fragmento muestra la estructura de respuesta y el inicio del main, donde se crea un asignador de depuración que luego se pasa por las funciones. Esa es la característica definitoria de Zig: cualquier función que pueda asignar memoria en el heap recibe un argumento de asignador, por lo que la propiedad de la memoria siempre está visible en la firma de la función.

const WeatherResponse = struct {
    current: struct {
        temperature_2m: f64,
        wind_speed_10m: f64,
        relative_humidity_2m: u8,
        wind_direction_10m: f64,
    },
};

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    // Every function that might allocate takes `allocator` as a parameter.
    // This is Zig's deal: you control memory, always.
}

El programa llegó a tener 108 líneas, lo que lo convirtió en el más largo de los cinco. La compilación en sí solo tomó 0,8 segundos, el resultado más rápido en la comparación, pero el proceso de construcción requirió más de una hora de trabajo. La causa fue TLS. std.http.Client incluye su propia implementación de TLS, pero aún así necesita certificados raíz confiables; en la máquina de pruebas no pudo encontrar el paquete de CA del sistema. El único síntoma fue error.TlsInitializationFailed sin ningún contexto adicional. La solución, cargar los certificados de forma explícita mediante std.crypto.Certificate.Bundle y pasar ese paquete al cliente, solo surgió en una discusión en un issue de GitHub. Sus resultados pueden variar según la plataforma, pero la lección permanece: un compilador rápido no puede compensar el tiempo perdido debido a errores en tiempo de ejecución poco claros.

La transmisión explícita del asignador es excelente para software donde el comportamiento de la memoria es importante. Para una utilidad que asigna unas pocas cadenas y un buffer JSON, en su mayoría solo añade complejidad innecesaria. Zig sí cuenta con un gestor de paquetes integrado basado en build.zig.zon, disponible desde la versión 0.11, pero el ecosistema sigue siendo escaso; no se encontró ninguna biblioteca mantenida para colores de terminal, por lo que se copió en su lugar un helper ANSI de aproximadamente 40 líneas de un gist.

Bun con un único archivo TypeScript

La versión de Bun es la más corta y fácil de leer. Lee la ciudad desde Bun.argv, sale con un mensaje de uso si está faltante, luego utiliza el built-in fetch dos veces y decodifica cada respuesta con .json(). El uso de await en el nivel superior significa que no existe ninguna función envolvente.

const city = Bun.argv[2];
if (!city) {
  console.error("usage: wx <city>");
  process.exit(1);
}

// Geocode city name to coordinates
const geoRes = await fetch(
  `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
const geo = await geoRes.json();
const { latitude, longitude } = geo.results[0];

// Fetch weather
const wxRes = await fetch(
  `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,wind_speed_10m,relative_humidity_2m,wind_direction_10m`
);
const data = await wxRes.json();

El archivo completo consta de 47 líneas, incluyendo ambas solicitudes, y tardó aproximadamente ocho minutos en ejecutarse, la mayor parte del tiempo dedicado a calcular la fórmula de temperatura. Sin embargo, hay algo que este fragmento no hace: nunca verifica res.ok, y geo.results[0] está indefinido cuando el geocodificador no encuentra coincidencias, por lo que si se escribe mal el nombre de una ciudad, se produce un error de desestructuración en lugar de un mensaje amigable. La brevedad es real, pero una CLI de producción necesita esas pocas líneas adicionales.

El problema con Bun radica en su distribución. bun build --compile genera un ejecutable independiente de unos 45 MB para esta herramienta tan pequeña, ya que el resultado incluye tanto el motor JavaScriptCore como el entorno de ejecución de Bun. Eso equivale a aproximadamente siete veces el tamaño del binario en Go y casi cuarenta veces el de Zig. Si los usuarios ya tienen instalado Bun, ejecutar directamente el archivo .ts evita por completo este problema.

Node.js, que no necesita una lista separada

La implementación de Node.js es esencialmente el código de Bun, ya que Node.js incluye una función fetch nativa desde la versión 18. Las diferencias significativas se encuentran en el entorno de ejecución:

  • Costo de inicio. La mayor parte de la diferencia en el tiempo total de ejecución proviene del propio inicio del proceso: 82 ms para Node.js frente a 24 ms para Bun, mientras que los viajes de ida y vuelta simulados por la red añaden solo unos pocos milisegundos. En el caso de una única orden interactiva nadie lo nota; pero dentro de un bucle en la shell que ejecuta la herramienta cientos de veces, esa diferencia se acumula.
  • Distribución. Los usuarios necesitan el entorno de ejecución Node.js, con aproximadamente 100 MB instalados, o bien se genera un archivo independiente a través de Aplicaciones ejecutables únicas. SEA es de primera parte, pero su estado de estabilidad ha ido evolucionando, por lo que es necesario consultar la documentación actual correspondiente a su versión de Node.js. Dado que incorpora todo el entorno de ejecución, el resultado tiene un tamaño similar al de un binario Bun compilado.
  • TypeScript. Ahora Node.js puede eliminar por sí mismo la sintaxis de TypeScript eliminable, de modo que un archivo simple .ts se ejecuta sin necesidad de tsx ni ts-node; en el momento de redactar este texto, esta función se consideraba estable en la línea LTS 24. Solo se admite la sintaxis eliminable: las anotaciones desaparecen, pero las estructuras que generan JavaScript, como los enums, los nombres de espacio y las propiedades de parámetros, siguen requiriendo un transpilador. Bun va aún más allá sin necesidad de configuración, ya que maneja JSX, decoradores y alias de rutas. Para conocer en mayor detalle qué abarca la eliminación nativa, consulte qué hace y qué no hace realmente el soporte nativo de TypeScript en Node.js.
  • Si lo consideramos únicamente desde la perspectiva de una CLI completamente nueva, Node.js ofrece el mismo código que Bun, pero con un tiempo de inicio más lento y una distribución más compleja. Sin embargo, se trata de un juicio limitado. Si su equipo ya está estandarizado en Node.js, depende de sus garantías de compatibilidad con npm o mantiene una CLI existente basada en él, esos factores pueden superar fácilmente la diferencia de 60 ms en el tiempo de inicio.

    Configuración de la medición y los números reportados

    Los tiempos se registraron en un M3 MacBook Pro con 18 GB de RAM que ejecuta macOS 26, utilizando hyperfine con la orden hyperfine --warmup 3 --min-runs 50 './wx reykjavik'. Para eliminar ruidos de red, ambas llamadas a la API apuntaron a un servidor HTTP local ficticio que devolvía JSON fijo. La cifra correspondiente al tiempo total de ejecución es el tiempo real desde el inicio hasta la finalización del proceso, incluyendo el tiempo de arranque. Los tiempos de desarrollo son estimaciones aproximadas y no valores medidos, por lo que se deben comparar las relaciones entre ellos y no los minutos.

    Los datos clave del proceso de ejecución:

    • Rust: aproximadamente 95 líneas, 3.8 MB después de los ajustes (8.4 MB por defecto), 28 segundos para una compilación sin errores, 4.8 ms de tiempo de ejecución.
    • Go: 68 líneas, 6.2 MB, menos de un segundo para compilar, 5.2 ms de tiempo de ejecución, con un tiempo total de ejecución de unos 10 minutos.
  • Zig: 108 líneas, 1,2 MB, 0,8 segundos para compilar, más de una hora de esfuerzo total.
  • Bun: 47 líneas, aproximadamente 45 MB al compilar, 24 ms de ejecución completa, tiempo de trabajo de unos 8 minutos.
  • Node.js: mismo código que Bun, 82 ms de ejecución completa, aproximadamente 100 MB en tiempo de ejecución o un binario SEA del mismo tamaño que el de Bun.
  • Las cifras de tamaño dependen de las opciones de compilación. Rust requirió lto = true y strip = true bajo [profile.release]. Go se compiló con go build -ldflags='-s -w' para eliminar la información de depuración. Los 1,2 MB de Zig corresponden a una compilación ReleaseSmall; mantiene un tamaño reducido incluso con TLS, ya que el cliente HTTP y el código criptográfico se encuentran en la biblioteca estándar y están vinculados estáticamente con código no utilizado eliminado, en lugar de utilizar algo como OpenSSL. Una compilación ReleaseSafe, que mantiene las comprobaciones de seguridad en tiempo de ejecución, alcanza unos 2,1 MB.

    Lo que ocultan las cifras de rendimiento

    Zig gana en teoría pero pierde en la práctica

    Según las métricas, Zig generó los binarios más pequeños y uno de los más rápidos. Sin embargo, la hora dedicada a 108 líneas de código cuenta otra historia:

    • El problema con el paquete de certificados consumió más de 40 minutos debido a un error en una sola línea.
  • La versión ReleaseSmall que genera 1,2 MB no contiene rastros de pila; ReleaseSafe mantiene los rastros de colapso, pero es la variante de 2,1 MB.
  • Fuera necesario integrar gestores de asignación en cada operación con cadenas, y dos llamadas olvidadas a defer causaron fugas de memoria que solo se detectaron cuando el gestor de depuración las reportó al finalizar.
  • Fuera necesario copiar un complemento para colores de terminal porque el ecosistema carecía de uno.
  • En una herramienta de larga duración para la que se desea controlar cada asignación y cada byte de salida, esa claridad explícita rinde frutos con el tiempo. Para algo que se necesita listo para usar antes del almuerzo, actualmente no es la opción adecuada. El diseño del lenguaje es elegante; el ecosistema que lo rodea simplemente es más joven.

    Go nunca es el mejor en una sola cosa y, aun así, gana en general

    Go se compila en menos de un segundo, genera un binario autocontenido razonable, solo necesita la biblioteca estándar y ya funcionaba en diez minutos. Es más grande que Rust optimizado (6,2 MB frente a 3,8 MB) y ligeramente más lento (5,2 ms frente a 4,8 ms), pero una persona no puede percibir esa diferencia en una herramienta que termina su ejecución en pocos milisegundos.

    La compilación cruzada se logra con una sola variable de entorno, por ejemplo GOOS=linux go build. Rust se acerca con cargo build --target x86_64-unknown-linux-gnu o la herramienta auxiliar cargo-zigbuild, y Zig tiene sin duda la mejor capacidad de compilación cruzada ya que incluye su propio enlazador y libc. La diferencia es que Go no requiere herramientas adicionales ni configuración alguna. Ese es el patrón recurrente: Go rara vez supera a otros en cualquier métrica individual, pero presenta la menor fricción total.

    Bun es excelente para uso local, pero complicado de distribuir

    Bun entregó el código más limpio en el menor tiempo posible. Para un script personal que se encuentra en ~/bin como archivo .ts, es difícil superarlo. En cuanto a la distribución, 45 MB para una búsqueda de clima constituye un verdadero inconveniente; además, como casi todo ese tamaño lo ocupa el motor integrado, no existe forma práctica de reducirlo sin una versión de ejecución más ligera por parte del equipo de Bun, la cual no existía en el momento de las pruebas.

    El atractivo de Rust para herramientas pequeñas se ha debilitado

    Hace unos años, los argumentos a favor de las CLI en Rust se basaban en la velocidad, la seguridad y los binarios pequeños. Para herramientas orientadas a operaciones de entrada/salida, ese argumento ya no es tan claro:

    • Go iguala la velocidad práctica de Rust.
    • Zig genera binarios más pequeños sin necesidad de ajustes.
    • Una compilación limpia que dura 28 segundos para un programa de 95 líneas representa una carga excesiva para utilidades rápidas.

    Rust sigue destacando cuando una herramienta atrae a miles de usuarios y cuenta con años de mantenimiento, ya que su sistema de tipos sigue detectando casos límite a lo largo del tiempo. ripgrep, fd, bat, delta e hyperfine son todas CLI en Rust, y todas son mantenidas seriamente durante largos períodos en lugar de haber sido escritas en un fin de semana. Para proyectos rápidos, los costos adicionales rara vez compensan; pero para una herramienta ampliamente distribuida, puede ser la opción con menor probabilidad de acumular errores sutiles.

    Elegir una cadena de herramientas para tu próxima CLI

    La decisión depende menos de la velocidad bruta que de quién utilizará la herramienta y cómo llegará a ellos:

    • Elige Go por defecto cuando el objetivo es el menor costo total desde un directorio vacío hasta un binario distribuido: código funcional en minutos, compilaciones en subsegundos, un único archivo de 6.2 MB sin dependencias y una compilación cruzada sencilla.
  • Elija Rust para herramientas con una gran audiencia donde son importantes los márgenes de ganancia, como herramientas de compilación, verificadores de código y ejecutores de pruebas, y acepte los tiempos de compilación.
  • Use Bun para herramientas personales o internas de equipo donde todos ya cuentan con el entorno de ejecución y los archivos nunca necesitan compilarse.
  • Posponga el uso de Zig para CLIs informales hasta que su ecosistema y mensajes de error estén más desarrollados; el binario de 1,2 MB es impresionante, pero el esfuerzo necesario para lograrlo aún no es proporcional para utilidades simples.
  • Mantén Node.js donde ya es tu plataforma. Para una CLI independiente completamente nueva, ofrece pocas ventajas sobre Bun, aunque las limitaciones del ecosistema y organizativas podrían seguir haciendo que sea la opción más sensata. Para una comparación más amplia de entornos de ejecución, consulta Node.js, Deno y Bun comparados.
  • Cada implementación es lo suficientemente pequeña como para poder reconstruirla a partir de los fragmentos anteriores en una tarde, junto con un servidor simulado y un script muy sencillo. Tus cifras exactas variarán según el hardware, el sistema operativo y las versiones de la cadena de herramientas, pero la imagen relativa debería mantenerse estable: Zig es el más pequeño, Bun el más grande, y Go presenta la menor fricción.

    Puntos clave

    • Mida todo el recorrido hasta un binario listo para enviar, no solo el tiempo de ejecución; la configuración, el depurado y la distribución son factores determinantes en las herramientas pequeñas.
    • La configuración de compilación por defecto puede duplicar el tamaño del binario, así que conozca las banderas de lanzamiento de la cadena de herramientas que elija.
    • Los binarios basados en tiempo de ejecución de Bun o Node.js SEA incluyen todo el motor, lo cual es mucho más importante que su tiempo de inicio.
    • Valide las entradas y las respuestas HTTP incluso en scripts cortos; la implementación más breve suele ser la que carece de manejo de errores.
    • Considere los estados y tamaños específicos de las versiones como una instantánea y vuelva a verificarlos con las versiones actuales antes de tomar una decisión.

    Lecturas relacionadas

  • Node.js vs Go para una API JSON: mismo rendimiento, 2.6 veces menos memoria — Una prueba de carga lado a lado de servicios HTTP idénticos en Node.js y Go muestra dónde resulta beneficioso reescribir el código (en cuanto a memoria ocupada) y dónde no lo es (en rendimiento y latencia).